Add skill-manager skill (migrated from skill-creator) with deploy action

- Skill content moved from ~/.agents/skills/skill-creator, renamed to skill-manager
- New deploy CLI action: installs any skill via absolute-path symlinks (or copies)
  into $HOME/.agents/skills, $HOME/.claude/skills and $HOME/.cline/skills
- Taskfile + shell module wrappers (task deploy / cli:deploy)
- SKILL.md: deploy docs, origin-repository/origin-path metadata

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-23 22:32:42 +03:00
co-authored by Claude Fable 5
parent cc30987dd2
commit 6042272ccc
62 changed files with 3474 additions and 1 deletions
+16
View File
@@ -0,0 +1,16 @@
# Node / pnpm
node_modules/
dist/
.pnpm-store/
# Build artefacts
*.js.map
*.d.ts.map
# Environment
.env
.env.local
.env.*.local
# OS
.DS_Store
+11
View File
@@ -0,0 +1,11 @@
# https://taskfile.dev
version: "3"
tasks:
environment:show:
desc: "Show environment vars"
cmds:
- |
./.scripts/base/api/environment-show.sh {{ .CLI_ARGS }}
silent: true
@@ -0,0 +1,9 @@
#!/bin/bash
# -------------------------------------------------------------------------------------
#
#
#
# -------------------------------------------------------------------------------------
. ./.scripts/base/lib/--index.sh
_base_environment_show "$@"
@@ -0,0 +1,28 @@
#!/bin/bash
# -------------------------------------------------------------------------------------
#
#
#
# -------------------------------------------------------------------------------------
export LOCAL_HOME_DIR=$(pwd)
export LOCAL_HOME_DIR_NAME=${PWD##*/}
export LOCAL_GIT_ROOT_DIR=$(dirname "${LOCAL_HOME_DIR}")
TMP_BASE_FILE_DOTENV=".env"
if
[ -e "${TMP_BASE_FILE_DOTENV}" ]
then
export $(
grep -v '^#' "${TMP_BASE_FILE_DOTENV}" | xargs
) >/dev/null 2>&1
fi
ALL_ARGS=("$@")
while [[ "$#" -gt 0 ]]; do
case $1 in
*) ;;
esac
shift
done
set -- "${ALL_ARGS[@]}"
@@ -0,0 +1,10 @@
#!/bin/bash
# -------------------------------------------------------------------------------------
#
#
#
# -------------------------------------------------------------------------------------
. ./.scripts/base/lib/--index-api.sh
# No required environment variables for skill-manager base
+12
View File
@@ -0,0 +1,12 @@
#!/bin/bash
# -------------------------------------------------------------------------------------
#
#
#
# -------------------------------------------------------------------------------------
. ./.scripts/base/lib/-ensure-environment-variable.sh
. ./.scripts/base/lib/-environment-show.sh
. ./.scripts/base/lib/-mask.sh
+20
View File
@@ -0,0 +1,20 @@
#!/bin/bash
# -------------------------------------------------------------------------------------
#
#
#
# -------------------------------------------------------------------------------------
. ./.scripts/loggers/lib/--index.sh
#
# --> passed parameters are read & exported environment variables
#
. ./.scripts/base/lib/--env-vars-reader.sh
#
# --> required environment variables are validated for existence
#
. ./.scripts/base/lib/--env-vars-validator.sh
#
# --> available functions are imported/exported
#
. ./.scripts/base/lib/--index-api.sh
# -------------------------------------------------------------------------------------
@@ -0,0 +1,18 @@
#!/bin/bash
# -------------------------------------------------------------------------------------
#
#
#
# -------------------------------------------------------------------------------------
. ./.scripts/loggers/lib/--index-api.sh
_base_ensureEnvironmentVariable() {
local FUNCTION_NAME="_base_ensureEnvironmentVariable"
local ENV_VAR_NAME="$1"
_loggers_info "${FUNCTION_NAME}" "ENV_VAR_NAME: ${ENV_VAR_NAME}"
if [ -z "${!ENV_VAR_NAME}" ]; then
_loggers_error "${FUNCTION_NAME}" "Missing required environment variable: ${ENV_VAR_NAME}. Check .env or .env-* files!"
exit 1
fi
}
@@ -0,0 +1,16 @@
#!/bin/bash
# -------------------------------------------------------------------------------------
#
#
#
# -------------------------------------------------------------------------------------
. ./.scripts/loggers/lib/--index-api.sh
_base_environment_show() {
local FUNCTION_NAME="_base_environment_show"
_loggers_info "${FUNCTION_NAME}" "LOCAL_HOME_DIR: ${LOCAL_HOME_DIR}"
_loggers_info "${FUNCTION_NAME}" "LOCAL_HOME_DIR_NAME: ${LOCAL_HOME_DIR_NAME}"
}
+19
View File
@@ -0,0 +1,19 @@
#!/bin/bash
# -------------------------------------------------------------------------------------
#
#
#
# -------------------------------------------------------------------------------------
_base_mask() {
local original_value="$1"
local RET_VAL
if
[[ -n "$original_value" && ${#original_value} -gt 2 ]]
then
RET_VAL="${original_value:0:1}$(printf '%*s' $((${#original_value} - 2)) '' | tr ' ' '*')${original_value: -1}"
else
RET_VAL="$(printf '%*s' ${#original_value} '' | tr ' ' '*')"
fi
echo "${RET_VAL}"
}
+38
View File
@@ -0,0 +1,38 @@
# https://taskfile.dev
version: "3"
tasks:
build:
desc: Compile TypeScript source to dist/
cmds:
- |
./.scripts/cli/api/build.sh {{ .CLI_ARGS }}
silent: true
validate:
desc: |
Validate a skill directory against the agentskills.io spec.
Usage: task cli:validate -- --skill-dir="/absolute/path/to/skill"
cmds:
- |
./.scripts/cli/api/validate.sh {{ .CLI_ARGS }}
silent: true
scaffold:
desc: |
Scaffold a new skill directory from the built-in template.
Usage: task cli:scaffold -- --skill-name="my-skill" [--output-dir="/path/to/.agents/skills"]
cmds:
- |
./.scripts/cli/api/scaffold.sh {{ .CLI_ARGS }}
silent: true
deploy:
desc: |
Deploy a skill into $HOME/.agents/skills, $HOME/.claude/skills and $HOME/.cline/skills (absolute paths).
Usage: task cli:deploy -- --skill-dir="/absolute/path/to/skill" [--mode=symlink|copy] [--force]
cmds:
- |
./.scripts/cli/api/deploy.sh {{ .CLI_ARGS }}
silent: true
+11
View File
@@ -0,0 +1,11 @@
#!/bin/bash
# -------------------------------------------------------------------------------------
#
#
#
# -------------------------------------------------------------------------------------
set -e
. ./.scripts/cli/lib/--index.sh
_cli__build "$@"
+11
View File
@@ -0,0 +1,11 @@
#!/bin/bash
# -------------------------------------------------------------------------------------
#
#
#
# -------------------------------------------------------------------------------------
set -e
. ./.scripts/cli/lib/--index.sh
_cli__deploy "$@"
+11
View File
@@ -0,0 +1,11 @@
#!/bin/bash
# -------------------------------------------------------------------------------------
#
#
#
# -------------------------------------------------------------------------------------
set -e
. ./.scripts/cli/lib/--index.sh
_cli__scaffold "$@"
+11
View File
@@ -0,0 +1,11 @@
#!/bin/bash
# -------------------------------------------------------------------------------------
#
#
#
# -------------------------------------------------------------------------------------
set -e
. ./.scripts/cli/lib/--index.sh
_cli__validate "$@"
@@ -0,0 +1,15 @@
#!/bin/bash
# -------------------------------------------------------------------------------------
#
#
#
# -------------------------------------------------------------------------------------
ALL_ARGS=("$@")
while [[ "$#" -gt 0 ]]; do
case $1 in
*) ;;
esac
shift
done
set -- "${ALL_ARGS[@]}"
@@ -0,0 +1,10 @@
#!/bin/bash
# -------------------------------------------------------------------------------------
#
#
#
# -------------------------------------------------------------------------------------
. ./.scripts/cli/lib/--index-api.sh
# No required environment variables for cli module
+14
View File
@@ -0,0 +1,14 @@
#!/bin/bash
# -------------------------------------------------------------------------------------
#
#
#
# -------------------------------------------------------------------------------------
. ./.scripts/cli/lib/-build.sh
. ./.scripts/cli/lib/-validate.sh
. ./.scripts/cli/lib/-scaffold.sh
. ./.scripts/cli/lib/-deploy.sh
+21
View File
@@ -0,0 +1,21 @@
#!/bin/bash
# -------------------------------------------------------------------------------------
#
#
#
# -------------------------------------------------------------------------------------
. ./.scripts/loggers/lib/--index.sh
. ./.scripts/base/lib/--index.sh
#
# --> passed parameters are read & exported environment variables
#
. ./.scripts/cli/lib/--env-vars-reader.sh
#
# --> required environment variables are validated for existence
#
. ./.scripts/cli/lib/--env-vars-validator.sh
#
# --> available functions are imported/exported
#
. ./.scripts/cli/lib/--index-api.sh
# -------------------------------------------------------------------------------------
+12
View File
@@ -0,0 +1,12 @@
#!/bin/bash
# -------------------------------------------------------------------------------------
#
#
#
# -------------------------------------------------------------------------------------
_cli__build() {
local FUNCTION_NAME="_cli__build"
_loggers_info "${FUNCTION_NAME}" "Compiling TypeScript source to dist/"
pnpm run build
}
+12
View File
@@ -0,0 +1,12 @@
#!/bin/bash
# -------------------------------------------------------------------------------------
#
#
#
# -------------------------------------------------------------------------------------
_cli__deploy() {
local FUNCTION_NAME="_cli__deploy"
_loggers_info "${FUNCTION_NAME}" "Deploying skill into agent skill directories via TypeScript CLI"
npx tsx src/cli/commands.ts --action deploy "$@"
}
+12
View File
@@ -0,0 +1,12 @@
#!/bin/bash
# -------------------------------------------------------------------------------------
#
#
#
# -------------------------------------------------------------------------------------
_cli__scaffold() {
local FUNCTION_NAME="_cli__scaffold"
_loggers_info "${FUNCTION_NAME}" "Running skill scaffolder via TypeScript CLI"
npx tsx src/cli/commands.ts --action scaffold "$@"
}
+12
View File
@@ -0,0 +1,12 @@
#!/bin/bash
# -------------------------------------------------------------------------------------
#
#
#
# -------------------------------------------------------------------------------------
_cli__validate() {
local FUNCTION_NAME="_cli__validate"
_loggers_info "${FUNCTION_NAME}" "Running skill validator via TypeScript CLI"
npx tsx src/cli/commands.ts --action validate "$@"
}
@@ -0,0 +1,18 @@
#!/bin/bash
# -------------------------------------------------------------------------------------
#
# TOBE ADDED - Licence & Copyright Disclaimer
#
# -------------------------------------------------------------------------------------
export LOGGER_TRAILING_NEW_LINE_ENABLED="TRUE"
export LOGGER_IS_ENABLED_ERROR=true
export LOGGER_IS_ENABLED_INFO=true
export LOGGER_IS_ENABLED_WARN=true
export LOGGER_IS_ENABLED_DEBUG=true
export LOGGER_IS_ENABLED_TRACE=false
export LOCAL_STRING_005_SPACES=" "
export LOCAL_STRING_010_SPACES="${LOCAL_STRING_005_SPACES}${LOCAL_STRING_005_SPACES}"
export LOCAL_STRING_050_SPACES="${LOCAL_STRING_010_SPACES}${LOCAL_STRING_010_SPACES}${LOCAL_STRING_010_SPACES}${LOCAL_STRING_010_SPACES}${LOCAL_STRING_010_SPACES}"
export LOCAL_STRING_070_SPACES="${LOCAL_STRING_050_SPACES}${LOCAL_STRING_010_SPACES}${LOCAL_STRING_010_SPACES}"
+9
View File
@@ -0,0 +1,9 @@
#!/bin/bash
# -------------------------------------------------------------------------------------
#
# TOBE ADDED - Licence & Copyright Disclaimer
#
# -------------------------------------------------------------------------------------
. ./.scripts/loggers/lib/--env-vars-reader.sh
. ./.scripts/loggers/lib/--index.sh
@@ -0,0 +1,22 @@
#!/bin/bash
# -------------------------------------------------------------------------------------
#
# TOBE ADDED - Licence & Copyright Disclaimer
#
# -------------------------------------------------------------------------------------
. ./.scripts/loggers/lib/-enable-trailing-new-line.sh
. ./.scripts/loggers/lib/-info.sh
. ./.scripts/loggers/lib/-trace.sh
. ./.scripts/loggers/lib/-debug.sh
. ./.scripts/loggers/lib/-error.sh
. ./.scripts/loggers/lib/-warn.sh
. ./.scripts/loggers/lib/-printf--debug.sh
. ./.scripts/loggers/lib/-printf--info.sh
. ./.scripts/loggers/lib/-printf--trace.sh
. ./.scripts/loggers/lib/-printf--warn.sh
. ./.scripts/loggers/lib/-empty-line.sh
. ./.scripts/loggers/lib/-waiting-dot.sh
+15
View File
@@ -0,0 +1,15 @@
#!/bin/bash
# -------------------------------------------------------------------------------------
#
# TOBE ADDED - Licence & Copyright Disclaimer
#
# -------------------------------------------------------------------------------------
#
# --> passed parameters are read & exported environment variables
#
. ./.scripts/loggers/lib/--env-vars-reader.sh
#
# --> available functions are imported/exported
#
. ./.scripts/loggers/lib/--index-api.sh
# -------------------------------------------------------------------------------------
+20
View File
@@ -0,0 +1,20 @@
#!/bin/bash
# -------------------------------------------------------------------------------------
#
# TOBE ADDED - Licence & Copyright Disclaimer
#
# -------------------------------------------------------------------------------------
. ./.scripts/loggers/lib/-enable-trailing-new-line.sh
_loggers_debug() {
_loggers_enableLoggerTrailingNewLine
if
[ "${LOGGER_IS_ENABLED_DEBUG}" = true ]
then
local TMP_1ST_PARAM="${1}${LOCAL_STRING_070_SPACES}"
TMP_1ST_PARAM="${TMP_1ST_PARAM:0:70}"
local TMP_LINE="# DEBUG # ${TMP_1ST_PARAM} # ${2}"
echo -e "\033[1;36m${TMP_LINE}\033[0m" >&2
fi
}
@@ -0,0 +1,13 @@
#!/bin/bash
# -------------------------------------------------------------------------------------
#
# TOBE ADDED - Licence & Copyright Disclaimer
#
# -------------------------------------------------------------------------------------
. ./.scripts/loggers/lib/-enable-trailing-new-line.sh
_loggers_emptyLine() {
_loggers_enableLoggerTrailingNewLine
echo -e "" >&2
}
@@ -0,0 +1,15 @@
#!/bin/bash
# -------------------------------------------------------------------------------------
#
# TOBE ADDED - Licence & Copyright Disclaimer
#
# -------------------------------------------------------------------------------------
_loggers_enableLoggerTrailingNewLine() {
if
[ -z "$LOGGER_TRAILING_NEW_LINE_DISABLED" ]
then
echo "" >&2
export LOGGER_TRAILING_NEW_LINE_DISABLED="TRUE"
fi
}
+21
View File
@@ -0,0 +1,21 @@
#!/bin/bash
# -------------------------------------------------------------------------------------
#
# TOBE ADDED - Licence & Copyright Disclaimer
#
# -------------------------------------------------------------------------------------
. ./.scripts/loggers/lib/-enable-trailing-new-line.sh
_loggers_error() {
_loggers_enableLoggerTrailingNewLine
if
[ "${LOGGER_IS_ENABLED_ERROR}" = true ]
then
local TMP_1ST_PARAM="${1}${LOCAL_STRING_070_SPACES}"
TMP_1ST_PARAM="${TMP_1ST_PARAM:0:70}"
local TMP_LINE="# ERROR # ${TMP_1ST_PARAM} # $2"
echo -e "\033[1;31m${TMP_LINE}\033[0m" >&2
fi
}
+20
View File
@@ -0,0 +1,20 @@
#!/bin/bash
# -------------------------------------------------------------------------------------
#
# TOBE ADDED - Licence & Copyright Disclaimer
#
# -------------------------------------------------------------------------------------
. ./.scripts/loggers/lib/-enable-trailing-new-line.sh
_loggers_info() {
_loggers_enableLoggerTrailingNewLine
if
[ "${LOGGER_IS_ENABLED_INFO}" = true ]
then
local TMP_1ST_PARAM="${1}${LOCAL_STRING_070_SPACES}"
TMP_1ST_PARAM="${TMP_1ST_PARAM:0:70}"
local TMP_LINE="# INFO # ${TMP_1ST_PARAM} # ${2}"
echo -e "${TMP_LINE}" >&2
fi
}
@@ -0,0 +1,14 @@
#!/bin/bash
# -------------------------------------------------------------------------------------
#
#
#
# -------------------------------------------------------------------------------------
_loggers_printf__debug() {
if
[ "${LOGGER_IS_ENABLED_DEBUG}" = true ]
then
printf '\033[1;36m%s\033[0m\n' "$*" >&2
fi
}
@@ -0,0 +1,14 @@
#!/bin/bash
# -------------------------------------------------------------------------------------
#
#
#
# -------------------------------------------------------------------------------------
_loggers_printf__info() {
if
[ "${LOGGER_IS_ENABLED_INFO}" = true ]
then
printf '\033[1;32m%s\033[0m\n' "$*" >&2
fi
}
@@ -0,0 +1,14 @@
#!/bin/bash
# -------------------------------------------------------------------------------------
#
#
#
# -------------------------------------------------------------------------------------
_loggers_printf__trace() {
if
[ "${LOGGER_IS_ENABLED_TRACE}" = true ]
then
printf '\033[0;90m%s\033[0m\n' "$*" >&2
fi
}
@@ -0,0 +1,14 @@
#!/bin/bash
# -------------------------------------------------------------------------------------
#
#
#
# -------------------------------------------------------------------------------------
_loggers_printf__warn() {
if
[ "${LOGGER_IS_ENABLED_WARN}" = true ]
then
printf '\033[1;33m%s\033[0m\n' "$*" >&2
fi
}
+21
View File
@@ -0,0 +1,21 @@
#!/bin/bash
# -------------------------------------------------------------------------------------
#
# TOBE ADDED - Licence & Copyright Disclaimer
#
# -------------------------------------------------------------------------------------
. ./.scripts/loggers/lib/-enable-trailing-new-line.sh
_loggers_trace() {
_loggers_enableLoggerTrailingNewLine
if
[ "${LOGGER_IS_ENABLED_TRACE}" = true ]
then
local TMP_1ST_PARAM="${1}${LOCAL_STRING_070_SPACES}"
TMP_1ST_PARAM="${TMP_1ST_PARAM:0:70}"
local TMP_LINE="# TRACE # ${TMP_1ST_PARAM} # $2"
echo -e "\033[0;94m${TMP_LINE}\033[0m" >&2
fi
}
@@ -0,0 +1,13 @@
#!/bin/bash
# -------------------------------------------------------------------------------------
#
# TOBE ADDED - Licence & Copyright Disclaimer
#
# -------------------------------------------------------------------------------------
. ./.scripts/loggers/lib/-enable-trailing-new-line.sh
_loggers_waitingDot() {
echo -n "." >&2
export LOGGER_TRAILING_NEW_LINE_DISABLED=""
}
+20
View File
@@ -0,0 +1,20 @@
#!/bin/bash
# -------------------------------------------------------------------------------------
#
# TOBE ADDED - Licence & Copyright Disclaimer
#
# -------------------------------------------------------------------------------------
. ./.scripts/loggers/lib/-enable-trailing-new-line.sh
_loggers_warn() {
_loggers_enableLoggerTrailingNewLine
if
[ "${LOGGER_IS_ENABLED_WARN}" = true ]
then
local TMP_1ST_PARAM="${1}${LOCAL_STRING_070_SPACES}"
TMP_1ST_PARAM="${TMP_1ST_PARAM:0:70}"
local TMP_LINE="# WARN # ${TMP_1ST_PARAM} # $2"
echo -e "\033[1;33m${TMP_LINE}\033[0m" >&2
fi
}
+11
View File
@@ -0,0 +1,11 @@
# https://taskfile.dev
version: "3"
tasks:
execute:
desc: -- --skill-dir="<path>"
cmds:
- |
./.scripts/validator/api/skill--execute.sh {{ .CLI_ARGS }}
silent: true
+6
View File
@@ -0,0 +1,6 @@
#!/bin/bash
# validator/api/skill--execute.sh — thin API wrapper for skill validation
. ./.scripts/validator/lib/--index.sh
_validator__skill__execute "$@"
+4
View File
@@ -0,0 +1,4 @@
#!/bin/bash
# validate/lib/--env-vars-reader.sh — environment loading for the validate module
# This module operates without env files (all input via CLI flags).
# No env loading required.
+4
View File
@@ -0,0 +1,4 @@
#!/bin/bash
# validate/lib/--env-vars-validator.sh — env validation for the validate module
# This module takes all input via --skill-dir CLI flag.
# No environment variables required.
+2
View File
@@ -0,0 +1,2 @@
#!/bin/bash
. ./.scripts/validator/lib/-skill--execute.sh
+20
View File
@@ -0,0 +1,20 @@
#!/bin/bash
# -------------------------------------------------------------------------------------
#
#
#
# -------------------------------------------------------------------------------------
. ./.scripts/base/lib/--index.sh
#
# --> passed parameters are read & exported environment variables
#
. ./.scripts/validator/lib/--env-vars-reader.sh
#
# --> required environment variables are validated for existence
#
. ./.scripts/validator/lib/--env-vars-validator.sh
#
# --> available functions are imported/exported
#
. ./.scripts/validator/lib/--index-api.sh
# -------------------------------------------------------------------------------------
+212
View File
@@ -0,0 +1,212 @@
#!/bin/bash
# validator/lib/-skill--execute.sh — validate a skill directory against the agentskills.io spec
_validator__skill__execute() {
local FUNCTION_NAME="_validator__skill__execute"
local SKILL_DIR=""
# ── Parse args ──────────────────────────────────────────────────────────────
while [[ $# -gt 0 ]]; do
case "$1" in
--skill-dir=*) SKILL_DIR="${1#*=}"; shift ;;
--skill-dir) SKILL_DIR="$2"; shift 2 ;;
--help|-h)
cat <<EOF
Usage: task -t scripts/Taskfile.yml validate-shell -- --skill-dir="<path>"
Validate an Agent Skill directory against the agentskills.io specification.
Options:
--skill-dir PATH Path to the skill directory to validate (required)
Checks:
- SKILL.md exists
- YAML frontmatter is present (--- delimiters)
- 'name' field is present and valid (a-z, 0-9, hyphens only)
- 'name' matches the directory name
- 'name' is <= 64 characters, no leading/trailing/consecutive hyphens
- 'description' field is present and <= 1024 characters
- SKILL.md body is <= 500 lines (warning if exceeded)
- Optional directories: scripts/, references/, assets/
Exit codes:
0 All checks passed
1 One or more checks failed
2 Usage error
Examples:
task -t scripts/Taskfile.yml validate-shell -- --skill-dir=".agents/skills/my-skill"
EOF
return 0
;;
*) echo "Error: unknown argument: $1" >&2; return 2 ;;
esac
done
# ── Validate required args ──────────────────────────────────────────────────
if [[ -z "$SKILL_DIR" ]]; then
echo "Error: --skill-dir is required." >&2
echo "Usage: task -t scripts/Taskfile.yml validate-shell -- --skill-dir=\"<path>\"" >&2
return 2
fi
SKILL_DIR="${SKILL_DIR%/}" # strip trailing slash
# ── Counters ────────────────────────────────────────────────────────────────
local PASS=0 FAIL=0 WARN=0
_pass() { echo " ✅ $1"; ((PASS++)) || true; }
_fail() { echo " ❌ $1"; ((FAIL++)) || true; }
_warn() { echo " ⚠️ $1"; ((WARN++)) || true; }
_info() { echo " ℹ️ $1"; }
echo ""
echo "Validating skill: $SKILL_DIR"
echo "──────────────────────────────────────────"
# ── Check: directory exists ─────────────────────────────────────────────────
if [[ ! -d "$SKILL_DIR" ]]; then
echo "Error: directory not found: $SKILL_DIR" >&2
return 2
fi
local SKILL_FILE="$SKILL_DIR/SKILL.md"
local DIR_NAME
DIR_NAME="$(basename "$SKILL_DIR")"
# ── Check: SKILL.md exists ──────────────────────────────────────────────────
if [[ ! -f "$SKILL_FILE" ]]; then
_fail "SKILL.md not found in $SKILL_DIR"
echo ""; echo "Result: ❌ FAILED"; return 1
fi
_pass "SKILL.md exists"
# ── Check: frontmatter delimiters ───────────────────────────────────────────
local CONTENT
CONTENT="$(cat "$SKILL_FILE")"
if ! echo "$CONTENT" | grep -q "^---$"; then
_fail "SKILL.md is missing YAML frontmatter (--- delimiters)"
echo ""; echo "Result: ❌ FAILED"; return 1
fi
_pass "YAML frontmatter delimiters found"
# ── Extract frontmatter ─────────────────────────────────────────────────────
local FRONTMATTER
FRONTMATTER="$(awk '/^---$/{if(p==0){p=1;next}else{exit}} p{print}' "$SKILL_FILE")"
# ── Check: name field ───────────────────────────────────────────────────────
local NAME_RAW NAME
NAME_RAW="$(echo "$FRONTMATTER" | grep "^name:" | head -1 | sed 's/^name:[[:space:]]*//')"
NAME="$(echo "$NAME_RAW" | tr -d "'\"" | tr -d '[:space:]')"
if [[ -z "$NAME" ]]; then
_fail "'name' field is missing or empty"
else
_pass "'name' field is present: $NAME"
if [[ "$NAME" != "$DIR_NAME" ]]; then
_fail "'name' ($NAME) does not match directory name ($DIR_NAME)"
else
_pass "'name' matches directory name"
fi
local NAME_LEN="${#NAME}"
if [[ $NAME_LEN -gt 64 ]]; then
_fail "'name' is $NAME_LEN characters (max 64)"
else
_pass "'name' length is $NAME_LEN (max 64)"
fi
if echo "$NAME" | grep -q '[^a-z0-9-]'; then
_fail "'name' contains invalid characters (only a-z, 0-9, - allowed)"
else
_pass "'name' uses only allowed characters (a-z, 0-9, -)"
fi
if [[ "$NAME" == -* ]]; then
_fail "'name' must not start with a hyphen"
else
_pass "'name' does not start with a hyphen"
fi
if [[ "$NAME" == *- ]]; then
_fail "'name' must not end with a hyphen"
else
_pass "'name' does not end with a hyphen"
fi
if echo "$NAME" | grep -q "\-\-"; then
_fail "'name' must not contain consecutive hyphens (--)"
else
_pass "'name' has no consecutive hyphens"
fi
fi
# ── Check: description field ────────────────────────────────────────────────
local DESC_LINE
DESC_LINE="$(echo "$FRONTMATTER" | grep "^description:" | head -1)"
if [[ -z "$DESC_LINE" ]]; then
_fail "'description' field is missing"
else
local DESC_VALUE
DESC_VALUE="$(echo "$DESC_LINE" | sed 's/^description:[[:space:]]*//')"
local DESC_FULL
if [[ "$DESC_VALUE" == ">" || "$DESC_VALUE" == "|" || -z "$DESC_VALUE" ]]; then
local DESC_BODY
DESC_BODY="$(awk '/^description:/{found=1; next} found && /^ /{print; next} found && /^[^ ]/{exit}' "$SKILL_FILE" | sed 's/^ //')"
DESC_FULL="$(echo "$DESC_BODY" | tr '\n' ' ' | sed 's/[[:space:]]*$//')"
else
DESC_FULL="$DESC_VALUE"
fi
if [[ -z "$DESC_FULL" ]]; then
_fail "'description' is empty"
else
local DESC_LEN="${#DESC_FULL}"
_pass "'description' is present (${DESC_LEN} chars)"
if [[ $DESC_LEN -gt 1024 ]]; then
_fail "'description' is $DESC_LEN characters (max 1024)"
else
_pass "'description' length is $DESC_LEN (max 1024)"
fi
fi
fi
# ── Check: body length ──────────────────────────────────────────────────────
local BODY_LINES
BODY_LINES="$(awk '/^---$/{n++} n>=2{print}' "$SKILL_FILE" | wc -l | tr -d ' ')"
if [[ $BODY_LINES -gt 500 ]]; then
_warn "SKILL.md body is $BODY_LINES lines (recommended max 500). Consider moving detail to references/."
else
_pass "SKILL.md body is $BODY_LINES lines (max 500 recommended)"
fi
# ── Optional directories ────────────────────────────────────────────────────
echo ""
_info "Optional directories:"
for dir in scripts references assets; do
if [[ -d "$SKILL_DIR/$dir" ]]; then
local FILE_COUNT
FILE_COUNT="$(find "$SKILL_DIR/$dir" -type f | wc -l | tr -d ' ')"
_info " $dir/ — found ($FILE_COUNT files)"
else
_info " $dir/ — not present"
fi
done
# ── Summary ─────────────────────────────────────────────────────────────────
echo ""
echo "──────────────────────────────────────────"
echo "Passed: $PASS | Failed: $FAIL | Warnings: $WARN"
echo ""
if [[ $FAIL -gt 0 ]]; then
echo "Result: ❌ FAILED"; return 1
elif [[ $WARN -gt 0 ]]; then
echo "Result: ✅ PASSED (with warnings)"; return 0
else
echo "Result: ✅ PASSED"; return 0
fi
}
+50
View File
@@ -0,0 +1,50 @@
# https://taskfile.dev
version: "3"
includes:
base: ./.scripts/base/Taskfile.yml
cli: ./.scripts/cli/Taskfile.yml
validator: ./.scripts/validator/Taskfile.yml
tasks:
default:
cmds:
- task --list-all
silent: true
build:
desc: Compile TypeScript source to dist/
cmds:
- task: cli:build
silent: true
validate:
desc: |
Validate a skill directory against the agentskills.io spec.
Usage: task validate -- --skill-dir="/absolute/path/to/skill"
cmds:
- task: cli:validate
vars:
CLI_ARGS: "{{ .CLI_ARGS }}"
silent: true
scaffold:
desc: |
Scaffold a new skill directory from the built-in template.
Usage: task scaffold -- --skill-name="my-skill" [--output-dir="/path/to/.agents/skills"]
cmds:
- task: cli:scaffold
vars:
CLI_ARGS: "{{ .CLI_ARGS }}"
silent: true
deploy:
desc: |
Deploy a skill into $HOME/.agents/skills, $HOME/.claude/skills and $HOME/.cline/skills (absolute paths).
Usage: task deploy -- --skill-dir="/absolute/path/to/skill" [--mode=symlink|copy] [--force]
cmds:
- task: cli:deploy
vars:
CLI_ARGS: "{{ .CLI_ARGS }}"
silent: true
+23
View File
@@ -0,0 +1,23 @@
{
"name": "skill-tools",
"version": "0.1.0",
"description": "CLI tools for creating and validating Agent Skills",
"type": "module",
"packageManager": "pnpm@9.15.0",
"bin": {
"skill-tools": "dist/cli/commands.js"
},
"scripts": {
"build": "tsc",
"cli": "tsx src/cli/commands.ts"
},
"devDependencies": {
"@types/js-yaml": "^4.0.9",
"@types/node": "^22.0.0",
"tsx": "^4.19.0",
"typescript": "^5.7.0"
},
"dependencies": {
"js-yaml": "^4.1.0"
}
}
+354
View File
@@ -0,0 +1,354 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
dependencies:
js-yaml:
specifier: ^4.1.0
version: 4.3.0
devDependencies:
'@types/js-yaml':
specifier: ^4.0.9
version: 4.0.9
'@types/node':
specifier: ^22.0.0
version: 22.20.1
tsx:
specifier: ^4.19.0
version: 4.23.1
typescript:
specifier: ^5.7.0
version: 5.9.3
packages:
'@esbuild/aix-ppc64@0.28.1':
resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [aix]
'@esbuild/android-arm64@0.28.1':
resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [android]
'@esbuild/android-arm@0.28.1':
resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==}
engines: {node: '>=18'}
cpu: [arm]
os: [android]
'@esbuild/android-x64@0.28.1':
resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==}
engines: {node: '>=18'}
cpu: [x64]
os: [android]
'@esbuild/darwin-arm64@0.28.1':
resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==}
engines: {node: '>=18'}
cpu: [arm64]
os: [darwin]
'@esbuild/darwin-x64@0.28.1':
resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==}
engines: {node: '>=18'}
cpu: [x64]
os: [darwin]
'@esbuild/freebsd-arm64@0.28.1':
resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==}
engines: {node: '>=18'}
cpu: [arm64]
os: [freebsd]
'@esbuild/freebsd-x64@0.28.1':
resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==}
engines: {node: '>=18'}
cpu: [x64]
os: [freebsd]
'@esbuild/linux-arm64@0.28.1':
resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==}
engines: {node: '>=18'}
cpu: [arm64]
os: [linux]
'@esbuild/linux-arm@0.28.1':
resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==}
engines: {node: '>=18'}
cpu: [arm]
os: [linux]
'@esbuild/linux-ia32@0.28.1':
resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==}
engines: {node: '>=18'}
cpu: [ia32]
os: [linux]
'@esbuild/linux-loong64@0.28.1':
resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==}
engines: {node: '>=18'}
cpu: [loong64]
os: [linux]
'@esbuild/linux-mips64el@0.28.1':
resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==}
engines: {node: '>=18'}
cpu: [mips64el]
os: [linux]
'@esbuild/linux-ppc64@0.28.1':
resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [linux]
'@esbuild/linux-riscv64@0.28.1':
resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==}
engines: {node: '>=18'}
cpu: [riscv64]
os: [linux]
'@esbuild/linux-s390x@0.28.1':
resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==}
engines: {node: '>=18'}
cpu: [s390x]
os: [linux]
'@esbuild/linux-x64@0.28.1':
resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==}
engines: {node: '>=18'}
cpu: [x64]
os: [linux]
'@esbuild/netbsd-arm64@0.28.1':
resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==}
engines: {node: '>=18'}
cpu: [arm64]
os: [netbsd]
'@esbuild/netbsd-x64@0.28.1':
resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==}
engines: {node: '>=18'}
cpu: [x64]
os: [netbsd]
'@esbuild/openbsd-arm64@0.28.1':
resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openbsd]
'@esbuild/openbsd-x64@0.28.1':
resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==}
engines: {node: '>=18'}
cpu: [x64]
os: [openbsd]
'@esbuild/openharmony-arm64@0.28.1':
resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openharmony]
'@esbuild/sunos-x64@0.28.1':
resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==}
engines: {node: '>=18'}
cpu: [x64]
os: [sunos]
'@esbuild/win32-arm64@0.28.1':
resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==}
engines: {node: '>=18'}
cpu: [arm64]
os: [win32]
'@esbuild/win32-ia32@0.28.1':
resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==}
engines: {node: '>=18'}
cpu: [ia32]
os: [win32]
'@esbuild/win32-x64@0.28.1':
resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==}
engines: {node: '>=18'}
cpu: [x64]
os: [win32]
'@types/js-yaml@4.0.9':
resolution: {integrity: sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==}
'@types/node@22.20.1':
resolution: {integrity: sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==}
argparse@2.0.1:
resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
esbuild@0.28.1:
resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==}
engines: {node: '>=18'}
hasBin: true
fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
js-yaml@4.3.0:
resolution: {integrity: sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==}
hasBin: true
tsx@4.23.1:
resolution: {integrity: sha512-GQHnkIfxyx1wYCOS/wonik5MVRZU9hi1TEZmzGZSCJB1y9YgoZ8H6itNE/u4suE+yLmOzuE4E5S4TZ/ZX2wcWQ==}
engines: {node: '>=18.0.0'}
hasBin: true
typescript@5.9.3:
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
engines: {node: '>=14.17'}
hasBin: true
undici-types@6.21.0:
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
snapshots:
'@esbuild/aix-ppc64@0.28.1':
optional: true
'@esbuild/android-arm64@0.28.1':
optional: true
'@esbuild/android-arm@0.28.1':
optional: true
'@esbuild/android-x64@0.28.1':
optional: true
'@esbuild/darwin-arm64@0.28.1':
optional: true
'@esbuild/darwin-x64@0.28.1':
optional: true
'@esbuild/freebsd-arm64@0.28.1':
optional: true
'@esbuild/freebsd-x64@0.28.1':
optional: true
'@esbuild/linux-arm64@0.28.1':
optional: true
'@esbuild/linux-arm@0.28.1':
optional: true
'@esbuild/linux-ia32@0.28.1':
optional: true
'@esbuild/linux-loong64@0.28.1':
optional: true
'@esbuild/linux-mips64el@0.28.1':
optional: true
'@esbuild/linux-ppc64@0.28.1':
optional: true
'@esbuild/linux-riscv64@0.28.1':
optional: true
'@esbuild/linux-s390x@0.28.1':
optional: true
'@esbuild/linux-x64@0.28.1':
optional: true
'@esbuild/netbsd-arm64@0.28.1':
optional: true
'@esbuild/netbsd-x64@0.28.1':
optional: true
'@esbuild/openbsd-arm64@0.28.1':
optional: true
'@esbuild/openbsd-x64@0.28.1':
optional: true
'@esbuild/openharmony-arm64@0.28.1':
optional: true
'@esbuild/sunos-x64@0.28.1':
optional: true
'@esbuild/win32-arm64@0.28.1':
optional: true
'@esbuild/win32-ia32@0.28.1':
optional: true
'@esbuild/win32-x64@0.28.1':
optional: true
'@types/js-yaml@4.0.9': {}
'@types/node@22.20.1':
dependencies:
undici-types: 6.21.0
argparse@2.0.1: {}
esbuild@0.28.1:
optionalDependencies:
'@esbuild/aix-ppc64': 0.28.1
'@esbuild/android-arm': 0.28.1
'@esbuild/android-arm64': 0.28.1
'@esbuild/android-x64': 0.28.1
'@esbuild/darwin-arm64': 0.28.1
'@esbuild/darwin-x64': 0.28.1
'@esbuild/freebsd-arm64': 0.28.1
'@esbuild/freebsd-x64': 0.28.1
'@esbuild/linux-arm': 0.28.1
'@esbuild/linux-arm64': 0.28.1
'@esbuild/linux-ia32': 0.28.1
'@esbuild/linux-loong64': 0.28.1
'@esbuild/linux-mips64el': 0.28.1
'@esbuild/linux-ppc64': 0.28.1
'@esbuild/linux-riscv64': 0.28.1
'@esbuild/linux-s390x': 0.28.1
'@esbuild/linux-x64': 0.28.1
'@esbuild/netbsd-arm64': 0.28.1
'@esbuild/netbsd-x64': 0.28.1
'@esbuild/openbsd-arm64': 0.28.1
'@esbuild/openbsd-x64': 0.28.1
'@esbuild/openharmony-arm64': 0.28.1
'@esbuild/sunos-x64': 0.28.1
'@esbuild/win32-arm64': 0.28.1
'@esbuild/win32-ia32': 0.28.1
'@esbuild/win32-x64': 0.28.1
fsevents@2.3.3:
optional: true
js-yaml@4.3.0:
dependencies:
argparse: 2.0.1
tsx@4.23.1:
dependencies:
esbuild: 0.28.1
optionalDependencies:
fsevents: 2.3.3
typescript@5.9.3: {}
undici-types@6.21.0: {}
+191
View File
@@ -0,0 +1,191 @@
/**
* Action: deploy
*
* Deploys a skill directory into the agent skill directories so compatible
* agents (Claude Code, Cline, Copilot, Codex, …) can discover it.
*
* Default targets (always resolved to absolute paths):
* $HOME/.agents/skills/<skill-name>
* $HOME/.claude/skills/<skill-name>
* $HOME/.cline/skills/<skill-name>
*
* Modes:
* symlink (default) — creates an absolute-path symlink to the skill source.
* The source stays the single source of truth (e.g. a git repo).
* copy — copies the skill directory (excluding node_modules/, dist/, .git/).
*
* Existing symlinks at the destination are replaced. Existing real directories
* are only replaced when --force is given.
*
* Usage:
* skill-tools --action deploy --skill-dir /abs/path/to/skill [--mode symlink|copy] [--targets dir1,dir2] [--force]
*/
import {
cpSync,
existsSync,
lstatSync,
mkdirSync,
readFileSync,
realpathSync,
rmSync,
symlinkSync,
unlinkSync,
} from "node:fs";
import { homedir } from "node:os";
import { basename, join, resolve } from "node:path";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const DEFAULT_TARGETS = [".agents/skills", ".claude/skills", ".cline/skills"];
const COPY_EXCLUDES = new Set(["node_modules", "dist", ".git"]);
function readSkillName(skillDir: string): string | undefined {
const skillMdPath = join(skillDir, "SKILL.md");
const content = readFileSync(skillMdPath, "utf-8");
const match = content.match(/^---\n[\s\S]*?^name:\s*(\S+)\s*$/m);
return match?.[1];
}
function safeRealpath(path: string): string {
try {
return realpathSync(path);
} catch {
return resolve(path);
}
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
export function run(options: Record<string, string | undefined>): Record<string, unknown> {
const rawSkillDir = options["skill-dir"];
if (!rawSkillDir) {
return {
action: "deploy",
success: false,
error: "Missing required option: --skill-dir",
};
}
const skillDir = resolve(rawSkillDir);
if (!existsSync(skillDir)) {
return {
action: "deploy",
success: false,
error: `Skill directory does not exist: ${skillDir}`,
};
}
if (!existsSync(join(skillDir, "SKILL.md"))) {
return {
action: "deploy",
success: false,
error: `Not a skill directory (missing SKILL.md): ${skillDir}`,
};
}
const mode = options["mode"] ?? "symlink";
if (mode !== "symlink" && mode !== "copy") {
return {
action: "deploy",
success: false,
error: `'mode' must be 'symlink' or 'copy'. Got: '${mode}'`,
};
}
const force = options["force"] === "true";
const skillName = readSkillName(skillDir) ?? basename(skillDir);
const dirName = basename(skillDir);
const warnings: string[] = [];
if (skillName !== dirName) {
warnings.push(
`Frontmatter name '${skillName}' does not match directory name '${dirName}' — deploying as '${skillName}'`
);
}
const targets = (
options["targets"]
? options["targets"].split(",").map((t) => t.trim()).filter(Boolean)
: DEFAULT_TARGETS.map((t) => join(homedir(), t))
).map((t) => resolve(t));
const sourceReal = safeRealpath(skillDir);
const deployments: Record<string, unknown>[] = [];
let success = true;
for (const targetDir of targets) {
const destination = join(targetDir, skillName);
try {
mkdirSync(targetDir, { recursive: true });
let existing: ReturnType<typeof lstatSync> | undefined;
try {
existing = lstatSync(destination);
} catch {
existing = undefined;
}
if (existing && !existing.isSymbolicLink() && safeRealpath(destination) === sourceReal) {
deployments.push({
target: destination,
status: "skipped",
reason: "Destination is the source directory itself",
});
continue;
}
if (existing) {
if (existing.isSymbolicLink()) {
unlinkSync(destination);
} else if (force) {
rmSync(destination, { recursive: true });
} else {
deployments.push({
target: destination,
status: "skipped",
reason: "Destination exists and is not a symlink — pass --force to replace",
});
success = false;
continue;
}
}
if (mode === "symlink") {
symlinkSync(skillDir, destination);
} else {
cpSync(skillDir, destination, {
recursive: true,
filter: (src) => !COPY_EXCLUDES.has(basename(src)),
});
}
deployments.push({ target: destination, status: "deployed", mode });
} catch (err) {
deployments.push({
target: destination,
status: "failed",
reason: err instanceof Error ? err.message : String(err),
});
success = false;
}
}
const result: Record<string, unknown> = {
action: "deploy",
success,
"skill-name": skillName,
"skill-dir": skillDir,
mode,
deployments,
};
if (warnings.length > 0) {
result["warnings"] = warnings;
}
return result;
}
+171
View File
@@ -0,0 +1,171 @@
/**
* Action: scaffold
*
* Scaffolds a new skill directory following the agentskills.io structure.
*
* Creates:
* <output-dir>/<skill-name>/
* ├── SKILL.md — populated from built-in template
* ├── references/ — created empty (ready for docs)
* └── assets/ — created empty (ready for templates)
*
* The SKILL.md is pre-filled with the standard frontmatter template:
* - name set to <skill-name>
* - description placeholder with imperative phrasing hint
* - license, metadata, compatibility stubs
*
* Usage:
* skill-tools --action scaffold --skill-name my-skill [--output-dir /path]
*/
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
import { join, resolve } from "node:path";
// ---------------------------------------------------------------------------
// SKILL.md template
// ---------------------------------------------------------------------------
function buildSkillMd(skillName: string): string {
return `---
name: ${skillName}
description: >
[What the skill does — be specific about capabilities].
Use this skill when [specific trigger conditions — user intent, task type, domain],
even if the user doesn't explicitly mention [domain keywords].
license: Proprietary
metadata:
author: workspace-swiss-knife
version: "1.0"
# compatibility: Designed for Cline, Claude Code, GitHub Copilot, OpenAI Codex, and other compatible agents (uncomment if needed)
# allowed-tools: Bash Read Write (uncomment if needed)
---
# [Skill Title]
[One-sentence summary of what this skill does and for whom.]
## Overview
[2–3 sentences of context. What problem does this skill solve? What domain does it operate in?]
## Prerequisites
[List any tools, packages, env vars, or conditions required. Remove section if none.]
- Requires: \`tool-name\`
- Environment: \`ENV_VAR=value\`
## Workflow
[Step-by-step instructions. Be prescriptive for fragile steps; flexible for steps with valid alternatives.]
1. [Step one]
2. [Step two]
3. [Step three]
## Gotchas
[Non-obvious facts the agent will get wrong without being told. This is the highest-value section.
Delete this section if you have no gotchas yet — add them as you discover them.]
- [Specific fact that defies reasonable assumptions]
- [Another non-obvious environment-specific detail]
## References
[Tell the agent when to load each reference file. Use conditional loading, not generic "see references/".
Delete this section if no references/ directory.]
- Read \`references/api-errors.md\` if the API returns a non-200 status code.
- Read \`references/schema.md\` before writing any database queries.
`;
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
export function run(options: Record<string, string | undefined>): Record<string, unknown> {
const skillName = options["skill-name"];
if (!skillName) {
return {
action: "scaffold",
success: false,
error: "Missing required option: --skill-name",
};
}
// Validate skill name
if (/[^a-z0-9-]/.test(skillName)) {
return {
action: "scaffold",
success: false,
error: `'skill-name' must use only a-z, 0-9, hyphens. Got: '${skillName}'`,
};
}
if (skillName.startsWith("-") || skillName.endsWith("-")) {
return {
action: "scaffold",
success: false,
error: `'skill-name' must not start or end with a hyphen. Got: '${skillName}'`,
};
}
if (/--/.test(skillName)) {
return {
action: "scaffold",
success: false,
error: `'skill-name' must not contain consecutive hyphens. Got: '${skillName}'`,
};
}
if (skillName.length > 64) {
return {
action: "scaffold",
success: false,
error: `'skill-name' must be ≤ 64 characters. Got: ${skillName.length}`,
};
}
// Resolve output directory
const rawOutputDir = options["output-dir"] ?? join(process.cwd(), "..", "..", ".agents", "skills");
const outputDir = resolve(rawOutputDir);
const skillDir = join(outputDir, skillName);
if (existsSync(skillDir)) {
return {
action: "scaffold",
success: false,
error: `Directory already exists: ${skillDir}`,
};
}
// Create directories
const created: string[] = [];
mkdirSync(skillDir, { recursive: true });
created.push(skillDir);
const refsDir = join(skillDir, "references");
const assetsDir = join(skillDir, "assets");
mkdirSync(refsDir);
created.push(refsDir);
mkdirSync(assetsDir);
created.push(assetsDir);
// Write SKILL.md
const skillMdPath = join(skillDir, "SKILL.md");
writeFileSync(skillMdPath, buildSkillMd(skillName), "utf-8");
created.push(skillMdPath);
return {
action: "scaffold",
success: true,
"skill-name": skillName,
"skill-dir": skillDir,
created,
"next-steps": [
`Edit ${skillMdPath} — fill in description, title, overview, workflow`,
`Run validation: skill-tools --action validate --skill-dir ${skillDir}`,
"Add reference docs to references/ as the skill grows",
"Add templates/data files to assets/ if needed",
],
};
}
+287
View File
@@ -0,0 +1,287 @@
/**
* Action: validate
*
* Validates a skill directory against the agentskills.io specification.
*
* Checks performed:
* 1. SKILL.md exists
* 2. YAML frontmatter delimiters (--- ... ---) are present
* 3. 'name' field is present in frontmatter
* 4. 'name' matches the directory name (basename)
* 5. 'name' length ≤ 64 characters
* 6. 'name' uses only a-z, 0-9, hyphens
* 7. 'name' does not start with a hyphen
* 8. 'name' does not end with a hyphen
* 9. 'name' has no consecutive hyphens
* 10. 'description' field is present and non-empty
* 11. 'description' length ≤ 1024 characters
* 12. SKILL.md body (lines after frontmatter) ≤ 500 lines (recommendation)
* 13. If scripts/ exists, Taskfile.yml must be present inside it
*
* Output: YAML with success flag, counts, and per-check detail list.
*/
import { existsSync, readFileSync, readdirSync } from "node:fs";
import { join, basename } from "node:path";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface CheckResult {
id: number;
pass: boolean;
level: "error" | "warning" | "info";
message: string;
}
interface DirectoryInfo {
found: boolean;
files?: number;
"Taskfile.yml"?: boolean;
}
interface ValidateOutput {
action: "validate";
"skill-dir": string;
success: boolean;
summary: {
passed: number;
failed: number;
warnings: number;
};
checks: CheckResult[];
optional: {
"scripts/": DirectoryInfo;
"references/": DirectoryInfo;
"assets/": DirectoryInfo;
};
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function countFilesSync(dir: string): number {
try {
const entries = readdirSync(dir, { withFileTypes: true });
let count = 0;
for (const e of entries) {
if (e.isDirectory()) {
count += countFilesSync(join(dir, e.name));
} else {
count++;
}
}
return count;
} catch {
return 0;
}
}
// ---------------------------------------------------------------------------
// Frontmatter parser — handles scalar and > block values
// ---------------------------------------------------------------------------
function parseFrontmatter(raw: string): Record<string, string> {
const result: Record<string, string> = {};
const lines = raw.split("\n");
let i = 0;
while (i < lines.length) {
const line = lines[i];
const keyMatch = line.match(/^([a-zA-Z_-]+)\s*:\s*(.*)/);
if (keyMatch) {
const key = keyMatch[1];
const val = keyMatch[2].trim();
if (val === ">" || val === "|") {
// Multi-line block scalar — collect indented continuation lines
const parts: string[] = [];
i++;
while (
i < lines.length &&
(lines[i].startsWith(" ") || lines[i].startsWith("\t") || lines[i].trim() === "")
) {
parts.push(lines[i].trim());
i++;
}
result[key] = parts.join(" ").trim();
continue;
} else {
result[key] = val;
}
}
i++;
}
return result;
}
// ---------------------------------------------------------------------------
// Optional directories inspector
// ---------------------------------------------------------------------------
function inspectOptional(skillDir: string): ValidateOutput["optional"] {
function dirInfo(subdir: string): DirectoryInfo {
const p = join(skillDir, subdir);
if (!existsSync(p)) return { found: false };
return { found: true, files: countFilesSync(p) };
}
const scriptsDir = join(skillDir, "scripts");
const scriptsInfo = dirInfo("scripts");
if (scriptsInfo.found) {
scriptsInfo["Taskfile.yml"] = existsSync(join(scriptsDir, "Taskfile.yml"));
}
return {
"scripts/": scriptsInfo,
"references/": dirInfo("references"),
"assets/": dirInfo("assets"),
};
}
// ---------------------------------------------------------------------------
// Output builder
// ---------------------------------------------------------------------------
function buildOutput(
skillDir: string,
checks: CheckResult[],
optional: ValidateOutput["optional"]
): Record<string, unknown> {
const passed = checks.filter((c) => c.pass).length;
const failed = checks.filter((c) => !c.pass && c.level === "error").length;
const warnings = checks.filter((c) => !c.pass && c.level === "warning").length;
return {
action: "validate",
"skill-dir": skillDir,
success: failed === 0,
summary: { passed, failed, warnings },
checks,
optional,
};
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
export function run(options: Record<string, string | undefined>): Record<string, unknown> {
const skillDir = options["skill-dir"];
if (!skillDir) {
return {
action: "validate",
success: false,
error: "Missing required option: --skill-dir",
};
}
const checks: CheckResult[] = [];
let checkId = 1;
const ok = (message: string, level: CheckResult["level"] = "error"): CheckResult =>
({ id: checkId++, pass: true, level, message });
const err = (message: string, level: CheckResult["level"] = "error"): CheckResult =>
({ id: checkId++, pass: false, level, message });
// ── Check 1: SKILL.md exists ─────────────────────────────────────────────
const skillMdPath = join(skillDir, "SKILL.md");
if (!existsSync(skillMdPath)) {
checks.push(err("SKILL.md does not exist in skill directory"));
return buildOutput(skillDir, checks, inspectOptional(skillDir));
}
checks.push(ok("SKILL.md exists"));
const raw = readFileSync(skillMdPath, "utf-8");
// ── Check 2: Frontmatter delimiters ──────────────────────────────────────
const fmMatch = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/);
if (!fmMatch) {
checks.push(err("YAML frontmatter delimiters (--- ... ---) not found"));
return buildOutput(skillDir, checks, inspectOptional(skillDir));
}
checks.push(ok("YAML frontmatter delimiters found"));
const fm = parseFrontmatter(fmMatch[1]);
const body = fmMatch[2];
// ── Checks 3–9: name field ───────────────────────────────────────────────
const name = fm["name"];
if (!name) {
checks.push(err("'name' field is missing from frontmatter"));
} else {
checks.push(ok(`'name' field is present: ${name}`));
const dirName = basename(skillDir);
checks.push(
name === dirName
? ok("'name' matches directory name")
: err(`'name' (${name}) does not match directory name (${dirName})`)
);
checks.push(
name.length <= 64
? ok(`'name' length is ${name.length} (max 64)`)
: err(`'name' length is ${name.length} (max 64)`)
);
checks.push(
/[^a-z0-9-]/.test(name)
? err("'name' contains characters outside a-z, 0-9, hyphen")
: ok("'name' uses only allowed characters (a-z, 0-9, -)")
);
checks.push(
name.startsWith("-")
? err("'name' must not start with a hyphen")
: ok("'name' does not start with a hyphen")
);
checks.push(
name.endsWith("-")
? err("'name' must not end with a hyphen")
: ok("'name' does not end with a hyphen")
);
checks.push(
/--/.test(name)
? err("'name' contains consecutive hyphens")
: ok("'name' has no consecutive hyphens")
);
}
// ── Checks 10–11: description field ──────────────────────────────────────
const description = fm["description"];
if (!description || description.trim() === "") {
checks.push(err("'description' field is missing or empty"));
} else {
const descLen = description.trim().length;
checks.push(ok(`'description' is present (${descLen} chars)`));
checks.push(
descLen <= 1024
? ok(`'description' length is ${descLen} (max 1024)`)
: err(`'description' length is ${descLen} (max 1024)`)
);
}
// ── Check 12: Body line count ─────────────────────────────────────────────
const bodyLines = body.split("\n").length;
checks.push(
bodyLines <= 500
? ok(`SKILL.md body is ${bodyLines} lines (max 500 recommended)`)
: err(`SKILL.md body is ${bodyLines} lines (max 500 recommended)`, "warning")
);
// ── Check 13: scripts/ must have Taskfile.yml ─────────────────────────────
const scriptsDir = join(skillDir, "scripts");
if (existsSync(scriptsDir)) {
checks.push(
existsSync(join(scriptsDir, "Taskfile.yml"))
? ok("scripts/Taskfile.yml exists")
: err("scripts/ exists but Taskfile.yml is missing — it is required")
);
}
return buildOutput(skillDir, checks, inspectOptional(skillDir));
}
+138
View File
@@ -0,0 +1,138 @@
#!/usr/bin/env node
/**
* skill-tools CLI dispatcher
*
* Usage:
* skill-tools --action <action-name> [options]
*
* Actions:
* validate — validate a skill directory against the agentskills.io spec
* scaffold — scaffold a new skill directory from the built-in template
* deploy — deploy a skill into $HOME/.agents/skills, $HOME/.claude/skills, $HOME/.cline/skills
*
* Output is always YAML to stdout. Exit 0 on success, 1 on error.
*/
import { parseArgs } from "node:util";
import * as yaml from "js-yaml";
// ---------------------------------------------------------------------------
// Action registry
// ---------------------------------------------------------------------------
type ActionModule = {
run: (options: Record<string, string | undefined>) => Record<string, unknown>;
};
const ACTIONS: Record<string, () => Promise<ActionModule>> = {
validate: () => import("../actions/validate/action.js"),
scaffold: () => import("../actions/scaffold/action.js"),
deploy: () => import("../actions/deploy/action.js"),
};
// ---------------------------------------------------------------------------
// CLI
// ---------------------------------------------------------------------------
async function main(): Promise<void> {
let values: {
action?: string;
"skill-dir"?: string;
"skill-name"?: string;
"output-dir"?: string;
mode?: string;
targets?: string;
force?: boolean;
help?: boolean;
};
try {
({ values } = parseArgs({
args: process.argv.slice(2),
options: {
action: { type: "string", short: "a" },
"skill-dir": { type: "string" },
"skill-name": { type: "string" },
"output-dir": { type: "string" },
mode: { type: "string" },
targets: { type: "string" },
force: { type: "boolean", short: "f" },
help: { type: "boolean", short: "h" },
},
strict: true,
}));
} catch (err) {
printError(String(err));
process.exit(1);
}
if (values.help || !values.action) {
console.log(`skill-tools --action <action> [options]
Actions:
validate Validate a skill directory against the agentskills.io spec
--skill-dir <path> Path to the skill root directory (required)
scaffold Scaffold a new skill directory from the built-in template
--skill-name <name> Skill name (lowercase, hyphens only, required)
--output-dir <path> Parent directory to create the skill in (default: .agents/skills)
deploy Deploy a skill into the agent skill directories (absolute paths)
--skill-dir <path> Path to the skill root directory (required)
--mode <mode> symlink (default) or copy
--targets <dirs> Comma-separated target directories
(default: $HOME/.agents/skills,$HOME/.claude/skills,$HOME/.cline/skills)
--force Replace an existing real directory at the destination
Examples:
skill-tools --action validate --skill-dir /path/to/my-skill
skill-tools --action scaffold --skill-name my-skill --output-dir /path/to/.agents/skills
skill-tools --action deploy --skill-dir /path/to/my-skill
skill-tools --action deploy --skill-dir /path/to/my-skill --mode copy --force
`);
process.exit(values.help ? 0 : 1);
}
const actionName = values.action;
if (!ACTIONS[actionName]) {
printError(
`Unknown action '${actionName}'. Available: ${Object.keys(ACTIONS).join(", ")}`
);
process.exit(1);
}
try {
const mod = await ACTIONS[actionName]();
const result = mod.run({
"skill-dir": values["skill-dir"],
"skill-name": values["skill-name"],
"output-dir": values["output-dir"],
mode: values.mode,
targets: values.targets,
force: values.force ? "true" : undefined,
});
process.stdout.write(
yaml.dump(result, { noRefs: true, sortKeys: false, lineWidth: 120 })
);
const success = (result["success"] as boolean | undefined) ?? true;
process.exit(success ? 0 : 1);
} catch (err) {
const errorOutput = {
success: false,
action: actionName,
error: err instanceof Error ? err.message : String(err),
};
process.stdout.write(yaml.dump(errorOutput, { noRefs: true, sortKeys: false }));
process.exit(1);
}
}
function printError(msg: string): void {
process.stderr.write(`Error: ${msg}\n`);
process.stderr.write(
`Usage: skill-tools --action <${Object.keys(ACTIONS).join("|")}> [options]\n`
);
}
main();
+17
View File
@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}