Merge branch 'main' into codex/repo-audit-remediation-package-2
# Conflicts: # README.md # setup/scripts/install-packages
This commit is contained in:
@@ -32,8 +32,6 @@ panama="$repo_dir/bin/panama"
|
||||
findings=()
|
||||
note() { findings+=("$1"); }
|
||||
|
||||
[[ -d "$apps_dir" ]] || { printf 'apps contract: no %s\n' "$apps_dir" >&2; exit 1; }
|
||||
|
||||
shopt -s nullglob
|
||||
definitions=("$apps_dir"/*)
|
||||
|
||||
|
||||
Executable
+195
@@ -0,0 +1,195 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# The one download that gets to run as root, and how it earns that.
|
||||
#
|
||||
# OpenAI publishes no signing key and no fingerprint that a first install could
|
||||
# fetch and compare against: the documented instructions are to download an RPM
|
||||
# and install it, and that RPM's own root scriptlet is what decides afterwards
|
||||
# which repository and which key the machine will trust. Panama pins the key
|
||||
# instead -- setup/keys/ carries it, setup/lib/chatgpt-package verifies the copy
|
||||
# and writes the repository -- so dnf checks a signature before root sees a byte
|
||||
# of it.
|
||||
#
|
||||
# What must hold:
|
||||
#
|
||||
# 1. The pinned key is the key the library says it is. Everything else here
|
||||
# is worthless if this drifts, and a changed key must be a failing test
|
||||
# somebody reads rather than a quiet change of publisher.
|
||||
# 2. A pinned key that is missing, unreadable, or simply not that key stops
|
||||
# the install and leaves the machine untouched. Failing closed is the
|
||||
# whole point; falling back to installing anyway would be worse than
|
||||
# never having checked.
|
||||
# 3. What it writes actually enforces the check: gpgcheck and repo_gpgcheck
|
||||
# on, and the gpgkey pointing at the key it just installed.
|
||||
# 4. Both callers go through it, and neither hands root a downloaded RPM.
|
||||
# The installer and the codex-desktop migration install `chatgpt` by name
|
||||
# from that repository, which is what makes the signature mandatory.
|
||||
#
|
||||
# Hermetic: the key file is read locally, root is a stub that records what it
|
||||
# was asked to do, and the destinations are redirected into a temporary
|
||||
# directory. Nothing here contacts OpenAI or touches /etc.
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
library="$repo_dir/setup/lib/chatgpt-package"
|
||||
installer="$repo_dir/setup/scripts/install-packages"
|
||||
migration="$repo_dir/migrations/1787804505.sh"
|
||||
pinned_key="$repo_dir/setup/keys/RPM-GPG-KEY-chatgpt"
|
||||
|
||||
findings=()
|
||||
note() { findings+=("$1"); }
|
||||
|
||||
[[ -r "$library" ]] || {
|
||||
printf 'chatgpt package contract: %s is missing\n' "$library" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
command -v gpg >/dev/null 2>&1 || {
|
||||
printf 'chatgpt package contract: gpg is required to read the pinned key\n' >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
work="$(mktemp -d)"
|
||||
trap 'rm -rf "$work"' EXIT
|
||||
|
||||
# Root, as a recording stub. It logs the command and then runs it for real,
|
||||
# which is safe because every destination below is redirected into $work.
|
||||
stub="$work/bin"
|
||||
mkdir -p "$stub"
|
||||
cat >"$stub/sudo" <<'STUB'
|
||||
#!/usr/bin/env bash
|
||||
printf '%s\n' "$*" >>"$SUDO_RECORD"
|
||||
exec "$@"
|
||||
STUB
|
||||
cat >"$stub/rpmkeys" <<'STUB'
|
||||
#!/usr/bin/env bash
|
||||
printf '%s\n' "$*" >>"$RPMKEYS_RECORD"
|
||||
STUB
|
||||
chmod +x "$stub/sudo" "$stub/rpmkeys"
|
||||
export PATH="$stub:$PATH"
|
||||
|
||||
# ── 1. The pinned key is the pinned key ─────────────────────────────────────
|
||||
|
||||
if [[ ! -r "$pinned_key" ]]; then
|
||||
note 'setup/keys/RPM-GPG-KEY-chatgpt is missing, so nothing can be verified'
|
||||
else
|
||||
declared="$(grep -oP '(?<=^CHATGPT_KEY_FINGERPRINT=")[0-9A-F]+' "$library" | head -1)"
|
||||
actual="$(gpg --show-keys --with-colons "$pinned_key" 2>/dev/null \
|
||||
| awk -F: '$1 == "fpr" { print $10; exit }')"
|
||||
[[ -n "$declared" ]] \
|
||||
|| note 'the library pins no fingerprint, so any key file would be accepted'
|
||||
[[ -n "$actual" ]] \
|
||||
|| note 'the pinned key file does not parse as a public key'
|
||||
[[ "$declared" == "$actual" ]] \
|
||||
|| note "the pinned key is $actual but the library expects $declared"
|
||||
grep -q 'RPM-GPG-KEY-chatgpt' "$repo_dir/setup/keys/README.md" 2>/dev/null \
|
||||
|| note 'setup/keys/README.md does not record where the pinned key came from'
|
||||
grep -q "$actual" "$repo_dir/setup/keys/README.md" 2>/dev/null \
|
||||
|| note 'setup/keys/README.md records a fingerprint other than the key it ships'
|
||||
fi
|
||||
|
||||
# One attempt, against redirected destinations and a recording root. Every
|
||||
# variable the library exposes is set here rather than in the caller's shell,
|
||||
# so a case cannot leak into the next one.
|
||||
attempt() {
|
||||
local dir="$1" panama_path="$2" fingerprint="${3:-}"
|
||||
mkdir -p "$dir"
|
||||
(
|
||||
export SUDO_RECORD="$dir/sudo.log" RPMKEYS_RECORD="$dir/rpmkeys.log"
|
||||
: >"$SUDO_RECORD"
|
||||
: >"$RPMKEYS_RECORD"
|
||||
PANAMA_PATH="$panama_path"
|
||||
# shellcheck source=/dev/null
|
||||
source "$library"
|
||||
CHATGPT_KEY_FILE="$dir/pki/RPM-GPG-KEY-chatgpt"
|
||||
CHATGPT_REPO_FILE="$dir/repos/chatgpt.repo"
|
||||
mkdir -p "$dir/repos"
|
||||
[[ -z "$fingerprint" ]] || CHATGPT_KEY_FINGERPRINT="$fingerprint"
|
||||
chatgpt_install_repository sudo
|
||||
) >"$dir/out" 2>&1
|
||||
}
|
||||
|
||||
# ── 2. It fails closed ──────────────────────────────────────────────────────
|
||||
|
||||
# A checkout with no pinned key at all.
|
||||
empty="$work/no-key"
|
||||
mkdir -p "$empty/checkout/setup/keys"
|
||||
attempt "$empty" "$empty/checkout" \
|
||||
&& note 'a missing pinned key still established the repository'
|
||||
[[ ! -e "$empty/repos/chatgpt.repo" ]] \
|
||||
|| note 'a missing pinned key still wrote a repository file'
|
||||
grep -qi 'missing' "$empty/out" \
|
||||
|| note 'a missing pinned key does not say so'
|
||||
|
||||
# A key file that is not a key.
|
||||
garbage="$work/garbage-key"
|
||||
mkdir -p "$garbage/checkout/setup/keys"
|
||||
printf 'not a key\n' >"$garbage/checkout/setup/keys/RPM-GPG-KEY-chatgpt"
|
||||
attempt "$garbage" "$garbage/checkout" \
|
||||
&& note 'an unreadable pinned key still established the repository'
|
||||
[[ ! -e "$garbage/repos/chatgpt.repo" ]] \
|
||||
|| note 'an unreadable pinned key still wrote a repository file'
|
||||
|
||||
# The real key, against a fingerprint that is not its own -- the shape a
|
||||
# substituted publisher would take.
|
||||
wrong="$work/wrong-fingerprint"
|
||||
attempt "$wrong" "$repo_dir" '0000000000000000000000000000000000000000' \
|
||||
&& note 'a key that does not match the pinned fingerprint was accepted'
|
||||
[[ ! -e "$wrong/repos/chatgpt.repo" ]] \
|
||||
|| note 'a fingerprint mismatch still wrote a repository file'
|
||||
[[ ! -s "$wrong/rpmkeys.log" ]] \
|
||||
|| note 'a fingerprint mismatch still imported the key into the rpm keyring'
|
||||
|
||||
# ── 3. What it writes enforces the check ────────────────────────────────────
|
||||
|
||||
good="$work/verified"
|
||||
if ! attempt "$good" "$repo_dir"; then
|
||||
note "the pinned key was rejected: $(cat "$good/out")"
|
||||
else
|
||||
repo_file="$good/repos/chatgpt.repo"
|
||||
key_file="$good/pki/RPM-GPG-KEY-chatgpt"
|
||||
|
||||
cmp -s "$key_file" "$pinned_key" \
|
||||
|| note 'the installed key is not the pinned key'
|
||||
grep -q 'import' "$good/rpmkeys.log" \
|
||||
|| note 'the verified key was never imported, so dnf has nothing to check against'
|
||||
|
||||
grep -qx 'gpgcheck=1' "$repo_file" \
|
||||
|| note 'the repository does not set gpgcheck=1, so package signatures go unchecked'
|
||||
grep -qx 'repo_gpgcheck=1' "$repo_file" \
|
||||
|| note 'the repository does not set repo_gpgcheck=1, so the metadata goes unchecked'
|
||||
grep -qx "gpgkey=file://$key_file" "$repo_file" \
|
||||
|| note 'the repository does not point gpgkey at the key that was just installed'
|
||||
grep -q 'baseurl=https://' "$repo_file" \
|
||||
|| note 'the repository has no https base URL'
|
||||
fi
|
||||
|
||||
# ── 4. Both callers go through it ───────────────────────────────────────────
|
||||
|
||||
for caller in "$installer" "$migration"; do
|
||||
name="${caller#"$repo_dir"/}"
|
||||
[[ -r "$caller" ]] || { note "$name is missing"; continue; }
|
||||
|
||||
grep -q 'setup/lib/chatgpt-package' "$caller" \
|
||||
|| note "$name does not source the verified install library"
|
||||
grep -q 'chatgpt_install_repository' "$caller" \
|
||||
|| note "$name does not establish the verified repository before installing"
|
||||
grep -qE 'dnf install -y chatgpt\b' "$caller" \
|
||||
|| note "$name does not install chatgpt by name from that repository"
|
||||
|
||||
# The shape this contract exists to keep out: fetch an RPM, hand it to
|
||||
# root, and let its scriptlet decide what the machine trusts afterwards.
|
||||
grep -qE 'curl.*chatgpt.*\.rpm' "$caller" \
|
||||
&& note "$name downloads a ChatGPT RPM instead of installing it from the verified repository"
|
||||
grep -qE 'dnf install[^|]*\$\{?chatgpt_rpm' "$caller" \
|
||||
&& note "$name installs a downloaded ChatGPT RPM as root"
|
||||
done
|
||||
|
||||
if (( ${#findings[@]} > 0 )); then
|
||||
printf 'chatgpt package contract: %d finding(s)\n' "${#findings[@]}" >&2
|
||||
printf ' - %s\n' "${findings[@]}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
printf 'chatgpt package contract: PASS\n'
|
||||
@@ -11,16 +11,22 @@ manifest="$repo_dir/tests/contracts.manifest"
|
||||
|
||||
discover_contracts() {
|
||||
discovered_contracts=()
|
||||
# Byte order, exactly as the runner discovers them. A UTF-8 collation folds
|
||||
# the punctuation away and reorders the pairs that differ only by `-` and
|
||||
# `_`, so a manifest correct here would be wrong on a machine with a
|
||||
# different LANG.
|
||||
while IFS= read -r path; do
|
||||
[[ -x "$path" || "$path" == *_test.py ]] || continue
|
||||
discovered_contracts+=("tests/${path#"$repo_dir/tests/"}")
|
||||
done < <(find "$repo_dir/tests" -type f \
|
||||
-not -path '*/fixtures/*' -not -path '*__pycache__*' | sort)
|
||||
-not -path '*/fixtures/*' -not -path '*__pycache__*' | LC_ALL=C sort)
|
||||
}
|
||||
|
||||
validate_manifest() {
|
||||
local candidate="$1"
|
||||
local -n expected_contracts="$2"
|
||||
# Byte order, for the same reason discover_contracts sorts in it.
|
||||
local LC_ALL=C
|
||||
local line capabilities path extra previous_comment="" previous_was_comment=0
|
||||
local -a capability_list=()
|
||||
local -A manifest_paths=() capability_counts=()
|
||||
|
||||
@@ -336,8 +336,9 @@ expect_failure load_installer_provenance "$parser_fixture"
|
||||
installer_fixture="$test_tmp/installer-fixture"
|
||||
mkdir -p "$installer_fixture/setup/lib" "$installer_fixture/setup/provenance/keys" \
|
||||
"$installer_fixture/setup/scripts"
|
||||
cp "$repo_dir/setup/lib/artifact-provenance" "$repo_dir/setup/lib/extras-catalog" \
|
||||
"$repo_dir/setup/lib/machine-role" "$installer_fixture/setup/lib/"
|
||||
cp "$repo_dir/setup/lib/artifact-provenance" "$repo_dir/setup/lib/chatgpt-package" \
|
||||
"$repo_dir/setup/lib/extras-catalog" "$repo_dir/setup/lib/machine-role" \
|
||||
"$installer_fixture/setup/lib/"
|
||||
cp "$config" "$installer_fixture/setup/provenance/installers.conf"
|
||||
cp "$repo_dir"/setup/provenance/keys/*.asc "$installer_fixture/setup/provenance/keys/"
|
||||
sed '/^# --- The server path/,$d' "$repo_dir/setup/scripts/install-packages" \
|
||||
|
||||
+41
-20
@@ -2,7 +2,7 @@
|
||||
|
||||
# The manual this machine hands an agent.
|
||||
#
|
||||
# skills/ and .claude/skills/panama exist because an agent asked to do anything
|
||||
# skills/ and .agents/skills/panama exist because an agent asked to do anything
|
||||
# on a Panama desktop will otherwise infer it from the source and get half of it
|
||||
# wrong. That only helps if what the skills say is true -- and a skill is worse
|
||||
# than no skill when it is stale, because an agent believes it verbatim and does
|
||||
@@ -16,7 +16,7 @@
|
||||
# That is a convention on the prose -- name things exactly, in backticks --
|
||||
# and it is how they should be written anyway.
|
||||
# 3. The delivery works: link-skills is a stage, in the right place, and the
|
||||
# personal manifest hands ~/.claude/skills over to the linkdir kind.
|
||||
# personal manifest hands both shared skill homes to the linkdir kind.
|
||||
#
|
||||
# Sections 1 and 2 report clearly and keep going when a skill is not written
|
||||
# yet, so this contract is useful while the skills are still being authored.
|
||||
@@ -32,9 +32,21 @@ manifest="$repo_dir/user/manifest"
|
||||
findings=()
|
||||
note() { findings+=("$1"); }
|
||||
|
||||
# The three, and where each is delivered from. The two under skills/ are shipped
|
||||
# to every machine; the third is project-level and needs no delivery at all.
|
||||
SKILL_DIRS=(skills/panama-desktop skills/panama-sudo .claude/skills/panama)
|
||||
# Every directory under skills/ ships to every machine. The project-level
|
||||
# panama skill stays in this repository and needs no home-directory delivery.
|
||||
SKILL_DIRS=()
|
||||
for directory in "$repo_dir"/skills/*; do
|
||||
[[ -d "$directory" ]] && SKILL_DIRS+=("${directory#"$repo_dir"/}")
|
||||
done
|
||||
SKILL_DIRS+=(.agents/skills/panama)
|
||||
|
||||
# The project skill has one agent-neutral source. Claude gets a compatibility
|
||||
# symlink, while Codex and other Agent Skills readers use .agents directly.
|
||||
[[ -L "$repo_dir/.claude/skills/panama" ]] \
|
||||
|| note '.claude/skills/panama is not a compatibility symlink to the agent-neutral source'
|
||||
[[ "$(readlink -f "$repo_dir/.claude/skills/panama" 2>/dev/null)" == \
|
||||
"$(readlink -f "$repo_dir/.agents/skills/panama" 2>/dev/null)" ]] \
|
||||
|| note '.claude and .agents resolve the project panama skill differently'
|
||||
|
||||
# ── 1. Each skill loads ─────────────────────────────────────────────────────
|
||||
|
||||
@@ -54,7 +66,7 @@ for relative in "${SKILL_DIRS[@]}"; do
|
||||
present+=("$directory")
|
||||
|
||||
# Frontmatter is the first --- delimited block, and a skill without one is
|
||||
# not a skill: Claude Code skips the directory entirely.
|
||||
# not a skill: agent loaders skip the directory entirely.
|
||||
frontmatter="$(awk 'NR==1 { if ($0 != "---") exit 1; next } $0 == "---" { exit } { print }' "$file")"
|
||||
if [[ -z "$frontmatter" ]]; then
|
||||
note "$relative/SKILL.md does not open with a --- frontmatter block"
|
||||
@@ -207,12 +219,12 @@ else
|
||||
work="$(mktemp -d)"
|
||||
trap 'rm -rf "$work"' EXIT
|
||||
|
||||
# A checkout and a home of its own. Never the real ones: ~/.claude/skills on
|
||||
# this machine is somebody's live agent setup, and a contract that broke it
|
||||
# mid-session would be worse than the bug it was looking for.
|
||||
# A checkout and a home of its own. Never the real ones: these are somebody's
|
||||
# live agent setup, and a contract that broke them mid-session would be worse
|
||||
# than the bug it was looking for.
|
||||
checkout="$work/Panama"
|
||||
home="$work/home"
|
||||
mkdir -p "$checkout/setup/scripts" "$checkout/skills/shipped" "$home/.claude"
|
||||
mkdir -p "$checkout/setup/scripts" "$checkout/skills/shipped" "$home/.claude" "$home/.agents"
|
||||
cp "$linker" "$checkout/setup/scripts/link-skills"
|
||||
printf 'a shipped skill\n' >"$checkout/skills/shipped/SKILL.md"
|
||||
|
||||
@@ -220,6 +232,7 @@ else
|
||||
# symlink, which is what link-user used to leave here.
|
||||
mkdir -p "$work/personal"
|
||||
ln -s "$work/personal" "$home/.claude/skills"
|
||||
ln -s "$work/personal" "$home/.agents/skills"
|
||||
|
||||
run() { HOME="$home" PANAMA_PATH="$checkout" "$checkout/setup/scripts/link-skills" >"$work/log" 2>&1; }
|
||||
|
||||
@@ -231,22 +244,30 @@ else
|
||||
|| note 'link-skills left ~/.claude/skills a symlink, so nothing else can be linked into it'
|
||||
[[ -L "$home/.claude/skills/shipped" ]] \
|
||||
|| note 'link-skills did not link each shipped skill as a child of ~/.claude/skills'
|
||||
[[ -d "$home/.agents/skills" && ! -L "$home/.agents/skills" ]] \
|
||||
|| note 'link-skills left ~/.agents/skills a symlink, so shipped and personal skills cannot coexist'
|
||||
[[ -L "$home/.agents/skills/shipped" ]] \
|
||||
|| note 'link-skills did not link each shipped skill as a child of ~/.agents/skills'
|
||||
grep -q 'Agent skills: 1 linked' "$work/log" \
|
||||
|| note 'link-skills does not report how many skills it linked'
|
||||
|
||||
# A real directory at a shipped skill's name is somebody's work: it moves to
|
||||
# config/old rather than being deleted, the same promise the other stages
|
||||
# make. A symlink is not, and must not accumulate there.
|
||||
rm "$home/.claude/skills/shipped"
|
||||
mkdir -p "$home/.claude/skills/shipped"
|
||||
printf 'installed by hand\n' >"$home/.claude/skills/shipped/SKILL.md"
|
||||
mkdir -p "$home/.claude/skills/untouched"
|
||||
for skill_home in "$home/.claude/skills" "$home/.agents/skills"; do
|
||||
rm -f "$skill_home/shipped"
|
||||
mkdir -p "$skill_home/shipped"
|
||||
printf 'installed by hand\n' >"$skill_home/shipped/SKILL.md"
|
||||
mkdir -p "$skill_home/untouched"
|
||||
done
|
||||
|
||||
run
|
||||
grep -rq 'installed by hand' "$checkout/config/old" 2>/dev/null \
|
||||
|| note 'link-skills destroyed a real skill instead of moving it to config/old'
|
||||
[[ -d "$home/.claude/skills/untouched" ]] \
|
||||
|| note 'link-skills removed a skill it does not ship'
|
||||
[[ "$(grep -rl 'installed by hand' "$checkout/config/old" 2>/dev/null | wc -l)" == 2 ]] \
|
||||
|| note 'link-skills did not preserve real skills from both agent homes'
|
||||
for skill_home in "$home/.claude/skills" "$home/.agents/skills"; do
|
||||
[[ -d "$skill_home/untouched" ]] \
|
||||
|| note "link-skills removed an unshipped skill from $skill_home"
|
||||
done
|
||||
|
||||
before="$(find "$checkout/config/old" | wc -l)"
|
||||
run
|
||||
@@ -287,8 +308,8 @@ fi
|
||||
|
||||
grep -qE '^\s*linkdir\s+agents/skills\s+~/\.claude/skills\s*$' "$manifest" \
|
||||
|| note 'the manifest does not use linkdir for ~/.claude/skills, so personal skills would replace the directory'
|
||||
grep -qE '^\s*link\s+agents/skills\s+~/\.agents/skills\s*$' "$manifest" \
|
||||
|| note '~/.agents/skills is no longer a whole-directory link, and nothing else claims that path'
|
||||
grep -qE '^\s*linkdir\s+agents/skills\s+~/\.agents/skills\s*$' "$manifest" \
|
||||
|| note 'the manifest does not use linkdir for ~/.agents/skills, so personal skills would replace shipped skills'
|
||||
grep -q 'linkdir)' "$user_linker" \
|
||||
|| note 'link-user does not implement the linkdir kind the manifest asks for'
|
||||
grep -q 'linkdir' "$repo_dir/user/README.md" \
|
||||
|
||||
@@ -73,7 +73,7 @@ cat >"$checkout/user/manifest" <<'FIXTURE'
|
||||
# a comment, and a blank line follow
|
||||
|
||||
link agents/AGENTS.md ~/.claude/CLAUDE.md
|
||||
link agents/skills ~/.agents/skills
|
||||
linkdir agents/skills ~/.agents/skills
|
||||
linkdir agents/skills ~/.claude/skills
|
||||
copy plain.txt ~/.config/plain.txt
|
||||
link missing.txt ~/.config/missing.txt
|
||||
@@ -102,11 +102,12 @@ PANAMA_USER_CONTENT=no run
|
||||
|
||||
# ── 2. Saying yes links, and keeps what was there ───────────────────────────
|
||||
|
||||
# ~/.claude/skills is shared now: link-skills has already made it a real
|
||||
# directory and linked Panama's own skills into it. A linkdir entry has to land
|
||||
# beside those rather than replace the directory holding them.
|
||||
mkdir -p "$home/.claude/skills"
|
||||
# Both skill homes are shared now: link-skills has already made them real
|
||||
# directories and linked Panama's own skills into each. Personal linkdir
|
||||
# entries land beside those rather than replacing either directory.
|
||||
mkdir -p "$home/.claude/skills" "$home/.agents/skills"
|
||||
ln -s "$checkout/skills/shipped" "$home/.claude/skills/shipped"
|
||||
ln -s "$checkout/skills/shipped" "$home/.agents/skills/shipped"
|
||||
|
||||
PANAMA_USER_CONTENT=yes run
|
||||
|
||||
@@ -114,8 +115,12 @@ PANAMA_USER_CONTENT=yes run
|
||||
|| note 'CLAUDE.md was not replaced with a symlink into the checkout'
|
||||
[[ "$(cat "$home/.claude/CLAUDE.md")" == "tracked instructions" ]] \
|
||||
|| note 'the CLAUDE.md link does not resolve to the tracked file'
|
||||
[[ -L "$home/.agents/skills" && -f "$home/.agents/skills/example/SKILL.md" ]] \
|
||||
|| note 'the skills directory was not linked as a directory'
|
||||
[[ -d "$home/.agents/skills" && ! -L "$home/.agents/skills" ]] \
|
||||
|| note 'a linkdir entry replaced ~/.agents/skills with a symlink'
|
||||
[[ -L "$home/.agents/skills/example" && -f "$home/.agents/skills/example/SKILL.md" ]] \
|
||||
|| note 'a linkdir entry did not publish the personal skill for agent-neutral readers'
|
||||
[[ -L "$home/.agents/skills/shipped" ]] \
|
||||
|| note 'a linkdir entry removed the shipped skill from ~/.agents/skills'
|
||||
[[ -d "$home/.claude/skills" && ! -L "$home/.claude/skills" ]] \
|
||||
|| note 'a linkdir entry replaced its destination directory with a symlink'
|
||||
[[ -L "$home/.claude/skills/example" && -f "$home/.claude/skills/example/SKILL.md" ]] \
|
||||
|
||||
Reference in New Issue
Block a user