diff --git a/README.md b/README.md index edd40a5..498f035 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/bin/ink-skills b/bin/ink-skills index 787290f..b21fe3a 100755 --- a/bin/ink-skills +++ b/bin/ink-skills @@ -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 diff --git a/test/install-smoke.sh b/test/install-smoke.sh index 551c70e..3a81648 100755 --- a/test/install-smoke.sh +++ b/test/install-smoke.sh @@ -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'