mirror of
https://github.com/warpdotdev/common-skills.git
synced 2026-09-14 19:03:05 +08:00
a50481aebb
Co-authored-by: Oz <oz-agent@warp.dev>
984 lines
27 KiB
Bash
Executable File
984 lines
27 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
|
|
set -eo pipefail
|
|
|
|
REPO_ROOT="${WARP_COMMON_SKILLS_TARGET_REPO_ROOT:-$(pwd)}"
|
|
LOCK_FILE=""
|
|
STAMP_RELATIVE_PATH="warp/common-skills-lock.hash"
|
|
SKILLS_CLI_VERSION="1.5.6"
|
|
COMMON_SKILLS_SOURCE="warpdotdev/common-skills"
|
|
COMMON_SKILLS_REF="${WARP_COMMON_SKILLS_REF:-}"
|
|
COMMON_SKILLS_REQUEST_SOURCE="${COMMON_SKILLS_SOURCE}"
|
|
SKILLS_CLI_AGENT="warp"
|
|
COMMON_SKILL_SELECTOR="*"
|
|
|
|
IF_NEEDED=0
|
|
FORCE=0
|
|
QUIET=0
|
|
PROMPT_FOR_TARGET=0
|
|
NON_INTERACTIVE=0
|
|
VERIFY_ONLY=0
|
|
TARGET="${WARP_COMMON_SKILLS_INSTALL_TARGET:-}"
|
|
RESOLVED_TARGET=""
|
|
ALLOW_GLOBAL_LOCK_UPDATE=0
|
|
UPDATED_LOCK_CANDIDATE=""
|
|
|
|
if [[ -n "${COMMON_SKILLS_REF}" ]]; then
|
|
COMMON_SKILLS_REQUEST_SOURCE="${COMMON_SKILLS_SOURCE}#${COMMON_SKILLS_REF}"
|
|
fi
|
|
|
|
resolve_repo_root() {
|
|
cd "$1" && pwd
|
|
}
|
|
|
|
lock_hash() {
|
|
git -C "${REPO_ROOT}" hash-object "${LOCK_FILE}"
|
|
}
|
|
|
|
project_stamp_file() {
|
|
local git_path=""
|
|
|
|
if git_path="$(git -C "${REPO_ROOT}" rev-parse --git-path "${STAMP_RELATIVE_PATH}" 2>/dev/null)"; then
|
|
case "${git_path}" in
|
|
/*)
|
|
echo "${git_path}"
|
|
;;
|
|
*)
|
|
echo "${REPO_ROOT}/${git_path}"
|
|
;;
|
|
esac
|
|
return
|
|
fi
|
|
|
|
echo "${REPO_ROOT}/.agents/skills/.common-skills-lock.hash"
|
|
}
|
|
|
|
project_exclude_file() {
|
|
local git_path=""
|
|
|
|
if git_path="$(git -C "${REPO_ROOT}" rev-parse --git-path "info/exclude" 2>/dev/null)"; then
|
|
case "${git_path}" in
|
|
/*)
|
|
echo "${git_path}"
|
|
;;
|
|
*)
|
|
echo "${REPO_ROOT}/${git_path}"
|
|
;;
|
|
esac
|
|
return
|
|
fi
|
|
|
|
return 1
|
|
}
|
|
|
|
global_stamp_file() {
|
|
echo "${HOME}/.agents/warp/common-skills-lock.hash"
|
|
}
|
|
|
|
stamp_file() {
|
|
if [[ "${TARGET}" = "global" ]]; then
|
|
global_stamp_file
|
|
else
|
|
project_stamp_file
|
|
fi
|
|
}
|
|
|
|
stamp_hash() {
|
|
local file="$1"
|
|
|
|
if [[ -f "${file}" ]]; then
|
|
cat "${file}"
|
|
fi
|
|
}
|
|
|
|
normalize_common_skills_target() {
|
|
local target
|
|
target="$(printf "%s" "$1" | tr '[:upper:]' '[:lower:]')"
|
|
|
|
case "${target}" in
|
|
""|p|project|1)
|
|
echo "project"
|
|
;;
|
|
g|global|2)
|
|
echo "global"
|
|
;;
|
|
*)
|
|
return 1
|
|
;;
|
|
esac
|
|
}
|
|
|
|
locked_skill_names() {
|
|
node -e '
|
|
const fs = require("fs");
|
|
const lock = JSON.parse(fs.readFileSync(process.argv[1], "utf8"));
|
|
for (const name of Object.keys(lock.skills || {})) {
|
|
console.log(name);
|
|
}
|
|
' "${LOCK_FILE}"
|
|
}
|
|
|
|
locked_skill_paths() {
|
|
node -e '
|
|
const fs = require("fs");
|
|
const lock = JSON.parse(fs.readFileSync(process.argv[1], "utf8"));
|
|
for (const skill of Object.values(lock.skills || {})) {
|
|
console.log(skill.skillPath);
|
|
}
|
|
' "${LOCK_FILE}"
|
|
}
|
|
|
|
locked_common_skills_source() {
|
|
node -e '
|
|
const fs = require("fs");
|
|
const lock = JSON.parse(fs.readFileSync(process.argv[1], "utf8"));
|
|
const skill = Object.values(lock.skills || {})[0];
|
|
if (!skill) {
|
|
process.exit(1);
|
|
}
|
|
const source = skill.ref ? `${skill.source}#${skill.ref}` : skill.source;
|
|
console.log(source);
|
|
' "${LOCK_FILE}"
|
|
}
|
|
|
|
target_description() {
|
|
if [[ "${TARGET}" = "global" ]]; then
|
|
echo "~/.agents/skills"
|
|
else
|
|
echo ".agents/skills"
|
|
fi
|
|
}
|
|
|
|
validate_required_commands() {
|
|
local missing=0
|
|
local tool=""
|
|
|
|
for tool in "$@"; do
|
|
if ! command -v "${tool}" >/dev/null 2>&1; then
|
|
echo "error: required command '${tool}' is not installed or not on PATH." >&2
|
|
missing=1
|
|
fi
|
|
done
|
|
|
|
if [[ "${missing}" -eq 1 ]]; then
|
|
echo "Install the missing tools, then rerun scripts/install_common_skills." >&2
|
|
exit 1
|
|
fi
|
|
}
|
|
|
|
validate_verify_dependencies() {
|
|
validate_required_commands git node
|
|
}
|
|
|
|
validate_install_dependencies() {
|
|
validate_required_commands git node npm npx
|
|
}
|
|
|
|
usage() {
|
|
cat <<EOF
|
|
Usage: scripts/install_common_skills [options]
|
|
|
|
Install or update common agent skills from the standard project skills-lock.json.
|
|
If skills-lock.json is missing, create it from warpdotdev/common-skills first.
|
|
|
|
Options:
|
|
--repo-root <path> Repository containing skills-lock.json and project skills.
|
|
--project Install into this checkout's .agents/skills directory.
|
|
--global Install into ~/.agents/skills for the current user.
|
|
--if-needed Skip installation when the local stamp matches skills-lock.json.
|
|
--prompt-for-target Prompt for project/global before installing or updating when no target is explicit.
|
|
--non-interactive Do not prompt; fail when no install target is explicit.
|
|
--force Install even if the local stamp is already up to date.
|
|
--quiet Suppress "already up to date" informational output.
|
|
--verify-only Verify common skills without installing or updating them.
|
|
-h, --help Show this help message.
|
|
|
|
Environment:
|
|
WARP_SKIP_COMMON_SKILLS_INSTALL=1
|
|
Skip installing common agent skills.
|
|
WARP_COMMON_SKILLS_INSTALL_TARGET=project|global
|
|
Choose the install target when no explicit --project/--global flag is provided.
|
|
If omitted in an interactive invocation, prompt with global as the
|
|
recommended default. If omitted in a non-interactive invocation, fail.
|
|
WARP_COMMON_SKILLS_TARGET_REPO_ROOT=/path/to/repo
|
|
Repository containing skills-lock.json and project-local .agents/skills.
|
|
WARP_COMMON_SKILLS_REF=<git-ref>
|
|
Use a branch, tag, or commit from warpdotdev/common-skills when creating
|
|
a missing lock or checking interactively for upstream lock updates.
|
|
EOF
|
|
}
|
|
|
|
common_skills_present_in_project() {
|
|
local path=""
|
|
|
|
while IFS= read -r path; do
|
|
if [[ -f "${REPO_ROOT}/${path}" ]]; then
|
|
return 0
|
|
fi
|
|
done < <(common_skill_paths)
|
|
|
|
return 1
|
|
}
|
|
|
|
common_skills_present_in_global() {
|
|
local name=""
|
|
if [[ -f "$(global_stamp_file)" ]]; then
|
|
return 0
|
|
fi
|
|
|
|
while IFS= read -r name; do
|
|
if [[ -f "${HOME}/.agents/skills/${name}/SKILL.md" ]]; then
|
|
return 0
|
|
fi
|
|
done < <(common_skill_names)
|
|
|
|
return 1
|
|
}
|
|
|
|
prompt_common_skills_target() {
|
|
local default_target="${1:-global}"
|
|
local reply=""
|
|
local target=""
|
|
if [[ "${NON_INTERACTIVE}" -eq 1 ]]; then
|
|
echo "error: common skills install target is required in non-interactive mode; pass --project, --global, or set WARP_COMMON_SKILLS_INSTALL_TARGET=project|global." >&2
|
|
return 1
|
|
elif can_prompt_interactively; then
|
|
echo "Where should common agent skills be installed?" > /dev/tty
|
|
if [[ "${default_target}" = "global" ]]; then
|
|
echo " 1) Project checkout .agents/skills" > /dev/tty
|
|
echo " 2) Global user skills ~/.agents/skills (recommended for local installs, default)" > /dev/tty
|
|
printf "Install common skills to [project/global] (default: global): " > /dev/tty
|
|
else
|
|
echo " 1) Project checkout .agents/skills (default)" > /dev/tty
|
|
echo " 2) Global user skills ~/.agents/skills" > /dev/tty
|
|
printf "Install common skills to [project/global] (default: project): " > /dev/tty
|
|
fi
|
|
read -r reply < /dev/tty
|
|
if [[ -z "${reply}" ]]; then
|
|
echo "${default_target}"
|
|
return
|
|
fi
|
|
if target="$(normalize_common_skills_target "${reply}")"; then
|
|
echo "${target}"
|
|
else
|
|
echo "error: unrecognized install target '${reply}'." >&2
|
|
return 1
|
|
fi
|
|
else
|
|
echo "error: common skills install target is required when no interactive prompt is available; pass --project, --global, or set WARP_COMMON_SKILLS_INSTALL_TARGET=project|global." >&2
|
|
return 1
|
|
fi
|
|
}
|
|
|
|
resolve_common_skills_target() {
|
|
local project_present=0
|
|
local global_present=0
|
|
local target=""
|
|
|
|
if common_skills_present_in_project; then
|
|
project_present=1
|
|
fi
|
|
|
|
if common_skills_present_in_global; then
|
|
global_present=1
|
|
fi
|
|
|
|
if [[ "${project_present}" -eq 1 && "${global_present}" -eq 1 ]]; then
|
|
echo "Common agent skills are installed in both this checkout's .agents/skills and ~/.agents/skills." >&2
|
|
echo "Remove one copy with scripts/remove_common_skills --repo-root ${REPO_ROOT} or scripts/remove_common_skills --repo-root ${REPO_ROOT} --global, then rerun." >&2
|
|
return 1
|
|
fi
|
|
|
|
if [[ -n "${TARGET}" ]]; then
|
|
if ! target="$(normalize_common_skills_target "${TARGET}")"; then
|
|
return 1
|
|
fi
|
|
|
|
if [[ "${target}" = "project" && "${global_present}" -eq 1 ]]; then
|
|
echo "Common agent skills are already installed globally in ~/.agents/skills." >&2
|
|
echo "Remove the global copy before installing them in this checkout's .agents/skills." >&2
|
|
return 1
|
|
fi
|
|
|
|
if [[ "${target}" = "global" && "${project_present}" -eq 1 ]]; then
|
|
echo "Common agent skills are already installed in this checkout's .agents/skills." >&2
|
|
echo "Remove the project copy before installing them globally in ~/.agents/skills." >&2
|
|
return 1
|
|
fi
|
|
|
|
echo "${target}"
|
|
return
|
|
fi
|
|
if [[ "${PROMPT_FOR_TARGET}" -eq 1 ]]; then
|
|
target="$(prompt_common_skills_target "global")"
|
|
|
|
if [[ "${target}" = "project" && "${global_present}" -eq 1 ]]; then
|
|
echo "Common agent skills are already installed globally in ~/.agents/skills." >&2
|
|
echo "Remove the global copy before installing them in this checkout's .agents/skills." >&2
|
|
return 1
|
|
fi
|
|
|
|
if [[ "${target}" = "global" && "${project_present}" -eq 1 ]]; then
|
|
echo "Common agent skills are already installed in this checkout's .agents/skills." >&2
|
|
echo "Remove the project copy before installing them globally in ~/.agents/skills." >&2
|
|
return 1
|
|
fi
|
|
|
|
echo "${target}"
|
|
return
|
|
fi
|
|
if [[ "${NON_INTERACTIVE}" -eq 1 ]]; then
|
|
echo "error: common skills install target is required in non-interactive mode; pass --project, --global, or set WARP_COMMON_SKILLS_INSTALL_TARGET=project|global." >&2
|
|
return 1
|
|
fi
|
|
|
|
prompt_common_skills_target "global"
|
|
}
|
|
|
|
log() {
|
|
if [[ "${QUIET}" -ne 1 ]]; then
|
|
echo "$@"
|
|
fi
|
|
}
|
|
|
|
can_prompt_interactively() {
|
|
[[ "${NON_INTERACTIVE}" -ne 1 ]] && { true < /dev/tty; } 2>/dev/null && { true > /dev/tty; } 2>/dev/null
|
|
}
|
|
|
|
fail_if_target_requires_unavailable_prompt() {
|
|
if [[ -n "${TARGET}" || "${VERIFY_ONLY}" -eq 1 ]]; then
|
|
return 0
|
|
fi
|
|
|
|
if [[ "${NON_INTERACTIVE}" -eq 1 ]]; then
|
|
echo "error: common skills install target is required in non-interactive mode; pass --project, --global, or set WARP_COMMON_SKILLS_INSTALL_TARGET=project|global." >&2
|
|
exit 1
|
|
fi
|
|
|
|
if ! can_prompt_interactively; then
|
|
echo "error: common skills install target is required when no interactive prompt is available; pass --project, --global, or set WARP_COMMON_SKILLS_INSTALL_TARGET=project|global." >&2
|
|
exit 1
|
|
fi
|
|
}
|
|
|
|
prepare_updated_lock_candidate() {
|
|
local temp_dir=""
|
|
local update_output=""
|
|
|
|
temp_dir="$(mktemp -d "${TMPDIR:-/tmp}/common-skills-lock.XXXXXX")"
|
|
update_output="${temp_dir}/update.log"
|
|
|
|
cp "${LOCK_FILE}" "${temp_dir}/skills-lock.json"
|
|
git -C "${temp_dir}" init >/dev/null 2>&1 || true
|
|
|
|
if ! (
|
|
cd "${temp_dir}"
|
|
npx --yes "skills@${SKILLS_CLI_VERSION}" add "${COMMON_SKILLS_REQUEST_SOURCE}" -a "${SKILLS_CLI_AGENT}" -s "${COMMON_SKILL_SELECTOR}" -y --copy
|
|
) < /dev/null > "${update_output}" 2>&1; then
|
|
if [[ "${QUIET}" -ne 1 ]]; then
|
|
echo "warning: could not check for updated common skills from ${COMMON_SKILLS_REQUEST_SOURCE}; continuing with the existing skills-lock.json." >&2
|
|
fi
|
|
rm -rf "${temp_dir}"
|
|
return 1
|
|
fi
|
|
|
|
if [[ ! -f "${temp_dir}/skills-lock.json" ]]; then
|
|
if [[ "${QUIET}" -ne 1 ]]; then
|
|
echo "warning: common skills update check did not produce a candidate skills-lock.json; continuing with the existing lock." >&2
|
|
fi
|
|
rm -rf "${temp_dir}"
|
|
return 1
|
|
fi
|
|
|
|
if cmp -s "${LOCK_FILE}" "${temp_dir}/skills-lock.json"; then
|
|
rm -rf "${temp_dir}"
|
|
return 1
|
|
fi
|
|
|
|
UPDATED_LOCK_CANDIDATE="$(mktemp "${TMPDIR:-/tmp}/common-skills-lock-candidate.XXXXXX")"
|
|
cp "${temp_dir}/skills-lock.json" "${UPDATED_LOCK_CANDIDATE}"
|
|
rm -rf "${temp_dir}"
|
|
return 0
|
|
}
|
|
|
|
maybe_prompt_for_upstream_lock_update() {
|
|
local reply=""
|
|
|
|
if [[ ! -f "${LOCK_FILE}" ]]; then
|
|
return 0
|
|
fi
|
|
|
|
if ! can_prompt_interactively; then
|
|
return 0
|
|
fi
|
|
|
|
if ! prepare_updated_lock_candidate; then
|
|
return 0
|
|
fi
|
|
|
|
echo "Common agent skills have been updated in ${COMMON_SKILLS_REQUEST_SOURCE}." > /dev/tty
|
|
printf "Update ${LOCK_FILE} and reinstall common skills from the updated lock? [y/N]: " > /dev/tty
|
|
read -r reply < /dev/tty
|
|
|
|
case "$(printf "%s" "${reply}" | tr '[:upper:]' '[:lower:]')" in
|
|
y|yes)
|
|
cp "${UPDATED_LOCK_CANDIDATE}" "${LOCK_FILE}"
|
|
rm -f "${UPDATED_LOCK_CANDIDATE}"
|
|
UPDATED_LOCK_CANDIDATE=""
|
|
ALLOW_GLOBAL_LOCK_UPDATE=1
|
|
FORCE=1
|
|
echo "Updated skills-lock.json. Common skills will be reinstalled from the updated lock." > /dev/tty
|
|
;;
|
|
*)
|
|
rm -f "${UPDATED_LOCK_CANDIDATE}"
|
|
UPDATED_LOCK_CANDIDATE=""
|
|
echo "Keeping existing skills-lock.json. Common skills will be installed from the existing lock." > /dev/tty
|
|
;;
|
|
esac
|
|
}
|
|
|
|
common_skill_names() {
|
|
if [[ -f "${LOCK_FILE}" ]]; then
|
|
locked_skill_names
|
|
fi
|
|
}
|
|
|
|
common_skill_paths() {
|
|
if [[ -f "${LOCK_FILE}" ]]; then
|
|
locked_skill_paths
|
|
fi
|
|
}
|
|
|
|
locked_skills_installed() {
|
|
local name=""
|
|
local path=""
|
|
|
|
if [[ "${TARGET}" = "global" ]]; then
|
|
while IFS= read -r name; do
|
|
if [[ ! -f "${HOME}/.agents/skills/${name}/SKILL.md" ]]; then
|
|
return 1
|
|
fi
|
|
done < <(locked_skill_names)
|
|
else
|
|
while IFS= read -r path; do
|
|
if [[ ! -f "${REPO_ROOT}/${path}" ]]; then
|
|
return 1
|
|
fi
|
|
done < <(locked_skill_paths)
|
|
fi
|
|
}
|
|
|
|
common_skills_already_up_to_date_without_target() {
|
|
local current_lock_hash=""
|
|
local project_stamp_hash=""
|
|
local global_stamp_hash=""
|
|
|
|
if [[ ! -f "${LOCK_FILE}" ]]; then
|
|
return 1
|
|
fi
|
|
|
|
fail_if_global_lock_mismatch
|
|
|
|
current_lock_hash="$(lock_hash)"
|
|
project_stamp_hash="$(stamp_hash "$(project_stamp_file)")"
|
|
global_stamp_hash="$(stamp_hash "$(global_stamp_file)")"
|
|
if [[ "${project_stamp_hash}" != "${current_lock_hash}" && "${global_stamp_hash}" != "${current_lock_hash}" ]]; then
|
|
return 1
|
|
fi
|
|
|
|
verify_common_skills >/dev/null 2>&1
|
|
}
|
|
|
|
install_project_from_source() {
|
|
(
|
|
cd "${REPO_ROOT}"
|
|
npx --yes "skills@${SKILLS_CLI_VERSION}" add "${COMMON_SKILLS_REQUEST_SOURCE}" -a "${SKILLS_CLI_AGENT}" -s "${COMMON_SKILL_SELECTOR}" -y --copy
|
|
) < /dev/null
|
|
}
|
|
|
|
install_project_from_lock() {
|
|
(
|
|
cd "${REPO_ROOT}"
|
|
npx --yes "skills@${SKILLS_CLI_VERSION}" experimental_install
|
|
) < /dev/null
|
|
}
|
|
|
|
install_global_from_lock() {
|
|
local skill_names=()
|
|
local name=""
|
|
local temp_dir=""
|
|
local skill_dir=""
|
|
|
|
while IFS= read -r name; do
|
|
skill_names+=("${name}")
|
|
done < <(locked_skill_names)
|
|
|
|
if [[ "${#skill_names[@]}" -eq 0 ]]; then
|
|
echo "error: no skills found in ${LOCK_FILE}" >&2
|
|
exit 1
|
|
fi
|
|
|
|
temp_dir="$(mktemp -d "${TMPDIR:-/tmp}/common-skills-global-install.XXXXXX")"
|
|
cp "${LOCK_FILE}" "${temp_dir}/skills-lock.json"
|
|
|
|
(
|
|
cd "${temp_dir}"
|
|
npx --yes "skills@${SKILLS_CLI_VERSION}" experimental_install
|
|
) < /dev/null
|
|
|
|
mkdir -p "${HOME}/.agents/skills"
|
|
for name in "${skill_names[@]}"; do
|
|
skill_dir="${temp_dir}/.agents/skills/${name}"
|
|
if [[ ! -d "${skill_dir}" ]]; then
|
|
rm -rf "${temp_dir}"
|
|
echo "error: expected ${name} to be restored from ${LOCK_FILE}" >&2
|
|
exit 1
|
|
fi
|
|
|
|
rm -rf -- "${HOME}/.agents/skills/${name}"
|
|
cp -R "${skill_dir}" "${HOME}/.agents/skills/${name}"
|
|
done
|
|
|
|
rm -rf "${temp_dir}"
|
|
}
|
|
|
|
remove_project_skills_from_lock() {
|
|
local path=""
|
|
local skill_dir=""
|
|
|
|
while IFS= read -r path; do
|
|
if [[ -z "${path}" ]]; then
|
|
continue
|
|
fi
|
|
|
|
case "${path}" in
|
|
.agents/skills/*/SKILL.md)
|
|
skill_dir="$(dirname "${REPO_ROOT}/${path}")"
|
|
;;
|
|
*)
|
|
echo "error: refusing to remove unexpected skill path from lock file: ${path}" >&2
|
|
exit 1
|
|
;;
|
|
esac
|
|
|
|
case "${skill_dir}" in
|
|
"${REPO_ROOT}/.agents/skills/"*)
|
|
rm -rf -- "${skill_dir}"
|
|
;;
|
|
*)
|
|
echo "error: refusing to remove path outside .agents/skills: ${skill_dir}" >&2
|
|
exit 1
|
|
;;
|
|
esac
|
|
done < <(locked_skill_paths)
|
|
}
|
|
|
|
ensure_project_common_skills_ignored() {
|
|
local exclude_file=""
|
|
local path=""
|
|
local skill_dir=""
|
|
local tmp_file=""
|
|
local marker_start="# BEGIN warp common-skills"
|
|
local marker_end="# END warp common-skills"
|
|
|
|
if [[ "${TARGET}" != "project" ]]; then
|
|
return 0
|
|
fi
|
|
|
|
if ! exclude_file="$(project_exclude_file)"; then
|
|
return 0
|
|
fi
|
|
|
|
mkdir -p "$(dirname "${exclude_file}")"
|
|
touch "${exclude_file}"
|
|
tmp_file="$(mktemp)"
|
|
|
|
awk -v start="${marker_start}" -v end="${marker_end}" '
|
|
$0 == start { skipping = 1; next }
|
|
$0 == end { skipping = 0; next }
|
|
!skipping { print }
|
|
' "${exclude_file}" > "${tmp_file}"
|
|
|
|
{
|
|
cat "${tmp_file}"
|
|
echo "${marker_start}"
|
|
while IFS= read -r path; do
|
|
if [[ -z "${path}" ]]; then
|
|
continue
|
|
fi
|
|
|
|
case "${path}" in
|
|
.agents/skills/*/SKILL.md)
|
|
skill_dir="${path%/SKILL.md}/"
|
|
echo "/${skill_dir}"
|
|
;;
|
|
esac
|
|
done < <(locked_skill_paths)
|
|
echo "${marker_end}"
|
|
} > "${exclude_file}"
|
|
|
|
rm -f "${tmp_file}"
|
|
}
|
|
|
|
install_from_lock() {
|
|
if [[ "${TARGET}" = "global" ]]; then
|
|
install_global_from_lock
|
|
else
|
|
install_project_from_lock
|
|
fi
|
|
}
|
|
|
|
fail_if_global_lock_mismatch() {
|
|
local target_stamp_file=""
|
|
local target_stamp_hash=""
|
|
local current_lock_hash=""
|
|
|
|
if [[ "${TARGET}" != "global" ]]; then
|
|
if [[ -n "${TARGET}" ]] || [[ ! -f "$(global_stamp_file)" ]]; then
|
|
return 0
|
|
fi
|
|
fi
|
|
|
|
target_stamp_file="$(global_stamp_file)"
|
|
target_stamp_hash="$(stamp_hash "${target_stamp_file}")"
|
|
if [[ -z "${target_stamp_hash}" ]]; then
|
|
return 0
|
|
fi
|
|
|
|
current_lock_hash="$(lock_hash)"
|
|
if [[ "${target_stamp_hash}" = "${current_lock_hash}" ]]; then
|
|
return 0
|
|
fi
|
|
|
|
if [[ "${TARGET}" = "global" && "${ALLOW_GLOBAL_LOCK_UPDATE}" -eq 1 ]]; then
|
|
return 0
|
|
fi
|
|
|
|
echo "error: global common skills are pinned to a different skills-lock.json than ${LOCK_FILE}." >&2
|
|
echo "Another checkout may depend on the currently installed global common-skills version." >&2
|
|
echo "To reuse the installed global copy, update this checkout to the same skills-lock.json and rerun." >&2
|
|
echo "To keep this checkout's existing lock, rerun and choose project instead of global." >&2
|
|
echo "To replace the global copy, run this from the consuming repo and then rerun:" >&2
|
|
echo " ./script/resolve_common_skills remove_common_skills -- --repo-root ${REPO_ROOT} --global" >&2
|
|
exit 1
|
|
}
|
|
|
|
verify_common_skills() {
|
|
if [[ ! -f "${LOCK_FILE}" ]]; then
|
|
echo "error: missing skills lock file: ${LOCK_FILE}" >&2
|
|
exit 1
|
|
fi
|
|
|
|
node - "${LOCK_FILE}" "${REPO_ROOT}" "${HOME}" "${TARGET}" "${QUIET}" "$(global_stamp_file)" <<'NODE'
|
|
const crypto = require("crypto");
|
|
const fs = require("fs");
|
|
const path = require("path");
|
|
|
|
const lockFile = process.argv[2];
|
|
const repoRoot = process.argv[3];
|
|
const home = process.argv[4];
|
|
let target = process.argv[5];
|
|
const quiet = process.argv[6] === "1";
|
|
const globalStampFile = process.argv[7];
|
|
|
|
function fail(messages) {
|
|
for (const message of messages) {
|
|
console.error(message);
|
|
}
|
|
process.exit(1);
|
|
}
|
|
|
|
function lockedSkills() {
|
|
const lock = JSON.parse(fs.readFileSync(lockFile, "utf8"));
|
|
const skills = Object.entries(lock.skills || {});
|
|
|
|
if (skills.length === 0) {
|
|
fail([`error: no skills found in ${lockFile}`]);
|
|
}
|
|
|
|
return skills;
|
|
}
|
|
|
|
function projectSkillFile(skill) {
|
|
return path.join(repoRoot, skill.skillPath);
|
|
}
|
|
|
|
function globalSkillFile(name) {
|
|
return path.join(home, ".agents", "skills", name, "SKILL.md");
|
|
}
|
|
|
|
function targetSkillDir(name, skill, installTarget) {
|
|
if (installTarget === "global") {
|
|
return path.dirname(globalSkillFile(name));
|
|
}
|
|
|
|
return path.dirname(projectSkillFile(skill));
|
|
}
|
|
|
|
function containsLockedSkill(skills, installTarget) {
|
|
return skills.some(([name, skill]) => {
|
|
const skillFile = installTarget === "global"
|
|
? globalSkillFile(name)
|
|
: projectSkillFile(skill);
|
|
return fs.existsSync(skillFile);
|
|
});
|
|
}
|
|
|
|
function collectFiles(baseDir, currentDir, results) {
|
|
for (const entry of fs.readdirSync(currentDir, { withFileTypes: true })) {
|
|
const fullPath = path.join(currentDir, entry.name);
|
|
|
|
if (entry.isDirectory()) {
|
|
if (entry.name === ".git" || entry.name === "node_modules") {
|
|
continue;
|
|
}
|
|
|
|
collectFiles(baseDir, fullPath, results);
|
|
} else if (entry.isFile()) {
|
|
results.push({
|
|
relativePath: path.relative(baseDir, fullPath).split(path.sep).join("/"),
|
|
content: fs.readFileSync(fullPath),
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
function normalizeCrlf(content) {
|
|
// Git or platform tooling may rewrite checked-out text files as CRLF even
|
|
// though the lockfile hash was computed from LF-normalized repository data.
|
|
const carriageReturnByte = 13;
|
|
const lineFeedByte = 10;
|
|
const normalized = [];
|
|
for (let index = 0; index < content.length; index++) {
|
|
const byte = content[index];
|
|
if (byte === carriageReturnByte && content[index + 1] === lineFeedByte) {
|
|
continue;
|
|
}
|
|
normalized.push(byte);
|
|
}
|
|
return Buffer.from(normalized);
|
|
}
|
|
|
|
function computeSkillFolderHash(skillDir, normalizeLineEndings = false) {
|
|
const files = [];
|
|
collectFiles(skillDir, skillDir, files);
|
|
files.sort((a, b) => a.relativePath.localeCompare(b.relativePath));
|
|
|
|
const hash = crypto.createHash("sha256");
|
|
for (const file of files) {
|
|
hash.update(file.relativePath);
|
|
hash.update(normalizeLineEndings ? normalizeCrlf(file.content) : file.content);
|
|
}
|
|
|
|
return hash.digest("hex");
|
|
}
|
|
|
|
function verifyTarget(skills, installTarget) {
|
|
const errors = [];
|
|
|
|
for (const [name, skill] of skills) {
|
|
const skillDir = targetSkillDir(name, skill, installTarget);
|
|
const skillFile = path.join(skillDir, "SKILL.md");
|
|
|
|
if (!fs.existsSync(skillFile)) {
|
|
errors.push(`error: missing ${name} in ${installTarget === "global" ? "~/.agents/skills" : ".agents/skills"}`);
|
|
continue;
|
|
}
|
|
|
|
const computedHash = computeSkillFolderHash(skillDir);
|
|
// Preserve exact-byte verification first, then allow an equivalent CRLF
|
|
// checkout to match the lockfile before reporting drift.
|
|
const normalizedComputedHash =
|
|
computedHash === skill.computedHash ? computedHash : computeSkillFolderHash(skillDir, true);
|
|
if (normalizedComputedHash !== skill.computedHash) {
|
|
errors.push(`error: ${name} does not match skills-lock.json`);
|
|
errors.push(` expected: ${skill.computedHash}`);
|
|
errors.push(` actual: ${computedHash}`);
|
|
}
|
|
}
|
|
|
|
if (errors.length > 0) {
|
|
fail(errors);
|
|
}
|
|
}
|
|
|
|
const skills = lockedSkills();
|
|
const projectPresent = containsLockedSkill(skills, "project");
|
|
const globalPresent = fs.existsSync(globalStampFile) || containsLockedSkill(skills, "global");
|
|
|
|
if (projectPresent && globalPresent) {
|
|
fail([
|
|
"error: common agent skills are installed in both this checkout's .agents/skills and ~/.agents/skills.",
|
|
`Remove one copy with scripts/remove_common_skills --repo-root ${repoRoot} or scripts/remove_common_skills --repo-root ${repoRoot} --global, then rerun.`,
|
|
]);
|
|
}
|
|
|
|
if (!projectPresent && !globalPresent) {
|
|
fail([
|
|
"error: common agent skills are not installed in this checkout's .agents/skills or ~/.agents/skills.",
|
|
`Install them with scripts/install_common_skills --repo-root ${repoRoot}, then rerun.`,
|
|
]);
|
|
}
|
|
|
|
if (target) {
|
|
if (target !== "project" && target !== "global") {
|
|
fail([`error: invalid common skills verification target: ${target} (expected project or global)`]);
|
|
}
|
|
|
|
if (target === "project" && !projectPresent) {
|
|
fail(["error: common agent skills are not installed in this checkout's .agents/skills."]);
|
|
}
|
|
|
|
if (target === "global" && !globalPresent) {
|
|
fail(["error: common agent skills are not installed in ~/.agents/skills."]);
|
|
}
|
|
} else {
|
|
target = projectPresent ? "project" : "global";
|
|
}
|
|
|
|
verifyTarget(skills, target);
|
|
if (!quiet) {
|
|
console.log(`Common skills are installed in ${target === "global" ? "~/.agents/skills" : ".agents/skills"} and match skills-lock.json.`);
|
|
}
|
|
NODE
|
|
}
|
|
|
|
while [[ $# -gt 0 ]]; do
|
|
case "$1" in
|
|
--repo-root)
|
|
if [[ -n "${2:-}" ]]; then
|
|
REPO_ROOT="$2"
|
|
shift 2
|
|
else
|
|
echo "error: argument for $1 is missing" >&2
|
|
usage >&2
|
|
exit 1
|
|
fi
|
|
;;
|
|
--project)
|
|
TARGET="project"
|
|
shift
|
|
;;
|
|
--global)
|
|
TARGET="global"
|
|
shift
|
|
;;
|
|
--if-needed)
|
|
IF_NEEDED=1
|
|
shift
|
|
;;
|
|
--prompt-for-target)
|
|
PROMPT_FOR_TARGET=1
|
|
shift
|
|
;;
|
|
--non-interactive)
|
|
NON_INTERACTIVE=1
|
|
shift
|
|
;;
|
|
--force)
|
|
FORCE=1
|
|
shift
|
|
;;
|
|
--quiet)
|
|
QUIET=1
|
|
shift
|
|
;;
|
|
--verify-only)
|
|
VERIFY_ONLY=1
|
|
shift
|
|
;;
|
|
-h|--help)
|
|
usage
|
|
exit 0
|
|
;;
|
|
*)
|
|
echo "error: unknown argument: $1" >&2
|
|
usage >&2
|
|
exit 1
|
|
;;
|
|
esac
|
|
done
|
|
|
|
if ! REPO_ROOT="$(resolve_repo_root "${REPO_ROOT}")"; then
|
|
echo "error: repo root does not exist: ${REPO_ROOT}" >&2
|
|
exit 1
|
|
fi
|
|
LOCK_FILE="${REPO_ROOT}/skills-lock.json"
|
|
if [[ "${WARP_SKIP_COMMON_SKILLS_INSTALL:-}" = "1" ]]; then
|
|
log "Skipping common-skills install because WARP_SKIP_COMMON_SKILLS_INSTALL=1."
|
|
exit 0
|
|
fi
|
|
if [[ "${VERIFY_ONLY}" -eq 1 ]]; then
|
|
if [[ -n "${TARGET}" ]]; then
|
|
if ! RESOLVED_TARGET="$(normalize_common_skills_target "${TARGET}")"; then
|
|
echo "error: invalid common skills verification target: ${TARGET} (expected project or global)" >&2
|
|
exit 1
|
|
fi
|
|
TARGET="${RESOLVED_TARGET}"
|
|
fi
|
|
if [[ ! -f "${LOCK_FILE}" ]]; then
|
|
verify_common_skills
|
|
exit 1
|
|
fi
|
|
validate_verify_dependencies
|
|
|
|
fail_if_global_lock_mismatch
|
|
verify_common_skills
|
|
exit 0
|
|
fi
|
|
validate_install_dependencies
|
|
maybe_prompt_for_upstream_lock_update
|
|
if [[ "${FORCE}" -ne 1 && "${IF_NEEDED}" -eq 1 && -z "${TARGET}" ]] && common_skills_already_up_to_date_without_target; then
|
|
log "Common skills are already up to date."
|
|
exit 0
|
|
fi
|
|
fail_if_target_requires_unavailable_prompt
|
|
if ! RESOLVED_TARGET="$(resolve_common_skills_target)"; then
|
|
echo "error: could not resolve common skills install target" >&2
|
|
usage >&2
|
|
exit 1
|
|
fi
|
|
TARGET="${RESOLVED_TARGET}"
|
|
if [[ ! -f "${LOCK_FILE}" ]]; then
|
|
echo "Creating skills-lock.json from ${COMMON_SKILLS_REQUEST_SOURCE}."
|
|
install_project_from_source
|
|
if [[ ! -f "${LOCK_FILE}" ]]; then
|
|
echo "error: expected ${LOCK_FILE} to be created by skills add" >&2
|
|
exit 1
|
|
fi
|
|
fail_if_global_lock_mismatch
|
|
|
|
if [[ "${TARGET}" = "global" ]]; then
|
|
install_global_from_lock
|
|
remove_project_skills_from_lock
|
|
else
|
|
ensure_project_common_skills_ignored
|
|
fi
|
|
|
|
LOCK_HASH="$(lock_hash)"
|
|
STAMP_FILE="$(stamp_file)"
|
|
mkdir -p "$(dirname "${STAMP_FILE}")"
|
|
printf "%s\n" "${LOCK_HASH}" > "${STAMP_FILE}"
|
|
verify_common_skills
|
|
exit 0
|
|
fi
|
|
|
|
LOCK_HASH="$(lock_hash)"
|
|
STAMP_FILE="$(stamp_file)"
|
|
STAMP_HASH="$(stamp_hash "${STAMP_FILE}")"
|
|
fail_if_global_lock_mismatch
|
|
ensure_project_common_skills_ignored
|
|
|
|
if [[ "${FORCE}" -ne 1 && "${IF_NEEDED}" -eq 1 && "${STAMP_HASH}" = "${LOCK_HASH}" ]] && locked_skills_installed; then
|
|
log "Common skills are already up to date."
|
|
verify_common_skills
|
|
exit 0
|
|
fi
|
|
|
|
echo "Installing common agent skills from skills-lock.json into $(target_description)."
|
|
install_from_lock
|
|
LOCK_HASH="$(lock_hash)"
|
|
mkdir -p "$(dirname "${STAMP_FILE}")"
|
|
printf "%s\n" "${LOCK_HASH}" > "${STAMP_FILE}"
|
|
verify_common_skills
|