#!/usr/bin/env bash # skill-dev.sh — local skill development pipeline for OpenClaw # Usage: skill-dev.sh [args] # # Subcommands: # new Scaffold a new skill dir with SKILL.md under ~/clawd/skills/ # link Symlink an external skill directory into ~/clawd/skills/ # test Validate an existing skill's structure and SKILL.md frontmatter set -euo pipefail SKILLS_DIR="${HOME}/clawd/skills" SCRIPT_NAME="$(basename "$0")" usage() { cat < [args] Subcommands: new Scaffold a new skill at ${SKILLS_DIR}//SKILL.md link Symlink an external skill directory into ${SKILLS_DIR}/ test Validate skill structure and SKILL.md frontmatter Examples: ${SCRIPT_NAME} new my-skill ${SCRIPT_NAME} link /path/to/external/my-skill ${SCRIPT_NAME} test weather ${SCRIPT_NAME} test my-skill EOF } # ── helpers ────────────────────────────────────────────────────────────────── die() { echo "ERROR: $*" >&2; exit 1; } info() { echo " $*"; } ok() { echo "[ok] $*"; } fail() { echo "[fail] $*" >&2; } require_arg() { [[ $# -ge 2 ]] || die "subcommand '${1}' requires an argument. Run: ${SCRIPT_NAME} --help" } # ── new ─────────────────────────────────────────────────────────────────────── cmd_new() { local name="${1:-}" [[ -n "${name}" ]] || die "'new' requires a skill name. Usage: ${SCRIPT_NAME} new " # Sanitise: lowercase, hyphens only local safe_name safe_name="$(echo "${name}" | tr '[:upper:]' '[:lower:]' | tr '_' '-' | tr -cd 'a-z0-9-')" [[ "${safe_name}" == "${name}" ]] || echo "Note: sanitised name '${name}' -> '${safe_name}'" local skill_dir="${SKILLS_DIR}/${safe_name}" [[ ! -e "${skill_dir}" ]] || die "Skill '${safe_name}' already exists at ${skill_dir}" [[ -d "${SKILLS_DIR}" ]] || die "Skills directory not found: ${SKILLS_DIR}" mkdir -p "${skill_dir}/scripts" # Write SKILL.md with YAML frontmatter (the format openclaw expects) cat > "${skill_dir}/SKILL.md" << EOF --- name: ${safe_name} description: "TODO: one-sentence description of what this skill does." metadata: { "openclaw": { "emoji": "🔧", "requires": { "bins": [] }, }, } --- # ${safe_name} Skill TODO: Describe what this skill enables the agent to do. ## Usage \`\`\`bash # Example command echo "replace with real usage" \`\`\` ## Notes - Add context, tips, and edge cases here. EOF # Write a starter helper script cat > "${skill_dir}/scripts/${safe_name}.sh" << EOF #!/usr/bin/env bash # ${safe_name}.sh — helper script for the ${safe_name} skill # Usage: ${safe_name}.sh [args] set -euo pipefail case "\${1:-}" in help|--help|-h) echo "Usage: ${safe_name}.sh " ;; *) echo "Unknown subcommand: \${1:-}" exit 1 ;; esac EOF chmod +x "${skill_dir}/scripts/${safe_name}.sh" echo "Scaffolded skill: ${safe_name}" info "Location : ${skill_dir}/" info "SKILL.md : ${skill_dir}/SKILL.md" info "Script : ${skill_dir}/scripts/${safe_name}.sh" echo "" echo "Next steps:" info "1. Edit ${skill_dir}/SKILL.md — fill in description, requirements, usage docs" info "2. Edit ${skill_dir}/scripts/${safe_name}.sh — implement your helper" info "3. Validate: ${SCRIPT_NAME} test ${safe_name}" info "4. Check readiness: openclaw skills check" } # ── link ───────────────────────────────────────────────────────────────────── cmd_link() { local src_path="${1:-}" [[ -n "${src_path}" ]] || die "'link' requires a path. Usage: ${SCRIPT_NAME} link " # Resolve to absolute path src_path="$(realpath "${src_path}" 2>/dev/null)" || die "Path does not exist: ${1}" [[ -d "${src_path}" ]] || die "Not a directory: ${src_path}" # Determine skill name from the directory or from SKILL.md frontmatter local skill_name local skill_md="${src_path}/SKILL.md" if [[ -f "${skill_md}" ]]; then # Try to extract name from frontmatter skill_name="$(awk '/^---/{if(++c==2)exit} c==1 && /^name:/{gsub(/^name:[[:space:]]*/, ""); gsub(/[[:space:]]*$/, ""); print}' "${skill_md}")" fi # Fall back to directory name if name not found in frontmatter if [[ -z "${skill_name}" ]]; then skill_name="$(basename "${src_path}")" echo "Warning: could not read 'name' from SKILL.md; using directory name '${skill_name}'" fi local link_target="${SKILLS_DIR}/${skill_name}" if [[ -L "${link_target}" ]]; then local existing_target existing_target="$(readlink -f "${link_target}")" if [[ "${existing_target}" == "${src_path}" ]]; then echo "Already linked: ${link_target} -> ${src_path}" exit 0 else die "A symlink for '${skill_name}' already exists pointing to ${existing_target}. Remove it first." fi fi [[ ! -e "${link_target}" ]] || die "A directory already exists at ${link_target}. Remove or rename it first." ln -s "${src_path}" "${link_target}" echo "Linked skill: ${skill_name}" info "Symlink : ${link_target}" info "Source : ${src_path}" echo "" info "Verify with: openclaw skills info ${skill_name}" } # ── test ───────────────────────────────────────────────────────────────────── cmd_test() { local name="${1:-}" [[ -n "${name}" ]] || die "'test' requires a skill name. Usage: ${SCRIPT_NAME} test " local skill_dir="${SKILLS_DIR}/${name}" local skill_md="${skill_dir}/SKILL.md" echo "Testing skill: ${name}" echo "" local pass=0 local fail_count=0 check() { local label="$1" local result="$2" # "ok" or error message if [[ "${result}" == "ok" ]]; then ok "${label}" ((pass++)) || true else fail "${label}: ${result}" ((fail_count++)) || true fi } # 1. Skill directory exists if [[ -d "${skill_dir}" ]]; then check "skill directory exists" "ok" else check "skill directory exists" "not found at ${skill_dir}" echo "" echo "Result: FAIL (${fail_count} check(s) failed)" exit 1 fi # 2. SKILL.md exists if [[ -f "${skill_md}" ]]; then check "SKILL.md present" "ok" else check "SKILL.md present" "missing at ${skill_md}" echo "" echo "Result: FAIL (${fail_count} check(s) failed)" exit 1 fi # 3. SKILL.md has opening frontmatter delimiter local first_line first_line="$(head -1 "${skill_md}")" if [[ "${first_line}" == "---" ]]; then check "frontmatter opening '---'" "ok" else check "frontmatter opening '---'" "first line is '${first_line}', expected '---'" fi # 4. SKILL.md has a 'name' field in frontmatter local fm_name fm_name="$(awk '/^---/{if(++c==2)exit} c==1 && /^name:/{gsub(/^name:[[:space:]]*/, ""); gsub(/[[:space:]]*$/, ""); print}' "${skill_md}")" if [[ -n "${fm_name}" ]]; then check "frontmatter 'name' field present (value: '${fm_name}')" "ok" else check "frontmatter 'name' field present" "missing or empty" fi # 5. name in frontmatter matches directory name if [[ -n "${fm_name}" ]] && [[ "${fm_name}" == "${name}" ]]; then check "frontmatter name matches directory name" "ok" elif [[ -n "${fm_name}" ]]; then check "frontmatter name matches directory name" "frontmatter says '${fm_name}', directory is '${name}'" fi # 6. SKILL.md has a 'description' field local fm_desc fm_desc="$(awk '/^---/{if(++c==2)exit} c==1 && /^description:/{gsub(/^description:[[:space:]]*/, ""); gsub(/^["'\'']|["'\''][[:space:]]*$/, ""); print}' "${skill_md}")" if [[ -n "${fm_desc}" ]]; then check "frontmatter 'description' field present" "ok" info " description: ${fm_desc:0:80}$([ ${#fm_desc} -gt 80 ] && echo '...' || true)" else check "frontmatter 'description' field present" "missing or empty" fi # 7. Closing frontmatter delimiter exists local close_count close_count="$(grep -c '^---$' "${skill_md}" || true)" if [[ "${close_count}" -ge 2 ]]; then check "frontmatter closing '---'" "ok" else check "frontmatter closing '---'" "only ${close_count} '---' delimiter(s) found; need at least 2" fi # 8. Markdown body is non-empty (content after second ---) local body_lines body_lines="$(awk '/^---/{if(++c==2){found=1;next}} found{print}' "${skill_md}" | grep -v '^[[:space:]]*$' | wc -l || true)" if [[ "${body_lines}" -gt 0 ]]; then check "markdown body non-empty (${body_lines} non-blank lines)" "ok" else check "markdown body non-empty" "body is empty after frontmatter" fi # 9. scripts/ directory check (optional — warn if present scripts aren't executable) if [[ -d "${skill_dir}/scripts" ]]; then local non_exec_scripts=() while IFS= read -r -d '' script_file; do [[ -x "${script_file}" ]] || non_exec_scripts+=("${script_file}") done < <(find "${skill_dir}/scripts" -maxdepth 1 -type f \( -name "*.sh" -o -name "*.py" -o -name "*.js" \) -print0 2>/dev/null) if [[ ${#non_exec_scripts[@]} -eq 0 ]]; then check "scripts/ — all scripts executable" "ok" else for f in "${non_exec_scripts[@]}"; do check "script executable: $(basename "${f}")" "not executable (run: chmod +x ${f})" done fi fi # 10. Check binary requirements declared in frontmatter local bins_line bins_line="$(awk '/^---/{if(++c==2)exit} c==1 && /\"bins\"/{print}' "${skill_md}" | head -1)" if [[ -n "${bins_line}" ]]; then # Extract quoted strings from the bins array local -a declared_bins while IFS= read -r bin; do [[ -n "${bin}" ]] && declared_bins+=("${bin}") done < <(echo "${bins_line}" | grep -oE '"[^"]+"' | tr -d '"' || true) for bin in "${declared_bins[@]}"; do if command -v "${bin}" &>/dev/null; then check "required binary '${bin}'" "ok" else check "required binary '${bin}'" "not found in PATH" fi done fi # 11. openclaw skills check for this skill (if CLI available) if command -v openclaw &>/dev/null; then local oc_status oc_status="$(openclaw skills check 2>/dev/null | grep -F "${name}" | head -1 || true)" if echo "${oc_status}" | grep -q "✓\|ready"; then check "openclaw skills check: ready" "ok" elif echo "${oc_status}" | grep -q "✗\|missing"; then check "openclaw skills check: ready" "reported as not ready — check binary requirements above" else info "(openclaw skills check: skill not found in output — may need gateway restart)" fi fi echo "" echo "Result: $( [[ ${fail_count} -eq 0 ]] && echo "PASS (${pass} checks)" || echo "FAIL (${fail_count} failed, ${pass} passed)" )" [[ ${fail_count} -eq 0 ]] } # ── dispatch ────────────────────────────────────────────────────────────────── case "${1:-}" in new) shift; cmd_new "$@" ;; link) shift; cmd_link "$@" ;; test) shift; cmd_test "$@" ;; --help|-h|help) usage ;; "") usage; exit 1 ;; *) die "Unknown subcommand: '${1}'. Run: ${SCRIPT_NAME} --help" ;; esac