Import verified skill archives
This commit is contained in:
@@ -5,7 +5,7 @@
|
|||||||
## Layout
|
## Layout
|
||||||
|
|
||||||
- `skills/<name>/SKILL.md` — one narrowly triggered on-demand behavior.
|
- `skills/<name>/SKILL.md` — one narrowly triggered on-demand behavior.
|
||||||
- `bin/ink-skills` — dependency-free installer; stdout is TSV, diagnostics stderr.
|
- `bin/ink-skills` — POSIX linker/importer; stdout is TSV, diagnostics stderr.
|
||||||
- `test/install-smoke.sh` — installer contract smoke.
|
- `test/install-smoke.sh` — installer contract smoke.
|
||||||
|
|
||||||
## Rules
|
## Rules
|
||||||
@@ -13,7 +13,8 @@
|
|||||||
- A skill owns reusable judgment, never runtime policy or repeatable mechanics.
|
- A skill owns reusable judgment, never runtime policy or repeatable mechanics.
|
||||||
- Ink source and `REQUIREMENTS.md` own host behavior; `toolset` owns external executables.
|
- Ink source and `REQUIREMENTS.md` own host behavior; `toolset` owns external executables.
|
||||||
- Keep skill names lowercase and hyphenated; directory and frontmatter name must match.
|
- Keep skill names lowercase and hyphenated; directory and frontmatter name must match.
|
||||||
- The installer only creates symlinks and must refuse collisions. Do not add a registry,
|
- `link` only creates symlinks. `import` may materialize only a caller-supplied,
|
||||||
network calls, package-manager dependency, prompts, copies, or hidden state.
|
SHA-256-verified local archive in the content-addressed store. Do not add URL,
|
||||||
|
Git, credential, registry, package-manager, prompt, or updater behavior.
|
||||||
- Run `sh -n bin/ink-skills`, `sh test/install-smoke.sh`, and
|
- Run `sh -n bin/ink-skills`, `sh test/install-smoke.sh`, and
|
||||||
`sh test/skills-smoke.sh` after changes.
|
`sh test/skills-smoke.sh` after changes.
|
||||||
|
|||||||
@@ -7,61 +7,58 @@ This repository owns reusable Ink judgment. Ink owns runtime enforcement and
|
|||||||
policy. [`toolset`](https://git.tmk241.com/tmk241/toolset) owns compiled external
|
policy. [`toolset`](https://git.tmk241.com/tmk241/toolset) owns compiled external
|
||||||
executables. Skills never grant authority by themselves.
|
executables. Skills never grant authority by themselves.
|
||||||
|
|
||||||
## Install
|
## Link local skills
|
||||||
|
|
||||||
Clone once, then symlink the skills you want:
|
Clone once, then link every skill shipped by this checkout:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
git clone git@git.tmk241.com:tmk241/ink-skills.git
|
git clone git@git.tmk241.com:tmk241/ink-skills.git
|
||||||
ink-skills/bin/ink-skills install
|
ink-skills/bin/ink-skills link
|
||||||
```
|
```
|
||||||
|
|
||||||
If the repository is already under `/opt/repositories`:
|
Link selected directories or target one project:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
/opt/repositories/ink-skills/bin/ink-skills install
|
ink-skills link /opt/repositories/ink-skills/skills/audit-ink-cli
|
||||||
|
ink-skills link --project /path/to/project skills/configure-ink-agent
|
||||||
```
|
```
|
||||||
|
|
||||||
Install selected skills only:
|
`link` only creates absolute symlinks. It performs no network access, copies,
|
||||||
|
prompts, registry lookup, or policy mutation. Pulling a linked checkout changes
|
||||||
|
its bytes; restart Ink to freeze the updated skill snapshot.
|
||||||
|
|
||||||
|
## Import verified artifacts
|
||||||
|
|
||||||
|
Transport and authentication remain ordinary shell jobs:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
ink-skills install audit-ink-cli configure-ink-agent
|
curl -fLo skills.tar https://example/skills.tar
|
||||||
|
git archive --format=tar HEAD >skills.tar
|
||||||
```
|
```
|
||||||
|
|
||||||
Install into one project instead of the user catalogue:
|
The publisher communicates the expected digest out of band. Import only after you
|
||||||
|
have that value:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
ink-skills install --project /path/to/project configure-ink-agent
|
ink-skills import sha256:012345... skills.tar
|
||||||
|
ink-skills import --project /path/to/project sha256:012345... skills.tar skills/review-sql
|
||||||
```
|
```
|
||||||
|
|
||||||
Install from any Git repository your normal Git credentials can read:
|
`import` verifies the complete archive before extraction, accepts only regular
|
||||||
|
files and directories with safe relative paths, and rejects symlinks and special
|
||||||
|
files. It materializes the tree under
|
||||||
|
`$INK_SKILLS_STORE/sha256/HASH` (or the XDG/default data path), then links skills
|
||||||
|
from that immutable content-addressed location. `.ink-skills.tsv` records each
|
||||||
|
installed skill's archive digest and path without modifying `SKILL.md`.
|
||||||
|
|
||||||
```sh
|
The same artifact works whether it arrived via curl, scp, USB, a browser download,
|
||||||
ink-skills add --ref main git@git.example:team/skills.git
|
or `git archive`. `ink-skills` deliberately has no URL, Git, credential, branch,
|
||||||
ink-skills add https://git.example/team/skills.git skills/review-sql
|
release, or update logic.
|
||||||
```
|
|
||||||
|
|
||||||
`add` resolves the ref to one commit, exports it into a content-addressed store,
|
Ink discovers `$HOME/.ink/skills`, `$CWD/.ink/skills`, and colon-separated
|
||||||
computes a SHA-256 over each selected skill tree, and symlinks that immutable
|
`INK_SKILLS_DIRS`. The installer-only `$INK_SKILLS_HOME` overrides the user link
|
||||||
artifact. Provenance is recorded next to installed links in `.ink-skills.tsv`:
|
target. `ink-skills list` emits TSV; `ink-skills --help` is the complete command
|
||||||
source, requested ref, resolved commit, path, and SHA-256. It deliberately does
|
manual. Existing paths and foreign symlinks are refused rather than overwritten.
|
||||||
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 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.
|
|
||||||
|
|
||||||
## Skills
|
## Skills
|
||||||
|
|
||||||
|
|||||||
+202
-169
@@ -4,41 +4,41 @@ set -eu
|
|||||||
usage() {
|
usage() {
|
||||||
cat <<'EOF'
|
cat <<'EOF'
|
||||||
usage: ink-skills list
|
usage: ink-skills list
|
||||||
ink-skills install [--user | --project DIR] [SKILL ...]
|
ink-skills link [--user | --project DIR] [SKILL_DIR ...]
|
||||||
ink-skills add [--user | --project DIR] [--ref REF] REPOSITORY [PATH ...]
|
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:
|
Commands:
|
||||||
list List skills in this checkout as TSV.
|
list List skills shipped by this checkout as TSV.
|
||||||
install Link named local skills; with no names, link every skill.
|
link Symlink local skill directories. With no directories, link every
|
||||||
add Fetch REPOSITORY, pin REF to a commit, materialize an
|
skill shipped by this checkout.
|
||||||
immutable snapshot, and link skill PATHs from it. PATH
|
import Verify ARCHIVE against the required SHA-256, safely materialize its
|
||||||
defaults to every skills/*/SKILL.md directory.
|
immutable tree, then link selected skill PATHs. PATH defaults to
|
||||||
|
every skills/*/SKILL.md directory in the archive.
|
||||||
|
|
||||||
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:
|
Artifact store:
|
||||||
$INK_SKILLS_STORE or $XDG_DATA_HOME/ink-skills, otherwise
|
$INK_SKILLS_STORE or $XDG_DATA_HOME/ink-skills, otherwise
|
||||||
$HOME/.local/share/ink-skills. Git, tar, and sha256sum are required by `add`.
|
$HOME/.local/share/ink-skills.
|
||||||
Authentication belongs to Git's SSH agent or credential helper; credentialed
|
|
||||||
HTTP URLs are refused. Installed provenance is written to
|
Transport is deliberately external:
|
||||||
TARGET/.ink-skills.tsv without modifying SKILL.md.
|
curl -fLo skills.tar URL
|
||||||
|
ink-skills import sha256:HASH skills.tar
|
||||||
|
|
||||||
Output:
|
Output:
|
||||||
TSV with SKILL, TARGET, ACTION, SOURCE, COMMIT, and SHA256 columns.
|
TSV with SKILL, TARGET, ACTION, SOURCE, and SHA256 columns.
|
||||||
|
|
||||||
Exit status:
|
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:
|
Requirements:
|
||||||
ink-skills list
|
POSIX sh and standard text tools. `import` additionally needs tar and one of
|
||||||
ink-skills install audit-ink-cli configure-ink-agent
|
sha256sum, shasum, or openssl.
|
||||||
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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,6 +47,22 @@ die() {
|
|||||||
exit 3
|
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() {
|
reject_record_breaks() {
|
||||||
case $1 in
|
case $1 in
|
||||||
*" "*|*"
|
*" "*|*"
|
||||||
@@ -54,17 +70,20 @@ reject_record_breaks() {
|
|||||||
esac
|
esac
|
||||||
}
|
}
|
||||||
|
|
||||||
script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd -P)
|
frontmatter_name() {
|
||||||
repo_dir=$(CDPATH= cd -- "$script_dir/.." && pwd -P)
|
sed -n 's/^name:[[:space:]]*//p' "$1/SKILL.md" | sed -n '1p'
|
||||||
skills_dir=$repo_dir/skills
|
}
|
||||||
|
|
||||||
list_skills() {
|
validate_skill_dir() {
|
||||||
printf 'SKILL\tSOURCE\n'
|
skill_dir=$1
|
||||||
for path in "$skills_dir"/*; do
|
[ -d "$skill_dir" ] && [ -f "$skill_dir/SKILL.md" ] || die "not a skill directory: $skill_dir"
|
||||||
[ -d "$path" ] || continue
|
[ ! -L "$skill_dir" ] && [ ! -L "$skill_dir/SKILL.md" ] || die "skill root and SKILL.md must not be symlinks: $skill_dir"
|
||||||
[ -f "$path/SKILL.md" ] || continue
|
skill_name=$(frontmatter_name "$skill_dir")
|
||||||
printf '%s\t%s\n' "$(basename -- "$path")" "$path"
|
[ -n "$skill_name" ] || die "missing frontmatter name: $skill_dir/SKILL.md"
|
||||||
done
|
[ "$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() {
|
select_target() {
|
||||||
@@ -85,48 +104,48 @@ select_target() {
|
|||||||
mkdir -p -- "$target"
|
mkdir -p -- "$target"
|
||||||
}
|
}
|
||||||
|
|
||||||
parse_target_option() {
|
parse_target_options() {
|
||||||
case $1 in
|
while [ "$#" -gt 0 ]; do
|
||||||
--user)
|
case $1 in
|
||||||
target_mode=user
|
--user)
|
||||||
shift_count=1
|
target_mode=user
|
||||||
;;
|
shift
|
||||||
--project)
|
;;
|
||||||
[ "$#" -ge 2 ] || {
|
--project)
|
||||||
|
[ "$#" -ge 2 ] || {
|
||||||
|
usage >&2
|
||||||
|
exit 2
|
||||||
|
}
|
||||||
|
target_mode=project
|
||||||
|
target_arg=$2
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--)
|
||||||
|
shift
|
||||||
|
break
|
||||||
|
;;
|
||||||
|
-*)
|
||||||
usage >&2
|
usage >&2
|
||||||
exit 2
|
exit 2
|
||||||
}
|
;;
|
||||||
target_mode=project
|
*) break ;;
|
||||||
target_arg=$2
|
esac
|
||||||
shift_count=2
|
done
|
||||||
;;
|
remaining_count=$#
|
||||||
*) shift_count=0 ;;
|
remaining_file=$work_args
|
||||||
esac
|
: >"$remaining_file"
|
||||||
}
|
for arg do
|
||||||
|
reject_record_breaks "$arg"
|
||||||
frontmatter_name() {
|
printf '%s\n' "$arg" >>"$remaining_file"
|
||||||
sed -n 's/^name:[[:space:]]*//p' "$1/SKILL.md" | sed -n '1p'
|
done
|
||||||
}
|
|
||||||
|
|
||||||
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() {
|
link_skill() {
|
||||||
skill_name=$1
|
name=$1
|
||||||
source_path=$2
|
source_path=$2
|
||||||
source_label=$3
|
source_label=$3
|
||||||
commit=$4
|
digest=$4
|
||||||
digest=$5
|
destination=$target/$name
|
||||||
destination=$target/$skill_name
|
|
||||||
if [ -L "$destination" ]; then
|
if [ -L "$destination" ]; then
|
||||||
linked=$(readlink "$destination")
|
linked=$(readlink "$destination")
|
||||||
[ "$linked" = "$source_path" ] || die "refusing foreign symlink: $destination -> $linked"
|
[ "$linked" = "$source_path" ] || die "refusing foreign symlink: $destination -> $linked"
|
||||||
@@ -137,34 +156,58 @@ link_skill() {
|
|||||||
ln -s -- "$source_path" "$destination"
|
ln -s -- "$source_path" "$destination"
|
||||||
action=linked
|
action=linked
|
||||||
fi
|
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() {
|
manifest_check() {
|
||||||
skill_name=$1
|
name=$1
|
||||||
source_label=$2
|
digest=$2
|
||||||
ref=$3
|
archive_path=$3
|
||||||
commit=$4
|
|
||||||
skill_path=$5
|
|
||||||
digest=$6
|
|
||||||
manifest=$target/.ink-skills.tsv
|
manifest=$target/.ink-skills.tsv
|
||||||
record_needed=yes
|
record_needed=yes
|
||||||
[ -e "$manifest" ] || return 0
|
[ -f "$manifest" ] || return 0
|
||||||
if awk -F '\t' -v skill="$skill_name" 'NR > 1 && $1 == skill { found = 1 } END { exit !found }' "$manifest"; then
|
if awk -F '\t' -v name="$name" 'NR > 1 && $1 == name { found = 1 } END { exit !found }' "$manifest"; then
|
||||||
existing=$(awk -F '\t' -v skill="$skill_name" 'NR > 1 && $1 == skill { print $0; exit }' "$manifest")
|
existing=$(awk -F '\t' -v name="$name" 'NR > 1 && $1 == name { 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")
|
wanted=$(printf '%s\t%s\t%s' "$name" "$digest" "$archive_path")
|
||||||
[ "$existing" = "$wanted" ] || die "provenance collision for installed skill: $skill_name"
|
[ "$existing" = "$wanted" ] || die "provenance collision for installed skill: $name"
|
||||||
record_needed=no
|
record_needed=no
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
append_source_record() {
|
manifest_append() {
|
||||||
[ "$record_needed" = yes ] || return 0
|
[ "$record_needed" = yes ] || return 0
|
||||||
manifest=$target/.ink-skills.tsv
|
manifest=$target/.ink-skills.tsv
|
||||||
if [ ! -e "$manifest" ]; then
|
[ -e "$manifest" ] || printf 'SKILL\tSHA256\tPATH\n' >"$manifest"
|
||||||
printf 'SKILL\tSOURCE\tREF\tCOMMIT\tPATH\tSHA256\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
|
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 ] || {
|
[ "$#" -gt 0 ] || {
|
||||||
@@ -183,10 +226,14 @@ case $command in
|
|||||||
usage >&2
|
usage >&2
|
||||||
exit 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
|
exit 0
|
||||||
;;
|
;;
|
||||||
install|add) ;;
|
link|import) ;;
|
||||||
*)
|
*)
|
||||||
usage >&2
|
usage >&2
|
||||||
exit 2
|
exit 2
|
||||||
@@ -195,74 +242,57 @@ esac
|
|||||||
|
|
||||||
target_mode=user
|
target_mode=user
|
||||||
target_arg=
|
target_arg=
|
||||||
ref=HEAD
|
work_root=${TMPDIR:-/tmp}/ink-skills-args-$$
|
||||||
while [ "$#" -gt 0 ]; do
|
(umask 077 && mkdir "$work_root") || die "cannot create temporary directory: $work_root"
|
||||||
parse_target_option "$@"
|
cleanup_root=$work_root
|
||||||
if [ "$shift_count" -gt 0 ]; then
|
work_args=$work_root/args
|
||||||
shift "$shift_count"
|
parse_target_options "$@"
|
||||||
continue
|
set --
|
||||||
fi
|
while IFS= read -r arg; do set -- "$@" "$arg"; done <"$work_args"
|
||||||
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
|
|
||||||
select_target
|
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
|
if [ "$#" -eq 0 ]; then
|
||||||
set --
|
set --
|
||||||
for path in "$skills_dir"/*; do
|
for path in "$bundled_dir"/*; do
|
||||||
[ -d "$path" ] || continue
|
[ -d "$path" ] && [ -f "$path/SKILL.md" ] || continue
|
||||||
[ -f "$path/SKILL.md" ] || continue
|
set -- "$@" "$path"
|
||||||
set -- "$@" "$(basename -- "$path")"
|
|
||||||
done
|
done
|
||||||
fi
|
fi
|
||||||
for skill do
|
for source_path do
|
||||||
case $skill in
|
case $source_path in
|
||||||
''|.*|*/*) die "invalid skill name: $skill" ;;
|
/*) ;;
|
||||||
|
*) 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
|
esac
|
||||||
source_path=$skills_dir/$skill
|
|
||||||
validate_skill_dir "$source_path"
|
validate_skill_dir "$source_path"
|
||||||
link_skill "$skill_name" "$source_path" local - -
|
link_skill "$skill_name" "$source_path" local -
|
||||||
done
|
done
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
[ "$#" -gt 0 ] || {
|
[ "$#" -ge 2 ] || {
|
||||||
usage >&2
|
usage >&2
|
||||||
exit 2
|
exit 2
|
||||||
}
|
}
|
||||||
repository=$1
|
digest_spec=$1
|
||||||
shift
|
archive=$2
|
||||||
reject_record_breaks "$repository"
|
shift 2
|
||||||
reject_record_breaks "$ref"
|
case $digest_spec in
|
||||||
case $ref in
|
sha256:*) digest=${digest_spec#sha256:} ;;
|
||||||
-*) die "invalid ref: $ref" ;;
|
*) die 'digest must use sha256:HASH' ;;
|
||||||
esac
|
esac
|
||||||
case $repository in
|
digest=$(printf '%s' "$digest" | tr 'A-F' 'a-f')
|
||||||
http://*@*|https://*@*) die 'credentialed HTTP URLs are refused; use an SSH agent or Git credential helper' ;;
|
case $digest in
|
||||||
|
*[!0-9a-f]*|'') die 'SHA-256 must contain 64 hexadecimal characters' ;;
|
||||||
esac
|
esac
|
||||||
command -v git >/dev/null 2>&1 || die 'git is required by add'
|
[ "${#digest}" -eq 64 ] || die 'SHA-256 must contain 64 hexadecimal characters'
|
||||||
command -v tar >/dev/null 2>&1 || die 'tar is required by add'
|
[ -f "$archive" ] || die "archive not found: $archive"
|
||||||
command -v sha256sum >/dev/null 2>&1 || die 'sha256sum is required by add'
|
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
|
if [ -n "${INK_SKILLS_STORE:-}" ]; then
|
||||||
store=$INK_SKILLS_STORE
|
store=$INK_SKILLS_STORE
|
||||||
@@ -272,48 +302,51 @@ else
|
|||||||
[ -n "${HOME:-}" ] || die 'HOME is unset; set HOME, XDG_DATA_HOME, or INK_SKILLS_STORE'
|
[ -n "${HOME:-}" ] || die 'HOME is unset; set HOME, XDG_DATA_HOME, or INK_SKILLS_STORE'
|
||||||
store=$HOME/.local/share/ink-skills
|
store=$HOME/.local/share/ink-skills
|
||||||
fi
|
fi
|
||||||
repo_key=$(printf '%s' "$repository" | git hash-object --stdin) || die 'cannot hash repository identity'
|
artifact=$store/sha256/$digest
|
||||||
mirror=$store/git/$repo_key.git
|
tree=$artifact/tree
|
||||||
mkdir -p -- "$store/git" "$store/artifacts/$repo_key"
|
if [ -d "$artifact" ]; then
|
||||||
if [ ! -d "$mirror" ]; then
|
[ -f "$artifact/complete" ] && [ "$(cat "$artifact/complete")" = "$digest" ] || die "incomplete artifact store entry: $artifact"
|
||||||
git clone --quiet --mirror -- "$repository" "$mirror" || die "cannot clone repository: $repository"
|
else
|
||||||
fi
|
mkdir -p -- "$store/sha256"
|
||||||
git --git-dir="$mirror" fetch --quiet --force origin "$ref" || die "cannot fetch ref: $ref"
|
lock=$store/sha256/.$digest.lock
|
||||||
commit=$(git --git-dir="$mirror" rev-parse --verify 'FETCH_HEAD^{commit}') || die "ref does not resolve to a commit: $ref"
|
mkdir "$lock" 2>/dev/null || die "artifact import already in progress: $digest"
|
||||||
artifact=$store/artifacts/$repo_key/$commit
|
temporary=$store/sha256/.$digest.tmp.$$
|
||||||
if [ ! -d "$artifact" ]; then
|
cleanup_lock=$lock
|
||||||
temporary=$artifact.tmp.$$
|
cleanup_artifact=$temporary
|
||||||
rm -rf -- "$temporary"
|
mkdir -p -- "$temporary/tree"
|
||||||
mkdir -p -- "$temporary"
|
validate_archive_listing "$archive" "$work_root/names" "$work_root/types"
|
||||||
if ! git --git-dir="$mirror" archive "$commit" | tar -x -C "$temporary"; then
|
tar -xf "$archive" -C "$temporary/tree" || die "cannot extract archive: $archive"
|
||||||
rm -rf -- "$temporary"
|
if find "$temporary/tree" -type l -print | grep . >/dev/null 2>&1; then
|
||||||
die "cannot materialize commit: $commit"
|
die 'archive extracted symlinks'
|
||||||
fi
|
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
|
fi
|
||||||
|
|
||||||
if [ "$#" -eq 0 ]; then
|
if [ "$#" -eq 0 ]; then
|
||||||
set --
|
set --
|
||||||
for source_path in "$artifact"/skills/*; do
|
for skill_dir in "$tree"/skills/*; do
|
||||||
[ -d "$source_path" ] || continue
|
[ -d "$skill_dir" ] && [ -f "$skill_dir/SKILL.md" ] || continue
|
||||||
[ -f "$source_path/SKILL.md" ] || continue
|
set -- "$@" "skills/$(basename -- "$skill_dir")"
|
||||||
set -- "$@" "skills/$(basename -- "$source_path")"
|
|
||||||
done
|
done
|
||||||
[ "$#" -gt 0 ] || die "repository has no skills/*/SKILL.md at $commit"
|
[ "$#" -gt 0 ] || die 'archive has no skills/*/SKILL.md directories'
|
||||||
fi
|
fi
|
||||||
|
|
||||||
for skill_path do
|
for archive_path do
|
||||||
reject_record_breaks "$skill_path"
|
case $archive_path in
|
||||||
case $skill_path in
|
''|/*|*:*|..|../*|*/../*|*/..) die "invalid archive skill path: $archive_path" ;;
|
||||||
''|/*|*:*|..|../*|*/../*|*/..) die "invalid repository skill path: $skill_path" ;;
|
|
||||||
esac
|
esac
|
||||||
source_path=$artifact/$skill_path
|
source_path=$tree/$archive_path
|
||||||
validate_skill_dir "$source_path"
|
validate_skill_dir "$source_path"
|
||||||
if find "$source_path" -type l -print | grep . >/dev/null 2>&1; then
|
name=$skill_name
|
||||||
die "remote skill contains symlinks: $skill_path"
|
manifest_check "$name" "$digest" "$archive_path"
|
||||||
fi
|
link_skill "$name" "$source_path" artifact "$digest"
|
||||||
digest=$(git --git-dir="$mirror" archive "$commit:$skill_path" | sha256sum | awk '{print $1}') || die "cannot hash skill artifact: $skill_path"
|
manifest_append
|
||||||
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
|
||||||
|
|||||||
+59
-39
@@ -3,7 +3,11 @@ set -eu
|
|||||||
|
|
||||||
repo=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd -P)
|
repo=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd -P)
|
||||||
tmp=${TMPDIR:-/tmp}/ink-skills-smoke-$$
|
tmp=${TMPDIR:-/tmp}/ink-skills-smoke-$$
|
||||||
trap 'rm -rf "$tmp"' EXIT HUP INT TERM
|
cleanup() {
|
||||||
|
chmod -R u+w "$tmp" 2>/dev/null || :
|
||||||
|
rm -rf "$tmp"
|
||||||
|
}
|
||||||
|
trap cleanup EXIT HUP INT TERM
|
||||||
mkdir -p "$tmp/home" "$tmp/project"
|
mkdir -p "$tmp/home" "$tmp/project"
|
||||||
|
|
||||||
list=$($repo/bin/ink-skills list)
|
list=$($repo/bin/ink-skills list)
|
||||||
@@ -11,28 +15,34 @@ printf '%s\n' "$list" | grep '^SKILL' >/dev/null
|
|||||||
printf '%s\n' "$list" | grep '^audit-ink-cli' >/dev/null
|
printf '%s\n' "$list" | grep '^audit-ink-cli' >/dev/null
|
||||||
printf '%s\n' "$list" | grep '^configure-ink-agent' >/dev/null
|
printf '%s\n' "$list" | grep '^configure-ink-agent' >/dev/null
|
||||||
|
|
||||||
HOME=$tmp/home $repo/bin/ink-skills install audit-ink-cli >"$tmp/install.tsv"
|
HOME=$tmp/home $repo/bin/ink-skills link >"$tmp/link.tsv"
|
||||||
grep "audit-ink-cli.*linked" "$tmp/install.tsv" >/dev/null
|
grep "audit-ink-cli.*linked.*local" "$tmp/link.tsv" >/dev/null
|
||||||
[ -L "$tmp/home/.ink/skills/audit-ink-cli" ]
|
[ -L "$tmp/home/.ink/skills/audit-ink-cli" ]
|
||||||
[ "$(readlink "$tmp/home/.ink/skills/audit-ink-cli")" = "$repo/skills/audit-ink-cli" ]
|
[ "$(readlink "$tmp/home/.ink/skills/audit-ink-cli")" = "$repo/skills/audit-ink-cli" ]
|
||||||
|
|
||||||
HOME=$tmp/home $repo/bin/ink-skills install audit-ink-cli >"$tmp/reinstall.tsv"
|
HOME=$tmp/home $repo/bin/ink-skills link "$repo/skills/audit-ink-cli" >"$tmp/relink.tsv"
|
||||||
grep "audit-ink-cli.*unchanged" "$tmp/reinstall.tsv" >/dev/null
|
grep "audit-ink-cli.*unchanged" "$tmp/relink.tsv" >/dev/null
|
||||||
|
|
||||||
mkdir -p "$tmp/home/.ink/skills/configure-ink-agent"
|
mkdir -p "$tmp/home/.ink/skills/collision"
|
||||||
if HOME=$tmp/home $repo/bin/ink-skills install configure-ink-agent >/dev/null 2>"$tmp/collision.err"; then
|
mkdir -p "$tmp/collision"
|
||||||
|
cat >"$tmp/collision/SKILL.md" <<'EOF'
|
||||||
|
---
|
||||||
|
name: collision
|
||||||
|
description: Fixture.
|
||||||
|
---
|
||||||
|
EOF
|
||||||
|
if HOME=$tmp/home $repo/bin/ink-skills link "$tmp/collision" >/dev/null 2>"$tmp/collision.err"; then
|
||||||
echo 'expected collision refusal' >&2
|
echo 'expected collision refusal' >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
grep 'refusing existing path' "$tmp/collision.err" >/dev/null
|
grep 'refusing existing path' "$tmp/collision.err" >/dev/null
|
||||||
|
|
||||||
HOME=$tmp/home $repo/bin/ink-skills install --project "$tmp/project" create-ink-tool >"$tmp/project.tsv"
|
HOME=$tmp/home $repo/bin/ink-skills link --project "$tmp/project" "$repo/skills/create-ink-tool" >"$tmp/project.tsv"
|
||||||
[ -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.*local" "$tmp/project.tsv" >/dev/null
|
||||||
|
|
||||||
remote=$tmp/remote
|
mkdir -p "$tmp/archive-tree/skills/remote-review"
|
||||||
mkdir -p "$remote/skills/remote-review"
|
cat >"$tmp/archive-tree/skills/remote-review/SKILL.md" <<'EOF'
|
||||||
cat >"$remote/skills/remote-review/SKILL.md" <<'EOF'
|
|
||||||
---
|
---
|
||||||
name: remote-review
|
name: remote-review
|
||||||
description: Review one remote fixture.
|
description: Review one remote fixture.
|
||||||
@@ -40,43 +50,53 @@ description: Review one remote fixture.
|
|||||||
|
|
||||||
# Remote review
|
# Remote review
|
||||||
EOF
|
EOF
|
||||||
git -C "$remote" init -q
|
tar -cf "$tmp/skills.tar" -C "$tmp/archive-tree" skills
|
||||||
git -C "$remote" config user.email ink-skills@example.invalid
|
digest=$(sha256sum "$tmp/skills.tar" | awk '{print $1}')
|
||||||
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"
|
mkdir -p "$tmp/import-home"
|
||||||
HOME=$tmp/remote-home INK_SKILLS_STORE=$tmp/store $repo/bin/ink-skills add --ref HEAD "$remote" skills/remote-review >"$tmp/add.tsv"
|
HOME=$tmp/import-home INK_SKILLS_STORE=$tmp/store $repo/bin/ink-skills import "sha256:$digest" "$tmp/skills.tar" >"$tmp/import.tsv"
|
||||||
remote_link=$tmp/remote-home/.ink/skills/remote-review
|
remote_link=$tmp/import-home/.ink/skills/remote-review
|
||||||
[ -L "$remote_link" ]
|
[ -L "$remote_link" ]
|
||||||
case $(readlink "$remote_link") in
|
[ "$(readlink "$remote_link")" = "$tmp/store/sha256/$digest/tree/skills/remote-review" ]
|
||||||
"$tmp/store/artifacts/"*"/$commit/skills/remote-review") ;;
|
grep "remote-review.*linked.*artifact.*$digest" "$tmp/import.tsv" >/dev/null
|
||||||
*) echo 'remote link is not pinned to resolved commit' >&2; exit 1 ;;
|
manifest=$tmp/import-home/.ink/skills/.ink-skills.tsv
|
||||||
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 ]
|
[ "$(wc -l <"$manifest")" -eq 2 ]
|
||||||
awk -F '\t' -v commit="$commit" 'NR == 2 { exit !($1 == "remote-review" && $4 == commit && length($6) == 64) }' "$manifest"
|
awk -F '\t' -v digest="$digest" 'NR == 2 { exit !($1 == "remote-review" && $2 == digest && $3 == "skills/remote-review") }' "$manifest"
|
||||||
|
|
||||||
HOME=$tmp/remote-home INK_SKILLS_STORE=$tmp/store $repo/bin/ink-skills add --ref HEAD "$remote" skills/remote-review >"$tmp/readd.tsv"
|
HOME=$tmp/import-home INK_SKILLS_STORE=$tmp/store $repo/bin/ink-skills import "sha256:$digest" "$tmp/skills.tar" skills/remote-review >"$tmp/reimport.tsv"
|
||||||
grep 'remote-review.*unchanged' "$tmp/readd.tsv" >/dev/null
|
grep 'remote-review.*unchanged' "$tmp/reimport.tsv" >/dev/null
|
||||||
[ "$(wc -l <"$manifest")" -eq 2 ]
|
[ "$(wc -l <"$manifest")" -eq 2 ]
|
||||||
|
|
||||||
printf '\nchanged\n' >>"$remote/skills/remote-review/SKILL.md"
|
zero_digest=$(awk 'BEGIN { for (i = 0; i < 64; i++) printf "0" }')
|
||||||
git -C "$remote" add skills/remote-review/SKILL.md
|
if HOME=$tmp/import-home INK_SKILLS_STORE=$tmp/store $repo/bin/ink-skills import "sha256:$zero_digest" "$tmp/skills.tar" >/dev/null 2>"$tmp/digest.err"; then
|
||||||
git -C "$remote" commit -qm changed
|
echo 'expected digest mismatch' >&2
|
||||||
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
|
exit 1
|
||||||
fi
|
fi
|
||||||
grep 'provenance collision' "$tmp/pin.err" >/dev/null
|
grep 'SHA-256 mismatch' "$tmp/digest.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
|
printf '\nchanged\n' >>"$tmp/archive-tree/skills/remote-review/SKILL.md"
|
||||||
echo 'expected credentialed URL refusal' >&2
|
tar -cf "$tmp/changed.tar" -C "$tmp/archive-tree" skills
|
||||||
|
changed_digest=$(sha256sum "$tmp/changed.tar" | awk '{print $1}')
|
||||||
|
if HOME=$tmp/import-home INK_SKILLS_STORE=$tmp/store $repo/bin/ink-skills import "sha256:$changed_digest" "$tmp/changed.tar" >/dev/null 2>"$tmp/provenance.err"; then
|
||||||
|
echo 'expected provenance collision after artifact changes' >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
grep 'credentialed HTTP URLs are refused' "$tmp/credential.err" >/dev/null
|
grep 'provenance collision' "$tmp/provenance.err" >/dev/null
|
||||||
|
|
||||||
|
mkdir -p "$tmp/unsafe/skills/unsafe"
|
||||||
|
cat >"$tmp/unsafe/skills/unsafe/SKILL.md" <<'EOF'
|
||||||
|
---
|
||||||
|
name: unsafe
|
||||||
|
description: Unsafe fixture.
|
||||||
|
---
|
||||||
|
EOF
|
||||||
|
ln -s /etc/passwd "$tmp/unsafe/skills/unsafe/passwd"
|
||||||
|
tar -cf "$tmp/unsafe.tar" -C "$tmp/unsafe" skills
|
||||||
|
unsafe_digest=$(sha256sum "$tmp/unsafe.tar" | awk '{print $1}')
|
||||||
|
if HOME=$tmp/import-home INK_SKILLS_STORE=$tmp/store $repo/bin/ink-skills import "sha256:$unsafe_digest" "$tmp/unsafe.tar" >/dev/null 2>"$tmp/unsafe.err"; then
|
||||||
|
echo 'expected symlink archive refusal' >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
grep 'regular files and directories' "$tmp/unsafe.err" >/dev/null
|
||||||
|
|
||||||
printf 'ok\n'
|
printf 'ok\n'
|
||||||
|
|||||||
Reference in New Issue
Block a user