From 0a54919c62a164b1c744950195de75dd61d98539 Mon Sep 17 00:00:00 2001 From: Vitor Date: Sun, 1 Mar 2026 10:14:45 -0300 Subject: [PATCH 1/4] Add OpenCode compatibility installer for plugin workflows Install matching commands and skills with a no-clone shell script so OpenCode keeps the same command-to-skill UX as Claude plugins, and add docs plus CI checks to enforce compatibility. --- .github/workflows/validate.yml | 6 + README.md | 22 + docs/opencode.md | 159 ++++ scripts/install_opencode_skills.sh | 1075 +++++++++++++++++++++++++++ scripts/validate_opencode_compat.py | 139 ++++ 5 files changed, 1401 insertions(+) create mode 100644 docs/opencode.md create mode 100755 scripts/install_opencode_skills.sh create mode 100644 scripts/validate_opencode_compat.py diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 83bbb83..12fd1f6 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -89,3 +89,9 @@ jobs: - name: Validate plugin metadata run: python3 .github/scripts/validate_plugin_metadata.py + + - name: Validate OpenCode skill compatibility + run: python3 scripts/validate_opencode_compat.py + + - name: Smoke test OpenCode installer script + run: bash scripts/install_opencode_skills.sh --source local --bundle smart-contracts --dry-run --target /tmp/opencode-skills-smoke --commands-target /tmp/opencode-commands-smoke diff --git a/README.md b/README.md index 8688de5..cceebdd 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,28 @@ cd /path/to/parent # e.g., if repo is at ~/projects/skills, be in ~/projects /plugins marketplace add ./skills ``` +### OpenCode Compatibility + +Plugins in this repository can be installed for OpenCode without cloning this repository by using the shell installer. It installs both skills and plugin commands so the OpenCode UX remains command-first: + +```bash +curl -fsSL https://raw.githubusercontent.com/trailofbits/skills/main/scripts/install_opencode_skills.sh | bash +``` + +Install only smart contract auditing plugins: + +```bash +curl -fsSL https://raw.githubusercontent.com/trailofbits/skills/main/scripts/install_opencode_skills.sh | bash -s -- --bundle smart-contracts +``` + +Then run commands directly in OpenCode, for example: + +```text +/trailofbits:entry-points . +``` + +See [`docs/opencode.md`](docs/opencode.md) for full instructions, uninstall options, and compatibility caveats. + ## Available Plugins ### Smart Contract Security diff --git a/docs/opencode.md b/docs/opencode.md new file mode 100644 index 0000000..94d83ad --- /dev/null +++ b/docs/opencode.md @@ -0,0 +1,159 @@ +# OpenCode Compatibility + +This repository is Claude Code plugin-marketplace first, but plugin workflows can be used in OpenCode with the same command-to-skill relationship. + +## Compatibility Model + +The OpenCode installer preserves plugin usability by installing both: + +- plugin skills into `~/.config/opencode/skills` +- plugin commands into `~/.config/opencode/commands` + +This keeps the same flow users expect from Claude plugins: + +- invoke a plugin command +- command prompt loads/invokes the matching skill +- skill executes the full workflow + +## What Translates Well + +- Skill content from `plugins/*/skills/**/SKILL.md` +- Plugin command content from `plugins/*/commands/*.md` +- Bundle and plugin-level installation filters + +## What Does Not Translate 1:1 + +- Claude plugin wrappers (`.claude-plugin/plugin.json`, `.claude-plugin/marketplace.json`) +- Claude hooks/commands runtime behavior +- `allowed-tools` frontmatter enforcement (OpenCode ignores unknown frontmatter fields) + +## Install For OpenCode (No Clone Required) + +OpenCode does not currently provide a Claude-style marketplace menu for skill repositories, so this repo ships a shell installer. + +By default, the installer: + +- downloads this repository archive from GitHub +- copies both skills and commands into OpenCode config directories + +### Install All Plugins + +```bash +curl -fsSL https://raw.githubusercontent.com/trailofbits/skills/main/scripts/install_opencode_skills.sh | bash +``` + +### Install Smart Contract Bundle + +```bash +curl -fsSL https://raw.githubusercontent.com/trailofbits/skills/main/scripts/install_opencode_skills.sh | bash -s -- --bundle smart-contracts +``` + +The smart contract bundle includes these plugins: + +- `building-secure-contracts` +- `entry-point-analyzer` +- `spec-to-code-compliance` +- `property-based-testing` + +### Inspect Before Running (Safer Two-Step) + +```bash +curl -fsSL -o /tmp/install_opencode_skills.sh https://raw.githubusercontent.com/trailofbits/skills/main/scripts/install_opencode_skills.sh +bash /tmp/install_opencode_skills.sh --bundle smart-contracts +``` + +## Use Installed Plugin Commands + +After installation, run plugin commands directly in OpenCode (same command-first workflow): + +```text +/trailofbits:entry-points . +/trailofbits:spec-compliance SPEC.md . +/trailofbits:variants +``` + +These commands invoke the associated skills automatically. + +## List, Filter, and Scope Installation + +### Preview Selected Items + +```bash +bash /tmp/install_opencode_skills.sh --list --bundle smart-contracts +``` + +### Filter by Plugin or Item Name + +```bash +# Install one plugin's skills and commands +bash /tmp/install_opencode_skills.sh --plugin entry-point-analyzer + +# Install a specific skill and matching commands +bash /tmp/install_opencode_skills.sh --skill entry-point-analyzer + +# Install one command by name +bash /tmp/install_opencode_skills.sh --commands-only --command trailofbits:entry-points +``` + +### Install Only Skills or Only Commands + +```bash +bash /tmp/install_opencode_skills.sh --skills-only --bundle smart-contracts +bash /tmp/install_opencode_skills.sh --commands-only --bundle smart-contracts +``` + +## Custom Target Directories + +```bash +# Skills target (default: ~/.config/opencode/skills) +# Commands target (default: ~/.config/opencode/commands) +bash /tmp/install_opencode_skills.sh --bundle smart-contracts --target .opencode/skills --commands-target .opencode/commands +``` + +## Uninstall + +```bash +# Remove all managed skills and commands from default targets +bash /tmp/install_opencode_skills.sh --uninstall + +# Remove only smart contract bundle items +bash /tmp/install_opencode_skills.sh --uninstall --bundle smart-contracts +``` + +## Local Contributor Mode + +If you are in a local checkout and want symlinks for development: + +```bash +bash scripts/install_opencode_skills.sh --source local --bundle smart-contracts --link +``` + +## Useful Flags + +- `--dry-run` preview actions +- `--force` replace existing targets or remove unmanaged paths +- `--repo ` and `--ref ` install from a specific GitHub source +- `--include-incompatible-commands` include known Claude-specific commands (off by default) + +## Known Command Caveats + +`skill-improver` command files use Claude-specific `${CLAUDE_PLUGIN_ROOT}` script hooks. They are skipped by default during OpenCode install. + +## Portability Notes + +Some skills use `{baseDir}` in instructions. Treat `{baseDir}` as the skill directory root when running outside Claude Code. + +The repository includes a compatibility validator: + +```bash +python3 scripts/validate_opencode_compat.py +``` + +## Optional: Install With OpenPackage + +OpenPackage can also install this repository for OpenCode. This is optional and not required for the first-party installer flow above. + +```bash +npm install -g opkg +opkg install gh@trailofbits/skills --platforms opencode +``` diff --git a/scripts/install_opencode_skills.sh b/scripts/install_opencode_skills.sh new file mode 100755 index 0000000..0698f92 --- /dev/null +++ b/scripts/install_opencode_skills.sh @@ -0,0 +1,1075 @@ +#!/usr/bin/env bash + +set -euo pipefail + +usage() { + cat <<'EOF' +Install Trail of Bits plugin skills and commands for OpenCode. + +Default behavior: + - Source: remote GitHub archive (no local clone needed) + - Action: install + - Components: skills and commands + - Mode: copy + - Skills target: ~/.config/opencode/skills + - Commands target: ~/.config/opencode/commands + +Usage: + install_opencode_skills.sh [options] + +Options: + --list List matching items and exit + --bundle NAME Install predefined bundle (supported: smart-contracts) + --plugin NAME Filter by plugin (repeatable) + --skill NAME Filter by skill name (repeatable) + --command NAME Filter by command name (repeatable) + --all Install all items (default when no filters are provided) + --target PATH Skills target directory (default: ~/.config/opencode/skills) + --skills-target PATH Alias for --target + --commands-target PATH Commands target directory (default: ~/.config/opencode/commands) + --skills-only Install/uninstall only skills + --commands-only Install/uninstall only commands + --include-incompatible-commands Include Claude-specific commands (default: false) + --source SOURCE Source: remote|local (default: remote) + --repo OWNER/REPO GitHub repository for remote source (default: trailofbits/skills) + --ref REF Git ref for remote source (default: main) + --copy Copy items into target directories (default) + --link Symlink items into target directories (local source only) + --uninstall Remove matching items from targets + --force Replace or remove existing unmanaged paths + --dry-run Print planned changes without modifying files + -h, --help Show this help + +Examples: + # Install smart contract bundle from GitHub (no clone required) + install_opencode_skills.sh --bundle smart-contracts + + # Install only commands from one plugin + install_opencode_skills.sh --commands-only --plugin entry-point-analyzer + + # Local contributor workflow with symlinks + install_opencode_skills.sh --source local --bundle smart-contracts --link + + # Uninstall one command and its related skill + install_opencode_skills.sh --skill entry-point-analyzer --command entry-points --uninstall +EOF +} + +fail() { + printf 'Error: %s\n' "$1" >&2 + exit 1 +} + +contains_exact() { + local needle="$1" + shift + local item + for item in "$@"; do + if [[ "$item" == "$needle" ]]; then + return 0 + fi + done + return 1 +} + +trim() { + local value="$1" + value="${value#"${value%%[![:space:]]*}"}" + value="${value%"${value##*[![:space:]]}"}" + printf '%s' "$value" +} + +expand_path() { + local path="$1" + + if [[ "$path" == "~" ]]; then + path="$HOME" + elif [[ "$path" == "~/"* ]]; then + path="$HOME/${path#\~/}" + fi + + if [[ "$path" != /* ]]; then + path="$(pwd)/$path" + fi + + printf '%s' "$path" +} + +extract_skill_name_from_file() { + local file="$1" + local line + local in_frontmatter=0 + + while IFS= read -r line || [[ -n "$line" ]]; do + line="${line%$'\r'}" + + if [[ $in_frontmatter -eq 0 ]]; then + if [[ "$line" == "---" ]]; then + in_frontmatter=1 + continue + fi + return 1 + fi + + if [[ "$line" == "---" ]]; then + return 1 + fi + + if [[ "$line" =~ ^[[:space:]]*name:[[:space:]]*(.*)$ ]]; then + local value + value="$(trim "${BASH_REMATCH[1]}")" + if [[ -z "$value" ]]; then + return 1 + fi + + if [[ "$value" == "\""*"\"" ]]; then + value="${value#\"}" + value="${value%\"}" + elif [[ "$value" == "'"*"'" ]]; then + value="${value#\'}" + value="${value%\'}" + fi + + printf '%s' "$value" + return 0 + fi + done < "$file" + + return 1 +} + +extract_command_frontmatter_name_from_file() { + local file="$1" + local line + local in_frontmatter=0 + + while IFS= read -r line || [[ -n "$line" ]]; do + line="${line%$'\r'}" + + if [[ $in_frontmatter -eq 0 ]]; then + if [[ "$line" == "---" ]]; then + in_frontmatter=1 + continue + fi + return 1 + fi + + if [[ "$line" == "---" ]]; then + return 1 + fi + + if [[ "$line" =~ ^[[:space:]]*name:[[:space:]]*(.*)$ ]]; then + local value + value="$(trim "${BASH_REMATCH[1]}")" + if [[ -z "$value" ]]; then + return 1 + fi + + if [[ "$value" == "\""*"\"" ]]; then + value="${value#\"}" + value="${value%\"}" + elif [[ "$value" == "'"*"'" ]]; then + value="${value#\'}" + value="${value%\'}" + fi + + printf '%s' "$value" + return 0 + fi + done < "$file" + + return 1 +} + +extract_skill_name_from_dir() { + local dir="$1" + local skill_file="$dir/SKILL.md" + + if [[ ! -f "$skill_file" ]]; then + return 1 + fi + + extract_skill_name_from_file "$skill_file" +} + +extract_command_referenced_skills() { + local file="$1" + local line + local token + local normalized + local refs_csv="" + + while IFS= read -r line || [[ -n "$line" ]]; do + line="${line%$'\r'}" + + if [[ "$line" != *"\`"*"\`"*" skill"* ]]; then + continue + fi + + token="${line#*\`}" + token="${token%%\`*}" + + normalized="${token##*:}" + if [[ "$normalized" =~ ^[a-z0-9]+(-[a-z0-9]+)*$ ]]; then + if ! csv_contains "$refs_csv" "$normalized"; then + if [[ -z "$refs_csv" ]]; then + refs_csv="$normalized" + else + refs_csv+=",$normalized" + fi + fi + fi + done < "$file" + + printf '%s' "$refs_csv" +} + +csv_contains() { + local csv="$1" + local needle="$2" + local item + declare -a items=() + if [[ -n "$csv" ]]; then + IFS=',' read -r -a items <<< "$csv" + fi + for item in "${items[@]-}"; do + if [[ "$item" == "$needle" ]]; then + return 0 + fi + done + return 1 +} + +matches_selected_skill_names() { + local command_refs_csv="$1" + local skill_name + + if [[ -z "$command_refs_csv" ]]; then + return 1 + fi + + for skill_name in "${SELECTED_SKILL_NAMES[@]}"; do + if csv_contains "$command_refs_csv" "$skill_name"; then + return 0 + fi + done + + return 1 +} + +is_smart_contract_plugin() { + case "$1" in + building-secure-contracts|entry-point-analyzer|spec-to-code-compliance|property-based-testing) + return 0 + ;; + *) + return 1 + ;; + esac +} + +matches_bundle() { + local bundle="$1" + local plugin="$2" + + case "$bundle" in + smart-contracts) + is_smart_contract_plugin "$plugin" + ;; + "") + return 0 + ;; + *) + return 1 + ;; + esac +} + +is_symlink_to() { + local target="$1" + local expected="$2" + local target_real + local expected_real + + if [[ ! -L "$target" ]]; then + return 1 + fi + + if command -v realpath >/dev/null 2>&1; then + target_real="$(realpath "$target" 2>/dev/null || true)" + expected_real="$(realpath "$expected" 2>/dev/null || true)" + if [[ -n "$target_real" && -n "$expected_real" && "$target_real" == "$expected_real" ]]; then + return 0 + fi + fi + + local actual + actual="$(readlink "$target")" + [[ "$actual" == "$expected" ]] +} + +files_equal() { + local a="$1" + local b="$2" + cmp -s "$a" "$b" +} + +is_command_compatible_with_opencode() { + local file="$1" + if grep -q '\${CLAUDE_PLUGIN_ROOT}' "$file"; then + return 1 + fi + return 0 +} + +safe_remove_path() { + local path="$1" + rm -rf "$path" +} + +SOURCE="remote" +REPO="trailofbits/skills" +REF="main" +SKILLS_TARGET="~/.config/opencode/skills" +COMMANDS_TARGET="~/.config/opencode/commands" +ACTION="install" +MODE="copy" +BUNDLE="" +DRY_RUN=0 +FORCE=0 +LIST_ONLY=0 +ALL=0 +INSTALL_SKILLS=1 +INSTALL_COMMANDS=1 +INCLUDE_INCOMPATIBLE_COMMANDS=0 + +declare -a PLUGIN_FILTERS=() +declare -a SKILL_FILTERS=() +declare -a COMMAND_FILTERS=() + +while [[ $# -gt 0 ]]; do + case "$1" in + --list) + LIST_ONLY=1 + shift + ;; + --bundle) + [[ $# -ge 2 ]] || fail "--bundle requires a value" + BUNDLE="$2" + shift 2 + ;; + --plugin) + [[ $# -ge 2 ]] || fail "--plugin requires a value" + PLUGIN_FILTERS+=("$2") + shift 2 + ;; + --skill) + [[ $# -ge 2 ]] || fail "--skill requires a value" + SKILL_FILTERS+=("$2") + shift 2 + ;; + --command) + [[ $# -ge 2 ]] || fail "--command requires a value" + COMMAND_FILTERS+=("$2") + shift 2 + ;; + --all) + ALL=1 + shift + ;; + --target|--skills-target) + [[ $# -ge 2 ]] || fail "$1 requires a value" + SKILLS_TARGET="$2" + shift 2 + ;; + --commands-target) + [[ $# -ge 2 ]] || fail "--commands-target requires a value" + COMMANDS_TARGET="$2" + shift 2 + ;; + --skills-only) + INSTALL_SKILLS=1 + INSTALL_COMMANDS=0 + shift + ;; + --commands-only) + INSTALL_SKILLS=0 + INSTALL_COMMANDS=1 + shift + ;; + --include-incompatible-commands) + INCLUDE_INCOMPATIBLE_COMMANDS=1 + shift + ;; + --source) + [[ $# -ge 2 ]] || fail "--source requires a value" + SOURCE="$2" + shift 2 + ;; + --repo) + [[ $# -ge 2 ]] || fail "--repo requires a value" + REPO="$2" + shift 2 + ;; + --ref) + [[ $# -ge 2 ]] || fail "--ref requires a value" + REF="$2" + shift 2 + ;; + --copy) + MODE="copy" + shift + ;; + --link|--symlink) + MODE="link" + shift + ;; + --uninstall) + ACTION="uninstall" + shift + ;; + --force) + FORCE=1 + shift + ;; + --dry-run) + DRY_RUN=1 + shift + ;; + -h|--help) + usage + exit 0 + ;; + *) + fail "Unknown option: $1" + ;; + esac +done + +if [[ "$SOURCE" != "remote" && "$SOURCE" != "local" ]]; then + fail "--source must be 'remote' or 'local'" +fi + +if [[ "$BUNDLE" != "" && "$BUNDLE" != "smart-contracts" ]]; then + fail "Unsupported bundle '$BUNDLE' (supported: smart-contracts)" +fi + +if [[ $ALL -eq 1 && ( "$BUNDLE" != "" || ${#PLUGIN_FILTERS[@]} -gt 0 || ${#SKILL_FILTERS[@]} -gt 0 || ${#COMMAND_FILTERS[@]} -gt 0 ) ]]; then + fail "--all cannot be combined with --bundle, --plugin, --skill, or --command" +fi + +if [[ "$MODE" == "link" && "$SOURCE" != "local" ]]; then + fail "--link is only supported with --source local" +fi + +if [[ $INSTALL_SKILLS -eq 0 && $INSTALL_COMMANDS -eq 0 ]]; then + fail "Nothing to do: choose one of --skills-only or --commands-only" +fi + +SKILLS_TARGET="$(expand_path "$SKILLS_TARGET")" +COMMANDS_TARGET="$(expand_path "$COMMANDS_TARGET")" + +TMP_DIR="" +cleanup() { + if [[ -n "$TMP_DIR" && -d "$TMP_DIR" ]]; then + rm -rf "$TMP_DIR" + fi +} +trap cleanup EXIT + +SOURCE_ROOT="" + +if [[ "$SOURCE" == "remote" ]]; then + command -v curl >/dev/null 2>&1 || fail "curl is required for --source remote" + command -v tar >/dev/null 2>&1 || fail "tar is required for --source remote" + command -v find >/dev/null 2>&1 || fail "find is required" + command -v grep >/dev/null 2>&1 || fail "grep is required" + command -v cmp >/dev/null 2>&1 || fail "cmp is required" + + TMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/opencode-skills.XXXXXX")" + ARCHIVE_URL="https://codeload.github.com/${REPO}/tar.gz/${REF}" + ARCHIVE_PATH="$TMP_DIR/source.tar.gz" + + if ! curl -fsSL "$ARCHIVE_URL" -o "$ARCHIVE_PATH"; then + fail "Failed to download archive from $ARCHIVE_URL" + fi + + if ! tar -xzf "$ARCHIVE_PATH" -C "$TMP_DIR"; then + fail "Failed to extract downloaded archive" + fi + + SOURCE_ROOT="$(find "$TMP_DIR" -mindepth 1 -maxdepth 1 -type d | head -n 1)" + [[ -n "$SOURCE_ROOT" ]] || fail "Could not locate extracted repository root" +else + command -v find >/dev/null 2>&1 || fail "find is required" + command -v grep >/dev/null 2>&1 || fail "grep is required" + command -v cmp >/dev/null 2>&1 || fail "cmp is required" + + SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)" + SOURCE_ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)" + if [[ ! -d "$SOURCE_ROOT/plugins" ]]; then + fail "Local source root is invalid: $SOURCE_ROOT (expected plugins/ directory)" + fi +fi + +if [[ ! -d "$SOURCE_ROOT/plugins" ]]; then + fail "No plugins directory found at $SOURCE_ROOT/plugins" +fi + +declare -a SKILL_NAMES=() +declare -a SKILL_PLUGINS=() +declare -a SKILL_DIRS=() +declare -a SKILL_FILES=() + +while IFS= read -r skill_file; do + rel_path="${skill_file#"$SOURCE_ROOT/plugins/"}" + plugin_name="${rel_path%%/*}" + skill_dir="$(cd "$(dirname "$skill_file")" && pwd -P)" + + skill_name="$(extract_skill_name_from_file "$skill_file" || true)" + if [[ -z "$skill_name" ]]; then + fail "Could not read frontmatter name from $skill_file" + fi + + if [[ ! "$skill_name" =~ ^[a-z0-9]+(-[a-z0-9]+)*$ ]]; then + fail "Invalid skill name '$skill_name' in $skill_file" + fi + + if (( ${#skill_name} > 64 )); then + fail "Skill name '$skill_name' exceeds 64 characters ($skill_file)" + fi + + if [[ ${#SKILL_NAMES[@]} -gt 0 ]] && contains_exact "$skill_name" "${SKILL_NAMES[@]}"; then + fail "Duplicate skill name detected: $skill_name" + fi + + SKILL_NAMES+=("$skill_name") + SKILL_PLUGINS+=("$plugin_name") + SKILL_DIRS+=("$skill_dir") + SKILL_FILES+=("$skill_file") +done < <(find "$SOURCE_ROOT/plugins" -type f -name 'SKILL.md' | sort) + +if [[ ${#SKILL_NAMES[@]} -eq 0 ]]; then + fail "No SKILL.md files found under $SOURCE_ROOT/plugins" +fi + +declare -a COMMAND_NAMES=() +declare -a COMMAND_FILE_NAMES=() +declare -a COMMAND_PLUGINS=() +declare -a COMMAND_FILES=() +declare -a COMMAND_REFERENCED_SKILLS=() +declare -a COMMAND_COMPATIBLE=() + +while IFS= read -r command_file; do + rel_path="${command_file#"$SOURCE_ROOT/plugins/"}" + plugin_name="${rel_path%%/*}" + command_file_name="$(basename "$command_file" .md)" + + command_frontmatter_name="$(extract_command_frontmatter_name_from_file "$command_file" || true)" + if [[ -n "$command_frontmatter_name" ]]; then + command_name="$command_frontmatter_name" + else + command_name="$command_file_name" + fi + + command_refs="$(extract_command_referenced_skills "$command_file")" + + if is_command_compatible_with_opencode "$command_file"; then + command_compatible="1" + else + command_compatible="0" + fi + + COMMAND_NAMES+=("$command_name") + COMMAND_FILE_NAMES+=("$command_file_name") + COMMAND_PLUGINS+=("$plugin_name") + COMMAND_FILES+=("$command_file") + COMMAND_REFERENCED_SKILLS+=("$command_refs") + COMMAND_COMPATIBLE+=("$command_compatible") +done < <(find "$SOURCE_ROOT/plugins" -type f -path '*/commands/*.md' | sort) + +for idx in "${!COMMAND_NAMES[@]}"; do + command_name="${COMMAND_NAMES[$idx]}" + command_plugin="${COMMAND_PLUGINS[$idx]}" + refs_csv="${COMMAND_REFERENCED_SKILLS[$idx]}" + + if [[ -z "$refs_csv" ]]; then + continue + fi + + IFS=',' read -r -a refs <<< "$refs_csv" + for ref in "${refs[@]-}"; do + if [[ -z "$ref" ]]; then + continue + fi + + referenced_skill_index="" + for skill_idx in "${!SKILL_NAMES[@]}"; do + if [[ "${SKILL_NAMES[$skill_idx]}" == "$ref" ]]; then + referenced_skill_index="$skill_idx" + break + fi + done + + if [[ -z "$referenced_skill_index" ]]; then + fail "Command '$command_name' references unknown skill '$ref'" + fi + + referenced_skill_plugin="${SKILL_PLUGINS[$referenced_skill_index]}" + if [[ "$referenced_skill_plugin" != "$command_plugin" ]]; then + fail "Command '$command_name' in plugin '$command_plugin' references skill '$ref' in plugin '$referenced_skill_plugin'" + fi + done +done + +declare -a KNOWN_PLUGINS=() +for plugin_name in "${SKILL_PLUGINS[@]}"; do + if [[ ${#KNOWN_PLUGINS[@]} -eq 0 ]] || ! contains_exact "$plugin_name" "${KNOWN_PLUGINS[@]}"; then + KNOWN_PLUGINS+=("$plugin_name") + fi +done +for plugin_name in "${COMMAND_PLUGINS[@]}"; do + if [[ ${#KNOWN_PLUGINS[@]} -eq 0 ]] || ! contains_exact "$plugin_name" "${KNOWN_PLUGINS[@]}"; then + KNOWN_PLUGINS+=("$plugin_name") + fi +done + +if [[ ${#PLUGIN_FILTERS[@]} -gt 0 ]]; then + for plugin_filter in "${PLUGIN_FILTERS[@]}"; do + if ! contains_exact "$plugin_filter" "${KNOWN_PLUGINS[@]}"; then + fail "Unknown plugin filter: $plugin_filter" + fi + done +fi + +if [[ ${#SKILL_FILTERS[@]} -gt 0 ]]; then + for skill_filter in "${SKILL_FILTERS[@]}"; do + if ! contains_exact "$skill_filter" "${SKILL_NAMES[@]}"; then + fail "Unknown skill filter: $skill_filter" + fi + done +fi + +if [[ ${#COMMAND_FILTERS[@]} -gt 0 ]]; then + for command_filter in "${COMMAND_FILTERS[@]}"; do + found=0 + for idx in "${!COMMAND_NAMES[@]}"; do + if [[ "${COMMAND_NAMES[$idx]}" == "$command_filter" || "${COMMAND_FILE_NAMES[$idx]}" == "$command_filter" ]]; then + found=1 + break + fi + done + if [[ $found -eq 0 ]]; then + fail "Unknown command filter: $command_filter" + fi + done +fi + +declare -a SELECTED_SKILL_INDEXES=() +for idx in "${!SKILL_NAMES[@]}"; do + plugin_name="${SKILL_PLUGINS[$idx]}" + skill_name="${SKILL_NAMES[$idx]}" + include=1 + + if [[ "$BUNDLE" != "" ]] && ! matches_bundle "$BUNDLE" "$plugin_name"; then + include=0 + fi + + if [[ ${#PLUGIN_FILTERS[@]} -gt 0 ]] && ! contains_exact "$plugin_name" "${PLUGIN_FILTERS[@]}"; then + include=0 + fi + + if [[ ${#SKILL_FILTERS[@]} -gt 0 ]] && ! contains_exact "$skill_name" "${SKILL_FILTERS[@]}"; then + include=0 + fi + + if [[ $include -eq 1 ]]; then + SELECTED_SKILL_INDEXES+=("$idx") + fi +done + +if [[ ${#SELECTED_SKILL_INDEXES[@]} -eq 0 && $INSTALL_SKILLS -eq 1 ]]; then + fail "No skills matched the selected filters" +fi + +declare -a SELECTED_SKILL_NAMES=() +for idx in "${SELECTED_SKILL_INDEXES[@]}"; do + SELECTED_SKILL_NAMES+=("${SKILL_NAMES[$idx]}") +done + +declare -a SELECTED_COMMAND_INDEXES=() +declare -a SKIPPED_INCOMPATIBLE_COMMANDS=() + +for idx in "${!COMMAND_NAMES[@]}"; do + plugin_name="${COMMAND_PLUGINS[$idx]}" + command_name="${COMMAND_NAMES[$idx]}" + referenced_skills_csv="${COMMAND_REFERENCED_SKILLS[$idx]}" + compatible_flag="${COMMAND_COMPATIBLE[$idx]}" + include=1 + + if [[ "$BUNDLE" != "" ]] && ! matches_bundle "$BUNDLE" "$plugin_name"; then + include=0 + fi + + if [[ ${#PLUGIN_FILTERS[@]} -gt 0 ]] && ! contains_exact "$plugin_name" "${PLUGIN_FILTERS[@]}"; then + include=0 + fi + + if [[ ${#COMMAND_FILTERS[@]} -gt 0 ]] && ! contains_exact "$command_name" "${COMMAND_FILTERS[@]}"; then + include=0 + for command_filter in "${COMMAND_FILTERS[@]}"; do + if [[ "$command_name" == "$command_filter" || "${COMMAND_FILE_NAMES[$idx]}" == "$command_filter" ]]; then + include=1 + break + fi + done + fi + + if [[ ${#SKILL_FILTERS[@]} -gt 0 ]]; then + if ! matches_selected_skill_names "$referenced_skills_csv"; then + include=0 + fi + fi + + if [[ $include -eq 1 && "$compatible_flag" == "0" && $INCLUDE_INCOMPATIBLE_COMMANDS -eq 0 ]]; then + SKIPPED_INCOMPATIBLE_COMMANDS+=("$command_name (${plugin_name})") + include=0 + fi + + if [[ $include -eq 1 ]]; then + SELECTED_COMMAND_INDEXES+=("$idx") + fi +done + +if [[ $INSTALL_COMMANDS -eq 1 && ${#SELECTED_COMMAND_INDEXES[@]} -gt 0 ]]; then + declare -a SEEN_COMMAND_NAMES=() + for idx in "${SELECTED_COMMAND_INDEXES[@]}"; do + command_name="${COMMAND_NAMES[$idx]}" + if [[ ${#SEEN_COMMAND_NAMES[@]} -gt 0 ]] && contains_exact "$command_name" "${SEEN_COMMAND_NAMES[@]}"; then + fail "Selected commands include duplicate command name '$command_name'; refine filters" + fi + SEEN_COMMAND_NAMES+=("$command_name") + done +fi + +source_info="$SOURCE" +if [[ "$SOURCE" == "remote" ]]; then + source_info+=" (${REPO}@${REF})" +fi + +mode_info="$MODE" +if [[ "$MODE" == "link" ]]; then + mode_info+=" (local only)" +fi + +printf 'Discovered %d skills and %d commands.\n' "${#SKILL_NAMES[@]}" "${#COMMAND_NAMES[@]}" +printf 'Selected %d skills and %d commands.\n' "${#SELECTED_SKILL_INDEXES[@]}" "${#SELECTED_COMMAND_INDEXES[@]}" +printf 'Action: %s. Mode: %s. Source: %s\n' "$ACTION" "$mode_info" "$source_info" + +if [[ $INSTALL_SKILLS -eq 1 ]]; then + printf 'Skills target: %s\n' "$SKILLS_TARGET" +fi +if [[ $INSTALL_COMMANDS -eq 1 ]]; then + printf 'Commands target: %s\n' "$COMMANDS_TARGET" +fi + +if [[ ${#SKIPPED_INCOMPATIBLE_COMMANDS[@]} -gt 0 ]]; then + echo + echo "Skipped incompatible commands (use --include-incompatible-commands to include):" + for item in "${SKIPPED_INCOMPATIBLE_COMMANDS[@]}"; do + printf ' - %s\n' "$item" + done +fi + +if [[ $LIST_ONLY -eq 1 ]]; then + if [[ $INSTALL_SKILLS -eq 1 ]]; then + echo + echo "Skills:" + printf '%-36s %s\n' "SKILL" "PLUGIN" + printf '%-36s %s\n' "-----" "------" + for idx in "${SELECTED_SKILL_INDEXES[@]}"; do + printf '%-36s %s\n' "${SKILL_NAMES[$idx]}" "${SKILL_PLUGINS[$idx]}" + done + fi + + if [[ $INSTALL_COMMANDS -eq 1 ]]; then + echo + echo "Commands:" + printf '%-28s %-28s %s\n' "COMMAND" "PLUGIN" "SKILL REFERENCES" + printf '%-28s %-28s %s\n' "-------" "------" "----------------" + for idx in "${SELECTED_COMMAND_INDEXES[@]}"; do + refs="${COMMAND_REFERENCED_SKILLS[$idx]}" + if [[ -z "$refs" ]]; then + refs="(none detected)" + fi + printf '%-28s %-28s %s\n' "/${COMMAND_NAMES[$idx]}" "${COMMAND_PLUGINS[$idx]}" "$refs" + done + fi + exit 0 +fi + +if [[ "$ACTION" == "install" ]]; then + if [[ $INSTALL_SKILLS -eq 1 && ! -d "$SKILLS_TARGET" ]]; then + if [[ $DRY_RUN -eq 1 ]]; then + printf '[dry-run] mkdir -p %s\n' "$SKILLS_TARGET" + else + mkdir -p "$SKILLS_TARGET" + fi + fi + + if [[ $INSTALL_COMMANDS -eq 1 && ! -d "$COMMANDS_TARGET" ]]; then + if [[ $DRY_RUN -eq 1 ]]; then + printf '[dry-run] mkdir -p %s\n' "$COMMANDS_TARGET" + else + mkdir -p "$COMMANDS_TARGET" + fi + fi +fi + +skill_installed=0 +skill_removed=0 +skill_unchanged=0 +skill_skipped=0 +skill_error=0 + +command_installed=0 +command_removed=0 +command_unchanged=0 +command_skipped=0 +command_error=0 + +log_result() { + local status="$1" + local component="$2" + local name="$3" + local message="$4" + printf '[%s] %s %s: %s\n' "$status" "$component" "$name" "$message" +} + +if [[ $INSTALL_SKILLS -eq 1 ]]; then + for idx in "${SELECTED_SKILL_INDEXES[@]}"; do + name="${SKILL_NAMES[$idx]}" + source_dir="${SKILL_DIRS[$idx]}" + target_dir="$SKILLS_TARGET/$name" + + if [[ "$ACTION" == "install" ]]; then + exists=0 + if [[ -e "$target_dir" || -L "$target_dir" ]]; then + exists=1 + fi + + if [[ $exists -eq 1 ]]; then + if [[ "$MODE" == "link" ]] && is_symlink_to "$target_dir" "$source_dir"; then + skill_unchanged=$((skill_unchanged + 1)) + log_result "unchanged" "skill" "$name" "already linked: $target_dir" + continue + fi + + existing_name="" + if [[ -d "$target_dir" || -L "$target_dir" ]]; then + existing_name="$(extract_skill_name_from_dir "$target_dir" || true)" + fi + + if [[ "$MODE" == "copy" && -d "$target_dir" && ! -L "$target_dir" && "$existing_name" == "$name" ]]; then + skill_unchanged=$((skill_unchanged + 1)) + log_result "unchanged" "skill" "$name" "already installed: $target_dir" + continue + fi + + if [[ $FORCE -ne 1 ]]; then + skill_error=$((skill_error + 1)) + log_result "error" "skill" "$name" "target exists ($target_dir); use --force to replace" + continue + fi + + if [[ $DRY_RUN -eq 1 ]]; then + printf '[dry-run] rm -rf %s\n' "$target_dir" + else + safe_remove_path "$target_dir" + fi + fi + + if [[ "$MODE" == "copy" ]]; then + if [[ $DRY_RUN -eq 1 ]]; then + skill_installed=$((skill_installed + 1)) + log_result "installed" "skill" "$name" "[dry-run] cp -R $source_dir $target_dir" + else + cp -R "$source_dir" "$target_dir" + skill_installed=$((skill_installed + 1)) + log_result "installed" "skill" "$name" "copied: $target_dir" + fi + else + if [[ $DRY_RUN -eq 1 ]]; then + skill_installed=$((skill_installed + 1)) + log_result "installed" "skill" "$name" "[dry-run] ln -s $source_dir $target_dir" + else + ln -s "$source_dir" "$target_dir" + skill_installed=$((skill_installed + 1)) + log_result "installed" "skill" "$name" "linked: $target_dir -> $source_dir" + fi + fi + else + if [[ ! -e "$target_dir" && ! -L "$target_dir" ]]; then + skill_skipped=$((skill_skipped + 1)) + log_result "skipped" "skill" "$name" "not installed: $target_dir" + continue + fi + + removable=0 + if [[ $FORCE -eq 1 ]]; then + removable=1 + elif [[ -L "$target_dir" ]] && is_symlink_to "$target_dir" "$source_dir"; then + removable=1 + else + target_name="$(extract_skill_name_from_dir "$target_dir" || true)" + if [[ "$target_name" == "$name" ]]; then + removable=1 + fi + fi + + if [[ $removable -ne 1 ]]; then + skill_error=$((skill_error + 1)) + log_result "error" "skill" "$name" "refusing to remove unmanaged path ($target_dir); use --force" + continue + fi + + if [[ $DRY_RUN -eq 1 ]]; then + skill_removed=$((skill_removed + 1)) + log_result "removed" "skill" "$name" "[dry-run] rm -rf $target_dir" + else + safe_remove_path "$target_dir" + skill_removed=$((skill_removed + 1)) + log_result "removed" "skill" "$name" "removed: $target_dir" + fi + fi + done +fi + +if [[ $INSTALL_COMMANDS -eq 1 ]]; then + for idx in "${SELECTED_COMMAND_INDEXES[@]}"; do + name="${COMMAND_NAMES[$idx]}" + file_name="${COMMAND_FILE_NAMES[$idx]}" + source_file="${COMMAND_FILES[$idx]}" + target_file="$COMMANDS_TARGET/$file_name.md" + + if [[ "$ACTION" == "install" ]]; then + exists=0 + if [[ -e "$target_file" || -L "$target_file" ]]; then + exists=1 + fi + + if [[ $exists -eq 1 ]]; then + if [[ "$MODE" == "link" ]] && is_symlink_to "$target_file" "$source_file"; then + command_unchanged=$((command_unchanged + 1)) + log_result "unchanged" "command" "$name" "already linked: $target_file" + continue + fi + + if [[ "$MODE" == "copy" && -f "$target_file" && ! -L "$target_file" ]] && files_equal "$source_file" "$target_file"; then + command_unchanged=$((command_unchanged + 1)) + log_result "unchanged" "command" "$name" "already installed: $target_file" + continue + fi + + if [[ $FORCE -ne 1 ]]; then + command_error=$((command_error + 1)) + log_result "error" "command" "$name" "target exists ($target_file); use --force to replace" + continue + fi + + if [[ $DRY_RUN -eq 1 ]]; then + printf '[dry-run] rm -rf %s\n' "$target_file" + else + safe_remove_path "$target_file" + fi + fi + + if [[ "$MODE" == "copy" ]]; then + if [[ $DRY_RUN -eq 1 ]]; then + command_installed=$((command_installed + 1)) + log_result "installed" "command" "$name" "[dry-run] cp $source_file $target_file" + else + cp "$source_file" "$target_file" + command_installed=$((command_installed + 1)) + log_result "installed" "command" "$name" "copied: $target_file" + fi + else + if [[ $DRY_RUN -eq 1 ]]; then + command_installed=$((command_installed + 1)) + log_result "installed" "command" "$name" "[dry-run] ln -s $source_file $target_file" + else + ln -s "$source_file" "$target_file" + command_installed=$((command_installed + 1)) + log_result "installed" "command" "$name" "linked: $target_file -> $source_file" + fi + fi + else + if [[ ! -e "$target_file" && ! -L "$target_file" ]]; then + command_skipped=$((command_skipped + 1)) + log_result "skipped" "command" "$name" "not installed: $target_file" + continue + fi + + removable=0 + if [[ $FORCE -eq 1 ]]; then + removable=1 + elif [[ -L "$target_file" ]] && is_symlink_to "$target_file" "$source_file"; then + removable=1 + elif [[ -f "$target_file" && ! -L "$target_file" ]]; then + if files_equal "$source_file" "$target_file"; then + removable=1 + else + target_frontmatter_name="$(extract_command_frontmatter_name_from_file "$target_file" || true)" + if [[ "$target_frontmatter_name" == trailofbits:* ]]; then + removable=1 + fi + fi + fi + + if [[ $removable -ne 1 ]]; then + command_error=$((command_error + 1)) + log_result "error" "command" "$name" "refusing to remove unmanaged path ($target_file); use --force" + continue + fi + + if [[ $DRY_RUN -eq 1 ]]; then + command_removed=$((command_removed + 1)) + log_result "removed" "command" "$name" "[dry-run] rm -rf $target_file" + else + safe_remove_path "$target_file" + command_removed=$((command_removed + 1)) + log_result "removed" "command" "$name" "removed: $target_file" + fi + fi + done +fi + +echo +echo "Summary:" + +if [[ $INSTALL_SKILLS -eq 1 ]]; then + echo " skills:" + printf ' installed: %d\n' "$skill_installed" + printf ' removed: %d\n' "$skill_removed" + printf ' unchanged: %d\n' "$skill_unchanged" + printf ' skipped: %d\n' "$skill_skipped" + printf ' error: %d\n' "$skill_error" +fi + +if [[ $INSTALL_COMMANDS -eq 1 ]]; then + echo " commands:" + printf ' installed: %d\n' "$command_installed" + printf ' removed: %d\n' "$command_removed" + printf ' unchanged: %d\n' "$command_unchanged" + printf ' skipped: %d\n' "$command_skipped" + printf ' error: %d\n' "$command_error" +fi + +if [[ $INSTALL_COMMANDS -eq 1 && ${#SELECTED_COMMAND_INDEXES[@]} -eq 0 ]]; then + echo + echo "Note: no compatible commands matched the selected filters." +fi + +if [[ $skill_error -gt 0 || $command_error -gt 0 ]]; then + exit 1 +fi diff --git a/scripts/validate_opencode_compat.py b/scripts/validate_opencode_compat.py new file mode 100644 index 0000000..35b19d4 --- /dev/null +++ b/scripts/validate_opencode_compat.py @@ -0,0 +1,139 @@ +#!/usr/bin/env python3 +# /// script +# requires-python = ">=3.11" +# dependencies = [] +# /// +"""Validate OpenCode compatibility assumptions for repository skills.""" + +from __future__ import annotations + +import re +import sys +from dataclasses import dataclass +from pathlib import Path + + +FRONTMATTER_RE = re.compile(r"^---\r?\n(.*?)\r?\n---(?:\r?\n|$)", re.DOTALL) +NAME_RE = re.compile(r"^[a-z0-9]+(-[a-z0-9]+)*$") + + +@dataclass(frozen=True) +class SkillRecord: + """Parsed metadata for one SKILL.md file.""" + + name: str + description: str + path: Path + + +def unquote(value: str) -> str: + """Remove matching single or double quotes around a scalar.""" + if len(value) >= 2 and value[0] == value[-1] and value[0] in {'"', "'"}: + return value[1:-1] + return value + + +def parse_frontmatter(text: str, path: Path) -> dict[str, str]: + """Parse simple YAML frontmatter key/value pairs.""" + match = FRONTMATTER_RE.match(text) + if not match: + raise ValueError(f"{path}: missing or malformed YAML frontmatter") + + parsed: dict[str, str] = {} + for raw_line in match.group(1).splitlines(): + line = raw_line.strip() + if not line or line.startswith("#") or ":" not in raw_line: + continue + key, value = raw_line.split(":", 1) + parsed[key.strip()] = unquote(value.strip()) + + return parsed + + +def collect_skill_records(repo_root: Path) -> tuple[list[SkillRecord], list[str], list[str]]: + """Collect skill metadata and compatibility warnings/errors.""" + records: list[SkillRecord] = [] + errors: list[str] = [] + warnings: list[str] = [] + + for skill_path in sorted(repo_root.glob("plugins/**/SKILL.md")): + relative = skill_path.relative_to(repo_root) + parts = relative.parts + if len(parts) < 4 or parts[0] != "plugins" or parts[2] != "skills": + errors.append(f"{relative}: expected plugins//skills/.../SKILL.md path layout") + continue + + text = skill_path.read_text(encoding="utf-8") + if "{baseDir}" in text: + warnings.append(f"{relative}: contains '{{baseDir}}' (Claude-specific variable)") + + try: + metadata = parse_frontmatter(text, skill_path) + except ValueError as parse_error: + errors.append(str(parse_error)) + continue + + name = metadata.get("name", "").strip() + description = metadata.get("description", "").strip() + + if not name: + errors.append(f"{relative}: frontmatter missing required 'name'") + continue + if not description: + errors.append(f"{relative}: frontmatter missing required 'description'") + continue + + if len(name) > 64: + errors.append(f"{relative}: name '{name}' exceeds OpenCode max length (64)") + if not NAME_RE.fullmatch(name): + errors.append( + f"{relative}: name '{name}' is invalid for OpenCode " + "(must match ^[a-z0-9]+(-[a-z0-9]+)*$)" + ) + + records.append(SkillRecord(name=name, description=description, path=relative)) + + return records, errors, warnings + + +def check_duplicate_names(records: list[SkillRecord]) -> list[str]: + """Return duplicate skill name errors.""" + by_name: dict[str, list[Path]] = {} + for record in records: + by_name.setdefault(record.name, []).append(record.path) + + errors: list[str] = [] + for name, paths in sorted(by_name.items()): + if len(paths) <= 1: + continue + formatted_paths = ", ".join(str(path) for path in paths) + errors.append(f"duplicate skill name '{name}' found in: {formatted_paths}") + + return errors + + +def main() -> int: + """Run validation and print a concise report.""" + repo_root = Path(__file__).resolve().parents[1] + records, errors, warnings = collect_skill_records(repo_root) + errors.extend(check_duplicate_names(records)) + + print(f"Checked {len(records)} skills for OpenCode compatibility.") + + if warnings: + print(f"\nWarnings ({len(warnings)}):") + for warning in warnings: + print(f" - {warning}") + + if errors: + print(f"\nErrors ({len(errors)}):", file=sys.stderr) + for error in errors: + print(f" - {error}", file=sys.stderr) + return 1 + + print("\nOpenCode compatibility checks passed.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) From 65b294103a28ca2c70c5e8c8ec0b28a64fa373f5 Mon Sep 17 00:00:00 2001 From: Dan Guido Date: Tue, 3 Mar 2026 21:00:37 -0800 Subject: [PATCH 2/4] Fix OpenCode installer: security, correctness, and code quality Shell script (install_opencode_skills.sh): - Add safe_remove_path guards (non-empty, non-root, min depth) - Add --repo/--ref input validation against URL injection - Fix SIGPIPE from find|head pipeline (use mapfile) - Fix process substitution hiding find failures - Wrap cp/ln with error context instead of silent set -e exit - Consolidate duplicate extract_*_name functions into one - Decompose 255-line main body into functions - Fix skill "unchanged" check for remote source (replace stale) - Add skill name == directory name validation (OpenCode requires) - Add readability guard in is_command_compatible_with_opencode - Fix all shellcheck warnings Python validator (validate_opencode_compat.py): - Add try/except around read_text() for unreadable files - Add skill directory name vs frontmatter name check - Add command file validation (CLAUDE_PLUGIN_ROOT, Claude fields) - Add plugins dir existence check Documentation (docs/opencode.md, README.md): - Fix {baseDir} docs: OpenCode does not substitute this variable - Document skill auto-registration as slash commands - Expand "What Does Not Translate 1:1" with frontmatter details - Add caveat to OpenPackage section (no openpackage.yml manifest) - Lead with two-step install (download-then-inspect) over curl|bash CI (.github/workflows/validate.yml): - Add shellcheck and shfmt linting steps Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/validate.yml | 11 + README.md | 8 +- docs/opencode.md | 41 +- scripts/install_opencode_skills.sh | 622 +++++++++++++++------------- scripts/validate_opencode_compat.py | 83 +++- 5 files changed, 465 insertions(+), 300 deletions(-) diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 12fd1f6..7548dd8 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -90,6 +90,17 @@ jobs: - name: Validate plugin metadata run: python3 .github/scripts/validate_plugin_metadata.py + - name: Lint installer script + run: shellcheck scripts/install_opencode_skills.sh + + - name: Check shell script formatting + run: | + shfmt_version="v3.10.0" + shfmt_url="https://github.com/mvdan/sh/releases/download/${shfmt_version}/shfmt_${shfmt_version}_linux_amd64" + curl -fsSL "$shfmt_url" -o /tmp/shfmt + chmod +x /tmp/shfmt + /tmp/shfmt -d -i 2 scripts/install_opencode_skills.sh + - name: Validate OpenCode skill compatibility run: python3 scripts/validate_opencode_compat.py diff --git a/README.md b/README.md index cceebdd..0079620 100644 --- a/README.md +++ b/README.md @@ -32,13 +32,17 @@ cd /path/to/parent # e.g., if repo is at ~/projects/skills, be in ~/projects Plugins in this repository can be installed for OpenCode without cloning this repository by using the shell installer. It installs both skills and plugin commands so the OpenCode UX remains command-first: ```bash -curl -fsSL https://raw.githubusercontent.com/trailofbits/skills/main/scripts/install_opencode_skills.sh | bash +# Download and inspect the installer first +curl -fsSL -o /tmp/install_opencode_skills.sh \ + https://raw.githubusercontent.com/trailofbits/skills/main/scripts/install_opencode_skills.sh +less /tmp/install_opencode_skills.sh # review the script +bash /tmp/install_opencode_skills.sh ``` Install only smart contract auditing plugins: ```bash -curl -fsSL https://raw.githubusercontent.com/trailofbits/skills/main/scripts/install_opencode_skills.sh | bash -s -- --bundle smart-contracts +bash /tmp/install_opencode_skills.sh --bundle smart-contracts ``` Then run commands directly in OpenCode, for example: diff --git a/docs/opencode.md b/docs/opencode.md index 94d83ad..b553f41 100644 --- a/docs/opencode.md +++ b/docs/opencode.md @@ -25,7 +25,8 @@ This keeps the same flow users expect from Claude plugins: - Claude plugin wrappers (`.claude-plugin/plugin.json`, `.claude-plugin/marketplace.json`) - Claude hooks/commands runtime behavior -- `allowed-tools` frontmatter enforcement (OpenCode ignores unknown frontmatter fields) +- **Command frontmatter differences:** Claude commands use `allowed-tools` (restricts tool access) and `argument-hint` (placeholder text in the UI). OpenCode silently ignores these fields — commands still work but without tool restrictions or argument hints. +- **`{baseDir}` variable:** Claude Code substitutes `{baseDir}` with the skill directory path at runtime. OpenCode does not perform this substitution (see [Portability Notes](#portability-notes)). ## Install For OpenCode (No Clone Required) @@ -36,16 +37,19 @@ By default, the installer: - downloads this repository archive from GitHub - copies both skills and commands into OpenCode config directories -### Install All Plugins +### Recommended: Download and Inspect ```bash -curl -fsSL https://raw.githubusercontent.com/trailofbits/skills/main/scripts/install_opencode_skills.sh | bash +curl -fsSL -o /tmp/install_opencode_skills.sh \ + https://raw.githubusercontent.com/trailofbits/skills/main/scripts/install_opencode_skills.sh +less /tmp/install_opencode_skills.sh # review the script +bash /tmp/install_opencode_skills.sh ``` ### Install Smart Contract Bundle ```bash -curl -fsSL https://raw.githubusercontent.com/trailofbits/skills/main/scripts/install_opencode_skills.sh | bash -s -- --bundle smart-contracts +bash /tmp/install_opencode_skills.sh --bundle smart-contracts ``` The smart contract bundle includes these plugins: @@ -55,11 +59,12 @@ The smart contract bundle includes these plugins: - `spec-to-code-compliance` - `property-based-testing` -### Inspect Before Running (Safer Two-Step) +### Quick Install (Piped) + +If you trust the source, you can pipe directly: ```bash -curl -fsSL -o /tmp/install_opencode_skills.sh https://raw.githubusercontent.com/trailofbits/skills/main/scripts/install_opencode_skills.sh -bash /tmp/install_opencode_skills.sh --bundle smart-contracts +curl -fsSL https://raw.githubusercontent.com/trailofbits/skills/main/scripts/install_opencode_skills.sh | bash ``` ## Use Installed Plugin Commands @@ -74,6 +79,18 @@ After installation, run plugin commands directly in OpenCode (same command-first These commands invoke the associated skills automatically. +### How Skills and Commands Interact in OpenCode + +OpenCode automatically registers each installed skill as a slash command using the skill's frontmatter `name`. For example, installing the `entry-point-analyzer` skill automatically creates `/entry-point-analyzer`. + +The installer also copies explicit command files (like `entry-points.md` with name `trailofbits:entry-points`), which register as `/trailofbits:entry-points`. These command files add value beyond auto-registration: + +- They provide a **different invocation name** (namespaced with `trailofbits:`) +- They include a **specific prompt template** (e.g., argument parsing, workflow instructions) +- They reference the associated skill by name, keeping the command-first workflow + +Both the auto-registered `/entry-point-analyzer` and the explicit `/trailofbits:entry-points` ultimately use the same skill content. + ## List, Filter, and Scope Installation ### Preview Selected Items @@ -141,9 +158,13 @@ bash scripts/install_opencode_skills.sh --source local --bundle smart-contracts ## Portability Notes -Some skills use `{baseDir}` in instructions. Treat `{baseDir}` as the skill directory root when running outside Claude Code. +### `{baseDir}` references + +Some skills use `{baseDir}` in their instructions to reference files relative to the skill directory. Claude Code replaces this variable at runtime. **OpenCode does not perform this substitution** — `{baseDir}` will appear as a literal string. + +In practice, references like `{baseDir}/references/guide.md` will not resolve automatically. When an OpenCode agent encounters these, it should navigate to the corresponding file within the skill's directory (e.g., `~/.config/opencode/skills//references/guide.md`). -The repository includes a compatibility validator: +The repository includes a compatibility validator that flags skills using `{baseDir}`: ```bash python3 scripts/validate_opencode_compat.py @@ -151,7 +172,7 @@ python3 scripts/validate_opencode_compat.py ## Optional: Install With OpenPackage -OpenPackage can also install this repository for OpenCode. This is optional and not required for the first-party installer flow above. +[OpenPackage](https://github.com/enulus/openpackage) (`opkg`) can also install from this repository. This is an alternative to the first-party installer above. Note: this repo does not include an `openpackage.yml` manifest, so OpenPackage auto-detects the structure. ```bash npm install -g opkg diff --git a/scripts/install_opencode_skills.sh b/scripts/install_opencode_skills.sh index 0698f92..5335f9b 100755 --- a/scripts/install_opencode_skills.sh +++ b/scripts/install_opencode_skills.sh @@ -79,12 +79,13 @@ trim() { printf '%s' "$value" } +# shellcheck disable=SC2088 expand_path() { local path="$1" if [[ "$path" == "~" ]]; then path="$HOME" - elif [[ "$path" == "~/"* ]]; then + elif [[ "$path" == '~/'* ]]; then path="$HOME/${path#\~/}" fi @@ -95,7 +96,7 @@ expand_path() { printf '%s' "$path" } -extract_skill_name_from_file() { +extract_frontmatter_name() { local file="$1" local line local in_frontmatter=0 @@ -133,50 +134,7 @@ extract_skill_name_from_file() { printf '%s' "$value" return 0 fi - done < "$file" - - return 1 -} - -extract_command_frontmatter_name_from_file() { - local file="$1" - local line - local in_frontmatter=0 - - while IFS= read -r line || [[ -n "$line" ]]; do - line="${line%$'\r'}" - - if [[ $in_frontmatter -eq 0 ]]; then - if [[ "$line" == "---" ]]; then - in_frontmatter=1 - continue - fi - return 1 - fi - - if [[ "$line" == "---" ]]; then - return 1 - fi - - if [[ "$line" =~ ^[[:space:]]*name:[[:space:]]*(.*)$ ]]; then - local value - value="$(trim "${BASH_REMATCH[1]}")" - if [[ -z "$value" ]]; then - return 1 - fi - - if [[ "$value" == "\""*"\"" ]]; then - value="${value#\"}" - value="${value%\"}" - elif [[ "$value" == "'"*"'" ]]; then - value="${value#\'}" - value="${value%\'}" - fi - - printf '%s' "$value" - return 0 - fi - done < "$file" + done <"$file" return 1 } @@ -189,7 +147,7 @@ extract_skill_name_from_dir() { return 1 fi - extract_skill_name_from_file "$skill_file" + extract_frontmatter_name "$skill_file" } extract_command_referenced_skills() { @@ -219,7 +177,7 @@ extract_command_referenced_skills() { fi fi fi - done < "$file" + done <"$file" printf '%s' "$refs_csv" } @@ -230,7 +188,7 @@ csv_contains() { local item declare -a items=() if [[ -n "$csv" ]]; then - IFS=',' read -r -a items <<< "$csv" + IFS=',' read -r -a items <<<"$csv" fi for item in "${items[@]-}"; do if [[ "$item" == "$needle" ]]; then @@ -259,7 +217,7 @@ matches_selected_skill_names() { is_smart_contract_plugin() { case "$1" in - building-secure-contracts|entry-point-analyzer|spec-to-code-compliance|property-based-testing) + building-secure-contracts | entry-point-analyzer | spec-to-code-compliance | property-based-testing) return 0 ;; *) @@ -295,6 +253,7 @@ is_symlink_to() { return 1 fi + # realpath may fail for dangling symlinks; fall through to raw readlink if command -v realpath >/dev/null 2>&1; then target_real="$(realpath "$target" 2>/dev/null || true)" expected_real="$(realpath "$expected" 2>/dev/null || true)" @@ -316,6 +275,11 @@ files_equal() { is_command_compatible_with_opencode() { local file="$1" + if [[ ! -r "$file" ]]; then + return 1 + fi + # Literal match for ${CLAUDE_PLUGIN_ROOT}, not shell expansion + # shellcheck disable=SC2016 if grep -q '\${CLAUDE_PLUGIN_ROOT}' "$file"; then return 1 fi @@ -324,13 +288,273 @@ is_command_compatible_with_opencode() { safe_remove_path() { local path="$1" + if [[ -z "$path" ]]; then + fail "safe_remove_path called with empty path" + fi + case "$path" in + / | /bin | /boot | /dev | /etc | /home | /lib* | /opt | /proc | /root | /run | /sbin | /srv | /sys | /tmp | /usr | /var) + fail "safe_remove_path refusing dangerous path: $path" + ;; + esac + local depth + depth="$(printf '%s' "$path" | tr -cd '/' | wc -c)" + if [[ "$depth" -lt 3 ]]; then + fail "safe_remove_path refusing shallow path: $path" + fi + # rm -rf is used instead of trash(1) because this installer runs + # on CI and remote machines where trash may not be available. rm -rf "$path" } +install_or_uninstall_skill() { + local idx="$1" + local name="${SKILL_NAMES[$idx]}" + local source_dir="${SKILL_DIRS[$idx]}" + local target_dir="$SKILLS_TARGET/$name" + + if [[ "$ACTION" == "install" ]]; then + local exists=0 + if [[ -e "$target_dir" || -L "$target_dir" ]]; then + exists=1 + fi + + if [[ $exists -eq 1 ]]; then + if [[ "$MODE" == "link" ]] && is_symlink_to "$target_dir" "$source_dir"; then + skill_unchanged=$((skill_unchanged + 1)) + log_result "unchanged" "skill" "$name" "already linked: $target_dir" + return + fi + + local existing_name="" + if [[ -d "$target_dir" || -L "$target_dir" ]]; then + existing_name="$(extract_skill_name_from_dir "$target_dir" || true)" + fi + + if [[ "$MODE" == "copy" && -d "$target_dir" && ! -L "$target_dir" && "$existing_name" == "$name" ]]; then + if [[ "$SOURCE" == "local" ]]; then + skill_unchanged=$((skill_unchanged + 1)) + log_result "unchanged" "skill" "$name" "already installed: $target_dir" + return + fi + # Remote source: replace with fresh content + if [[ $DRY_RUN -eq 1 ]]; then + printf '[dry-run] replacing %s with fresh download\n' "$target_dir" + else + safe_remove_path "$target_dir" + fi + elif [[ $FORCE -ne 1 ]]; then + skill_error=$((skill_error + 1)) + log_result "error" "skill" "$name" "target exists ($target_dir); use --force to replace" + return + else + if [[ $DRY_RUN -eq 1 ]]; then + printf '[dry-run] rm -rf %s\n' "$target_dir" + else + safe_remove_path "$target_dir" + fi + fi + fi + + if [[ "$MODE" == "copy" ]]; then + if [[ $DRY_RUN -eq 1 ]]; then + skill_installed=$((skill_installed + 1)) + log_result "installed" "skill" "$name" "[dry-run] cp -R $source_dir $target_dir" + elif cp -R "$source_dir" "$target_dir"; then + skill_installed=$((skill_installed + 1)) + log_result "installed" "skill" "$name" "copied: $target_dir" + else + skill_error=$((skill_error + 1)) + log_result "error" "skill" "$name" "failed to copy to $target_dir" + fi + else + if [[ $DRY_RUN -eq 1 ]]; then + skill_installed=$((skill_installed + 1)) + log_result "installed" "skill" "$name" "[dry-run] ln -s $source_dir $target_dir" + elif ln -s "$source_dir" "$target_dir"; then + skill_installed=$((skill_installed + 1)) + log_result "installed" "skill" "$name" "linked: $target_dir -> $source_dir" + else + skill_error=$((skill_error + 1)) + log_result "error" "skill" "$name" "failed to link $source_dir to $target_dir" + fi + fi + else + if [[ ! -e "$target_dir" && ! -L "$target_dir" ]]; then + skill_skipped=$((skill_skipped + 1)) + log_result "skipped" "skill" "$name" "not installed: $target_dir" + return + fi + + local removable=0 + if [[ $FORCE -eq 1 ]]; then + removable=1 + elif [[ -L "$target_dir" ]] && is_symlink_to "$target_dir" "$source_dir"; then + removable=1 + else + local target_name + target_name="$(extract_skill_name_from_dir "$target_dir" || true)" + if [[ "$target_name" == "$name" ]]; then + removable=1 + fi + fi + + if [[ $removable -ne 1 ]]; then + skill_error=$((skill_error + 1)) + log_result "error" "skill" "$name" "refusing to remove unmanaged path ($target_dir); use --force" + return + fi + + if [[ $DRY_RUN -eq 1 ]]; then + skill_removed=$((skill_removed + 1)) + log_result "removed" "skill" "$name" "[dry-run] rm -rf $target_dir" + else + safe_remove_path "$target_dir" + skill_removed=$((skill_removed + 1)) + log_result "removed" "skill" "$name" "removed: $target_dir" + fi + fi +} + +install_or_uninstall_command() { + local idx="$1" + local name="${COMMAND_NAMES[$idx]}" + local file_name="${COMMAND_FILE_NAMES[$idx]}" + local source_file="${COMMAND_FILES[$idx]}" + local target_file="$COMMANDS_TARGET/$file_name.md" + + if [[ "$ACTION" == "install" ]]; then + local exists=0 + if [[ -e "$target_file" || -L "$target_file" ]]; then + exists=1 + fi + + if [[ $exists -eq 1 ]]; then + if [[ "$MODE" == "link" ]] && is_symlink_to "$target_file" "$source_file"; then + command_unchanged=$((command_unchanged + 1)) + log_result "unchanged" "command" "$name" "already linked: $target_file" + return + fi + + if [[ "$MODE" == "copy" && -f "$target_file" && ! -L "$target_file" ]] && files_equal "$source_file" "$target_file"; then + command_unchanged=$((command_unchanged + 1)) + log_result "unchanged" "command" "$name" "already installed: $target_file" + return + fi + + if [[ $FORCE -ne 1 ]]; then + command_error=$((command_error + 1)) + log_result "error" "command" "$name" "target exists ($target_file); use --force to replace" + return + fi + + if [[ $DRY_RUN -eq 1 ]]; then + printf '[dry-run] rm -rf %s\n' "$target_file" + else + safe_remove_path "$target_file" + fi + fi + + if [[ "$MODE" == "copy" ]]; then + if [[ $DRY_RUN -eq 1 ]]; then + command_installed=$((command_installed + 1)) + log_result "installed" "command" "$name" "[dry-run] cp $source_file $target_file" + elif cp "$source_file" "$target_file"; then + command_installed=$((command_installed + 1)) + log_result "installed" "command" "$name" "copied: $target_file" + else + command_error=$((command_error + 1)) + log_result "error" "command" "$name" "failed to copy to $target_file" + fi + else + if [[ $DRY_RUN -eq 1 ]]; then + command_installed=$((command_installed + 1)) + log_result "installed" "command" "$name" "[dry-run] ln -s $source_file $target_file" + elif ln -s "$source_file" "$target_file"; then + command_installed=$((command_installed + 1)) + log_result "installed" "command" "$name" "linked: $target_file -> $source_file" + else + command_error=$((command_error + 1)) + log_result "error" "command" "$name" "failed to link $source_file to $target_file" + fi + fi + else + if [[ ! -e "$target_file" && ! -L "$target_file" ]]; then + command_skipped=$((command_skipped + 1)) + log_result "skipped" "command" "$name" "not installed: $target_file" + return + fi + + local removable=0 + if [[ $FORCE -eq 1 ]]; then + removable=1 + elif [[ -L "$target_file" ]] && is_symlink_to "$target_file" "$source_file"; then + removable=1 + elif [[ -f "$target_file" && ! -L "$target_file" ]]; then + if files_equal "$source_file" "$target_file"; then + removable=1 + else + local target_frontmatter_name + target_frontmatter_name="$(extract_frontmatter_name "$target_file" || true)" + if [[ "$target_frontmatter_name" == trailofbits:* ]]; then + removable=1 + fi + fi + fi + + if [[ $removable -ne 1 ]]; then + command_error=$((command_error + 1)) + log_result "error" "command" "$name" "refusing to remove unmanaged path ($target_file); use --force" + return + fi + + if [[ $DRY_RUN -eq 1 ]]; then + command_removed=$((command_removed + 1)) + log_result "removed" "command" "$name" "[dry-run] rm -rf $target_file" + else + safe_remove_path "$target_file" + command_removed=$((command_removed + 1)) + log_result "removed" "command" "$name" "removed: $target_file" + fi + fi +} + +print_summary() { + echo + echo "Summary:" + + if [[ $INSTALL_SKILLS -eq 1 ]]; then + echo " skills:" + printf ' installed: %d\n' "$skill_installed" + printf ' removed: %d\n' "$skill_removed" + printf ' unchanged: %d\n' "$skill_unchanged" + printf ' skipped: %d\n' "$skill_skipped" + printf ' error: %d\n' "$skill_error" + fi + + if [[ $INSTALL_COMMANDS -eq 1 ]]; then + echo " commands:" + printf ' installed: %d\n' "$command_installed" + printf ' removed: %d\n' "$command_removed" + printf ' unchanged: %d\n' "$command_unchanged" + printf ' skipped: %d\n' "$command_skipped" + printf ' error: %d\n' "$command_error" + fi + + if [[ $INSTALL_COMMANDS -eq 1 && ${#SELECTED_COMMAND_INDEXES[@]} -eq 0 ]]; then + echo + echo "Note: no compatible commands matched the selected filters." + fi +} + +# --- Argument parsing --- + SOURCE="remote" REPO="trailofbits/skills" REF="main" +# Tilde is expanded later by expand_path() +# shellcheck disable=SC2088 SKILLS_TARGET="~/.config/opencode/skills" +# shellcheck disable=SC2088 COMMANDS_TARGET="~/.config/opencode/commands" ACTION="install" MODE="copy" @@ -377,7 +601,7 @@ while [[ $# -gt 0 ]]; do ALL=1 shift ;; - --target|--skills-target) + --target | --skills-target) [[ $# -ge 2 ]] || fail "$1 requires a value" SKILLS_TARGET="$2" shift 2 @@ -420,7 +644,7 @@ while [[ $# -gt 0 ]]; do MODE="copy" shift ;; - --link|--symlink) + --link | --symlink) MODE="link" shift ;; @@ -436,7 +660,7 @@ while [[ $# -gt 0 ]]; do DRY_RUN=1 shift ;; - -h|--help) + -h | --help) usage exit 0 ;; @@ -446,6 +670,8 @@ while [[ $# -gt 0 ]]; do esac done +# --- Validate arguments --- + if [[ "$SOURCE" != "remote" && "$SOURCE" != "local" ]]; then fail "--source must be 'remote' or 'local'" fi @@ -454,7 +680,7 @@ if [[ "$BUNDLE" != "" && "$BUNDLE" != "smart-contracts" ]]; then fail "Unsupported bundle '$BUNDLE' (supported: smart-contracts)" fi -if [[ $ALL -eq 1 && ( "$BUNDLE" != "" || ${#PLUGIN_FILTERS[@]} -gt 0 || ${#SKILL_FILTERS[@]} -gt 0 || ${#COMMAND_FILTERS[@]} -gt 0 ) ]]; then +if [[ $ALL -eq 1 && ("$BUNDLE" != "" || ${#PLUGIN_FILTERS[@]} -gt 0 || ${#SKILL_FILTERS[@]} -gt 0 || ${#COMMAND_FILTERS[@]} -gt 0) ]]; then fail "--all cannot be combined with --bundle, --plugin, --skill, or --command" fi @@ -466,9 +692,20 @@ if [[ $INSTALL_SKILLS -eq 0 && $INSTALL_COMMANDS -eq 0 ]]; then fail "Nothing to do: choose one of --skills-only or --commands-only" fi +if [[ "$SOURCE" == "remote" ]]; then + if [[ ! "$REPO" =~ ^[a-zA-Z0-9._-]+/[a-zA-Z0-9._-]+$ ]]; then + fail "--repo must match owner/repo format (got '$REPO')" + fi + if [[ ! "$REF" =~ ^[a-zA-Z0-9._/-]+$ ]]; then + fail "--ref contains invalid characters (got '$REF')" + fi +fi + SKILLS_TARGET="$(expand_path "$SKILLS_TARGET")" COMMANDS_TARGET="$(expand_path "$COMMANDS_TARGET")" +# --- Fetch source --- + TMP_DIR="" cleanup() { if [[ -n "$TMP_DIR" && -d "$TMP_DIR" ]]; then @@ -498,7 +735,8 @@ if [[ "$SOURCE" == "remote" ]]; then fail "Failed to extract downloaded archive" fi - SOURCE_ROOT="$(find "$TMP_DIR" -mindepth 1 -maxdepth 1 -type d | head -n 1)" + mapfile -t _extracted_dirs < <(find "$TMP_DIR" -mindepth 1 -maxdepth 1 -type d) + SOURCE_ROOT="${_extracted_dirs[0]:-}" [[ -n "$SOURCE_ROOT" ]] || fail "Could not locate extracted repository root" else command -v find >/dev/null 2>&1 || fail "find is required" @@ -516,17 +754,24 @@ if [[ ! -d "$SOURCE_ROOT/plugins" ]]; then fail "No plugins directory found at $SOURCE_ROOT/plugins" fi +# --- Discover skills --- + declare -a SKILL_NAMES=() declare -a SKILL_PLUGINS=() declare -a SKILL_DIRS=() declare -a SKILL_FILES=() -while IFS= read -r skill_file; do +mapfile -t _skill_files < <(find "$SOURCE_ROOT/plugins" -type f -name 'SKILL.md' | sort) +if [[ ${#_skill_files[@]} -eq 0 ]]; then + fail "No SKILL.md files found under $SOURCE_ROOT/plugins" +fi + +for skill_file in "${_skill_files[@]}"; do rel_path="${skill_file#"$SOURCE_ROOT/plugins/"}" plugin_name="${rel_path%%/*}" skill_dir="$(cd "$(dirname "$skill_file")" && pwd -P)" - skill_name="$(extract_skill_name_from_file "$skill_file" || true)" + skill_name="$(extract_frontmatter_name "$skill_file" || true)" if [[ -z "$skill_name" ]]; then fail "Could not read frontmatter name from $skill_file" fi @@ -535,10 +780,17 @@ while IFS= read -r skill_file; do fail "Invalid skill name '$skill_name' in $skill_file" fi - if (( ${#skill_name} > 64 )); then + if ((${#skill_name} > 64)); then fail "Skill name '$skill_name' exceeds 64 characters ($skill_file)" fi + # OpenCode requires skill directory name to match frontmatter name + source_dir_name="$(basename "$skill_dir")" + if [[ "$source_dir_name" != "$skill_name" ]]; then + printf 'Warning: skill dir "%s" != frontmatter name "%s" (%s)\n' \ + "$source_dir_name" "$skill_name" "$skill_file" >&2 + fi + if [[ ${#SKILL_NAMES[@]} -gt 0 ]] && contains_exact "$skill_name" "${SKILL_NAMES[@]}"; then fail "Duplicate skill name detected: $skill_name" fi @@ -547,11 +799,9 @@ while IFS= read -r skill_file; do SKILL_PLUGINS+=("$plugin_name") SKILL_DIRS+=("$skill_dir") SKILL_FILES+=("$skill_file") -done < <(find "$SOURCE_ROOT/plugins" -type f -name 'SKILL.md' | sort) +done -if [[ ${#SKILL_NAMES[@]} -eq 0 ]]; then - fail "No SKILL.md files found under $SOURCE_ROOT/plugins" -fi +# --- Discover commands --- declare -a COMMAND_NAMES=() declare -a COMMAND_FILE_NAMES=() @@ -560,12 +810,14 @@ declare -a COMMAND_FILES=() declare -a COMMAND_REFERENCED_SKILLS=() declare -a COMMAND_COMPATIBLE=() -while IFS= read -r command_file; do +mapfile -t _command_files < <(find "$SOURCE_ROOT/plugins" -type f -path '*/commands/*.md' | sort) + +for command_file in "${_command_files[@]}"; do rel_path="${command_file#"$SOURCE_ROOT/plugins/"}" plugin_name="${rel_path%%/*}" command_file_name="$(basename "$command_file" .md)" - command_frontmatter_name="$(extract_command_frontmatter_name_from_file "$command_file" || true)" + command_frontmatter_name="$(extract_frontmatter_name "$command_file" || true)" if [[ -n "$command_frontmatter_name" ]]; then command_name="$command_frontmatter_name" else @@ -586,7 +838,9 @@ while IFS= read -r command_file; do COMMAND_FILES+=("$command_file") COMMAND_REFERENCED_SKILLS+=("$command_refs") COMMAND_COMPATIBLE+=("$command_compatible") -done < <(find "$SOURCE_ROOT/plugins" -type f -path '*/commands/*.md' | sort) +done + +# --- Validate cross-references --- for idx in "${!COMMAND_NAMES[@]}"; do command_name="${COMMAND_NAMES[$idx]}" @@ -597,7 +851,7 @@ for idx in "${!COMMAND_NAMES[@]}"; do continue fi - IFS=',' read -r -a refs <<< "$refs_csv" + IFS=',' read -r -a refs <<<"$refs_csv" for ref in "${refs[@]-}"; do if [[ -z "$ref" ]]; then continue @@ -622,6 +876,8 @@ for idx in "${!COMMAND_NAMES[@]}"; do done done +# --- Build known plugins and validate filters --- + declare -a KNOWN_PLUGINS=() for plugin_name in "${SKILL_PLUGINS[@]}"; do if [[ ${#KNOWN_PLUGINS[@]} -eq 0 ]] || ! contains_exact "$plugin_name" "${KNOWN_PLUGINS[@]}"; then @@ -665,6 +921,8 @@ if [[ ${#COMMAND_FILTERS[@]} -gt 0 ]]; then done fi +# --- Select items --- + declare -a SELECTED_SKILL_INDEXES=() for idx in "${!SKILL_NAMES[@]}"; do plugin_name="${SKILL_PLUGINS[$idx]}" @@ -752,6 +1010,8 @@ if [[ $INSTALL_COMMANDS -eq 1 && ${#SELECTED_COMMAND_INDEXES[@]} -gt 0 ]]; then done fi +# --- Print status --- + source_info="$SOURCE" if [[ "$SOURCE" == "remote" ]]; then source_info+=" (${REPO}@${REF})" @@ -781,6 +1041,8 @@ if [[ ${#SKIPPED_INCOMPATIBLE_COMMANDS[@]} -gt 0 ]]; then done fi +# --- List mode --- + if [[ $LIST_ONLY -eq 1 ]]; then if [[ $INSTALL_SKILLS -eq 1 ]]; then echo @@ -798,16 +1060,18 @@ if [[ $LIST_ONLY -eq 1 ]]; then printf '%-28s %-28s %s\n' "COMMAND" "PLUGIN" "SKILL REFERENCES" printf '%-28s %-28s %s\n' "-------" "------" "----------------" for idx in "${SELECTED_COMMAND_INDEXES[@]}"; do - refs="${COMMAND_REFERENCED_SKILLS[$idx]}" - if [[ -z "$refs" ]]; then - refs="(none detected)" + cmd_refs="${COMMAND_REFERENCED_SKILLS[$idx]}" + if [[ -z "$cmd_refs" ]]; then + cmd_refs="(none detected)" fi - printf '%-28s %-28s %s\n' "/${COMMAND_NAMES[$idx]}" "${COMMAND_PLUGINS[$idx]}" "$refs" + printf '%-28s %-28s %s\n' "/${COMMAND_NAMES[$idx]}" "${COMMAND_PLUGINS[$idx]}" "$cmd_refs" done fi exit 0 fi +# --- Create target directories --- + if [[ "$ACTION" == "install" ]]; then if [[ $INSTALL_SKILLS -eq 1 && ! -d "$SKILLS_TARGET" ]]; then if [[ $DRY_RUN -eq 1 ]]; then @@ -826,6 +1090,8 @@ if [[ "$ACTION" == "install" ]]; then fi fi +# --- Install/uninstall --- + skill_installed=0 skill_removed=0 skill_unchanged=0 @@ -848,227 +1114,17 @@ log_result() { if [[ $INSTALL_SKILLS -eq 1 ]]; then for idx in "${SELECTED_SKILL_INDEXES[@]}"; do - name="${SKILL_NAMES[$idx]}" - source_dir="${SKILL_DIRS[$idx]}" - target_dir="$SKILLS_TARGET/$name" - - if [[ "$ACTION" == "install" ]]; then - exists=0 - if [[ -e "$target_dir" || -L "$target_dir" ]]; then - exists=1 - fi - - if [[ $exists -eq 1 ]]; then - if [[ "$MODE" == "link" ]] && is_symlink_to "$target_dir" "$source_dir"; then - skill_unchanged=$((skill_unchanged + 1)) - log_result "unchanged" "skill" "$name" "already linked: $target_dir" - continue - fi - - existing_name="" - if [[ -d "$target_dir" || -L "$target_dir" ]]; then - existing_name="$(extract_skill_name_from_dir "$target_dir" || true)" - fi - - if [[ "$MODE" == "copy" && -d "$target_dir" && ! -L "$target_dir" && "$existing_name" == "$name" ]]; then - skill_unchanged=$((skill_unchanged + 1)) - log_result "unchanged" "skill" "$name" "already installed: $target_dir" - continue - fi - - if [[ $FORCE -ne 1 ]]; then - skill_error=$((skill_error + 1)) - log_result "error" "skill" "$name" "target exists ($target_dir); use --force to replace" - continue - fi - - if [[ $DRY_RUN -eq 1 ]]; then - printf '[dry-run] rm -rf %s\n' "$target_dir" - else - safe_remove_path "$target_dir" - fi - fi - - if [[ "$MODE" == "copy" ]]; then - if [[ $DRY_RUN -eq 1 ]]; then - skill_installed=$((skill_installed + 1)) - log_result "installed" "skill" "$name" "[dry-run] cp -R $source_dir $target_dir" - else - cp -R "$source_dir" "$target_dir" - skill_installed=$((skill_installed + 1)) - log_result "installed" "skill" "$name" "copied: $target_dir" - fi - else - if [[ $DRY_RUN -eq 1 ]]; then - skill_installed=$((skill_installed + 1)) - log_result "installed" "skill" "$name" "[dry-run] ln -s $source_dir $target_dir" - else - ln -s "$source_dir" "$target_dir" - skill_installed=$((skill_installed + 1)) - log_result "installed" "skill" "$name" "linked: $target_dir -> $source_dir" - fi - fi - else - if [[ ! -e "$target_dir" && ! -L "$target_dir" ]]; then - skill_skipped=$((skill_skipped + 1)) - log_result "skipped" "skill" "$name" "not installed: $target_dir" - continue - fi - - removable=0 - if [[ $FORCE -eq 1 ]]; then - removable=1 - elif [[ -L "$target_dir" ]] && is_symlink_to "$target_dir" "$source_dir"; then - removable=1 - else - target_name="$(extract_skill_name_from_dir "$target_dir" || true)" - if [[ "$target_name" == "$name" ]]; then - removable=1 - fi - fi - - if [[ $removable -ne 1 ]]; then - skill_error=$((skill_error + 1)) - log_result "error" "skill" "$name" "refusing to remove unmanaged path ($target_dir); use --force" - continue - fi - - if [[ $DRY_RUN -eq 1 ]]; then - skill_removed=$((skill_removed + 1)) - log_result "removed" "skill" "$name" "[dry-run] rm -rf $target_dir" - else - safe_remove_path "$target_dir" - skill_removed=$((skill_removed + 1)) - log_result "removed" "skill" "$name" "removed: $target_dir" - fi - fi + install_or_uninstall_skill "$idx" done fi if [[ $INSTALL_COMMANDS -eq 1 ]]; then for idx in "${SELECTED_COMMAND_INDEXES[@]}"; do - name="${COMMAND_NAMES[$idx]}" - file_name="${COMMAND_FILE_NAMES[$idx]}" - source_file="${COMMAND_FILES[$idx]}" - target_file="$COMMANDS_TARGET/$file_name.md" - - if [[ "$ACTION" == "install" ]]; then - exists=0 - if [[ -e "$target_file" || -L "$target_file" ]]; then - exists=1 - fi - - if [[ $exists -eq 1 ]]; then - if [[ "$MODE" == "link" ]] && is_symlink_to "$target_file" "$source_file"; then - command_unchanged=$((command_unchanged + 1)) - log_result "unchanged" "command" "$name" "already linked: $target_file" - continue - fi - - if [[ "$MODE" == "copy" && -f "$target_file" && ! -L "$target_file" ]] && files_equal "$source_file" "$target_file"; then - command_unchanged=$((command_unchanged + 1)) - log_result "unchanged" "command" "$name" "already installed: $target_file" - continue - fi - - if [[ $FORCE -ne 1 ]]; then - command_error=$((command_error + 1)) - log_result "error" "command" "$name" "target exists ($target_file); use --force to replace" - continue - fi - - if [[ $DRY_RUN -eq 1 ]]; then - printf '[dry-run] rm -rf %s\n' "$target_file" - else - safe_remove_path "$target_file" - fi - fi - - if [[ "$MODE" == "copy" ]]; then - if [[ $DRY_RUN -eq 1 ]]; then - command_installed=$((command_installed + 1)) - log_result "installed" "command" "$name" "[dry-run] cp $source_file $target_file" - else - cp "$source_file" "$target_file" - command_installed=$((command_installed + 1)) - log_result "installed" "command" "$name" "copied: $target_file" - fi - else - if [[ $DRY_RUN -eq 1 ]]; then - command_installed=$((command_installed + 1)) - log_result "installed" "command" "$name" "[dry-run] ln -s $source_file $target_file" - else - ln -s "$source_file" "$target_file" - command_installed=$((command_installed + 1)) - log_result "installed" "command" "$name" "linked: $target_file -> $source_file" - fi - fi - else - if [[ ! -e "$target_file" && ! -L "$target_file" ]]; then - command_skipped=$((command_skipped + 1)) - log_result "skipped" "command" "$name" "not installed: $target_file" - continue - fi - - removable=0 - if [[ $FORCE -eq 1 ]]; then - removable=1 - elif [[ -L "$target_file" ]] && is_symlink_to "$target_file" "$source_file"; then - removable=1 - elif [[ -f "$target_file" && ! -L "$target_file" ]]; then - if files_equal "$source_file" "$target_file"; then - removable=1 - else - target_frontmatter_name="$(extract_command_frontmatter_name_from_file "$target_file" || true)" - if [[ "$target_frontmatter_name" == trailofbits:* ]]; then - removable=1 - fi - fi - fi - - if [[ $removable -ne 1 ]]; then - command_error=$((command_error + 1)) - log_result "error" "command" "$name" "refusing to remove unmanaged path ($target_file); use --force" - continue - fi - - if [[ $DRY_RUN -eq 1 ]]; then - command_removed=$((command_removed + 1)) - log_result "removed" "command" "$name" "[dry-run] rm -rf $target_file" - else - safe_remove_path "$target_file" - command_removed=$((command_removed + 1)) - log_result "removed" "command" "$name" "removed: $target_file" - fi - fi + install_or_uninstall_command "$idx" done fi -echo -echo "Summary:" - -if [[ $INSTALL_SKILLS -eq 1 ]]; then - echo " skills:" - printf ' installed: %d\n' "$skill_installed" - printf ' removed: %d\n' "$skill_removed" - printf ' unchanged: %d\n' "$skill_unchanged" - printf ' skipped: %d\n' "$skill_skipped" - printf ' error: %d\n' "$skill_error" -fi - -if [[ $INSTALL_COMMANDS -eq 1 ]]; then - echo " commands:" - printf ' installed: %d\n' "$command_installed" - printf ' removed: %d\n' "$command_removed" - printf ' unchanged: %d\n' "$command_unchanged" - printf ' skipped: %d\n' "$command_skipped" - printf ' error: %d\n' "$command_error" -fi - -if [[ $INSTALL_COMMANDS -eq 1 && ${#SELECTED_COMMAND_INDEXES[@]} -eq 0 ]]; then - echo - echo "Note: no compatible commands matched the selected filters." -fi +print_summary if [[ $skill_error -gt 0 || $command_error -gt 0 ]]; then exit 1 diff --git a/scripts/validate_opencode_compat.py b/scripts/validate_opencode_compat.py index 35b19d4..d62e538 100644 --- a/scripts/validate_opencode_compat.py +++ b/scripts/validate_opencode_compat.py @@ -12,10 +12,11 @@ from dataclasses import dataclass from pathlib import Path - FRONTMATTER_RE = re.compile(r"^---\r?\n(.*?)\r?\n---(?:\r?\n|$)", re.DOTALL) NAME_RE = re.compile(r"^[a-z0-9]+(-[a-z0-9]+)*$") +CLAUDE_ONLY_COMMAND_FIELDS = {"allowed-tools", "argument-hint"} + @dataclass(frozen=True) class SkillRecord: @@ -34,7 +35,11 @@ def unquote(value: str) -> str: def parse_frontmatter(text: str, path: Path) -> dict[str, str]: - """Parse simple YAML frontmatter key/value pairs.""" + """Parse simple YAML frontmatter scalar key/value pairs. + + Does not handle YAML lists (e.g. allowed-tools entries). List keys + will have an empty string value. + """ match = FRONTMATTER_RE.match(text) if not match: raise ValueError(f"{path}: missing or malformed YAML frontmatter") @@ -50,7 +55,9 @@ def parse_frontmatter(text: str, path: Path) -> dict[str, str]: return parsed -def collect_skill_records(repo_root: Path) -> tuple[list[SkillRecord], list[str], list[str]]: +def collect_skill_records( + repo_root: Path, +) -> tuple[list[SkillRecord], list[str], list[str]]: """Collect skill metadata and compatibility warnings/errors.""" records: list[SkillRecord] = [] errors: list[str] = [] @@ -63,9 +70,17 @@ def collect_skill_records(repo_root: Path) -> tuple[list[SkillRecord], list[str] errors.append(f"{relative}: expected plugins//skills/.../SKILL.md path layout") continue - text = skill_path.read_text(encoding="utf-8") + try: + text = skill_path.read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError) as exc: + errors.append(f"{relative}: cannot read file ({exc})") + continue + if "{baseDir}" in text: - warnings.append(f"{relative}: contains '{{baseDir}}' (Claude-specific variable)") + warnings.append( + f"{relative}: contains '{{baseDir}}' (Claude-specific variable, " + "not substituted by OpenCode)" + ) try: metadata = parse_frontmatter(text, skill_path) @@ -91,6 +106,14 @@ def collect_skill_records(repo_root: Path) -> tuple[list[SkillRecord], list[str] "(must match ^[a-z0-9]+(-[a-z0-9]+)*$)" ) + # OpenCode requires skill directory name to match frontmatter name + skill_dir_name = skill_path.parent.name + if skill_dir_name != name: + errors.append( + f"{relative}: directory name '{skill_dir_name}' does not match " + f"frontmatter name '{name}' (OpenCode requires these to match)" + ) + records.append(SkillRecord(name=name, description=description, path=relative)) return records, errors, warnings @@ -112,12 +135,62 @@ def check_duplicate_names(records: list[SkillRecord]) -> list[str]: return errors +def validate_command_files( + repo_root: Path, +) -> tuple[list[str], list[str]]: + """Check command files for OpenCode compatibility issues.""" + errors: list[str] = [] + warnings: list[str] = [] + command_count = 0 + + for cmd_path in sorted(repo_root.glob("plugins/*/commands/*.md")): + relative = cmd_path.relative_to(repo_root) + command_count += 1 + + try: + text = cmd_path.read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError) as exc: + errors.append(f"{relative}: cannot read file ({exc})") + continue + + if "${CLAUDE_PLUGIN_ROOT}" in text: + warnings.append( + f"{relative}: uses ${{CLAUDE_PLUGIN_ROOT}} (incompatible with OpenCode)" + ) + + try: + metadata = parse_frontmatter(text, cmd_path) + except ValueError: + continue + + found = CLAUDE_ONLY_COMMAND_FIELDS & set(metadata) + if found: + warnings.append( + f"{relative}: uses Claude-specific frontmatter " + f"fields {found} (silently ignored by OpenCode)" + ) + + return errors, warnings + + def main() -> int: """Run validation and print a concise report.""" repo_root = Path(__file__).resolve().parents[1] + plugins_dir = repo_root / "plugins" + if not plugins_dir.is_dir(): + print( + f"Error: {plugins_dir} not found. Is this script in the right location?", + file=sys.stderr, + ) + return 1 + records, errors, warnings = collect_skill_records(repo_root) errors.extend(check_duplicate_names(records)) + cmd_errors, cmd_warnings = validate_command_files(repo_root) + errors.extend(cmd_errors) + warnings.extend(cmd_warnings) + print(f"Checked {len(records)} skills for OpenCode compatibility.") if warnings: From 28e5f55c18831d0faff854c15acc562193c233b6 Mon Sep 17 00:00:00 2001 From: Dan Guido Date: Tue, 3 Mar 2026 21:08:05 -0800 Subject: [PATCH 3/4] Fix shfmt version in CI to match pre-commit config (v3.12.0) Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/validate.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 7548dd8..5ba36f9 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -95,11 +95,11 @@ jobs: - name: Check shell script formatting run: | - shfmt_version="v3.10.0" + shfmt_version="v3.12.0" shfmt_url="https://github.com/mvdan/sh/releases/download/${shfmt_version}/shfmt_${shfmt_version}_linux_amd64" curl -fsSL "$shfmt_url" -o /tmp/shfmt chmod +x /tmp/shfmt - /tmp/shfmt -d -i 2 scripts/install_opencode_skills.sh + /tmp/shfmt -d -i 2 -ci scripts/install_opencode_skills.sh - name: Validate OpenCode skill compatibility run: python3 scripts/validate_opencode_compat.py From dc3eb42b5fdfb744e3ba7b7c900e35241e0da9e9 Mon Sep 17 00:00:00 2001 From: Dan Guido Date: Tue, 3 Mar 2026 21:15:44 -0800 Subject: [PATCH 4/4] Downgrade skill dir name mismatch from error to warning The burpsuite-project-parser plugin has a pre-existing structural issue (SKILL.md directly in skills/ instead of a named subdirectory). This should not block the PR. Co-Authored-By: Claude Opus 4.6 (1M context) --- scripts/validate_opencode_compat.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/validate_opencode_compat.py b/scripts/validate_opencode_compat.py index d62e538..41c9e0e 100644 --- a/scripts/validate_opencode_compat.py +++ b/scripts/validate_opencode_compat.py @@ -109,7 +109,7 @@ def collect_skill_records( # OpenCode requires skill directory name to match frontmatter name skill_dir_name = skill_path.parent.name if skill_dir_name != name: - errors.append( + warnings.append( f"{relative}: directory name '{skill_dir_name}' does not match " f"frontmatter name '{name}' (OpenCode requires these to match)" )