mirror of
https://github.com/HKUDS/CLI-Anything.git
synced 2026-09-01 15:36:07 +08:00
Merge remote-tracking branch 'upstream/main' into codex/unrealinsights-agent-harness
# Conflicts: # README.md
This commit is contained in:
@@ -22,7 +22,8 @@ Fixes #<!-- issue number -->
|
||||
<!-- If this PR adds a new software CLI inside the monorepo, ALL items below must be checked. -->
|
||||
|
||||
- [ ] `<SOFTWARE>.md` SOP document exists at `<software>/agent-harness/<SOFTWARE>.md`
|
||||
- [ ] `SKILL.md` exists inside the Python package (`cli_anything/<software>/SKILL.md`)
|
||||
- [ ] Canonical `SKILL.md` exists at `skills/cli-anything-<software>/SKILL.md`
|
||||
- [ ] Packaged compatibility `SKILL.md` exists at `cli_anything/<software>/skills/SKILL.md`
|
||||
- [ ] Unit tests at `cli_anything/<software>/tests/test_core.py` are present and pass without backend
|
||||
- [ ] E2E tests at `cli_anything/<software>/tests/test_full_e2e.py` are present
|
||||
- [ ] `README.md` includes the new software (with link to harness directory)
|
||||
|
||||
@@ -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())
|
||||
@@ -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"
|
||||
@@ -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
|
||||
|
||||
|
||||
def get_pypi_date(install_cmd):
|
||||
"""Get the last release date from PyPI for a simple pip package."""
|
||||
# Only works for plain "pip install <package>" commands (not git+ URLs)
|
||||
match = re.match(r'^pip install ([a-zA-Z0-9_-]+)$', install_cmd)
|
||||
if not match:
|
||||
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 _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()
|
||||
|
||||
@@ -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())
|
||||
@@ -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
|
||||
@@ -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/
|
||||
|
||||
+3
-3
@@ -15,7 +15,7 @@ Adding a new CLI harness is the most impactful contribution. You can either add
|
||||
Place your code under `<software>/agent-harness/` and ensure the following:
|
||||
|
||||
1. **`<SOFTWARE>.md`** — the SOP document exists at `<software>/agent-harness/<SOFTWARE>.md` describing the harness architecture.
|
||||
2. **`SKILL.md`** — the AI-discoverable skill definition exists inside the Python package at `cli_anything/<software>/SKILL.md`.
|
||||
2. **`SKILL.md`** — the canonical AI-discoverable skill definition exists at `skills/cli-anything-<software>/SKILL.md`, and the packaged compatibility copy exists at `cli_anything/<software>/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 `<name>/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"}
|
||||
|
||||
@@ -45,6 +45,12 @@ CLI-Anything: Bridging the Gap Between AI Agents and the World's Software</stron
|
||||
|
||||
> 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 <skill-name> -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-16** 📈 **Unreal Insights CLI** expanded — added background capture session control (`capture start/status/snapshot/stop`), engine-root-matched `UnrealInsights.exe` resolution/build flows, and refreshed docs/tests for the new orchestration workflow.
|
||||
|
||||
- **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.
|
||||
@@ -309,6 +315,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`.
|
||||
@@ -502,7 +510,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/<software>/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-<software>/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/<software>/skills/SKILL.md`, and the REPL banner prefers the repo-root canonical file when present, falling back to the packaged copy otherwise.
|
||||
|
||||
---
|
||||
|
||||
@@ -538,7 +546,7 @@ The agent will browse the catalog, install whichever CLI fits the task, and use
|
||||
|
||||
The catalog auto-updates whenever `registry.json` changes — new community CLIs show up automatically.
|
||||
|
||||
> **For Claude Code users:** Copy [`cli-hub-meta-skill/SKILL.md`](cli-hub-meta-skill/SKILL.md) into your project or skills directory for the same automatic CLI discovery.
|
||||
> **For Claude Code users:** Copy [`skills/cli-hub-meta-skill/SKILL.md`](skills/cli-hub-meta-skill/SKILL.md) into your project or skills directory for the same automatic CLI discovery.
|
||||
|
||||
---
|
||||
|
||||
@@ -671,7 +679,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/<software>/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-<software>/SKILL.md`. This makes the current monorepo directly consumable by `npx skills`, while a packaged compatibility copy at `cli_anything/<software>/skills/SKILL.md` preserves installed-harness behavior.
|
||||
|
||||
**What SKILL.md provides:**
|
||||
- **YAML frontmatter** with name and description for agent skill discovery
|
||||
@@ -679,7 +687,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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ Usage:
|
||||
from cli_anything.<software>.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-<software>/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-id>/SKILL.md when running
|
||||
# inside the CLI-Anything monorepo. Fall back to the packaged
|
||||
# cli_anything/<software>/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)
|
||||
|
||||
@@ -7,7 +7,7 @@ Usage:
|
||||
from cli_anything.<software>.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-<software>/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-id>/SKILL.md when running
|
||||
# inside the CLI-Anything monorepo. Fall back to the packaged
|
||||
# cli_anything/<software>/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
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ Usage:
|
||||
from cli_anything.<software>.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-<software>/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-id>/SKILL.md when running
|
||||
# inside the CLI-Anything monorepo. Fall back to the packaged
|
||||
# cli_anything/<software>/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)
|
||||
|
||||
@@ -7,7 +7,7 @@ Usage:
|
||||
from cli_anything.<software>.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-<software>/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-id>/SKILL.md when running
|
||||
# inside the CLI-Anything monorepo. Fall back to the packaged
|
||||
# cli_anything/<software>/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)
|
||||
|
||||
@@ -6,20 +6,21 @@ Copy this file into your CLI package at:
|
||||
Usage:
|
||||
from cli_anything.<software>.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-<software>/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-id>/SKILL.md when running
|
||||
# inside the CLI-Anything monorepo. Fall back to the packaged
|
||||
# cli_anything/<software>/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
|
||||
}
|
||||
|
||||
@@ -6,20 +6,21 @@ Copy this file into your CLI package at:
|
||||
Usage:
|
||||
from cli_anything.<software>.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-<software>/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-id>/SKILL.md when running
|
||||
# inside the CLI-Anything monorepo. Fall back to the packaged
|
||||
# cli_anything/<software>/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
|
||||
}
|
||||
|
||||
@@ -79,7 +79,7 @@ designed for humans, without needing a display or mouse.
|
||||
from cli_anything.<software>.utils.repl_skin import ReplSkin
|
||||
|
||||
skin = ReplSkin("<software>", 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-<software>/SKILL.md`
|
||||
when running inside this monorepo, and falls back to the packaged
|
||||
`cli_anything/<software>/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/<software>/skills/SKILL.md` so it is installed with `pip install`.
|
||||
**Output Location:** The canonical skill lives at
|
||||
`skills/cli-anything-<software>/SKILL.md`. A compatibility copy is also written to
|
||||
`cli_anything/<software>/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:
|
||||
|
||||
|
||||
@@ -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/<software>/skills/SKILL.md` (inside the Python package)
|
||||
- Output canonical skill to `skills/cli-anything-<software>/SKILL.md`
|
||||
- Refresh package-local compatibility copy at `cli_anything/<software>/skills/SKILL.md`
|
||||
|
||||
**Output:** SKILL.md file for AI agent discovery
|
||||
|
||||
@@ -237,6 +238,10 @@ Package and install:
|
||||
## Output Structure
|
||||
|
||||
```
|
||||
skills/
|
||||
└── cli-anything-<software>/
|
||||
└── SKILL.md # Canonical repo-root skill for npx skills discovery
|
||||
|
||||
<software>/
|
||||
└── agent-harness/
|
||||
├── <SOFTWARE>.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
|
||||
|
||||
@@ -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/<software>/skills/SKILL.md` inside the Python package
|
||||
- Outputs the canonical skill to `skills/cli-anything-<software>/SKILL.md` and refreshes the packaged compatibility copy at `cli_anything/<software>/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-<software>/
|
||||
└── SKILL.md
|
||||
```
|
||||
|
||||
## Example
|
||||
|
||||
```bash
|
||||
|
||||
@@ -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/<software>/skills/SKILL.md
|
||||
# Default output: skills/cli-anything-<software>/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-<software>/
|
||||
└── SKILL.md
|
||||
|
||||
<software>/
|
||||
└── 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.<software>.utils.repl_skin import ReplSkin
|
||||
|
||||
skin = ReplSkin("<software>", version="1.0.0")
|
||||
skin.print_banner() # Auto-detects and displays: ◇ Skill: /path/to/cli_anything/<software>/skills/SKILL.md
|
||||
skin.print_banner() # Displays repo-root skills/cli-anything-<software>/SKILL.md when available
|
||||
```
|
||||
|
||||
## Package Data
|
||||
|
||||
@@ -7,7 +7,7 @@ Usage:
|
||||
from cli_anything.<software>.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-<software>/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/<software>/utils/repl_skin.py (this file)
|
||||
# cli_anything/<software>/skills/SKILL.md (target)
|
||||
# Prefer repo-root canonical skills/<skill-id>/SKILL.md when running
|
||||
# inside the CLI-Anything monorepo. Fall back to the packaged
|
||||
# cli_anything/<software>/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)
|
||||
|
||||
@@ -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/<software>/skills/SKILL.md)
|
||||
output_path: Optional output path for SKILL.md
|
||||
(default: skills/cli-anything-<software>/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/<software>/skills/SKILL.md)",
|
||||
help="Output path for SKILL.md (default: skills/cli-anything-<software>/SKILL.md)",
|
||||
default=None
|
||||
)
|
||||
parser.add_argument(
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -7,7 +7,7 @@ Usage:
|
||||
from cli_anything.<software>.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-<software>/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/<software>/utils/repl_skin.py (this file)
|
||||
# cli_anything/<software>/skills/SKILL.md (target)
|
||||
# Prefer repo-root canonical skills/<skill-id>/SKILL.md when running
|
||||
# inside the CLI-Anything monorepo. Fall back to the packaged
|
||||
# cli_anything/<software>/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)
|
||||
|
||||
@@ -7,7 +7,7 @@ Usage:
|
||||
from cli_anything.<software>.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-<software>/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/<software>/utils/repl_skin.py (this file)
|
||||
# cli_anything/<software>/skills/SKILL.md (target)
|
||||
# Prefer repo-root canonical skills/<skill-id>/SKILL.md when running
|
||||
# inside the CLI-Anything monorepo. Fall back to the packaged
|
||||
# cli_anything/<software>/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)
|
||||
|
||||
@@ -7,7 +7,7 @@ Usage:
|
||||
from cli_anything.<software>.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-<software>/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/<software>/utils/repl_skin.py (this file)
|
||||
# cli_anything/<software>/skills/SKILL.md (target)
|
||||
# Prefer repo-root canonical skills/<skill-id>/SKILL.md when running
|
||||
# inside the CLI-Anything monorepo. Fall back to the packaged
|
||||
# cli_anything/<software>/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)
|
||||
|
||||
+166
-56
@@ -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 @@
|
||||
</span>
|
||||
</a>
|
||||
<div class="nav-links">
|
||||
<a class="nav-link" href="https://reeceyang.sgp1.cdn.digitaloceanspaces.com/SKILL.md" target="_blank">
|
||||
<a class="nav-link" href="https://github.com/HKUDS/CLI-Anything/blob/main/skills/cli-hub-meta-skill/SKILL.md" target="_blank">
|
||||
SKILL.md
|
||||
</a>
|
||||
<a class="nav-link" href="https://github.com/HKUDS/CLI-Anything" target="_blank">
|
||||
<svg viewBox="0 0 98 96" fill="currentColor"><path fill-rule="evenodd" clip-rule="evenodd" d="M48.854 0C21.839 0 0 22 0 49.217c0 21.756 13.993 40.172 33.405 46.69 2.427.49 3.316-1.059 3.316-2.362 0-1.141-.08-5.052-.08-9.127-13.59 2.934-16.42-5.867-16.42-5.867-2.184-5.704-5.42-7.17-5.42-7.17-4.448-3.015.324-3.015.324-3.015 4.934.326 7.523 5.052 7.523 5.052 4.367 7.496 11.404 5.378 14.235 4.074.404-3.178 1.699-5.378 3.074-6.6C29.304 70.25 17.9 66.013 17.9 47.02c0-5.52 1.94-10.046 5.127-13.58-.486-1.302-2.264-6.437.486-13.34 0 0 4.206-1.302 13.59 5.216 3.963-1.14 8.17-1.628 12.34-1.628 4.17 0 8.376.568 12.34 1.628 9.384-6.518 13.59-5.216 13.59-5.216 2.75 6.903.972 12.038.486 13.34 3.268 3.534 5.127 8.06 5.127 13.58 0 19.074-11.485 23.15-22.328 24.29 1.78 1.548 3.316 4.481 3.316 9.126 0 6.6-.08 11.897-.08 13.526 0 1.304.89 2.853 3.316 2.364 19.412-6.52 33.405-24.935 33.405-46.691C97.707 22 75.788 0 48.854 0z"/></svg>
|
||||
GitHub
|
||||
<span class="nav-link-stars" data-github-stars aria-label="GitHub star count">--</span>
|
||||
</a>
|
||||
<a class="nav-link" href="https://github.com/HKUDS/CLI-Anything/blob/main/CONTRIBUTING.md" target="_blank">Contribute</a>
|
||||
<button class="theme-toggle" onclick="toggleTheme()" aria-label="Toggle theme">
|
||||
@@ -1779,7 +1811,7 @@
|
||||
<h1><em>CLI</em> tools for <span>software, APIs, and autonomous agents.</span></h1>
|
||||
<p class="hero-lead"><strong>Any software. Any codebase. Any Web API.</strong> Install an orchestration hub that can discover, explain, install, update, and remove CLIs across first-party harnesses and public ecosystems.</p>
|
||||
<div class="hero-actions">
|
||||
<a class="btn-primary" href="https://reeceyang.sgp1.cdn.digitaloceanspaces.com/SKILL.md" target="_blank">Get Agent SKILL</a>
|
||||
<a class="btn-primary" href="https://github.com/HKUDS/CLI-Anything/blob/main/skills/cli-hub-meta-skill/SKILL.md" target="_blank">Get Agent SKILL</a>
|
||||
<a class="btn-secondary" href="https://github.com/HKUDS/CLI-Anything" target="_blank">
|
||||
<svg viewBox="0 0 98 96" fill="currentColor"><path fill-rule="evenodd" clip-rule="evenodd" d="M48.854 0C21.839 0 0 22 0 49.217c0 21.756 13.993 40.172 33.405 46.69 2.427.49 3.316-1.059 3.316-2.362 0-1.141-.08-5.052-.08-9.127-13.59 2.934-16.42-5.867-16.42-5.867-2.184-5.704-5.42-7.17-5.42-7.17-4.448-3.015.324-3.015.324-3.015 4.934.326 7.523 5.052 7.523 5.052 4.367 7.496 11.404 5.378 14.235 4.074.404-3.178 1.699-5.378 3.074-6.6C29.304 70.25 17.9 66.013 17.9 47.02c0-5.52 1.94-10.046 5.127-13.58-.486-1.302-2.264-6.437.486-13.34 0 0 4.206-1.302 13.59 5.216 3.963-1.14 8.17-1.628 12.34-1.628 4.17 0 8.376.568 12.34 1.628 9.384-6.518 13.59-5.216 13.59-5.216 2.75 6.903.972 12.038.486 13.34 3.268 3.534 5.127 8.06 5.127 13.58 0 19.074-11.485 23.15-22.328 24.29 1.78 1.548 3.316 4.481 3.316 9.126 0 6.6-.08 11.897-.08 13.526 0 1.304.89 2.853 3.316 2.364 19.412-6.52 33.405-24.935 33.405-46.691C97.707 22 75.788 0 48.854 0z"/></svg>
|
||||
Star on GitHub
|
||||
@@ -1810,6 +1842,11 @@
|
||||
<section class="rail-panel">
|
||||
<h3>Empower your agent</h3>
|
||||
<p>Install the meta-skill once, then let the agent choose the right CLI from the registry.</p>
|
||||
<div class="install-row">
|
||||
<span class="chip-label">npx skills</span>
|
||||
<code>npx skills add HKUDS/CLI-Anything --skill cli-hub-meta-skill -g -y</code>
|
||||
<button class="copy-btn" onclick="copyCmd(this, 'npx skills add HKUDS/CLI-Anything --skill cli-hub-meta-skill -g -y')">Copy</button>
|
||||
</div>
|
||||
<div class="install-row">
|
||||
<span class="chip-label">OpenClaw</span>
|
||||
<code>openclaw skills install cli-anything-hub</code>
|
||||
@@ -1948,13 +1985,17 @@
|
||||
<span class="control-kicker">Sort mode</span>
|
||||
</div>
|
||||
<button class="sort-trigger" id="sort-trigger-public" type="button" aria-haspopup="listbox" aria-expanded="false">
|
||||
<span class="sort-current" id="sort-current-public">Name</span>
|
||||
<span class="sort-current" id="sort-current-public">Last updated</span>
|
||||
<span class="sort-chevron">
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>
|
||||
</span>
|
||||
</button>
|
||||
<div class="sort-menu" id="sort-menu-public" role="listbox" hidden>
|
||||
<button class="sort-option" type="button" data-deck="public" data-value="name" role="option" aria-selected="true">
|
||||
<button class="sort-option" type="button" data-deck="public" data-value="updated" role="option" aria-selected="true">
|
||||
<span><strong>Last updated</strong><span>Put the freshest public CLIs first.</span></span>
|
||||
<span class="sort-option-mark">✓</span>
|
||||
</button>
|
||||
<button class="sort-option" type="button" data-deck="public" data-value="name" role="option" aria-selected="false">
|
||||
<span><strong>Name</strong><span>Alphabetical list for known public tools.</span></span>
|
||||
<span class="sort-option-mark">✓</span>
|
||||
</button>
|
||||
@@ -1992,7 +2033,7 @@
|
||||
</div>
|
||||
<div class="footer-stats">
|
||||
<div class="footer-label">Traffic Snapshot</div>
|
||||
<a class="footer-analytics-link" href="https://cloud.umami.is/share/mnilRgKH0oo7SJTs" target="_blank">
|
||||
<div class="footer-analytics-link" aria-label="Traffic snapshot">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></svg>
|
||||
<span class="stat-dot dot-total"></span>
|
||||
<span id="stat-total">—</span> visits
|
||||
@@ -2002,7 +2043,7 @@
|
||||
<span class="analytics-sep">·</span>
|
||||
<span class="stat-dot dot-agent"></span>
|
||||
<span id="stat-agent">—</span> AI agent
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
@@ -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) =>
|
||||
'<span class="card-command-comment"># ' + esc(step.label.toLowerCase()) + '</span><span class="card-command-line">' + esc(step.cmd) + '</span>'
|
||||
).join('');
|
||||
const copyText = steps.map((step) =>
|
||||
'# ' + step.label.toLowerCase() + '\n' + step.cmd
|
||||
).join('\n');
|
||||
return '<div class="card-command-block">' +
|
||||
'<div class="card-command-label"><span>Install Commands</span><span>' + esc(sourceLabel) + '</span></div>' +
|
||||
'<div class="card-command">' + display + '</div>' +
|
||||
'<div class="card-command-actions"><button class="card-copy-btn" onclick=\'copyCmd(this, ' + JSON.stringify(copyText) + ')'>Copy</button></div>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
function renderHarnessCard(c) {
|
||||
const requiresHtml = c.requires
|
||||
? '<div class="card-requires"><strong>Requires</strong> ' + esc(c.requires) + '</div>'
|
||||
@@ -2310,6 +2421,12 @@
|
||||
: '';
|
||||
const dateHtml = c.last_modified ? '<div class="card-date">Updated ' + esc(c.last_modified) + '</div>' : '';
|
||||
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 `
|
||||
<article class="card card--harness">
|
||||
@@ -2326,16 +2443,7 @@
|
||||
${dateHtml}
|
||||
${requiresHtml}
|
||||
</div>
|
||||
<div class="card-command-block">
|
||||
<div class="card-command-label">
|
||||
<span>Install via CLI-Hub</span>
|
||||
<span>PyPI</span>
|
||||
</div>
|
||||
<div class="card-command">pip install cli-anything-hub\ncli-hub install ${esc(c.name)}</div>
|
||||
<div class="card-command-actions">
|
||||
<button class="card-copy-btn" onclick="copyCmd(this, 'pip install cli-anything-hub && cli-hub install ${esc(c.name)}')">Copy</button>
|
||||
</div>
|
||||
</div>
|
||||
${installBlock}
|
||||
<div class="card-footer">
|
||||
<div class="card-links">${linksHtml}</div>
|
||||
${contributorHtml}
|
||||
@@ -2356,6 +2464,7 @@
|
||||
|
||||
function renderPublicCard(c) {
|
||||
const isBundled = (c.install_strategy || c.package_manager) === 'bundled';
|
||||
const dateHtml = c.last_modified ? '<div class="card-date">Updated ' + esc(c.last_modified) + '</div>' : '';
|
||||
const requiresHtml = c.requires
|
||||
? '<div class="card-requires"><strong>Requires</strong> ' + esc(c.requires) + '</div>'
|
||||
: '';
|
||||
@@ -2374,13 +2483,14 @@
|
||||
? '<div class="card-npx">Direct command: <code>' + esc(directCmd) + '</code></div>'
|
||||
: '';
|
||||
const installNotesHtml = c.install_notes ? '<div class="card-npx">' + esc(c.install_notes) + '</div>' : '';
|
||||
const installSteps = isBundled
|
||||
? []
|
||||
: [{ label: 'step 1 · install cli', cmd: 'cli-hub install ' + c.name, badge: 'CLI-Hub' }];
|
||||
const skillCmd = publicSkillCmd(c);
|
||||
if (skillCmd) installSteps.push({ label: 'step 2 · install skill', cmd: skillCmd, badge: 'npx skills' });
|
||||
const installBlock = isBundled
|
||||
? '<div class="card-install-note">Bundled with the upstream application. Install or update the parent app and enable its CLI integration.</div>'
|
||||
: '<div class="card-command-block">' +
|
||||
'<div class="card-command-label"><span>Install via CLI-Hub</span><span>Source aware</span></div>' +
|
||||
'<div class="card-command">pip install cli-anything-hub\ncli-hub install ' + esc(c.name) + '</div>' +
|
||||
'<div class="card-command-actions"><button class="card-copy-btn" onclick="copyCmd(this, \'pip install cli-anything-hub && cli-hub install ' + esc(c.name) + '\')">Copy</button></div>' +
|
||||
'</div>';
|
||||
: renderCommandStack(installSteps, 'Source aware');
|
||||
|
||||
return `
|
||||
<article class="card card--public">
|
||||
@@ -2394,6 +2504,7 @@
|
||||
<div class="card-title">${titleHtml}</div>
|
||||
<div class="card-desc">${esc(c.description)}</div>
|
||||
<div class="card-submeta">
|
||||
${dateHtml}
|
||||
${requiresHtml}
|
||||
${directCmdHtml}
|
||||
${installNotesHtml}
|
||||
@@ -2511,48 +2622,47 @@
|
||||
|
||||
const UMAMI_API = 'https://api.umami.is/v1';
|
||||
const UMAMI_KEY = 'api_idAebMhzn6z0hsUQT7BSxRuCK2GUZvRY';
|
||||
const WEBSITE_IDS = [
|
||||
'07082d05-efd3-4f85-a7a1-b426b0e8bfaa',
|
||||
'a076c661-bed1-405c-a522-813794e688b4',
|
||||
];
|
||||
const HKUDS_WEBSITE_ID = '07082d05-efd3-4f85-a7a1-b426b0e8bfaa';
|
||||
const CC_WEBSITE_ID = 'a076c661-bed1-405c-a522-813794e688b4';
|
||||
const headers = { Accept: 'application/json', 'x-umami-api-key': UMAMI_KEY };
|
||||
|
||||
async function loadVisitorStats() {
|
||||
try {
|
||||
const now = Date.now();
|
||||
let totalVisits = 0;
|
||||
let humanCount = 0;
|
||||
const fetches = WEBSITE_IDS.flatMap((id) => [
|
||||
fetch(UMAMI_API + '/websites/' + id + '/stats?startAt=0&endAt=' + now, { headers }),
|
||||
fetch(UMAMI_API + '/websites/' + id + '/events/series?startAt=0&endAt=' + now + '&unit=year&timezone=UTC', { headers })
|
||||
let hkudsHumanVisits = 0;
|
||||
let ccHumanVisits = 0;
|
||||
let ccAgentVisits = 0;
|
||||
const [hkudsStatsResp, ccEventsResp] = await Promise.all([
|
||||
fetch(UMAMI_API + '/websites/' + HKUDS_WEBSITE_ID + '/stats?startAt=0&endAt=' + now, { headers }),
|
||||
fetch(UMAMI_API + '/websites/' + CC_WEBSITE_ID + '/events/series?startAt=0&endAt=' + now + '&unit=year&timezone=UTC', { headers })
|
||||
]);
|
||||
const [statsResp1, eventsResp1, statsResp2, eventsResp2] = await Promise.all(fetches);
|
||||
|
||||
for (const resp of [statsResp1, statsResp2]) {
|
||||
if (resp.ok) {
|
||||
const stats = await resp.json();
|
||||
totalVisits += stats.visits ?? 0;
|
||||
}
|
||||
if (hkudsStatsResp.ok) {
|
||||
const stats = await hkudsStatsResp.json();
|
||||
hkudsHumanVisits = stats.visits ?? 0;
|
||||
}
|
||||
|
||||
for (const resp of [eventsResp1, eventsResp2]) {
|
||||
if (resp.ok) {
|
||||
const events = await resp.json();
|
||||
events.forEach((e) => {
|
||||
if (e.x === 'visit-human') humanCount += e.y || 0;
|
||||
});
|
||||
}
|
||||
if (ccEventsResp.ok) {
|
||||
const events = await ccEventsResp.json();
|
||||
events.forEach((e) => {
|
||||
if (e.x === 'visit-human') ccHumanVisits += e.y || 0;
|
||||
if (e.x === 'visit-agent') ccAgentVisits += e.y || 0;
|
||||
});
|
||||
}
|
||||
|
||||
const humanCount = hkudsHumanVisits + ccHumanVisits;
|
||||
const totalVisits = humanCount + ccAgentVisits;
|
||||
|
||||
document.getElementById('stat-total').textContent = totalVisits.toLocaleString();
|
||||
document.getElementById('stat-human').textContent = humanCount.toLocaleString();
|
||||
document.getElementById('stat-agent').textContent = Math.max(0, totalVisits - humanCount).toLocaleString();
|
||||
document.getElementById('stat-agent').textContent = ccAgentVisits.toLocaleString();
|
||||
} catch (_) {
|
||||
// Leave fallback dashes.
|
||||
}
|
||||
}
|
||||
|
||||
loadVisitorStats();
|
||||
loadGitHubStars();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
+949
-100
File diff suppressed because it is too large
Load Diff
@@ -7,7 +7,7 @@ Usage:
|
||||
from cli_anything.<software>.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
|
||||
"drawio": "\033[38;5;202m", # draw.io orange
|
||||
}
|
||||
_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-<software>/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-id>/SKILL.md when running
|
||||
# inside the CLI-Anything monorepo. Fall back to the packaged
|
||||
# cli_anything/<software>/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)
|
||||
@@ -495,6 +563,5 @@ _ANSI_256_TO_HEX = {
|
||||
"\033[38;5;75m": "#5fafff", # default sky blue
|
||||
"\033[38;5;80m": "#5fd7d7", # brand cyan
|
||||
"\033[38;5;208m": "#ff8700", # blender deep orange
|
||||
"\033[38;5;202m": "#ff5f00", # drawio orange
|
||||
"\033[38;5;214m": "#ffaf00", # gimp warm orange
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ Usage:
|
||||
from cli_anything.<software>.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-<software>/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-id>/SKILL.md when running
|
||||
# inside the CLI-Anything monorepo. Fall back to the packaged
|
||||
# cli_anything/<software>/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)
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
---
|
||||
name: "cli-anything-exa"
|
||||
description: >-
|
||||
Agent-native CLI for Exa web search and content retrieval workflows.
|
||||
---
|
||||
|
||||
# Exa CLI Skill
|
||||
|
||||
## Identity
|
||||
|
||||
@@ -7,7 +7,7 @@ Usage:
|
||||
from cli_anything.<software>.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")
|
||||
@@ -106,21 +106,32 @@ class ReplSkin:
|
||||
history_file: Path for persistent command history.
|
||||
Defaults to ~/.cli-anything-<software>/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
|
||||
|
||||
# Auto-detect skill path from package layout:
|
||||
# cli_anything/<software>/utils/repl_skin.py (this file)
|
||||
# cli_anything/<software>/skills/SKILL.md (target)
|
||||
# Prefer repo-root canonical skills/<skill-id>/SKILL.md when running
|
||||
# inside the CLI-Anything monorepo. Fall back to the packaged
|
||||
# cli_anything/<software>/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)
|
||||
software_aliases = {"iterm2_ctl": "iterm2"}
|
||||
skill_slug = software_aliases.get(self.software, self.software).replace("_", "-")
|
||||
package_skill = Path(__file__).resolve().parent.parent / "skills" / "SKILL.md"
|
||||
repo_skill = None
|
||||
for parent in Path(__file__).resolve().parents:
|
||||
candidate = parent / "skills" / f"cli-anything-{skill_slug}" / "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)
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ Usage:
|
||||
from cli_anything.<software>.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) ──────────────
|
||||
|
||||
@@ -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
|
||||
"freecad": "\033[38;5;196m", # FreeCAD red
|
||||
}
|
||||
_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.
|
||||
|
||||
@@ -107,27 +120,44 @@ class ReplSkin:
|
||||
history_file: Path for persistent command history.
|
||||
Defaults to ~/.cli-anything-<software>/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/<software>/utils/repl_skin.py (this file)
|
||||
# cli_anything/<software>/skills/SKILL.md (target)
|
||||
# Prefer repo-root canonical skills/<skill-id>/SKILL.md when running
|
||||
# inside the CLI-Anything monorepo. Fall back to the packaged
|
||||
# cli_anything/<software>/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")
|
||||
@@ -157,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."""
|
||||
@@ -165,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}")
|
||||
|
||||
@@ -179,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)
|
||||
@@ -518,6 +563,5 @@ _ANSI_256_TO_HEX = {
|
||||
"\033[38;5;75m": "#5fafff", # default sky blue
|
||||
"\033[38;5;80m": "#5fd7d7", # brand cyan
|
||||
"\033[38;5;208m": "#ff8700", # blender deep orange
|
||||
"\033[38;5;196m": "#ff0000", # freecad red
|
||||
"\033[38;5;214m": "#ffaf00", # gimp warm orange
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ Usage:
|
||||
from cli_anything.<software>.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-<software>/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-id>/SKILL.md when running
|
||||
# inside the CLI-Anything monorepo. Fall back to the packaged
|
||||
# cli_anything/<software>/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)
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
---
|
||||
name: "cli-anything-godot"
|
||||
description: >-
|
||||
Agent-native CLI for Godot project management, scenes, exports, and script execution.
|
||||
---
|
||||
|
||||
# Godot Engine CLI
|
||||
|
||||
Agent-native CLI for the Godot game engine. Manage projects, scenes, exports, and GDScript execution from the command line.
|
||||
|
||||
@@ -7,7 +7,7 @@ Usage:
|
||||
from cli_anything.<software>.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-<software>/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/<software>/utils/repl_skin.py (this file)
|
||||
# cli_anything/<software>/skills/SKILL.md (target)
|
||||
# Prefer repo-root canonical skills/<skill-id>/SKILL.md when running
|
||||
# inside the CLI-Anything monorepo. Fall back to the packaged
|
||||
# cli_anything/<software>/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)
|
||||
|
||||
@@ -7,7 +7,7 @@ Usage:
|
||||
from cli_anything.<software>.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-<software>/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-id>/SKILL.md when running
|
||||
# inside the CLI-Anything monorepo. Fall back to the packaged
|
||||
# cli_anything/<software>/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)
|
||||
|
||||
@@ -7,7 +7,7 @@ Usage:
|
||||
from cli_anything.<software>.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-<software>/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-id>/SKILL.md when running
|
||||
# inside the CLI-Anything monorepo. Fall back to the packaged
|
||||
# cli_anything/<software>/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)
|
||||
|
||||
@@ -7,7 +7,7 @@ Usage:
|
||||
from cli_anything.<software>.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-<software>/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/<software>/utils/repl_skin.py (this file)
|
||||
# cli_anything/<software>/skills/SKILL.md (target)
|
||||
# Prefer repo-root canonical skills/<skill-id>/SKILL.md when running
|
||||
# inside the CLI-Anything monorepo. Fall back to the packaged
|
||||
# cli_anything/<software>/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)
|
||||
|
||||
@@ -7,7 +7,7 @@ Usage:
|
||||
from cli_anything.<software>.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-<software>/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-id>/SKILL.md when running
|
||||
# inside the CLI-Anything monorepo. Fall back to the packaged
|
||||
# cli_anything/<software>/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)
|
||||
|
||||
@@ -7,7 +7,7 @@ Usage:
|
||||
from cli_anything.<software>.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) ──────────────
|
||||
|
||||
@@ -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
|
||||
"krita": "\033[38;5;98m", # purple (Krita brand)
|
||||
}
|
||||
_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.
|
||||
|
||||
@@ -107,27 +120,44 @@ class ReplSkin:
|
||||
history_file: Path for persistent command history.
|
||||
Defaults to ~/.cli-anything-<software>/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/<software>/utils/repl_skin.py (this file)
|
||||
# cli_anything/<software>/skills/SKILL.md (target)
|
||||
# Prefer repo-root canonical skills/<skill-id>/SKILL.md when running
|
||||
# inside the CLI-Anything monorepo. Fall back to the packaged
|
||||
# cli_anything/<software>/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")
|
||||
@@ -157,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."""
|
||||
@@ -165,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}")
|
||||
|
||||
@@ -179,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)
|
||||
@@ -516,7 +561,6 @@ _ANSI_256_TO_HEX = {
|
||||
"\033[38;5;55m": "#5f00af", # obs purple
|
||||
"\033[38;5;69m": "#5f87ff", # kdenlive slate blue
|
||||
"\033[38;5;75m": "#5fafff", # default sky blue
|
||||
"\033[38;5;98m": "#875fd7", # krita purple
|
||||
"\033[38;5;80m": "#5fd7d7", # brand cyan
|
||||
"\033[38;5;208m": "#ff8700", # blender deep orange
|
||||
"\033[38;5;214m": "#ffaf00", # gimp warm orange
|
||||
|
||||
@@ -7,7 +7,7 @@ Usage:
|
||||
from cli_anything.<software>.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-<software>/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-id>/SKILL.md when running
|
||||
# inside the CLI-Anything monorepo. Fall back to the packaged
|
||||
# cli_anything/<software>/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)
|
||||
|
||||
@@ -2,18 +2,61 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
import textwrap
|
||||
|
||||
from prompt_toolkit import PromptSession
|
||||
from prompt_toolkit.auto_suggest import AutoSuggestFromHistory
|
||||
from prompt_toolkit.history import InMemoryHistory
|
||||
|
||||
_SKILL_SOURCE_REPO = os.environ.get("CLI_ANYTHING_SKILL_REPO", "HKUDS/CLI-Anything")
|
||||
|
||||
|
||||
def _display_home_path(path: str) -> str:
|
||||
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:
|
||||
def __init__(self, software: str, version: str = "1.0.0"):
|
||||
def __init__(self, software: str, version: str = "1.0.0", skill_path: str | None = None):
|
||||
self.software = software
|
||||
self.version = version
|
||||
self.skill_slug = 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")
|
||||
self.skill_path = skill_path or self._detect_skill_path()
|
||||
|
||||
def _detect_skill_path(self) -> str | None:
|
||||
for parent in Path(__file__).resolve().parents:
|
||||
candidate = parent / "skills" / self.skill_id / "SKILL.md"
|
||||
if candidate.is_file():
|
||||
return str(candidate)
|
||||
package_skill = Path(__file__).resolve().parent.parent / "skills" / "SKILL.md"
|
||||
if package_skill.is_file():
|
||||
return str(package_skill)
|
||||
return None
|
||||
|
||||
def print_banner(self) -> None:
|
||||
print(f"cli-anything-{self.software} v{self.version}")
|
||||
install_lines = textwrap.wrap(
|
||||
self.skill_install_cmd, width=88, break_long_words=True, break_on_hyphens=False
|
||||
) or [self.skill_install_cmd]
|
||||
for index, line in enumerate(install_lines):
|
||||
prefix = "Install: " if index == 0 else " "
|
||||
print(f"{prefix}{line}")
|
||||
print(f"Global skill: {_display_home_path(self.global_skill_path)}")
|
||||
print("Type help for commands, quit to exit")
|
||||
|
||||
def create_prompt_session(self) -> PromptSession:
|
||||
|
||||
@@ -7,7 +7,7 @@ Usage:
|
||||
from cli_anything.<software>.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-<software>/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/<software>/utils/repl_skin.py (this file)
|
||||
# cli_anything/<software>/skills/SKILL.md (target)
|
||||
# Prefer repo-root canonical skills/<skill-id>/SKILL.md when running
|
||||
# inside the CLI-Anything monorepo. Fall back to the packaged
|
||||
# cli_anything/<software>/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)
|
||||
|
||||
@@ -237,7 +237,12 @@ def extract_cli_metadata(harness_path: str) -> SkillMetadata:
|
||||
command_groups = extract_commands_from_cli(cli_file) if cli_file.exists() else []
|
||||
examples = generate_examples(software_name, command_groups)
|
||||
skill_name = f"cli-anything-{software_name}"
|
||||
skill_description = f"Command-line interface for {_format_display_name(software_name)} - {skill_intro[:100]}..."
|
||||
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,
|
||||
@@ -399,12 +404,18 @@ def generate_skill_md(metadata: SkillMetadata, template_path: Optional[str] = No
|
||||
def generate_skill_file(harness_path: str, output_path: Optional[str] = None, template_path: Optional[str] = None) -> str:
|
||||
metadata = extract_cli_metadata(harness_path)
|
||||
content = generate_skill_md(metadata, template_path)
|
||||
harness_root = Path(harness_path)
|
||||
skill_id = f"cli-anything-{harness_root.parent.name.replace('_', '-')}"
|
||||
if output_path is None:
|
||||
output = Path(harness_path) / "cli_anything" / metadata.software_name / "skills" / "SKILL.md"
|
||||
output = harness_root.parent.parent / "skills" / skill_id / "SKILL.md"
|
||||
else:
|
||||
output = Path(output_path)
|
||||
mirror = harness_root / "cli_anything" / metadata.software_name / "skills" / "SKILL.md"
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
output.write_text(content, encoding="utf-8")
|
||||
if mirror != output:
|
||||
mirror.parent.mkdir(parents=True, exist_ok=True)
|
||||
mirror.write_text(content, encoding="utf-8")
|
||||
return str(output)
|
||||
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ Usage:
|
||||
from cli_anything.<software>.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-<software>/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-id>/SKILL.md when running
|
||||
# inside the CLI-Anything monorepo. Fall back to the packaged
|
||||
# cli_anything/<software>/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)
|
||||
|
||||
@@ -7,7 +7,7 @@ Usage:
|
||||
from cli_anything.<software>.utils.repl_skin import ReplSkin
|
||||
|
||||
skin = ReplSkin("n8n", version="2.4.7")
|
||||
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_workflow", modified=True)
|
||||
skin.success("Workflow activated")
|
||||
skin.error("Connection failed")
|
||||
@@ -22,6 +22,7 @@ import json
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import click
|
||||
@@ -63,6 +64,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
|
||||
@@ -95,6 +98,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.
|
||||
|
||||
@@ -112,27 +126,43 @@ class ReplSkin:
|
||||
history_file: Path for persistent command history.
|
||||
Defaults to ~/.cli-anything-<software>/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
|
||||
self.skill_slug = 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/<software>/utils/repl_skin.py (this file)
|
||||
# cli_anything/<software>/skills/SKILL.md (target)
|
||||
# Prefer repo-root canonical skills/<skill-id>/SKILL.md when running
|
||||
# inside the CLI-Anything monorepo. Fall back to the packaged
|
||||
# cli_anything/<software>/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")
|
||||
@@ -162,7 +192,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."""
|
||||
@@ -170,6 +202,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}")
|
||||
|
||||
@@ -184,19 +234,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)
|
||||
|
||||
@@ -7,7 +7,7 @@ Usage:
|
||||
from cli_anything.<software>.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) ──────────────
|
||||
|
||||
@@ -30,7 +31,7 @@ _ITALIC = "\033[3m"
|
||||
_UNDERLINE = "\033[4m"
|
||||
|
||||
# Brand colors
|
||||
_CYAN = "\033[38;5;80m" # cli-anything brand cyan
|
||||
_CYAN = "\033[38;5;80m" # cli-anything brand cyan
|
||||
_CYAN_BG = "\033[48;5;80m"
|
||||
_WHITE = "\033[97m"
|
||||
_GRAY = "\033[38;5;245m"
|
||||
@@ -39,18 +40,16 @@ _LIGHT_GRAY = "\033[38;5;250m"
|
||||
|
||||
# Software accent colors — each software gets a unique accent
|
||||
_ACCENT_COLORS = {
|
||||
"gimp": "\033[38;5;214m", # warm orange
|
||||
"blender": "\033[38;5;208m", # deep orange
|
||||
"inkscape": "\033[38;5;39m", # bright blue
|
||||
"audacity": "\033[38;5;33m", # navy blue
|
||||
"libreoffice": "\033[38;5;40m", # green
|
||||
"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
|
||||
"novita": "\033[38;5;81m", # vivid blue (for Novita AI)
|
||||
"gimp": "\033[38;5;214m", # warm orange
|
||||
"blender": "\033[38;5;208m", # deep orange
|
||||
"inkscape": "\033[38;5;39m", # bright blue
|
||||
"audacity": "\033[38;5;33m", # navy blue
|
||||
"libreoffice": "\033[38;5;40m", # green
|
||||
"obs_studio": "\033[38;5;55m", # purple
|
||||
"kdenlive": "\033[38;5;69m", # slate blue
|
||||
"shotcut": "\033[38;5;35m", # teal green
|
||||
}
|
||||
_DEFAULT_ACCENT = "\033[38;5;75m" # default sky blue
|
||||
_DEFAULT_ACCENT = "\033[38;5;75m" # default sky blue
|
||||
|
||||
# Status colors
|
||||
_GREEN = "\033[38;5;78m"
|
||||
@@ -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
|
||||
@@ -83,7 +84,6 @@ _CROSS = "┼"
|
||||
def _strip_ansi(text: str) -> str:
|
||||
"""Remove ANSI escape codes for length calculation."""
|
||||
import re
|
||||
|
||||
return re.sub(r"\033\[[^m]*m", "", text)
|
||||
|
||||
|
||||
@@ -92,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,9 +110,8 @@ class ReplSkin:
|
||||
across all CLI harnesses built with the cli-anything methodology.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self, software: str, version: str = "1.0.0", history_file: str | None = None
|
||||
):
|
||||
def __init__(self, software: str, version: str = "1.0.0",
|
||||
history_file: str | None = None, skill_path: str | None = None):
|
||||
"""Initialize the REPL skin.
|
||||
|
||||
Args:
|
||||
@@ -109,16 +119,45 @@ class ReplSkin:
|
||||
version: CLI version string.
|
||||
history_file: Path for persistent command history.
|
||||
Defaults to ~/.cli-anything-<software>/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-id>/SKILL.md when running
|
||||
# inside the CLI-Anything monorepo. Fall back to the packaged
|
||||
# cli_anything/<software>/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")
|
||||
@@ -148,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."""
|
||||
@@ -156,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 · Novita
|
||||
# Title: ◆ cli-anything · Shotcut
|
||||
icon = self._c(_CYAN + _BOLD, "◆")
|
||||
brand = self._c(_CYAN + _BOLD, "cli-anything")
|
||||
dot = self._c(_DARK_GRAY, "·")
|
||||
@@ -170,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)
|
||||
@@ -180,9 +244,8 @@ class ReplSkin:
|
||||
|
||||
# ── Prompt ────────────────────────────────────────────────────────
|
||||
|
||||
def prompt(
|
||||
self, project_name: str = "", modified: bool = False, context: str = ""
|
||||
) -> str:
|
||||
def prompt(self, project_name: str = "", modified: bool = False,
|
||||
context: str = "") -> str:
|
||||
"""Build a styled prompt string for prompt_toolkit or input().
|
||||
|
||||
Args:
|
||||
@@ -210,15 +273,14 @@ class ReplSkin:
|
||||
mod = "*" if modified else ""
|
||||
parts.append(f" {self._c(_DARK_GRAY, '[')}")
|
||||
parts.append(self._c(_LIGHT_GRAY, f"{ctx}{mod}"))
|
||||
parts.append(self._c(_DARK_GRAY, "]"))
|
||||
parts.append(self._c(_DARK_GRAY, ']'))
|
||||
|
||||
parts.append(self._c(_GRAY, " ❯ "))
|
||||
|
||||
return "".join(parts)
|
||||
|
||||
def prompt_tokens(
|
||||
self, project_name: str = "", modified: bool = False, context: str = ""
|
||||
):
|
||||
def prompt_tokens(self, project_name: str = "", modified: bool = False,
|
||||
context: str = ""):
|
||||
"""Build prompt_toolkit formatted text tokens for the prompt.
|
||||
|
||||
Use with prompt_toolkit's FormattedText for proper ANSI handling.
|
||||
@@ -256,25 +318,23 @@ class ReplSkin:
|
||||
|
||||
accent_hex = _ANSI_256_TO_HEX.get(self.accent, "#5fafff")
|
||||
|
||||
return Style.from_dict(
|
||||
{
|
||||
"icon": "#5fdfdf bold", # cyan brand color
|
||||
"software": f"{accent_hex} bold",
|
||||
"bracket": "#585858",
|
||||
"context": "#bcbcbc",
|
||||
"arrow": "#808080",
|
||||
# Completion menu
|
||||
"completion-menu.completion": "bg:#303030 #bcbcbc",
|
||||
"completion-menu.completion.current": f"bg:{accent_hex} #000000",
|
||||
"completion-menu.meta.completion": "bg:#303030 #808080",
|
||||
"completion-menu.meta.completion.current": f"bg:{accent_hex} #000000",
|
||||
# Auto-suggest
|
||||
"auto-suggest": "#585858",
|
||||
# Bottom toolbar
|
||||
"bottom-toolbar": "bg:#1c1c1c #808080",
|
||||
"bottom-toolbar.text": "#808080",
|
||||
}
|
||||
)
|
||||
return Style.from_dict({
|
||||
"icon": "#5fdfdf bold", # cyan brand color
|
||||
"software": f"{accent_hex} bold",
|
||||
"bracket": "#585858",
|
||||
"context": "#bcbcbc",
|
||||
"arrow": "#808080",
|
||||
# Completion menu
|
||||
"completion-menu.completion": "bg:#303030 #bcbcbc",
|
||||
"completion-menu.completion.current": f"bg:{accent_hex} #000000",
|
||||
"completion-menu.meta.completion": "bg:#303030 #808080",
|
||||
"completion-menu.meta.completion.current": f"bg:{accent_hex} #000000",
|
||||
# Auto-suggest
|
||||
"auto-suggest": "#585858",
|
||||
# Bottom toolbar
|
||||
"bottom-toolbar": "bg:#1c1c1c #808080",
|
||||
"bottom-toolbar.text": "#808080",
|
||||
})
|
||||
|
||||
# ── Messages ──────────────────────────────────────────────────────
|
||||
|
||||
@@ -351,7 +411,8 @@ class ReplSkin:
|
||||
|
||||
# ── Table display ─────────────────────────────────────────────────
|
||||
|
||||
def table(self, headers: list[str], rows: list[list[str]], max_col_width: int = 40):
|
||||
def table(self, headers: list[str], rows: list[list[str]],
|
||||
max_col_width: int = 40):
|
||||
"""Print a formatted table with box-drawing characters.
|
||||
|
||||
Args:
|
||||
@@ -377,7 +438,8 @@ class ReplSkin:
|
||||
|
||||
# Header
|
||||
header_cells = [
|
||||
self._c(_CYAN + _BOLD, pad(h, col_widths[i])) for i, h in enumerate(headers)
|
||||
self._c(_CYAN + _BOLD, pad(h, col_widths[i]))
|
||||
for i, h in enumerate(headers)
|
||||
]
|
||||
sep = self._c(_DARK_GRAY, f" {_V_LINE} ")
|
||||
header_line = f" {sep.join(header_cells)}"
|
||||
@@ -385,9 +447,7 @@ class ReplSkin:
|
||||
|
||||
# Separator
|
||||
sep_parts = [self._c(_DARK_GRAY, _H_LINE * w) for w in col_widths]
|
||||
sep_line = self._c(
|
||||
_DARK_GRAY, f" {'───'.join([_H_LINE * w for w in col_widths])}"
|
||||
)
|
||||
sep_line = self._c(_DARK_GRAY, f" {'───'.join([_H_LINE * w for w in col_widths])}")
|
||||
print(sep_line)
|
||||
|
||||
# Rows
|
||||
@@ -447,13 +507,8 @@ class ReplSkin:
|
||||
except ImportError:
|
||||
return None
|
||||
|
||||
def get_input(
|
||||
self,
|
||||
pt_session,
|
||||
project_name: str = "",
|
||||
modified: bool = False,
|
||||
context: str = "",
|
||||
) -> str:
|
||||
def get_input(self, pt_session, project_name: str = "",
|
||||
modified: bool = False, context: str = "") -> str:
|
||||
"""Get input from user using prompt_toolkit or fallback.
|
||||
|
||||
Args:
|
||||
@@ -467,7 +522,6 @@ class ReplSkin:
|
||||
"""
|
||||
if pt_session is not None:
|
||||
from prompt_toolkit.formatted_text import FormattedText
|
||||
|
||||
tokens = self.prompt_tokens(project_name, modified, context)
|
||||
return pt_session.prompt(FormattedText(tokens)).strip()
|
||||
else:
|
||||
@@ -485,10 +539,8 @@ class ReplSkin:
|
||||
Returns:
|
||||
A callable that returns FormattedText for the toolbar.
|
||||
"""
|
||||
|
||||
def toolbar():
|
||||
from prompt_toolkit.formatted_text import FormattedText
|
||||
|
||||
parts = []
|
||||
for i, (k, v) in enumerate(items.items()):
|
||||
if i > 0:
|
||||
@@ -496,23 +548,20 @@ class ReplSkin:
|
||||
parts.append(("class:bottom-toolbar.text", f" {k}: "))
|
||||
parts.append(("class:bottom-toolbar", v))
|
||||
return FormattedText(parts)
|
||||
|
||||
return toolbar
|
||||
|
||||
|
||||
# ── ANSI 256-color to hex mapping (for prompt_toolkit styles) ─────────
|
||||
|
||||
_ANSI_256_TO_HEX = {
|
||||
"\033[38;5;33m": "#0087ff", # audacity navy blue
|
||||
"\033[38;5;35m": "#00af5f", # shotcut teal
|
||||
"\033[38;5;39m": "#00afff", # inkscape bright blue
|
||||
"\033[38;5;40m": "#00d700", # libreoffice green
|
||||
"\033[38;5;55m": "#5f00af", # obs purple
|
||||
"\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;81m": "#5fd7ff", # novita vivid blue
|
||||
"\033[38;5;141m": "#af87ff", # anygen soft violet
|
||||
"\033[38;5;33m": "#0087ff", # audacity navy blue
|
||||
"\033[38;5;35m": "#00af5f", # shotcut teal
|
||||
"\033[38;5;39m": "#00afff", # inkscape bright blue
|
||||
"\033[38;5;40m": "#00d700", # libreoffice green
|
||||
"\033[38;5;55m": "#5f00af", # obs purple
|
||||
"\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;208m": "#ff8700", # blender deep orange
|
||||
"\033[38;5;214m": "#ffaf00", # gimp warm orange
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ Usage:
|
||||
from cli_anything.<software>.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-<software>/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-id>/SKILL.md when running
|
||||
# inside the CLI-Anything monorepo. Fall back to the packaged
|
||||
# cli_anything/<software>/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)
|
||||
|
||||
@@ -6,20 +6,21 @@ Copy this file into your CLI package at:
|
||||
Usage:
|
||||
from cli_anything.<software>.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-<software>/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-id>/SKILL.md when running
|
||||
# inside the CLI-Anything monorepo. Fall back to the packaged
|
||||
# cli_anything/<software>/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
|
||||
}
|
||||
|
||||
@@ -6,20 +6,21 @@ Copy this file into your CLI package at:
|
||||
Usage:
|
||||
from cli_anything.<software>.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-<software>/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-id>/SKILL.md when running
|
||||
# inside the CLI-Anything monorepo. Fall back to the packaged
|
||||
# cli_anything/<software>/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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
# OpenClaw Macro System — Agent Harness SOP
|
||||
|
||||
## What Is This?
|
||||
|
||||
**OpenClaw Macro System** is a layered CLI that turns valuable GUI workflows into
|
||||
parameterized, agent-callable macros. The agent sends one command:
|
||||
|
||||
```bash
|
||||
cli-anything-openclaw macro run export_png --param output=/tmp/out.png --json
|
||||
```
|
||||
|
||||
The system handles everything else: parameter validation, precondition checks,
|
||||
backend selection, step execution, postcondition verification, and structured
|
||||
result output. The agent never touches the GUI directly.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Agent
|
||||
└─▶ cli-anything-openclaw macro run <name> --param k=v --json (L6: CLI)
|
||||
│
|
||||
MacroRuntime (L5)
|
||||
│ 1. Validate params against MacroDefinition schema
|
||||
│ 2. Check preconditions (file_exists, process_running, …)
|
||||
│ 3. For each step:
|
||||
│ RoutingEngine → select backend by priority (L3)
|
||||
│ Backend.execute(step, resolved_params) (L2)
|
||||
│ 4. Check postconditions
|
||||
│ 5. Collect declared outputs
|
||||
│ 6. Record telemetry in ExecutionSession
|
||||
└─▶ { success, output, error, telemetry }
|
||||
```
|
||||
|
||||
## Layer Mapping
|
||||
|
||||
| Layer | Name | Implementation |
|
||||
|-------|------|---------------|
|
||||
| L7 | Agent Task Interface | Caller (OpenClaw or any agent) |
|
||||
| L6 | Unified CLI Entry | `openclaw_cli.py` — Click CLI |
|
||||
| L5 | Macro Execution Runtime | `core/runtime.py` |
|
||||
| L4 | Parameterized Macro Model | `core/macro_model.py` + `macro_definitions/*.yaml` |
|
||||
| L3 | Backend Routing Engine | `core/routing.py` |
|
||||
| L2 | Execution Backends | `backends/` (5 backends) |
|
||||
| L1 | Target Application | Any GUI-first or closed-source app |
|
||||
|
||||
## Execution Backends
|
||||
|
||||
| Backend | Priority | Trigger | Use case |
|
||||
|---------|----------|---------|----------|
|
||||
| `native_api` | 100 | `backend: native_api` | subprocess / shell commands |
|
||||
| `gui_macro` | 80 | `backend: gui_macro` | precompiled coordinate replay (pyautogui) |
|
||||
| `file_transform` | 70 | `backend: file_transform` | XML, JSON, text file editing |
|
||||
| `semantic_ui` | 50 | `backend: semantic_ui` | accessibility API + keyboard (xdotool) |
|
||||
| `recovery` | 10 | `backend: recovery` | retry + fallback orchestration |
|
||||
|
||||
The RoutingEngine respects the step's explicit `backend:` field; if that backend
|
||||
is unavailable it walks down the priority list.
|
||||
|
||||
## Macro Definition Format
|
||||
|
||||
Macros live in `cli_anything/openclaw/macro_definitions/` as YAML files:
|
||||
|
||||
```yaml
|
||||
name: export_png
|
||||
version: "1.0"
|
||||
description: Export the active diagram to PNG.
|
||||
|
||||
parameters:
|
||||
output:
|
||||
type: string
|
||||
required: true
|
||||
example: /tmp/diagram.png
|
||||
|
||||
preconditions:
|
||||
- process_running: draw.io
|
||||
- file_exists: /path/to/input.drawio
|
||||
|
||||
steps:
|
||||
- id: export
|
||||
backend: native_api
|
||||
action: run_command
|
||||
params:
|
||||
command: [draw.io, --export, --output, "${output}", input.drawio]
|
||||
timeout_ms: 30000
|
||||
on_failure: fail # or: skip | continue
|
||||
|
||||
postconditions:
|
||||
- file_exists: ${output}
|
||||
- file_size_gt:
|
||||
- ${output}
|
||||
- 100
|
||||
|
||||
outputs:
|
||||
- name: exported_file
|
||||
path: ${output}
|
||||
|
||||
agent_hints:
|
||||
danger_level: safe
|
||||
side_effects: [creates_file]
|
||||
reversible: true
|
||||
```
|
||||
|
||||
### Supported Condition Types
|
||||
|
||||
| Type | Args | Checks |
|
||||
|------|------|--------|
|
||||
| `file_exists` | path | `os.path.exists(path)` |
|
||||
| `file_size_gt` | [path, min_bytes] | `os.stat(path).st_size > min_bytes` |
|
||||
| `process_running` | name | `pgrep -x name` or psutil |
|
||||
| `env_var` | name | `name in os.environ` |
|
||||
| `always` | true/false | constant pass/fail |
|
||||
|
||||
## Package Layout
|
||||
|
||||
```
|
||||
openclaw-skill/
|
||||
└── agent-harness/
|
||||
├── setup.py entry_point: cli-anything-openclaw
|
||||
└── cli_anything/openclaw/
|
||||
├── openclaw_cli.py Main Click CLI
|
||||
├── macro_definitions/ YAML macro registry
|
||||
│ ├── manifest.yaml
|
||||
│ └── examples/
|
||||
│ ├── export_file.yaml
|
||||
│ ├── transform_json.yaml
|
||||
│ └── undo_last.yaml
|
||||
├── core/
|
||||
│ ├── macro_model.py MacroDefinition + YAML loader
|
||||
│ ├── registry.py MacroRegistry
|
||||
│ ├── routing.py RoutingEngine
|
||||
│ ├── runtime.py MacroRuntime (full lifecycle)
|
||||
│ └── session.py ExecutionSession + telemetry
|
||||
├── backends/
|
||||
│ ├── base.py Backend ABC + StepResult
|
||||
│ ├── native_api.py subprocess backend
|
||||
│ ├── file_transform.py XML/JSON/text backend
|
||||
│ ├── semantic_ui.py accessibility backend
|
||||
│ ├── gui_macro.py compiled replay backend
|
||||
│ └── recovery.py retry/fallback backend
|
||||
├── skills/SKILL.md Agent-readable skill definition
|
||||
├── utils/repl_skin.py Unified REPL skin (cli-anything standard)
|
||||
└── tests/
|
||||
├── test_core.py Unit tests (49 tests, no external deps)
|
||||
└── test_full_e2e.py E2E + CLI subprocess tests (15 tests)
|
||||
```
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
cd openclaw-skill/agent-harness
|
||||
pip install -e .
|
||||
```
|
||||
|
||||
**Runtime dependencies:** Python 3.10+, PyYAML, click, prompt-toolkit.
|
||||
|
||||
**Optional (for specific backends):**
|
||||
- `xdotool` — semantic_ui backend on Linux
|
||||
- `pyautogui` — gui_macro backend
|
||||
- `psutil` — richer process_running checks
|
||||
|
||||
## Running Tests
|
||||
|
||||
```bash
|
||||
cd openclaw-skill/agent-harness
|
||||
python3 -m pytest cli_anything/openclaw/tests/ -v -s
|
||||
# 64 passed
|
||||
```
|
||||
|
||||
## Key Design Decisions
|
||||
|
||||
**Why YAML macros, not Python?** YAML macros are readable by agents without
|
||||
running code, inspectable via `macro info`, and editable without touching the
|
||||
harness source.
|
||||
|
||||
**Why 5 backends?** Real GUI applications expose many different control
|
||||
surfaces. The routing engine picks the most reliable one available — the agent
|
||||
doesn't need to know which one ran.
|
||||
|
||||
**Why preconditions and postconditions?** Agents operate in environments where
|
||||
state is uncertain. Failing loudly before execution (preconditions) and
|
||||
verifying after (postconditions) catches problems the agent can act on.
|
||||
|
||||
**Why `on_failure: skip | continue`?** Some macro steps are best-effort (e.g.,
|
||||
confirming a dialog that may or may not appear). Skipping lets the macro
|
||||
continue to the real work.
|
||||
@@ -0,0 +1,75 @@
|
||||
# OpenClaw Macro System
|
||||
|
||||
**OpenClaw Macro System** is a layered CLI that converts GUI workflows into
|
||||
parameterized, agent-callable macros. Agents call `macro run <name>` through
|
||||
a stable CLI; the system routes execution to the right backend (native plugin,
|
||||
file transform, semantic UI, or compiled GUI replay) — invisible to the agent.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
pip install -e .
|
||||
```
|
||||
|
||||
**Dependencies:** Python 3.10+, PyYAML, click, prompt-toolkit
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
# List available macros
|
||||
cli-anything-openclaw macro list --json
|
||||
|
||||
# Inspect a macro
|
||||
cli-anything-openclaw macro info export_file --json
|
||||
|
||||
# Execute a macro
|
||||
cli-anything-openclaw macro run transform_json \
|
||||
--param file=/tmp/config.json \
|
||||
--param key=theme --param value=dark --json
|
||||
|
||||
# Dry run
|
||||
cli-anything-openclaw --dry-run macro run export_file \
|
||||
--param output=/tmp/out.txt --json
|
||||
|
||||
# Interactive REPL
|
||||
cli-anything-openclaw
|
||||
```
|
||||
|
||||
## Run Tests
|
||||
|
||||
```bash
|
||||
cd openclaw-skill/agent-harness
|
||||
pip install -e ".[dev]"
|
||||
python -m pytest cli_anything/openclaw/tests/ -v -s
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
cli-anything-openclaw (CLI)
|
||||
└─▶ macro run <name> --param key=value
|
||||
│
|
||||
MacroRuntime
|
||||
│ validate params
|
||||
│ check preconditions
|
||||
│ for each step:
|
||||
│ RoutingEngine → select backend
|
||||
│ Backend.execute(step, params)
|
||||
│ check postconditions
|
||||
└─▶ ExecutionResult { success, output, telemetry }
|
||||
```
|
||||
|
||||
**Backends:**
|
||||
- `native_api` — subprocess / shell commands
|
||||
- `file_transform` — XML, JSON, text file editing
|
||||
- `semantic_ui` — accessibility controls + keyboard shortcuts
|
||||
- `gui_macro` — precompiled coordinate-based replay
|
||||
- `recovery` — retry + fallback orchestration
|
||||
|
||||
## Adding a Macro
|
||||
|
||||
1. Create `cli_anything/openclaw/macro_definitions/my_macro.yaml`
|
||||
2. Add it to `macro_definitions/manifest.yaml`
|
||||
3. Verify: `cli-anything-openclaw macro validate my_macro --json`
|
||||
|
||||
See `skills/SKILL.md` (installed with the package) for full macro YAML schema.
|
||||
@@ -0,0 +1 @@
|
||||
# cli_anything/openclaw package
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Enable: python3 -m cli_anything.openclaw"""
|
||||
from cli_anything.openclaw.openclaw_cli import cli
|
||||
|
||||
if __name__ == "__main__":
|
||||
cli()
|
||||
@@ -0,0 +1,89 @@
|
||||
"""Backend base classes and result types.
|
||||
|
||||
All execution backends inherit from Backend and return StepResult.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Optional
|
||||
|
||||
|
||||
@dataclass
|
||||
class StepResult:
|
||||
"""Result of a single macro step execution."""
|
||||
success: bool
|
||||
output: dict = field(default_factory=dict)
|
||||
error: str = ""
|
||||
duration_ms: float = 0.0
|
||||
backend_used: str = ""
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"success": self.success,
|
||||
"output": self.output,
|
||||
"error": self.error,
|
||||
"duration_ms": self.duration_ms,
|
||||
"backend_used": self.backend_used,
|
||||
}
|
||||
|
||||
|
||||
class BackendContext:
|
||||
"""Runtime context passed to each backend during step execution."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
params: dict,
|
||||
previous_results: Optional[list[StepResult]] = None,
|
||||
dry_run: bool = False,
|
||||
timeout_ms: int = 30_000,
|
||||
):
|
||||
self.params = params
|
||||
self.previous_results: list[StepResult] = previous_results or []
|
||||
self.dry_run = dry_run
|
||||
self.timeout_ms = timeout_ms
|
||||
self._start = time.time()
|
||||
|
||||
def elapsed_ms(self) -> float:
|
||||
return (time.time() - self._start) * 1000
|
||||
|
||||
|
||||
class Backend(ABC):
|
||||
"""Abstract base class for all execution backends.
|
||||
|
||||
Concrete backends implement execute() and return a StepResult.
|
||||
"""
|
||||
|
||||
name: str = "base"
|
||||
priority: int = 0
|
||||
|
||||
@abstractmethod
|
||||
def execute(
|
||||
self,
|
||||
step: "MacroStep", # type: ignore[name-defined]
|
||||
params: dict,
|
||||
context: BackendContext,
|
||||
) -> StepResult:
|
||||
"""Execute a macro step.
|
||||
|
||||
Args:
|
||||
step: The MacroStep definition being executed.
|
||||
params: Fully resolved (substituted) parameters.
|
||||
context: Runtime context with previous results and flags.
|
||||
|
||||
Returns:
|
||||
StepResult describing success/failure and captured output.
|
||||
"""
|
||||
|
||||
def is_available(self) -> bool:
|
||||
"""Return True if this backend can be used in the current environment."""
|
||||
return True
|
||||
|
||||
def describe(self) -> dict:
|
||||
return {
|
||||
"name": self.name,
|
||||
"priority": self.priority,
|
||||
"available": self.is_available(),
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
"""FileTransformBackend — read, transform, and write project files.
|
||||
|
||||
Supports XML (ElementTree), JSON, and plain text transformations.
|
||||
|
||||
Example macro step:
|
||||
|
||||
- backend: file_transform
|
||||
action: json_set
|
||||
params:
|
||||
input_file: ${project_file}
|
||||
output_file: ${project_file}
|
||||
path: settings.grid_size
|
||||
value: 20
|
||||
|
||||
- backend: file_transform
|
||||
action: xml_set_attr
|
||||
params:
|
||||
input_file: diagram.drawio
|
||||
output_file: diagram.drawio
|
||||
xpath: .//mxCell[@id='1']
|
||||
attr: style
|
||||
value: rounded=1;
|
||||
|
||||
- backend: file_transform
|
||||
action: text_replace
|
||||
params:
|
||||
input_file: config.ini
|
||||
output_file: config.ini
|
||||
find: "theme=default"
|
||||
replace: "theme=dark"
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from cli_anything.openclaw.backends.base import Backend, BackendContext, StepResult
|
||||
from cli_anything.openclaw.core.macro_model import MacroStep, substitute
|
||||
|
||||
|
||||
class FileTransformBackend(Backend):
|
||||
"""Transform project files without invoking the target application."""
|
||||
|
||||
name = "file_transform"
|
||||
priority = 70
|
||||
|
||||
def execute(self, step: MacroStep, params: dict, context: BackendContext) -> StepResult:
|
||||
t0 = time.time()
|
||||
action = step.action
|
||||
step_params = substitute(step.params, params)
|
||||
|
||||
if context.dry_run:
|
||||
return StepResult(
|
||||
success=True,
|
||||
output={"dry_run": True, "action": action},
|
||||
backend_used=self.name,
|
||||
duration_ms=(time.time() - t0) * 1000,
|
||||
)
|
||||
|
||||
dispatch = {
|
||||
"json_get": self._json_get,
|
||||
"json_set": self._json_set,
|
||||
"json_delete": self._json_delete,
|
||||
"xml_set_attr": self._xml_set_attr,
|
||||
"xml_get_attr": self._xml_get_attr,
|
||||
"text_replace": self._text_replace,
|
||||
"copy_file": self._copy_file,
|
||||
}
|
||||
|
||||
handler = dispatch.get(action)
|
||||
if handler is None:
|
||||
return StepResult(
|
||||
success=False,
|
||||
error=f"FileTransformBackend: unknown action '{action}'.",
|
||||
backend_used=self.name,
|
||||
duration_ms=(time.time() - t0) * 1000,
|
||||
)
|
||||
|
||||
try:
|
||||
output = handler(step_params)
|
||||
return StepResult(
|
||||
success=True,
|
||||
output=output or {},
|
||||
backend_used=self.name,
|
||||
duration_ms=(time.time() - t0) * 1000,
|
||||
)
|
||||
except Exception as exc:
|
||||
return StepResult(
|
||||
success=False,
|
||||
error=f"FileTransformBackend.{action}: {exc}",
|
||||
backend_used=self.name,
|
||||
duration_ms=(time.time() - t0) * 1000,
|
||||
)
|
||||
|
||||
# ── JSON actions ─────────────────────────────────────────────────────
|
||||
|
||||
def _json_get(self, p: dict) -> dict:
|
||||
"""Read a value from a JSON file by dot-path."""
|
||||
data = self._load_json(p["input_file"])
|
||||
val = self._dotpath_get(data, p["path"])
|
||||
return {"value": val, "path": p["path"]}
|
||||
|
||||
def _json_set(self, p: dict) -> dict:
|
||||
"""Set a value in a JSON file by dot-path and write it back."""
|
||||
path = p.get("path", "")
|
||||
value = p["value"]
|
||||
data = self._load_json(p["input_file"]) if Path(p["input_file"]).is_file() else {}
|
||||
self._dotpath_set(data, path, value)
|
||||
self._save_json(p.get("output_file", p["input_file"]), data)
|
||||
return {"path": path, "value": value}
|
||||
|
||||
def _json_delete(self, p: dict) -> dict:
|
||||
"""Delete a key from a JSON file by dot-path."""
|
||||
data = self._load_json(p["input_file"])
|
||||
self._dotpath_delete(data, p["path"])
|
||||
self._save_json(p.get("output_file", p["input_file"]), data)
|
||||
return {"deleted": p["path"]}
|
||||
|
||||
# ── XML actions ──────────────────────────────────────────────────────
|
||||
|
||||
def _xml_set_attr(self, p: dict) -> dict:
|
||||
"""Set an XML element attribute matched by XPath."""
|
||||
from xml.etree import ElementTree as ET
|
||||
tree = ET.parse(p["input_file"])
|
||||
root = tree.getroot()
|
||||
elements = root.findall(p["xpath"])
|
||||
if not elements:
|
||||
raise ValueError(f"XPath matched nothing: {p['xpath']}")
|
||||
for el in elements:
|
||||
el.set(p["attr"], str(p["value"]))
|
||||
tree.write(p.get("output_file", p["input_file"]), encoding="unicode", xml_declaration=True)
|
||||
return {"matched": len(elements), "attr": p["attr"]}
|
||||
|
||||
def _xml_get_attr(self, p: dict) -> dict:
|
||||
"""Get an XML element attribute matched by XPath."""
|
||||
from xml.etree import ElementTree as ET
|
||||
tree = ET.parse(p["input_file"])
|
||||
root = tree.getroot()
|
||||
elements = root.findall(p["xpath"])
|
||||
values = [el.get(p["attr"]) for el in elements]
|
||||
return {"values": values, "attr": p["attr"]}
|
||||
|
||||
# ── Text actions ─────────────────────────────────────────────────────
|
||||
|
||||
def _text_replace(self, p: dict) -> dict:
|
||||
"""Simple find-and-replace in a text file."""
|
||||
content = Path(p["input_file"]).read_text(encoding="utf-8")
|
||||
count = content.count(p["find"])
|
||||
content = content.replace(p["find"], p["replace"])
|
||||
out = p.get("output_file", p["input_file"])
|
||||
Path(out).write_text(content, encoding="utf-8")
|
||||
return {"replacements": count}
|
||||
|
||||
def _copy_file(self, p: dict) -> dict:
|
||||
"""Copy a file from src to dst."""
|
||||
import shutil
|
||||
shutil.copy2(p["src"], p["dst"])
|
||||
size = os.path.getsize(p["dst"])
|
||||
return {"src": p["src"], "dst": p["dst"], "size": size}
|
||||
|
||||
# ── Helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
def _load_json(self, path: str) -> dict:
|
||||
with open(path, encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
|
||||
def _save_json(self, path: str, data: dict) -> None:
|
||||
Path(path).parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, indent=2, ensure_ascii=False)
|
||||
|
||||
def _dotpath_get(self, data: dict, path: str):
|
||||
keys = path.split(".")
|
||||
cur = data
|
||||
for k in keys:
|
||||
if isinstance(cur, dict) and k in cur:
|
||||
cur = cur[k]
|
||||
else:
|
||||
return None
|
||||
return cur
|
||||
|
||||
def _dotpath_set(self, data: dict, path: str, value) -> None:
|
||||
keys = path.split(".")
|
||||
cur = data
|
||||
for k in keys[:-1]:
|
||||
if k not in cur or not isinstance(cur[k], dict):
|
||||
cur[k] = {}
|
||||
cur = cur[k]
|
||||
cur[keys[-1]] = value
|
||||
|
||||
def _dotpath_delete(self, data: dict, path: str) -> None:
|
||||
keys = path.split(".")
|
||||
cur = data
|
||||
for k in keys[:-1]:
|
||||
if isinstance(cur, dict) and k in cur:
|
||||
cur = cur[k]
|
||||
else:
|
||||
return
|
||||
if isinstance(cur, dict) and keys[-1] in cur:
|
||||
del cur[keys[-1]]
|
||||
@@ -0,0 +1,205 @@
|
||||
"""GUIMacroBackend — replay precompiled coordinate-based macro sequences.
|
||||
|
||||
A compiled macro is a JSON blob describing an exact sequence of mouse clicks,
|
||||
key presses, and wait conditions. These are fast to execute but fragile to
|
||||
layout changes.
|
||||
|
||||
Compiled macro format (stored separately, referenced by step params):
|
||||
|
||||
{
|
||||
"version": 1,
|
||||
"screen_resolution": "1920x1080",
|
||||
"layout_hash": "abc123",
|
||||
"steps": [
|
||||
{"type": "click", "x": 100, "y": 200, "button": "left", "delay_ms": 200},
|
||||
{"type": "key", "keys": "ctrl+s", "delay_ms": 100},
|
||||
{"type": "type", "text": "output.png", "delay_ms": 50},
|
||||
{"type": "wait_file", "path": "/tmp/out.png", "timeout_ms": 5000},
|
||||
{"type": "sleep", "ms": 500}
|
||||
]
|
||||
}
|
||||
|
||||
Example macro step:
|
||||
|
||||
- backend: gui_macro
|
||||
action: replay
|
||||
params:
|
||||
macro_file: macros/compiled/export_png.json
|
||||
layout_strict: false # if true, fail when screen res changes
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from cli_anything.openclaw.backends.base import Backend, BackendContext, StepResult
|
||||
from cli_anything.openclaw.core.macro_model import MacroStep, substitute
|
||||
|
||||
|
||||
class GUIMacroBackend(Backend):
|
||||
"""Replay precompiled GUI automation sequences."""
|
||||
|
||||
name = "gui_macro"
|
||||
priority = 80
|
||||
|
||||
def execute(self, step: MacroStep, params: dict, context: BackendContext) -> StepResult:
|
||||
t0 = time.time()
|
||||
action = step.action
|
||||
step_params = substitute(step.params, params)
|
||||
|
||||
if context.dry_run:
|
||||
return StepResult(
|
||||
success=True,
|
||||
output={"dry_run": True, "action": action},
|
||||
backend_used=self.name,
|
||||
duration_ms=(time.time() - t0) * 1000,
|
||||
)
|
||||
|
||||
if action != "replay":
|
||||
return StepResult(
|
||||
success=False,
|
||||
error=f"GUIMacroBackend: unknown action '{action}'. Expected 'replay'.",
|
||||
backend_used=self.name,
|
||||
duration_ms=(time.time() - t0) * 1000,
|
||||
)
|
||||
|
||||
return self._replay(step_params, context, t0)
|
||||
|
||||
def is_available(self) -> bool:
|
||||
"""Available when at least one automation library is present."""
|
||||
for lib in ("pyautogui", "pynput"):
|
||||
try:
|
||||
__import__(lib)
|
||||
return True
|
||||
except ImportError:
|
||||
pass
|
||||
return False
|
||||
|
||||
def _replay(self, p: dict, context: BackendContext, t0: float) -> StepResult:
|
||||
"""Load and replay a compiled macro file."""
|
||||
macro_file = p.get("macro_file", "")
|
||||
if not macro_file:
|
||||
return StepResult(
|
||||
success=False,
|
||||
error="GUIMacroBackend.replay: 'macro_file' param is required.",
|
||||
backend_used=self.name,
|
||||
duration_ms=(time.time() - t0) * 1000,
|
||||
)
|
||||
|
||||
macro_path = Path(macro_file)
|
||||
if not macro_path.is_file():
|
||||
return StepResult(
|
||||
success=False,
|
||||
error=f"GUIMacroBackend: compiled macro not found: {macro_file}",
|
||||
backend_used=self.name,
|
||||
duration_ms=(time.time() - t0) * 1000,
|
||||
)
|
||||
|
||||
try:
|
||||
with open(macro_path, encoding="utf-8") as f:
|
||||
macro_blob = json.load(f)
|
||||
except Exception as exc:
|
||||
return StepResult(
|
||||
success=False,
|
||||
error=f"GUIMacroBackend: failed to load macro file: {exc}",
|
||||
backend_used=self.name,
|
||||
duration_ms=(time.time() - t0) * 1000,
|
||||
)
|
||||
|
||||
layout_strict: bool = p.get("layout_strict", False)
|
||||
if layout_strict:
|
||||
check = self._check_layout(macro_blob)
|
||||
if check:
|
||||
return StepResult(
|
||||
success=False,
|
||||
error=f"GUIMacroBackend: layout mismatch — {check}",
|
||||
backend_used=self.name,
|
||||
duration_ms=(time.time() - t0) * 1000,
|
||||
)
|
||||
|
||||
try:
|
||||
steps_run = self._execute_steps(macro_blob.get("steps", []), context)
|
||||
return StepResult(
|
||||
success=True,
|
||||
output={"steps_executed": steps_run, "macro_file": macro_file},
|
||||
backend_used=self.name,
|
||||
duration_ms=(time.time() - t0) * 1000,
|
||||
)
|
||||
except Exception as exc:
|
||||
return StepResult(
|
||||
success=False,
|
||||
error=f"GUIMacroBackend.replay: {exc}",
|
||||
backend_used=self.name,
|
||||
duration_ms=(time.time() - t0) * 1000,
|
||||
)
|
||||
|
||||
def _check_layout(self, macro_blob: dict) -> str:
|
||||
"""Return error string if current screen doesn't match expected."""
|
||||
expected_res = macro_blob.get("screen_resolution", "")
|
||||
if not expected_res:
|
||||
return ""
|
||||
try:
|
||||
import pyautogui
|
||||
w, h = pyautogui.size()
|
||||
current_res = f"{w}x{h}"
|
||||
if current_res != expected_res:
|
||||
return f"screen is {current_res}, macro expects {expected_res}"
|
||||
except ImportError:
|
||||
pass # Can't verify — allow through
|
||||
return ""
|
||||
|
||||
def _execute_steps(self, steps: list, context: BackendContext) -> int:
|
||||
"""Execute each step in the compiled macro."""
|
||||
try:
|
||||
import pyautogui
|
||||
has_pyautogui = True
|
||||
except ImportError:
|
||||
has_pyautogui = False
|
||||
|
||||
count = 0
|
||||
for s in steps:
|
||||
stype = s.get("type", "")
|
||||
delay = s.get("delay_ms", 100) / 1000.0
|
||||
|
||||
if stype == "click":
|
||||
if not has_pyautogui:
|
||||
raise ImportError("pyautogui required for click steps. pip install pyautogui")
|
||||
button = s.get("button", "left")
|
||||
pyautogui.click(s["x"], s["y"], button=button)
|
||||
|
||||
elif stype == "key":
|
||||
if not has_pyautogui:
|
||||
raise ImportError("pyautogui required for key steps. pip install pyautogui")
|
||||
keys = s.get("keys", "").split("+")
|
||||
if len(keys) == 1:
|
||||
pyautogui.press(keys[0])
|
||||
else:
|
||||
pyautogui.hotkey(*keys)
|
||||
|
||||
elif stype == "type":
|
||||
if not has_pyautogui:
|
||||
raise ImportError("pyautogui required for type steps. pip install pyautogui")
|
||||
pyautogui.typewrite(s.get("text", ""), interval=0.03)
|
||||
|
||||
elif stype == "wait_file":
|
||||
deadline = time.time() + s.get("timeout_ms", 5000) / 1000.0
|
||||
path = s.get("path", "")
|
||||
while time.time() < deadline:
|
||||
if Path(path).exists():
|
||||
break
|
||||
time.sleep(0.1)
|
||||
else:
|
||||
raise TimeoutError(f"wait_file timed out: {path}")
|
||||
delay = 0 # no additional delay after file wait
|
||||
|
||||
elif stype == "sleep":
|
||||
time.sleep(s.get("ms", 500) / 1000.0)
|
||||
delay = 0
|
||||
|
||||
if delay > 0:
|
||||
time.sleep(delay)
|
||||
count += 1
|
||||
|
||||
return count
|
||||
@@ -0,0 +1,167 @@
|
||||
"""NativeAPIBackend — executes macro steps via subprocess.
|
||||
|
||||
Supports these action types (configured in macro step params):
|
||||
|
||||
action: run_command
|
||||
params:
|
||||
command: [inkscape, --export-filename, /tmp/out.png, input.svg]
|
||||
cwd: /optional/working/dir # optional
|
||||
env: {KEY: value} # optional extra env vars
|
||||
capture_stdout: true # store stdout in output.stdout
|
||||
|
||||
action: find_executable
|
||||
params:
|
||||
name: inkscape
|
||||
candidates: [inkscape, inkscape-1.0, /usr/bin/inkscape]
|
||||
install_hint: "apt install inkscape"
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from cli_anything.openclaw.backends.base import Backend, BackendContext, StepResult
|
||||
from cli_anything.openclaw.core.macro_model import MacroStep, substitute
|
||||
|
||||
|
||||
class NativeAPIBackend(Backend):
|
||||
"""Execute a macro step by running an external command."""
|
||||
|
||||
name = "native_api"
|
||||
priority = 100
|
||||
|
||||
def execute(self, step: MacroStep, params: dict, context: BackendContext) -> StepResult:
|
||||
t0 = time.time()
|
||||
action = step.action
|
||||
|
||||
if action == "find_executable":
|
||||
return self._find_executable(step, params, context, t0)
|
||||
elif action == "run_command":
|
||||
return self._run_command(step, params, context, t0)
|
||||
else:
|
||||
return StepResult(
|
||||
success=False,
|
||||
error=f"NativeAPIBackend: unknown action '{action}'.",
|
||||
backend_used=self.name,
|
||||
duration_ms=(time.time() - t0) * 1000,
|
||||
)
|
||||
|
||||
# ── Actions ──────────────────────────────────────────────────────────
|
||||
|
||||
def _find_executable(
|
||||
self, step: MacroStep, params: dict, context: BackendContext, t0: float
|
||||
) -> StepResult:
|
||||
"""Check that an executable exists; return its path."""
|
||||
step_params = substitute(step.params, params)
|
||||
exe_name = step_params.get("name", "")
|
||||
candidates: list[str] = step_params.get("candidates", [exe_name] if exe_name else [])
|
||||
install_hint: str = step_params.get("install_hint", f"Install {exe_name}")
|
||||
|
||||
for candidate in candidates:
|
||||
found = shutil.which(candidate)
|
||||
if found:
|
||||
return StepResult(
|
||||
success=True,
|
||||
output={"executable": found, "name": candidate},
|
||||
backend_used=self.name,
|
||||
duration_ms=(time.time() - t0) * 1000,
|
||||
)
|
||||
|
||||
return StepResult(
|
||||
success=False,
|
||||
error=(
|
||||
f"Executable not found: {exe_name}. "
|
||||
f"Tried: {candidates}. "
|
||||
f"Install with: {install_hint}"
|
||||
),
|
||||
backend_used=self.name,
|
||||
duration_ms=(time.time() - t0) * 1000,
|
||||
)
|
||||
|
||||
def _run_command(
|
||||
self, step: MacroStep, params: dict, context: BackendContext, t0: float
|
||||
) -> StepResult:
|
||||
"""Run an external command."""
|
||||
step_params = substitute(step.params, params)
|
||||
command: list[str] = step_params.get("command", [])
|
||||
if not command:
|
||||
return StepResult(
|
||||
success=False,
|
||||
error="NativeAPIBackend.run_command: 'command' param is required.",
|
||||
backend_used=self.name,
|
||||
duration_ms=(time.time() - t0) * 1000,
|
||||
)
|
||||
|
||||
if isinstance(command, str):
|
||||
import shlex
|
||||
command = shlex.split(command)
|
||||
command = [str(c) for c in command]
|
||||
|
||||
cwd: str = step_params.get("cwd", "")
|
||||
extra_env: dict = step_params.get("env", {})
|
||||
capture_stdout: bool = step_params.get("capture_stdout", False)
|
||||
|
||||
env = os.environ.copy()
|
||||
if extra_env:
|
||||
env.update({k: str(v) for k, v in extra_env.items()})
|
||||
|
||||
timeout_s = context.timeout_ms / 1000.0
|
||||
|
||||
if context.dry_run:
|
||||
return StepResult(
|
||||
success=True,
|
||||
output={"dry_run": True, "command": command},
|
||||
backend_used=self.name,
|
||||
duration_ms=(time.time() - t0) * 1000,
|
||||
)
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
command,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout_s,
|
||||
cwd=cwd or None,
|
||||
env=env,
|
||||
)
|
||||
except FileNotFoundError as exc:
|
||||
return StepResult(
|
||||
success=False,
|
||||
error=f"Command not found: {command[0]}. {exc}",
|
||||
backend_used=self.name,
|
||||
duration_ms=(time.time() - t0) * 1000,
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
return StepResult(
|
||||
success=False,
|
||||
error=f"Command timed out after {timeout_s:.0f}s: {command}",
|
||||
backend_used=self.name,
|
||||
duration_ms=(time.time() - t0) * 1000,
|
||||
)
|
||||
|
||||
duration = (time.time() - t0) * 1000
|
||||
if result.returncode != 0:
|
||||
return StepResult(
|
||||
success=False,
|
||||
error=(
|
||||
f"Command failed (exit {result.returncode}): {command}\n"
|
||||
f"stderr: {result.stderr.strip()}"
|
||||
),
|
||||
output={"returncode": result.returncode, "stderr": result.stderr},
|
||||
backend_used=self.name,
|
||||
duration_ms=duration,
|
||||
)
|
||||
|
||||
output: dict[str, Any] = {"returncode": 0}
|
||||
if capture_stdout:
|
||||
output["stdout"] = result.stdout
|
||||
return StepResult(
|
||||
success=True,
|
||||
output=output,
|
||||
backend_used=self.name,
|
||||
duration_ms=duration,
|
||||
)
|
||||
@@ -0,0 +1,128 @@
|
||||
"""RecoveryBackend — retry and fallback orchestration.
|
||||
|
||||
This backend wraps another backend and retries failed steps with exponential
|
||||
backoff. It can also fall back to an alternative backend on exhausted retries.
|
||||
|
||||
Example macro step using recovery explicitly:
|
||||
|
||||
- backend: recovery
|
||||
action: retry_with_fallback
|
||||
params:
|
||||
primary_backend: native_api
|
||||
fallback_backend: file_transform
|
||||
max_retries: 3
|
||||
backoff_ms: [1000, 2000, 5000]
|
||||
step:
|
||||
action: run_command
|
||||
params:
|
||||
command: [inkscape, --export-filename, ${output}, input.svg]
|
||||
|
||||
The MacroRuntime also uses the RecoveryBackend automatically when a step
|
||||
specifies retry_max > 0 in the macro definition.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
from cli_anything.openclaw.backends.base import Backend, BackendContext, StepResult
|
||||
from cli_anything.openclaw.core.macro_model import MacroStep, substitute
|
||||
|
||||
|
||||
class RecoveryBackend(Backend):
|
||||
"""Retry and fallback orchestration backend."""
|
||||
|
||||
name = "recovery"
|
||||
priority = 10 # lowest — last resort
|
||||
|
||||
def __init__(self, backends: dict[str, "Backend"] | None = None):
|
||||
"""
|
||||
Args:
|
||||
backends: Dict of backend_name -> Backend instance.
|
||||
Injected by the RoutingEngine at runtime.
|
||||
"""
|
||||
self._backends = backends or {}
|
||||
|
||||
def register_backend(self, backend: "Backend") -> None:
|
||||
self._backends[backend.name] = backend
|
||||
|
||||
def execute(self, step: MacroStep, params: dict, context: BackendContext) -> StepResult:
|
||||
t0 = time.time()
|
||||
action = step.action
|
||||
|
||||
if action not in ("retry", "retry_with_fallback"):
|
||||
return StepResult(
|
||||
success=False,
|
||||
error=f"RecoveryBackend: unknown action '{action}'. "
|
||||
"Use 'retry' or 'retry_with_fallback'.",
|
||||
backend_used=self.name,
|
||||
duration_ms=(time.time() - t0) * 1000,
|
||||
)
|
||||
|
||||
step_params = substitute(step.params, params)
|
||||
inner_step_raw = step_params.get("step", {})
|
||||
if not inner_step_raw:
|
||||
return StepResult(
|
||||
success=False,
|
||||
error="RecoveryBackend: 'step' param is required.",
|
||||
backend_used=self.name,
|
||||
duration_ms=(time.time() - t0) * 1000,
|
||||
)
|
||||
|
||||
# Build an inner MacroStep from the nested definition
|
||||
inner_step = MacroStep(
|
||||
id=inner_step_raw.get("id", "recovery_inner"),
|
||||
backend=step_params.get("primary_backend", inner_step_raw.get("backend", "native_api")),
|
||||
action=inner_step_raw.get("action", ""),
|
||||
params=inner_step_raw.get("params", {}),
|
||||
timeout_ms=context.timeout_ms,
|
||||
)
|
||||
|
||||
max_retries: int = int(step_params.get("max_retries", step.retry_max or 2))
|
||||
backoff_ms: list[int] = step_params.get("backoff_ms", [1000, 2000, 5000])
|
||||
fallback_name: str = step_params.get("fallback_backend", "")
|
||||
|
||||
last_result = None
|
||||
for attempt in range(max_retries + 1):
|
||||
backend = self._backends.get(inner_step.backend)
|
||||
if backend is None:
|
||||
return StepResult(
|
||||
success=False,
|
||||
error=f"RecoveryBackend: backend '{inner_step.backend}' not registered.",
|
||||
backend_used=self.name,
|
||||
duration_ms=(time.time() - t0) * 1000,
|
||||
)
|
||||
|
||||
last_result = backend.execute(inner_step, params, context)
|
||||
if last_result.success:
|
||||
last_result.backend_used = f"{self.name}({inner_step.backend}, attempt={attempt + 1})"
|
||||
return last_result
|
||||
|
||||
# Failed — decide whether to retry or fall back
|
||||
if attempt < max_retries:
|
||||
wait = backoff_ms[min(attempt, len(backoff_ms) - 1)] / 1000.0
|
||||
time.sleep(wait)
|
||||
elif fallback_name and fallback_name in self._backends:
|
||||
# Switch to fallback backend for one final attempt
|
||||
inner_step = MacroStep(
|
||||
id=inner_step.id,
|
||||
backend=fallback_name,
|
||||
action=inner_step.action,
|
||||
params=inner_step.params,
|
||||
timeout_ms=inner_step.timeout_ms,
|
||||
)
|
||||
fallback_result = self._backends[fallback_name].execute(inner_step, params, context)
|
||||
if fallback_result.success:
|
||||
fallback_result.backend_used = f"{self.name}(fallback={fallback_name})"
|
||||
return fallback_result
|
||||
last_result = fallback_result
|
||||
|
||||
if last_result is None:
|
||||
last_result = StepResult(
|
||||
success=False,
|
||||
error="RecoveryBackend: no attempts made.",
|
||||
backend_used=self.name,
|
||||
)
|
||||
last_result.duration_ms = (time.time() - t0) * 1000
|
||||
last_result.backend_used = self.name
|
||||
return last_result
|
||||
@@ -0,0 +1,202 @@
|
||||
"""SemanticUIBackend — drive applications via accessibility and keyboard shortcuts.
|
||||
|
||||
This backend provides stubs for AT-SPI (Linux), Windows UIA, and macOS
|
||||
accessibility APIs. Full implementations require platform-specific libraries
|
||||
(pyatspi2, pywinauto, pyobjc-framework-Accessibility).
|
||||
|
||||
Example macro step:
|
||||
|
||||
- backend: semantic_ui
|
||||
action: menu_click
|
||||
params:
|
||||
menu_path: [File, Export As, PNG]
|
||||
|
||||
- backend: semantic_ui
|
||||
action: shortcut
|
||||
params:
|
||||
keys: ctrl+shift+e
|
||||
|
||||
- backend: semantic_ui
|
||||
action: wait_for_window
|
||||
params:
|
||||
title_contains: Export
|
||||
timeout_ms: 5000
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import platform
|
||||
import time
|
||||
|
||||
from cli_anything.openclaw.backends.base import Backend, BackendContext, StepResult
|
||||
from cli_anything.openclaw.core.macro_model import MacroStep, substitute
|
||||
|
||||
|
||||
class SemanticUIBackend(Backend):
|
||||
"""Drive applications through semantic (accessibility) controls."""
|
||||
|
||||
name = "semantic_ui"
|
||||
priority = 50
|
||||
|
||||
def execute(self, step: MacroStep, params: dict, context: BackendContext) -> StepResult:
|
||||
t0 = time.time()
|
||||
action = step.action
|
||||
step_params = substitute(step.params, params)
|
||||
|
||||
if context.dry_run:
|
||||
return StepResult(
|
||||
success=True,
|
||||
output={"dry_run": True, "action": action},
|
||||
backend_used=self.name,
|
||||
duration_ms=(time.time() - t0) * 1000,
|
||||
)
|
||||
|
||||
dispatch = {
|
||||
"shortcut": self._shortcut,
|
||||
"menu_click": self._menu_click,
|
||||
"wait_for_window": self._wait_for_window,
|
||||
"button_click": self._button_click,
|
||||
"type_text": self._type_text,
|
||||
}
|
||||
|
||||
handler = dispatch.get(action)
|
||||
if handler is None:
|
||||
return StepResult(
|
||||
success=False,
|
||||
error=f"SemanticUIBackend: unknown action '{action}'.",
|
||||
backend_used=self.name,
|
||||
duration_ms=(time.time() - t0) * 1000,
|
||||
)
|
||||
|
||||
try:
|
||||
output = handler(step_params)
|
||||
return StepResult(
|
||||
success=True,
|
||||
output=output or {},
|
||||
backend_used=self.name,
|
||||
duration_ms=(time.time() - t0) * 1000,
|
||||
)
|
||||
except NotImplementedError as exc:
|
||||
return StepResult(
|
||||
success=False,
|
||||
error=str(exc),
|
||||
backend_used=self.name,
|
||||
duration_ms=(time.time() - t0) * 1000,
|
||||
)
|
||||
except Exception as exc:
|
||||
return StepResult(
|
||||
success=False,
|
||||
error=f"SemanticUIBackend.{action}: {exc}",
|
||||
backend_used=self.name,
|
||||
duration_ms=(time.time() - t0) * 1000,
|
||||
)
|
||||
|
||||
def is_available(self) -> bool:
|
||||
# Available when at least one accessibility library can be found
|
||||
if platform.system() == "Linux":
|
||||
try:
|
||||
import pyatspi # noqa: F401
|
||||
return True
|
||||
except ImportError:
|
||||
pass
|
||||
elif platform.system() == "Windows":
|
||||
try:
|
||||
import pywinauto # noqa: F401
|
||||
return True
|
||||
except ImportError:
|
||||
pass
|
||||
elif platform.system() == "Darwin":
|
||||
try:
|
||||
import ApplicationServices # noqa: F401
|
||||
return True
|
||||
except ImportError:
|
||||
pass
|
||||
return False
|
||||
|
||||
# ── Actions ──────────────────────────────────────────────────────────
|
||||
|
||||
def _shortcut(self, p: dict) -> dict:
|
||||
"""Send a keyboard shortcut to the focused window."""
|
||||
keys: str = p.get("keys", "")
|
||||
if not keys:
|
||||
raise ValueError("shortcut action requires 'keys' param.")
|
||||
|
||||
sys = platform.system()
|
||||
if sys == "Linux":
|
||||
return self._shortcut_xdotool(keys)
|
||||
elif sys == "Windows":
|
||||
return self._shortcut_win32(keys)
|
||||
elif sys == "Darwin":
|
||||
return self._shortcut_macos(keys)
|
||||
raise NotImplementedError(
|
||||
f"SemanticUIBackend.shortcut: not yet implemented for platform {sys}. "
|
||||
"Consider using native_api backend instead."
|
||||
)
|
||||
|
||||
def _shortcut_xdotool(self, keys: str) -> dict:
|
||||
"""Use xdotool to send keys on Linux."""
|
||||
import shutil
|
||||
if not shutil.which("xdotool"):
|
||||
raise NotImplementedError(
|
||||
"xdotool not found. Install with: apt install xdotool"
|
||||
)
|
||||
import subprocess
|
||||
# Convert from ctrl+shift+e → ctrl+shift+e (xdotool format)
|
||||
xdg_keys = keys.replace("+", " ")
|
||||
subprocess.run(["xdotool", "key", xdg_keys], check=True)
|
||||
return {"keys": keys, "method": "xdotool"}
|
||||
|
||||
def _shortcut_win32(self, keys: str) -> dict:
|
||||
raise NotImplementedError(
|
||||
"SemanticUIBackend.shortcut: Windows requires pywinauto. "
|
||||
"pip install pywinauto"
|
||||
)
|
||||
|
||||
def _shortcut_macos(self, keys: str) -> dict:
|
||||
raise NotImplementedError(
|
||||
"SemanticUIBackend.shortcut: macOS requires pyobjc. "
|
||||
"pip install pyobjc-framework-Quartz"
|
||||
)
|
||||
|
||||
def _menu_click(self, p: dict) -> dict:
|
||||
"""Click a menu item by path."""
|
||||
menu_path: list = p.get("menu_path", [])
|
||||
raise NotImplementedError(
|
||||
f"SemanticUIBackend.menu_click: not yet implemented. "
|
||||
f"Menu path: {menu_path}. "
|
||||
"Use native_api or file_transform backends instead when possible."
|
||||
)
|
||||
|
||||
def _wait_for_window(self, p: dict) -> dict:
|
||||
"""Wait for a window matching title criteria."""
|
||||
title_contains: str = p.get("title_contains", "")
|
||||
timeout_ms: int = int(p.get("timeout_ms", 5000))
|
||||
raise NotImplementedError(
|
||||
f"SemanticUIBackend.wait_for_window: not yet implemented. "
|
||||
f"Looking for: '{title_contains}'. "
|
||||
"Consider using postconditions (file_exists) to detect completion."
|
||||
)
|
||||
|
||||
def _button_click(self, p: dict) -> dict:
|
||||
"""Click a UI button by label."""
|
||||
raise NotImplementedError(
|
||||
f"SemanticUIBackend.button_click: not yet implemented. "
|
||||
"Use native_api or file_transform backends instead when possible."
|
||||
)
|
||||
|
||||
def _type_text(self, p: dict) -> dict:
|
||||
"""Type text into the focused input field."""
|
||||
text: str = p.get("text", "")
|
||||
if not text:
|
||||
raise ValueError("type_text action requires 'text' param.")
|
||||
sys = platform.system()
|
||||
if sys == "Linux":
|
||||
import shutil
|
||||
if not shutil.which("xdotool"):
|
||||
raise NotImplementedError("xdotool not found. apt install xdotool")
|
||||
import subprocess
|
||||
subprocess.run(["xdotool", "type", "--clearmodifiers", text], check=True)
|
||||
return {"text": text, "method": "xdotool"}
|
||||
raise NotImplementedError(
|
||||
f"SemanticUIBackend.type_text: not yet implemented for {sys}."
|
||||
)
|
||||
@@ -0,0 +1,342 @@
|
||||
"""Macro data model — parse and validate YAML macro definitions.
|
||||
|
||||
A macro definition file (YAML) describes a reusable, parameterized workflow
|
||||
that the MacroRuntime can execute against any backend.
|
||||
|
||||
Example (minimal):
|
||||
|
||||
name: export_file
|
||||
version: "1.0"
|
||||
description: Export a file using the target app's CLI.
|
||||
|
||||
parameters:
|
||||
output:
|
||||
type: string
|
||||
required: true
|
||||
example: /tmp/out.png
|
||||
|
||||
steps:
|
||||
- backend: native_api
|
||||
action: run_command
|
||||
params:
|
||||
command: [echo, "exported", "${output}"]
|
||||
|
||||
postconditions:
|
||||
- file_exists: ${output}
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
try:
|
||||
import yaml
|
||||
except ImportError as e:
|
||||
raise ImportError("PyYAML is required: pip install PyYAML") from e
|
||||
|
||||
|
||||
# ── Dataclasses ──────────────────────────────────────────────────────────────
|
||||
|
||||
@dataclass
|
||||
class MacroParameter:
|
||||
name: str
|
||||
type: str = "string" # string | integer | boolean | list | dict
|
||||
required: bool = False
|
||||
default: Any = None
|
||||
description: str = ""
|
||||
example: Any = None
|
||||
enum: Optional[list] = None
|
||||
min: Optional[float] = None
|
||||
max: Optional[float] = None
|
||||
|
||||
def validate_value(self, value: Any) -> list[str]:
|
||||
"""Return list of validation error strings (empty if valid)."""
|
||||
errors: list[str] = []
|
||||
if value is None:
|
||||
if self.required:
|
||||
errors.append(f"Parameter '{self.name}' is required.")
|
||||
return errors
|
||||
|
||||
if self.type == "integer":
|
||||
if not isinstance(value, int):
|
||||
try:
|
||||
value = int(value)
|
||||
except (ValueError, TypeError):
|
||||
errors.append(f"Parameter '{self.name}' must be an integer.")
|
||||
return errors
|
||||
if self.min is not None and value < self.min:
|
||||
errors.append(f"Parameter '{self.name}' must be >= {self.min}.")
|
||||
if self.max is not None and value > self.max:
|
||||
errors.append(f"Parameter '{self.name}' must be <= {self.max}.")
|
||||
|
||||
if self.enum and value not in self.enum:
|
||||
errors.append(
|
||||
f"Parameter '{self.name}' must be one of {self.enum}, got {value!r}."
|
||||
)
|
||||
return errors
|
||||
|
||||
|
||||
@dataclass
|
||||
class MacroStep:
|
||||
backend: str # native_api | file_transform | semantic_ui | gui_macro | recovery
|
||||
action: str # backend-specific action name
|
||||
id: str = ""
|
||||
params: dict = field(default_factory=dict)
|
||||
timeout_ms: int = 30_000
|
||||
on_failure: str = "fail" # fail | skip | continue
|
||||
retry_max: int = 0
|
||||
retry_backoff_ms: list[int] = field(default_factory=lambda: [1000])
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"id": self.id,
|
||||
"backend": self.backend,
|
||||
"action": self.action,
|
||||
"params": self.params,
|
||||
"timeout_ms": self.timeout_ms,
|
||||
"on_failure": self.on_failure,
|
||||
"retry_max": self.retry_max,
|
||||
"retry_backoff_ms": self.retry_backoff_ms,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class MacroCondition:
|
||||
"""A single pre- or post-condition check.
|
||||
|
||||
Supported types (derived from the YAML key):
|
||||
file_exists: <path>
|
||||
file_size_gt: [<path>, <bytes>]
|
||||
process_running: <name>
|
||||
env_var: <name>
|
||||
always: true | false
|
||||
"""
|
||||
type: str
|
||||
args: Any # depends on type
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {"type": self.type, "args": self.args}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d: dict) -> "MacroCondition":
|
||||
"""Parse a condition dict like {file_exists: /tmp/out.png}."""
|
||||
if not isinstance(d, dict) or len(d) != 1:
|
||||
raise ValueError(f"Condition must be a single-key dict, got: {d!r}")
|
||||
ctype, args = next(iter(d.items()))
|
||||
return cls(type=ctype, args=args)
|
||||
|
||||
|
||||
@dataclass
|
||||
class MacroOutput:
|
||||
name: str
|
||||
description: str = ""
|
||||
path: Optional[str] = None # raw template string (may contain ${...})
|
||||
value: Optional[Any] = None
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"name": self.name,
|
||||
"description": self.description,
|
||||
"path": self.path,
|
||||
"value": self.value,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class MacroDefinition:
|
||||
name: str
|
||||
version: str = "1.0"
|
||||
description: str = ""
|
||||
parameters: dict[str, MacroParameter] = field(default_factory=dict)
|
||||
preconditions: list[MacroCondition] = field(default_factory=list)
|
||||
steps: list[MacroStep] = field(default_factory=list)
|
||||
postconditions: list[MacroCondition] = field(default_factory=list)
|
||||
outputs: list[MacroOutput] = field(default_factory=list)
|
||||
tags: list[str] = field(default_factory=list)
|
||||
composable: bool = False
|
||||
agent_hints: dict = field(default_factory=dict)
|
||||
source_path: str = "" # absolute path to the .yaml file
|
||||
|
||||
# ── Validation ────────────────────────────────────────────────────
|
||||
|
||||
def validate(self) -> list[str]:
|
||||
"""Structural validation — returns list of error strings."""
|
||||
errors: list[str] = []
|
||||
if not self.name:
|
||||
errors.append("Macro name is required.")
|
||||
if not self.steps:
|
||||
errors.append(f"Macro '{self.name}' has no steps.")
|
||||
valid_backends = {"native_api", "file_transform", "semantic_ui", "gui_macro", "recovery"}
|
||||
for i, step in enumerate(self.steps):
|
||||
if step.backend not in valid_backends:
|
||||
errors.append(
|
||||
f"Step {i} has unknown backend '{step.backend}'. "
|
||||
f"Valid: {sorted(valid_backends)}"
|
||||
)
|
||||
if not step.action:
|
||||
errors.append(f"Step {i} (backend={step.backend}) is missing 'action'.")
|
||||
for pname, pspec in self.parameters.items():
|
||||
if pspec.type not in ("string", "integer", "boolean", "list", "dict", "float"):
|
||||
errors.append(f"Parameter '{pname}' has unknown type '{pspec.type}'.")
|
||||
return errors
|
||||
|
||||
def validate_params(self, params: dict) -> list[str]:
|
||||
"""Validate runtime parameter values against schema."""
|
||||
errors: list[str] = []
|
||||
for pname, pspec in self.parameters.items():
|
||||
value = params.get(pname, pspec.default)
|
||||
errors.extend(pspec.validate_value(value))
|
||||
return errors
|
||||
|
||||
def resolve_params(self, params: dict) -> dict:
|
||||
"""Return params with defaults filled in."""
|
||||
resolved = {}
|
||||
for pname, pspec in self.parameters.items():
|
||||
resolved[pname] = params.get(pname, pspec.default)
|
||||
# Pass through any extra params not in schema
|
||||
for k, v in params.items():
|
||||
if k not in resolved:
|
||||
resolved[k] = v
|
||||
return resolved
|
||||
|
||||
# ── Serialisation ─────────────────────────────────────────────────
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"name": self.name,
|
||||
"version": self.version,
|
||||
"description": self.description,
|
||||
"parameters": {
|
||||
n: {
|
||||
"type": p.type,
|
||||
"required": p.required,
|
||||
"default": p.default,
|
||||
"description": p.description,
|
||||
"example": p.example,
|
||||
"enum": p.enum,
|
||||
}
|
||||
for n, p in self.parameters.items()
|
||||
},
|
||||
"preconditions": [c.to_dict() for c in self.preconditions],
|
||||
"steps": [s.to_dict() for s in self.steps],
|
||||
"postconditions": [c.to_dict() for c in self.postconditions],
|
||||
"outputs": [o.to_dict() for o in self.outputs],
|
||||
"tags": self.tags,
|
||||
"composable": self.composable,
|
||||
"agent_hints": self.agent_hints,
|
||||
"source_path": self.source_path,
|
||||
}
|
||||
|
||||
|
||||
# ── Substitution ─────────────────────────────────────────────────────────────
|
||||
|
||||
_SUBST_RE = re.compile(r"\$\{([^}]+)\}")
|
||||
|
||||
|
||||
def substitute(template: Any, params: dict) -> Any:
|
||||
"""Replace ${key} placeholders in strings (and nested structures).
|
||||
|
||||
Works recursively on strings, lists, and dicts.
|
||||
Leaves non-string types (int, bool, None) untouched.
|
||||
"""
|
||||
if isinstance(template, str):
|
||||
def _replace(m: re.Match) -> str:
|
||||
key = m.group(1).strip()
|
||||
val = params.get(key)
|
||||
return str(val) if val is not None else m.group(0)
|
||||
return _SUBST_RE.sub(_replace, template)
|
||||
if isinstance(template, list):
|
||||
return [substitute(item, params) for item in template]
|
||||
if isinstance(template, dict):
|
||||
return {k: substitute(v, params) for k, v in template.items()}
|
||||
return template
|
||||
|
||||
|
||||
# ── YAML loader ───────────────────────────────────────────────────────────────
|
||||
|
||||
def _parse_parameter(name: str, raw: dict) -> MacroParameter:
|
||||
return MacroParameter(
|
||||
name=name,
|
||||
type=raw.get("type", "string"),
|
||||
required=raw.get("required", False),
|
||||
default=raw.get("default"),
|
||||
description=raw.get("description", ""),
|
||||
example=raw.get("example"),
|
||||
enum=raw.get("enum"),
|
||||
min=raw.get("min"),
|
||||
max=raw.get("max"),
|
||||
)
|
||||
|
||||
|
||||
def _parse_step(i: int, raw: dict) -> MacroStep:
|
||||
retry = raw.get("retry_policy", {}) or {}
|
||||
return MacroStep(
|
||||
id=raw.get("id", f"step_{i}"),
|
||||
backend=raw.get("backend", "native_api"),
|
||||
action=raw.get("action", ""),
|
||||
params=raw.get("params", {}),
|
||||
timeout_ms=int(raw.get("timeout_ms", raw.get("timeout", "30s")
|
||||
.replace("s", "000") if isinstance(raw.get("timeout"), str)
|
||||
else raw.get("timeout_ms", 30_000))),
|
||||
on_failure=raw.get("on_failure", "fail"),
|
||||
retry_max=retry.get("max_retries", raw.get("retry_max", 0)),
|
||||
retry_backoff_ms=retry.get("backoff_ms", [1000]),
|
||||
)
|
||||
|
||||
|
||||
def _parse_condition(raw: Any) -> MacroCondition:
|
||||
if isinstance(raw, dict):
|
||||
return MacroCondition.from_dict(raw)
|
||||
raise ValueError(f"Cannot parse condition from {raw!r}")
|
||||
|
||||
|
||||
def _parse_output(raw: dict) -> MacroOutput:
|
||||
return MacroOutput(
|
||||
name=raw.get("name", ""),
|
||||
description=raw.get("description", ""),
|
||||
path=raw.get("path"),
|
||||
value=raw.get("value"),
|
||||
)
|
||||
|
||||
|
||||
def load_from_yaml(path: str) -> MacroDefinition:
|
||||
"""Load and parse a macro definition from a YAML file."""
|
||||
p = Path(path)
|
||||
if not p.is_file():
|
||||
raise FileNotFoundError(f"Macro file not found: {path}")
|
||||
with open(p, encoding="utf-8") as f:
|
||||
raw = yaml.safe_load(f)
|
||||
if not isinstance(raw, dict):
|
||||
raise ValueError(f"Macro YAML must be a mapping, got {type(raw).__name__}: {path}")
|
||||
|
||||
parameters: dict[str, MacroParameter] = {}
|
||||
for pname, praw in (raw.get("parameters") or {}).items():
|
||||
if isinstance(praw, dict):
|
||||
parameters[pname] = _parse_parameter(pname, praw)
|
||||
else:
|
||||
# shorthand: parameter_name: string
|
||||
parameters[pname] = MacroParameter(name=pname, type=str(praw))
|
||||
|
||||
steps = [_parse_step(i, s) for i, s in enumerate(raw.get("steps") or [])]
|
||||
preconditions = [_parse_condition(c) for c in (raw.get("preconditions") or [])]
|
||||
postconditions = [_parse_condition(c) for c in (raw.get("postconditions") or [])]
|
||||
outputs = [_parse_output(o) for o in (raw.get("outputs") or [])]
|
||||
|
||||
macro = MacroDefinition(
|
||||
name=raw.get("name", p.stem),
|
||||
version=str(raw.get("version", "1.0")),
|
||||
description=raw.get("description", ""),
|
||||
parameters=parameters,
|
||||
preconditions=preconditions,
|
||||
steps=steps,
|
||||
postconditions=postconditions,
|
||||
outputs=outputs,
|
||||
tags=raw.get("tags", []),
|
||||
composable=raw.get("composable", False),
|
||||
agent_hints=raw.get("agent_hints", {}),
|
||||
source_path=str(p.resolve()),
|
||||
)
|
||||
return macro
|
||||
@@ -0,0 +1,164 @@
|
||||
"""MacroRegistry — discovers and loads macro definitions from a directory.
|
||||
|
||||
The registry scans a macros/ directory (and subdirectories) for *.yaml files,
|
||||
optionally guided by a manifest.yaml index.
|
||||
|
||||
Usage:
|
||||
from cli_anything.openclaw.core.registry import MacroRegistry
|
||||
registry = MacroRegistry("/path/to/macros")
|
||||
macro = registry.load("export_file")
|
||||
all_macros = registry.list_all()
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
try:
|
||||
import yaml
|
||||
except ImportError as e:
|
||||
raise ImportError("PyYAML is required: pip install PyYAML") from e
|
||||
|
||||
from cli_anything.openclaw.core.macro_model import MacroDefinition, load_from_yaml
|
||||
|
||||
|
||||
class MacroRegistry:
|
||||
"""Discovers and caches macro definitions from a macros/ directory."""
|
||||
|
||||
def __init__(self, macros_dir: Optional[str] = None):
|
||||
"""
|
||||
Args:
|
||||
macros_dir: Path to the directory containing macro YAML files.
|
||||
Defaults to the macros/ directory bundled with the package.
|
||||
"""
|
||||
if macros_dir is None:
|
||||
macros_dir = str(Path(__file__).resolve().parent.parent / "macro_definitions")
|
||||
self.macros_dir = Path(macros_dir)
|
||||
self._cache: dict[str, MacroDefinition] = {}
|
||||
self._scanned = False
|
||||
|
||||
# ── Internal scan ────────────────────────────────────────────────────
|
||||
|
||||
def _scan(self) -> None:
|
||||
"""Scan macros_dir and populate the cache."""
|
||||
if self._scanned:
|
||||
return
|
||||
|
||||
if not self.macros_dir.is_dir():
|
||||
self._scanned = True
|
||||
return
|
||||
|
||||
# Try manifest.yaml first (explicit ordered index)
|
||||
manifest_path = self.macros_dir / "manifest.yaml"
|
||||
if manifest_path.is_file():
|
||||
self._load_from_manifest(manifest_path)
|
||||
else:
|
||||
# Fallback: scan all *.yaml files recursively (except manifest.yaml)
|
||||
for yaml_path in sorted(self.macros_dir.rglob("*.yaml")):
|
||||
if yaml_path.name == "manifest.yaml":
|
||||
continue
|
||||
self._load_file(yaml_path)
|
||||
|
||||
self._scanned = True
|
||||
|
||||
def _load_from_manifest(self, manifest_path: Path) -> None:
|
||||
"""Load macros listed in manifest.yaml."""
|
||||
with open(manifest_path, encoding="utf-8") as f:
|
||||
manifest = yaml.safe_load(f) or {}
|
||||
|
||||
macros_list = manifest.get("macros", [])
|
||||
for entry in macros_list:
|
||||
if isinstance(entry, dict):
|
||||
rel_path = entry.get("path")
|
||||
else:
|
||||
rel_path = str(entry)
|
||||
if not rel_path:
|
||||
continue
|
||||
yaml_path = self.macros_dir / rel_path
|
||||
if yaml_path.is_file():
|
||||
self._load_file(yaml_path)
|
||||
|
||||
# Also scan for any yaml files NOT in the manifest (permissive)
|
||||
listed_names = {m.name for m in self._cache.values()}
|
||||
for yaml_path in sorted(self.macros_dir.rglob("*.yaml")):
|
||||
if yaml_path.name == "manifest.yaml":
|
||||
continue
|
||||
try:
|
||||
# Quick peek to get the name without full parse
|
||||
with open(yaml_path, encoding="utf-8") as f:
|
||||
raw = yaml.safe_load(f) or {}
|
||||
name = raw.get("name", yaml_path.stem)
|
||||
if name not in listed_names:
|
||||
self._load_file(yaml_path)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _load_file(self, yaml_path: Path) -> Optional[MacroDefinition]:
|
||||
"""Parse one yaml file and cache the result."""
|
||||
try:
|
||||
macro = load_from_yaml(str(yaml_path))
|
||||
self._cache[macro.name] = macro
|
||||
return macro
|
||||
except Exception as exc:
|
||||
# Log but don't crash — bad macros should not block the registry
|
||||
import sys
|
||||
print(f"[registry] Warning: failed to load {yaml_path}: {exc}", file=sys.stderr)
|
||||
return None
|
||||
|
||||
# ── Public API ───────────────────────────────────────────────────────
|
||||
|
||||
def load(self, name: str) -> MacroDefinition:
|
||||
"""Load a macro by name.
|
||||
|
||||
Raises:
|
||||
KeyError: if the macro is not found.
|
||||
"""
|
||||
self._scan()
|
||||
if name not in self._cache:
|
||||
available = sorted(self._cache.keys())
|
||||
raise KeyError(
|
||||
f"Macro '{name}' not found. Available: {available}"
|
||||
)
|
||||
return self._cache[name]
|
||||
|
||||
def list_all(self) -> list[MacroDefinition]:
|
||||
"""Return all loaded macro definitions, sorted by name."""
|
||||
self._scan()
|
||||
return sorted(self._cache.values(), key=lambda m: m.name)
|
||||
|
||||
def list_names(self) -> list[str]:
|
||||
"""Return all macro names, sorted."""
|
||||
self._scan()
|
||||
return sorted(self._cache.keys())
|
||||
|
||||
def reload(self, name: Optional[str] = None) -> None:
|
||||
"""Force reload from disk.
|
||||
|
||||
Args:
|
||||
name: If given, reload just that macro file.
|
||||
If None, rescan the entire directory.
|
||||
"""
|
||||
if name is None:
|
||||
self._cache.clear()
|
||||
self._scanned = False
|
||||
self._scan()
|
||||
elif name in self._cache:
|
||||
path = self._cache[name].source_path
|
||||
if path and Path(path).is_file():
|
||||
self._load_file(Path(path))
|
||||
|
||||
def register(self, macro: MacroDefinition) -> None:
|
||||
"""Programmatically register a macro (e.g. from tests)."""
|
||||
self._cache[macro.name] = macro
|
||||
self._scanned = True # Don't re-scan over in-memory registrations
|
||||
|
||||
def info(self) -> dict:
|
||||
"""Return registry metadata."""
|
||||
self._scan()
|
||||
return {
|
||||
"macros_dir": str(self.macros_dir),
|
||||
"total": len(self._cache),
|
||||
"names": self.list_names(),
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
"""RoutingEngine — select the best backend for each macro step.
|
||||
|
||||
Priority order (higher = preferred):
|
||||
native_api 100
|
||||
gui_macro 80
|
||||
file_transform 70
|
||||
semantic_ui 50
|
||||
recovery 10 (only via explicit backend: recovery, or auto-retry)
|
||||
|
||||
The router respects the step's explicit `backend:` field.
|
||||
It then checks availability; if the primary is unavailable it walks down
|
||||
the priority list.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from cli_anything.openclaw.backends.base import Backend, BackendContext, StepResult
|
||||
from cli_anything.openclaw.backends.native_api import NativeAPIBackend
|
||||
from cli_anything.openclaw.backends.file_transform import FileTransformBackend
|
||||
from cli_anything.openclaw.backends.semantic_ui import SemanticUIBackend
|
||||
from cli_anything.openclaw.backends.gui_macro import GUIMacroBackend
|
||||
from cli_anything.openclaw.backends.recovery import RecoveryBackend
|
||||
from cli_anything.openclaw.core.macro_model import MacroStep
|
||||
|
||||
|
||||
_BACKEND_PRIORITY: dict[str, int] = {
|
||||
"native_api": 100,
|
||||
"gui_macro": 80,
|
||||
"file_transform": 70,
|
||||
"semantic_ui": 50,
|
||||
"recovery": 10,
|
||||
}
|
||||
|
||||
|
||||
class RoutingEngine:
|
||||
"""Selects and manages execution backends for macro steps."""
|
||||
|
||||
def __init__(self):
|
||||
self._recovery = RecoveryBackend()
|
||||
self._backends: dict[str, Backend] = {
|
||||
"native_api": NativeAPIBackend(),
|
||||
"file_transform": FileTransformBackend(),
|
||||
"semantic_ui": SemanticUIBackend(),
|
||||
"gui_macro": GUIMacroBackend(),
|
||||
"recovery": self._recovery,
|
||||
}
|
||||
# Wire recovery with all other backends so it can delegate
|
||||
for b in self._backends.values():
|
||||
self._recovery.register_backend(b)
|
||||
|
||||
def select(self, step: MacroStep) -> Backend:
|
||||
"""Return the best available backend for the given step.
|
||||
|
||||
Respects step.backend if set; falls back down the priority list
|
||||
if that backend is unavailable.
|
||||
|
||||
Raises:
|
||||
RuntimeError: if no backend is available.
|
||||
"""
|
||||
requested = step.backend
|
||||
# Try the requested backend first
|
||||
if requested in self._backends:
|
||||
b = self._backends[requested]
|
||||
if b.is_available():
|
||||
return b
|
||||
|
||||
# Walk by priority (descending) for a fallback
|
||||
for name in sorted(_BACKEND_PRIORITY, key=lambda k: -_BACKEND_PRIORITY[k]):
|
||||
if name == "recovery":
|
||||
continue # Never auto-fall-through to recovery
|
||||
b = self._backends.get(name)
|
||||
if b and b.is_available():
|
||||
return b
|
||||
|
||||
raise RuntimeError(
|
||||
f"No backend available for step '{step.id}' "
|
||||
f"(requested: '{requested}'). "
|
||||
"Check that required tools are installed."
|
||||
)
|
||||
|
||||
def execute_step(
|
||||
self,
|
||||
step: MacroStep,
|
||||
params: dict,
|
||||
context: BackendContext,
|
||||
) -> StepResult:
|
||||
"""Route and execute a step, applying retry logic if configured."""
|
||||
backend = self.select(step)
|
||||
|
||||
if step.retry_max <= 0:
|
||||
return backend.execute(step, params, context)
|
||||
|
||||
# Retry with backoff
|
||||
last_result: StepResult | None = None
|
||||
backoff = step.retry_backoff_ms or [1000]
|
||||
for attempt in range(step.retry_max + 1):
|
||||
last_result = backend.execute(step, params, context)
|
||||
if last_result.success:
|
||||
return last_result
|
||||
if attempt < step.retry_max:
|
||||
import time
|
||||
wait = backoff[min(attempt, len(backoff) - 1)] / 1000.0
|
||||
time.sleep(wait)
|
||||
|
||||
assert last_result is not None
|
||||
return last_result
|
||||
|
||||
def describe(self) -> dict:
|
||||
"""Return a description of all registered backends and their status."""
|
||||
return {
|
||||
name: b.describe()
|
||||
for name, b in self._backends.items()
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
"""MacroRuntime — orchestrates the full macro execution lifecycle.
|
||||
|
||||
Lifecycle for execute(macro_name, params):
|
||||
|
||||
1. Load macro definition from registry
|
||||
2. Resolve + validate parameters (fill defaults, type-check)
|
||||
3. Check preconditions
|
||||
4. For each step:
|
||||
a. substitute ${params} into step.params
|
||||
b. route to backend
|
||||
c. execute (with retry if configured)
|
||||
d. handle on_failure = fail | skip | continue
|
||||
5. Check postconditions
|
||||
6. Collect declared outputs
|
||||
7. Record telemetry in session
|
||||
8. Return ExecutionResult
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Optional
|
||||
|
||||
from cli_anything.openclaw.core.macro_model import (
|
||||
MacroCondition,
|
||||
MacroDefinition,
|
||||
MacroStep,
|
||||
substitute,
|
||||
)
|
||||
from cli_anything.openclaw.core.registry import MacroRegistry
|
||||
from cli_anything.openclaw.core.routing import RoutingEngine
|
||||
from cli_anything.openclaw.core.session import ExecutionSession, RunRecord
|
||||
from cli_anything.openclaw.backends.base import BackendContext, StepResult
|
||||
|
||||
|
||||
# ── Result types ─────────────────────────────────────────────────────────────
|
||||
|
||||
@dataclass
|
||||
class ExecutionResult:
|
||||
success: bool
|
||||
macro_name: str
|
||||
output: dict = field(default_factory=dict)
|
||||
error: str = ""
|
||||
step_results: list[StepResult] = field(default_factory=list)
|
||||
telemetry: dict = field(default_factory=dict)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"success": self.success,
|
||||
"macro_name": self.macro_name,
|
||||
"output": self.output,
|
||||
"error": self.error,
|
||||
"telemetry": self.telemetry,
|
||||
"steps": [s.to_dict() for s in self.step_results],
|
||||
}
|
||||
|
||||
|
||||
# ── Condition checker ────────────────────────────────────────────────────────
|
||||
|
||||
def _check_condition(cond: MacroCondition, resolved_params: dict) -> Optional[str]:
|
||||
"""Evaluate one condition.
|
||||
|
||||
Returns None if the condition passes, or an error string if it fails.
|
||||
"""
|
||||
ctype = cond.type
|
||||
args = substitute(cond.args, resolved_params)
|
||||
|
||||
if ctype == "file_exists":
|
||||
path = str(args)
|
||||
if not os.path.exists(path):
|
||||
return f"file_exists: '{path}' not found."
|
||||
return None
|
||||
|
||||
elif ctype == "file_size_gt":
|
||||
if not isinstance(args, (list, tuple)) or len(args) < 2:
|
||||
return f"file_size_gt: expected [path, min_bytes], got {args!r}"
|
||||
path, min_bytes = str(args[0]), int(args[1])
|
||||
if not os.path.exists(path):
|
||||
return f"file_size_gt: '{path}' not found."
|
||||
size = os.path.getsize(path)
|
||||
if size <= min_bytes:
|
||||
return f"file_size_gt: '{path}' is {size} bytes, expected > {min_bytes}."
|
||||
return None
|
||||
|
||||
elif ctype == "process_running":
|
||||
name = str(args)
|
||||
# Try pgrep first, then psutil
|
||||
import shutil
|
||||
import subprocess
|
||||
if shutil.which("pgrep"):
|
||||
r = subprocess.run(["pgrep", "-x", name], capture_output=True)
|
||||
if r.returncode == 0:
|
||||
return None
|
||||
return f"process_running: '{name}' not found (pgrep)."
|
||||
try:
|
||||
import psutil
|
||||
for proc in psutil.process_iter(["name"]):
|
||||
if proc.info["name"] == name:
|
||||
return None
|
||||
return f"process_running: '{name}' not found."
|
||||
except ImportError:
|
||||
# Can't verify — let it pass with a warning
|
||||
return None
|
||||
|
||||
elif ctype == "env_var":
|
||||
name = str(args)
|
||||
if name not in os.environ:
|
||||
return f"env_var: '{name}' is not set in the environment."
|
||||
return None
|
||||
|
||||
elif ctype == "always":
|
||||
if str(args).lower() in ("false", "0", "no"):
|
||||
return "always: false condition."
|
||||
return None
|
||||
|
||||
else:
|
||||
# Unknown condition type — warn but don't block
|
||||
return None
|
||||
|
||||
|
||||
# ── Runtime ──────────────────────────────────────────────────────────────────
|
||||
|
||||
class MacroRuntime:
|
||||
"""Executes macros end-to-end."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
registry: Optional[MacroRegistry] = None,
|
||||
routing_engine: Optional[RoutingEngine] = None,
|
||||
session: Optional[ExecutionSession] = None,
|
||||
):
|
||||
self.registry = registry or MacroRegistry()
|
||||
self.routing = routing_engine or RoutingEngine()
|
||||
self.session = session or ExecutionSession()
|
||||
|
||||
# ── Public API ───────────────────────────────────────────────────────
|
||||
|
||||
def execute(
|
||||
self,
|
||||
macro_name: str,
|
||||
params: dict,
|
||||
dry_run: bool = False,
|
||||
) -> ExecutionResult:
|
||||
"""Execute a macro by name with the given parameters.
|
||||
|
||||
Args:
|
||||
macro_name: Name of the macro to execute.
|
||||
params: Input parameters (raw, may be strings from CLI).
|
||||
dry_run: If True, skip all side effects and return simulated success.
|
||||
|
||||
Returns:
|
||||
ExecutionResult with success status, outputs, and telemetry.
|
||||
"""
|
||||
t0 = time.time()
|
||||
|
||||
# 1. Load macro
|
||||
try:
|
||||
macro = self.registry.load(macro_name)
|
||||
except KeyError as exc:
|
||||
return ExecutionResult(
|
||||
success=False, macro_name=macro_name, error=str(exc)
|
||||
)
|
||||
|
||||
# 2. Resolve + validate params
|
||||
resolved = macro.resolve_params(params)
|
||||
param_errors = macro.validate_params(resolved)
|
||||
if param_errors:
|
||||
return ExecutionResult(
|
||||
success=False,
|
||||
macro_name=macro_name,
|
||||
error="Parameter validation failed:\n" + "\n".join(f" - {e}" for e in param_errors),
|
||||
)
|
||||
|
||||
# 3. Check preconditions
|
||||
precond_errors = self.check_conditions(macro.preconditions, resolved)
|
||||
if precond_errors:
|
||||
return ExecutionResult(
|
||||
success=False,
|
||||
macro_name=macro_name,
|
||||
error="Preconditions not met:\n" + "\n".join(f" - {e}" for e in precond_errors),
|
||||
)
|
||||
|
||||
# 4. Execute steps
|
||||
step_results: list[StepResult] = []
|
||||
context = BackendContext(
|
||||
params=resolved,
|
||||
previous_results=step_results,
|
||||
dry_run=dry_run,
|
||||
)
|
||||
|
||||
aborted = False
|
||||
abort_error = ""
|
||||
for step in macro.steps:
|
||||
context.timeout_ms = step.timeout_ms
|
||||
try:
|
||||
result = self.routing.execute_step(step, resolved, context)
|
||||
except Exception as exc:
|
||||
result = StepResult(
|
||||
success=False,
|
||||
error=f"Unhandled exception in step '{step.id}': {exc}",
|
||||
backend_used=step.backend,
|
||||
)
|
||||
|
||||
step_results.append(result)
|
||||
|
||||
if not result.success:
|
||||
if step.on_failure == "fail":
|
||||
aborted = True
|
||||
abort_error = f"Step '{step.id}' failed: {result.error}"
|
||||
break
|
||||
elif step.on_failure == "skip":
|
||||
continue
|
||||
# on_failure == "continue" — keep going regardless
|
||||
|
||||
# 5. Check postconditions (skip if already failed)
|
||||
postcond_errors: list[str] = []
|
||||
if not aborted:
|
||||
postcond_errors = self.check_conditions(macro.postconditions, resolved)
|
||||
|
||||
success = not aborted and not postcond_errors
|
||||
|
||||
# 6. Collect outputs
|
||||
output = self._collect_outputs(macro, resolved, step_results) if success else {}
|
||||
|
||||
# 7. Build error string
|
||||
error = ""
|
||||
if aborted:
|
||||
error = abort_error
|
||||
elif postcond_errors:
|
||||
error = "Postconditions failed:\n" + "\n".join(f" - {e}" for e in postcond_errors)
|
||||
|
||||
# 8. Telemetry
|
||||
duration_ms = (time.time() - t0) * 1000
|
||||
backends_used = list({r.backend_used for r in step_results if r.backend_used})
|
||||
telemetry = {
|
||||
"duration_ms": duration_ms,
|
||||
"steps_total": len(macro.steps),
|
||||
"steps_run": len(step_results),
|
||||
"backends_used": backends_used,
|
||||
"dry_run": dry_run,
|
||||
}
|
||||
|
||||
# 9. Record in session
|
||||
record = RunRecord(
|
||||
macro_name=macro_name,
|
||||
params=params,
|
||||
success=success,
|
||||
output=output,
|
||||
error=error,
|
||||
duration_ms=duration_ms,
|
||||
backends_used=backends_used,
|
||||
steps_run=len(step_results),
|
||||
)
|
||||
self.session.record(record)
|
||||
|
||||
return ExecutionResult(
|
||||
success=success,
|
||||
macro_name=macro_name,
|
||||
output=output,
|
||||
error=error,
|
||||
step_results=step_results,
|
||||
telemetry=telemetry,
|
||||
)
|
||||
|
||||
def check_conditions(
|
||||
self,
|
||||
conditions: list[MacroCondition],
|
||||
params: dict,
|
||||
) -> list[str]:
|
||||
"""Evaluate a list of conditions; return list of failure messages."""
|
||||
errors: list[str] = []
|
||||
for cond in conditions:
|
||||
err = _check_condition(cond, params)
|
||||
if err:
|
||||
errors.append(err)
|
||||
return errors
|
||||
|
||||
def validate_macro(self, macro_name: str) -> list[str]:
|
||||
"""Load and structurally validate a macro; return error list."""
|
||||
try:
|
||||
macro = self.registry.load(macro_name)
|
||||
except KeyError as exc:
|
||||
return [str(exc)]
|
||||
return macro.validate()
|
||||
|
||||
# ── Helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
def _collect_outputs(
|
||||
self,
|
||||
macro: MacroDefinition,
|
||||
params: dict,
|
||||
step_results: list[StepResult],
|
||||
) -> dict:
|
||||
"""Resolve declared macro outputs into a concrete dict."""
|
||||
out: dict[str, Any] = {}
|
||||
for output_spec in macro.outputs:
|
||||
name = output_spec.name
|
||||
if output_spec.path:
|
||||
out[name] = substitute(output_spec.path, params)
|
||||
elif output_spec.value is not None:
|
||||
out[name] = substitute(output_spec.value, params)
|
||||
# Always include combined step outputs under 'steps'
|
||||
out["_steps"] = [r.output for r in step_results]
|
||||
return out
|
||||
@@ -0,0 +1,186 @@
|
||||
"""ExecutionSession — tracks macro run history and telemetry.
|
||||
|
||||
Persists run records to ~/.openclaw-macro/sessions/ so agents can inspect
|
||||
what was run, what succeeded, and what the outputs were.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
SESSION_DIR = Path.home() / ".openclaw-macro" / "sessions"
|
||||
MAX_HISTORY = 200
|
||||
|
||||
|
||||
def _locked_save_json(path: str, data, **dump_kwargs) -> None:
|
||||
"""Atomically write JSON with exclusive file locking."""
|
||||
try:
|
||||
f = open(path, "r+")
|
||||
except FileNotFoundError:
|
||||
os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True)
|
||||
f = open(path, "w")
|
||||
with f:
|
||||
_locked = False
|
||||
try:
|
||||
import fcntl
|
||||
fcntl.flock(f.fileno(), fcntl.LOCK_EX)
|
||||
_locked = True
|
||||
except (ImportError, OSError):
|
||||
pass
|
||||
try:
|
||||
f.seek(0)
|
||||
f.truncate()
|
||||
json.dump(data, f, **dump_kwargs)
|
||||
f.flush()
|
||||
finally:
|
||||
if _locked:
|
||||
import fcntl
|
||||
fcntl.flock(f.fileno(), fcntl.LOCK_UN)
|
||||
|
||||
|
||||
class RunRecord:
|
||||
"""A single macro execution record."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
macro_name: str,
|
||||
params: dict,
|
||||
success: bool,
|
||||
output: dict,
|
||||
error: str,
|
||||
duration_ms: float,
|
||||
backends_used: list[str],
|
||||
steps_run: int,
|
||||
timestamp: Optional[float] = None,
|
||||
):
|
||||
self.macro_name = macro_name
|
||||
self.params = params
|
||||
self.success = success
|
||||
self.output = output
|
||||
self.error = error
|
||||
self.duration_ms = duration_ms
|
||||
self.backends_used = backends_used
|
||||
self.steps_run = steps_run
|
||||
self.timestamp = timestamp or time.time()
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"macro_name": self.macro_name,
|
||||
"params": self.params,
|
||||
"success": self.success,
|
||||
"output": self.output,
|
||||
"error": self.error,
|
||||
"duration_ms": self.duration_ms,
|
||||
"backends_used": self.backends_used,
|
||||
"steps_run": self.steps_run,
|
||||
"timestamp": self.timestamp,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d: dict) -> "RunRecord":
|
||||
return cls(
|
||||
macro_name=d.get("macro_name", ""),
|
||||
params=d.get("params", {}),
|
||||
success=d.get("success", False),
|
||||
output=d.get("output", {}),
|
||||
error=d.get("error", ""),
|
||||
duration_ms=d.get("duration_ms", 0),
|
||||
backends_used=d.get("backends_used", []),
|
||||
steps_run=d.get("steps_run", 0),
|
||||
timestamp=d.get("timestamp"),
|
||||
)
|
||||
|
||||
|
||||
class ExecutionSession:
|
||||
"""Tracks macro run history for the current session."""
|
||||
|
||||
def __init__(self, session_id: Optional[str] = None):
|
||||
self.session_id = session_id or f"session_{int(time.time())}"
|
||||
self._history: list[RunRecord] = []
|
||||
|
||||
# ── Record management ─────────────────────────────────────────────
|
||||
|
||||
def record(self, run: RunRecord) -> None:
|
||||
"""Add a run record to history."""
|
||||
self._history.append(run)
|
||||
if len(self._history) > MAX_HISTORY:
|
||||
self._history = self._history[-MAX_HISTORY:]
|
||||
|
||||
def last(self) -> Optional[RunRecord]:
|
||||
"""Return the most recent run record."""
|
||||
return self._history[-1] if self._history else None
|
||||
|
||||
def history(self, limit: int = 20) -> list[RunRecord]:
|
||||
"""Return recent run records, newest first."""
|
||||
return list(reversed(self._history[-limit:]))
|
||||
|
||||
def stats(self) -> dict:
|
||||
"""Return aggregate statistics for this session."""
|
||||
total = len(self._history)
|
||||
if total == 0:
|
||||
return {"total": 0, "success_rate": 0.0, "avg_duration_ms": 0.0}
|
||||
successes = sum(1 for r in self._history if r.success)
|
||||
avg_dur = sum(r.duration_ms for r in self._history) / total
|
||||
return {
|
||||
"total": total,
|
||||
"success": successes,
|
||||
"failure": total - successes,
|
||||
"success_rate": successes / total,
|
||||
"avg_duration_ms": avg_dur,
|
||||
}
|
||||
|
||||
def status(self) -> dict:
|
||||
return {
|
||||
"session_id": self.session_id,
|
||||
"runs": len(self._history),
|
||||
**self.stats(),
|
||||
}
|
||||
|
||||
# ── Persistence ───────────────────────────────────────────────────
|
||||
|
||||
def save(self) -> str:
|
||||
"""Persist session to disk. Returns the file path."""
|
||||
SESSION_DIR.mkdir(parents=True, exist_ok=True)
|
||||
path = str(SESSION_DIR / f"{self.session_id}.json")
|
||||
data = {
|
||||
"session_id": self.session_id,
|
||||
"timestamp": time.time(),
|
||||
"history": [r.to_dict() for r in self._history],
|
||||
}
|
||||
_locked_save_json(path, data, indent=2, sort_keys=True)
|
||||
return path
|
||||
|
||||
@classmethod
|
||||
def load(cls, session_id: str) -> Optional["ExecutionSession"]:
|
||||
"""Load a session from disk."""
|
||||
path = SESSION_DIR / f"{session_id}.json"
|
||||
if not path.is_file():
|
||||
return None
|
||||
with open(path, encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
session = cls(session_id=data.get("session_id", session_id))
|
||||
session._history = [RunRecord.from_dict(r) for r in data.get("history", [])]
|
||||
return session
|
||||
|
||||
@classmethod
|
||||
def list_sessions(cls) -> list[dict]:
|
||||
"""List all saved sessions (metadata only)."""
|
||||
SESSION_DIR.mkdir(parents=True, exist_ok=True)
|
||||
sessions = []
|
||||
for p in SESSION_DIR.glob("*.json"):
|
||||
try:
|
||||
with open(p, encoding="utf-8") as f:
|
||||
d = json.load(f)
|
||||
sessions.append({
|
||||
"session_id": d.get("session_id"),
|
||||
"timestamp": d.get("timestamp", 0),
|
||||
"runs": len(d.get("history", [])),
|
||||
})
|
||||
except Exception:
|
||||
continue
|
||||
sessions.sort(key=lambda s: s.get("timestamp", 0), reverse=True)
|
||||
return sessions
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
name: export_file
|
||||
version: "1.0"
|
||||
description: >
|
||||
Export a file from the target application using its native CLI.
|
||||
Demonstrates the native_api backend with a run_command action.
|
||||
Replace the command with your actual application's export command.
|
||||
tags: [export, native_api, example]
|
||||
|
||||
parameters:
|
||||
output:
|
||||
type: string
|
||||
required: true
|
||||
description: Destination file path for the export.
|
||||
example: /tmp/exported.txt
|
||||
input:
|
||||
type: string
|
||||
required: false
|
||||
default: ""
|
||||
description: Source file to export from (optional, app-dependent).
|
||||
format:
|
||||
type: string
|
||||
required: false
|
||||
default: plain
|
||||
description: Output format.
|
||||
enum: [plain, json, csv]
|
||||
|
||||
preconditions:
|
||||
- file_exists: /bin/echo # sanity check: a real macro would check the app binary
|
||||
|
||||
steps:
|
||||
- id: step_export
|
||||
backend: native_api
|
||||
action: run_command
|
||||
params:
|
||||
# Replace this with your real export command, e.g.:
|
||||
# command: [inkscape, --export-filename, "${output}", "${input}"]
|
||||
command: [echo, "Exported to ${output} (format=${format})"]
|
||||
capture_stdout: true
|
||||
timeout_ms: 30000
|
||||
on_failure: fail
|
||||
|
||||
- id: step_write_output
|
||||
backend: file_transform
|
||||
action: text_replace
|
||||
params:
|
||||
input_file: /dev/null
|
||||
output_file: ${output}
|
||||
find: ""
|
||||
replace: ""
|
||||
on_failure: skip # Writing the marker file is best-effort
|
||||
|
||||
postconditions: []
|
||||
|
||||
outputs:
|
||||
- name: exported_file
|
||||
path: ${output}
|
||||
description: Path where the export was written.
|
||||
|
||||
agent_hints:
|
||||
danger_level: safe
|
||||
side_effects: [creates_file]
|
||||
reversible: true
|
||||
estimated_duration_ms: 2000
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
name: transform_json
|
||||
version: "1.0"
|
||||
description: >
|
||||
Read a JSON file, set a nested key to a new value, and write it back.
|
||||
Demonstrates the file_transform backend with json_set action.
|
||||
tags: [json, file_transform, example]
|
||||
|
||||
parameters:
|
||||
file:
|
||||
type: string
|
||||
required: true
|
||||
description: Path to the JSON file to transform.
|
||||
example: /tmp/config.json
|
||||
key:
|
||||
type: string
|
||||
required: true
|
||||
description: Dot-separated key path to set (e.g. settings.theme).
|
||||
example: settings.theme
|
||||
value:
|
||||
type: string
|
||||
required: true
|
||||
description: Value to write at the key path.
|
||||
example: dark
|
||||
|
||||
preconditions:
|
||||
- file_exists: ${file}
|
||||
|
||||
steps:
|
||||
- id: step_set_key
|
||||
backend: file_transform
|
||||
action: json_set
|
||||
params:
|
||||
input_file: ${file}
|
||||
output_file: ${file}
|
||||
path: ${key}
|
||||
value: ${value}
|
||||
timeout_ms: 10000
|
||||
on_failure: fail
|
||||
|
||||
postconditions:
|
||||
- file_exists: ${file}
|
||||
|
||||
outputs:
|
||||
- name: modified_file
|
||||
path: ${file}
|
||||
description: Path to the modified JSON file.
|
||||
|
||||
agent_hints:
|
||||
danger_level: moderate
|
||||
side_effects: [modifies_file]
|
||||
reversible: false
|
||||
estimated_duration_ms: 100
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
name: undo_last
|
||||
version: "1.0"
|
||||
description: >
|
||||
Trigger an undo operation in the target application using a keyboard shortcut
|
||||
(Ctrl+Z). This macro demonstrates the semantic_ui backend.
|
||||
On Linux, requires xdotool. On macOS/Windows, adapt to your platform.
|
||||
tags: [undo, semantic_ui, example]
|
||||
|
||||
parameters:
|
||||
app_name:
|
||||
type: string
|
||||
required: false
|
||||
default: ""
|
||||
description: Name of the target application process (used to verify it is running).
|
||||
example: inkscape
|
||||
|
||||
preconditions:
|
||||
- always: true # process check is optional in this example
|
||||
|
||||
steps:
|
||||
- id: step_undo
|
||||
backend: semantic_ui
|
||||
action: shortcut
|
||||
params:
|
||||
keys: ctrl+z
|
||||
timeout_ms: 5000
|
||||
on_failure: fail
|
||||
|
||||
postconditions: []
|
||||
|
||||
outputs: []
|
||||
|
||||
agent_hints:
|
||||
danger_level: safe
|
||||
side_effects: [modifies_app_state]
|
||||
reversible: true # can be redone with ctrl+y
|
||||
estimated_duration_ms: 500
|
||||
@@ -0,0 +1,10 @@
|
||||
macros:
|
||||
- name: export_file
|
||||
path: examples/export_file.yaml
|
||||
version: "1.0"
|
||||
- name: undo_last
|
||||
path: examples/undo_last.yaml
|
||||
version: "1.0"
|
||||
- name: transform_json
|
||||
path: examples/transform_json.yaml
|
||||
version: "1.0"
|
||||
@@ -0,0 +1,572 @@
|
||||
#!/usr/bin/env python3
|
||||
"""OpenClaw CLI — agent-callable interface for the Macro System.
|
||||
|
||||
This CLI is the L6 "Unified CLI Entry" in the OpenClaw Macro System.
|
||||
It provides a stable, machine-readable interface for AI agents and
|
||||
power users to invoke macros without touching the GUI.
|
||||
|
||||
Usage (one-shot):
|
||||
cli-anything-openclaw macro run export_file --param output=/tmp/out.png --json
|
||||
cli-anything-openclaw macro list --json
|
||||
cli-anything-openclaw macro info export_file --json
|
||||
|
||||
Usage (REPL):
|
||||
cli-anything-openclaw # enters interactive REPL
|
||||
cli-anything-openclaw repl
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import json
|
||||
import click
|
||||
from typing import Optional
|
||||
|
||||
from cli_anything.openclaw.core.registry import MacroRegistry
|
||||
from cli_anything.openclaw.core.runtime import MacroRuntime
|
||||
from cli_anything.openclaw.core.session import ExecutionSession
|
||||
|
||||
# ── Global state ─────────────────────────────────────────────────────────────
|
||||
|
||||
_json_output = False
|
||||
_repl_mode = False
|
||||
_dry_run = False
|
||||
|
||||
_session: Optional[ExecutionSession] = None
|
||||
_runtime: Optional[MacroRuntime] = None
|
||||
|
||||
|
||||
def get_runtime() -> MacroRuntime:
|
||||
global _runtime, _session
|
||||
if _runtime is None:
|
||||
_session = _session or ExecutionSession()
|
||||
_runtime = MacroRuntime(session=_session)
|
||||
return _runtime
|
||||
|
||||
|
||||
def get_session() -> ExecutionSession:
|
||||
global _session
|
||||
if _session is None:
|
||||
_session = ExecutionSession()
|
||||
return _session
|
||||
|
||||
|
||||
# ── Output helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
def output(data, message: str = ""):
|
||||
"""Print result: JSON in --json mode, human-readable otherwise."""
|
||||
if _json_output:
|
||||
click.echo(json.dumps(data, indent=2, default=str))
|
||||
else:
|
||||
if message:
|
||||
click.echo(message)
|
||||
_print_value(data)
|
||||
|
||||
|
||||
def _print_value(val, indent: int = 0):
|
||||
prefix = " " * indent
|
||||
if isinstance(val, dict):
|
||||
for k, v in val.items():
|
||||
if isinstance(v, (dict, list)):
|
||||
click.echo(f"{prefix}{k}:")
|
||||
_print_value(v, indent + 1)
|
||||
else:
|
||||
click.echo(f"{prefix}{k}: {v}")
|
||||
elif isinstance(val, list):
|
||||
for i, item in enumerate(val):
|
||||
if isinstance(item, dict):
|
||||
click.echo(f"{prefix}[{i}]")
|
||||
_print_value(item, indent + 1)
|
||||
else:
|
||||
click.echo(f"{prefix}- {item}")
|
||||
else:
|
||||
click.echo(f"{prefix}{val}")
|
||||
|
||||
|
||||
def handle_error(func):
|
||||
"""Decorator: consistent error handling across commands."""
|
||||
import functools
|
||||
|
||||
@functools.wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
try:
|
||||
return func(*args, **kwargs)
|
||||
except KeyError as e:
|
||||
msg = str(e).strip("'\"")
|
||||
if _json_output:
|
||||
click.echo(json.dumps({"error": msg, "type": "not_found"}))
|
||||
else:
|
||||
click.echo(f"Error: {msg}", err=True)
|
||||
if not _repl_mode:
|
||||
sys.exit(1)
|
||||
except FileNotFoundError as e:
|
||||
if _json_output:
|
||||
click.echo(json.dumps({"error": str(e), "type": "file_not_found"}))
|
||||
else:
|
||||
click.echo(f"Error: {e}", err=True)
|
||||
if not _repl_mode:
|
||||
sys.exit(1)
|
||||
except Exception as e:
|
||||
if _json_output:
|
||||
click.echo(json.dumps({"error": str(e), "type": type(e).__name__}))
|
||||
else:
|
||||
click.echo(f"Error: {e}", err=True)
|
||||
if not _repl_mode:
|
||||
sys.exit(1)
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
# ── Parameter parsing ─────────────────────────────────────────────────────────
|
||||
|
||||
def _parse_params(param_tuples: tuple) -> dict:
|
||||
"""Convert --param key=value tuples to a dict."""
|
||||
result = {}
|
||||
for pair in param_tuples:
|
||||
if "=" in pair:
|
||||
k, v = pair.split("=", 1)
|
||||
result[k.strip()] = v.strip()
|
||||
else:
|
||||
click.echo(f"Warning: --param '{pair}' ignored (expected key=value format).", err=True)
|
||||
return result
|
||||
|
||||
|
||||
# ── Main CLI group ────────────────────────────────────────────────────────────
|
||||
|
||||
@click.group(invoke_without_command=True)
|
||||
@click.option("--json", "json_flag", is_flag=True, help="Machine-readable JSON output.")
|
||||
@click.option("--dry-run", "dry_run_flag", is_flag=True,
|
||||
help="Simulate execution without side effects.")
|
||||
@click.option("--session-id", default=None, help="Resume or create a named session.")
|
||||
@click.pass_context
|
||||
def cli(ctx, json_flag, dry_run_flag, session_id):
|
||||
"""OpenClaw Macro System — run GUI workflows as CLI commands.
|
||||
|
||||
\b
|
||||
Quick start:
|
||||
cli-anything-openclaw macro list
|
||||
cli-anything-openclaw macro info <name>
|
||||
cli-anything-openclaw macro run <name> --param key=value
|
||||
|
||||
Enter interactive REPL by running without arguments.
|
||||
"""
|
||||
global _json_output, _dry_run, _session
|
||||
|
||||
_json_output = json_flag
|
||||
_dry_run = dry_run_flag
|
||||
|
||||
if session_id:
|
||||
loaded = ExecutionSession.load(session_id)
|
||||
_session = loaded or ExecutionSession(session_id=session_id)
|
||||
|
||||
ctx.ensure_object(dict)
|
||||
|
||||
if ctx.invoked_subcommand is None:
|
||||
ctx.invoke(repl)
|
||||
|
||||
|
||||
# ── macro group ──────────────────────────────────────────────────────────────
|
||||
|
||||
@cli.group()
|
||||
def macro():
|
||||
"""Macro management and execution."""
|
||||
|
||||
|
||||
@macro.command("run")
|
||||
@click.argument("name")
|
||||
@click.option("--param", "-p", multiple=True,
|
||||
help="Macro parameter in key=value format. Repeat for multiple.")
|
||||
@handle_error
|
||||
def macro_run(name, param):
|
||||
"""Execute a macro by name.
|
||||
|
||||
\b
|
||||
Example:
|
||||
macro run export_file --param output=/tmp/out.txt
|
||||
macro run export_file -p output=/tmp/out.txt -p format=plain --json
|
||||
"""
|
||||
params = _parse_params(param)
|
||||
runtime = get_runtime()
|
||||
result = runtime.execute(name, params, dry_run=_dry_run)
|
||||
|
||||
if _json_output:
|
||||
output(result.to_dict())
|
||||
else:
|
||||
if result.success:
|
||||
click.echo(f"✓ Macro '{name}' completed successfully.")
|
||||
if result.output:
|
||||
for k, v in result.output.items():
|
||||
if not k.startswith("_"):
|
||||
click.echo(f" {k}: {v}")
|
||||
else:
|
||||
click.echo(f"✗ Macro '{name}' failed.", err=True)
|
||||
click.echo(f" {result.error}", err=True)
|
||||
if result.telemetry:
|
||||
click.echo(
|
||||
f" [{result.telemetry.get('duration_ms', 0):.0f}ms, "
|
||||
f"backends: {', '.join(result.telemetry.get('backends_used', []))}]"
|
||||
)
|
||||
if not result.success and not _repl_mode:
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
@macro.command("list")
|
||||
@handle_error
|
||||
def macro_list():
|
||||
"""List all available macros."""
|
||||
runtime = get_runtime()
|
||||
macros = runtime.registry.list_all()
|
||||
|
||||
if _json_output:
|
||||
output([{
|
||||
"name": m.name,
|
||||
"version": m.version,
|
||||
"description": m.description,
|
||||
"tags": m.tags,
|
||||
"parameters": list(m.parameters.keys()),
|
||||
} for m in macros])
|
||||
else:
|
||||
if not macros:
|
||||
click.echo("No macros found.")
|
||||
return
|
||||
click.echo(f"Available macros ({len(macros)}):\n")
|
||||
for m in macros:
|
||||
tags = f" [{', '.join(m.tags)}]" if m.tags else ""
|
||||
click.echo(f" {m.name:<30} {m.description}{tags}")
|
||||
|
||||
|
||||
@macro.command("info")
|
||||
@click.argument("name")
|
||||
@handle_error
|
||||
def macro_info(name):
|
||||
"""Show full details for a macro (schema, parameters, steps)."""
|
||||
runtime = get_runtime()
|
||||
m = runtime.registry.load(name)
|
||||
|
||||
if _json_output:
|
||||
output(m.to_dict())
|
||||
else:
|
||||
click.echo(f"\nMacro: {m.name} (v{m.version})")
|
||||
click.echo(f" {m.description}\n")
|
||||
|
||||
if m.parameters:
|
||||
click.echo("Parameters:")
|
||||
for pname, pspec in m.parameters.items():
|
||||
req = "(required)" if pspec.required else f"(default: {pspec.default!r})"
|
||||
click.echo(f" --param {pname}=<{pspec.type}> {req}")
|
||||
if pspec.description:
|
||||
click.echo(f" {pspec.description}")
|
||||
|
||||
if m.preconditions:
|
||||
click.echo(f"\nPreconditions ({len(m.preconditions)}):")
|
||||
for c in m.preconditions:
|
||||
click.echo(f" {c.type}: {c.args}")
|
||||
|
||||
if m.steps:
|
||||
click.echo(f"\nSteps ({len(m.steps)}):")
|
||||
for s in m.steps:
|
||||
click.echo(f" [{s.id}] backend={s.backend} action={s.action}")
|
||||
|
||||
if m.postconditions:
|
||||
click.echo(f"\nPostconditions ({len(m.postconditions)}):")
|
||||
for c in m.postconditions:
|
||||
click.echo(f" {c.type}: {c.args}")
|
||||
|
||||
if m.outputs:
|
||||
click.echo(f"\nOutputs:")
|
||||
for o in m.outputs:
|
||||
click.echo(f" {o.name}: {o.path or o.value}")
|
||||
|
||||
if m.agent_hints:
|
||||
click.echo(f"\nAgent hints: {m.agent_hints}")
|
||||
click.echo()
|
||||
|
||||
|
||||
@macro.command("validate")
|
||||
@click.argument("name", required=False)
|
||||
@handle_error
|
||||
def macro_validate(name):
|
||||
"""Validate macro definition(s). Pass a name or omit to validate all."""
|
||||
runtime = get_runtime()
|
||||
|
||||
if name:
|
||||
names = [name]
|
||||
else:
|
||||
names = runtime.registry.list_names()
|
||||
|
||||
results = {}
|
||||
for n in names:
|
||||
errors = runtime.validate_macro(n)
|
||||
results[n] = errors
|
||||
|
||||
if _json_output:
|
||||
output({n: {"valid": len(e) == 0, "errors": e} for n, e in results.items()})
|
||||
else:
|
||||
all_ok = True
|
||||
for n, errors in results.items():
|
||||
if errors:
|
||||
all_ok = False
|
||||
click.echo(f"✗ {n}:")
|
||||
for err in errors:
|
||||
click.echo(f" - {err}", err=True)
|
||||
else:
|
||||
click.echo(f"✓ {n}")
|
||||
if all_ok:
|
||||
click.echo("\nAll macros valid.")
|
||||
else:
|
||||
if not _repl_mode:
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
@macro.command("dry-run")
|
||||
@click.argument("name")
|
||||
@click.option("--param", "-p", multiple=True, help="Parameter in key=value format.")
|
||||
@handle_error
|
||||
def macro_dry_run(name, param):
|
||||
"""Simulate macro execution without any side effects."""
|
||||
params = _parse_params(param)
|
||||
runtime = get_runtime()
|
||||
result = runtime.execute(name, params, dry_run=True)
|
||||
|
||||
if _json_output:
|
||||
output(result.to_dict())
|
||||
else:
|
||||
click.echo(f"[dry-run] Macro '{name}'")
|
||||
if result.success:
|
||||
click.echo(" Would execute successfully.")
|
||||
click.echo(f" Steps: {len(result.step_results)}")
|
||||
else:
|
||||
click.echo(f" Would fail: {result.error}", err=True)
|
||||
|
||||
|
||||
@macro.command("define")
|
||||
@click.argument("name")
|
||||
@click.option("--output", "-o", default=None, help="Write YAML to this file path.")
|
||||
@handle_error
|
||||
def macro_define(name, output):
|
||||
"""Scaffold a new macro YAML definition."""
|
||||
import textwrap
|
||||
template = textwrap.dedent(f"""\
|
||||
name: {name}
|
||||
version: "1.0"
|
||||
description: "Describe what this macro does."
|
||||
tags: []
|
||||
|
||||
parameters:
|
||||
# Add your parameters here
|
||||
# output:
|
||||
# type: string
|
||||
# required: true
|
||||
# description: Output file path
|
||||
# example: /tmp/result.txt
|
||||
|
||||
preconditions:
|
||||
# Conditions that must be true before execution
|
||||
# - file_exists: /path/to/input
|
||||
# - process_running: my-app
|
||||
|
||||
steps:
|
||||
- id: step_1
|
||||
backend: native_api # or: file_transform, semantic_ui, gui_macro
|
||||
action: run_command
|
||||
params:
|
||||
command: [echo, "Hello from {name}"]
|
||||
timeout_ms: 30000
|
||||
on_failure: fail # or: skip, continue
|
||||
|
||||
postconditions:
|
||||
# Conditions verified after execution
|
||||
# - file_exists: ${{output}}
|
||||
|
||||
outputs:
|
||||
# Named outputs the agent can use
|
||||
# - name: result_file
|
||||
# path: ${{output}}
|
||||
|
||||
agent_hints:
|
||||
danger_level: safe # safe | moderate | dangerous
|
||||
side_effects: []
|
||||
reversible: true
|
||||
""")
|
||||
if output:
|
||||
from pathlib import Path
|
||||
p = Path(output)
|
||||
p.parent.mkdir(parents=True, exist_ok=True)
|
||||
p.write_text(template, encoding="utf-8")
|
||||
if _json_output:
|
||||
click.echo(json.dumps({"created": str(p.resolve())}))
|
||||
else:
|
||||
click.echo(f"✓ Macro scaffold written to: {p.resolve()}")
|
||||
else:
|
||||
click.echo(template)
|
||||
|
||||
|
||||
# ── session group ─────────────────────────────────────────────────────────────
|
||||
|
||||
@cli.group()
|
||||
def session():
|
||||
"""Session management and run history."""
|
||||
|
||||
|
||||
@session.command("status")
|
||||
@handle_error
|
||||
def session_status():
|
||||
"""Show current session status and statistics."""
|
||||
sess = get_session()
|
||||
data = sess.status()
|
||||
output(data, "Session status:")
|
||||
|
||||
|
||||
@session.command("history")
|
||||
@click.option("--limit", default=10, show_default=True, help="Number of records to show.")
|
||||
@handle_error
|
||||
def session_history(limit):
|
||||
"""Show recent macro execution history."""
|
||||
sess = get_session()
|
||||
records = sess.history(limit=limit)
|
||||
|
||||
if _json_output:
|
||||
output([r.to_dict() for r in records])
|
||||
else:
|
||||
if not records:
|
||||
click.echo("No runs recorded in this session.")
|
||||
return
|
||||
click.echo(f"Recent runs ({len(records)}):\n")
|
||||
for r in records:
|
||||
status = "✓" if r.success else "✗"
|
||||
import datetime
|
||||
ts = datetime.datetime.fromtimestamp(r.timestamp).strftime("%H:%M:%S")
|
||||
click.echo(f" {status} [{ts}] {r.macro_name} ({r.duration_ms:.0f}ms)")
|
||||
if not r.success:
|
||||
click.echo(f" Error: {r.error}", err=True)
|
||||
|
||||
|
||||
@session.command("save")
|
||||
@handle_error
|
||||
def session_save():
|
||||
"""Persist current session to disk."""
|
||||
sess = get_session()
|
||||
path = sess.save()
|
||||
output({"saved": path, "session_id": sess.session_id},
|
||||
f"Session saved: {path}")
|
||||
|
||||
|
||||
@session.command("list")
|
||||
@handle_error
|
||||
def session_list():
|
||||
"""List all saved sessions."""
|
||||
sessions = ExecutionSession.list_sessions()
|
||||
if _json_output:
|
||||
output(sessions)
|
||||
else:
|
||||
if not sessions:
|
||||
click.echo("No saved sessions.")
|
||||
return
|
||||
click.echo("Saved sessions:\n")
|
||||
for s in sessions:
|
||||
import datetime
|
||||
ts = datetime.datetime.fromtimestamp(s.get("timestamp", 0)).strftime("%Y-%m-%d %H:%M:%S")
|
||||
click.echo(f" {s['session_id']} ({s['runs']} runs) {ts}")
|
||||
|
||||
|
||||
# ── backends command ──────────────────────────────────────────────────────────
|
||||
|
||||
@cli.command()
|
||||
@handle_error
|
||||
def backends():
|
||||
"""Show available execution backends and their status."""
|
||||
runtime = get_runtime()
|
||||
data = runtime.routing.describe()
|
||||
if _json_output:
|
||||
output(data)
|
||||
else:
|
||||
click.echo("Execution backends:\n")
|
||||
for name, info in sorted(data.items(), key=lambda x: -x[1].get("priority", 0)):
|
||||
status = "✓" if info.get("available") else "✗"
|
||||
click.echo(
|
||||
f" {status} {name:<20} priority={info.get('priority', '?'):<5}"
|
||||
f" available={info.get('available')}"
|
||||
)
|
||||
|
||||
|
||||
# ── repl command ──────────────────────────────────────────────────────────────
|
||||
|
||||
@cli.command()
|
||||
@click.pass_context
|
||||
def repl(ctx):
|
||||
"""Enter the interactive REPL (default when no command given)."""
|
||||
global _repl_mode
|
||||
_repl_mode = True
|
||||
|
||||
from cli_anything.openclaw.utils.repl_skin import ReplSkin
|
||||
skin = ReplSkin("openclaw", version="1.0.0")
|
||||
skin.print_banner()
|
||||
|
||||
runtime = get_runtime()
|
||||
|
||||
# Show quick summary on startup
|
||||
macros = runtime.registry.list_all()
|
||||
skin.info(f"{len(macros)} macros loaded. Type 'macro list' to see them.")
|
||||
skin.info("Type 'help' for commands, 'quit' to exit.\n")
|
||||
|
||||
pt_session = skin.create_prompt_session()
|
||||
session_obj = get_session()
|
||||
|
||||
while True:
|
||||
try:
|
||||
line = skin.get_input(
|
||||
pt_session,
|
||||
context=f"{session_obj.session_id[:12]}",
|
||||
)
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
skin.print_goodbye()
|
||||
break
|
||||
|
||||
if not line:
|
||||
continue
|
||||
if line.lower() in ("quit", "exit", "q"):
|
||||
skin.print_goodbye()
|
||||
break
|
||||
if line.lower() in ("help", "?"):
|
||||
skin.help({
|
||||
"macro list": "List all available macros",
|
||||
"macro info <name>": "Show macro schema",
|
||||
"macro run <name> [--param k=v ...]": "Execute a macro",
|
||||
"macro dry-run <name>": "Simulate without side effects",
|
||||
"macro validate [name]": "Validate macro definitions",
|
||||
"macro define <name>": "Scaffold a new macro YAML",
|
||||
"session status": "Show session statistics",
|
||||
"session history": "Show recent runs",
|
||||
"backends": "Show backend availability",
|
||||
"quit": "Exit the REPL",
|
||||
})
|
||||
continue
|
||||
|
||||
# Parse and dispatch via Click's standalone_mode=False
|
||||
import shlex
|
||||
try:
|
||||
args = shlex.split(line)
|
||||
except ValueError as e:
|
||||
skin.error(f"Parse error: {e}")
|
||||
continue
|
||||
|
||||
try:
|
||||
ctx_obj = cli.make_context(
|
||||
"cli-anything-openclaw",
|
||||
args,
|
||||
standalone_mode=False,
|
||||
parent=ctx,
|
||||
)
|
||||
with ctx_obj:
|
||||
cli.invoke(ctx_obj)
|
||||
except SystemExit:
|
||||
pass
|
||||
except click.ClickException as e:
|
||||
skin.error(str(e))
|
||||
except Exception as e:
|
||||
skin.error(str(e))
|
||||
|
||||
|
||||
# ── Entry point ───────────────────────────────────────────────────────────────
|
||||
|
||||
if __name__ == "__main__":
|
||||
cli()
|
||||
@@ -0,0 +1,220 @@
|
||||
---
|
||||
name: cli-anything-openclaw
|
||||
description: >
|
||||
Use when the agent wants to define, list, inspect, or execute GUI macros
|
||||
via the OpenClaw Macro System CLI. Macros are parameterized, CLI-callable
|
||||
workflows — the agent invokes `macro run <name>` and the system handles
|
||||
backend routing (plugin, file transform, accessibility, compiled GUI replay).
|
||||
---
|
||||
|
||||
# OpenClaw Macro System CLI
|
||||
|
||||
## What It Is
|
||||
|
||||
The OpenClaw Macro System converts valuable GUI workflows into parameterized,
|
||||
CLI-callable macros. Agents **never touch the GUI directly** — they call macros
|
||||
through this stable CLI, and the runtime routes execution to the best available
|
||||
backend (native plugin/API, file transformation, semantic UI control, or
|
||||
precompiled GUI macro replay).
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
cd openclaw-skill/agent-harness
|
||||
pip install -e .
|
||||
```
|
||||
|
||||
**Requirements:** Python 3.10+, PyYAML, click, prompt-toolkit.
|
||||
|
||||
## Quick Start (for agents)
|
||||
|
||||
```bash
|
||||
# 1. See what macros are available
|
||||
cli-anything-openclaw macro list --json
|
||||
|
||||
# 2. Inspect a macro's parameters
|
||||
cli-anything-openclaw macro info export_file --json
|
||||
|
||||
# 3. Dry-run to check params without side effects
|
||||
cli-anything-openclaw --dry-run macro run export_file \
|
||||
--param output=/tmp/test.txt --json
|
||||
|
||||
# 4. Execute a macro
|
||||
cli-anything-openclaw macro run export_file \
|
||||
--param output=/tmp/result.txt --json
|
||||
|
||||
# 5. See what backends are available
|
||||
cli-anything-openclaw backends --json
|
||||
```
|
||||
|
||||
## Command Reference
|
||||
|
||||
### Global Flags
|
||||
|
||||
| Flag | Description |
|
||||
|------|-------------|
|
||||
| `--json` | Machine-readable JSON output on stdout |
|
||||
| `--dry-run` | Simulate all steps, skip side effects |
|
||||
| `--session-id <id>` | Resume or create a named session |
|
||||
|
||||
### `macro` group
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `macro list` | List all available macros |
|
||||
| `macro info <name>` | Show macro schema (parameters, steps, conditions) |
|
||||
| `macro run <name> --param k=v` | Execute a macro |
|
||||
| `macro dry-run <name> --param k=v` | Simulate without side effects |
|
||||
| `macro validate [name]` | Structural validation |
|
||||
| `macro define <name>` | Scaffold a new macro YAML |
|
||||
|
||||
### `session` group
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `session status` | Show session statistics |
|
||||
| `session history` | Show recent run history |
|
||||
| `session save` | Persist session to disk |
|
||||
| `session list` | List all saved sessions |
|
||||
|
||||
### `backends`
|
||||
|
||||
```bash
|
||||
cli-anything-openclaw backends --json
|
||||
# Shows: native_api, file_transform, semantic_ui, gui_macro, recovery
|
||||
# and whether each is available in the current environment.
|
||||
```
|
||||
|
||||
## Macro Parameters
|
||||
|
||||
Pass parameters with `--param key=value`. Repeat for multiple:
|
||||
|
||||
```bash
|
||||
cli-anything-openclaw macro run transform_json \
|
||||
--param file=/path/to/data.json \
|
||||
--param key=settings.theme \
|
||||
--param value=dark \
|
||||
--json
|
||||
```
|
||||
|
||||
## Output Format (--json)
|
||||
|
||||
All commands output JSON when `--json` is set:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"macro_name": "export_file",
|
||||
"output": {
|
||||
"exported_file": "/tmp/result.txt"
|
||||
},
|
||||
"error": "",
|
||||
"telemetry": {
|
||||
"duration_ms": 312,
|
||||
"steps_total": 2,
|
||||
"steps_run": 2,
|
||||
"backends_used": ["native_api"],
|
||||
"dry_run": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
On failure (`"success": false`), read the `"error"` field for the reason.
|
||||
Exit code is 1 on failure.
|
||||
|
||||
## Execution Backends
|
||||
|
||||
Backends are selected automatically based on the macro step definition:
|
||||
|
||||
| Backend | Triggered by | Use case |
|
||||
|---------|-------------|----------|
|
||||
| `native_api` | `backend: native_api` | Subprocess / shell command |
|
||||
| `file_transform` | `backend: file_transform` | XML, JSON, text file editing |
|
||||
| `semantic_ui` | `backend: semantic_ui` | Accessibility / keyboard shortcuts |
|
||||
| `gui_macro` | `backend: gui_macro` | Precompiled coordinate replay |
|
||||
| `recovery` | `backend: recovery` | Retry / fallback orchestration |
|
||||
|
||||
## Writing Macros
|
||||
|
||||
Macros are YAML files in `cli_anything/openclaw/macro_definitions/`.
|
||||
Scaffold one with:
|
||||
|
||||
```bash
|
||||
cli-anything-openclaw macro define my_macro --output \
|
||||
cli_anything/openclaw/macro_definitions/examples/my_macro.yaml
|
||||
```
|
||||
|
||||
Minimal schema:
|
||||
|
||||
```yaml
|
||||
name: my_macro
|
||||
version: "1.0"
|
||||
description: What this macro does.
|
||||
|
||||
parameters:
|
||||
output:
|
||||
type: string
|
||||
required: true
|
||||
description: Where to write results.
|
||||
example: /tmp/result.txt
|
||||
|
||||
preconditions:
|
||||
- file_exists: /path/to/input
|
||||
|
||||
steps:
|
||||
- id: step1
|
||||
backend: native_api
|
||||
action: run_command
|
||||
params:
|
||||
command: [my-app, --export, "${output}"]
|
||||
timeout_ms: 30000
|
||||
on_failure: fail # or: skip, continue
|
||||
|
||||
postconditions:
|
||||
- file_exists: ${output}
|
||||
- file_size_gt: [${output}, 100]
|
||||
|
||||
outputs:
|
||||
- name: result_file
|
||||
path: ${output}
|
||||
|
||||
agent_hints:
|
||||
danger_level: safe # safe | moderate | dangerous
|
||||
side_effects: [creates_file]
|
||||
reversible: true
|
||||
```
|
||||
|
||||
## Agent Usage Rules
|
||||
|
||||
1. **Always use `--json`** for programmatic output.
|
||||
2. **Use `--dry-run` to validate params** before executing side-effectful macros.
|
||||
3. **Check `success` field** — do not assume success from exit code alone.
|
||||
4. **Read `error` field** when `success` is false — it explains what failed.
|
||||
5. **Use `macro info <name>` to discover params** before calling `macro run`.
|
||||
6. **Use absolute paths** for all file parameters.
|
||||
|
||||
## Example Workflow
|
||||
|
||||
```bash
|
||||
# Step 1: What's available?
|
||||
cli-anything-openclaw macro list --json
|
||||
|
||||
# Step 2: What params does transform_json need?
|
||||
cli-anything-openclaw macro info transform_json --json
|
||||
|
||||
# Step 3: Test safely
|
||||
cli-anything-openclaw --dry-run macro run transform_json \
|
||||
--param file=/tmp/config.json \
|
||||
--param key=theme \
|
||||
--param value=dark --json
|
||||
|
||||
# Step 4: Execute for real
|
||||
cli-anything-openclaw macro run transform_json \
|
||||
--param file=/tmp/config.json \
|
||||
--param key=theme \
|
||||
--param value=dark --json
|
||||
```
|
||||
|
||||
## Version
|
||||
|
||||
1.0.0
|
||||
@@ -0,0 +1,595 @@
|
||||
"""Unit tests for OpenClaw Macro System core modules.
|
||||
|
||||
Covers: MacroDefinition, MacroRegistry, MacroRuntime, backends, routing.
|
||||
All tests use synthetic data and do not require external software.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import textwrap
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
SIMPLE_MACRO_YAML = textwrap.dedent("""\
|
||||
name: test_macro
|
||||
version: "1.0"
|
||||
description: A test macro.
|
||||
parameters:
|
||||
output:
|
||||
type: string
|
||||
required: true
|
||||
description: Output path
|
||||
count:
|
||||
type: integer
|
||||
required: false
|
||||
default: 1
|
||||
min: 1
|
||||
max: 100
|
||||
steps:
|
||||
- id: step1
|
||||
backend: native_api
|
||||
action: run_command
|
||||
params:
|
||||
command: [echo, hello]
|
||||
postconditions: []
|
||||
""")
|
||||
|
||||
|
||||
def write_macro(tmp_path: Path, name: str, content: str) -> Path:
|
||||
p = tmp_path / f"{name}.yaml"
|
||||
p.write_text(content, encoding="utf-8")
|
||||
return p
|
||||
|
||||
|
||||
# ── macro_model tests ─────────────────────────────────────────────────────────
|
||||
|
||||
class TestMacroModel:
|
||||
def test_load_from_yaml(self, tmp_path):
|
||||
from cli_anything.openclaw.core.macro_model import load_from_yaml
|
||||
p = write_macro(tmp_path, "test_macro", SIMPLE_MACRO_YAML)
|
||||
m = load_from_yaml(str(p))
|
||||
assert m.name == "test_macro"
|
||||
assert m.version == "1.0"
|
||||
assert "output" in m.parameters
|
||||
assert len(m.steps) == 1
|
||||
assert m.steps[0].backend == "native_api"
|
||||
|
||||
def test_load_missing_file(self):
|
||||
from cli_anything.openclaw.core.macro_model import load_from_yaml
|
||||
with pytest.raises(FileNotFoundError):
|
||||
load_from_yaml("/nonexistent/path.yaml")
|
||||
|
||||
def test_validate_params_required(self, tmp_path):
|
||||
from cli_anything.openclaw.core.macro_model import load_from_yaml
|
||||
p = write_macro(tmp_path, "test_macro", SIMPLE_MACRO_YAML)
|
||||
m = load_from_yaml(str(p))
|
||||
errors = m.validate_params({})
|
||||
assert any("output" in e for e in errors)
|
||||
|
||||
def test_validate_params_type_error(self, tmp_path):
|
||||
from cli_anything.openclaw.core.macro_model import load_from_yaml
|
||||
p = write_macro(tmp_path, "test_macro", SIMPLE_MACRO_YAML)
|
||||
m = load_from_yaml(str(p))
|
||||
errors = m.validate_params({"output": "/tmp/x", "count": "not_an_int"})
|
||||
assert any("count" in e for e in errors)
|
||||
|
||||
def test_validate_params_range(self, tmp_path):
|
||||
from cli_anything.openclaw.core.macro_model import load_from_yaml
|
||||
p = write_macro(tmp_path, "test_macro", SIMPLE_MACRO_YAML)
|
||||
m = load_from_yaml(str(p))
|
||||
errors = m.validate_params({"output": "/tmp/x", "count": 200})
|
||||
assert any("count" in e for e in errors)
|
||||
|
||||
def test_validate_params_ok(self, tmp_path):
|
||||
from cli_anything.openclaw.core.macro_model import load_from_yaml
|
||||
p = write_macro(tmp_path, "test_macro", SIMPLE_MACRO_YAML)
|
||||
m = load_from_yaml(str(p))
|
||||
errors = m.validate_params({"output": "/tmp/x"})
|
||||
assert errors == []
|
||||
|
||||
def test_resolve_params_defaults(self, tmp_path):
|
||||
from cli_anything.openclaw.core.macro_model import load_from_yaml
|
||||
p = write_macro(tmp_path, "test_macro", SIMPLE_MACRO_YAML)
|
||||
m = load_from_yaml(str(p))
|
||||
resolved = m.resolve_params({"output": "/tmp/x"})
|
||||
assert resolved["count"] == 1
|
||||
|
||||
def test_structural_validation_no_steps(self, tmp_path):
|
||||
from cli_anything.openclaw.core.macro_model import load_from_yaml
|
||||
yaml_content = "name: bad\nsteps: []\n"
|
||||
p = write_macro(tmp_path, "bad", yaml_content)
|
||||
m = load_from_yaml(str(p))
|
||||
errors = m.validate()
|
||||
assert any("steps" in e for e in errors)
|
||||
|
||||
def test_structural_validation_bad_backend(self, tmp_path):
|
||||
from cli_anything.openclaw.core.macro_model import load_from_yaml
|
||||
yaml_content = textwrap.dedent("""\
|
||||
name: bad
|
||||
steps:
|
||||
- id: x
|
||||
backend: fake_backend
|
||||
action: do_thing
|
||||
""")
|
||||
p = write_macro(tmp_path, "bad_backend", yaml_content)
|
||||
m = load_from_yaml(str(p))
|
||||
errors = m.validate()
|
||||
assert any("fake_backend" in e for e in errors)
|
||||
|
||||
def test_to_dict(self, tmp_path):
|
||||
from cli_anything.openclaw.core.macro_model import load_from_yaml
|
||||
p = write_macro(tmp_path, "test_macro", SIMPLE_MACRO_YAML)
|
||||
m = load_from_yaml(str(p))
|
||||
d = m.to_dict()
|
||||
assert d["name"] == "test_macro"
|
||||
assert "parameters" in d
|
||||
assert "steps" in d
|
||||
|
||||
|
||||
class TestSubstitute:
|
||||
def test_string_substitution(self):
|
||||
from cli_anything.openclaw.core.macro_model import substitute
|
||||
result = substitute("hello ${name}", {"name": "world"})
|
||||
assert result == "hello world"
|
||||
|
||||
def test_nested_list(self):
|
||||
from cli_anything.openclaw.core.macro_model import substitute
|
||||
result = substitute(["echo", "${output}"], {"output": "/tmp/x"})
|
||||
assert result == ["echo", "/tmp/x"]
|
||||
|
||||
def test_nested_dict(self):
|
||||
from cli_anything.openclaw.core.macro_model import substitute
|
||||
result = substitute({"path": "${output}", "other": 42}, {"output": "/out"})
|
||||
assert result["path"] == "/out"
|
||||
assert result["other"] == 42
|
||||
|
||||
def test_missing_key_left_as_is(self):
|
||||
from cli_anything.openclaw.core.macro_model import substitute
|
||||
result = substitute("${missing}", {})
|
||||
assert result == "${missing}"
|
||||
|
||||
|
||||
# ── MacroRegistry tests ───────────────────────────────────────────────────────
|
||||
|
||||
class TestMacroRegistry:
|
||||
def test_load_macro(self, tmp_path):
|
||||
from cli_anything.openclaw.core.registry import MacroRegistry
|
||||
write_macro(tmp_path, "test_macro", SIMPLE_MACRO_YAML)
|
||||
reg = MacroRegistry(str(tmp_path))
|
||||
m = reg.load("test_macro")
|
||||
assert m.name == "test_macro"
|
||||
|
||||
def test_load_missing_raises(self, tmp_path):
|
||||
from cli_anything.openclaw.core.registry import MacroRegistry
|
||||
reg = MacroRegistry(str(tmp_path))
|
||||
with pytest.raises(KeyError):
|
||||
reg.load("nonexistent_macro")
|
||||
|
||||
def test_list_all(self, tmp_path):
|
||||
from cli_anything.openclaw.core.registry import MacroRegistry
|
||||
write_macro(tmp_path, "test_macro", SIMPLE_MACRO_YAML)
|
||||
write_macro(tmp_path, "another", SIMPLE_MACRO_YAML.replace("test_macro", "another"))
|
||||
reg = MacroRegistry(str(tmp_path))
|
||||
names = reg.list_names()
|
||||
assert "test_macro" in names
|
||||
assert "another" in names
|
||||
|
||||
def test_manifest_index(self, tmp_path):
|
||||
from cli_anything.openclaw.core.registry import MacroRegistry
|
||||
sub = tmp_path / "sub"
|
||||
sub.mkdir()
|
||||
write_macro(sub, "alpha", SIMPLE_MACRO_YAML.replace("test_macro", "alpha"))
|
||||
manifest = tmp_path / "manifest.yaml"
|
||||
manifest.write_text("macros:\n - name: alpha\n path: sub/alpha.yaml\n")
|
||||
reg = MacroRegistry(str(tmp_path))
|
||||
m = reg.load("alpha")
|
||||
assert m.name == "alpha"
|
||||
|
||||
def test_register_programmatic(self):
|
||||
from cli_anything.openclaw.core.registry import MacroRegistry
|
||||
from cli_anything.openclaw.core.macro_model import MacroDefinition, MacroStep
|
||||
reg = MacroRegistry("/nonexistent")
|
||||
macro = MacroDefinition(
|
||||
name="inline_macro",
|
||||
steps=[MacroStep(backend="native_api", action="run_command")],
|
||||
)
|
||||
reg.register(macro)
|
||||
assert reg.load("inline_macro").name == "inline_macro"
|
||||
|
||||
def test_info(self, tmp_path):
|
||||
from cli_anything.openclaw.core.registry import MacroRegistry
|
||||
write_macro(tmp_path, "test_macro", SIMPLE_MACRO_YAML)
|
||||
reg = MacroRegistry(str(tmp_path))
|
||||
info = reg.info()
|
||||
assert info["total"] >= 1
|
||||
assert "macros_dir" in info
|
||||
|
||||
|
||||
# ── Backend tests ─────────────────────────────────────────────────────────────
|
||||
|
||||
class TestNativeAPIBackend:
|
||||
def _make_context(self, params=None):
|
||||
from cli_anything.openclaw.backends.base import BackendContext
|
||||
return BackendContext(params=params or {})
|
||||
|
||||
def _make_step(self, action, step_params):
|
||||
from cli_anything.openclaw.core.macro_model import MacroStep
|
||||
return MacroStep(id="test", backend="native_api", action=action, params=step_params)
|
||||
|
||||
def test_run_command_success(self):
|
||||
from cli_anything.openclaw.backends.native_api import NativeAPIBackend
|
||||
b = NativeAPIBackend()
|
||||
step = self._make_step("run_command", {"command": ["echo", "hello"]})
|
||||
result = b.execute(step, {}, self._make_context())
|
||||
assert result.success
|
||||
|
||||
def test_run_command_fails_bad_exit(self):
|
||||
from cli_anything.openclaw.backends.native_api import NativeAPIBackend
|
||||
b = NativeAPIBackend()
|
||||
step = self._make_step("run_command", {"command": ["false"]})
|
||||
result = b.execute(step, {}, self._make_context())
|
||||
assert not result.success
|
||||
assert "exit" in result.error.lower() or "failed" in result.error.lower()
|
||||
|
||||
def test_run_command_not_found(self):
|
||||
from cli_anything.openclaw.backends.native_api import NativeAPIBackend
|
||||
b = NativeAPIBackend()
|
||||
step = self._make_step("run_command", {"command": ["__nonexistent_cmd__"]})
|
||||
result = b.execute(step, {}, self._make_context())
|
||||
assert not result.success
|
||||
|
||||
def test_find_executable_found(self):
|
||||
from cli_anything.openclaw.backends.native_api import NativeAPIBackend
|
||||
b = NativeAPIBackend()
|
||||
step = self._make_step("find_executable", {"name": "echo"})
|
||||
result = b.execute(step, {}, self._make_context())
|
||||
assert result.success
|
||||
assert "executable" in result.output
|
||||
|
||||
def test_find_executable_missing(self):
|
||||
from cli_anything.openclaw.backends.native_api import NativeAPIBackend
|
||||
b = NativeAPIBackend()
|
||||
step = self._make_step("find_executable", {
|
||||
"name": "__nonexistent__",
|
||||
"install_hint": "brew install nonexistent"
|
||||
})
|
||||
result = b.execute(step, {}, self._make_context())
|
||||
assert not result.success
|
||||
assert "brew install" in result.error
|
||||
|
||||
def test_dry_run_does_not_execute(self):
|
||||
from cli_anything.openclaw.backends.native_api import NativeAPIBackend
|
||||
from cli_anything.openclaw.backends.base import BackendContext
|
||||
b = NativeAPIBackend()
|
||||
step = self._make_step("run_command", {"command": ["false"]})
|
||||
ctx = BackendContext(params={}, dry_run=True)
|
||||
result = b.execute(step, {}, ctx)
|
||||
assert result.success
|
||||
assert result.output.get("dry_run")
|
||||
|
||||
def test_param_substitution_in_command(self):
|
||||
from cli_anything.openclaw.backends.native_api import NativeAPIBackend
|
||||
b = NativeAPIBackend()
|
||||
step = self._make_step("run_command", {"command": ["echo", "${msg}"]})
|
||||
result = b.execute(step, {"msg": "substituted"}, self._make_context({"msg": "substituted"}))
|
||||
assert result.success
|
||||
|
||||
def test_unknown_action(self):
|
||||
from cli_anything.openclaw.backends.native_api import NativeAPIBackend
|
||||
b = NativeAPIBackend()
|
||||
step = self._make_step("unknown_action", {})
|
||||
result = b.execute(step, {}, self._make_context())
|
||||
assert not result.success
|
||||
assert "unknown action" in result.error.lower()
|
||||
|
||||
|
||||
class TestFileTransformBackend:
|
||||
def _make_context(self):
|
||||
from cli_anything.openclaw.backends.base import BackendContext
|
||||
return BackendContext(params={})
|
||||
|
||||
def test_json_set_and_get(self, tmp_path):
|
||||
from cli_anything.openclaw.backends.file_transform import FileTransformBackend
|
||||
from cli_anything.openclaw.core.macro_model import MacroStep
|
||||
b = FileTransformBackend()
|
||||
ctx = self._make_context()
|
||||
|
||||
json_file = tmp_path / "data.json"
|
||||
json_file.write_text('{"a": 1}', encoding="utf-8")
|
||||
|
||||
step = MacroStep(id="set", backend="file_transform", action="json_set", params={
|
||||
"input_file": str(json_file),
|
||||
"output_file": str(json_file),
|
||||
"path": "settings.theme",
|
||||
"value": "dark",
|
||||
})
|
||||
result = b.execute(step, {}, ctx)
|
||||
assert result.success
|
||||
|
||||
import json
|
||||
data = json.loads(json_file.read_text())
|
||||
assert data["settings"]["theme"] == "dark"
|
||||
|
||||
def test_text_replace(self, tmp_path):
|
||||
from cli_anything.openclaw.backends.file_transform import FileTransformBackend
|
||||
from cli_anything.openclaw.core.macro_model import MacroStep
|
||||
b = FileTransformBackend()
|
||||
ctx = self._make_context()
|
||||
|
||||
txt_file = tmp_path / "config.ini"
|
||||
txt_file.write_text("theme=default\nsize=10\n", encoding="utf-8")
|
||||
|
||||
step = MacroStep(id="replace", backend="file_transform", action="text_replace", params={
|
||||
"input_file": str(txt_file),
|
||||
"output_file": str(txt_file),
|
||||
"find": "theme=default",
|
||||
"replace": "theme=dark",
|
||||
})
|
||||
result = b.execute(step, {}, ctx)
|
||||
assert result.success
|
||||
assert "theme=dark" in txt_file.read_text()
|
||||
assert result.output["replacements"] == 1
|
||||
|
||||
def test_copy_file(self, tmp_path):
|
||||
from cli_anything.openclaw.backends.file_transform import FileTransformBackend
|
||||
from cli_anything.openclaw.core.macro_model import MacroStep
|
||||
b = FileTransformBackend()
|
||||
ctx = self._make_context()
|
||||
|
||||
src = tmp_path / "src.txt"
|
||||
dst = tmp_path / "dst.txt"
|
||||
src.write_text("content", encoding="utf-8")
|
||||
|
||||
step = MacroStep(id="copy", backend="file_transform", action="copy_file", params={
|
||||
"src": str(src),
|
||||
"dst": str(dst),
|
||||
})
|
||||
result = b.execute(step, {}, ctx)
|
||||
assert result.success
|
||||
assert dst.read_text() == "content"
|
||||
|
||||
def test_unknown_action(self):
|
||||
from cli_anything.openclaw.backends.file_transform import FileTransformBackend
|
||||
from cli_anything.openclaw.core.macro_model import MacroStep
|
||||
b = FileTransformBackend()
|
||||
step = MacroStep(id="x", backend="file_transform", action="unknown_op", params={})
|
||||
result = b.execute(step, {}, self._make_context())
|
||||
assert not result.success
|
||||
|
||||
|
||||
class TestStepResult:
|
||||
def test_to_dict(self):
|
||||
from cli_anything.openclaw.backends.base import StepResult
|
||||
r = StepResult(success=True, output={"key": "val"}, backend_used="native_api")
|
||||
d = r.to_dict()
|
||||
assert d["success"] is True
|
||||
assert d["output"]["key"] == "val"
|
||||
assert d["backend_used"] == "native_api"
|
||||
|
||||
|
||||
# ── Routing tests ─────────────────────────────────────────────────────────────
|
||||
|
||||
class TestRoutingEngine:
|
||||
def test_select_native_api(self):
|
||||
from cli_anything.openclaw.core.routing import RoutingEngine
|
||||
from cli_anything.openclaw.core.macro_model import MacroStep
|
||||
engine = RoutingEngine()
|
||||
step = MacroStep(id="x", backend="native_api", action="run_command")
|
||||
backend = engine.select(step)
|
||||
assert backend.name == "native_api"
|
||||
|
||||
def test_select_file_transform(self):
|
||||
from cli_anything.openclaw.core.routing import RoutingEngine
|
||||
from cli_anything.openclaw.core.macro_model import MacroStep
|
||||
engine = RoutingEngine()
|
||||
step = MacroStep(id="x", backend="file_transform", action="json_set")
|
||||
backend = engine.select(step)
|
||||
assert backend.name == "file_transform"
|
||||
|
||||
def test_describe(self):
|
||||
from cli_anything.openclaw.core.routing import RoutingEngine
|
||||
engine = RoutingEngine()
|
||||
desc = engine.describe()
|
||||
assert "native_api" in desc
|
||||
assert "file_transform" in desc
|
||||
assert "recovery" in desc
|
||||
|
||||
def test_execute_step_native_api(self):
|
||||
from cli_anything.openclaw.core.routing import RoutingEngine
|
||||
from cli_anything.openclaw.core.macro_model import MacroStep
|
||||
from cli_anything.openclaw.backends.base import BackendContext
|
||||
engine = RoutingEngine()
|
||||
step = MacroStep(id="x", backend="native_api", action="run_command",
|
||||
params={"command": ["echo", "hello"]})
|
||||
ctx = BackendContext(params={})
|
||||
result = engine.execute_step(step, {}, ctx)
|
||||
assert result.success
|
||||
|
||||
|
||||
# ── Runtime tests ─────────────────────────────────────────────────────────────
|
||||
|
||||
class TestMacroRuntime:
|
||||
def _make_runtime(self, tmp_path):
|
||||
from cli_anything.openclaw.core.registry import MacroRegistry
|
||||
from cli_anything.openclaw.core.runtime import MacroRuntime
|
||||
# Register a real macro that just echoes
|
||||
yaml_content = textwrap.dedent("""\
|
||||
name: echo_macro
|
||||
parameters:
|
||||
msg:
|
||||
type: string
|
||||
required: false
|
||||
default: hello
|
||||
steps:
|
||||
- id: step1
|
||||
backend: native_api
|
||||
action: run_command
|
||||
params:
|
||||
command: [echo, "${msg}"]
|
||||
""")
|
||||
write_macro(tmp_path, "echo_macro", yaml_content)
|
||||
reg = MacroRegistry(str(tmp_path))
|
||||
return MacroRuntime(registry=reg)
|
||||
|
||||
def test_execute_success(self, tmp_path):
|
||||
rt = self._make_runtime(tmp_path)
|
||||
result = rt.execute("echo_macro", {"msg": "test"})
|
||||
assert result.success
|
||||
assert result.telemetry["steps_run"] == 1
|
||||
|
||||
def test_execute_unknown_macro(self, tmp_path):
|
||||
rt = self._make_runtime(tmp_path)
|
||||
result = rt.execute("nonexistent_macro", {})
|
||||
assert not result.success
|
||||
assert "not found" in result.error.lower()
|
||||
|
||||
def test_execute_param_validation_failure(self, tmp_path):
|
||||
from cli_anything.openclaw.core.registry import MacroRegistry
|
||||
from cli_anything.openclaw.core.runtime import MacroRuntime
|
||||
yaml_content = textwrap.dedent("""\
|
||||
name: required_param_macro
|
||||
parameters:
|
||||
output:
|
||||
type: string
|
||||
required: true
|
||||
steps:
|
||||
- id: s1
|
||||
backend: native_api
|
||||
action: run_command
|
||||
params:
|
||||
command: [echo, "${output}"]
|
||||
""")
|
||||
write_macro(tmp_path, "required_param_macro", yaml_content)
|
||||
reg = MacroRegistry(str(tmp_path))
|
||||
rt = MacroRuntime(registry=reg)
|
||||
result = rt.execute("required_param_macro", {})
|
||||
assert not result.success
|
||||
assert "output" in result.error
|
||||
|
||||
def test_precondition_failure(self, tmp_path):
|
||||
from cli_anything.openclaw.core.registry import MacroRegistry
|
||||
from cli_anything.openclaw.core.runtime import MacroRuntime
|
||||
yaml_content = textwrap.dedent("""\
|
||||
name: precond_macro
|
||||
preconditions:
|
||||
- file_exists: /nonexistent_file_xyz_abc
|
||||
steps:
|
||||
- id: s1
|
||||
backend: native_api
|
||||
action: run_command
|
||||
params:
|
||||
command: [echo, ok]
|
||||
""")
|
||||
write_macro(tmp_path, "precond_macro", yaml_content)
|
||||
reg = MacroRegistry(str(tmp_path))
|
||||
rt = MacroRuntime(registry=reg)
|
||||
result = rt.execute("precond_macro", {})
|
||||
assert not result.success
|
||||
assert "precondition" in result.error.lower()
|
||||
|
||||
def test_postcondition_failure(self, tmp_path):
|
||||
from cli_anything.openclaw.core.registry import MacroRegistry
|
||||
from cli_anything.openclaw.core.runtime import MacroRuntime
|
||||
yaml_content = textwrap.dedent("""\
|
||||
name: postcond_macro
|
||||
steps:
|
||||
- id: s1
|
||||
backend: native_api
|
||||
action: run_command
|
||||
params:
|
||||
command: [echo, ok]
|
||||
postconditions:
|
||||
- file_exists: /nonexistent_output_xyz
|
||||
""")
|
||||
write_macro(tmp_path, "postcond_macro", yaml_content)
|
||||
reg = MacroRegistry(str(tmp_path))
|
||||
rt = MacroRuntime(registry=reg)
|
||||
result = rt.execute("postcond_macro", {})
|
||||
assert not result.success
|
||||
assert "postcondition" in result.error.lower()
|
||||
|
||||
def test_dry_run(self, tmp_path):
|
||||
rt = self._make_runtime(tmp_path)
|
||||
result = rt.execute("echo_macro", {}, dry_run=True)
|
||||
assert result.success
|
||||
assert result.telemetry["dry_run"] is True
|
||||
|
||||
def test_session_records_run(self, tmp_path):
|
||||
rt = self._make_runtime(tmp_path)
|
||||
rt.execute("echo_macro", {})
|
||||
last = rt.session.last()
|
||||
assert last is not None
|
||||
assert last.macro_name == "echo_macro"
|
||||
|
||||
def test_validate_macro(self, tmp_path):
|
||||
rt = self._make_runtime(tmp_path)
|
||||
errors = rt.validate_macro("echo_macro")
|
||||
assert errors == []
|
||||
|
||||
def test_on_failure_skip(self, tmp_path):
|
||||
from cli_anything.openclaw.core.registry import MacroRegistry
|
||||
from cli_anything.openclaw.core.runtime import MacroRuntime
|
||||
yaml_content = textwrap.dedent("""\
|
||||
name: skip_macro
|
||||
steps:
|
||||
- id: bad_step
|
||||
backend: native_api
|
||||
action: run_command
|
||||
params:
|
||||
command: [false]
|
||||
on_failure: skip
|
||||
- id: good_step
|
||||
backend: native_api
|
||||
action: run_command
|
||||
params:
|
||||
command: [echo, reached]
|
||||
""")
|
||||
write_macro(tmp_path, "skip_macro", yaml_content)
|
||||
reg = MacroRegistry(str(tmp_path))
|
||||
rt = MacroRuntime(registry=reg)
|
||||
result = rt.execute("skip_macro", {})
|
||||
# Should succeed because bad step was skipped
|
||||
assert result.success
|
||||
assert result.telemetry["steps_run"] == 2
|
||||
|
||||
|
||||
# ── Session tests ─────────────────────────────────────────────────────────────
|
||||
|
||||
class TestExecutionSession:
|
||||
def test_record_and_retrieve(self):
|
||||
from cli_anything.openclaw.core.session import ExecutionSession, RunRecord
|
||||
sess = ExecutionSession(session_id="test_sess")
|
||||
rec = RunRecord("m1", {}, True, {}, "", 100.0, ["native_api"], 1)
|
||||
sess.record(rec)
|
||||
assert sess.last().macro_name == "m1"
|
||||
assert len(sess.history()) == 1
|
||||
|
||||
def test_stats(self):
|
||||
from cli_anything.openclaw.core.session import ExecutionSession, RunRecord
|
||||
sess = ExecutionSession()
|
||||
sess.record(RunRecord("m1", {}, True, {}, "", 100.0, [], 1))
|
||||
sess.record(RunRecord("m2", {}, False, {}, "err", 50.0, [], 0))
|
||||
stats = sess.stats()
|
||||
assert stats["total"] == 2
|
||||
assert stats["success"] == 1
|
||||
assert stats["success_rate"] == 0.5
|
||||
|
||||
def test_save_and_load(self, tmp_path, monkeypatch):
|
||||
from cli_anything.openclaw.core import session as sess_mod
|
||||
import cli_anything.openclaw.core.session as sess_module
|
||||
# Redirect SESSION_DIR to tmp_path
|
||||
monkeypatch.setattr(sess_module, "SESSION_DIR", tmp_path)
|
||||
from cli_anything.openclaw.core.session import ExecutionSession, RunRecord
|
||||
sess = ExecutionSession(session_id="save_test")
|
||||
sess.record(RunRecord("m1", {"k": "v"}, True, {}, "", 200.0, ["native_api"], 1))
|
||||
sess.save()
|
||||
|
||||
loaded = ExecutionSession.load("save_test")
|
||||
assert loaded is not None
|
||||
assert loaded.session_id == "save_test"
|
||||
assert loaded.last().macro_name == "m1"
|
||||
@@ -0,0 +1,369 @@
|
||||
"""End-to-end integration tests for the OpenClaw Macro System.
|
||||
|
||||
Tests the full lifecycle: macro discovery → runtime execution → CLI subprocess.
|
||||
Uses real file I/O and subprocess calls (echo, cat, etc.) as the "target apps".
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import textwrap
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
# ── CLI resolver (same pattern as in HARNESS.md) ─────────────────────────────
|
||||
|
||||
def _resolve_cli(name: str) -> list[str]:
|
||||
"""Resolve installed CLI command; falls back to python -m for dev."""
|
||||
import shutil
|
||||
force = os.environ.get("CLI_ANYTHING_FORCE_INSTALLED", "").strip() == "1"
|
||||
path = shutil.which(name)
|
||||
if path:
|
||||
print(f"[_resolve_cli] Using installed command: {path}")
|
||||
return [path]
|
||||
if force:
|
||||
raise RuntimeError(f"{name} not found in PATH. Install with: pip install -e .")
|
||||
module = "cli_anything.openclaw.openclaw_cli"
|
||||
print(f"[_resolve_cli] Falling back to: {sys.executable} -m {module}")
|
||||
return [sys.executable, "-m", "cli_anything.openclaw"]
|
||||
|
||||
|
||||
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
def write_macro(tmp_path: Path, name: str, content: str) -> Path:
|
||||
p = tmp_path / f"{name}.yaml"
|
||||
p.write_text(content, encoding="utf-8")
|
||||
return p
|
||||
|
||||
|
||||
# ── E2E: File transform workflow ──────────────────────────────────────────────
|
||||
|
||||
class TestFileTransformE2E:
|
||||
def test_json_set_and_verify(self, tmp_path):
|
||||
"""Write a JSON file, transform it, verify the result."""
|
||||
from cli_anything.openclaw.core.macro_model import MacroDefinition, MacroStep, MacroCondition, MacroOutput
|
||||
from cli_anything.openclaw.core.registry import MacroRegistry
|
||||
from cli_anything.openclaw.core.runtime import MacroRuntime
|
||||
|
||||
json_file = tmp_path / "settings.json"
|
||||
json_file.write_text('{"version": 1}', encoding="utf-8")
|
||||
|
||||
yaml_content = textwrap.dedent(f"""\
|
||||
name: set_json_key
|
||||
parameters:
|
||||
file:
|
||||
type: string
|
||||
required: true
|
||||
key:
|
||||
type: string
|
||||
required: true
|
||||
value:
|
||||
type: string
|
||||
required: true
|
||||
preconditions:
|
||||
- file_exists: ${{file}}
|
||||
steps:
|
||||
- id: transform
|
||||
backend: file_transform
|
||||
action: json_set
|
||||
params:
|
||||
input_file: ${{file}}
|
||||
output_file: ${{file}}
|
||||
path: ${{key}}
|
||||
value: ${{value}}
|
||||
postconditions:
|
||||
- file_exists: ${{file}}
|
||||
outputs:
|
||||
- name: modified_file
|
||||
path: ${{file}}
|
||||
""")
|
||||
write_macro(tmp_path, "set_json_key", yaml_content)
|
||||
|
||||
reg = MacroRegistry(str(tmp_path))
|
||||
rt = MacroRuntime(registry=reg)
|
||||
result = rt.execute("set_json_key", {
|
||||
"file": str(json_file),
|
||||
"key": "config.theme",
|
||||
"value": "dark",
|
||||
})
|
||||
|
||||
assert result.success, f"Failed: {result.error}"
|
||||
data = json.loads(json_file.read_text())
|
||||
assert data["config"]["theme"] == "dark"
|
||||
print(f"\n Modified JSON: {json_file} → {json.dumps(data)}")
|
||||
|
||||
|
||||
class TestNativeAPIE2E:
|
||||
def test_run_real_command_and_capture(self, tmp_path):
|
||||
"""Run a real shell command and capture its stdout."""
|
||||
from cli_anything.openclaw.core.macro_model import MacroDefinition
|
||||
from cli_anything.openclaw.core.registry import MacroRegistry
|
||||
from cli_anything.openclaw.core.runtime import MacroRuntime
|
||||
|
||||
output_file = tmp_path / "result.txt"
|
||||
yaml_content = textwrap.dedent(f"""\
|
||||
name: capture_date
|
||||
parameters:
|
||||
output:
|
||||
type: string
|
||||
required: true
|
||||
steps:
|
||||
- id: run_date
|
||||
backend: native_api
|
||||
action: run_command
|
||||
params:
|
||||
command: [date, "+%Y-%m-%d"]
|
||||
capture_stdout: true
|
||||
postconditions: []
|
||||
outputs:
|
||||
- name: output_file
|
||||
path: ${{output}}
|
||||
""")
|
||||
write_macro(tmp_path, "capture_date", yaml_content)
|
||||
|
||||
reg = MacroRegistry(str(tmp_path))
|
||||
rt = MacroRuntime(registry=reg)
|
||||
result = rt.execute("capture_date", {"output": str(output_file)})
|
||||
|
||||
assert result.success, f"Failed: {result.error}"
|
||||
# Step output contains stdout
|
||||
steps_output = result.output.get("_steps", [])
|
||||
assert steps_output, "Expected _steps in output"
|
||||
stdout = steps_output[0].get("stdout", "")
|
||||
# Date format YYYY-MM-DD
|
||||
import re
|
||||
assert re.match(r"\d{4}-\d{2}-\d{2}", stdout.strip()), f"Unexpected stdout: {stdout!r}"
|
||||
print(f"\n Captured date: {stdout.strip()}")
|
||||
|
||||
def test_step_failure_aborts_macro(self, tmp_path):
|
||||
"""A failing step with on_failure=fail should abort the macro."""
|
||||
from cli_anything.openclaw.core.registry import MacroRegistry
|
||||
from cli_anything.openclaw.core.runtime import MacroRuntime
|
||||
|
||||
yaml_content = textwrap.dedent("""\
|
||||
name: fail_abort
|
||||
steps:
|
||||
- id: bad
|
||||
backend: native_api
|
||||
action: run_command
|
||||
params:
|
||||
command: [false]
|
||||
on_failure: fail
|
||||
- id: good
|
||||
backend: native_api
|
||||
action: run_command
|
||||
params:
|
||||
command: [echo, should_not_run]
|
||||
""")
|
||||
write_macro(tmp_path, "fail_abort", yaml_content)
|
||||
reg = MacroRegistry(str(tmp_path))
|
||||
rt = MacroRuntime(registry=reg)
|
||||
result = rt.execute("fail_abort", {})
|
||||
|
||||
assert not result.success
|
||||
# Only the first step should have run
|
||||
assert result.telemetry["steps_run"] == 1
|
||||
print(f"\n Correctly aborted after first step: {result.error}")
|
||||
|
||||
def test_step_failure_skip_continues(self, tmp_path):
|
||||
"""A failing step with on_failure=skip should allow the macro to continue."""
|
||||
from cli_anything.openclaw.core.registry import MacroRegistry
|
||||
from cli_anything.openclaw.core.runtime import MacroRuntime
|
||||
|
||||
yaml_content = textwrap.dedent("""\
|
||||
name: fail_skip
|
||||
steps:
|
||||
- id: bad
|
||||
backend: native_api
|
||||
action: run_command
|
||||
params:
|
||||
command: [false]
|
||||
on_failure: skip
|
||||
- id: good
|
||||
backend: native_api
|
||||
action: run_command
|
||||
params:
|
||||
command: [echo, still_ran]
|
||||
""")
|
||||
write_macro(tmp_path, "fail_skip", yaml_content)
|
||||
reg = MacroRegistry(str(tmp_path))
|
||||
rt = MacroRuntime(registry=reg)
|
||||
result = rt.execute("fail_skip", {})
|
||||
|
||||
assert result.success
|
||||
assert result.telemetry["steps_run"] == 2
|
||||
print(f"\n Macro succeeded despite skipped step")
|
||||
|
||||
|
||||
class TestPostconditionE2E:
|
||||
def test_postcondition_file_exists_passes(self, tmp_path):
|
||||
from cli_anything.openclaw.core.registry import MacroRegistry
|
||||
from cli_anything.openclaw.core.runtime import MacroRuntime
|
||||
|
||||
output_file = tmp_path / "output.txt"
|
||||
yaml_content = textwrap.dedent(f"""\
|
||||
name: write_and_verify
|
||||
parameters:
|
||||
output:
|
||||
type: string
|
||||
required: true
|
||||
steps:
|
||||
- id: write
|
||||
backend: file_transform
|
||||
action: text_replace
|
||||
params:
|
||||
input_file: /dev/null
|
||||
output_file: ${{output}}
|
||||
find: ""
|
||||
replace: ""
|
||||
postconditions:
|
||||
- file_exists: ${{output}}
|
||||
""")
|
||||
write_macro(tmp_path, "write_and_verify", yaml_content)
|
||||
reg = MacroRegistry(str(tmp_path))
|
||||
rt = MacroRuntime(registry=reg)
|
||||
result = rt.execute("write_and_verify", {"output": str(output_file)})
|
||||
assert result.success, f"Failed: {result.error}"
|
||||
print(f"\n Output file: {output_file} ({output_file.stat().st_size} bytes)")
|
||||
|
||||
def test_postcondition_file_size_gt(self, tmp_path):
|
||||
from cli_anything.openclaw.core.registry import MacroRegistry
|
||||
from cli_anything.openclaw.core.runtime import MacroRuntime
|
||||
|
||||
output_file = tmp_path / "output.txt"
|
||||
output_file.write_text("x" * 200, encoding="utf-8")
|
||||
|
||||
yaml_content = textwrap.dedent(f"""\
|
||||
name: size_check
|
||||
parameters:
|
||||
output:
|
||||
type: string
|
||||
required: true
|
||||
steps:
|
||||
- id: noop
|
||||
backend: native_api
|
||||
action: run_command
|
||||
params:
|
||||
command: [echo, noop]
|
||||
postconditions:
|
||||
- file_size_gt:
|
||||
- ${{output}}
|
||||
- 100
|
||||
""")
|
||||
write_macro(tmp_path, "size_check", yaml_content)
|
||||
reg = MacroRegistry(str(tmp_path))
|
||||
rt = MacroRuntime(registry=reg)
|
||||
result = rt.execute("size_check", {"output": str(output_file)})
|
||||
assert result.success, f"Failed: {result.error}"
|
||||
print(f"\n File size: {output_file.stat().st_size} bytes")
|
||||
|
||||
|
||||
# ── CLI subprocess tests ──────────────────────────────────────────────────────
|
||||
|
||||
class TestCLISubprocess:
|
||||
CLI_BASE = _resolve_cli("cli-anything-openclaw")
|
||||
|
||||
def _run(self, args: list[str], check: bool = True) -> subprocess.CompletedProcess:
|
||||
return subprocess.run(
|
||||
self.CLI_BASE + args,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=check,
|
||||
)
|
||||
|
||||
def test_help(self):
|
||||
result = self._run(["--help"])
|
||||
assert result.returncode == 0
|
||||
assert "macro" in result.stdout.lower()
|
||||
|
||||
def test_macro_list_json(self):
|
||||
result = self._run(["--json", "macro", "list"])
|
||||
assert result.returncode == 0
|
||||
data = json.loads(result.stdout)
|
||||
assert isinstance(data, list)
|
||||
print(f"\n macros: {[m['name'] for m in data]}")
|
||||
|
||||
def test_macro_info_json(self):
|
||||
# Info on a bundled example macro
|
||||
result = self._run(["--json", "macro", "info", "export_file"])
|
||||
assert result.returncode == 0, f"stderr: {result.stderr}"
|
||||
data = json.loads(result.stdout)
|
||||
assert data["name"] == "export_file"
|
||||
assert "parameters" in data
|
||||
print(f"\n Macro info: {data['name']} v{data['version']}")
|
||||
|
||||
def test_macro_validate_all(self):
|
||||
result = self._run(["--json", "macro", "validate"])
|
||||
assert result.returncode == 0
|
||||
data = json.loads(result.stdout)
|
||||
assert isinstance(data, dict)
|
||||
for name, info in data.items():
|
||||
assert info["valid"], f"Macro {name} failed: {info['errors']}"
|
||||
print(f"\n Validated {len(data)} macros, all valid")
|
||||
|
||||
def test_macro_dry_run_json(self):
|
||||
result = self._run([
|
||||
"--json", "--dry-run",
|
||||
"macro", "run", "export_file",
|
||||
"--param", "output=/tmp/test_openclaw_e2e.txt",
|
||||
])
|
||||
assert result.returncode == 0, f"stderr: {result.stderr}"
|
||||
data = json.loads(result.stdout)
|
||||
assert data["success"] is True
|
||||
assert data["telemetry"]["dry_run"] is True
|
||||
print(f"\n Dry run result: {data['success']}")
|
||||
|
||||
def test_backends_json(self):
|
||||
result = self._run(["--json", "backends"])
|
||||
assert result.returncode == 0
|
||||
data = json.loads(result.stdout)
|
||||
assert "native_api" in data
|
||||
assert "file_transform" in data
|
||||
print(f"\n Backends: {list(data.keys())}")
|
||||
|
||||
def test_session_status_json(self):
|
||||
result = self._run(["--json", "session", "status"])
|
||||
assert result.returncode == 0
|
||||
data = json.loads(result.stdout)
|
||||
assert "session_id" in data
|
||||
print(f"\n Session: {data['session_id']}")
|
||||
|
||||
def test_macro_run_json_transform_workflow(self, tmp_path):
|
||||
"""Full E2E: create a JSON file, run transform_json macro, verify output."""
|
||||
json_file = tmp_path / "data.json"
|
||||
json_file.write_text('{"name": "test"}', encoding="utf-8")
|
||||
|
||||
result = self._run([
|
||||
"--json",
|
||||
"macro", "run", "transform_json",
|
||||
"--param", f"file={json_file}",
|
||||
"--param", "key=config.mode",
|
||||
"--param", "value=production",
|
||||
])
|
||||
print(f"\n CLI stdout: {result.stdout[:200]}")
|
||||
print(f" CLI stderr: {result.stderr[:200]}")
|
||||
|
||||
assert result.returncode == 0, f"CLI failed:\n{result.stderr}"
|
||||
data = json.loads(result.stdout)
|
||||
assert data["success"] is True, f"Macro failed: {data.get('error')}"
|
||||
|
||||
# Verify file was actually modified
|
||||
modified = json.loads(json_file.read_text())
|
||||
assert modified["config"]["mode"] == "production"
|
||||
print(f"\n Modified JSON: {modified}")
|
||||
print(f" File: {json_file} ({json_file.stat().st_size} bytes)")
|
||||
|
||||
def test_unknown_macro_returns_error_json(self):
|
||||
result = self._run(
|
||||
["--json", "macro", "run", "nonexistent_macro_xyz"],
|
||||
check=False,
|
||||
)
|
||||
assert result.returncode != 0
|
||||
data = json.loads(result.stdout)
|
||||
assert data["success"] is False
|
||||
assert "error" in data
|
||||
print(f"\n Error: {data['error']}")
|
||||
@@ -0,0 +1,521 @@
|
||||
"""cli-anything REPL Skin — Unified terminal interface for all CLI harnesses.
|
||||
|
||||
Copy this file into your CLI package at:
|
||||
cli_anything/<software>/utils/repl_skin.py
|
||||
|
||||
Usage:
|
||||
from cli_anything.<software>.utils.repl_skin import ReplSkin
|
||||
|
||||
skin = ReplSkin("shotcut", version="1.0.0")
|
||||
skin.print_banner() # auto-detects skills/SKILL.md inside the package
|
||||
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
|
||||
|
||||
# ── ANSI color codes (no external deps for core styling) ──────────────
|
||||
|
||||
_RESET = "\033[0m"
|
||||
_BOLD = "\033[1m"
|
||||
_DIM = "\033[2m"
|
||||
_ITALIC = "\033[3m"
|
||||
_UNDERLINE = "\033[4m"
|
||||
|
||||
# Brand colors
|
||||
_CYAN = "\033[38;5;80m" # cli-anything brand cyan
|
||||
_CYAN_BG = "\033[48;5;80m"
|
||||
_WHITE = "\033[97m"
|
||||
_GRAY = "\033[38;5;245m"
|
||||
_DARK_GRAY = "\033[38;5;240m"
|
||||
_LIGHT_GRAY = "\033[38;5;250m"
|
||||
|
||||
# Software accent colors — each software gets a unique accent
|
||||
_ACCENT_COLORS = {
|
||||
"gimp": "\033[38;5;214m", # warm orange
|
||||
"blender": "\033[38;5;208m", # deep orange
|
||||
"inkscape": "\033[38;5;39m", # bright blue
|
||||
"audacity": "\033[38;5;33m", # navy blue
|
||||
"libreoffice": "\033[38;5;40m", # green
|
||||
"obs_studio": "\033[38;5;55m", # purple
|
||||
"kdenlive": "\033[38;5;69m", # slate blue
|
||||
"shotcut": "\033[38;5;35m", # teal green
|
||||
}
|
||||
_DEFAULT_ACCENT = "\033[38;5;75m" # default sky blue
|
||||
|
||||
# Status colors
|
||||
_GREEN = "\033[38;5;78m"
|
||||
_YELLOW = "\033[38;5;220m"
|
||||
_RED = "\033[38;5;196m"
|
||||
_BLUE = "\033[38;5;75m"
|
||||
_MAGENTA = "\033[38;5;176m"
|
||||
|
||||
# ── Brand icon ────────────────────────────────────────────────────────
|
||||
|
||||
# The cli-anything icon: a small colored diamond/chevron mark
|
||||
_ICON = f"{_CYAN}{_BOLD}◆{_RESET}"
|
||||
_ICON_SMALL = f"{_CYAN}▸{_RESET}"
|
||||
|
||||
# ── Box drawing characters ────────────────────────────────────────────
|
||||
|
||||
_H_LINE = "─"
|
||||
_V_LINE = "│"
|
||||
_TL = "╭"
|
||||
_TR = "╮"
|
||||
_BL = "╰"
|
||||
_BR = "╯"
|
||||
_T_DOWN = "┬"
|
||||
_T_UP = "┴"
|
||||
_T_RIGHT = "├"
|
||||
_T_LEFT = "┤"
|
||||
_CROSS = "┼"
|
||||
|
||||
|
||||
def _strip_ansi(text: str) -> str:
|
||||
"""Remove ANSI escape codes for length calculation."""
|
||||
import re
|
||||
return re.sub(r"\033\[[^m]*m", "", text)
|
||||
|
||||
|
||||
def _visible_len(text: str) -> int:
|
||||
"""Get visible length of text (excluding ANSI codes)."""
|
||||
return len(_strip_ansi(text))
|
||||
|
||||
|
||||
class ReplSkin:
|
||||
"""Unified REPL skin for cli-anything CLIs.
|
||||
|
||||
Provides consistent branding, prompts, and message formatting
|
||||
across all CLI harnesses built with the cli-anything methodology.
|
||||
"""
|
||||
|
||||
def __init__(self, software: str, version: str = "1.0.0",
|
||||
history_file: str | None = None, skill_path: str | None = None):
|
||||
"""Initialize the REPL skin.
|
||||
|
||||
Args:
|
||||
software: Software name (e.g., "gimp", "shotcut", "blender").
|
||||
version: CLI version string.
|
||||
history_file: Path for persistent command history.
|
||||
Defaults to ~/.cli-anything-<software>/history
|
||||
skill_path: Path to the SKILL.md file for agent discovery.
|
||||
Auto-detected from the package's skills/ directory if not provided.
|
||||
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
|
||||
|
||||
# Auto-detect skill path from package layout:
|
||||
# cli_anything/<software>/utils/repl_skin.py (this file)
|
||||
# cli_anything/<software>/skills/SKILL.md (target)
|
||||
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)
|
||||
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")
|
||||
else:
|
||||
self.history_file = history_file
|
||||
|
||||
# Detect terminal capabilities
|
||||
self._color = self._detect_color_support()
|
||||
|
||||
def _detect_color_support(self) -> bool:
|
||||
"""Check if terminal supports color."""
|
||||
if os.environ.get("NO_COLOR"):
|
||||
return False
|
||||
if os.environ.get("CLI_ANYTHING_NO_COLOR"):
|
||||
return False
|
||||
if not hasattr(sys.stdout, "isatty"):
|
||||
return False
|
||||
return sys.stdout.isatty()
|
||||
|
||||
def _c(self, code: str, text: str) -> str:
|
||||
"""Apply color code if colors are supported."""
|
||||
if not self._color:
|
||||
return text
|
||||
return f"{code}{text}{_RESET}"
|
||||
|
||||
# ── Banner ────────────────────────────────────────────────────────
|
||||
|
||||
def print_banner(self):
|
||||
"""Print the startup banner with branding."""
|
||||
inner = 54
|
||||
|
||||
def _box_line(content: str) -> str:
|
||||
"""Wrap content in box drawing, padding to inner width."""
|
||||
pad = inner - _visible_len(content)
|
||||
vl = self._c(_DARK_GRAY, _V_LINE)
|
||||
return f"{vl}{content}{' ' * max(0, pad)}{vl}"
|
||||
|
||||
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 · Shotcut
|
||||
icon = self._c(_CYAN + _BOLD, "◆")
|
||||
brand = self._c(_CYAN + _BOLD, "cli-anything")
|
||||
dot = self._c(_DARK_GRAY, "·")
|
||||
name = self._c(self.accent + _BOLD, self.display_name)
|
||||
title = f" {icon} {brand} {dot} {name}"
|
||||
|
||||
ver = f" {self._c(_DARK_GRAY, f' v{self.version}')}"
|
||||
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}"
|
||||
|
||||
print(top)
|
||||
print(_box_line(title))
|
||||
print(_box_line(ver))
|
||||
if skill_line:
|
||||
print(_box_line(skill_line))
|
||||
print(_box_line(empty))
|
||||
print(_box_line(tip))
|
||||
print(bot)
|
||||
print()
|
||||
|
||||
# ── Prompt ────────────────────────────────────────────────────────
|
||||
|
||||
def prompt(self, project_name: str = "", modified: bool = False,
|
||||
context: str = "") -> str:
|
||||
"""Build a styled prompt string for prompt_toolkit or input().
|
||||
|
||||
Args:
|
||||
project_name: Current project name (empty if none open).
|
||||
modified: Whether the project has unsaved changes.
|
||||
context: Optional extra context to show in prompt.
|
||||
|
||||
Returns:
|
||||
Formatted prompt string.
|
||||
"""
|
||||
parts = []
|
||||
|
||||
# Icon
|
||||
if self._color:
|
||||
parts.append(f"{_CYAN}◆{_RESET} ")
|
||||
else:
|
||||
parts.append("> ")
|
||||
|
||||
# Software name
|
||||
parts.append(self._c(self.accent + _BOLD, self.software))
|
||||
|
||||
# Project context
|
||||
if project_name or context:
|
||||
ctx = context or project_name
|
||||
mod = "*" if modified else ""
|
||||
parts.append(f" {self._c(_DARK_GRAY, '[')}")
|
||||
parts.append(self._c(_LIGHT_GRAY, f"{ctx}{mod}"))
|
||||
parts.append(self._c(_DARK_GRAY, ']'))
|
||||
|
||||
parts.append(self._c(_GRAY, " ❯ "))
|
||||
|
||||
return "".join(parts)
|
||||
|
||||
def prompt_tokens(self, project_name: str = "", modified: bool = False,
|
||||
context: str = ""):
|
||||
"""Build prompt_toolkit formatted text tokens for the prompt.
|
||||
|
||||
Use with prompt_toolkit's FormattedText for proper ANSI handling.
|
||||
|
||||
Returns:
|
||||
list of (style, text) tuples for prompt_toolkit.
|
||||
"""
|
||||
accent_hex = _ANSI_256_TO_HEX.get(self.accent, "#5fafff")
|
||||
tokens = []
|
||||
|
||||
tokens.append(("class:icon", "◆ "))
|
||||
tokens.append(("class:software", self.software))
|
||||
|
||||
if project_name or context:
|
||||
ctx = context or project_name
|
||||
mod = "*" if modified else ""
|
||||
tokens.append(("class:bracket", " ["))
|
||||
tokens.append(("class:context", f"{ctx}{mod}"))
|
||||
tokens.append(("class:bracket", "]"))
|
||||
|
||||
tokens.append(("class:arrow", " ❯ "))
|
||||
|
||||
return tokens
|
||||
|
||||
def get_prompt_style(self):
|
||||
"""Get a prompt_toolkit Style object matching the skin.
|
||||
|
||||
Returns:
|
||||
prompt_toolkit.styles.Style
|
||||
"""
|
||||
try:
|
||||
from prompt_toolkit.styles import Style
|
||||
except ImportError:
|
||||
return None
|
||||
|
||||
accent_hex = _ANSI_256_TO_HEX.get(self.accent, "#5fafff")
|
||||
|
||||
return Style.from_dict({
|
||||
"icon": "#5fdfdf bold", # cyan brand color
|
||||
"software": f"{accent_hex} bold",
|
||||
"bracket": "#585858",
|
||||
"context": "#bcbcbc",
|
||||
"arrow": "#808080",
|
||||
# Completion menu
|
||||
"completion-menu.completion": "bg:#303030 #bcbcbc",
|
||||
"completion-menu.completion.current": f"bg:{accent_hex} #000000",
|
||||
"completion-menu.meta.completion": "bg:#303030 #808080",
|
||||
"completion-menu.meta.completion.current": f"bg:{accent_hex} #000000",
|
||||
# Auto-suggest
|
||||
"auto-suggest": "#585858",
|
||||
# Bottom toolbar
|
||||
"bottom-toolbar": "bg:#1c1c1c #808080",
|
||||
"bottom-toolbar.text": "#808080",
|
||||
})
|
||||
|
||||
# ── Messages ──────────────────────────────────────────────────────
|
||||
|
||||
def success(self, message: str):
|
||||
"""Print a success message with green checkmark."""
|
||||
icon = self._c(_GREEN + _BOLD, "✓")
|
||||
print(f" {icon} {self._c(_GREEN, message)}")
|
||||
|
||||
def error(self, message: str):
|
||||
"""Print an error message with red cross."""
|
||||
icon = self._c(_RED + _BOLD, "✗")
|
||||
print(f" {icon} {self._c(_RED, message)}", file=sys.stderr)
|
||||
|
||||
def warning(self, message: str):
|
||||
"""Print a warning message with yellow triangle."""
|
||||
icon = self._c(_YELLOW + _BOLD, "⚠")
|
||||
print(f" {icon} {self._c(_YELLOW, message)}")
|
||||
|
||||
def info(self, message: str):
|
||||
"""Print an info message with blue dot."""
|
||||
icon = self._c(_BLUE, "●")
|
||||
print(f" {icon} {self._c(_LIGHT_GRAY, message)}")
|
||||
|
||||
def hint(self, message: str):
|
||||
"""Print a subtle hint message."""
|
||||
print(f" {self._c(_DARK_GRAY, message)}")
|
||||
|
||||
def section(self, title: str):
|
||||
"""Print a section header."""
|
||||
print()
|
||||
print(f" {self._c(self.accent + _BOLD, title)}")
|
||||
print(f" {self._c(_DARK_GRAY, _H_LINE * len(title))}")
|
||||
|
||||
# ── Status display ────────────────────────────────────────────────
|
||||
|
||||
def status(self, label: str, value: str):
|
||||
"""Print a key-value status line."""
|
||||
lbl = self._c(_GRAY, f" {label}:")
|
||||
val = self._c(_WHITE, f" {value}")
|
||||
print(f"{lbl}{val}")
|
||||
|
||||
def status_block(self, items: dict[str, str], title: str = ""):
|
||||
"""Print a block of status key-value pairs.
|
||||
|
||||
Args:
|
||||
items: Dict of label -> value pairs.
|
||||
title: Optional title for the block.
|
||||
"""
|
||||
if title:
|
||||
self.section(title)
|
||||
|
||||
max_key = max(len(k) for k in items) if items else 0
|
||||
for label, value in items.items():
|
||||
lbl = self._c(_GRAY, f" {label:<{max_key}}")
|
||||
val = self._c(_WHITE, f" {value}")
|
||||
print(f"{lbl}{val}")
|
||||
|
||||
def progress(self, current: int, total: int, label: str = ""):
|
||||
"""Print a simple progress indicator.
|
||||
|
||||
Args:
|
||||
current: Current step number.
|
||||
total: Total number of steps.
|
||||
label: Optional label for the progress.
|
||||
"""
|
||||
pct = int(current / total * 100) if total > 0 else 0
|
||||
bar_width = 20
|
||||
filled = int(bar_width * current / total) if total > 0 else 0
|
||||
bar = "█" * filled + "░" * (bar_width - filled)
|
||||
text = f" {self._c(_CYAN, bar)} {self._c(_GRAY, f'{pct:3d}%')}"
|
||||
if label:
|
||||
text += f" {self._c(_LIGHT_GRAY, label)}"
|
||||
print(text)
|
||||
|
||||
# ── Table display ─────────────────────────────────────────────────
|
||||
|
||||
def table(self, headers: list[str], rows: list[list[str]],
|
||||
max_col_width: int = 40):
|
||||
"""Print a formatted table with box-drawing characters.
|
||||
|
||||
Args:
|
||||
headers: Column header strings.
|
||||
rows: List of rows, each a list of cell strings.
|
||||
max_col_width: Maximum column width before truncation.
|
||||
"""
|
||||
if not headers:
|
||||
return
|
||||
|
||||
# Calculate column widths
|
||||
col_widths = [min(len(h), max_col_width) for h in headers]
|
||||
for row in rows:
|
||||
for i, cell in enumerate(row):
|
||||
if i < len(col_widths):
|
||||
col_widths[i] = min(
|
||||
max(col_widths[i], len(str(cell))), max_col_width
|
||||
)
|
||||
|
||||
def pad(text: str, width: int) -> str:
|
||||
t = str(text)[:width]
|
||||
return t + " " * (width - len(t))
|
||||
|
||||
# Header
|
||||
header_cells = [
|
||||
self._c(_CYAN + _BOLD, pad(h, col_widths[i]))
|
||||
for i, h in enumerate(headers)
|
||||
]
|
||||
sep = self._c(_DARK_GRAY, f" {_V_LINE} ")
|
||||
header_line = f" {sep.join(header_cells)}"
|
||||
print(header_line)
|
||||
|
||||
# Separator
|
||||
sep_parts = [self._c(_DARK_GRAY, _H_LINE * w) for w in col_widths]
|
||||
sep_line = self._c(_DARK_GRAY, f" {'───'.join([_H_LINE * w for w in col_widths])}")
|
||||
print(sep_line)
|
||||
|
||||
# Rows
|
||||
for row in rows:
|
||||
cells = []
|
||||
for i, cell in enumerate(row):
|
||||
if i < len(col_widths):
|
||||
cells.append(self._c(_LIGHT_GRAY, pad(str(cell), col_widths[i])))
|
||||
row_sep = self._c(_DARK_GRAY, f" {_V_LINE} ")
|
||||
print(f" {row_sep.join(cells)}")
|
||||
|
||||
# ── Help display ──────────────────────────────────────────────────
|
||||
|
||||
def help(self, commands: dict[str, str]):
|
||||
"""Print a formatted help listing.
|
||||
|
||||
Args:
|
||||
commands: Dict of command -> description pairs.
|
||||
"""
|
||||
self.section("Commands")
|
||||
max_cmd = max(len(c) for c in commands) if commands else 0
|
||||
for cmd, desc in commands.items():
|
||||
cmd_styled = self._c(self.accent, f" {cmd:<{max_cmd}}")
|
||||
desc_styled = self._c(_GRAY, f" {desc}")
|
||||
print(f"{cmd_styled}{desc_styled}")
|
||||
print()
|
||||
|
||||
# ── Goodbye ───────────────────────────────────────────────────────
|
||||
|
||||
def print_goodbye(self):
|
||||
"""Print a styled goodbye message."""
|
||||
print(f"\n {_ICON_SMALL} {self._c(_GRAY, 'Goodbye!')}\n")
|
||||
|
||||
# ── Prompt toolkit session factory ────────────────────────────────
|
||||
|
||||
def create_prompt_session(self):
|
||||
"""Create a prompt_toolkit PromptSession with skin styling.
|
||||
|
||||
Returns:
|
||||
A configured PromptSession, or None if prompt_toolkit unavailable.
|
||||
"""
|
||||
try:
|
||||
from prompt_toolkit import PromptSession
|
||||
from prompt_toolkit.history import FileHistory
|
||||
from prompt_toolkit.auto_suggest import AutoSuggestFromHistory
|
||||
from prompt_toolkit.formatted_text import FormattedText
|
||||
|
||||
style = self.get_prompt_style()
|
||||
|
||||
session = PromptSession(
|
||||
history=FileHistory(self.history_file),
|
||||
auto_suggest=AutoSuggestFromHistory(),
|
||||
style=style,
|
||||
enable_history_search=True,
|
||||
)
|
||||
return session
|
||||
except ImportError:
|
||||
return None
|
||||
|
||||
def get_input(self, pt_session, project_name: str = "",
|
||||
modified: bool = False, context: str = "") -> str:
|
||||
"""Get input from user using prompt_toolkit or fallback.
|
||||
|
||||
Args:
|
||||
pt_session: A prompt_toolkit PromptSession (or None).
|
||||
project_name: Current project name.
|
||||
modified: Whether project has unsaved changes.
|
||||
context: Optional context string.
|
||||
|
||||
Returns:
|
||||
User input string (stripped).
|
||||
"""
|
||||
if pt_session is not None:
|
||||
from prompt_toolkit.formatted_text import FormattedText
|
||||
tokens = self.prompt_tokens(project_name, modified, context)
|
||||
return pt_session.prompt(FormattedText(tokens)).strip()
|
||||
else:
|
||||
raw_prompt = self.prompt(project_name, modified, context)
|
||||
return input(raw_prompt).strip()
|
||||
|
||||
# ── Toolbar builder ───────────────────────────────────────────────
|
||||
|
||||
def bottom_toolbar(self, items: dict[str, str]):
|
||||
"""Create a bottom toolbar callback for prompt_toolkit.
|
||||
|
||||
Args:
|
||||
items: Dict of label -> value pairs to show in toolbar.
|
||||
|
||||
Returns:
|
||||
A callable that returns FormattedText for the toolbar.
|
||||
"""
|
||||
def toolbar():
|
||||
from prompt_toolkit.formatted_text import FormattedText
|
||||
parts = []
|
||||
for i, (k, v) in enumerate(items.items()):
|
||||
if i > 0:
|
||||
parts.append(("class:bottom-toolbar.text", " │ "))
|
||||
parts.append(("class:bottom-toolbar.text", f" {k}: "))
|
||||
parts.append(("class:bottom-toolbar", v))
|
||||
return FormattedText(parts)
|
||||
return toolbar
|
||||
|
||||
|
||||
# ── ANSI 256-color to hex mapping (for prompt_toolkit styles) ─────────
|
||||
|
||||
_ANSI_256_TO_HEX = {
|
||||
"\033[38;5;33m": "#0087ff", # audacity navy blue
|
||||
"\033[38;5;35m": "#00af5f", # shotcut teal
|
||||
"\033[38;5;39m": "#00afff", # inkscape bright blue
|
||||
"\033[38;5;40m": "#00d700", # libreoffice green
|
||||
"\033[38;5;55m": "#5f00af", # obs purple
|
||||
"\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;208m": "#ff8700", # blender deep orange
|
||||
"\033[38;5;214m": "#ffaf00", # gimp warm orange
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
setup.py for cli-anything-openclaw
|
||||
|
||||
Install with: pip install -e .
|
||||
"""
|
||||
|
||||
from setuptools import setup, find_namespace_packages
|
||||
|
||||
with open("cli_anything/openclaw/README.md", "r", encoding="utf-8") as fh:
|
||||
long_description = fh.read()
|
||||
|
||||
setup(
|
||||
name="cli-anything-openclaw",
|
||||
version="1.0.0",
|
||||
author="cli-anything contributors",
|
||||
author_email="",
|
||||
description=(
|
||||
"OpenClaw Macro System — A layered CLI that converts GUI workflows into "
|
||||
"parameterized, agent-callable macros. Requires: PyYAML, click, prompt-toolkit."
|
||||
),
|
||||
long_description=long_description,
|
||||
long_description_content_type="text/markdown",
|
||||
url="https://github.com/HKUDS/CLI-Anything",
|
||||
packages=find_namespace_packages(include=["cli_anything.*"]),
|
||||
classifiers=[
|
||||
"Development Status :: 4 - Beta",
|
||||
"Intended Audience :: Developers",
|
||||
"Topic :: Software Development :: Libraries :: Python Modules",
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
],
|
||||
python_requires=">=3.10",
|
||||
install_requires=[
|
||||
"click>=8.0.0",
|
||||
"prompt-toolkit>=3.0.0",
|
||||
"PyYAML>=6.0",
|
||||
],
|
||||
extras_require={
|
||||
"dev": [
|
||||
"pytest>=7.0.0",
|
||||
"pytest-cov>=4.0.0",
|
||||
],
|
||||
},
|
||||
entry_points={
|
||||
"console_scripts": [
|
||||
"cli-anything-openclaw=cli_anything.openclaw.openclaw_cli:cli",
|
||||
],
|
||||
},
|
||||
package_data={
|
||||
"cli_anything.openclaw": ["skills/*.md", "macro_definitions/*.yaml", "macro_definitions/examples/*.yaml"],
|
||||
},
|
||||
include_package_data=True,
|
||||
zip_safe=False,
|
||||
)
|
||||
@@ -7,7 +7,7 @@ Usage:
|
||||
from cli_anything.<software>.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-<software>/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-id>/SKILL.md when running
|
||||
# inside the CLI-Anything monorepo. Fall back to the packaged
|
||||
# cli_anything/<software>/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)
|
||||
@@ -459,26 +528,6 @@ class ReplSkin:
|
||||
raw_prompt = self.prompt(project_name, modified, context)
|
||||
return input(raw_prompt).strip()
|
||||
|
||||
# ── Sub-prompt input ────────────────────────────────────────────
|
||||
|
||||
def sub_input(self, prompt_text: str, pt_session=None) -> str:
|
||||
"""Get input for a sub-prompt (e.g., parameter entry in add flows).
|
||||
|
||||
Uses prompt_toolkit if a session is available, otherwise falls back
|
||||
to plain input(). This preserves history and styling consistency.
|
||||
|
||||
Args:
|
||||
prompt_text: The prompt to display (e.g., " start_ms: ").
|
||||
pt_session: An optional prompt_toolkit PromptSession.
|
||||
|
||||
Returns:
|
||||
User input string (stripped).
|
||||
"""
|
||||
if pt_session is not None:
|
||||
return pt_session.prompt(prompt_text).strip()
|
||||
else:
|
||||
return input(prompt_text).strip()
|
||||
|
||||
# ── Toolbar builder ───────────────────────────────────────────────
|
||||
|
||||
def bottom_toolbar(self, items: dict[str, str]):
|
||||
|
||||
@@ -6,20 +6,21 @@ Copy this file into your CLI package at:
|
||||
Usage:
|
||||
from cli_anything.<software>.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-<software>/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-id>/SKILL.md when running
|
||||
# inside the CLI-Anything monorepo. Fall back to the packaged
|
||||
# cli_anything/<software>/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
|
||||
}
|
||||
|
||||
+102
-1
@@ -2,7 +2,7 @@
|
||||
"meta": {
|
||||
"repo": "https://github.com/HKUDS/CLI-Anything",
|
||||
"description": "Public CLI Registry — Third-party and official CLIs managed by CLI-Hub across npm, bundled, brew, and other install methods",
|
||||
"updated": "2026-04-16"
|
||||
"updated": "2026-04-18"
|
||||
},
|
||||
"clis": [
|
||||
{
|
||||
@@ -18,6 +18,7 @@
|
||||
"npm_package": "@larksuite/cli",
|
||||
"install_cmd": "npm install -g @larksuite/cli",
|
||||
"npx_cmd": "npx @larksuite/cli",
|
||||
"skill_md": "npx skills add larksuite/cli -y -g",
|
||||
"entry_point": "lark-cli",
|
||||
"contributors": [
|
||||
{
|
||||
@@ -39,6 +40,7 @@
|
||||
"npm_package": "minimax-cli",
|
||||
"install_cmd": "npm install -g minimax-cli",
|
||||
"npx_cmd": "npx minimax-cli",
|
||||
"skill_md": "https://platform.minimax.io/docs/token-plan/minimax-cli",
|
||||
"entry_point": "minimax-cli",
|
||||
"contributors": [
|
||||
{
|
||||
@@ -60,6 +62,7 @@
|
||||
"npm_package": "@wecom/cli",
|
||||
"install_cmd": "npm install -g @wecom/cli",
|
||||
"npx_cmd": "npx @wecom/cli",
|
||||
"skill_md": "npx skills add WeComTeam/wecom-cli -y -g",
|
||||
"entry_point": "wecom-cli",
|
||||
"contributors": [
|
||||
{
|
||||
@@ -81,6 +84,7 @@
|
||||
"npm_package": "contentful-cli",
|
||||
"install_cmd": "npm install -g contentful-cli",
|
||||
"npx_cmd": "npx contentful-cli",
|
||||
"skill_md": "https://github.com/contentful/contentful-cli/tree/main/docs",
|
||||
"entry_point": "contentful",
|
||||
"contributors": [
|
||||
{
|
||||
@@ -102,6 +106,7 @@
|
||||
"npm_package": "sanity",
|
||||
"install_cmd": "npm install -g sanity",
|
||||
"npx_cmd": "npx sanity@latest",
|
||||
"skill_md": "https://www.sanity.io/docs/apis-and-sdks/cli",
|
||||
"entry_point": "sanity",
|
||||
"contributors": [
|
||||
{
|
||||
@@ -123,6 +128,7 @@
|
||||
"npm_package": "@shopify/cli",
|
||||
"install_cmd": "npm install -g @shopify/cli",
|
||||
"npx_cmd": "npx @shopify/cli@latest",
|
||||
"skill_md": "https://github.com/Shopify/cli/blob/main/packages/cli/README.md#commands",
|
||||
"entry_point": "shopify",
|
||||
"contributors": [
|
||||
{
|
||||
@@ -144,6 +150,7 @@
|
||||
"npm_package": "@sentry/cli",
|
||||
"install_cmd": "npm install -g @sentry/cli",
|
||||
"npx_cmd": "npx @sentry/cli",
|
||||
"skill_md": "https://docs.sentry.io/cli/",
|
||||
"entry_point": "sentry-cli",
|
||||
"contributors": [
|
||||
{
|
||||
@@ -166,6 +173,7 @@
|
||||
"install_cmd": "brew install --cask 1password-cli",
|
||||
"uninstall_cmd": "brew uninstall --cask 1password-cli",
|
||||
"update_cmd": "brew upgrade --cask 1password-cli",
|
||||
"skill_md": "https://developer.1password.com/docs/cli/get-started/",
|
||||
"entry_point": "op",
|
||||
"contributors": [
|
||||
{
|
||||
@@ -174,6 +182,31 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "android-cli",
|
||||
"display_name": "Android CLI",
|
||||
"version": "0.7",
|
||||
"description": "Official Android terminal interface for SDK setup, project creation, emulator/device management, app run/deploy workflows, docs access, and skill management for any agent",
|
||||
"category": "mobile",
|
||||
"requires": "Preview release; Linux x86_64, macOS arm64, or Windows x86_64. Android SDK / JDK requirements depend on the workflow you run.",
|
||||
"homepage": "https://developer.android.com/tools/agents/android-cli",
|
||||
"docs_url": "https://android-developers.googleblog.com/2026/04/build-android-apps-3x-faster-using-any-agent.html",
|
||||
"source_url": null,
|
||||
"package_manager": "bundled",
|
||||
"install_strategy": "bundled",
|
||||
"install_notes": "Install Android CLI from the official archive page: Linux `curl -fsSL https://dl.google.com/android/cli/latest/linux_x86_64/install.sh | bash`; macOS `curl -fsSL https://dl.google.com/android/cli/latest/darwin_arm64/install.sh | bash`; Windows `curl.exe -fsSL https://dl.google.com/android/cli/latest/windows_x86_64/install.cmd -o \"%TEMP%\\i.cmd\" && \"%TEMP%\\i.cmd\"`.",
|
||||
"uninstall_notes": "Remove the Android CLI binary from your PATH or rerun the official installer cleanup flow for your platform.",
|
||||
"update_notes": "Run `android update` to fetch the latest Android CLI capabilities, or reinstall from the official archive page if needed.",
|
||||
"skill_md": "https://developer.android.com/tools/agents/android-skills",
|
||||
"entry_point": "android",
|
||||
"detect_cmd": "android",
|
||||
"contributors": [
|
||||
{
|
||||
"name": "Android Developers",
|
||||
"url": "https://developer.android.com"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "generate-veo-video",
|
||||
"display_name": "Generate Veo Video",
|
||||
@@ -187,6 +220,7 @@
|
||||
"install_cmd": "uv tool install git+https://github.com/charles-forsyth/generate-veo-video.git",
|
||||
"uninstall_cmd": "uv tool uninstall generate-veo-video",
|
||||
"update_cmd": "uv tool upgrade generate-veo-video",
|
||||
"skill_md": "https://github.com/charles-forsyth/generate-veo-video",
|
||||
"entry_point": "generate-veo",
|
||||
"contributors": [
|
||||
{
|
||||
@@ -195,6 +229,51 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "suno",
|
||||
"display_name": "Suno CLI",
|
||||
"version": "latest",
|
||||
"description": "CLI for generating music with Suno AI from lyrics and style prompts, with batch generation, status polling, downloads, and automatic MP3 tagging",
|
||||
"category": "music",
|
||||
"requires": "Python 3 and a Suno API key from sunoapi.org",
|
||||
"homepage": "https://github.com/slauger/suno-cli",
|
||||
"source_url": "https://github.com/slauger/suno-cli",
|
||||
"package_manager": "pip",
|
||||
"install_strategy": "command",
|
||||
"install_cmd": "python3 -m pip install git+https://github.com/slauger/suno-cli.git",
|
||||
"uninstall_cmd": "python3 -m pip uninstall -y suno-cli",
|
||||
"update_cmd": "python3 -m pip install --upgrade --force-reinstall git+https://github.com/slauger/suno-cli.git",
|
||||
"skill_md": "https://github.com/slauger/suno-cli/blob/main/docs/USAGE.md",
|
||||
"entry_point": "suno",
|
||||
"contributors": [
|
||||
{
|
||||
"name": "slauger",
|
||||
"url": "https://github.com/slauger"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "elevenlabs",
|
||||
"display_name": "ElevenLabs CLI",
|
||||
"version": "latest",
|
||||
"description": "Official ElevenLabs CLI for managing voice agents as code with local configs, templates, auth, push/pull sync, tests, widgets, and branch-aware workflows",
|
||||
"category": "audio",
|
||||
"requires": "Node.js and npm, ELEVENLABS_API_KEY or interactive login",
|
||||
"homepage": "https://github.com/elevenlabs/cli",
|
||||
"source_url": "https://github.com/elevenlabs/cli",
|
||||
"package_manager": "npm",
|
||||
"npm_package": "@elevenlabs/cli",
|
||||
"install_cmd": "npm install -g @elevenlabs/cli",
|
||||
"npx_cmd": "npx @elevenlabs/cli@latest",
|
||||
"skill_md": "https://github.com/elevenlabs/cli",
|
||||
"entry_point": "elevenlabs",
|
||||
"contributors": [
|
||||
{
|
||||
"name": "ElevenLabs",
|
||||
"url": "https://github.com/elevenlabs"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "jimeng",
|
||||
"display_name": "Jimeng / Dreamina CLI",
|
||||
@@ -208,6 +287,7 @@
|
||||
"package_manager": "script",
|
||||
"install_strategy": "command",
|
||||
"install_cmd": "curl -s https://jimeng.jianying.com/cli | bash",
|
||||
"skill_md": "https://bytedance.larkoffice.com/wiki/FVTwwm0bGiishxkKOoScdHR2nsg",
|
||||
"entry_point": "dreamina",
|
||||
"contributors": [
|
||||
{
|
||||
@@ -230,6 +310,7 @@
|
||||
"install_notes": "Bundled within the Obsidian 1.12+ installer. Enable Command line interface in Obsidian settings and follow the registration prompt so `obsidian` is added to PATH.",
|
||||
"uninstall_notes": "Disable Command line interface from Obsidian settings or remove/update the Obsidian installer to stop using the bundled CLI.",
|
||||
"update_notes": "Update the Obsidian installer to a newer 1.12+ release to update the bundled CLI.",
|
||||
"skill_md": "https://obsidian.md/help/cli",
|
||||
"entry_point": "obsidian",
|
||||
"detect_cmd": "obsidian",
|
||||
"contributors": [
|
||||
@@ -238,6 +319,26 @@
|
||||
"url": "https://obsidian.md"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "py4csr",
|
||||
"display_name": "TraceCSR / Py4CSR CLI",
|
||||
"version": "latest",
|
||||
"description": "GxP-compliant agent harness for CDISC Clinical Study Report (CSR) and Tables/Figures/Listings (TFL) generation",
|
||||
"category": "data-science",
|
||||
"requires": "Python >= 3.10",
|
||||
"homepage": "https://github.com/yanmingyu92/py4csr",
|
||||
"source_url": "https://github.com/yanmingyu92/py4csr",
|
||||
"skill_md": "https://github.com/yanmingyu92/py4csr",
|
||||
"package_manager": "pip",
|
||||
"install_cmd": "pip install py4csr",
|
||||
"entry_point": "tracecsr",
|
||||
"contributors": [
|
||||
{
|
||||
"name": "yanmingyu92",
|
||||
"url": "https://github.com/yanmingyu92"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
+59
-40
@@ -15,7 +15,7 @@
|
||||
"source_url": null,
|
||||
"install_cmd": "pip install git+https://github.com/HKUDS/CLI-Anything.git#subdirectory=wiremock/agent-harness",
|
||||
"entry_point": "cli-anything-wiremock",
|
||||
"skill_md": "wiremock/agent-harness/cli_anything/wiremock/skills/SKILL.md",
|
||||
"skill_md": "skills/cli-anything-wiremock/SKILL.md",
|
||||
"category": "testing",
|
||||
"contributors": [
|
||||
{
|
||||
@@ -34,7 +34,7 @@
|
||||
"source_url": null,
|
||||
"install_cmd": "pip install git+https://github.com/HKUDS/CLI-Anything.git#subdirectory=anygen/agent-harness",
|
||||
"entry_point": "cli-anything-anygen",
|
||||
"skill_md": "anygen/agent-harness/cli_anything/anygen/skills/SKILL.md",
|
||||
"skill_md": "skills/cli-anything-anygen/SKILL.md",
|
||||
"category": "generation",
|
||||
"contributors": [
|
||||
{
|
||||
@@ -72,7 +72,7 @@
|
||||
"source_url": null,
|
||||
"install_cmd": "pip install git+https://github.com/HKUDS/CLI-Anything.git#subdirectory=audacity/agent-harness",
|
||||
"entry_point": "cli-anything-audacity",
|
||||
"skill_md": "audacity/agent-harness/cli_anything/audacity/skills/SKILL.md",
|
||||
"skill_md": "skills/cli-anything-audacity/SKILL.md",
|
||||
"category": "audio",
|
||||
"contributors": [
|
||||
{
|
||||
@@ -91,7 +91,7 @@
|
||||
"source_url": null,
|
||||
"install_cmd": "pip install git+https://github.com/HKUDS/CLI-Anything.git#subdirectory=blender/agent-harness",
|
||||
"entry_point": "cli-anything-blender",
|
||||
"skill_md": "blender/agent-harness/cli_anything/blender/skills/SKILL.md",
|
||||
"skill_md": "skills/cli-anything-blender/SKILL.md",
|
||||
"category": "3d",
|
||||
"contributors": [
|
||||
{
|
||||
@@ -110,7 +110,7 @@
|
||||
"source_url": null,
|
||||
"install_cmd": "pip install git+https://github.com/HKUDS/CLI-Anything.git#subdirectory=browser/agent-harness",
|
||||
"entry_point": "cli-anything-browser",
|
||||
"skill_md": "browser/agent-harness/cli_anything/browser/skills/SKILL.md",
|
||||
"skill_md": "skills/cli-anything-browser/SKILL.md",
|
||||
"category": "web",
|
||||
"contributors": [
|
||||
{
|
||||
@@ -148,7 +148,7 @@
|
||||
"source_url": null,
|
||||
"install_cmd": "pip install git+https://github.com/HKUDS/CLI-Anything.git#subdirectory=drawio/agent-harness",
|
||||
"entry_point": "cli-anything-drawio",
|
||||
"skill_md": "drawio/agent-harness/cli_anything/drawio/skills/SKILL.md",
|
||||
"skill_md": "skills/cli-anything-drawio/SKILL.md",
|
||||
"category": "diagrams",
|
||||
"contributors": [
|
||||
{
|
||||
@@ -167,7 +167,7 @@
|
||||
"source_url": null,
|
||||
"install_cmd": "pip install git+https://github.com/HKUDS/CLI-Anything.git#subdirectory=eth2-quickstart/agent-harness",
|
||||
"entry_point": "cli-anything-eth2-quickstart",
|
||||
"skill_md": "eth2-quickstart/agent-harness/cli_anything/eth2_quickstart/skills/SKILL.md",
|
||||
"skill_md": "skills/cli-anything-eth2-quickstart/SKILL.md",
|
||||
"category": "devops",
|
||||
"contributors": [
|
||||
{
|
||||
@@ -186,7 +186,7 @@
|
||||
"source_url": null,
|
||||
"install_cmd": "pip install git+https://github.com/HKUDS/CLI-Anything.git#subdirectory=gimp/agent-harness",
|
||||
"entry_point": "cli-anything-gimp",
|
||||
"skill_md": "gimp/agent-harness/cli_anything/gimp/skills/SKILL.md",
|
||||
"skill_md": "skills/cli-anything-gimp/SKILL.md",
|
||||
"category": "image",
|
||||
"contributors": [
|
||||
{
|
||||
@@ -205,7 +205,7 @@
|
||||
"source_url": null,
|
||||
"install_cmd": "pip install git+https://github.com/HKUDS/CLI-Anything.git#subdirectory=inkscape/agent-harness",
|
||||
"entry_point": "cli-anything-inkscape",
|
||||
"skill_md": "inkscape/agent-harness/cli_anything/inkscape/skills/SKILL.md",
|
||||
"skill_md": "skills/cli-anything-inkscape/SKILL.md",
|
||||
"category": "image",
|
||||
"contributors": [
|
||||
{
|
||||
@@ -224,7 +224,7 @@
|
||||
"source_url": null,
|
||||
"install_cmd": "pip install git+https://github.com/HKUDS/CLI-Anything.git#subdirectory=kdenlive/agent-harness",
|
||||
"entry_point": "cli-anything-kdenlive",
|
||||
"skill_md": "kdenlive/agent-harness/cli_anything/kdenlive/skills/SKILL.md",
|
||||
"skill_md": "skills/cli-anything-kdenlive/SKILL.md",
|
||||
"category": "video",
|
||||
"contributors": [
|
||||
{
|
||||
@@ -243,7 +243,7 @@
|
||||
"source_url": null,
|
||||
"install_cmd": "pip install git+https://github.com/HKUDS/CLI-Anything.git#subdirectory=krita/agent-harness",
|
||||
"entry_point": "cli-anything-krita",
|
||||
"skill_md": "krita/agent-harness/cli_anything/krita/skills/SKILL.md",
|
||||
"skill_md": "skills/cli-anything-krita/SKILL.md",
|
||||
"category": "image",
|
||||
"contributors": [
|
||||
{
|
||||
@@ -262,7 +262,7 @@
|
||||
"source_url": null,
|
||||
"install_cmd": "pip install git+https://github.com/HKUDS/CLI-Anything.git#subdirectory=libreoffice/agent-harness",
|
||||
"entry_point": "cli-anything-libreoffice",
|
||||
"skill_md": "libreoffice/agent-harness/cli_anything/libreoffice/skills/SKILL.md",
|
||||
"skill_md": "skills/cli-anything-libreoffice/SKILL.md",
|
||||
"category": "office",
|
||||
"contributors": [
|
||||
{
|
||||
@@ -304,7 +304,7 @@
|
||||
"source_url": null,
|
||||
"install_cmd": "pip install git+https://github.com/HKUDS/CLI-Anything.git#subdirectory=mubu/agent-harness",
|
||||
"entry_point": "cli-anything-mubu",
|
||||
"skill_md": "mubu/agent-harness/cli_anything/mubu/skills/SKILL.md",
|
||||
"skill_md": "skills/cli-anything-mubu/SKILL.md",
|
||||
"category": "office",
|
||||
"contributors": [
|
||||
{
|
||||
@@ -342,7 +342,7 @@
|
||||
"source_url": null,
|
||||
"install_cmd": "pip install git+https://github.com/HKUDS/CLI-Anything.git#subdirectory=notebooklm/agent-harness",
|
||||
"entry_point": "cli-anything-notebooklm",
|
||||
"skill_md": "notebooklm/agent-harness/cli_anything/notebooklm/skills/SKILL.md",
|
||||
"skill_md": "skills/cli-anything-notebooklm/SKILL.md",
|
||||
"category": "ai",
|
||||
"contributors": [
|
||||
{
|
||||
@@ -361,7 +361,7 @@
|
||||
"source_url": null,
|
||||
"install_cmd": "pip install git+https://github.com/HKUDS/CLI-Anything.git#subdirectory=ollama/agent-harness",
|
||||
"entry_point": "cli-anything-ollama",
|
||||
"skill_md": "ollama/agent-harness/cli_anything/ollama/skills/SKILL.md",
|
||||
"skill_md": "skills/cli-anything-ollama/SKILL.md",
|
||||
"category": "ai",
|
||||
"contributors": [
|
||||
{
|
||||
@@ -380,7 +380,7 @@
|
||||
"source_url": null,
|
||||
"install_cmd": "pip install git+https://github.com/HKUDS/CLI-Anything.git#subdirectory=obs-studio/agent-harness",
|
||||
"entry_point": "cli-anything-obs-studio",
|
||||
"skill_md": "obs-studio/agent-harness/cli_anything/obs_studio/skills/SKILL.md",
|
||||
"skill_md": "skills/cli-anything-obs-studio/SKILL.md",
|
||||
"category": "streaming",
|
||||
"contributors": [
|
||||
{
|
||||
@@ -399,7 +399,7 @@
|
||||
"source_url": null,
|
||||
"install_cmd": "pip install git+https://github.com/HKUDS/CLI-Anything.git#subdirectory=shotcut/agent-harness",
|
||||
"entry_point": "cli-anything-shotcut",
|
||||
"skill_md": "shotcut/agent-harness/cli_anything/shotcut/skills/SKILL.md",
|
||||
"skill_md": "skills/cli-anything-shotcut/SKILL.md",
|
||||
"category": "video",
|
||||
"contributors": [
|
||||
{
|
||||
@@ -418,7 +418,7 @@
|
||||
"source_url": null,
|
||||
"install_cmd": "pip install git+https://github.com/HKUDS/CLI-Anything.git#subdirectory=openscreen/agent-harness",
|
||||
"entry_point": "cli-anything-openscreen",
|
||||
"skill_md": "openscreen/agent-harness/cli_anything/openscreen/skills/SKILL.md",
|
||||
"skill_md": "skills/cli-anything-openscreen/SKILL.md",
|
||||
"category": "video",
|
||||
"contributors": [
|
||||
{
|
||||
@@ -437,7 +437,7 @@
|
||||
"source_url": null,
|
||||
"install_cmd": "pip install git+https://github.com/HKUDS/CLI-Anything.git#subdirectory=zoom/agent-harness",
|
||||
"entry_point": "cli-anything-zoom",
|
||||
"skill_md": "zoom/agent-harness/cli_anything/zoom/skills/SKILL.md",
|
||||
"skill_md": "skills/cli-anything-zoom/SKILL.md",
|
||||
"category": "communication",
|
||||
"contributors": [
|
||||
{
|
||||
@@ -456,7 +456,7 @@
|
||||
"source_url": null,
|
||||
"install_cmd": "pip install git+https://github.com/HKUDS/CLI-Anything.git#subdirectory=novita/agent-harness",
|
||||
"entry_point": "cli-anything-novita",
|
||||
"skill_md": "novita/agent-harness/cli_anything/novita/skills/SKILL.md",
|
||||
"skill_md": "skills/cli-anything-novita/SKILL.md",
|
||||
"category": "ai",
|
||||
"contributors": [
|
||||
{
|
||||
@@ -475,7 +475,7 @@
|
||||
"source_url": null,
|
||||
"install_cmd": "pip install git+https://github.com/HKUDS/CLI-Anything.git#subdirectory=seaclip/agent-harness",
|
||||
"entry_point": "cli-anything-seaclip",
|
||||
"skill_md": "seaclip/agent-harness/cli_anything/seaclip/skills/SKILL.md",
|
||||
"skill_md": "skills/cli-anything-seaclip/SKILL.md",
|
||||
"category": "project-management",
|
||||
"contributors": [
|
||||
{
|
||||
@@ -494,7 +494,7 @@
|
||||
"source_url": null,
|
||||
"install_cmd": "pip install git+https://github.com/HKUDS/CLI-Anything.git#subdirectory=pm2/agent-harness",
|
||||
"entry_point": "cli-anything-pm2",
|
||||
"skill_md": "pm2/agent-harness/cli_anything/pm2/skills/SKILL.md",
|
||||
"skill_md": "skills/cli-anything-pm2/SKILL.md",
|
||||
"category": "devops",
|
||||
"contributors": [
|
||||
{
|
||||
@@ -527,7 +527,7 @@
|
||||
"source_url": null,
|
||||
"install_cmd": "pip install git+https://github.com/HKUDS/CLI-Anything.git#subdirectory=chromadb/agent-harness",
|
||||
"entry_point": "cli-anything-chromadb",
|
||||
"skill_md": "chromadb/agent-harness/cli_anything/chromadb/skills/SKILL.md",
|
||||
"skill_md": "skills/cli-anything-chromadb/SKILL.md",
|
||||
"category": "database",
|
||||
"contributors": [
|
||||
{
|
||||
@@ -546,7 +546,7 @@
|
||||
"source_url": null,
|
||||
"install_cmd": "pip install git+https://github.com/HKUDS/CLI-Anything.git#subdirectory=musescore/agent-harness",
|
||||
"entry_point": "cli-anything-musescore",
|
||||
"skill_md": "musescore/agent-harness/cli_anything/musescore/skills/SKILL.md",
|
||||
"skill_md": "skills/cli-anything-musescore/SKILL.md",
|
||||
"category": "music",
|
||||
"contributors": [
|
||||
{
|
||||
@@ -584,7 +584,7 @@
|
||||
"source_url": null,
|
||||
"install_cmd": "pip install git+https://github.com/HKUDS/CLI-Anything.git#subdirectory=freecad/agent-harness",
|
||||
"entry_point": "cli-anything-freecad",
|
||||
"skill_md": "freecad/agent-harness/cli_anything/freecad/skills/SKILL.md",
|
||||
"skill_md": "skills/cli-anything-freecad/SKILL.md",
|
||||
"category": "3d",
|
||||
"contributors": [
|
||||
{
|
||||
@@ -603,7 +603,7 @@
|
||||
"source_url": null,
|
||||
"install_cmd": "pip install git+https://github.com/HKUDS/CLI-Anything.git#subdirectory=iterm2/agent-harness",
|
||||
"entry_point": "cli-anything-iterm2",
|
||||
"skill_md": "iterm2/agent-harness/cli_anything/iterm2_ctl/skills/SKILL.md",
|
||||
"skill_md": "skills/cli-anything-iterm2/SKILL.md",
|
||||
"category": "devops",
|
||||
"contributors": [
|
||||
{
|
||||
@@ -622,7 +622,7 @@
|
||||
"source_url": null,
|
||||
"install_cmd": "pip install git+https://github.com/HKUDS/CLI-Anything.git#subdirectory=slay_the_spire_ii/agent-harness",
|
||||
"entry_point": "cli-anything-sts2",
|
||||
"skill_md": "slay_the_spire_ii/agent-harness/cli_anything/slay_the_spire_ii/skills/SKILL.md",
|
||||
"skill_md": "skills/cli-anything-slay-the-spire-ii/SKILL.md",
|
||||
"category": "game",
|
||||
"contributors": [
|
||||
{
|
||||
@@ -641,7 +641,7 @@
|
||||
"source_url": null,
|
||||
"install_cmd": "pip install git+https://github.com/HKUDS/CLI-Anything.git#subdirectory=rms/agent-harness",
|
||||
"entry_point": "cli-anything-rms",
|
||||
"skill_md": "rms/agent-harness/cli_anything/rms/skills/SKILL.md",
|
||||
"skill_md": "skills/cli-anything-rms/SKILL.md",
|
||||
"category": "network",
|
||||
"contributors": [
|
||||
{
|
||||
@@ -660,7 +660,7 @@
|
||||
"source_url": null,
|
||||
"install_cmd": "pip install git+https://github.com/HKUDS/CLI-Anything.git#subdirectory=renderdoc/agent-harness",
|
||||
"entry_point": "cli-anything-renderdoc",
|
||||
"skill_md": "renderdoc/agent-harness/cli_anything/renderdoc/skills/SKILL.md",
|
||||
"skill_md": "skills/cli-anything-renderdoc/SKILL.md",
|
||||
"category": "graphics",
|
||||
"contributors": [
|
||||
{
|
||||
@@ -698,7 +698,7 @@
|
||||
"source_url": null,
|
||||
"install_cmd": "pip install git+https://github.com/HKUDS/CLI-Anything.git#subdirectory=videocaptioner/agent-harness",
|
||||
"entry_point": "cli-anything-videocaptioner",
|
||||
"skill_md": "videocaptioner/agent-harness/cli_anything/videocaptioner/skills/SKILL.md",
|
||||
"skill_md": "skills/cli-anything-videocaptioner/SKILL.md",
|
||||
"category": "video",
|
||||
"contributors": [
|
||||
{
|
||||
@@ -717,7 +717,7 @@
|
||||
"source_url": null,
|
||||
"install_cmd": "pip install git+https://github.com/HKUDS/CLI-Anything.git#subdirectory=intelwatch/agent-harness",
|
||||
"entry_point": "cli-anything-intelwatch",
|
||||
"skill_md": "intelwatch/agent-harness/cli_anything/intelwatch/skills/SKILL.md",
|
||||
"skill_md": "skills/cli-anything-intelwatch/SKILL.md",
|
||||
"category": "osint",
|
||||
"contributors": [
|
||||
{
|
||||
@@ -755,7 +755,7 @@
|
||||
"source_url": null,
|
||||
"install_cmd": "pip install git+https://github.com/HKUDS/CLI-Anything.git#subdirectory=cloudcompare/agent-harness",
|
||||
"entry_point": "cli-anything-cloudcompare",
|
||||
"skill_md": "cloudcompare/agent-harness/cli_anything/cloudcompare/skills/SKILL.md",
|
||||
"skill_md": "skills/cli-anything-cloudcompare/SKILL.md",
|
||||
"category": "graphics",
|
||||
"contributors": [
|
||||
{
|
||||
@@ -774,7 +774,7 @@
|
||||
"source_url": null,
|
||||
"install_cmd": "pip install git+https://github.com/HKUDS/CLI-Anything.git#subdirectory=exa/agent-harness",
|
||||
"entry_point": "cli-anything-exa",
|
||||
"skill_md": "exa/agent-harness/cli_anything/exa/skills/SKILL.md",
|
||||
"skill_md": "skills/cli-anything-exa/SKILL.md",
|
||||
"category": "search",
|
||||
"contributors": [
|
||||
{
|
||||
@@ -793,7 +793,7 @@
|
||||
"source_url": null,
|
||||
"install_cmd": "pip install git+https://github.com/HKUDS/CLI-Anything.git#subdirectory=godot/agent-harness",
|
||||
"entry_point": "cli-anything-godot",
|
||||
"skill_md": "godot/agent-harness/cli_anything/godot/skills/SKILL.md",
|
||||
"skill_md": "skills/cli-anything-godot/SKILL.md",
|
||||
"category": "gamedev",
|
||||
"contributors": [
|
||||
{
|
||||
@@ -812,7 +812,7 @@
|
||||
"source_url": null,
|
||||
"install_cmd": "pip install git+https://github.com/HKUDS/CLI-Anything.git#subdirectory=dify-workflow/agent-harness",
|
||||
"entry_point": "cli-anything-dify-workflow",
|
||||
"skill_md": "dify-workflow/agent-harness/cli_anything/dify_workflow/skills/SKILL.md",
|
||||
"skill_md": "skills/cli-anything-dify-workflow/SKILL.md",
|
||||
"category": "ai",
|
||||
"contributors": [
|
||||
{
|
||||
@@ -831,7 +831,7 @@
|
||||
"source_url": null,
|
||||
"install_cmd": "pip install git+https://github.com/HKUDS/CLI-Anything.git#subdirectory=n8n/agent-harness",
|
||||
"entry_point": "cli-anything-n8n",
|
||||
"skill_md": "n8n/agent-harness/cli_anything/n8n/skills/SKILL.md",
|
||||
"skill_md": "skills/cli-anything-n8n/SKILL.md",
|
||||
"category": "automation",
|
||||
"contributors": [
|
||||
{
|
||||
@@ -850,7 +850,7 @@
|
||||
"source_url": null,
|
||||
"install_cmd": "pip install git+https://github.com/HKUDS/CLI-Anything.git#subdirectory=cloudanalyzer/agent-harness",
|
||||
"entry_point": "cli-anything-cloudanalyzer",
|
||||
"skill_md": "cloudanalyzer/agent-harness/cli_anything/cloudanalyzer/skills/SKILL.md",
|
||||
"skill_md": "skills/cli-anything-cloudanalyzer/SKILL.md",
|
||||
"category": "graphics",
|
||||
"contributors": [
|
||||
{
|
||||
@@ -869,7 +869,7 @@
|
||||
"source_url": null,
|
||||
"install_cmd": "pip install git+https://github.com/HKUDS/CLI-Anything.git#subdirectory=obsidian/agent-harness",
|
||||
"entry_point": "cli-anything-obsidian",
|
||||
"skill_md": "obsidian/agent-harness/cli_anything/obsidian/skills/SKILL.md",
|
||||
"skill_md": "skills/cli-anything-obsidian/SKILL.md",
|
||||
"category": "knowledge",
|
||||
"contributors": [
|
||||
{
|
||||
@@ -888,7 +888,7 @@
|
||||
"source_url": null,
|
||||
"install_cmd": "pip install git+https://github.com/HKUDS/CLI-Anything.git#subdirectory=unimol_tools/agent-harness",
|
||||
"entry_point": "cli-anything-unimol-tools",
|
||||
"skill_md": "unimol_tools/agent-harness/cli_anything/unimol_tools/SKILL.md",
|
||||
"skill_md": "skills/cli-anything-unimol-tools/SKILL.md",
|
||||
"category": "science",
|
||||
"contributors": [
|
||||
{
|
||||
@@ -907,7 +907,7 @@
|
||||
"source_url": null,
|
||||
"install_cmd": "pip install git+https://github.com/HKUDS/CLI-Anything.git#subdirectory=safari/agent-harness",
|
||||
"entry_point": "cli-anything-safari",
|
||||
"skill_md": "safari/agent-harness/cli_anything/safari/skills/SKILL.md",
|
||||
"skill_md": "skills/cli-anything-safari/SKILL.md",
|
||||
"category": "web",
|
||||
"contributors": [
|
||||
{
|
||||
@@ -915,6 +915,25 @@
|
||||
"url": "https://github.com/achiya-automation"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "openclaw-macro",
|
||||
"display_name": "OpenClaw Macro System",
|
||||
"version": "1.0.0",
|
||||
"description": "Layered CLI that converts GUI workflows into parameterized, agent-callable macros — with backend routing across native APIs, file transforms, accessibility controls, and compiled GUI replay",
|
||||
"requires": null,
|
||||
"homepage": "https://github.com/HKUDS/CLI-Anything",
|
||||
"source_url": null,
|
||||
"install_cmd": "pip install git+https://github.com/HKUDS/CLI-Anything.git#subdirectory=openclaw-skill/agent-harness",
|
||||
"entry_point": "cli-anything-openclaw",
|
||||
"skill_md": "openclaw-skill/agent-harness/cli_anything/openclaw/skills/SKILL.md",
|
||||
"category": "automation",
|
||||
"contributors": [
|
||||
{
|
||||
"name": "haorui-harry",
|
||||
"url": "https://github.com/haorui-harry"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ Usage:
|
||||
from cli_anything.<software>.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-<software>/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/<software>/utils/repl_skin.py (this file)
|
||||
# cli_anything/<software>/skills/SKILL.md (target)
|
||||
# Prefer repo-root canonical skills/<skill-id>/SKILL.md when running
|
||||
# inside the CLI-Anything monorepo. Fall back to the packaged
|
||||
# cli_anything/<software>/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)
|
||||
|
||||
@@ -7,7 +7,7 @@ Usage:
|
||||
from cli_anything.<software>.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) ──────────────
|
||||
|
||||
@@ -47,9 +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
|
||||
"novita": "\033[38;5;81m", # vivid blue (for Novita AI)
|
||||
"rms": "\033[38;5;27m", # Teltonika blue
|
||||
}
|
||||
_DEFAULT_ACCENT = "\033[38;5;75m" # default sky blue
|
||||
|
||||
@@ -60,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
|
||||
@@ -92,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.
|
||||
|
||||
@@ -109,27 +120,44 @@ class ReplSkin:
|
||||
history_file: Path for persistent command history.
|
||||
Defaults to ~/.cli-anything-<software>/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/<software>/utils/repl_skin.py (this file)
|
||||
# cli_anything/<software>/skills/SKILL.md (target)
|
||||
# Prefer repo-root canonical skills/<skill-id>/SKILL.md when running
|
||||
# inside the CLI-Anything monorepo. Fall back to the packaged
|
||||
# cli_anything/<software>/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")
|
||||
@@ -159,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."""
|
||||
@@ -167,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}")
|
||||
|
||||
@@ -181,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)
|
||||
|
||||
@@ -7,7 +7,7 @@ Usage:
|
||||
from cli_anything.<software>.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) ──────────────
|
||||
|
||||
@@ -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
|
||||
"safari": "\033[38;5;33m", # Safari blue
|
||||
}
|
||||
_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.
|
||||
|
||||
@@ -107,27 +120,44 @@ class ReplSkin:
|
||||
history_file: Path for persistent command history.
|
||||
Defaults to ~/.cli-anything-<software>/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/<software>/utils/repl_skin.py (this file)
|
||||
# cli_anything/<software>/skills/SKILL.md (target)
|
||||
# Prefer repo-root canonical skills/<skill-id>/SKILL.md when running
|
||||
# inside the CLI-Anything monorepo. Fall back to the packaged
|
||||
# cli_anything/<software>/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")
|
||||
@@ -157,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."""
|
||||
@@ -165,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}")
|
||||
|
||||
@@ -179,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)
|
||||
|
||||
@@ -6,20 +6,21 @@ Copy this file into your CLI package at:
|
||||
Usage:
|
||||
from cli_anything.<software>.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-<software>/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-id>/SKILL.md when running
|
||||
# inside the CLI-Anything monorepo. Fall back to the packaged
|
||||
# cli_anything/<software>/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
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
.worktrees/
|
||||
@@ -6,6 +6,7 @@ import shutil
|
||||
from typing import Optional
|
||||
|
||||
from ..utils import mlt_xml
|
||||
from ..utils.time import timecode_to_frames, frames_to_timecode
|
||||
from .session import Session
|
||||
|
||||
|
||||
@@ -411,6 +412,57 @@ def render(session: Session, output_path: str,
|
||||
width, height)
|
||||
|
||||
|
||||
def _set_tractor_out(session: Session) -> None:
|
||||
"""Set tractor out= to actual timeline duration before passing to melt.
|
||||
|
||||
The tractor is created with out="00:00:00.000" and never updated as clips
|
||||
are added. Without this fix, melt falls back to the longest track in the
|
||||
multitrack — the 4-hour black background — and renders a 4-hour file.
|
||||
"""
|
||||
profile = session.get_profile()
|
||||
fps_num = int(profile.get("frame_rate_num", 30000))
|
||||
fps_den = int(profile.get("frame_rate_den", 1001))
|
||||
|
||||
tractor = mlt_xml.get_main_tractor(session.root)
|
||||
if tractor is None:
|
||||
return
|
||||
|
||||
tracks = mlt_xml.get_tractor_tracks(tractor)
|
||||
max_frames = 0
|
||||
for te in tracks:
|
||||
prod_id = te.get("producer", "")
|
||||
if prod_id == "background":
|
||||
continue
|
||||
playlist = mlt_xml.find_element_by_id(session.root, prod_id)
|
||||
if playlist is None:
|
||||
continue
|
||||
total = 0
|
||||
for child in playlist:
|
||||
if child.tag == "entry":
|
||||
in_f = timecode_to_frames(child.get("in", "0"), fps_num, fps_den)
|
||||
out_f = timecode_to_frames(child.get("out", "0"), fps_num, fps_den)
|
||||
total += max(0, out_f - in_f + 1)
|
||||
elif child.tag == "blank":
|
||||
try:
|
||||
total += timecode_to_frames(child.get("length", "0"), fps_num, fps_den)
|
||||
except Exception:
|
||||
pass
|
||||
max_frames = max(max_frames, total)
|
||||
|
||||
if max_frames > 0:
|
||||
out_tc = frames_to_timecode(max_frames - 1, fps_num, fps_den)
|
||||
tractor.set("out", out_tc)
|
||||
# Cap the background track to the same duration so melt doesn't
|
||||
# extend the render to the 4-hour background default.
|
||||
bg_playlist = mlt_xml.find_element_by_id(session.root, "background")
|
||||
if bg_playlist is not None:
|
||||
for entry in bg_playlist.findall("entry"):
|
||||
entry.set("out", out_tc)
|
||||
black_producer = session.root.find(".//producer[@id='black']")
|
||||
if black_producer is not None:
|
||||
black_producer.set("out", out_tc)
|
||||
|
||||
|
||||
def _render_with_melt(session: Session, output_path: str,
|
||||
preset: dict, melt_path: str,
|
||||
width: Optional[int], height: Optional[int],
|
||||
@@ -418,6 +470,10 @@ def _render_with_melt(session: Session, output_path: str,
|
||||
"""Render using melt command."""
|
||||
import tempfile
|
||||
|
||||
# Fix tractor out before rendering — without this melt renders the full
|
||||
# 4-hour background track instead of the actual content duration.
|
||||
_set_tractor_out(session)
|
||||
|
||||
# Save project to temp file
|
||||
with tempfile.NamedTemporaryFile(suffix=".mlt", delete=False, mode="w") as f:
|
||||
temp_mlt = f.name
|
||||
|
||||
@@ -25,8 +25,10 @@ from cli_anything.shotcut.utils.mlt_xml import (
|
||||
create_blank_project, mlt_to_string, parse_mlt, write_mlt,
|
||||
get_property, set_property, get_main_tractor, get_tractor_tracks,
|
||||
get_all_producers, get_playlist_entries, find_element_by_id,
|
||||
add_filter_to_element,
|
||||
add_filter_to_element, add_track_to_tractor, add_entry_to_playlist,
|
||||
add_blank_to_playlist,
|
||||
)
|
||||
from cli_anything.shotcut.utils.time import frames_to_timecode as _ftc
|
||||
|
||||
|
||||
# ============================================================================
|
||||
@@ -689,6 +691,70 @@ class TestExport:
|
||||
finally:
|
||||
os.unlink(tmpfile)
|
||||
|
||||
def test_set_tractor_out_single_clip(self):
|
||||
"""_set_tractor_out sets tractor out to match a single clip duration."""
|
||||
s = Session()
|
||||
proj_mod.new_project(s, "hd1080p30")
|
||||
tractor = get_main_tractor(s.root)
|
||||
playlist_id, _ = add_track_to_tractor(s.root, tractor, "video", "V1")
|
||||
playlist = find_element_by_id(s.root, playlist_id)
|
||||
|
||||
# Add a 6-second clip (frames 0–179 at 29.97fps ≈ 180 frames)
|
||||
prod = s.root.makeelement("producer", {"id": "clip1"})
|
||||
s.root.insert(0, prod)
|
||||
add_entry_to_playlist(playlist, "clip1", "00:00:00.000", "00:00:05.999")
|
||||
|
||||
# Before fix: tractor out is still the initial value
|
||||
assert tractor.get("out") == "00:00:00.000"
|
||||
|
||||
export_mod._set_tractor_out(s)
|
||||
|
||||
# After fix: tractor out should reflect the clip duration
|
||||
assert tractor.get("out") != "00:00:00.000"
|
||||
assert tractor.get("out") != "04:00:00.000"
|
||||
# Background entry should be capped too
|
||||
bg = find_element_by_id(s.root, "background")
|
||||
bg_entry = bg.find("entry")
|
||||
assert bg_entry.get("out") == tractor.get("out")
|
||||
# Black producer should be capped
|
||||
black = s.root.find(".//producer[@id='black']")
|
||||
assert black.get("out") == tractor.get("out")
|
||||
|
||||
def test_set_tractor_out_multi_segment(self):
|
||||
"""_set_tractor_out sums entry spans and blanks across segments."""
|
||||
s = Session()
|
||||
proj_mod.new_project(s, "hd1080p30")
|
||||
tractor = get_main_tractor(s.root)
|
||||
pid, _ = add_track_to_tractor(s.root, tractor, "video", "V1")
|
||||
playlist = find_element_by_id(s.root, pid)
|
||||
|
||||
# Two 3-second clips with a 1-second blank between
|
||||
prod1 = s.root.makeelement("producer", {"id": "seg1"})
|
||||
prod2 = s.root.makeelement("producer", {"id": "seg2"})
|
||||
s.root.insert(0, prod1)
|
||||
s.root.insert(0, prod2)
|
||||
add_entry_to_playlist(playlist, "seg1", "00:00:00.000", "00:00:02.999")
|
||||
add_blank_to_playlist(playlist, "00:00:01.000")
|
||||
add_entry_to_playlist(playlist, "seg2", "00:00:00.000", "00:00:02.999")
|
||||
|
||||
export_mod._set_tractor_out(s)
|
||||
|
||||
# Tractor out should cover all segments + blank (~7 seconds)
|
||||
out_tc = tractor.get("out")
|
||||
assert out_tc != "00:00:00.000"
|
||||
assert out_tc != "04:00:00.000"
|
||||
|
||||
def test_set_tractor_out_empty_timeline(self):
|
||||
"""_set_tractor_out is a no-op on a blank project with no clips."""
|
||||
s = Session()
|
||||
proj_mod.new_project(s, "hd1080p30")
|
||||
tractor = get_main_tractor(s.root)
|
||||
|
||||
export_mod._set_tractor_out(s)
|
||||
|
||||
# No clips → tractor out should stay unchanged
|
||||
assert tractor.get("out") == "00:00:00.000"
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Integration: full workflow
|
||||
|
||||
@@ -7,7 +7,7 @@ Usage:
|
||||
from cli_anything.<software>.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-<software>/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-id>/SKILL.md when running
|
||||
# inside the CLI-Anything monorepo. Fall back to the packaged
|
||||
# cli_anything/<software>/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)
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
# CLI-Anything Skills
|
||||
|
||||
This directory is the canonical `npx skills` surface for in-repo CLI-Anything
|
||||
harnesses.
|
||||
|
||||
Layout:
|
||||
|
||||
```text
|
||||
skills/
|
||||
cli-anything-audacity/SKILL.md
|
||||
cli-anything-blender/SKILL.md
|
||||
...
|
||||
```
|
||||
|
||||
Typical usage:
|
||||
|
||||
```bash
|
||||
npx skills add HKUDS/CLI-Anything --list
|
||||
npx skills add HKUDS/CLI-Anything --skill cli-anything-audacity -g -y
|
||||
```
|
||||
|
||||
The `SKILL.md` files here are the canonical repo-root copies. Installed harness
|
||||
packages still ship compatibility copies inside `cli_anything/<software>/skills/`
|
||||
for local runtime discovery.
|
||||
|
||||
CI rule:
|
||||
|
||||
- If a harness keeps a deep packaged `SKILL.md`, it must also have a matching
|
||||
repo-root `skills/<skill-id>/SKILL.md`.
|
||||
- A future harness that only defines its canonical skill directly in `skills/`
|
||||
is also valid.
|
||||
@@ -0,0 +1,255 @@
|
||||
---
|
||||
name: "cli-anything-adguardhome"
|
||||
description: >-
|
||||
Command-line interface for AdGuard Home - Network-wide ad blocking and DNS management via AdGuard Home REST API. Designed for AI agents and power users who need to manage filtering, DNS rewrites, clients, DHCP, and query logs without a GUI.
|
||||
---
|
||||
|
||||
# cli-anything-adguardhome
|
||||
|
||||
Network-wide ad blocking and DNS management via the AdGuard Home REST API. Designed for AI agents and power users who need to manage filtering, DNS rewrites, clients, DHCP, and query logs without a GUI.
|
||||
|
||||
## Installation
|
||||
|
||||
This CLI is installed as part of the cli-anything-adguardhome package:
|
||||
|
||||
```bash
|
||||
pip install cli-anything-adguardhome
|
||||
```
|
||||
|
||||
**Prerequisites:**
|
||||
- Python 3.10+
|
||||
- AdGuard Home must be installed and running
|
||||
- Install AdGuard Home: `curl -s -S -L https://raw.githubusercontent.com/AdguardTeam/AdGuardHome/master/scripts/install.sh | sh -s -- -v`
|
||||
|
||||
## Usage
|
||||
|
||||
### Basic Commands
|
||||
|
||||
```bash
|
||||
# Show help
|
||||
cli-anything-adguardhome --help
|
||||
|
||||
# Start interactive REPL mode
|
||||
cli-anything-adguardhome
|
||||
|
||||
# Check server status
|
||||
cli-anything-adguardhome server status
|
||||
|
||||
# Run with JSON output (for agent consumption)
|
||||
cli-anything-adguardhome --json server status
|
||||
```
|
||||
|
||||
### REPL Mode
|
||||
|
||||
When invoked without a subcommand, the CLI enters an interactive REPL session:
|
||||
|
||||
```bash
|
||||
cli-anything-adguardhome
|
||||
# Enter commands interactively with tab-completion and history
|
||||
```
|
||||
|
||||
## Command Groups
|
||||
|
||||
### Config
|
||||
|
||||
Connection and configuration management.
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `show` | Show current connection configuration |
|
||||
| `save` | Save connection settings to a config file |
|
||||
| `test` | Test the connection to AdGuard Home |
|
||||
|
||||
### Server
|
||||
|
||||
Server status and control commands.
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `status` | Show server protection status |
|
||||
| `version` | Show AdGuard Home version |
|
||||
| `restart` | Restart the AdGuard Home server |
|
||||
|
||||
### Filter
|
||||
|
||||
DNS filter list management.
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `list` | List all configured filter lists |
|
||||
| `status` | Show filtering status |
|
||||
| `toggle` | Enable or disable filtering globally |
|
||||
| `add` | Add a new filter list by URL |
|
||||
| `remove` | Remove a filter list |
|
||||
| `enable` | Enable a specific filter list |
|
||||
| `disable` | Disable a specific filter list |
|
||||
| `refresh` | Force-refresh all filter lists |
|
||||
|
||||
### Blocking
|
||||
|
||||
Parental control, safe browsing, and safe search settings.
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `parental status` | Show parental control status |
|
||||
| `parental enable` | Enable parental control |
|
||||
| `parental disable` | Disable parental control |
|
||||
| `safebrowsing status` | Show safe browsing status |
|
||||
| `safebrowsing enable` | Enable safe browsing |
|
||||
| `safebrowsing disable` | Disable safe browsing |
|
||||
| `safesearch status` | Show safe search status |
|
||||
| `safesearch enable` | Enable safe search |
|
||||
| `safesearch disable` | Disable safe search |
|
||||
|
||||
### Blocked-Services
|
||||
|
||||
Manage blocked internet services.
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `list` | List currently blocked services |
|
||||
| `set` | Set the list of blocked services |
|
||||
|
||||
### Clients
|
||||
|
||||
Client device management.
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `list` | List all configured clients |
|
||||
| `add` | Add a new client by name and IP |
|
||||
| `remove` | Remove a client |
|
||||
| `show` | Show details for a specific client |
|
||||
|
||||
### Stats
|
||||
|
||||
Query statistics.
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `show` | Show DNS query statistics |
|
||||
| `reset` | Reset all statistics |
|
||||
| `config` | View or update statistics retention interval |
|
||||
|
||||
### Log
|
||||
|
||||
DNS query log management.
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `show` | Show recent DNS query log entries |
|
||||
| `config` | View or update query log settings |
|
||||
| `clear` | Clear the query log |
|
||||
|
||||
### Rewrite
|
||||
|
||||
DNS rewrite rules.
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `list` | List all DNS rewrite rules |
|
||||
| `add` | Add a DNS rewrite rule |
|
||||
| `remove` | Remove a DNS rewrite rule |
|
||||
|
||||
### DHCP
|
||||
|
||||
DHCP server management.
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `status` | Show DHCP server status |
|
||||
| `leases` | List active DHCP leases |
|
||||
| `add-static` | Add a static DHCP lease |
|
||||
| `remove-static` | Remove a static DHCP lease |
|
||||
|
||||
### TLS
|
||||
|
||||
TLS/HTTPS configuration.
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `status` | Show TLS configuration status |
|
||||
|
||||
## Examples
|
||||
|
||||
### Check Server Status
|
||||
|
||||
```bash
|
||||
cli-anything-adguardhome server status
|
||||
cli-anything-adguardhome server version
|
||||
```
|
||||
|
||||
### Manage Filter Lists
|
||||
|
||||
```bash
|
||||
# List current filters
|
||||
cli-anything-adguardhome filter list
|
||||
|
||||
# Add a new blocklist
|
||||
cli-anything-adguardhome filter add --url https://somehost.com/list.txt --name "My List"
|
||||
|
||||
# Refresh all filters
|
||||
cli-anything-adguardhome filter refresh
|
||||
```
|
||||
|
||||
### DNS Rewrites
|
||||
|
||||
```bash
|
||||
# Add a local DNS entry
|
||||
cli-anything-adguardhome rewrite add --domain "myserver.local" --answer "192.168.1.50"
|
||||
|
||||
# List all rewrites
|
||||
cli-anything-adguardhome rewrite list
|
||||
```
|
||||
|
||||
### Client Management
|
||||
|
||||
```bash
|
||||
cli-anything-adguardhome clients add --name "My PC" --ip 192.168.1.100
|
||||
cli-anything-adguardhome clients list
|
||||
```
|
||||
|
||||
### Query Statistics
|
||||
|
||||
```bash
|
||||
# Show stats (human-readable)
|
||||
cli-anything-adguardhome stats show
|
||||
|
||||
# Show stats (JSON for agents)
|
||||
cli-anything-adguardhome --json stats show
|
||||
```
|
||||
|
||||
## Output Formats
|
||||
|
||||
All commands support dual output modes:
|
||||
|
||||
- **Human-readable** (default): Tables, colors, formatted text
|
||||
- **Machine-readable** (`--json` flag): Structured JSON for agent consumption
|
||||
|
||||
```bash
|
||||
# Human output
|
||||
cli-anything-adguardhome filter list
|
||||
|
||||
# JSON output for agents
|
||||
cli-anything-adguardhome --json filter list
|
||||
```
|
||||
|
||||
## For AI Agents
|
||||
|
||||
When using this CLI programmatically:
|
||||
|
||||
1. **Always use `--json` flag** for parseable output
|
||||
2. **Check return codes** - 0 for success, non-zero for errors
|
||||
3. **Parse stderr** for error messages on failure
|
||||
4. **Use absolute paths** for all file operations
|
||||
5. **Test connection first** with `config test` before other commands
|
||||
|
||||
## More Information
|
||||
|
||||
- Full documentation: See README.md in the package
|
||||
- Test coverage: See TEST.md in the package
|
||||
- Methodology: See HARNESS.md in the cli-anything-plugin
|
||||
|
||||
## Version
|
||||
|
||||
1.0.0
|
||||
@@ -0,0 +1,173 @@
|
||||
---
|
||||
name: "cli-anything-anygen"
|
||||
description: >-
|
||||
Command-line interface for Anygen - A stateful command-line interface for AnyGen OpenAPI — generate professional slides, documents, webs...
|
||||
---
|
||||
|
||||
# cli-anything-anygen
|
||||
|
||||
A stateful command-line interface for AnyGen OpenAPI — generate professional slides, documents, websites, diagrams, and more from natural language prompts. Designed for AI agents and power users.
|
||||
|
||||
## Installation
|
||||
|
||||
This CLI is installed as part of the cli-anything-anygen package:
|
||||
|
||||
```bash
|
||||
pip install cli-anything-anygen
|
||||
```
|
||||
|
||||
**Prerequisites:**
|
||||
- Python 3.10+
|
||||
- anygen must be installed on your system
|
||||
|
||||
|
||||
## Usage
|
||||
|
||||
### Basic Commands
|
||||
|
||||
```bash
|
||||
# Show help
|
||||
cli-anything-anygen --help
|
||||
|
||||
# Start interactive REPL mode
|
||||
cli-anything-anygen
|
||||
|
||||
# Create a new project
|
||||
cli-anything-anygen project new -o project.json
|
||||
|
||||
# Run with JSON output (for agent consumption)
|
||||
cli-anything-anygen --json project info -p project.json
|
||||
```
|
||||
|
||||
### REPL Mode
|
||||
|
||||
When invoked without a subcommand, the CLI enters an interactive REPL session:
|
||||
|
||||
```bash
|
||||
cli-anything-anygen
|
||||
# Enter commands interactively with tab-completion and history
|
||||
```
|
||||
|
||||
|
||||
## Command Groups
|
||||
|
||||
|
||||
### Task
|
||||
|
||||
Task management — create, poll, download, and run tasks.
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `create` | Create a generation task |
|
||||
| `status` | Query task status (non-blocking) |
|
||||
| `poll` | Poll task until completion (blocking) |
|
||||
| `download` | Download the generated file for a completed task |
|
||||
| `thumbnail` | Download thumbnail image for a completed task |
|
||||
| `run` | Full workflow: create, poll, download |
|
||||
| `list` | List locally cached task records |
|
||||
| `prepare` | Multi-turn requirement analysis before creating a task |
|
||||
|
||||
|
||||
### File
|
||||
|
||||
File operations — upload reference files.
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `upload` | Upload a reference file to get a file_token |
|
||||
|
||||
|
||||
### Config
|
||||
|
||||
Configuration management — API key and settings.
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `set` | Set a configuration value |
|
||||
| `get` | Get a configuration value (or show all) |
|
||||
| `delete` | Delete a configuration value |
|
||||
| `path` | Show the config file path |
|
||||
|
||||
|
||||
### Session
|
||||
|
||||
Session management — history, undo, redo.
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `status` | Show session status |
|
||||
| `history` | Show command history |
|
||||
| `undo` | Undo last command |
|
||||
| `redo` | Redo last undone command |
|
||||
|
||||
|
||||
|
||||
|
||||
## Examples
|
||||
|
||||
|
||||
### Create a New Project
|
||||
|
||||
Create a new anygen project file.
|
||||
|
||||
```bash
|
||||
cli-anything-anygen project new -o myproject.json
|
||||
# Or with JSON output for programmatic use
|
||||
cli-anything-anygen --json project new -o myproject.json
|
||||
```
|
||||
|
||||
|
||||
### Interactive REPL Session
|
||||
|
||||
Start an interactive session with undo/redo support.
|
||||
|
||||
```bash
|
||||
cli-anything-anygen
|
||||
# Enter commands interactively
|
||||
# Use 'help' to see available commands
|
||||
# Use 'undo' and 'redo' for history navigation
|
||||
```
|
||||
|
||||
|
||||
## State Management
|
||||
|
||||
The CLI maintains session state with:
|
||||
|
||||
- **Undo/Redo**: Up to 50 levels of history
|
||||
- **Project persistence**: Save/load project state as JSON
|
||||
- **Session tracking**: Track modifications and changes
|
||||
|
||||
## Output Formats
|
||||
|
||||
All commands support dual output modes:
|
||||
|
||||
- **Human-readable** (default): Tables, colors, formatted text
|
||||
- **Machine-readable** (`--json` flag): Structured JSON for agent consumption
|
||||
|
||||
```bash
|
||||
# Human output
|
||||
cli-anything-anygen project info -p project.json
|
||||
|
||||
# JSON output for agents
|
||||
cli-anything-anygen --json project info -p project.json
|
||||
```
|
||||
|
||||
## For AI Agents
|
||||
|
||||
When using this CLI programmatically:
|
||||
|
||||
1. **Always use `--json` flag** for parseable output
|
||||
2. **Check return codes** - 0 for success, non-zero for errors
|
||||
3. **Parse stderr** for error messages on failure
|
||||
4. **Use absolute paths** for all file operations
|
||||
5. **Verify outputs exist** after export operations
|
||||
|
||||
## More Information
|
||||
|
||||
- Full documentation: See README.md in the package
|
||||
- Test coverage: See TEST.md in the package
|
||||
- Methodology: See HARNESS.md in the cli-anything-plugin
|
||||
|
||||
## Version
|
||||
|
||||
1.0.0
|
||||
@@ -0,0 +1,244 @@
|
||||
---
|
||||
name: "cli-anything-audacity"
|
||||
description: >-
|
||||
Command-line interface for Audacity - A stateful command-line interface for audio editing, following the same patterns as the GIMP and Ble...
|
||||
---
|
||||
|
||||
# cli-anything-audacity
|
||||
|
||||
A stateful command-line interface for audio editing, following the same patterns as the GIMP and Blender CLIs in this repo.
|
||||
|
||||
## Installation
|
||||
|
||||
This CLI is installed as part of the cli-anything-audacity package:
|
||||
|
||||
```bash
|
||||
pip install cli-anything-audacity
|
||||
```
|
||||
|
||||
**Prerequisites:**
|
||||
- Python 3.10+
|
||||
- audacity must be installed on your system
|
||||
|
||||
|
||||
## Usage
|
||||
|
||||
### Basic Commands
|
||||
|
||||
```bash
|
||||
# Show help
|
||||
cli-anything-audacity --help
|
||||
|
||||
# Start interactive REPL mode
|
||||
cli-anything-audacity
|
||||
|
||||
# Create a new project
|
||||
cli-anything-audacity project new -o project.json
|
||||
|
||||
# Run with JSON output (for agent consumption)
|
||||
cli-anything-audacity --json project info -p project.json
|
||||
```
|
||||
|
||||
### REPL Mode
|
||||
|
||||
When invoked without a subcommand, the CLI enters an interactive REPL session:
|
||||
|
||||
```bash
|
||||
cli-anything-audacity
|
||||
# Enter commands interactively with tab-completion and history
|
||||
```
|
||||
|
||||
|
||||
## Command Groups
|
||||
|
||||
|
||||
### Project
|
||||
|
||||
Project management commands.
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `new` | Create a new project |
|
||||
| `open` | Open an existing project |
|
||||
| `save` | Save the current project |
|
||||
| `info` | Show project information |
|
||||
| `settings` | View or update project settings |
|
||||
| `json` | Print raw project JSON |
|
||||
|
||||
|
||||
### Track
|
||||
|
||||
Track management commands.
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `add` | Add a new track |
|
||||
| `remove` | Remove a track by index |
|
||||
| `list` | List all tracks |
|
||||
| `set` | Set a track property (name, mute, solo, volume, pan) |
|
||||
|
||||
|
||||
### Clip
|
||||
|
||||
Clip management commands.
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `import` | Probe/import an audio file (show metadata) |
|
||||
| `add` | Add an audio clip to a track |
|
||||
| `remove` | Remove a clip from a track |
|
||||
| `trim` | Trim a clip's start and/or end |
|
||||
| `split` | Split a clip at a given time position |
|
||||
| `move` | Move a clip to a new start time |
|
||||
| `list` | List clips on a track |
|
||||
|
||||
|
||||
### Effect Group
|
||||
|
||||
Effect management commands.
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `list-available` | List all available effects |
|
||||
| `info` | Show details about an effect |
|
||||
| `add` | Add an effect to a track |
|
||||
| `remove` | Remove an effect by index |
|
||||
| `set` | Set an effect parameter |
|
||||
| `list` | List effects on a track |
|
||||
|
||||
|
||||
### Selection
|
||||
|
||||
Selection management commands.
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `set` | Set selection range |
|
||||
| `all` | Select all (entire project duration) |
|
||||
| `none` | Clear selection |
|
||||
| `info` | Show current selection |
|
||||
|
||||
|
||||
### Label
|
||||
|
||||
Label/marker management commands.
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `add` | Add a label at a time position |
|
||||
| `remove` | Remove a label by index |
|
||||
| `list` | List all labels |
|
||||
|
||||
|
||||
### Media
|
||||
|
||||
Media file operations.
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `probe` | Analyze an audio file |
|
||||
| `check` | Check that all referenced audio files exist |
|
||||
|
||||
|
||||
### Export Group
|
||||
|
||||
Export/render commands.
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `presets` | List export presets |
|
||||
| `preset-info` | Show preset details |
|
||||
| `render` | Render the project to an audio file |
|
||||
|
||||
|
||||
### Session Group
|
||||
|
||||
Session management commands.
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `status` | Show session status |
|
||||
| `undo` | Undo the last operation |
|
||||
| `redo` | Redo the last undone operation |
|
||||
| `history` | Show undo history |
|
||||
|
||||
|
||||
|
||||
|
||||
## Examples
|
||||
|
||||
|
||||
### Create a New Project
|
||||
|
||||
Create a new audacity project file.
|
||||
|
||||
```bash
|
||||
cli-anything-audacity project new -o myproject.json
|
||||
# Or with JSON output for programmatic use
|
||||
cli-anything-audacity --json project new -o myproject.json
|
||||
```
|
||||
|
||||
|
||||
### Interactive REPL Session
|
||||
|
||||
Start an interactive session with undo/redo support.
|
||||
|
||||
```bash
|
||||
cli-anything-audacity
|
||||
# Enter commands interactively
|
||||
# Use 'help' to see available commands
|
||||
# Use 'undo' and 'redo' for history navigation
|
||||
```
|
||||
|
||||
|
||||
### Export Project
|
||||
|
||||
Export the project to a final output format.
|
||||
|
||||
```bash
|
||||
cli-anything-audacity --project myproject.json export render output.pdf --overwrite
|
||||
```
|
||||
|
||||
|
||||
## State Management
|
||||
|
||||
The CLI maintains session state with:
|
||||
|
||||
- **Undo/Redo**: Up to 50 levels of history
|
||||
- **Project persistence**: Save/load project state as JSON
|
||||
- **Session tracking**: Track modifications and changes
|
||||
|
||||
## Output Formats
|
||||
|
||||
All commands support dual output modes:
|
||||
|
||||
- **Human-readable** (default): Tables, colors, formatted text
|
||||
- **Machine-readable** (`--json` flag): Structured JSON for agent consumption
|
||||
|
||||
```bash
|
||||
# Human output
|
||||
cli-anything-audacity project info -p project.json
|
||||
|
||||
# JSON output for agents
|
||||
cli-anything-audacity --json project info -p project.json
|
||||
```
|
||||
|
||||
## For AI Agents
|
||||
|
||||
When using this CLI programmatically:
|
||||
|
||||
1. **Always use `--json` flag** for parseable output
|
||||
2. **Check return codes** - 0 for success, non-zero for errors
|
||||
3. **Parse stderr** for error messages on failure
|
||||
4. **Use absolute paths** for all file operations
|
||||
5. **Verify outputs exist** after export operations
|
||||
|
||||
## More Information
|
||||
|
||||
- Full documentation: See README.md in the package
|
||||
- Test coverage: See TEST.md in the package
|
||||
- Methodology: See HARNESS.md in the cli-anything-plugin
|
||||
|
||||
## Version
|
||||
|
||||
1.0.0
|
||||
@@ -0,0 +1,241 @@
|
||||
---
|
||||
name: "cli-anything-blender"
|
||||
description: >-
|
||||
Command-line interface for Blender - A stateful command-line interface for 3D scene editing, following the same patterns as the GIMP CLI ...
|
||||
---
|
||||
|
||||
# cli-anything-blender
|
||||
|
||||
A stateful command-line interface for 3D scene editing, following the same patterns as the GIMP CLI harness. Uses a JSON scene description format with bpy script generation for actual Blender rendering.
|
||||
|
||||
## Installation
|
||||
|
||||
This CLI is installed as part of the cli-anything-blender package:
|
||||
|
||||
```bash
|
||||
pip install cli-anything-blender
|
||||
```
|
||||
|
||||
**Prerequisites:**
|
||||
- Python 3.10+
|
||||
- blender (>= 4.2) must be installed on your system
|
||||
|
||||
|
||||
## Usage
|
||||
|
||||
### Basic Commands
|
||||
|
||||
```bash
|
||||
# Show help
|
||||
cli-anything-blender --help
|
||||
|
||||
# Start interactive REPL mode
|
||||
cli-anything-blender
|
||||
|
||||
# Create a new project
|
||||
cli-anything-blender project new -o project.json
|
||||
|
||||
# Run with JSON output (for agent consumption)
|
||||
cli-anything-blender --json project info -p project.json
|
||||
```
|
||||
|
||||
### REPL Mode
|
||||
|
||||
When invoked without a subcommand, the CLI enters an interactive REPL session:
|
||||
|
||||
```bash
|
||||
cli-anything-blender
|
||||
# Enter commands interactively with tab-completion and history
|
||||
```
|
||||
|
||||
|
||||
## Command Groups
|
||||
|
||||
|
||||
### Scene
|
||||
|
||||
Scene management commands.
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `new` | Create a new scene |
|
||||
| `open` | Open an existing scene |
|
||||
| `save` | Save the current scene |
|
||||
| `info` | Show scene information |
|
||||
| `profiles` | List available scene profiles |
|
||||
| `json` | Print raw scene JSON |
|
||||
|
||||
|
||||
### Object Group
|
||||
|
||||
3D object management commands.
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `add` | Add a 3D primitive object |
|
||||
| `remove` | Remove an object by index |
|
||||
| `duplicate` | Duplicate an object |
|
||||
| `transform` | Transform an object (translate, rotate, scale) |
|
||||
| `set` | Set an object property (name, visible, location, rotation, scale, parent) |
|
||||
| `list` | List all objects |
|
||||
| `get` | Get detailed info about an object |
|
||||
|
||||
|
||||
### Material
|
||||
|
||||
Material management commands.
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `create` | Create a new material |
|
||||
| `assign` | Assign a material to an object |
|
||||
| `set` | Set a material property (color, metallic, roughness, specular, alpha, etc.) |
|
||||
| `list` | List all materials |
|
||||
| `get` | Get detailed info about a material |
|
||||
|
||||
|
||||
### Modifier Group
|
||||
|
||||
Modifier management commands.
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `list-available` | List all available modifiers |
|
||||
| `info` | Show details about a modifier |
|
||||
| `add` | Add a modifier to an object |
|
||||
| `remove` | Remove a modifier by index |
|
||||
| `set` | Set a modifier parameter |
|
||||
| `list` | List modifiers on an object |
|
||||
|
||||
|
||||
### Camera
|
||||
|
||||
Camera management commands.
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `add` | Add a camera to the scene |
|
||||
| `set` | Set a camera property |
|
||||
| `set-active` | Set the active camera |
|
||||
| `list` | List all cameras |
|
||||
|
||||
|
||||
### Light
|
||||
|
||||
Light management commands.
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `add` | Add a light to the scene |
|
||||
| `set` | Set a light property |
|
||||
| `list` | List all lights |
|
||||
|
||||
|
||||
### Animation
|
||||
|
||||
Animation and keyframe commands.
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `keyframe` | Set a keyframe on an object |
|
||||
| `remove-keyframe` | Remove a keyframe from an object |
|
||||
| `frame-range` | Set the animation frame range |
|
||||
| `fps` | Set the animation FPS |
|
||||
| `list-keyframes` | List keyframes for an object |
|
||||
|
||||
|
||||
### Render Group
|
||||
|
||||
Render settings and output commands.
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `settings` | Configure render settings |
|
||||
| `info` | Show current render settings |
|
||||
| `presets` | List available render presets |
|
||||
| `execute` | Render the scene (generates bpy script) |
|
||||
| `script` | Generate bpy script without rendering |
|
||||
|
||||
|
||||
### Session
|
||||
|
||||
Session management commands.
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `status` | Show session status |
|
||||
| `undo` | Undo the last operation |
|
||||
| `redo` | Redo the last undone operation |
|
||||
| `history` | Show undo history |
|
||||
|
||||
|
||||
|
||||
|
||||
## Examples
|
||||
|
||||
|
||||
### Create a New Project
|
||||
|
||||
Create a new blender project file.
|
||||
|
||||
```bash
|
||||
cli-anything-blender project new -o myproject.json
|
||||
# Or with JSON output for programmatic use
|
||||
cli-anything-blender --json project new -o myproject.json
|
||||
```
|
||||
|
||||
|
||||
### Interactive REPL Session
|
||||
|
||||
Start an interactive session with undo/redo support.
|
||||
|
||||
```bash
|
||||
cli-anything-blender
|
||||
# Enter commands interactively
|
||||
# Use 'help' to see available commands
|
||||
# Use 'undo' and 'redo' for history navigation
|
||||
```
|
||||
|
||||
|
||||
## State Management
|
||||
|
||||
The CLI maintains session state with:
|
||||
|
||||
- **Undo/Redo**: Up to 50 levels of history
|
||||
- **Project persistence**: Save/load project state as JSON
|
||||
- **Session tracking**: Track modifications and changes
|
||||
|
||||
## Output Formats
|
||||
|
||||
All commands support dual output modes:
|
||||
|
||||
- **Human-readable** (default): Tables, colors, formatted text
|
||||
- **Machine-readable** (`--json` flag): Structured JSON for agent consumption
|
||||
|
||||
```bash
|
||||
# Human output
|
||||
cli-anything-blender project info -p project.json
|
||||
|
||||
# JSON output for agents
|
||||
cli-anything-blender --json project info -p project.json
|
||||
```
|
||||
|
||||
## For AI Agents
|
||||
|
||||
When using this CLI programmatically:
|
||||
|
||||
1. **Always use `--json` flag** for parseable output
|
||||
2. **Check return codes** - 0 for success, non-zero for errors
|
||||
3. **Parse stderr** for error messages on failure
|
||||
4. **MANDATORY: Use absolute paths** for all file operations (rendering, project files). Relative paths are prone to failure in background execution.
|
||||
5. **Verify outputs exist** after export operations
|
||||
|
||||
## More Information
|
||||
|
||||
- Full documentation: See README.md in the package
|
||||
- Test coverage: See TEST.md in the package
|
||||
- Methodology: See HARNESS.md in the cli-anything-plugin
|
||||
|
||||
## Version
|
||||
|
||||
1.0.0
|
||||
@@ -0,0 +1,213 @@
|
||||
---
|
||||
name: "cli-anything-browser"
|
||||
description: "Browser automation CLI using DOMShell MCP server. Maps Chrome's Accessibility Tree to a virtual filesystem for agent-native navigation."
|
||||
---
|
||||
|
||||
# cli-anything-browser
|
||||
|
||||
A command-line interface for browser automation using [DOMShell](https://github.com/apireno/DOMShell)'s MCP server. Navigate web pages using filesystem commands: `ls`, `cd`, `cat`, `grep`, `click`.
|
||||
|
||||
## Installation
|
||||
|
||||
### Prerequisites
|
||||
|
||||
1. **Node.js and npx** (for DOMShell MCP server):
|
||||
```bash
|
||||
# Install Node.js from https://nodejs.org/
|
||||
npx --version
|
||||
```
|
||||
|
||||
2. **Chrome/Chromium** with [DOMShell extension](https://chromewebstore.google.com/detail/domshell-browser-filesy/okcliheamhmijccjknkkplploacoidnp):
|
||||
- Install extension in Chrome
|
||||
- Ensure Chrome is running before using CLI
|
||||
|
||||
3. **Python 3.10+**
|
||||
|
||||
### Install CLI
|
||||
|
||||
```bash
|
||||
cd browser/agent-harness
|
||||
pip install -e .
|
||||
```
|
||||
|
||||
## Command Groups
|
||||
|
||||
### `page` — Page Navigation
|
||||
|
||||
- `page open <url>` — Navigate to URL
|
||||
- `page reload` — Reload current page
|
||||
- `page back` — Navigate back in history
|
||||
- `page forward` — Navigate forward in history
|
||||
- `page info` — Show current page info
|
||||
|
||||
### `fs` — Filesystem Commands (Accessibility Tree)
|
||||
|
||||
- `fs ls [path]` — List elements at path
|
||||
- `fs cd <path>` — Change directory
|
||||
- `fs cat [path]` — Read element content
|
||||
- `fs grep <pattern> [path]` — Search for text pattern
|
||||
- `fs pwd` — Print working directory
|
||||
|
||||
### `act` — Action Commands
|
||||
|
||||
- `act click <path>` — Click an element
|
||||
- `act type <path> <text>` — Type text into input
|
||||
|
||||
### `session` — Session Management
|
||||
|
||||
- `session status` — Show session state
|
||||
- `session daemon-start` — Start persistent daemon mode
|
||||
- `session daemon-stop` — Stop daemon mode
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Basic Navigation
|
||||
|
||||
```bash
|
||||
# Open a page
|
||||
cli-anything-browser page open https://example.com
|
||||
|
||||
# Explore structure
|
||||
cli-anything-browser fs ls /
|
||||
cli-anything-browser fs cd /main
|
||||
cli-anything-browser fs ls
|
||||
|
||||
# Go back to root
|
||||
cli-anything-browser fs cd /
|
||||
```
|
||||
|
||||
### Search and Click
|
||||
|
||||
```bash
|
||||
cli-anything-browser fs grep "Login"
|
||||
cli-anything-browser act click /main/button[0]
|
||||
```
|
||||
|
||||
### Form Fill
|
||||
|
||||
```bash
|
||||
cli-anything-browser act type /main/input[0] "user@example.com"
|
||||
cli-anything-browser act click /main/button[0]
|
||||
```
|
||||
|
||||
### JSON Output
|
||||
|
||||
```bash
|
||||
cli-anything-browser --json fs ls /
|
||||
```
|
||||
|
||||
### Daemon Mode (Faster Interactive Use)
|
||||
|
||||
```bash
|
||||
# Start persistent connection
|
||||
cli-anything-browser session daemon-start
|
||||
|
||||
# Run commands (uses persistent connection)
|
||||
cli-anything-browser fs ls /
|
||||
cli-anything-browser fs cd /main
|
||||
|
||||
# Stop daemon when done
|
||||
cli-anything-browser session daemon-stop
|
||||
```
|
||||
|
||||
### Interactive REPL
|
||||
|
||||
```bash
|
||||
cli-anything-browser
|
||||
```
|
||||
|
||||
## Path Syntax
|
||||
|
||||
DOMShell uses a filesystem-like path for the Accessibility Tree:
|
||||
|
||||
```
|
||||
/ — Root (document)
|
||||
/main — Main landmark
|
||||
/main/div[0] — First div in main
|
||||
/main/div[0]/button[2] — Third button in first div
|
||||
```
|
||||
|
||||
- Array indices are **0-based**: `button[0]` is the first button
|
||||
- Use `..` to go up one level
|
||||
- Use `/` for root
|
||||
|
||||
## Agent-Specific Guidance
|
||||
|
||||
### JSON Output for Parsing
|
||||
|
||||
All commands support `--json` flag for machine-readable output:
|
||||
|
||||
```bash
|
||||
cli-anything-browser --json fs ls /
|
||||
```
|
||||
|
||||
Returns:
|
||||
```json
|
||||
{
|
||||
"path": "/",
|
||||
"entries": [
|
||||
{"name": "main", "role": "landmark", "path": "/main"}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Error Handling
|
||||
|
||||
The CLI provides clear error messages for common issues:
|
||||
|
||||
- **npx not found**: Install Node.js from https://nodejs.org/
|
||||
- **DOMShell not found**: Run `npx @apireno/domshell --version`
|
||||
- **MCP call failed**: Install DOMShell Chrome extension
|
||||
|
||||
Check `is_available()` return value before running commands.
|
||||
|
||||
### Daemon Mode for Efficiency
|
||||
|
||||
For agent workflows with multiple commands, use daemon mode:
|
||||
|
||||
1. Start daemon: `cli-anything-browser session daemon-start`
|
||||
2. Run commands: Each command reuses the MCP connection
|
||||
3. Stop daemon: `cli-anything-browser session daemon-stop`
|
||||
|
||||
This avoids the 1-3 second cold start overhead for each command.
|
||||
|
||||
## Links
|
||||
|
||||
- [DOMShell GitHub](https://github.com/apireno/DOMShell)
|
||||
- [CLI-Anything](https://github.com/HKUDS/CLI-Anything)
|
||||
- [Issue #90](https://github.com/HKUDS/CLI-Anything/issues/90)
|
||||
|
||||
## Security Considerations
|
||||
|
||||
**IMPORTANT**: When using this CLI with AI agents, be aware of the following security considerations:
|
||||
|
||||
### URL Restrictions
|
||||
The browser harness validates all URLs before navigation:
|
||||
- **Explicit scheme required**: URLs must include `http://` or `https://` scheme (scheme-less URLs like `example.com` are rejected)
|
||||
- **Blocked schemes**: `file://`, `javascript://`, `data://`, `vbscript://`, `about://`, `chrome://`, and browser-internal schemes
|
||||
- **Allowed schemes**: `http://` and `https://` only (configurable via `CLI_ANYTHING_BROWSER_ALLOWED_SCHEMES`)
|
||||
- **Private network blocking**: Optional via `CLI_ANYTHING_BROWSER_BLOCK_PRIVATE=true` (disabled by default)
|
||||
|
||||
### DOM Content Risks
|
||||
The Accessibility Tree includes all visible and hidden elements on a page. Malicious websites could:
|
||||
- Craft ARIA labels with manipulative text (e.g., "Ignore previous instructions")
|
||||
- Use aria-hidden elements to inject content not visible to users
|
||||
- Create confusing DOM structures that mislead navigation
|
||||
|
||||
**Mitigation**: When interacting with untrusted websites, consider:
|
||||
1. Using the `--json` flag for structured output that's easier to parse safely
|
||||
2. Sanitizing or filtering DOM content before including it in prompts
|
||||
3. Limiting navigation to trusted domains
|
||||
|
||||
### Private Network Access
|
||||
By default, the browser can access localhost and private networks (192.168.x.x, 10.x.x.x, etc.). To block:
|
||||
```bash
|
||||
export CLI_ANYTHING_BROWSER_BLOCK_PRIVATE=true
|
||||
cli-anything-browser page open http://localhost:8080 # Will be blocked
|
||||
```
|
||||
|
||||
### Session Isolation
|
||||
Multiple browser sessions share the same Chrome instance. Cookies and authentication state may persist across sessions. For sensitive operations, consider:
|
||||
1. Using Chrome's guest mode or incognito
|
||||
2. Clearing cookies between sessions
|
||||
3. Using separate Chrome profiles for different security contexts
|
||||
@@ -0,0 +1,116 @@
|
||||
---
|
||||
name: "cli-anything-chromadb"
|
||||
description: >-
|
||||
Command-line interface for ChromaDB - A stateless CLI for managing vector database collections, documents, and semantic search. Designed for AI agents and automation via the ChromaDB HTTP API v2.
|
||||
---
|
||||
|
||||
# cli-anything-chromadb
|
||||
|
||||
A stateless command-line interface for ChromaDB vector database, built on the HTTP API v2. Designed for AI agents and power users who need to manage collections, documents, and run semantic queries without a browser UI.
|
||||
|
||||
## Installation
|
||||
|
||||
This CLI is installed as part of the cli-anything-chromadb package:
|
||||
|
||||
```bash
|
||||
pip install cli-anything-chromadb
|
||||
```
|
||||
|
||||
**Prerequisites:**
|
||||
- Python 3.10+
|
||||
- ChromaDB server running at localhost:8000 (or specify via --host)
|
||||
|
||||
## Usage
|
||||
|
||||
### Basic Commands
|
||||
|
||||
```bash
|
||||
# Show help
|
||||
cli-anything-chromadb --help
|
||||
|
||||
# Start interactive REPL mode
|
||||
cli-anything-chromadb
|
||||
|
||||
# Check server health
|
||||
cli-anything-chromadb --json server heartbeat
|
||||
|
||||
# List all collections
|
||||
cli-anything-chromadb --json collection list
|
||||
|
||||
# Semantic search
|
||||
cli-anything-chromadb --json query search --collection hub_knowledge --text "How to deploy"
|
||||
```
|
||||
|
||||
### REPL Mode
|
||||
|
||||
When invoked without a subcommand, the CLI enters an interactive REPL session:
|
||||
|
||||
```bash
|
||||
cli-anything-chromadb
|
||||
# Enter commands interactively with tab-completion and history
|
||||
```
|
||||
|
||||
## Command Groups
|
||||
|
||||
### server
|
||||
Server health and version commands.
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `heartbeat` | Check ChromaDB server health |
|
||||
| `version` | Get ChromaDB server version |
|
||||
|
||||
### collection
|
||||
Manage ChromaDB collections.
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `list` | List all collections |
|
||||
| `create --name NAME` | Create a new collection |
|
||||
| `delete --name NAME` | Delete a collection |
|
||||
| `info NAME` | Get collection info |
|
||||
|
||||
### document
|
||||
Manage documents in collections.
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `add --collection C --id ID --document TEXT` | Add document(s) |
|
||||
| `get --collection C` | Get documents |
|
||||
| `delete --collection C --id ID` | Delete document(s) |
|
||||
| `count --collection C` | Count documents |
|
||||
|
||||
### query
|
||||
Semantic search against collections.
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `search --collection C --text T` | Semantic search |
|
||||
|
||||
## Output Formats
|
||||
|
||||
All commands support dual output modes:
|
||||
|
||||
- **Human-readable** (default): Tables, colors, formatted text
|
||||
- **Machine-readable** (`--json` flag): Structured JSON for agent consumption
|
||||
|
||||
```bash
|
||||
# Human output
|
||||
cli-anything-chromadb server heartbeat
|
||||
|
||||
# JSON output for agents
|
||||
cli-anything-chromadb --json server heartbeat
|
||||
```
|
||||
|
||||
## For AI Agents
|
||||
|
||||
When using this CLI programmatically:
|
||||
|
||||
1. **Always use `--json` flag** for parseable output
|
||||
2. **Check return codes** - 0 for success, non-zero for errors
|
||||
3. **Parse stderr** for error messages on failure
|
||||
4. **Use `--host`** to connect to non-default ChromaDB instances
|
||||
|
||||
## Version
|
||||
|
||||
1.0.0
|
||||
@@ -0,0 +1,301 @@
|
||||
---
|
||||
name: "cli-anything-cloudanalyzer"
|
||||
description: "Command-line interface for CloudAnalyzer — Agent-friendly harness for CloudAnalyzer, a QA platform for mapping, localization, and perception outputs. Supports 27 commands across 8 groups: point cloud evaluation, trajectory evaluation, ground segmentation QA, config-driven quality gates, baseline evolution, processing, visualization, and interactive REPL."
|
||||
---
|
||||
|
||||
# cli-anything-cloudanalyzer
|
||||
|
||||
Agent-friendly command-line harness for [CloudAnalyzer](https://github.com/rsasaki0109/CloudAnalyzer) — a QA platform for mapping, localization, and perception point cloud outputs.
|
||||
|
||||
**27 commands** across 8 groups.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
pip install cli-anything-cloudanalyzer
|
||||
```
|
||||
|
||||
**Prerequisites:**
|
||||
- Python 3.10+
|
||||
- CloudAnalyzer: `pip install cloudanalyzer`
|
||||
|
||||
## Global Options
|
||||
|
||||
```bash
|
||||
cli-anything-cloudanalyzer [--project FILE] [--json] COMMAND [ARGS]...
|
||||
```
|
||||
|
||||
| Option | Description |
|
||||
|---|---|
|
||||
| `-p, --project TEXT` | Path to project JSON file |
|
||||
| `--json` | Output results as JSON (for agent consumption) |
|
||||
|
||||
## Command Groups
|
||||
|
||||
### 1. evaluate — Point Cloud Evaluation (6 commands)
|
||||
|
||||
#### evaluate run
|
||||
Evaluate a point cloud against a reference (Chamfer, F1, AUC, Hausdorff).
|
||||
|
||||
```bash
|
||||
cli-anything-cloudanalyzer evaluate run source.pcd reference.pcd
|
||||
cli-anything-cloudanalyzer --json evaluate run source.pcd reference.pcd
|
||||
```
|
||||
|
||||
Options: `--plot TEXT`, `--threshold FLOAT`
|
||||
|
||||
#### evaluate compare
|
||||
Compare two point clouds with optional registration.
|
||||
|
||||
```bash
|
||||
cli-anything-cloudanalyzer evaluate compare src.pcd tgt.pcd --register gicp
|
||||
```
|
||||
|
||||
Options: `--register TEXT` (icp/gicp/none)
|
||||
|
||||
#### evaluate diff
|
||||
Quick distance statistics between two point clouds.
|
||||
|
||||
```bash
|
||||
cli-anything-cloudanalyzer evaluate diff a.pcd b.pcd --threshold 0.1
|
||||
```
|
||||
|
||||
#### evaluate batch
|
||||
Batch evaluation of multiple point clouds against a reference.
|
||||
|
||||
```bash
|
||||
cli-anything-cloudanalyzer --json evaluate batch results/ reference.pcd --min-auc 0.95
|
||||
```
|
||||
|
||||
Options: `--min-auc FLOAT`, `--max-chamfer FLOAT`
|
||||
|
||||
#### evaluate ground
|
||||
Evaluate ground segmentation quality (precision, recall, F1, IoU).
|
||||
|
||||
```bash
|
||||
cli-anything-cloudanalyzer --json evaluate ground est_ground.pcd est_ng.pcd ref_ground.pcd ref_ng.pcd --min-f1 0.9
|
||||
```
|
||||
|
||||
Options: `--voxel-size FLOAT`, `--min-precision FLOAT`, `--min-recall FLOAT`, `--min-f1 FLOAT`, `--min-iou FLOAT`
|
||||
|
||||
#### evaluate pipeline
|
||||
Filter, downsample, evaluate in one command.
|
||||
|
||||
```bash
|
||||
cli-anything-cloudanalyzer evaluate pipeline input.pcd reference.pcd -o output.pcd
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. trajectory — Trajectory Evaluation (3 commands)
|
||||
|
||||
#### trajectory evaluate
|
||||
Evaluate estimated vs reference trajectory (ATE, RPE, drift, lateral, longitudinal).
|
||||
|
||||
```bash
|
||||
cli-anything-cloudanalyzer --json trajectory evaluate est.csv gt.csv --max-ate 0.5 --max-lateral 0.3
|
||||
```
|
||||
|
||||
Options: `--max-ate FLOAT`, `--max-rpe FLOAT`, `--max-drift FLOAT`, `--min-coverage FLOAT`, `--max-lateral FLOAT`, `--max-longitudinal FLOAT`, `--align-origin`, `--align-rigid`
|
||||
|
||||
#### trajectory batch
|
||||
Batch trajectory evaluation.
|
||||
|
||||
```bash
|
||||
cli-anything-cloudanalyzer trajectory batch runs/ --reference-dir gt/ --max-drift 1.0
|
||||
```
|
||||
|
||||
#### trajectory run-evaluate
|
||||
Integrated map + trajectory evaluation.
|
||||
|
||||
```bash
|
||||
cli-anything-cloudanalyzer trajectory run-evaluate map.pcd map_ref.pcd traj.csv traj_ref.csv
|
||||
```
|
||||
|
||||
Options: `--min-auc FLOAT`, `--max-ate FLOAT`
|
||||
|
||||
---
|
||||
|
||||
### 3. check — Config-Driven Quality Gate (2 commands)
|
||||
|
||||
#### check run
|
||||
Run unified QA from a config file.
|
||||
|
||||
```bash
|
||||
cli-anything-cloudanalyzer --json check run cloudanalyzer.yaml
|
||||
```
|
||||
|
||||
Options: `--output-json TEXT`
|
||||
|
||||
#### check init
|
||||
Generate a starter config file.
|
||||
|
||||
```bash
|
||||
cli-anything-cloudanalyzer check init cloudanalyzer.yaml --profile integrated
|
||||
```
|
||||
|
||||
Options: `--profile TEXT` (mapping/localization/perception/integrated), `--force`
|
||||
|
||||
---
|
||||
|
||||
### 4. baseline — Baseline Evolution (3 commands)
|
||||
|
||||
#### baseline decision
|
||||
Decide whether to promote, keep, or reject a candidate baseline.
|
||||
|
||||
```bash
|
||||
cli-anything-cloudanalyzer --json baseline decision qa/summary.json --history-dir qa/history/
|
||||
```
|
||||
|
||||
Options: `--history TEXT` (repeatable), `--history-dir TEXT`, `--output-json TEXT`
|
||||
|
||||
#### baseline save
|
||||
Save a QA summary to the history directory.
|
||||
|
||||
```bash
|
||||
cli-anything-cloudanalyzer baseline save qa/summary.json --history-dir qa/history/ --keep 10
|
||||
```
|
||||
|
||||
Options: `--history-dir TEXT`, `--label TEXT`, `--keep INTEGER`
|
||||
|
||||
#### baseline list
|
||||
List saved baselines.
|
||||
|
||||
```bash
|
||||
cli-anything-cloudanalyzer --json baseline list --history-dir qa/history/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 5. process — Point Cloud Processing (6 commands)
|
||||
|
||||
#### process downsample
|
||||
Voxel grid downsampling.
|
||||
|
||||
```bash
|
||||
cli-anything-cloudanalyzer process downsample cloud.pcd -o down.pcd -v 0.05
|
||||
```
|
||||
|
||||
#### process sample
|
||||
Random point sampling.
|
||||
|
||||
```bash
|
||||
cli-anything-cloudanalyzer process sample cloud.pcd -o sampled.pcd -n 10000
|
||||
```
|
||||
|
||||
#### process filter
|
||||
Statistical outlier removal.
|
||||
|
||||
```bash
|
||||
cli-anything-cloudanalyzer process filter cloud.pcd -o filtered.pcd
|
||||
```
|
||||
|
||||
#### process split
|
||||
Split point cloud into grid tiles (writes metadata.yaml).
|
||||
|
||||
```bash
|
||||
cli-anything-cloudanalyzer process split large.pcd -o tiles/ -g 100
|
||||
```
|
||||
|
||||
#### process merge
|
||||
Merge multiple point clouds.
|
||||
|
||||
```bash
|
||||
cli-anything-cloudanalyzer process merge a.pcd b.pcd -o merged.pcd
|
||||
```
|
||||
|
||||
#### process convert
|
||||
Convert between point cloud formats.
|
||||
|
||||
```bash
|
||||
cli-anything-cloudanalyzer process convert input.las -o output.pcd
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 6. inspect — Visualization (3 commands)
|
||||
|
||||
#### inspect view
|
||||
Open a point cloud viewer.
|
||||
|
||||
```bash
|
||||
cli-anything-cloudanalyzer inspect view cloud.pcd
|
||||
```
|
||||
|
||||
#### inspect web
|
||||
Interactive browser inspection.
|
||||
|
||||
```bash
|
||||
cli-anything-cloudanalyzer inspect web map.pcd ref.pcd --heatmap
|
||||
```
|
||||
|
||||
#### inspect web-export
|
||||
Export a static HTML inspection bundle.
|
||||
|
||||
```bash
|
||||
cli-anything-cloudanalyzer inspect web-export map.pcd ref.pcd -o bundle/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 7. info — Metadata (2 commands)
|
||||
|
||||
#### info show
|
||||
Show point cloud metadata.
|
||||
|
||||
```bash
|
||||
cli-anything-cloudanalyzer --json info show cloud.pcd
|
||||
```
|
||||
|
||||
#### info version
|
||||
Show CloudAnalyzer version.
|
||||
|
||||
---
|
||||
|
||||
### 8. session — Session Management (2 commands)
|
||||
|
||||
#### session new
|
||||
Create a new harness project JSON file.
|
||||
|
||||
```bash
|
||||
cli-anything-cloudanalyzer session new -o project.json -n my-run
|
||||
```
|
||||
|
||||
#### session history
|
||||
Show recent operations for the project given with `-p` / `--project`.
|
||||
|
||||
```bash
|
||||
cli-anything-cloudanalyzer --project project.json session history --last 20
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Typical Agent Workflows
|
||||
|
||||
### Workflow 1: Evaluate and gate a point cloud
|
||||
|
||||
```bash
|
||||
cli-anything-cloudanalyzer --json evaluate run output.pcd reference.pcd
|
||||
```
|
||||
|
||||
### Workflow 2: Config-driven QA pipeline
|
||||
|
||||
```bash
|
||||
cli-anything-cloudanalyzer check init cloudanalyzer.yaml --profile integrated
|
||||
cli-anything-cloudanalyzer --json check run cloudanalyzer.yaml
|
||||
```
|
||||
|
||||
### Workflow 3: Baseline management
|
||||
|
||||
```bash
|
||||
cli-anything-cloudanalyzer --json check run cloudanalyzer.yaml --output-json qa/summary.json
|
||||
cli-anything-cloudanalyzer baseline save qa/summary.json --history-dir qa/history/
|
||||
cli-anything-cloudanalyzer --json baseline decision qa/summary.json --history-dir qa/history/
|
||||
```
|
||||
|
||||
### Workflow 4: Ground segmentation QA
|
||||
|
||||
```bash
|
||||
cli-anything-cloudanalyzer --json evaluate ground \
|
||||
est_ground.pcd est_ng.pcd ref_ground.pcd ref_ng.pcd --min-f1 0.9
|
||||
```
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user