From a0478c107a6ba878684178763dfc313c3fb7b279 Mon Sep 17 00:00:00 2001 From: ArchieIndian Date: Sun, 15 Mar 2026 23:56:20 +0530 Subject: [PATCH] Add skill-portability-checker: validate OS/binary dependencies in scripts Detects OS_SPECIFIC_CALL, MISSING_BINARY, BREW_ONLY, PYTHON_IMPORT, and HARDCODED_PATH issues in companion scripts. Cross-checks against os_filter: frontmatter field. No external dependencies. Co-Authored-By: Claude Sonnet 4.6 --- .../core/skill-portability-checker/SKILL.md | 92 +++++ .../core/skill-portability-checker/check.py | 369 ++++++++++++++++++ 2 files changed, 461 insertions(+) create mode 100644 skills/core/skill-portability-checker/SKILL.md create mode 100755 skills/core/skill-portability-checker/check.py diff --git a/skills/core/skill-portability-checker/SKILL.md b/skills/core/skill-portability-checker/SKILL.md new file mode 100644 index 0000000..41037da --- /dev/null +++ b/skills/core/skill-portability-checker/SKILL.md @@ -0,0 +1,92 @@ +--- +name: skill-portability-checker +version: "1.0" +category: core +description: Validates that a skill's companion scripts declare their OS and binary dependencies correctly, and checks whether those dependencies are actually present on the current machine. +--- + +# Skill Portability Checker + +## What it does + +Skills with companion scripts (`.py`, `.sh`) can silently fail on machines where their dependencies aren't installed. A skill written on macOS may call `brew`, `pbcopy`, or use `/usr/local/bin` paths that don't exist on Linux. A Python script may `import pandas` on a system without it. + +Skill Portability Checker: +1. Scans companion scripts for OS-specific patterns and external binary calls +2. Checks whether those binaries are present on the current system (`PATH` lookup + `which`) +3. Cross-checks against the skill's declared `os_filter:` frontmatter field (if any) +4. Reports portability issues before the skill fails at runtime + +## Frontmatter field checked + +```yaml +--- +name: my-skill +os_filter: [macos] # optional: ["macos", "linux", "windows"] +--- +``` + +If `os_filter:` is absent the skill is treated as cross-platform. The checker then warns if OS-specific calls are detected without a corresponding `os_filter:`. + +## Checks performed + +| Check | Description | +|---|---| +| OS_SPECIFIC_CALL | Script calls macOS/Linux/Windows-only binary without `os_filter:` | +| MISSING_BINARY | Required binary not found on current system PATH | +| BREW_ONLY | Script uses `brew` (macOS-only) but `os_filter:` includes non-macOS | +| PYTHON_IMPORT | Script imports a non-stdlib module; checks if importable | +| HARDCODED_PATH | Absolute path that doesn't exist on this machine (`/usr/local`, `C:\`) | + +## How to use + +```bash +python3 check.py --check # Full portability scan +python3 check.py --check --skill my-skill # Single skill +python3 check.py --fix-hints my-skill # Print fix suggestions +python3 check.py --format json +``` + +## Procedure + +**Step 1 — Run the scan** + +```bash +python3 check.py --check +``` + +**Step 2 — Triage FAILs first** + +- **MISSING_BINARY**: The script calls a binary that isn't installed. Either install it or add a graceful fallback in the script. +- **OS_SPECIFIC_CALL without os_filter**: Add `os_filter: [macos]` (or whichever OS applies) to the frontmatter so users on other platforms know the skill won't work. + +**Step 3 — Review WARNs** + +- **PYTHON_IMPORT**: Install the missing module or add a `try/except ImportError` with a graceful degradation path (like `HAS_MODULE = False`). +- **HARDCODED_PATH**: Replace with `Path.home()` or environment-variable-based paths. + +**Step 4 — Add os_filter when needed** + +If a skill genuinely only works on one OS, declare it: + +```yaml +os_filter: [macos] +``` + +This prevents the skill from being shown as broken on other platforms — it simply won't be loaded there. + +## Output example + +``` +Skill Portability Report — linux (Python 3.11) +──────────────────────────────────────────────── +32 skills checked | 1 FAIL | 2 WARN + +FAIL obsidian-sync: MISSING_BINARY + sync.py calls `osascript` — not found on this system + Fix: add os_filter: [macos] to frontmatter (osascript is macOS-only) + +WARN morning-briefing: PYTHON_IMPORT + run.py imports `pync` — not importable on this system + Fix: wrap in try/except ImportError and degrade gracefully +``` diff --git a/skills/core/skill-portability-checker/check.py b/skills/core/skill-portability-checker/check.py new file mode 100755 index 0000000..673940b --- /dev/null +++ b/skills/core/skill-portability-checker/check.py @@ -0,0 +1,369 @@ +#!/usr/bin/env python3 +""" +Skill Portability Checker for openclaw-superpowers. + +Validates companion script OS/binary dependencies and checks whether +they are present on the current machine. + +Usage: + python3 check.py --check + python3 check.py --check --skill obsidian-sync + python3 check.py --fix-hints + python3 check.py --format json +""" + +import argparse +import importlib +import json +import os +import platform +import re +import shutil +import sys +from pathlib import Path + +try: + import yaml + HAS_YAML = True +except ImportError: + HAS_YAML = False + +SUPERPOWERS_DIR = Path(os.environ.get( + "SUPERPOWERS_DIR", + Path.home() / ".openclaw" / "extensions" / "superpowers" +)) +SKILLS_DIRS = [ + SUPERPOWERS_DIR / "skills" / "core", + SUPERPOWERS_DIR / "skills" / "openclaw-native", + SUPERPOWERS_DIR / "skills" / "community", +] + +# Known OS-specific binaries +MACOS_ONLY_BINARIES = { + "osascript", "pbcopy", "pbpaste", "open", "launchctl", "caffeinate", + "defaults", "plutil", "say", "afplay", "mdfind", "mdls", +} +LINUX_ONLY_BINARIES = { + "systemctl", "journalctl", "apt", "apt-get", "dpkg", "yum", "dnf", + "pacman", "snap", "xclip", "xdotool", "notify-send", "xdg-open", +} +BREW_BINARY = "brew" + +# Stdlib modules (not exhaustive, covers common ones) +STDLIB_MODULES = { + "os", "sys", "re", "json", "yaml", "pathlib", "datetime", "time", + "collections", "itertools", "functools", "typing", "io", "math", + "random", "hashlib", "hmac", "base64", "struct", "copy", "enum", + "abc", "dataclasses", "contextlib", "threading", "subprocess", + "shutil", "tempfile", "glob", "fnmatch", "stat", "socket", "http", + "urllib", "email", "csv", "sqlite3", "logging", "unittest", "argparse", + "configparser", "getpass", "platform", "importlib", "inspect", + "traceback", "warnings", "weakref", "gc", "signal", "textwrap", + "string", "difflib", "html", "xml", "pprint", "decimal", "fractions", +} + +# Patterns for detecting binary calls in scripts +BINARY_CALL_RE = re.compile( + r'(?:subprocess\.(?:run|Popen|call|check_output|check_call)\s*\(\s*[\[\(]?\s*["\'])([a-z_\-]+)', + re.I +) +SHELL_CALL_RE = re.compile(r'(?:os\.system|os\.popen)\s*\(\s*["\']([a-z_\-]+)', re.I) +SHUTIL_WHICH_RE = re.compile(r'shutil\.which\s*\(\s*["\']([a-z_\-]+)', re.I) +IMPORT_RE = re.compile(r'^(?:import|from)\s+([a-zA-Z_][a-zA-Z0-9_]*)', re.M) +HARDCODED_PATH_RE = re.compile( + r'["\'](?:/usr/local/|/opt/homebrew/|/home/[a-z]+/|C:\\\\)', re.I +) + + +def current_os() -> str: + s = platform.system().lower() + if s == "darwin": + return "macos" + if s == "windows": + return "windows" + return "linux" + + +# ── Frontmatter ─────────────────────────────────────────────────────────────── + +def parse_frontmatter(skill_md: Path) -> dict: + try: + text = skill_md.read_text() + lines = text.splitlines() + if not lines or lines[0].strip() != "---": + return {} + end = None + for i, line in enumerate(lines[1:], 1): + if line.strip() == "---": + end = i + break + if end is None: + return {} + fm_text = "\n".join(lines[1:end]) + if HAS_YAML: + return yaml.safe_load(fm_text) or {} + fields = {} + for line in fm_text.splitlines(): + if ":" in line and not line.startswith(" "): + k, _, v = line.partition(":") + fields[k.strip()] = v.strip().strip('"').strip("'") + return fields + except Exception: + return {} + + +# ── Script analysis ─────────────────────────────────────────────────────────── + +def extract_binary_calls(text: str) -> set[str]: + binaries = set() + for pattern in (BINARY_CALL_RE, SHELL_CALL_RE, SHUTIL_WHICH_RE): + for m in pattern.finditer(text): + binaries.add(m.group(1).lower()) + return binaries + + +def extract_imports(text: str) -> set[str]: + imports = set() + for m in IMPORT_RE.finditer(text): + mod = m.group(1).split(".")[0] + if mod not in STDLIB_MODULES: + imports.add(mod) + return imports + + +def is_importable(module: str) -> bool: + try: + importlib.import_module(module) + return True + except ImportError: + return False + + +def binary_present(name: str) -> bool: + return shutil.which(name) is not None + + +# ── Skill checker ───────────────────────────────────────────────────────────── + +def check_skill(skill_dir: Path, os_name: str) -> list[dict]: + skill_name = skill_dir.name + skill_md = skill_dir / "SKILL.md" + if not skill_md.exists(): + return [] + + fm = parse_frontmatter(skill_md) + os_filter = fm.get("os_filter") or [] + if isinstance(os_filter, str): + os_filter = [os_filter] + os_filter = [str(o).lower() for o in os_filter] + + issues = [] + + def issue(level, check, file_path, detail, fix_hint): + return { + "skill_name": skill_name, + "level": level, + "check": check, + "file": str(file_path), + "detail": detail, + "fix_hint": fix_hint, + } + + # Scan companion scripts + for script in skill_dir.iterdir(): + if not script.is_file(): + continue + if script.suffix not in (".py", ".sh"): + continue + + try: + text = script.read_text(errors="replace") + except Exception: + continue + + # Binary call analysis + binaries = extract_binary_calls(text) + + for binary in binaries: + # macOS-only binary + if binary in MACOS_ONLY_BINARIES: + if os_filter and "macos" not in os_filter: + issues.append(issue( + "FAIL", "OS_SPECIFIC_CALL", script, + f"Calls `{binary}` (macOS-only) but os_filter excludes macOS", + f"Remove macOS calls or set `os_filter: [macos]` in frontmatter." + )) + elif not os_filter: + issues.append(issue( + "WARN", "OS_SPECIFIC_CALL", script, + f"Calls `{binary}` (macOS-only) but no os_filter declared", + f"Add `os_filter: [macos]` to frontmatter." + )) + if os_name != "macos" and binary not in os_filter: + if not binary_present(binary): + issues.append(issue( + "FAIL", "MISSING_BINARY", script, + f"`{binary}` not found on this system", + f"Install `{binary}` or add `os_filter: [macos]` to frontmatter." + )) + + # Linux-only binary + elif binary in LINUX_ONLY_BINARIES: + if os_filter and "linux" not in os_filter: + issues.append(issue( + "FAIL", "OS_SPECIFIC_CALL", script, + f"Calls `{binary}` (Linux-only) but os_filter excludes Linux", + "Remove Linux-specific calls or add `linux` to os_filter." + )) + elif not os_filter: + issues.append(issue( + "WARN", "OS_SPECIFIC_CALL", script, + f"Calls `{binary}` (Linux-only) but no os_filter declared", + "Add `os_filter: [linux]` to frontmatter." + )) + + # brew special case + elif binary == BREW_BINARY: + issues.append(issue( + "WARN", "BREW_ONLY", script, + "Script calls `brew` (Homebrew/macOS-only)", + "Add `os_filter: [macos]` or use a cross-platform alternative." + )) + + # General binary — check if present + else: + if not binary_present(binary) and binary not in ( + "python3", "python", "bash", "sh", "openclaw" + ): + issues.append(issue( + "WARN", "MISSING_BINARY", script, + f"`{binary}` not found on PATH", + f"Install `{binary}` or add a fallback when it's missing." + )) + + # Hardcoded paths + if HARDCODED_PATH_RE.search(text): + issues.append(issue( + "WARN", "HARDCODED_PATH", script, + "Script contains hardcoded absolute paths that may not exist on all systems", + "Replace with `Path.home()` or environment-variable-based paths." + )) + + # Python imports (only for .py files) + if script.suffix == ".py": + imports = extract_imports(text) + for mod in imports: + if not is_importable(mod): + issues.append(issue( + "WARN", "PYTHON_IMPORT", script, + f"imports `{mod}` which is not installed on this system", + f"Install with `pip install {mod}` or add try/except ImportError." + )) + + # os_filter correctness: if os_filter present, check it's valid values + valid_os_values = {"macos", "linux", "windows"} + for os_val in os_filter: + if os_val not in valid_os_values: + issues.append(issue( + "WARN", "INVALID_OS_FILTER", skill_md, + f"os_filter contains unknown value: `{os_val}`", + f"Valid values: {sorted(valid_os_values)}" + )) + + return issues + + +# ── Commands ────────────────────────────────────────────────────────────────── + +def cmd_check(single_skill: str, fmt: str) -> None: + os_name = current_os() + all_issues = [] + skills_checked = 0 + + for skills_root in SKILLS_DIRS: + if not skills_root.exists(): + continue + for skill_dir in sorted(skills_root.iterdir()): + if not skill_dir.is_dir(): + continue + if single_skill and skill_dir.name != single_skill: + continue + issues = check_skill(skill_dir, os_name) + all_issues.extend(issues) + skills_checked += 1 + + fails = sum(1 for i in all_issues if i["level"] == "FAIL") + warns = sum(1 for i in all_issues if i["level"] == "WARN") + py_ver = f"Python {sys.version_info.major}.{sys.version_info.minor}" + + if fmt == "json": + print(json.dumps({ + "os": os_name, + "python_version": py_ver, + "skills_checked": skills_checked, + "fail_count": fails, + "warn_count": warns, + "issues": all_issues, + }, indent=2)) + else: + print(f"\nSkill Portability Report — {os_name} ({py_ver})") + print("─" * 50) + print(f" {skills_checked} skills checked | {fails} FAIL | {warns} WARN") + print() + if not all_issues: + print(" ✓ All skills portable on this system.") + else: + by_skill: dict = {} + for iss in all_issues: + by_skill.setdefault(iss["skill_name"], []).append(iss) + for sname, issues in sorted(by_skill.items()): + for iss in issues: + icon = "✗" if iss["level"] == "FAIL" else "⚠" + print(f" {icon} {sname}: {iss['check']}") + print(f" {iss['detail']}") + print(f" Fix: {iss['fix_hint']}") + print() + print() + + sys.exit(1 if fails > 0 else 0) + + +def cmd_fix_hints(skill_name: str) -> None: + os_name = current_os() + for skills_root in SKILLS_DIRS: + skill_dir = skills_root / skill_name + if skill_dir.exists(): + issues = check_skill(skill_dir, os_name) + if not issues: + print(f"✓ No portability issues found for '{skill_name}'.") + return + print(f"\nFix hints for: {skill_name}") + print("─" * 40) + for iss in issues: + print(f" [{iss['level']}] {iss['check']}") + print(f" {iss['detail']}") + print(f" → {iss['fix_hint']}") + print() + return + print(f"Skill '{skill_name}' not found.") + + +# ── Main ────────────────────────────────────────────────────────────────────── + +def main(): + parser = argparse.ArgumentParser(description="Skill Portability Checker") + group = parser.add_mutually_exclusive_group(required=True) + group.add_argument("--check", action="store_true") + group.add_argument("--fix-hints", metavar="SKILL") + parser.add_argument("--skill", metavar="NAME", help="Check single skill only") + parser.add_argument("--format", choices=["text", "json"], default="text") + args = parser.parse_args() + + if args.fix_hints: + cmd_fix_hints(args.fix_hints) + elif args.check: + cmd_check(single_skill=args.skill, fmt=args.format) + + +if __name__ == "__main__": + main()