Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
1 change: 1 addition & 0 deletions .claude/skills/audit-macos-scripts-menus
1 change: 1 addition & 0 deletions .claude/skills/docs-maintainer
1 change: 1 addition & 0 deletions .claude/skills/mqlaunch-command-surface
1 change: 1 addition & 0 deletions .claude/skills/mqlaunch-menu-template
1 change: 1 addition & 0 deletions .claude/skills/release-readiness
1 change: 1 addition & 0 deletions .claude/skills/repo-health-brief
1 change: 1 addition & 0 deletions .claude/skills/terminal-ui-polisher
1 change: 1 addition & 0 deletions .claude/skills/vector-store-maintainer
60 changes: 50 additions & 10 deletions tests/skills-repos-smoke.sh
Original file line number Diff line number Diff line change
Expand Up @@ -12,32 +12,32 @@ DOC="$ROOT/docs/COMMANDS.md"

echo "SMOKE: skills and repos command surface"

echo "[1/10] scripts exist and are executable"
echo "[1/12] scripts exist and are executable"
test -x "$SKILLS"
test -x "$REPOS"

echo "[2/10] script syntax checks"
echo "[2/12] script syntax checks"
PYTHONPYCACHEPREFIX="${TMPDIR:-/tmp}/mqlaunch-pycache" python3 -m py_compile "$SKILLS" "$REPOS"

echo "[3/10] command-mode syntax check"
echo "[3/12] command-mode syntax check"
bash -n "$CMD"

echo "[4/10] launcher syntax check"
echo "[4/12] launcher syntax check"
zsh -n "$LAUNCHER"

echo "[5/10] tools menu syntax check"
echo "[5/12] tools menu syntax check"
bash -n "$TOOLS_MENU"

echo "[6/10] command-mode routes skills and repos"
echo "[6/12] command-mode routes skills and repos"
grep -q "mq-skills.py" "$CMD"
grep -q "mq-repos.py" "$CMD"

# Same as brain-bridge: the skills/repos case arms live in command mode (step 6),
# and mqlaunch.sh reaches them by sourcing that module.
echo "[7/10] main launcher reaches that routing (sources command mode)"
echo "[7/12] main launcher reaches that routing (sources command mode)"
grep -q 'source "\$BASE_DIR/terminal/launchers/mqlaunch-command-mode.sh"' "$LAUNCHER"

echo "[8/10] tools menu exposes ecosystem actions"
echo "[8/12] tools menu exposes ecosystem actions"
# Asserted as reachable actions rather than as label text. The labels were
# "Skills audit" and "Repos diff" while those sat flat in the Tools menu; they
# are "Audit" and "Diff summary" inside the Skills and Repos submenus now, and
Expand All @@ -50,13 +50,13 @@ for handler in run_mq_skills_audit run_mq_repos_diff_summary; do
}
done

echo "[9/10] docs mention commands"
echo "[9/12] docs mention commands"
grep -q "mqlaunch skills audit" "$DOC"
grep -q "mqlaunch skills validate --ecosystem" "$DOC"
grep -q "mqlaunch repos status" "$DOC"
grep -q "mqlaunch repos diff-summary" "$DOC"

echo "[10/10] scripts run read-only summaries"
echo "[10/12] scripts run read-only summaries"
"$SKILLS" validate >/tmp/mq-skills-validate.out
"$SKILLS" validate --ecosystem >/tmp/mq-skills-validate-ecosystem.out
"$REPOS" list >/tmp/mq-repos-list.out
Expand All @@ -79,4 +79,44 @@ else
echo " skip: sibling MQ repos not checked out; listing assertions need them"
fi

echo "[11/12] audit reports whether each skill is discoverable by Claude Code"
# The blind spot this closes: mq-skills.py called all 63 skills in the stack
# "ok, indexed" while not one of them was loadable. It validated the MQ
# convention (skills/ plus a local index) and knew nothing about Claude Code's
# search path, which is .claude/skills/. An index nobody reads is not discovery.
# --repo takes a path, and it has to be this checkout: a bare name resolves
# under $HOME, which on a CI runner is not where the repo lives. Step 10 already
# skips its listing assertions for exactly that reason.
out="$("$SKILLS" audit --repo "$ROOT")"
grep -q "discoverable" <<<"$out"

