@spardutti/claude-skills 2.31.0 → 2.32.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/hooks/gauntlet.sh +328 -0
- package/hooks/ship-gate-hook.sh +88 -0
- package/hooks/ship-gate.sh +506 -0
- package/hooks/skill-application-gate.sh +162 -0
- package/hooks/skill-gate-automark.sh +32 -0
- package/hooks/skill-gate.sh +192 -0
- package/hooks/version-check.sh +88 -0
- package/lib/setup-hook.mjs +48 -1461
- package/package.json +5 -4
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# PostToolUse on Skill: marks gate satisfied + records loaded skill.
|
|
3
|
+
|
|
4
|
+
INPUT=$(cat)
|
|
5
|
+
|
|
6
|
+
SESSION_ID=$(printf '%s' "$INPUT" | grep -o '"session_id":"[^"]*"' | head -1 | sed 's/"session_id":"//; s/"$//')
|
|
7
|
+
if [ -z "$SESSION_ID" ]; then
|
|
8
|
+
exit 0
|
|
9
|
+
fi
|
|
10
|
+
|
|
11
|
+
# Keyed exactly as the gate keys it. A subagent carries the parent's
|
|
12
|
+
# session_id, so both scripts fall back to agent_id when one is present — and
|
|
13
|
+
# they must agree, or a loaded skill writes a marker the gate never looks for
|
|
14
|
+
# and every edit is denied forever.
|
|
15
|
+
AGENT_ID=$(printf '%s' "$INPUT" | grep -o '"agent_id":"[^"]*"' | head -1 | sed 's/"agent_id":"//; s/"$//')
|
|
16
|
+
KEY="$SESSION_ID"
|
|
17
|
+
if [ -n "$AGENT_ID" ]; then
|
|
18
|
+
KEY=$(printf '%s' "$AGENT_ID" | tr -cd 'A-Za-z0-9_-')
|
|
19
|
+
fi
|
|
20
|
+
|
|
21
|
+
touch "/tmp/claude-skill-gate-$KEY"
|
|
22
|
+
|
|
23
|
+
SKILL_NAME=$(printf '%s' "$INPUT" | grep -o '"skill":"[^"]*"' | head -1 | sed 's/"skill":"//; s/"$//')
|
|
24
|
+
if [ -n "$SKILL_NAME" ]; then
|
|
25
|
+
# Sanitize: only allow [A-Za-z0-9_-] in the marker filename.
|
|
26
|
+
SAFE_NAME=$(printf '%s' "$SKILL_NAME" | tr -cd 'A-Za-z0-9_-')
|
|
27
|
+
if [ -n "$SAFE_NAME" ]; then
|
|
28
|
+
touch "/tmp/claude-skill-loaded-$KEY-$SAFE_NAME"
|
|
29
|
+
fi
|
|
30
|
+
fi
|
|
31
|
+
|
|
32
|
+
exit 0
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# PreToolUse gate: forces skill evaluation before file-writing tools run.
|
|
3
|
+
|
|
4
|
+
INPUT=$(cat)
|
|
5
|
+
|
|
6
|
+
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
7
|
+
PROJECT_DIR="$(dirname "$(dirname "$SCRIPT_DIR")")"
|
|
8
|
+
|
|
9
|
+
if ! find "$PROJECT_DIR" -path '*/.claude/skills/*/SKILL.md' 2>/dev/null | grep -q .; then
|
|
10
|
+
exit 0
|
|
11
|
+
fi
|
|
12
|
+
|
|
13
|
+
SESSION_ID=$(printf '%s' "$INPUT" | grep -o '"session_id":"[^"]*"' | head -1 | sed 's/"session_id":"//; s/"$//')
|
|
14
|
+
if [ -z "$SESSION_ID" ]; then
|
|
15
|
+
exit 0
|
|
16
|
+
fi
|
|
17
|
+
|
|
18
|
+
# A subagent reports the PARENT's session_id — measured, not assumed: a probe
|
|
19
|
+
# hook dumped its own stdin from the main thread and from inside a subagent and
|
|
20
|
+
# the two ids were identical. So a worker spawned to implement part of a plan
|
|
21
|
+
# inherited the main thread's cleared markers and edited with none of the
|
|
22
|
+
# skills loaded in its own context.
|
|
23
|
+
#
|
|
24
|
+
# agent_id is present only inside a subagent, and holds steady across that
|
|
25
|
+
# subagent's turns, so keying on it gives each worker its own namespace and
|
|
26
|
+
# costs it one Skill call per skill, not one per turn.
|
|
27
|
+
AGENT_ID=$(printf '%s' "$INPUT" | grep -o '"agent_id":"[^"]*"' | head -1 | sed 's/"agent_id":"//; s/"$//')
|
|
28
|
+
KEY="$SESSION_ID"
|
|
29
|
+
if [ -n "$AGENT_ID" ]; then
|
|
30
|
+
KEY=$(printf '%s' "$AGENT_ID" | tr -cd 'A-Za-z0-9_-')
|
|
31
|
+
fi
|
|
32
|
+
|
|
33
|
+
# The marker is checked at the very bottom now, not here. A skill the edited
|
|
34
|
+
# file's own stack demands is not something an already-cleared gate excuses,
|
|
35
|
+
# so the required-skill scan below runs first.
|
|
36
|
+
MARKER="/tmp/claude-skill-gate-$KEY"
|
|
37
|
+
|
|
38
|
+
# Write|Edit|MultiEdit is not the only way to change a file. A session that edits
|
|
39
|
+
# through `python3 - <<'PY'` in Bash walked past this gate entirely — twelve
|
|
40
|
+
# source files, not one prompt — and some harnesses actively tell the model to
|
|
41
|
+
# prefer Bash for edits. So Bash is gated too, but only for commands that can
|
|
42
|
+
# write, and only until the gate is cleared once for the session.
|
|
43
|
+
|
|
44
|
+
# Skills are about code. Blocking a plan document to ask how the React rules
|
|
45
|
+
# apply is pure noise, and a repo full of PLAN_*.md hits it on every write.
|
|
46
|
+
# Config files stay gated — skills do have rules about tsconfig and compose.
|
|
47
|
+
PROSE_EXT='md|mdx|txt|rst|adoc|png|jpg|jpeg|gif|svg|webp|ico'
|
|
48
|
+
CODE_EXT='ts|tsx|js|jsx|mjs|cjs|py|go|rs|java|kt|rb|php|c|h|cpp|hpp|cs|swift|sql|sh|json|ya?ml|toml'
|
|
49
|
+
|
|
50
|
+
TOOL=$(printf '%s' "$INPUT" | grep -o '"tool_name":"[^"]*"' | head -1 | sed 's/.*:"//; s/"$//')
|
|
51
|
+
|
|
52
|
+
# Every file this call would write, collected for the required-skill scan below.
|
|
53
|
+
TARGETS=""
|
|
54
|
+
|
|
55
|
+
if [ "$TOOL" != "Bash" ]; then
|
|
56
|
+
# The structured tools name their target outright.
|
|
57
|
+
TARGET=$(printf '%s' "$INPUT" | grep -o '"file_path":[[:space:]]*"[^"]*"' | head -1 | sed 's/.*:[[:space:]]*"//; s/"$//')
|
|
58
|
+
case "$TARGET" in
|
|
59
|
+
# The settings file that configures the escape hatch cannot sit behind the
|
|
60
|
+
# gate, or a denied ack has no way to be un-denied. The bare relative form
|
|
61
|
+
# matters: a skill claiming **/* gated it, and nothing could re-open it.
|
|
62
|
+
.claude/settings.json|.claude/settings.local.json) exit 0 ;;
|
|
63
|
+
*/.claude/settings.json|*/.claude/settings.local.json) exit 0 ;;
|
|
64
|
+
*.*) printf '%s' "$TARGET" | grep -qiE ".($PROSE_EXT)$" && exit 0 ;;
|
|
65
|
+
esac
|
|
66
|
+
TARGETS="$TARGET"
|
|
67
|
+
fi
|
|
68
|
+
if [ "$TOOL" = "Bash" ]; then
|
|
69
|
+
CMD=$(printf '%s' "$INPUT" | grep -o '"command":[[:space:]]*"[^"]*"' | head -1 | sed 's/.*:[[:space:]]*"//; s/"$//')
|
|
70
|
+
|
|
71
|
+
# Never gate the command that clears the gate, or this deadlocks.
|
|
72
|
+
case "$CMD" in
|
|
73
|
+
*claude-skill-gate-*|*claude-skill-acked-*|*claude-skill-loaded-*) exit 0 ;;
|
|
74
|
+
*.claude/settings.json*|*.claude/settings.local.json*) exit 0 ;;
|
|
75
|
+
esac
|
|
76
|
+
|
|
77
|
+
# /dev/null redirects are not file writes; drop them before looking for one.
|
|
78
|
+
|
|
79
|
+
# A command that names a prose file and no code file is writing prose.
|
|
80
|
+
if printf '%s' "$CMD" | grep -qiE ".($PROSE_EXT)([^A-Za-z0-9]|$)" && ! printf '%s' "$CMD" | grep -qiE ".($CODE_EXT)([^A-Za-z0-9]|$)"; then
|
|
81
|
+
exit 0
|
|
82
|
+
fi
|
|
83
|
+
STRIPPED=$(printf '%s' "$CMD" | sed 's![12]*>>*[[:space:]]*/dev/null!!g')
|
|
84
|
+
WRITES=""
|
|
85
|
+
case "$STRIPPED" in
|
|
86
|
+
*">"*|*"tee "*|*"sed -i"*|*"cp "*|*"mv "*|*"truncate "*|*"dd "*) WRITES=1 ;;
|
|
87
|
+
esac
|
|
88
|
+
# An interpreter given inline code or a heredoc can write anything, and the
|
|
89
|
+
# shell shows no redirect at all — this is the shape that got past the gate.
|
|
90
|
+
case "$CMD" in
|
|
91
|
+
*"<<"*|*python*" -c"*|*node*" -e"*|*perl*" -e"*|*ruby*" -e"*) WRITES=1 ;;
|
|
92
|
+
esac
|
|
93
|
+
[ -z "$WRITES" ] && exit 0
|
|
94
|
+
# A Bash write names its files in the command itself. Dockerfile and
|
|
95
|
+
# .dockerignore carry no extension, so they are matched by name.
|
|
96
|
+
TARGETS=$(printf '%s' "$CMD" | grep -oiE "[A-Za-z0-9_./-]+.($CODE_EXT)|[A-Za-z0-9_./-]*(Dockerfile[A-Za-z0-9_.-]*|.dockerignore)" 2>/dev/null)
|
|
97
|
+
fi
|
|
98
|
+
|
|
99
|
+
# --- mandatory skills -------------------------------------------------------
|
|
100
|
+
# The generic gate below asks the model to rate every skill ACTIVATE or SKIP,
|
|
101
|
+
# and hands it an all-SKIP escape. A session took that escape, touched the
|
|
102
|
+
# marker, and edited React files with none of the React rules loaded. It had
|
|
103
|
+
# followed the hook exactly: the hook let it decide.
|
|
104
|
+
#
|
|
105
|
+
# So a skill that declares BOTH paths: and tracks: stops getting a vote. If the
|
|
106
|
+
# edited file matches its paths AND this project depends on a package it
|
|
107
|
+
# tracks, that skill is mandatory and only Skill(<name>) clears it. Skills
|
|
108
|
+
# without both fields keep the old behaviour, so this tightens nothing that was
|
|
109
|
+
# not deliberately declared.
|
|
110
|
+
MISSING=""
|
|
111
|
+
MANIFESTS=""
|
|
112
|
+
if [ -n "$TARGETS" ]; then
|
|
113
|
+
MANIFESTS=$(find "$PROJECT_DIR" -maxdepth 4 \( -name node_modules -o -name .git -o -name .venv -o -name dist \) -prune -o \( -name package.json -o -name pyproject.toml -o -name requirements.txt \) -print 2>/dev/null)
|
|
114
|
+
fi
|
|
115
|
+
if [ -n "$TARGETS" ]; then
|
|
116
|
+
for SKILL_FILE in $(find "$PROJECT_DIR" -path '*/.claude/skills/*/SKILL.md' 2>/dev/null); do
|
|
117
|
+
# Read gate-paths from under metadata:, never a bare paths: key. A bare
|
|
118
|
+
# paths: made Claude Code drop the skill from its registry outright —
|
|
119
|
+
# every Skill(name) call returned "Unknown skill" and, with no SKIP for a
|
|
120
|
+
# mandatory skill, the session could not edit code at all. metadata is
|
|
121
|
+
# documented as free-form and ignored by Claude Code, and the docs say in
|
|
122
|
+
# so many words not to reuse paths as a key inside it.
|
|
123
|
+
#
|
|
124
|
+
# The value must be quoted in YAML either way: a scalar opening with * is
|
|
125
|
+
# an alias indicator.
|
|
126
|
+
UNQUOTE='s/^["'"'"']//; s/["'"'"']$//'
|
|
127
|
+
SPATHS=$(sed -n 's/^[[:space:]]*gate-paths:[[:space:]]*//p' "$SKILL_FILE" | head -1 | sed "$UNQUOTE")
|
|
128
|
+
STRACKS=$(sed -n 's/^tracks:[[:space:]]*//p' "$SKILL_FILE" | head -1 | sed "$UNQUOTE")
|
|
129
|
+
SNAME=$(sed -n 's/^name:[[:space:]]*//p' "$SKILL_FILE" | head -1 | sed "$UNQUOTE")
|
|
130
|
+
[ -z "$SPATHS" ] && continue
|
|
131
|
+
[ -z "$SNAME" ] && continue
|
|
132
|
+
|
|
133
|
+
HIT=""
|
|
134
|
+
for G in $(printf '%s' "$SPATHS" | tr ',' ' '); do
|
|
135
|
+
# Claude Code writes these gitignore-style. A case glob has no **, but *
|
|
136
|
+
# already crosses / here, so dropping the **/ prefix is equivalent.
|
|
137
|
+
G=$(printf '%s' "$G" | sed 's!\*\*/!!g')
|
|
138
|
+
case "$G" in \**|/*) ;; *) G="*$G" ;; esac
|
|
139
|
+
for T in $TARGETS; do
|
|
140
|
+
case "$T" in $G) HIT=1 ;; esac
|
|
141
|
+
done
|
|
142
|
+
done
|
|
143
|
+
[ -z "$HIT" ] && continue
|
|
144
|
+
|
|
145
|
+
# A .tsx file is not proof of React — it could be Astro, Solid or Preact.
|
|
146
|
+
# The packages the skill tracks are the proof, so the manifests decide.
|
|
147
|
+
#
|
|
148
|
+
# Some skills need no such proof: a .sql file is SQL and a Dockerfile is a
|
|
149
|
+
# Dockerfile, and no package declares either. A skill that names paths and
|
|
150
|
+
# no tracks is taken at its word.
|
|
151
|
+
PY=""
|
|
152
|
+
case "$STRACKS" in *pypi*) PY=1 ;; esac
|
|
153
|
+
DEP=""
|
|
154
|
+
[ -z "$STRACKS" ] && DEP=1
|
|
155
|
+
for P in $(printf '%s' "$STRACKS" | tr ',' ' '); do
|
|
156
|
+
case "$P" in *@*) ;; *) continue ;; esac
|
|
157
|
+
PKG=$(printf '%s' "$P" | sed 's/@[^@]*$//')
|
|
158
|
+
[ -z "$PKG" ] && continue
|
|
159
|
+
for M in $MANIFESTS; do
|
|
160
|
+
if [ -n "$PY" ]; then
|
|
161
|
+
grep -qiE "(^|[^A-Za-z0-9_.-])$PKG([^A-Za-z0-9_.-]|$)" "$M" 2>/dev/null && DEP=1
|
|
162
|
+
else
|
|
163
|
+
grep -qs "\"$PKG\"" "$M" && DEP=1
|
|
164
|
+
fi
|
|
165
|
+
done
|
|
166
|
+
done
|
|
167
|
+
[ -z "$DEP" ] && continue
|
|
168
|
+
|
|
169
|
+
SAFE=$(printf '%s' "$SNAME" | tr -cd 'A-Za-z0-9_-')
|
|
170
|
+
[ -f "/tmp/claude-skill-loaded-$KEY-$SAFE" ] && continue
|
|
171
|
+
case " $MISSING " in *" $SNAME "*) ;; *) MISSING="$MISSING $SNAME" ;; esac
|
|
172
|
+
done
|
|
173
|
+
fi
|
|
174
|
+
|
|
175
|
+
if [ -n "$MISSING" ]; then
|
|
176
|
+
LIST=$(printf '%s' "$MISSING" | sed 's/^ //; s/ /, /g')
|
|
177
|
+
FIRST=$(printf '%s' "$MISSING" | awk '{print $1}')
|
|
178
|
+
cat <<EOF
|
|
179
|
+
{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"BLOCKED: this file's stack has mandatory skills that are not loaded: $LIST.\n\nThere is no SKIP for these. The file matches each skill's declared paths AND this project depends on a package that skill tracks, so they apply as a matter of fact, not judgement. Touching the gate marker will not clear them.\n\nCall Skill($FIRST) now — then every other name in the list — and retry the edit."}}
|
|
180
|
+
EOF
|
|
181
|
+
exit 0
|
|
182
|
+
fi
|
|
183
|
+
|
|
184
|
+
if [ -f "$MARKER" ]; then
|
|
185
|
+
exit 0
|
|
186
|
+
fi
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
cat <<EOF
|
|
190
|
+
{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"BLOCKED: skill evaluation required before file edits in this session.\n\nStep 1 — evaluate every available skill as ACTIVATE or SKIP with a one-line reason.\n\nStep 2 — you MUST take EXACTLY ONE of these tool actions to clear the gate. Listing skills in text is NOT enough; retrying the edit without doing one of these will be denied again:\n (a) If any skill is ACTIVATE → call Skill(name) for it. This auto-clears the gate.\n (b) If ALL skills are SKIP → run this Bash tool call: touch /tmp/claude-skill-gate-$KEY\n\nStep 3 — only after Step 2 completes, retry the file edit."}}
|
|
191
|
+
EOF
|
|
192
|
+
exit 0
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# version-check.sh — SessionStart. Says once a day when a newer catalog exists.
|
|
3
|
+
#
|
|
4
|
+
# SessionStart stdout is added to the model's context, so this does not draw a
|
|
5
|
+
# banner: it tells the assistant, and the assistant mentions it. That is the
|
|
6
|
+
# whole reach of this hook — it never installs anything and never asks a
|
|
7
|
+
# question the user has to answer before working.
|
|
8
|
+
#
|
|
9
|
+
# The rule that keeps it invisible: NEVER touch the network in the foreground.
|
|
10
|
+
# A session start that waits on npm is a session start that hangs on a bad
|
|
11
|
+
# connection, and a version nudge is not worth one second of that. So it reads
|
|
12
|
+
# a cache written by the PREVIOUS session's background refresh, and kicks off
|
|
13
|
+
# the next refresh detached. The first session after an install therefore says
|
|
14
|
+
# nothing, and after that the answer is at worst a day old — which is the right
|
|
15
|
+
# resolution for "there is a new version" anyway.
|
|
16
|
+
#
|
|
17
|
+
# Silent when: no manifest, no cache yet, offline, already current, or the user
|
|
18
|
+
# has already been told today.
|
|
19
|
+
#
|
|
20
|
+
# CLAUDE_SKILLS_NO_VERSION_CHECK=1 turns it off entirely
|
|
21
|
+
|
|
22
|
+
set -uo pipefail
|
|
23
|
+
|
|
24
|
+
[ -n "${CLAUDE_SKILLS_NO_VERSION_CHECK:-}" ] && exit 0
|
|
25
|
+
|
|
26
|
+
PKG="@spardutti/claude-skills"
|
|
27
|
+
PROJECT_DIR="${CLAUDE_PROJECT_DIR:-$PWD}"
|
|
28
|
+
MANIFEST="$PROJECT_DIR/.claude/.claude-skills.json"
|
|
29
|
+
|
|
30
|
+
# No manifest means the CLI never installed here, so there is nothing to update.
|
|
31
|
+
[ -f "$MANIFEST" ] || exit 0
|
|
32
|
+
|
|
33
|
+
INSTALLED=$(sed -n 's/.*"catalogVersion"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$MANIFEST" | head -1)
|
|
34
|
+
[ -n "$INSTALLED" ] || exit 0
|
|
35
|
+
|
|
36
|
+
# One cache for the whole machine, not one per project: the answer is the same
|
|
37
|
+
# everywhere, and a developer with ten repos should still cost one request.
|
|
38
|
+
CACHE_DIR="${HOME:-/tmp}/.claude"
|
|
39
|
+
CACHE="$CACHE_DIR/.claude-skills-version"
|
|
40
|
+
STAMP="$CACHE_DIR/.claude-skills-version-told"
|
|
41
|
+
mkdir -p "$CACHE_DIR" 2>/dev/null
|
|
42
|
+
|
|
43
|
+
NOW=$(date +%s 2>/dev/null || echo 0)
|
|
44
|
+
DAY=86400
|
|
45
|
+
|
|
46
|
+
# Refresh in the background, fully detached. Nothing downstream waits on it: all
|
|
47
|
+
# three streams are closed, or the session start blocks until curl gives up.
|
|
48
|
+
refresh() {
|
|
49
|
+
AGE=$DAY
|
|
50
|
+
if [ -f "$CACHE" ]; then
|
|
51
|
+
MTIME=$(date -r "$CACHE" +%s 2>/dev/null || echo 0)
|
|
52
|
+
AGE=$((NOW - MTIME))
|
|
53
|
+
fi
|
|
54
|
+
[ "$AGE" -lt "$DAY" ] && return
|
|
55
|
+
command -v curl >/dev/null 2>&1 || return
|
|
56
|
+
( curl -fsS --max-time 5 "https://registry.npmjs.org/$PKG/latest" \
|
|
57
|
+
| sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' \
|
|
58
|
+
| head -1 > "$CACHE.tmp" 2>/dev/null
|
|
59
|
+
# Only replace the cache with something that parsed. A truncated reply that
|
|
60
|
+
# overwrote it would nudge toward a version that does not exist.
|
|
61
|
+
if [ -s "$CACHE.tmp" ]; then mv "$CACHE.tmp" "$CACHE"; else rm -f "$CACHE.tmp"; fi
|
|
62
|
+
) </dev/null >/dev/null 2>&1 &
|
|
63
|
+
}
|
|
64
|
+
refresh
|
|
65
|
+
|
|
66
|
+
[ -f "$CACHE" ] || exit 0
|
|
67
|
+
LATEST=$(head -1 "$CACHE" | tr -d '[:space:]')
|
|
68
|
+
[ -n "$LATEST" ] || exit 0
|
|
69
|
+
[ "$LATEST" = "$INSTALLED" ] && exit 0
|
|
70
|
+
|
|
71
|
+
# sort -V puts the newer one last. Equal versions were already handled, so the
|
|
72
|
+
# only case left to reject is a cache that is BEHIND the install — which happens
|
|
73
|
+
# normally right after publishing from the machine that publishes.
|
|
74
|
+
NEWER=$(printf '%s\n%s\n' "$INSTALLED" "$LATEST" | sort -V | tail -1)
|
|
75
|
+
[ "$NEWER" = "$LATEST" ] || exit 0
|
|
76
|
+
|
|
77
|
+
# Once a day, however many sessions that day holds.
|
|
78
|
+
if [ -f "$STAMP" ]; then
|
|
79
|
+
TOLD=$(date -r "$STAMP" +%s 2>/dev/null || echo 0)
|
|
80
|
+
[ $((NOW - TOLD)) -lt "$DAY" ] && exit 0
|
|
81
|
+
fi
|
|
82
|
+
: > "$STAMP"
|
|
83
|
+
|
|
84
|
+
echo "A newer version of the $PKG catalog is available: $INSTALLED installed, $LATEST published."
|
|
85
|
+
echo "Mention this to the user ONCE, in a single line, and only when the current task is done."
|
|
86
|
+
echo "Do not interrupt what they are doing, and do not run the installer unless they ask."
|
|
87
|
+
echo "To update, they run: npx $PKG"
|
|
88
|
+
exit 0
|