Install pinned skills from Git repositories
This commit is contained in:
@@ -34,13 +34,31 @@ Install into one project instead of the user catalogue:
|
||||
ink-skills install --project /path/to/project configure-ink-agent
|
||||
```
|
||||
|
||||
The installer 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
|
||||
Install from any Git repository your normal Git credentials can read:
|
||||
|
||||
```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
|
||||
updates installed skills; restarting Ink freezes the new bytes into the next
|
||||
startup snapshot. Ink itself discovers `$HOME/.ink/skills`, `$CWD/.ink/skills`,
|
||||
and colon-separated `INK_SKILLS_DIRS`.
|
||||
updates locally installed skills; restarting Ink freezes the new bytes into the
|
||||
next startup snapshot. Ink itself discovers `$HOME/.ink/skills`,
|
||||
`$CWD/.ink/skills`, and colon-separated `INK_SKILLS_DIRS`.
|
||||
|
||||
`ink-skills list` emits TSV. `ink-skills --help` is the complete command manual.
|
||||
Existing paths and foreign symlinks are refused rather than overwritten.
|
||||
@@ -85,7 +103,7 @@ sh test/skills-smoke.sh
|
||||
## Refusals
|
||||
|
||||
- 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 bundled binaries; those belong in `toolset`.
|
||||
- No automatic installation by Ink itself.
|
||||
|
||||
+215
-47
@@ -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,24 +222,11 @@ 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
|
||||
;;
|
||||
esac
|
||||
|
||||
mkdir -p -- "$target"
|
||||
printf 'SKILL\tTARGET\tACTION\tSOURCE\tCOMMIT\tSHA256\n'
|
||||
|
||||
if [ "$command" = install ]; then
|
||||
if [ "$#" -eq 0 ]; then
|
||||
set --
|
||||
for path in "$skills_dir"/*; do
|
||||
@@ -128,24 +235,85 @@ if [ "$#" -eq 0 ]; then
|
||||
set -- "$@" "$(basename -- "$path")"
|
||||
done
|
||||
fi
|
||||
|
||||
printf 'SKILL\tTARGET\tACTION\n'
|
||||
for skill do
|
||||
case $skill in
|
||||
''|.*|*/*) die "invalid skill name: $skill" ;;
|
||||
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
|
||||
fi
|
||||
printf '%s\t%s\t%s\n' "$skill" "$destination" "$action"
|
||||
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'
|
||||
|
||||
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 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
|
||||
|
||||
@@ -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" ]
|
||||
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'
|
||||
|
||||
Reference in New Issue
Block a user