- Reset master to upstream/main (16,697 commits) - Overlay 2,271 local-only files (skills, tools, workspace, configs, apps) - Restore IDENTITY.md and USER.md templates - Build verified, gateway running, Discord working Co-Authored-By: Claude Opus 4.6 <[email protected]>
334 lines
11 KiB
Bash
334 lines
11 KiB
Bash
#!/usr/bin/env bash
|
|
# skill-dev.sh — local skill development pipeline for OpenClaw
|
|
# Usage: skill-dev.sh <subcommand> [args]
|
|
#
|
|
# Subcommands:
|
|
# new <name> Scaffold a new skill dir with SKILL.md under ~/clawd/skills/
|
|
# link <path> Symlink an external skill directory into ~/clawd/skills/
|
|
# test <name> Validate an existing skill's structure and SKILL.md frontmatter
|
|
|
|
set -euo pipefail
|
|
|
|
SKILLS_DIR="${HOME}/clawd/skills"
|
|
SCRIPT_NAME="$(basename "$0")"
|
|
|
|
usage() {
|
|
cat <<EOF
|
|
Usage: ${SCRIPT_NAME} <subcommand> [args]
|
|
|
|
Subcommands:
|
|
new <name> Scaffold a new skill at ${SKILLS_DIR}/<name>/SKILL.md
|
|
link <path> Symlink an external skill directory into ${SKILLS_DIR}/
|
|
test <name> 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 <name>"
|
|
|
|
# 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 <subcommand> [args]
|
|
|
|
set -euo pipefail
|
|
|
|
case "\${1:-}" in
|
|
help|--help|-h)
|
|
echo "Usage: ${safe_name}.sh <subcommand>"
|
|
;;
|
|
*)
|
|
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 <path>"
|
|
|
|
# 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 <name>"
|
|
|
|
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
|