Install pinned skills from Git repositories
This commit is contained in:
+218
-50
@@ -5,27 +5,40 @@ usage() {
|
||||
cat <<'EOF'
|
||||
usage: ink-skills list
|
||||
ink-skills install [--user | --project DIR] [SKILL ...]
|
||||
ink-skills add [--user | --project DIR] [--ref REF] REPOSITORY [PATH ...]
|
||||
|
||||
Install Ink skills from this checkout as symlinks.
|
||||
Install Ink skills as symlinks from this checkout or a pinned Git artifact.
|
||||
|
||||
Commands:
|
||||
list List available skill names and source paths as TSV.
|
||||
install Link named skills; with no names, link every skill.
|
||||
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.
|
||||
|
||||
Targets:
|
||||
--user $INK_SKILLS_HOME or $HOME/.ink/skills (default)
|
||||
--project DIR DIR/.ink/skills
|
||||
|
||||
Git storage:
|
||||
$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.
|
||||
|
||||
Output:
|
||||
TSV with SKILL, TARGET, and ACTION columns.
|
||||
TSV with SKILL, TARGET, ACTION, SOURCE, COMMIT, and SHA256 columns.
|
||||
|
||||
Exit status:
|
||||
0 success; 2 usage error; 3 target collision or invalid skill.
|
||||
0 success; 2 usage error; 3 collision, invalid source, or fetch failure.
|
||||
|
||||
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
|
||||
EOF
|
||||
}
|
||||
|
||||
@@ -34,6 +47,13 @@ die() {
|
||||
exit 3
|
||||
}
|
||||
|
||||
reject_record_breaks() {
|
||||
case $1 in
|
||||
*" "*|*"
|
||||
"*) die "tabs and newlines are not allowed: $1" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd -P)
|
||||
repo_dir=$(CDPATH= cd -- "$script_dir/.." && pwd -P)
|
||||
skills_dir=$repo_dir/skills
|
||||
@@ -47,11 +67,110 @@ list_skills() {
|
||||
done
|
||||
}
|
||||
|
||||
select_target() {
|
||||
case $target_mode in
|
||||
user)
|
||||
if [ -n "${INK_SKILLS_HOME:-}" ]; then
|
||||
target=$INK_SKILLS_HOME
|
||||
else
|
||||
[ -n "${HOME:-}" ] || die 'HOME is unset; set HOME or INK_SKILLS_HOME'
|
||||
target=$HOME/.ink/skills
|
||||
fi
|
||||
;;
|
||||
project)
|
||||
project=$(CDPATH= cd -- "$target_arg" 2>/dev/null && pwd -P) || die "project directory not found: $target_arg"
|
||||
target=$project/.ink/skills
|
||||
;;
|
||||
esac
|
||||
mkdir -p -- "$target"
|
||||
}
|
||||
|
||||
parse_target_option() {
|
||||
case $1 in
|
||||
--user)
|
||||
target_mode=user
|
||||
shift_count=1
|
||||
;;
|
||||
--project)
|
||||
[ "$#" -ge 2 ] || {
|
||||
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
|
||||
}
|
||||
|
||||
link_skill() {
|
||||
skill_name=$1
|
||||
source_path=$2
|
||||
source_label=$3
|
||||
commit=$4
|
||||
digest=$5
|
||||
destination=$target/$skill_name
|
||||
if [ -L "$destination" ]; then
|
||||
linked=$(readlink "$destination")
|
||||
[ "$linked" = "$source_path" ] || die "refusing foreign symlink: $destination -> $linked"
|
||||
action=unchanged
|
||||
elif [ -e "$destination" ]; then
|
||||
die "refusing existing path: $destination"
|
||||
else
|
||||
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"
|
||||
}
|
||||
|
||||
check_source_record() {
|
||||
skill_name=$1
|
||||
source_label=$2
|
||||
ref=$3
|
||||
commit=$4
|
||||
skill_path=$5
|
||||
digest=$6
|
||||
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"
|
||||
record_needed=no
|
||||
fi
|
||||
}
|
||||
|
||||
append_source_record() {
|
||||
[ "$record_needed" = yes ] || return 0
|
||||
manifest=$target/.ink-skills.tsv
|
||||
if [ ! -e "$manifest" ]; then
|
||||
printf 'SKILL\tSOURCE\tREF\tCOMMIT\tPATH\tSHA256\n' >"$manifest"
|
||||
fi
|
||||
printf '%s\t%s\t%s\t%s\t%s\t%s\n' "$skill_name" "$repository" "$ref" "$commit" "$skill_path" "$digest" >>"$manifest"
|
||||
}
|
||||
|
||||
[ "$#" -gt 0 ] || {
|
||||
usage >&2
|
||||
exit 2
|
||||
}
|
||||
|
||||
command=$1
|
||||
shift
|
||||
case $command in
|
||||
@@ -67,7 +186,7 @@ case $command in
|
||||
list_skills
|
||||
exit 0
|
||||
;;
|
||||
install) ;;
|
||||
install|add) ;;
|
||||
*)
|
||||
usage >&2
|
||||
exit 2
|
||||
@@ -76,19 +195,20 @@ 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
|
||||
--user)
|
||||
target_mode=user
|
||||
shift
|
||||
;;
|
||||
--project)
|
||||
[ "$#" -ge 2 ] || {
|
||||
--ref)
|
||||
[ "$command" = add ] && [ "$#" -ge 2 ] || {
|
||||
usage >&2
|
||||
exit 2
|
||||
}
|
||||
target_mode=project
|
||||
target_arg=$2
|
||||
ref=$2
|
||||
shift 2
|
||||
;;
|
||||
--)
|
||||
@@ -102,50 +222,98 @@ while [ "$#" -gt 0 ]; do
|
||||
*) break ;;
|
||||
esac
|
||||
done
|
||||
select_target
|
||||
|
||||
case $target_mode in
|
||||
user)
|
||||
if [ -n "${INK_SKILLS_HOME:-}" ]; then
|
||||
target=$INK_SKILLS_HOME
|
||||
else
|
||||
[ -n "${HOME:-}" ] || die 'HOME is unset; set HOME or INK_SKILLS_HOME'
|
||||
target=$HOME/.ink/skills
|
||||
fi
|
||||
;;
|
||||
project)
|
||||
project=$(CDPATH= cd -- "$target_arg" 2>/dev/null && pwd -P) || die "project directory not found: $target_arg"
|
||||
target=$project/.ink/skills
|
||||
;;
|
||||
printf 'SKILL\tTARGET\tACTION\tSOURCE\tCOMMIT\tSHA256\n'
|
||||
|
||||
if [ "$command" = install ]; then
|
||||
if [ "$#" -eq 0 ]; then
|
||||
set --
|
||||
for path in "$skills_dir"/*; do
|
||||
[ -d "$path" ] || continue
|
||||
[ -f "$path/SKILL.md" ] || continue
|
||||
set -- "$@" "$(basename -- "$path")"
|
||||
done
|
||||
fi
|
||||
for skill do
|
||||
case $skill in
|
||||
''|.*|*/*) die "invalid skill name: $skill" ;;
|
||||
esac
|
||||
source_path=$skills_dir/$skill
|
||||
validate_skill_dir "$source_path"
|
||||
link_skill "$skill_name" "$source_path" local - -
|
||||
done
|
||||
exit 0
|
||||
fi
|
||||
|
||||
[ "$#" -gt 0 ] || {
|
||||
usage >&2
|
||||
exit 2
|
||||
}
|
||||
repository=$1
|
||||
shift
|
||||
reject_record_breaks "$repository"
|
||||
reject_record_breaks "$ref"
|
||||
case $ref in
|
||||
-*) die "invalid ref: $ref" ;;
|
||||
esac
|
||||
case $repository in
|
||||
http://*@*|https://*@*) die 'credentialed HTTP URLs are refused; use an SSH agent or Git credential helper' ;;
|
||||
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'
|
||||
|
||||
mkdir -p -- "$target"
|
||||
if [ -n "${INK_SKILLS_STORE:-}" ]; then
|
||||
store=$INK_SKILLS_STORE
|
||||
elif [ -n "${XDG_DATA_HOME:-}" ]; then
|
||||
store=$XDG_DATA_HOME/ink-skills
|
||||
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"
|
||||
fi
|
||||
mv -- "$temporary" "$artifact"
|
||||
fi
|
||||
|
||||
if [ "$#" -eq 0 ]; then
|
||||
set --
|
||||
for path in "$skills_dir"/*; do
|
||||
[ -d "$path" ] || continue
|
||||
[ -f "$path/SKILL.md" ] || continue
|
||||
set -- "$@" "$(basename -- "$path")"
|
||||
for source_path in "$artifact"/skills/*; do
|
||||
[ -d "$source_path" ] || continue
|
||||
[ -f "$source_path/SKILL.md" ] || continue
|
||||
set -- "$@" "skills/$(basename -- "$source_path")"
|
||||
done
|
||||
[ "$#" -gt 0 ] || die "repository has no skills/*/SKILL.md at $commit"
|
||||
fi
|
||||
|
||||
printf 'SKILL\tTARGET\tACTION\n'
|
||||
for skill do
|
||||
case $skill in
|
||||
''|.*|*/*) die "invalid skill name: $skill" ;;
|
||||
for skill_path do
|
||||
reject_record_breaks "$skill_path"
|
||||
case $skill_path in
|
||||
''|/*|*:*|..|../*|*/../*|*/..) die "invalid repository skill path: $skill_path" ;;
|
||||
esac
|
||||
source=$skills_dir/$skill
|
||||
[ -d "$source" ] && [ -f "$source/SKILL.md" ] || die "unknown skill: $skill"
|
||||
destination=$target/$skill
|
||||
if [ -L "$destination" ]; then
|
||||
linked=$(readlink "$destination")
|
||||
[ "$linked" = "$source" ] || die "refusing foreign symlink: $destination -> $linked"
|
||||
action=unchanged
|
||||
elif [ -e "$destination" ]; then
|
||||
die "refusing existing path: $destination"
|
||||
else
|
||||
ln -s -- "$source" "$destination"
|
||||
action=linked
|
||||
source_path=$artifact/$skill_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
|
||||
printf '%s\t%s\t%s\n' "$skill" "$destination" "$action"
|
||||
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
|
||||
done
|
||||
|
||||
Reference in New Issue
Block a user