Import verified skill archives

This commit is contained in:
tmk241
2026-08-11 17:04:37 +02:00
parent efc4b70e13
commit 8547d6ea33
4 changed files with 297 additions and 246 deletions
+202 -169
View File
@@ -4,41 +4,41 @@ set -eu
usage() {
cat <<'EOF'
usage: ink-skills list
ink-skills install [--user | --project DIR] [SKILL ...]
ink-skills add [--user | --project DIR] [--ref REF] REPOSITORY [PATH ...]
ink-skills link [--user | --project DIR] [SKILL_DIR ...]
ink-skills import [--user | --project DIR] sha256:HASH ARCHIVE [PATH ...]
Install Ink skills as symlinks from this checkout or a pinned Git artifact.
Link local Ink skills or import a verified skill archive.
Commands:
list List skills in this checkout as TSV.
install Link named local skills; with no names, link every skill.
add Fetch REPOSITORY, pin REF to a commit, materialize an
immutable snapshot, and link skill PATHs from it. PATH
defaults to every skills/*/SKILL.md directory.
list List skills shipped by this checkout as TSV.
link Symlink local skill directories. With no directories, link every
skill shipped by this checkout.
import Verify ARCHIVE against the required SHA-256, safely materialize its
immutable tree, then link selected skill PATHs. PATH defaults to
every skills/*/SKILL.md directory in the archive.
Targets:
--user $INK_SKILLS_HOME or $HOME/.ink/skills (default)
--project DIR DIR/.ink/skills
Git storage:
Artifact store:
$INK_SKILLS_STORE or $XDG_DATA_HOME/ink-skills, otherwise
$HOME/.local/share/ink-skills. Git, tar, and sha256sum are required by `add`.
Authentication belongs to Git's SSH agent or credential helper; credentialed
HTTP URLs are refused. Installed provenance is written to
TARGET/.ink-skills.tsv without modifying SKILL.md.
$HOME/.local/share/ink-skills.
Transport is deliberately external:
curl -fLo skills.tar URL
ink-skills import sha256:HASH skills.tar
Output:
TSV with SKILL, TARGET, ACTION, SOURCE, COMMIT, and SHA256 columns.
TSV with SKILL, TARGET, ACTION, SOURCE, and SHA256 columns.
Exit status:
0 success; 2 usage error; 3 collision, invalid source, or fetch failure.
0 success; 2 usage error; 3 invalid skill, collision, digest mismatch, or
unsafe archive.
Examples:
ink-skills list
ink-skills install audit-ink-cli configure-ink-agent
ink-skills install --project . create-ink-tool
ink-skills add --ref main git@git.example:team/skills.git
ink-skills add https://git.example/team/skills.git skills/review-sql
Requirements:
POSIX sh and standard text tools. `import` additionally needs tar and one of
sha256sum, shasum, or openssl.
EOF
}
@@ -47,6 +47,22 @@ die() {
exit 3
}
script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd -P)
repo_dir=$(CDPATH= cd -- "$script_dir/.." && pwd -P)
bundled_dir=$repo_dir/skills
cleanup_root=
cleanup_artifact=
cleanup_lock=
cleanup_all() {
if [ -n "$cleanup_artifact" ]; then
chmod -R u+w "$cleanup_artifact" 2>/dev/null || :
rm -rf "$cleanup_artifact"
fi
[ -z "$cleanup_lock" ] || rmdir "$cleanup_lock" 2>/dev/null || :
[ -z "$cleanup_root" ] || rm -rf "$cleanup_root"
}
trap cleanup_all EXIT HUP INT TERM
reject_record_breaks() {
case $1 in
*" "*|*"
@@ -54,17 +70,20 @@ reject_record_breaks() {
esac
}
script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd -P)
repo_dir=$(CDPATH= cd -- "$script_dir/.." && pwd -P)
skills_dir=$repo_dir/skills
frontmatter_name() {
sed -n 's/^name:[[:space:]]*//p' "$1/SKILL.md" | sed -n '1p'
}
list_skills() {
printf 'SKILL\tSOURCE\n'
for path in "$skills_dir"/*; do
[ -d "$path" ] || continue
[ -f "$path/SKILL.md" ] || continue
printf '%s\t%s\n' "$(basename -- "$path")" "$path"
done
validate_skill_dir() {
skill_dir=$1
[ -d "$skill_dir" ] && [ -f "$skill_dir/SKILL.md" ] || die "not a skill directory: $skill_dir"
[ ! -L "$skill_dir" ] && [ ! -L "$skill_dir/SKILL.md" ] || die "skill root and SKILL.md must not be symlinks: $skill_dir"
skill_name=$(frontmatter_name "$skill_dir")
[ -n "$skill_name" ] || die "missing frontmatter name: $skill_dir/SKILL.md"
[ "$skill_name" = "$(basename -- "$skill_dir")" ] || die "frontmatter name does not match directory: $skill_dir"
case $skill_name in
''|.*|*/*) die "invalid skill name: $skill_name" ;;
esac
}
select_target() {
@@ -85,48 +104,48 @@ select_target() {
mkdir -p -- "$target"
}
parse_target_option() {
case $1 in
--user)
target_mode=user
shift_count=1
;;
--project)
[ "$#" -ge 2 ] || {
parse_target_options() {
while [ "$#" -gt 0 ]; do
case $1 in
--user)
target_mode=user
shift
;;
--project)
[ "$#" -ge 2 ] || {
usage >&2
exit 2
}
target_mode=project
target_arg=$2
shift 2
;;
--)
shift
break
;;
-*)
usage >&2
exit 2
}
target_mode=project
target_arg=$2
shift_count=2
;;
*) shift_count=0 ;;
esac
}
frontmatter_name() {
sed -n 's/^name:[[:space:]]*//p' "$1/SKILL.md" | sed -n '1p'
}
validate_skill_dir() {
source_path=$1
[ -d "$source_path" ] && [ -f "$source_path/SKILL.md" ] || die "not a skill directory: $source_path"
[ ! -L "$source_path" ] && [ ! -L "$source_path/SKILL.md" ] || die "skill root and SKILL.md must not be symlinks: $source_path"
skill_name=$(frontmatter_name "$source_path")
[ -n "$skill_name" ] || die "missing frontmatter name: $source_path/SKILL.md"
[ "$skill_name" = "$(basename -- "$source_path")" ] || die "frontmatter name does not match directory: $source_path"
case $skill_name in
''|.*|*/*) die "invalid skill name: $skill_name" ;;
esac
;;
*) break ;;
esac
done
remaining_count=$#
remaining_file=$work_args
: >"$remaining_file"
for arg do
reject_record_breaks "$arg"
printf '%s\n' "$arg" >>"$remaining_file"
done
}
link_skill() {
skill_name=$1
name=$1
source_path=$2
source_label=$3
commit=$4
digest=$5
destination=$target/$skill_name
digest=$4
destination=$target/$name
if [ -L "$destination" ]; then
linked=$(readlink "$destination")
[ "$linked" = "$source_path" ] || die "refusing foreign symlink: $destination -> $linked"
@@ -137,34 +156,58 @@ link_skill() {
ln -s -- "$source_path" "$destination"
action=linked
fi
printf '%s\t%s\t%s\t%s\t%s\t%s\n' "$skill_name" "$destination" "$action" "$source_label" "$commit" "$digest"
printf '%s\t%s\t%s\t%s\t%s\n' "$name" "$destination" "$action" "$source_label" "$digest"
}
check_source_record() {
skill_name=$1
source_label=$2
ref=$3
commit=$4
skill_path=$5
digest=$6
manifest_check() {
name=$1
digest=$2
archive_path=$3
manifest=$target/.ink-skills.tsv
record_needed=yes
[ -e "$manifest" ] || return 0
if awk -F '\t' -v skill="$skill_name" 'NR > 1 && $1 == skill { found = 1 } END { exit !found }' "$manifest"; then
existing=$(awk -F '\t' -v skill="$skill_name" 'NR > 1 && $1 == skill { print $0; exit }' "$manifest")
wanted=$(printf '%s\t%s\t%s\t%s\t%s\t%s' "$skill_name" "$source_label" "$ref" "$commit" "$skill_path" "$digest")
[ "$existing" = "$wanted" ] || die "provenance collision for installed skill: $skill_name"
[ -f "$manifest" ] || return 0
if awk -F '\t' -v name="$name" 'NR > 1 && $1 == name { found = 1 } END { exit !found }' "$manifest"; then
existing=$(awk -F '\t' -v name="$name" 'NR > 1 && $1 == name { print $0; exit }' "$manifest")
wanted=$(printf '%s\t%s\t%s' "$name" "$digest" "$archive_path")
[ "$existing" = "$wanted" ] || die "provenance collision for installed skill: $name"
record_needed=no
fi
}
append_source_record() {
manifest_append() {
[ "$record_needed" = yes ] || return 0
manifest=$target/.ink-skills.tsv
if [ ! -e "$manifest" ]; then
printf 'SKILL\tSOURCE\tREF\tCOMMIT\tPATH\tSHA256\n' >"$manifest"
[ -e "$manifest" ] || printf 'SKILL\tSHA256\tPATH\n' >"$manifest"
printf '%s\t%s\t%s\n' "$name" "$digest" "$archive_path" >>"$manifest"
}
sha256_file() {
file=$1
if command -v sha256sum >/dev/null 2>&1; then
sha256sum "$file" | awk '{print $1}'
elif command -v shasum >/dev/null 2>&1; then
shasum -a 256 "$file" | awk '{print $1}'
elif command -v openssl >/dev/null 2>&1; then
openssl dgst -sha256 "$file" | sed 's/^.*= //'
else
die 'import requires sha256sum, shasum, or openssl'
fi
printf '%s\t%s\t%s\t%s\t%s\t%s\n' "$skill_name" "$repository" "$ref" "$commit" "$skill_path" "$digest" >>"$manifest"
}
validate_archive_listing() {
archive=$1
names=$2
types=$3
tar -tf "$archive" >"$names" || die "cannot list archive: $archive"
[ -s "$names" ] || die 'archive is empty'
while IFS= read -r member; do
reject_record_breaks "$member"
case $member in
''|/*|..|../*|*/../*|*/..) die "unsafe archive path: $member" ;;
esac
done <"$names"
LC_ALL=C tar -tvf "$archive" >"$types" || die "cannot inspect archive: $archive"
awk 'substr($1, 1, 1) != "-" && substr($1, 1, 1) != "d" { exit 1 }' "$types" || die 'archive may contain only regular files and directories'
}
[ "$#" -gt 0 ] || {
@@ -183,10 +226,14 @@ case $command in
usage >&2
exit 2
}
list_skills
printf 'SKILL\tSOURCE\n'
for path in "$bundled_dir"/*; do
[ -d "$path" ] && [ -f "$path/SKILL.md" ] || continue
printf '%s\t%s\n' "$(basename -- "$path")" "$path"
done
exit 0
;;
install|add) ;;
link|import) ;;
*)
usage >&2
exit 2
@@ -195,74 +242,57 @@ esac
target_mode=user
target_arg=
ref=HEAD
while [ "$#" -gt 0 ]; do
parse_target_option "$@"
if [ "$shift_count" -gt 0 ]; then
shift "$shift_count"
continue
fi
case $1 in
--ref)
[ "$command" = add ] && [ "$#" -ge 2 ] || {
usage >&2
exit 2
}
ref=$2
shift 2
;;
--)
shift
break
;;
-*)
usage >&2
exit 2
;;
*) break ;;
esac
done
work_root=${TMPDIR:-/tmp}/ink-skills-args-$$
(umask 077 && mkdir "$work_root") || die "cannot create temporary directory: $work_root"
cleanup_root=$work_root
work_args=$work_root/args
parse_target_options "$@"
set --
while IFS= read -r arg; do set -- "$@" "$arg"; done <"$work_args"
select_target
printf 'SKILL\tTARGET\tACTION\tSOURCE\tCOMMIT\tSHA256\n'
printf 'SKILL\tTARGET\tACTION\tSOURCE\tSHA256\n'
if [ "$command" = install ]; then
if [ "$command" = link ]; then
if [ "$#" -eq 0 ]; then
set --
for path in "$skills_dir"/*; do
[ -d "$path" ] || continue
[ -f "$path/SKILL.md" ] || continue
set -- "$@" "$(basename -- "$path")"
for path in "$bundled_dir"/*; do
[ -d "$path" ] && [ -f "$path/SKILL.md" ] || continue
set -- "$@" "$path"
done
fi
for skill do
case $skill in
''|.*|*/*) die "invalid skill name: $skill" ;;
for source_path do
case $source_path in
/*) ;;
*) source_path=$(CDPATH= cd -- "$(dirname -- "$source_path")" 2>/dev/null && printf '%s/%s\n' "$PWD" "$(basename -- "$source_path")") || die "skill directory not found: $source_path" ;;
esac
source_path=$skills_dir/$skill
validate_skill_dir "$source_path"
link_skill "$skill_name" "$source_path" local - -
link_skill "$skill_name" "$source_path" local -
done
exit 0
fi
[ "$#" -gt 0 ] || {
[ "$#" -ge 2 ] || {
usage >&2
exit 2
}
repository=$1
shift
reject_record_breaks "$repository"
reject_record_breaks "$ref"
case $ref in
-*) die "invalid ref: $ref" ;;
digest_spec=$1
archive=$2
shift 2
case $digest_spec in
sha256:*) digest=${digest_spec#sha256:} ;;
*) die 'digest must use sha256:HASH' ;;
esac
case $repository in
http://*@*|https://*@*) die 'credentialed HTTP URLs are refused; use an SSH agent or Git credential helper' ;;
digest=$(printf '%s' "$digest" | tr 'A-F' 'a-f')
case $digest in
*[!0-9a-f]*|'') die 'SHA-256 must contain 64 hexadecimal characters' ;;
esac
command -v git >/dev/null 2>&1 || die 'git is required by add'
command -v tar >/dev/null 2>&1 || die 'tar is required by add'
command -v sha256sum >/dev/null 2>&1 || die 'sha256sum is required by add'
[ "${#digest}" -eq 64 ] || die 'SHA-256 must contain 64 hexadecimal characters'
[ -f "$archive" ] || die "archive not found: $archive"
command -v tar >/dev/null 2>&1 || die 'import requires tar'
actual=$(sha256_file "$archive")
actual=$(printf '%s' "$actual" | tr 'A-F' 'a-f')
[ "$actual" = "$digest" ] || die "SHA-256 mismatch: expected $digest, got $actual"
if [ -n "${INK_SKILLS_STORE:-}" ]; then
store=$INK_SKILLS_STORE
@@ -272,48 +302,51 @@ else
[ -n "${HOME:-}" ] || die 'HOME is unset; set HOME, XDG_DATA_HOME, or INK_SKILLS_STORE'
store=$HOME/.local/share/ink-skills
fi
repo_key=$(printf '%s' "$repository" | git hash-object --stdin) || die 'cannot hash repository identity'
mirror=$store/git/$repo_key.git
mkdir -p -- "$store/git" "$store/artifacts/$repo_key"
if [ ! -d "$mirror" ]; then
git clone --quiet --mirror -- "$repository" "$mirror" || die "cannot clone repository: $repository"
fi
git --git-dir="$mirror" fetch --quiet --force origin "$ref" || die "cannot fetch ref: $ref"
commit=$(git --git-dir="$mirror" rev-parse --verify 'FETCH_HEAD^{commit}') || die "ref does not resolve to a commit: $ref"
artifact=$store/artifacts/$repo_key/$commit
if [ ! -d "$artifact" ]; then
temporary=$artifact.tmp.$$
rm -rf -- "$temporary"
mkdir -p -- "$temporary"
if ! git --git-dir="$mirror" archive "$commit" | tar -x -C "$temporary"; then
rm -rf -- "$temporary"
die "cannot materialize commit: $commit"
artifact=$store/sha256/$digest
tree=$artifact/tree
if [ -d "$artifact" ]; then
[ -f "$artifact/complete" ] && [ "$(cat "$artifact/complete")" = "$digest" ] || die "incomplete artifact store entry: $artifact"
else
mkdir -p -- "$store/sha256"
lock=$store/sha256/.$digest.lock
mkdir "$lock" 2>/dev/null || die "artifact import already in progress: $digest"
temporary=$store/sha256/.$digest.tmp.$$
cleanup_lock=$lock
cleanup_artifact=$temporary
mkdir -p -- "$temporary/tree"
validate_archive_listing "$archive" "$work_root/names" "$work_root/types"
tar -xf "$archive" -C "$temporary/tree" || die "cannot extract archive: $archive"
if find "$temporary/tree" -type l -print | grep . >/dev/null 2>&1; then
die 'archive extracted symlinks'
fi
mv -- "$temporary" "$artifact"
if find "$temporary/tree" ! -type d ! -type f -print | grep . >/dev/null 2>&1; then
die 'archive extracted non-file entries'
fi
printf '%s\n' "$digest" >"$temporary/complete"
chmod -R a-w "$temporary"
mv "$temporary" "$artifact"
cleanup_artifact=
rmdir "$lock"
cleanup_lock=
fi
if [ "$#" -eq 0 ]; then
set --
for source_path in "$artifact"/skills/*; do
[ -d "$source_path" ] || continue
[ -f "$source_path/SKILL.md" ] || continue
set -- "$@" "skills/$(basename -- "$source_path")"
for skill_dir in "$tree"/skills/*; do
[ -d "$skill_dir" ] && [ -f "$skill_dir/SKILL.md" ] || continue
set -- "$@" "skills/$(basename -- "$skill_dir")"
done
[ "$#" -gt 0 ] || die "repository has no skills/*/SKILL.md at $commit"
[ "$#" -gt 0 ] || die 'archive has no skills/*/SKILL.md directories'
fi
for skill_path do
reject_record_breaks "$skill_path"
case $skill_path in
''|/*|*:*|..|../*|*/../*|*/..) die "invalid repository skill path: $skill_path" ;;
for archive_path do
case $archive_path in
''|/*|*:*|..|../*|*/../*|*/..) die "invalid archive skill path: $archive_path" ;;
esac
source_path=$artifact/$skill_path
source_path=$tree/$archive_path
validate_skill_dir "$source_path"
if find "$source_path" -type l -print | grep . >/dev/null 2>&1; then
die "remote skill contains symlinks: $skill_path"
fi
digest=$(git --git-dir="$mirror" archive "$commit:$skill_path" | sha256sum | awk '{print $1}') || die "cannot hash skill artifact: $skill_path"
check_source_record "$skill_name" "$repository" "$ref" "$commit" "$skill_path" "$digest"
link_skill "$skill_name" "$source_path" "$repository" "$commit" "$digest"
append_source_record
name=$skill_name
manifest_check "$name" "$digest" "$archive_path"
link_skill "$name" "$source_path" artifact "$digest"
manifest_append
done