Why: repair_placeholders ran inside a command substitution, so a failing sed left an emptied file behind and the script still exited 0 reporting success. - write via tmp file and abort on sed or mv failure - re-check the target before moving staging into place - stage in a dot-prefixed mktemp dir so a killed run leaves no fake skill - source apm claims in deployment-modes.md; tag untyped code blocks Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KkT7RSDwDbmrM9T34b6sTi
336 lines
11 KiB
Bash
336 lines
11 KiB
Bash
#!/usr/bin/env bats
|
|
|
|
setup() {
|
|
REPO_ROOT="$(cd "$BATS_TEST_DIRNAME/../../../../../../" && pwd)"
|
|
load "$REPO_ROOT/tests/test_helper/bats-support/load"
|
|
load "$REPO_ROOT/tests/test_helper/bats-assert/load"
|
|
|
|
SCRIPT="$(cd "$BATS_TEST_DIRNAME/../scripts" && pwd)/new-skill.sh"
|
|
DEST="$(mktemp -d)"
|
|
}
|
|
|
|
teardown() {
|
|
rm -rf "$DEST"
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Passing cases
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@test "--help exits 0" {
|
|
run bash "$SCRIPT" --help
|
|
assert_success
|
|
assert_output --partial "Usage:"
|
|
}
|
|
|
|
@test "creates scaffold directory at destination" {
|
|
run bash "$SCRIPT" my-tool "$DEST"
|
|
assert_success
|
|
assert [ -d "$DEST/my-tool" ]
|
|
}
|
|
|
|
@test "scaffold contains SKILL.md" {
|
|
bash "$SCRIPT" my-tool "$DEST"
|
|
assert [ -f "$DEST/my-tool/SKILL.md" ]
|
|
}
|
|
|
|
@test "scaffold contains scripts/, references/, assets/, tests/ directories" {
|
|
bash "$SCRIPT" my-tool "$DEST"
|
|
assert [ -d "$DEST/my-tool/scripts" ]
|
|
assert [ -d "$DEST/my-tool/references" ]
|
|
assert [ -d "$DEST/my-tool/assets" ]
|
|
assert [ -d "$DEST/my-tool/tests" ]
|
|
}
|
|
|
|
@test "substitutes skill name in SKILL.md" {
|
|
bash "$SCRIPT" my-tool "$DEST"
|
|
run grep "my-tool" "$DEST/my-tool/SKILL.md"
|
|
assert_success
|
|
}
|
|
|
|
@test "substitutes skill name in tests/README.md" {
|
|
bash "$SCRIPT" my-tool "$DEST"
|
|
run grep "my-tool" "$DEST/my-tool/tests/README.md"
|
|
assert_success
|
|
}
|
|
|
|
@test "skill name with numbers is valid" {
|
|
run bash "$SCRIPT" my-tool-2 "$DEST"
|
|
assert_success
|
|
assert [ -d "$DEST/my-tool-2" ]
|
|
}
|
|
|
|
@test "next-steps output references /factory-audit not validate.sh" {
|
|
run bash "$SCRIPT" my-tool "$DEST"
|
|
assert_output --partial "/factory-audit"
|
|
refute_output --partial "validate.sh"
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Failing cases
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@test "fails when no arguments given" {
|
|
run bash "$SCRIPT"
|
|
assert_failure
|
|
}
|
|
|
|
@test "fails when skill name contains uppercase" {
|
|
run bash "$SCRIPT" MyTool "$DEST"
|
|
assert_failure
|
|
}
|
|
|
|
@test "fails when skill name has consecutive hyphens" {
|
|
run bash "$SCRIPT" my--tool "$DEST"
|
|
assert_failure
|
|
}
|
|
|
|
@test "fails when skill name has a leading hyphen" {
|
|
run bash "$SCRIPT" -my-tool "$DEST"
|
|
assert_failure
|
|
}
|
|
|
|
@test "fails when skill name has a trailing hyphen" {
|
|
run bash "$SCRIPT" my-tool- "$DEST"
|
|
assert_failure
|
|
}
|
|
|
|
@test "fails when destination directory does not exist" {
|
|
run bash "$SCRIPT" my-tool "/nonexistent/path"
|
|
assert_failure
|
|
}
|
|
|
|
@test "exits 0 when target already exists (no-op)" {
|
|
mkdir -p "$DEST/my-tool"
|
|
run bash "$SCRIPT" my-tool "$DEST"
|
|
assert_success
|
|
assert_output --partial "nothing to do"
|
|
}
|
|
|
|
@test "no SKILL_NAME placeholder remains anywhere in a fresh scaffold" {
|
|
bash "$SCRIPT" my-tool "$DEST"
|
|
run grep -r "SKILL_NAME" "$DEST/my-tool"
|
|
assert_failure
|
|
}
|
|
|
|
# Lists every entry in $1 other than my-tool and the fixture copies, so a
|
|
# staging directory of any name (dotted or not) left behind is caught.
|
|
leftovers() {
|
|
local entry name
|
|
for entry in "$1"/* "$1"/.[!.]* "$1"/..?*; do
|
|
[[ -e "$entry" ]] || continue
|
|
name=${entry##*/}
|
|
case "$name" in my-tool|bin|skill.orig) ;; *) printf '%s\n' "$name" ;; esac
|
|
done
|
|
}
|
|
|
|
# Puts a `sed` on PATH that fails without writing output, so a test can
|
|
# inject a failure into the substitution step.
|
|
stub_failing_sed() {
|
|
mkdir -p "$DEST/bin"
|
|
printf '#!/usr/bin/env bash\nexit 1\n' > "$DEST/bin/sed"
|
|
chmod +x "$DEST/bin/sed"
|
|
}
|
|
|
|
@test "leaves no staging directory behind after a successful run" {
|
|
bash "$SCRIPT" my-tool "$DEST"
|
|
run leftovers "$DEST"
|
|
assert_output ""
|
|
}
|
|
|
|
@test "stages the build in a dot-prefixed directory that cannot pass for a skill" {
|
|
# A SIGKILL skips the cleanup trap, so the staging directory's name is what
|
|
# keeps a leftover from looking like a skill under .apm/skills/.
|
|
mkdir -p "$DEST/bin"
|
|
real_sed="$(command -v sed)"
|
|
printf '#!/usr/bin/env bash\nprintf "%%s\\n" "${@: -1}" >> "%s/sed.log"\nexec "%s" "$@"\n' \
|
|
"$DEST/bin" "$real_sed" > "$DEST/bin/sed"
|
|
chmod +x "$DEST/bin/sed"
|
|
PATH="$DEST/bin:$PATH" run bash "$SCRIPT" my-tool "$DEST"
|
|
assert_success
|
|
run grep -c . "$DEST/bin/sed.log"
|
|
refute_output "0"
|
|
run grep -vE "^$DEST/\.[^/]+/" "$DEST/bin/sed.log"
|
|
assert_output ""
|
|
}
|
|
|
|
@test "scaffold directory gets umask-default permissions, not mktemp's 0700" {
|
|
bash "$SCRIPT" my-tool "$DEST"
|
|
mkdir "$DEST/reference-dir"
|
|
assert_equal "$(ls -ld "$DEST/my-tool" | cut -c1-10)" \
|
|
"$(ls -ld "$DEST/reference-dir" | cut -c1-10)"
|
|
rmdir "$DEST/reference-dir"
|
|
}
|
|
|
|
@test "fresh scaffold: a failing sed aborts non-zero with no target and no staging left" {
|
|
stub_failing_sed
|
|
PATH="$DEST/bin:$PATH" run bash "$SCRIPT" my-tool "$DEST"
|
|
assert_failure
|
|
assert [ ! -e "$DEST/my-tool" ]
|
|
run leftovers "$DEST"
|
|
assert_output ""
|
|
}
|
|
|
|
@test "repair: a failing sed aborts non-zero and leaves the original file unchanged" {
|
|
cp -r "$BATS_TEST_DIRNAME/../assets/templates" "$DEST/my-tool"
|
|
cp "$DEST/my-tool/SKILL.md" "$DEST/skill.orig"
|
|
stub_failing_sed
|
|
PATH="$DEST/bin:$PATH" run bash "$SCRIPT" my-tool "$DEST"
|
|
assert_failure
|
|
refute_output --partial "Repaired"
|
|
run cmp "$DEST/skill.orig" "$DEST/my-tool/SKILL.md"
|
|
assert_success
|
|
run find "$DEST/my-tool" -name '*.tmp'
|
|
assert_output ""
|
|
}
|
|
|
|
@test "a target that appears after the existence check is not nested into" {
|
|
# A sed wrapper creates the target mid-build, standing in for a concurrent
|
|
# run winning the race between the check and the final rename.
|
|
mkdir -p "$DEST/bin"
|
|
real_sed="$(command -v sed)"
|
|
printf '#!/usr/bin/env bash\nmkdir -p "%s/my-tool"\nexec "%s" "$@"\n' \
|
|
"$DEST" "$real_sed" > "$DEST/bin/sed"
|
|
chmod +x "$DEST/bin/sed"
|
|
PATH="$DEST/bin:$PATH" run bash "$SCRIPT" my-tool "$DEST"
|
|
assert_failure
|
|
run ls -A "$DEST/my-tool"
|
|
assert_output ""
|
|
run leftovers "$DEST"
|
|
assert_output ""
|
|
}
|
|
|
|
@test "retry repairs a half-built scaffold that still carries SKILL_NAME" {
|
|
# Simulate an earlier run that copied the templates but died before the
|
|
# name substitution: the retry must finish the job, not no-op.
|
|
cp -r "$BATS_TEST_DIRNAME/../assets/templates" "$DEST/my-tool"
|
|
run bash "$SCRIPT" my-tool "$DEST"
|
|
assert_success
|
|
assert_output --partial "Repaired partial scaffold"
|
|
assert_output --partial "only the name placeholder"
|
|
run grep -r "SKILL_NAME" "$DEST/my-tool"
|
|
assert_failure
|
|
run grep -E '^name: my-tool$' "$DEST/my-tool/SKILL.md"
|
|
assert_success
|
|
}
|
|
|
|
@test "a complete skill whose text mentions SKILL_NAME stays a true no-op" {
|
|
# Only the template's exact marker lines mark a half-built scaffold. A
|
|
# finished skill that merely mentions the token must not be rewritten.
|
|
mkdir -p "$DEST/my-tool/tests"
|
|
printf -- '---\nname: my-tool\n---\n\nUse `__SKILL_NAME_PLACEHOLDER__` here.\n' \
|
|
> "$DEST/my-tool/SKILL.md"
|
|
printf 'Set SKILL_NAME before running.\n' > "$DEST/my-tool/tests/README.md"
|
|
cp "$DEST/my-tool/SKILL.md" "$DEST/skill.orig"
|
|
cp "$DEST/my-tool/tests/README.md" "$DEST/readme.orig"
|
|
run bash "$SCRIPT" my-tool "$DEST"
|
|
assert_success
|
|
assert_output --partial "nothing to do"
|
|
refute_output --partial "Repaired"
|
|
run cmp "$DEST/skill.orig" "$DEST/my-tool/SKILL.md"
|
|
assert_success
|
|
run cmp "$DEST/readme.orig" "$DEST/my-tool/tests/README.md"
|
|
assert_success
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Mode detection: package vs standalone
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@test "standalone mode is chosen when no apm.yml or .git is found above path" {
|
|
run bash "$SCRIPT" my-tool "$DEST"
|
|
assert_success
|
|
assert_output --partial "Mode: standalone"
|
|
assert [ -d "$DEST/my-tool" ]
|
|
}
|
|
|
|
@test "package mode: writes into <package-root>/.apm/skills/<name>, not under the given subdir" {
|
|
mkdir -p "$DEST/pkg/sub/deep"
|
|
cat > "$DEST/pkg/apm.yml" <<'EOF'
|
|
name: my-pkg
|
|
version: 1.0.0
|
|
type: skill
|
|
EOF
|
|
run bash "$SCRIPT" my-tool "$DEST/pkg/sub/deep"
|
|
assert_success
|
|
assert_output --partial "Mode: package"
|
|
assert [ -d "$DEST/pkg/.apm/skills/my-tool" ]
|
|
assert [ ! -d "$DEST/pkg/sub/deep/my-tool" ]
|
|
}
|
|
|
|
@test "package mode scaffold contains SKILL.md with name substituted" {
|
|
mkdir -p "$DEST/pkg"
|
|
cat > "$DEST/pkg/apm.yml" <<'EOF'
|
|
name: my-pkg
|
|
version: 1.0.0
|
|
type: hybrid
|
|
EOF
|
|
bash "$SCRIPT" my-tool "$DEST/pkg"
|
|
assert [ -f "$DEST/pkg/.apm/skills/my-tool/SKILL.md" ]
|
|
run grep "my-tool" "$DEST/pkg/.apm/skills/my-tool/SKILL.md"
|
|
assert_success
|
|
}
|
|
|
|
@test "scaffold emits a live metadata.version seeded at 0.1.0 (ADR-0022)" {
|
|
# The scaffold must clear .pre-commit-config.yaml's `skill-size-check` hook
|
|
# on its first commit: a commented-out metadata block ships a skill with no
|
|
# version and is blocked. Assert the field is live, not a comment.
|
|
bash "$SCRIPT" my-tool "$DEST"
|
|
run grep -E '^metadata:$' "$DEST/my-tool/SKILL.md"
|
|
assert_success
|
|
run grep -E '^ version: "[0-9]+\.[0-9]+\.[0-9]+"$' "$DEST/my-tool/SKILL.md"
|
|
assert_success
|
|
assert_output --partial '0.1.0'
|
|
}
|
|
|
|
@test "walk-up skips a type-less apm.yml (marketplace-only) and finds a real package root further up" {
|
|
mkdir -p "$DEST/mid/sub"
|
|
cat > "$DEST/apm.yml" <<'EOF'
|
|
name: root-pkg
|
|
version: 1.0.0
|
|
type: skill
|
|
EOF
|
|
cat > "$DEST/mid/apm.yml" <<'EOF'
|
|
marketplace:
|
|
owner: acme
|
|
packages: []
|
|
EOF
|
|
run bash "$SCRIPT" my-tool "$DEST/mid/sub"
|
|
assert_success
|
|
assert_output --partial "Mode: package"
|
|
assert [ -d "$DEST/.apm/skills/my-tool" ]
|
|
assert [ ! -d "$DEST/mid/.apm" ]
|
|
}
|
|
|
|
@test "walk-up stops at .git boundary and uses standalone mode at the given path" {
|
|
mkdir -p "$DEST/repo/.git"
|
|
mkdir -p "$DEST/repo/sub"
|
|
run bash "$SCRIPT" my-tool "$DEST/repo/sub"
|
|
assert_success
|
|
assert_output --partial "Mode: standalone"
|
|
assert [ -d "$DEST/repo/sub/my-tool" ]
|
|
assert [ ! -d "$DEST/repo/my-tool" ]
|
|
}
|
|
|
|
@test "package mode: matched-quote type value ('skill') is recognized" {
|
|
mkdir -p "$DEST/pkg"
|
|
printf 'name: my-pkg\ntype: "skill"\n' > "$DEST/pkg/apm.yml"
|
|
run bash "$SCRIPT" my-tool "$DEST/pkg"
|
|
assert_success
|
|
assert_output --partial "Mode: package"
|
|
}
|
|
|
|
@test "mismatched-quote type value is rejected, falls through to standalone mode" {
|
|
printf "name: my-pkg\ntype: \"skill'\n" > "$DEST/apm.yml"
|
|
run bash "$SCRIPT" my-tool "$DEST"
|
|
assert_success
|
|
assert_output --partial "Mode: standalone"
|
|
}
|
|
|
|
@test "type: line is recognized even without a trailing newline on apm.yml" {
|
|
printf 'name: my-pkg\ntype: skill' > "$DEST/apm.yml"
|
|
run bash "$SCRIPT" my-tool "$DEST"
|
|
assert_success
|
|
assert_output --partial "Mode: package"
|
|
}
|