echo "[12/12] an unlinked skill is reported, and a linked one is not"
# A scratch repo, so the assertion is about the checker rather than about
# whichever skills happen to be wired up in the real tree today.
work="$(mktemp -d)"
trap 'rm -rf "$work"' EXIT
mkdir -p "$work/repo/skills/ghost-skill" "$work/repo/.claude/skills"
cat >"$work/repo/skills/ghost-skill/SKILL.md" <<'SKILL'
---
name: ghost-skill
description: Exists on disk, indexed, and reachable by nobody.
---
SKILL
printf 'skills/ghost-skill/SKILL.md\n' >"$work/repo/SKILLS.md"

out="$("$SKILLS" audit --repo "$work/repo" 2>&1)"
grep -q "not-discoverable" <<<"$out" || {
echo "FAIL: an unlinked skill was not reported as undiscoverable" >&2
printf '%s\n' "$out" >&2
exit 1
}

ln -s ../../skills/ghost-skill "$work/repo/.claude/skills/ghost-skill"
out="$("$SKILLS" audit --repo "$work/repo" 2>&1)"
grep -q "not-discoverable" <<<"$out" && {
echo "FAIL: a linked skill is still reported as undiscoverable" >&2
printf '%s\n' "$out" >&2
exit 1
}

echo "OK: skills and repos command surface smoke test passed"
42 changes: 38 additions & 4 deletions tools/scripts/mq-skills.py
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,26 @@ def index_state(repo: Path, skill: Skill) -> str:
return "not-indexed"


def discovery_state(repo: Path, skill: Skill) -> str:
"""Can an agent actually load this skill?

The MQ convention keeps skills in <repo>/skills/ and indexes them in
SKILLS.md. Claude Code discovers skills somewhere else entirely: under
<repo>/.claude/skills/. Those two facts were never checked against each
other, so the whole stack — 63 skills across eight repos — audited as "ok,
indexed" while not one of them could be loaded. Being indexed says a human
can find the file; this says a tool can run it.
"""
entry = repo / ".claude" / "skills" / skill.path.parent.name
if entry.is_symlink() and not entry.exists():
return "broken-link"
if not entry.exists():
return "not-discoverable"
if not (entry / "SKILL.md").is_file():
return "broken-link"
return "discoverable"


def roadmap_text(repo: Path) -> str:
parts = []
for path in (repo / "ROADMAP.md", repo / "docs" / "ROADMAP.md"):
Expand All @@ -153,12 +173,17 @@ def audit(args: argparse.Namespace) -> int:
for repo in repo_paths(args.repo):
skills = find_skills(repo)
states = [index_state(repo, skill) for skill in skills]
discovery = [discovery_state(repo, skill) for skill in skills]
indexed_count = sum(1 for state in states if state == "indexed")
print(f"{repo.name}: {len(skills)} skill(s), {indexed_count} indexed")
for skill, idx in zip(skills, states):
loadable = sum(1 for state in discovery if state == "discoverable")
print(
f"{repo.name}: {len(skills)} skill(s), {indexed_count} indexed, "
f"{loadable} discoverable"
)
for skill, idx, disc in zip(skills, states, discovery):
status = "ok" if skill.name and skill.description else "frontmatter-missing"
print(f" - {skill.path.parent.name}: {status}, {idx}")
if status != "ok" or idx != "indexed":
print(f" - {skill.path.parent.name}: {status}, {idx}, {disc}")
if status != "ok" or idx != "indexed" or disc != "discoverable":
any_warn = True

text = roadmap_text(repo)
Expand Down Expand Up @@ -201,6 +226,11 @@ def validate(args: argparse.Namespace) -> int:
warnings.append(f"{repo.name}/{folder}: not referenced by local skill index")
elif idx == "no-index-file":
warnings.append(f"{repo.name}/{folder}: repo has no local skill index")
disc = discovery_state(repo, skill)
if disc == "not-discoverable":
warnings.append(f"{repo.name}/{folder}: not linked into .claude/skills")
elif disc == "broken-link":
warnings.append(f"{repo.name}/{folder}: .claude/skills entry does not resolve")

if args.ecosystem:
by_name: dict[str, list[str]] = {}
Expand Down Expand Up @@ -243,6 +273,10 @@ def fix_suggestion(message: str) -> str:
return "add the skill to SKILLS.md or skills/platform-skills.md"
if "repo has no local skill index" in message:
return "create SKILLS.md and list the repo skills"
if "not linked into .claude/skills" in message:
return "ln -s ../../skills/<name> .claude/skills/<name> — indexed is not the same as loadable"
if ".claude/skills entry does not resolve" in message:
return "repoint or remove the .claude/skills entry; it has no SKILL.md behind it"
if "repo not found" in message:
return "clone the repo locally or pass --repo with the correct path"
if "duplicate skill name" in message:
Expand Down
Loading