Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,11 @@ into this repository:

`scripts/link-skills.sh` is the only supported way to install these skills
into `~/.agents/skills`. It must stay compatible with bash 3.2 (the default
`/bin/bash` on macOS) and pass `shellcheck` clean.
`/bin/bash` on macOS) and pass `shellcheck` clean. The entry point sources its
topic modules from `scripts/lib/link-skills/` by absolute path from an explicit
ordered list, so it needs that directory next to it; the harness copies the
script and the directory into every fixture through
`fixtures_install_script`.

After changing it, or after changing any file of its harness, run the harness,
which CI runs on Ubuntu and macOS:
Expand Down
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -393,6 +393,11 @@ before the install-hooks topics that call it. The cases for
`scripts/validate-skills.mjs` are
`node:test` files under `scripts/tests/validate-skills/`.

`scripts/link-skills.sh` is an entry point as well: it sources its topic
modules from `scripts/lib/link-skills/` by absolute path from an explicit
ordered list, so a copy of the script needs that directory beside it. The
harness copies both into every fixture.

Each package's own README documents its usage; run its suite directly with
`pnpm --filter <package-name> test` during development.

Expand Down
72 changes: 72 additions & 0 deletions scripts/lib/link-skills/names.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
# shellcheck shell=bash
#
# names.sh - the name rules: which manifest name and which manifest field
# are safe to act on, and how two entry names compare on a filesystem that
# folds case. It holds the "names and casing" section of the single-file
# script.
#
# Reads: ASSEMBLY_DIR (detect_case_insensitive), CASE_INSENSITIVE
# (detect_case_insensitive, names_equal).
# Writes: CASE_INSENSITIVE (detect_case_insensitive).

