Install pinned skills from Git repositories

This commit is contained in:
tmk241
2026-08-11 16:54:17 +02:00
parent cb555a41b2
commit efc4b70e13
3 changed files with 292 additions and 57 deletions
+25 -7
View File
@@ -34,13 +34,31 @@ Install into one project instead of the user catalogue:
ink-skills install --project /path/to/project configure-ink-agent ink-skills install --project /path/to/project configure-ink-agent
``` ```
The installer is intentionally smaller than `npx skills`: no registry, package Install from any Git repository your normal Git credentials can read:
manager, network access, copies, prompts, lockfile, or hidden state. It creates
absolute symlinks from `$HOME/.ink/skills` (or the installer-only ```sh
ink-skills add --ref main git@git.example:team/skills.git
ink-skills add https://git.example/team/skills.git skills/review-sql
```
`add` resolves the ref to one commit, exports it into a content-addressed store,
computes a SHA-256 over each selected skill tree, and symlinks that immutable
artifact. Provenance is recorded next to installed links in `.ink-skills.tsv`:
source, requested ref, resolved commit, path, and SHA-256. It deliberately does
not edit `SKILL.md` comments. A moved branch does not silently update an installed
skill; the existing pin causes a visible provenance collision.
Git SSH agents and credential helpers remain Git's job. Credential-bearing HTTP
URLs are refused so secrets do not enter manifests or process listings. Remote
skill trees containing symlinks are also refused.
The local `install` path is intentionally smaller than `npx skills`: no registry,
package manager, network access, copies, prompts, lockfile, or hidden state. It
creates absolute symlinks from `$HOME/.ink/skills` (or the installer-only
`$INK_SKILLS_HOME` target override) to this checkout. Pulling the repository `$INK_SKILLS_HOME` target override) to this checkout. Pulling the repository
updates installed skills; restarting Ink freezes the new bytes into the next updates locally installed skills; restarting Ink freezes the new bytes into the
startup snapshot. Ink itself discovers `$HOME/.ink/skills`, `$CWD/.ink/skills`, next startup snapshot. Ink itself discovers `$HOME/.ink/skills`,
and colon-separated `INK_SKILLS_DIRS`. `$CWD/.ink/skills`, and colon-separated `INK_SKILLS_DIRS`.
`ink-skills list` emits TSV. `ink-skills --help` is the complete command manual. `ink-skills list` emits TSV. `ink-skills --help` is the complete command manual.
Existing paths and foreign symlinks are refused rather than overwritten. Existing paths and foreign symlinks are refused rather than overwritten.
@@ -85,7 +103,7 @@ sh test/skills-smoke.sh
## Refusals ## Refusals
- No npm package merely to create symlinks. - No npm package merely to create symlinks.
- No skill registry or update daemon. - No skill registry, automatic updater, or network daemon.
- No policy mutation during installation. - No policy mutation during installation.
- No bundled binaries; those belong in `toolset`. - No bundled binaries; those belong in `toolset`.
- No automatic installation by Ink itself. - No automatic installation by Ink itself.
+217 -49
View File
@@ -5,27 +5,40 @@ usage() {
cat <<'EOF' cat <<'EOF'
usage: ink-skills list usage: ink-skills list
ink-skills install [--user | --project DIR] [SKILL ...] 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: Commands:
list List available skill names and source paths as TSV. list List skills in this checkout as TSV.
install Link named skills; with no names, link every skill. 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: Targets:
--user $INK_SKILLS_HOME or $HOME/.ink/skills (default) --user $INK_SKILLS_HOME or $HOME/.ink/skills (default)
--project DIR DIR/.ink/skills --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: Output:
TSV with SKILL, TARGET, and ACTION columns. TSV with SKILL, TARGET, ACTION, SOURCE, COMMIT, and SHA256 columns.
Exit status: 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: Examples:
ink-skills list ink-skills list
ink-skills install audit-ink-cli configure-ink-agent ink-skills install audit-ink-cli configure-ink-agent
ink-skills install --project . create-ink-tool 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 EOF
} }
@@ -34,6 +47,13 @@ die() {
exit 3 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) script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd -P)
repo_dir=$(CDPATH= cd -- "$script_dir/.." && pwd -P) repo_dir=$(CDPATH= cd -- "$script_dir/.." && pwd -P)
skills_dir=$repo_dir/skills skills_dir=$repo_dir/skills
@@ -47,11 +67,110 @@ list_skills() {
done 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 ] || { [ "$#" -gt 0 ] || {
usage >&2 usage >&2
exit 2 exit 2
} }
command=$1 command=$1
shift shift
case $command in case $command in
@@ -67,7 +186,7 @@ case $command in
list_skills list_skills
exit 0 exit 0
;; ;;
install) ;; install|add) ;;
*) *)
usage >&2 usage >&2
exit 2 exit 2
@@ -76,19 +195,20 @@ esac
target_mode=user target_mode=user
target_arg= target_arg=
ref=HEAD
while [ "$#" -gt 0 ]; do while [ "$#" -gt 0 ]; do
parse_target_option "$@"
if [ "$shift_count" -gt 0 ]; then
shift "$shift_count"
continue
fi
case $1 in case $1 in
--user) --ref)
target_mode=user [ "$command" = add ] && [ "$#" -ge 2 ] || {
shift
;;
--project)
[ "$#" -ge 2 ] || {
usage >&2 usage >&2
exit 2 exit 2
} }
target_mode=project ref=$2
target_arg=$2
shift 2 shift 2
;; ;;
--) --)
@@ -102,50 +222,98 @@ while [ "$#" -gt 0 ]; do
*) break ;; *) break ;;
esac esac
done done
select_target
case $target_mode in printf 'SKILL\tTARGET\tACTION\tSOURCE\tCOMMIT\tSHA256\n'
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" if [ "$command" = install ]; then
if [ "$#" -eq 0 ]; then
if [ "$#" -eq 0 ]; then
set -- set --
for path in "$skills_dir"/*; do for path in "$skills_dir"/*; do
[ -d "$path" ] || continue [ -d "$path" ] || continue
[ -f "$path/SKILL.md" ] || continue [ -f "$path/SKILL.md" ] || continue
set -- "$@" "$(basename -- "$path")" set -- "$@" "$(basename -- "$path")"
done done
fi fi
for skill do
printf 'SKILL\tTARGET\tACTION\n'
for skill do
case $skill in case $skill in
''|.*|*/*) die "invalid skill name: $skill" ;; ''|.*|*/*) die "invalid skill name: $skill" ;;
esac esac
source=$skills_dir/$skill source_path=$skills_dir/$skill
[ -d "$source" ] && [ -f "$source/SKILL.md" ] || die "unknown skill: $skill" validate_skill_dir "$source_path"
destination=$target/$skill link_skill "$skill_name" "$source_path" local - -
if [ -L "$destination" ]; then done
linked=$(readlink "$destination") exit 0
[ "$linked" = "$source" ] || die "refusing foreign symlink: $destination -> $linked" fi
action=unchanged
elif [ -e "$destination" ]; then [ "$#" -gt 0 ] || {
die "refusing existing path: $destination" usage >&2
else exit 2
ln -s -- "$source" "$destination" }
action=linked 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'
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 fi
printf '%s\t%s\t%s\n' "$skill" "$destination" "$action" mv -- "$temporary" "$artifact"
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")"
done
[ "$#" -gt 0 ] || die "repository has no skills/*/SKILL.md at $commit"
fi
for skill_path do
reject_record_breaks "$skill_path"
case $skill_path in
''|/*|*:*|..|../*|*/../*|*/..) die "invalid repository skill path: $skill_path" ;;
esac
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
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 done
+49
View File
@@ -30,4 +30,53 @@ HOME=$tmp/home $repo/bin/ink-skills install --project "$tmp/project" create-ink-
[ -L "$tmp/project/.ink/skills/create-ink-tool" ] [ -L "$tmp/project/.ink/skills/create-ink-tool" ]
grep "create-ink-tool.*linked" "$tmp/project.tsv" >/dev/null grep "create-ink-tool.*linked" "$tmp/project.tsv" >/dev/null
remote=$tmp/remote
mkdir -p "$remote/skills/remote-review"
cat >"$remote/skills/remote-review/SKILL.md" <<'EOF'
---
name: remote-review
description: Review one remote fixture.
---
# Remote review
EOF
git -C "$remote" init -q
git -C "$remote" config user.email ink-skills@example.invalid
git -C "$remote" config user.name 'Ink Skills Test'
git -C "$remote" add skills/remote-review/SKILL.md
git -C "$remote" commit -qm fixture
commit=$(git -C "$remote" rev-parse HEAD)
mkdir -p "$tmp/remote-home"
HOME=$tmp/remote-home INK_SKILLS_STORE=$tmp/store $repo/bin/ink-skills add --ref HEAD "$remote" skills/remote-review >"$tmp/add.tsv"
remote_link=$tmp/remote-home/.ink/skills/remote-review
[ -L "$remote_link" ]
case $(readlink "$remote_link") in
"$tmp/store/artifacts/"*"/$commit/skills/remote-review") ;;
*) echo 'remote link is not pinned to resolved commit' >&2; exit 1 ;;
esac
grep "remote-review.*linked.*$commit" "$tmp/add.tsv" >/dev/null
manifest=$tmp/remote-home/.ink/skills/.ink-skills.tsv
[ "$(wc -l <"$manifest")" -eq 2 ]
awk -F '\t' -v commit="$commit" 'NR == 2 { exit !($1 == "remote-review" && $4 == commit && length($6) == 64) }' "$manifest"
HOME=$tmp/remote-home INK_SKILLS_STORE=$tmp/store $repo/bin/ink-skills add --ref HEAD "$remote" skills/remote-review >"$tmp/readd.tsv"
grep 'remote-review.*unchanged' "$tmp/readd.tsv" >/dev/null
[ "$(wc -l <"$manifest")" -eq 2 ]
printf '\nchanged\n' >>"$remote/skills/remote-review/SKILL.md"
git -C "$remote" add skills/remote-review/SKILL.md
git -C "$remote" commit -qm changed
if HOME=$tmp/remote-home INK_SKILLS_STORE=$tmp/store $repo/bin/ink-skills add --ref HEAD "$remote" skills/remote-review >/dev/null 2>"$tmp/pin.err"; then
echo 'expected provenance collision after ref moves' >&2
exit 1
fi
grep 'provenance collision' "$tmp/pin.err" >/dev/null
if HOME=$tmp/remote-home INK_SKILLS_STORE=$tmp/store $repo/bin/ink-skills add 'https://user:secret@example.invalid/skills.git' >/dev/null 2>"$tmp/credential.err"; then
echo 'expected credentialed URL refusal' >&2
exit 1
fi
grep 'credentialed HTTP URLs are refused' "$tmp/credential.err" >/dev/null
printf 'ok\n' printf 'ok\n'