Files
ai-proj-helper/install-skills.sh
johnQ d43b414fc9 feat(skill): 技能治理 v0.2 —— 分桶/调用模式/校验/安全/清单/安装矩阵
- 新增 SCOPE.md 收录门槛 + .out-of-scope/ 拒绝档案
- 生命周期分桶 promoted/beta/deprecated,marketplace 只发布 promoted
- 调用模式 model-invoked/user-invoked(user-invoked 不占模型目录)
- 新增脚本 validate-skills.py / generate-catalog.py / scan-skill.sh(+allowlist)
- 新增 beta 技能 skill-creator、ai-proj-helper-entry、verification-before-completion(各带 evals)
- install-skills.sh: --agent dsh / --link / --audit / --include-beta / --no-validate
- 融合:dev-review 接收评审意见、req-design 依赖边、agent-swarm 并行编排、dev-test TDD
- catalog.json 61 个技能(58 promoted / 3 beta);validate 0 错误;scan 0 高危;现有测试通过
2026-10-31 22:03:13 +08:00

547 lines
18 KiB
Bash
Executable File

#!/usr/bin/env bash
# install-skills.sh — Cross-agent skill sync from ai-proj-helper
#
# Usage:
# ./install-skills.sh [options]
#
# Options:
# --agent <agent> Install target: codex (default), dsh, or claude
# --link Symlink skills from the repo instead of copying (dev machines)
# --audit Show installed vs repo versions for the selected target
# --include-beta Also install skills whose plugin.json status=beta
# --no-validate Skip scripts/validate-skills.py before installing
# --dry-run Preview changes without writing anything
# --category <cat> Only install plugins in dir_category=<cat>
# Valid values: biz, core, dev, integration, personal, req
# --exclude <name> Skip one install_name (repeatable)
# --force Overwrite even if local files were modified
# --cleanup Remove locally installed skills that are no longer in repo
# --list List all available plugins without installing
# --help Show this help
set -euo pipefail
REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SKILLS_DIR=""
COMMANDS_DIR=""
STATE_FILE=""
AGENT_TARGET="codex"
DRY_RUN=false
CATEGORY_FILTER=""
EXCLUDED_NAMES=()
FORCE=false
CLEANUP=false
LIST_ONLY=false
INSTALL_ACTION=false
LINK_MODE=false
AUDIT_ONLY=false
INCLUDE_BETA=false
NO_VALIDATE=false
# ── Colour helpers ─────────────────────────────────────────────────────────────
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
RED='\033[0;31m'
BLUE='\033[0;34m'
RESET='\033[0m'
info() { echo -e "${BLUE}[info]${RESET} $*"; }
ok() { echo -e "${GREEN}[ok]${RESET} $*"; }
warn() { echo -e "${YELLOW}[warn]${RESET} $*"; }
error() { echo -e "${RED}[error]${RESET} $*" >&2; }
dry() { echo -e "${YELLOW}[dry]${RESET} $*"; }
# ── Argument parsing ───────────────────────────────────────────────────────────
while [[ $# -gt 0 ]]; do
case "$1" in
--agent)
[[ $# -ge 2 ]] || { error "--agent requires codex, dsh or claude"; exit 1; }
AGENT_TARGET="$2"; shift ;;
--dry-run) DRY_RUN=true ;;
--link) LINK_MODE=true ;;
--audit) AUDIT_ONLY=true ;;
--include-beta) INCLUDE_BETA=true ;;
--no-validate) NO_VALIDATE=true ;;
--force) FORCE=true ;;
--cleanup) CLEANUP=true ;;
--list) LIST_ONLY=true ;;
--category)
[[ $# -ge 2 ]] || { error "--category requires a value"; exit 1; }
CATEGORY_FILTER="$2"; shift ;;
--exclude)
[[ $# -ge 2 ]] || { error "--exclude requires an install_name"; exit 1; }
EXCLUDED_NAMES+=("$2"); shift ;;
--help|-h)
grep '^#' "$0" | grep -v '!/usr' | sed 's/^# \?//'
exit 0 ;;
*)
error "Unknown argument: $1"
exit 1 ;;
esac
shift
done
case "$AGENT_TARGET" in
codex)
# ~/.agents/skills is the current user-level Codex discovery location and
# is intentionally agent-neutral. Commands are installed as normal skills.
SKILLS_DIR="${AI_PROJ_HELPER_SKILLS_DIR:-${HOME}/.agents/skills}"
STATE_FILE="${AI_PROJ_HELPER_STATE_FILE:-${HOME}/.agents/.ai-proj-helper-installed-skills.json}"
;;
dsh)
if [[ -z "${DSH_HOME:-}" ]]; then
error "--agent dsh requires DSH_HOME (or set AI_PROJ_HELPER_SKILLS_DIR explicitly)"
exit 1
fi
SKILLS_DIR="${AI_PROJ_HELPER_SKILLS_DIR:-${DSH_HOME}/skills}"
STATE_FILE="${AI_PROJ_HELPER_STATE_FILE:-${DSH_HOME}/.ai-proj-helper-installed-skills.json}"
;;
claude)
SKILLS_DIR="${AI_PROJ_HELPER_SKILLS_DIR:-${HOME}/.claude/skills}"
COMMANDS_DIR="${AI_PROJ_HELPER_COMMANDS_DIR:-${HOME}/.claude/commands}"
STATE_FILE="${AI_PROJ_HELPER_STATE_FILE:-${HOME}/.claude/.installed-skills.json}"
;;
*)
error "Unsupported agent: $AGENT_TARGET (expected codex, dsh or claude)"
exit 1
;;
esac
# ── State helpers (plain JSON via python3) ─────────────────────────────────────
state_get() {
# state_get <install_name> -> prints version or empty string
local name="$1"
if [[ -f "$STATE_FILE" ]]; then
python3 -c "
import json,sys
try:
d=json.load(open('$STATE_FILE'))
print(d.get('$name',{}).get('version',''))
except: pass
" 2>/dev/null || true
fi
}
state_digest() {
# state_digest <install_name> -> prints installed content digest or empty string
local name="$1"
if [[ -f "$STATE_FILE" ]]; then
python3 -c "
import json
try:
d=json.load(open('$STATE_FILE'))
print(d.get('$name',{}).get('content_digest',''))
except: pass
" 2>/dev/null || true
fi
}
state_set() {
# state_set <install_name> <version> <install_type> <content_digest>
local name="$1" ver="$2" itype="$3" digest="$4"
python3 -c "
import json,os
f='$STATE_FILE'
d=json.load(open(f)) if os.path.exists(f) else {}
d['$name']={'version':'$ver','install_type':'$itype','content_digest':'$digest','agent':'$AGENT_TARGET'}
json.dump(d,open(f,'w'),indent=2)
" 2>/dev/null
}
state_remove() {
local name="$1"
python3 -c "
import json,os
f='$STATE_FILE'
if not os.path.exists(f): exit()
d=json.load(open(f))
d.pop('$name',None)
json.dump(d,open(f,'w'),indent=2)
" 2>/dev/null
}
state_all_names() {
if [[ -f "$STATE_FILE" ]]; then
python3 -c "
import json
d=json.load(open('$STATE_FILE'))
for k in d: print(k)
" 2>/dev/null || true
fi
}
# ── Plugin discovery ───────────────────────────────────────────────────────────
find_plugins() {
find "$REPO_DIR" -path "*/skills-*/*-plugin/.claude-plugin/plugin.json" | sort
}
read_field() {
# read_field <json_file> <field>
python3 -c "import json,sys; d=json.load(open('$1')); print(d.get('$2',''))" 2>/dev/null || true
}
content_digest() {
# Stable digest for one command file or a complete skill directory.
python3 - "$1" <<'PY'
import hashlib
import os
import pathlib
import sys
target = pathlib.Path(sys.argv[1])
if not target.exists():
print("")
raise SystemExit
digest = hashlib.sha256()
files = [target] if target.is_file() else sorted(
path for path in target.rglob("*") if path.is_file() or path.is_symlink()
)
for path in files:
# A single-file command is renamed when installed for Claude. Hash its
# content under a stable logical name so source and target compare equally.
relative = "." if target.is_file() else path.relative_to(target).as_posix()
digest.update(relative.encode("utf-8"))
digest.update(b"\0")
if path.is_symlink():
digest.update(b"link\0")
digest.update(os.readlink(path).encode("utf-8"))
else:
digest.update(path.read_bytes())
digest.update(b"\0")
print(digest.hexdigest())
PY
}
is_compatible_subset() {
# True when every file in an existing legacy target also exists unchanged in
# the repository source. This safely upgrades old SKILL.md-only installs.
python3 - "$1" "$2" <<'PY'
import pathlib
import sys
source = pathlib.Path(sys.argv[1])
target = pathlib.Path(sys.argv[2])
if not source.is_dir() or not target.is_dir():
raise SystemExit(1)
for target_path in target.rglob("*"):
if target_path.is_dir():
continue
source_path = source / target_path.relative_to(target)
if not source_path.is_file() or target_path.is_symlink() != source_path.is_symlink():
raise SystemExit(1)
if target_path.is_symlink():
if target_path.readlink() != source_path.readlink():
raise SystemExit(1)
elif target_path.read_bytes() != source_path.read_bytes():
raise SystemExit(1)
raise SystemExit(0)
PY
}
# Resolve the actual source directory to rsync from.
# If skills/ has SKILL.md at the top level, use it directly.
# If skills/ has a single subdirectory (e.g. skills/dev-test/SKILL.md), use that subdirectory.
resolve_skills_src() {
local skills_dir="$1"
if [[ -f "$skills_dir/SKILL.md" ]]; then
echo "$skills_dir"
return
fi
# Find the first subdirectory that contains SKILL.md
local sub
sub="$(find "$skills_dir" -maxdepth 2 -name 'SKILL.md' | head -1)"
if [[ -n "$sub" ]]; then
echo "$(dirname "$sub")"
return
fi
echo "$skills_dir"
}
# ── Install a single plugin ────────────────────────────────────────────────────
install_plugin() {
INSTALL_ACTION=false
local json_path="$1"
local plugin_dir
plugin_dir="$(dirname "$(dirname "$json_path")")" # strip /.claude-plugin/plugin.json
local skills_dir="$plugin_dir/skills"
local install_name install_type dir_category version
install_name="$(read_field "$json_path" install_name)"
install_type="$(read_field "$json_path" install_type)"
dir_category="$(read_field "$json_path" dir_category)"
version="$(read_field "$json_path" version)"
local excluded
for excluded in ${EXCLUDED_NAMES[@]+"${EXCLUDED_NAMES[@]}"}; do
[[ "$install_name" == "$excluded" ]] && return
done
# Skip if no install metadata (legacy plugin without our new fields)
if [[ -z "$install_name" || -z "$install_type" ]]; then
warn "$(basename "$plugin_dir"): missing install_name/install_type, skipping"
return
fi
# Category filter
if [[ -n "$CATEGORY_FILTER" && "$dir_category" != "$CATEGORY_FILTER" ]]; then
return
fi
# Verify skills directory exists
if [[ ! -d "$skills_dir" ]]; then
warn "$install_name: no skills/ directory in plugin, skipping"
return
fi
local effective_install_type="$install_type"
if [[ "$AGENT_TARGET" == "codex" ]]; then
effective_install_type="skill"
fi
local status
status="$(read_field "$json_path" status)"
status="${status:-promoted}"
if [[ "$LIST_ONLY" == true ]]; then
echo " [$dir_category] $status $effective_install_type:$install_name v$version"
return
fi
if [[ "$status" == "deprecated" ]]; then
return
fi
if [[ "$status" == "beta" && "$INCLUDE_BETA" != true ]]; then
return
fi
# Resolve actual source (handles plugins where content sits one level deeper,
# e.g. skills/dev-test/SKILL.md instead of skills/SKILL.md).
local src_dir
src_dir="$(resolve_skills_src "$skills_dir")"
local source_path target_path
if [[ "$effective_install_type" == "command" ]]; then
source_path="$src_dir/SKILL.md"
target_path="$COMMANDS_DIR/${install_name}.md"
else
source_path="$src_dir"
target_path="$SKILLS_DIR/$install_name"
fi
if [[ ! -e "$source_path" ]]; then
warn "$install_name: install source not found, skipping"
return
fi
# Dev machines may symlink instead of copying: repository edits apply at once.
if [[ "$LINK_MODE" == true && "$effective_install_type" != "command" ]]; then
if [[ "$DRY_RUN" == true ]]; then
dry "$install_name → symlink $target_path → $src_dir"
else
mkdir -p "$SKILLS_DIR"
rm -rf "$target_path"
ln -sfn "$src_dir" "$target_path"
state_set "$install_name" "$version" "symlink" "$(content_digest "$src_dir")"
ok "$install_name → symlink (v$version)"
fi
INSTALL_ACTION=true
return
fi
# A recorded content digest distinguishes repository updates from user edits.
# Legacy state is adopted automatically only when the target is missing or
# already identical to the repository source.
local current_version recorded_digest source_digest target_digest
current_version="$(state_get "$install_name")"
recorded_digest="$(state_digest "$install_name")"
source_digest="$(content_digest "$source_path")"
target_digest="$(content_digest "$target_path")"
if [[ "$FORCE" == false && -n "$target_digest" && "$target_digest" == "$source_digest" ]]; then
if [[ "$DRY_RUN" == false && ( "$current_version" != "$version" || "$recorded_digest" != "$source_digest" ) ]]; then
state_set "$install_name" "$version" "$effective_install_type" "$source_digest"
fi
return
fi
local legacy_subset=false
if [[ "$effective_install_type" == "skill" && -z "$recorded_digest" && -n "$target_digest" ]]; then
if is_compatible_subset "$source_path" "$target_path"; then
legacy_subset=true
fi
fi
if [[ "$FORCE" == false && -n "$target_digest" && "$legacy_subset" == false ]]; then
if [[ -z "$recorded_digest" || "$target_digest" != "$recorded_digest" ]]; then
warn "$install_name: local files were modified or have legacy unverified state — skipping (use --force once to adopt repository content)"
return
fi
fi
# Perform install
if [[ "$effective_install_type" == "command" ]]; then
# Single-file command → ~/.claude/commands/<name>.md
local src_md="$src_dir/SKILL.md"
if [[ ! -f "$src_md" ]]; then
warn "$install_name: SKILL.md not found, skipping"
return
fi
if [[ "$DRY_RUN" == true ]]; then
dry "$install_name → $COMMANDS_DIR/${install_name}.md"
INSTALL_ACTION=true
else
mkdir -p "$COMMANDS_DIR"
cp "$src_md" "$COMMANDS_DIR/${install_name}.md"
state_set "$install_name" "$version" "$effective_install_type" "$source_digest"
ok "$install_name → command (v$version)"
INSTALL_ACTION=true
fi
else
# Standard skill directory → the selected agent's discovery root.
local dst_dir="$SKILLS_DIR/$install_name"
if [[ "$DRY_RUN" == true ]]; then
dry "$install_name → $dst_dir/"
INSTALL_ACTION=true
else
mkdir -p "$dst_dir"
# rsync resolved source (handles nested skills/ structures)
rsync -a --checksum --delete "$src_dir/" "$dst_dir/"
state_set "$install_name" "$version" "$effective_install_type" "$source_digest"
ok "$install_name → skill (v$version)"
INSTALL_ACTION=true
fi
fi
}
# ── Cleanup removed plugins ────────────────────────────────────────────────────
cleanup_removed() {
if [[ "$CLEANUP" == false ]]; then
return
fi
info "Checking for removed plugins to clean up..."
# Collect all install_names still in repo
local repo_names=()
while IFS= read -r json_path; do
local name
name="$(read_field "$json_path" install_name)"
[[ -n "$name" ]] && repo_names+=("$name")
done < <(find_plugins)
# Check state file for installed plugins no longer in repo
while IFS= read -r installed_name; do
local found=false
for repo_name in "${repo_names[@]}"; do
[[ "$repo_name" == "$installed_name" ]] && found=true && break
done
if [[ "$found" == false ]]; then
local itype
itype="$(python3 -c "import json; d=json.load(open('$STATE_FILE')); print(d.get('$installed_name',{}).get('install_type',''))" 2>/dev/null || true)"
if [[ "$DRY_RUN" == true ]]; then
dry "Would remove: $installed_name ($itype)"
else
if [[ "$itype" == "command" ]]; then
rm -f "$COMMANDS_DIR/${installed_name}.md"
else
rm -rf "${SKILLS_DIR:?}/$installed_name"
fi
state_remove "$installed_name"
ok "Removed: $installed_name"
fi
fi
done < <(state_all_names)
}
# ── Audit ──────────────────────────────────────────────────────────────────────
audit_target() {
python3 - "$REPO_DIR" "$STATE_FILE" "$AGENT_TARGET" "$SKILLS_DIR" <<'PYEOF'
import json, sys, pathlib
repo, state_file, agent, skills_dir = sys.argv[1:5]
root = pathlib.Path(repo)
state = {}
if pathlib.Path(state_file).is_file():
try: state = json.loads(pathlib.Path(state_file).read_text())
except Exception: state = {}
rows = []
for pj in sorted(root.glob('skills-*/*-plugin/.claude-plugin/plugin.json')):
try: meta = json.loads(pj.read_text())
except Exception: continue
name = meta.get('install_name')
if not name: continue
status = meta.get('status', 'promoted')
want = meta.get('version', '')
got = state.get(name, {}).get('version', '')
itype = state.get(name, {}).get('install_type', '')
drift = '' if got == want else ('missing' if not got else 'stale')
rows.append((name, status, want, got or '-', itype or '-', drift))
drift_rows = [r for r in rows if r[5]]
print(f'audit agent={agent} skills_dir={skills_dir}')
print(f'{"name":34} {"status":10} {"repo":8} {"installed":9} {"mode":8} drift')
for r in rows:
print(f'{r[0]:34} {r[1]:10} {r[2]:8} {r[3]:9} {r[4]:8} {r[5]}')
print(f'\n{len(rows)} 个技能,{len(drift_rows)} 个漂移(未安装/版本落后)')
raise SystemExit(1 if drift_rows else 0)
PYEOF
}
# ── Main ───────────────────────────────────────────────────────────────────────
main() {
if [[ "$LIST_ONLY" == true ]]; then
info "Available plugins in $REPO_DIR:"
local count=0
while IFS= read -r json_path; do
install_plugin "$json_path"
((count++)) || true
done < <(find_plugins)
echo ""
info "Total: $count plugins"
return
fi
if [[ "$AUDIT_ONLY" == true ]]; then
audit_target
return
fi
info "Installing skills for $AGENT_TARGET from: $REPO_DIR"
if [[ "$NO_VALIDATE" != true && -f "$REPO_DIR/scripts/validate-skills.py" ]]; then
if ! python3 "$REPO_DIR/scripts/validate-skills.py" >/tmp/ai-proj-helper-validate.log 2>&1; then
error "validate-skills.py 未通过,修复后再装(或加 --no-validate 跳过)"
tail -20 /tmp/ai-proj-helper-validate.log >&2 || true
exit 1
fi
fi
[[ "$DRY_RUN" == true ]] && warn "DRY RUN — no files will be written"
[[ -n "$CATEGORY_FILTER" ]] && info "Category filter: $CATEGORY_FILTER"
local installed=0
while IFS= read -r json_path; do
install_plugin "$json_path"
if [[ "$INSTALL_ACTION" == true ]]; then
((installed++)) || true
fi
done < <(find_plugins)
cleanup_removed
echo ""
if [[ "$DRY_RUN" == true ]]; then
info "Dry run complete. $installed plugins would be installed/updated."
else
info "Done. $installed plugins installed/updated."
info "State saved to: $STATE_FILE"
fi
}
main "$@"