# A manifest name must be one plain basename. Anything else could name a path
# outside the assembly directory, so it never licenses a removal.
name_is_safe() {
Comment thread
chapati23 marked this conversation as resolved.
case "$1" in
"" | "." | "..") return 1 ;;
*/*) return 1 ;;
*$'\t'* | *$'\n'*) return 1 ;;
esac
return 0
}

# A manifest field is one tab-separated line, so neither a tab nor a newline can
# round-trip through it.
field_is_safe() {
case "$1" in
*$'\t'* | *$'\n'*) return 1 ;;
esac
return 0
}

to_lower() {
printf '%s' "$1" | tr '[:upper:]' '[:lower:]'
}

# Probe the assembly directory once per run. macOS formats APFS and HFS+
# case-insensitive by default, so 'Foo' and 'foo' are one entry there and the
# name comparisons below must agree with the filesystem.
detect_case_insensitive() {
local probe base up
if [ "$CASE_INSENSITIVE" -ge 0 ]; then
return 0
fi
CASE_INSENSITIVE=0
if [ ! -d "$ASSEMBLY_DIR" ]; then
return 0
fi
if ! probe=$(mktemp "$ASSEMBLY_DIR/.skill-links.case.XXXXXX" 2>/dev/null); then
return 0
fi
base=$(basename "$probe")
up=$(printf '%s' "$base" | tr '[:lower:]' '[:upper:]')
if [ "$up" != "$base" ] && [ -e "$ASSEMBLY_DIR/$up" ]; then
CASE_INSENSITIVE=1
fi
rm -f "$probe"
return 0
}

# Two entry names that the filesystem in use cannot tell apart.
names_equal() {
if [ "$1" = "$2" ]; then
return 0
fi
if [ "$CASE_INSENSITIVE" != "1" ]; then
return 1
fi
if [ "$(to_lower "$1")" = "$(to_lower "$2")" ]; then
return 0
fi
return 1
}
129 changes: 129 additions & 0 deletions scripts/lib/link-skills/output.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
# shellcheck shell=bash
#
# output.sh - what this script prints: the four report levels, the refusal
# that stops a run, the line a session hook prints, and the help text. It
# holds the "output" section of the single-file script.
#
# Reads: QUIET (info, warn), PROG (err, warn, die, hook_say), HOOK_MODE
# (die).
# Writes: ERRORS (err).
#
# die ends the process. In hook mode it prints one bracketed line and exits
# 0, because a session start must end well whatever this script finds.

info() {
Comment thread
chapati23 marked this conversation as resolved.
if [ "$QUIET" -eq 0 ]; then
printf '%s\n' "$*"
fi
}

err() {
printf '%s: %s\n' "$PROG" "$*" >&2
ERRORS=$((ERRORS + 1))
}

# A problem worth naming that must not change the exit code.
warn() {
if [ "$QUIET" -eq 0 ]; then
printf '%s: warning: %s\n' "$PROG" "$*" >&2
fi
}

# A refusal that stops the run. The session hook is the exception: a session
# must start whatever this script finds, so in hook mode the same refusal is
# one '[link-skills]' line and exit 0. Every other command keeps exit 2.
die() {
if [ "$HOOK_MODE" -eq 1 ]; then
hook_say "$*"
exit 0
fi
printf '%s: %s\n' "$PROG" "$*" >&2
exit 2
}

hook_say() {
printf '[%s] %s\n' "$PROG" "$*"
}

# No here documents anywhere in this script: bash 3.2 writes every here
# document to a temporary file, which fails on hosts with a locked-down /tmp.
# The help text names $HOME literally; it is documentation, not an expansion.
usage() {
usage_head
usage_options
usage_notes
}

usage_head() {
printf '%s\n' \
'Usage: link-skills.sh [options] [command]' \
'' \
'Commands:' \
' link Link every skill in every source into the assembly' \
' directory and refresh the runtime symlinks. Default.' \
' check Report source and assembly state. Creates and removes' \
' no links. Fetches every git source every time it' \
' runs, and writes a fetch-* stamp in the' \
' .skill-links.d directory inside the assembly.' \
' hook SessionStart hook mode. Notifies only: it changes no' \
' clone and no link, and takes no lock. Silent when' \
' current, never fails. Bounded by 25 seconds of wall' \
' clock, everything it starts included.' \
' install-hooks Add the SessionStart hook to Claude Code and Codex.' \
' A settings file that already runs the hook is left' \
' byte for byte as it is. An entry that runs a' \
' link-skills.sh whose path no longer exists, whose' \
' path is relative, whose --sources or --assembly' \
' names another installation, or whose arguments are' \
' not a hook run, is rewritten to the command this' \
' run is for; an entry that runs another script is' \
' left alone.' \
' unlink Remove the links this script recorded, and the manifest.' \
' help Print this text.' \
''
}

usage_options() {
# shellcheck disable=SC2016 # literal $HOME in the help text
printf '%s\n' \
'Options:' \
' --sources FILE Sources list (default: $HOME/.agents/skill-sources)' \
' --assembly DIR Assembly directory (default: $HOME/.agents/skills).' \
' It must not be, or hold, $HOME/.claude/skills or' \
' $HOME/.codex/skills: those two paths become links' \
' into the assembly, so either would be a link into' \
' itself.' \
' --quiet Print only problems.' \
'' \
'Environment:' \
' SKILL_SOURCES_FILE Same as --sources.' \
' SKILLS_ASSEMBLY_DIR Same as --assembly.' \
' SKILL_SOURCES_FETCH_INTERVAL_HOURS Hook fetch throttle in hours' \
' (default 6, 0 fetches every' \
' time). check always fetches.' \
'' \
'Exit codes:' \
' 0 nothing to report' \
' 1 at least one problem was reported'
}

usage_notes() {
printf '%s\n' \
' 2 wrong usage, or no source to work from: no sources file, a' \
' sources file that is not a regular file, names one of the' \
' assembly control paths, or lists no source, a path whose' \
' components are not all directories, or an assembly directory' \
' that is or holds a runtime skills path' \
'' \
'Sources file format, one entry per line. Each path names the directory' \
'whose immediate children are skill directories holding a SKILL.md:' \
' /absolute/path/to/skills' \
' ~/code/my-skills/skills' \
' ~/code/agents/skills' \
'' \
"Lines that are empty or start with '#' are ignored. A relative path" \
'resolves against the directory that holds the sources file. A line is' \
'one path, spaces in the path included. The one token refused is a' \
"trailing 'auto-update'. A line that names no directory is reported" \
'as a missing source.'
}
Loading
Loading