setup — Machine-Level Install Check

SkillAI & models

Use when the user says \"set up workflows\", \"workflows setup\", \"is workflows installed correctly\", \"verify my install\", \"check the workflows install\", \"why isn't my agent's guidance loading\", \"my agent dispatch got denied\", \"/setup\", or asks whether this plugin's agents, their preloaded skills and the main-thread guard's allowlist actually resolve on this machine. Use proactively right after installing or updating the plugin, a preload that fails to resolve is logged to the debug log only, and the run reads exactly as if it had loaded. Machine-level and idempotent; offers one optional user-tier plansDirectory write, always behind a question. NEGATIVE ROUTING: setting up a course's teaching plugin is teaching:setup and checking the Codex CLI is codex:setup; actually upgrading to a newer plugin version is plugin-update, this skill only checks what is installed, it never installs.

Available today. Use it from your connected AI after setup.

Connect ahel once, and every AI you use reads what you have installed.

Then ask your AI: use the setup — Machine-Level Install Check skill

What this skill tells your AI

The instructions your AI receives, as published by edwinhu/workflows in skills/setup/SKILL.md and read by ahel’s review.

What this skill carries — grep references/ for any subject the names below miss: !d=${CLAUDE_SKILL_DIR}; command -v skill-toc >/dev/null 2>&1 && exec skill-toc "$d"; s=$HOME/.claude/skills/plugin-utils/bin/skill-toc; [ -x "$s" ] && exec "$s" "$d"; echo "(skill-toc unavailable: references and scripts are NOT listed here — install the plugin-utils plugin, or start a new session so its bin/ reaches PATH)"

This is a machine setup, run once per machine, not per project. It verifies the installed plugin's agents and their preloaded skills resolve, offers one optional user-tier setting, and reports one dotfiles line the user may want to add themselves.

Idempotent — safe to re-run. It reads before it writes and it asks before every write.

NO WRITE WITHOUT AN EXPLICIT ANSWER FROM THE USER FIRST. Every write in this skill is optional. Asking costs one question; a silent write to a settings file the user shares across every project costs them keys they cannot restore.

NO SETTINGS WRITE WITHOUT PARSING THE FILE FIRST — A FILE THAT FAILS TO PARSE IS A REFUSAL, NEVER AN OVERWRITE. A malformed settings file is far more likely to be mid-edit in another window than to be garbage. Overwriting it destroys work and looks like success.

NO HARDCODED AGENT ROSTER. ENUMERATE ~/.claude/agents/*.md AT RUNTIME. A literal list stops covering agents added later, which is the exact silent drift this skill exists to catch. If you are about to type an agent name into a check, you have reintroduced the bug.

THIS SKILL CONFIGURES NO PROJECT. It does not touch .claude-workflows.json, does not set a per-project persona, and does not write anything under a project directory. Being helpful about the project in front of you is how a machine-level check became per-project nagging.


Step (a) — Verify the Install

Why this step is the reason the skill exists. An agent's skills: frontmatter preloads guidance into that agent. A preload that does not resolve to a real skill — or that names a skill with disable-model-invocation: true — is skipped with a warning to the debug log only. The agent still launches, the guidance never arrives, and the run reads exactly as if it had. Nothing surfaces this but a check.

THE DIRECTORY STATES THE SCOPE. Agents ship in two directories under ~/.claude/skills/workflows/, and each has exactly one discovery path:

  • agents/auto-discovered by Claude Code, registers plugin-scoped. It answers only to workflows:<name>, and its hooks:, mcpServers: and permissionMode: frontmatter is ignored. It is deliberately NOT symlinked anywhere.
  • user-agents/not auto-discovered. It reaches Claude Code only through a symlink into ~/.claude/agents/, which registers it under its bare name with those fields honoured. This plugin's skills dispatch those bare names, so an unlinked file here registers nowhere: the dispatch falls back to a default agent and its guard never fires.

Check both halves. Enumerate the shipped agents; never name them:

ls -1 ~/.claude/skills/workflows/agents/*.md 2>/dev/null || echo "NO plugin-scoped agents shipped"
ls -1 ~/.claude/skills/workflows/user-agents/*.md 2>/dev/null || echo "NO user-scoped agents shipped"
ls -la ~/.claude/agents/ 2>/dev/null || echo "NO ~/.claude/agents directory"

Then, for every enumerated agent, check its skills: entries against the installed plugin's skills/ and whether it resolves at user scope:

P=~/.claude/skills/workflows bun -e '
import { readdirSync, readFileSync, existsSync, realpathSync } from "node:fs";
import { join } from "node:path";
import { homedir } from "node:os";
const root = process.env.P.replace(/^~/, process.env.HOME);
const userDir = join(homedir(), ".claude", "agents");
// NO NAMED EXCEPTIONS: the directory an agent sits in states its scope.
const dirs = [["agents", "plugin"], ["user-agents", "user"]].filter(([d]) => existsSync(join(root, d)));
if (!dirs.length) { console.log(`NO AGENTS: neither agents/ nor user-agents/ exists under ${root}`); process.exit(1); }
const real = p => { try { return realpathSync(p); } catch { return null; } };
let bad = 0, agents = [];
for (const [sub, tier] of dirs) {
  const agentsDir = join(root, sub);
  // ENUMERATED, never listed.
  for (const a of readdirSync(agentsDir).filter(f => f.endsWith(".md")).sort()) {
  agents.push(a);
  const name = a.replace(/\.md$/, "");
  if (tier === "user") {
    const want = real(join(agentsDir, a));
    const got = real(join(userDir, a));
    if (got === null) { console.log(`  UNLINKED  ${name} (no resolving ${userDir}/${a}) — registers nowhere, hooks never fire`); bad++; }
    else if (got !== want) { console.log(`  MISLINKED ${name} -> ${got}, expected ${want}`); bad++; }
    else console.log(`  SCOPED    ${name} (user-level via symlink)`);
  } else {
    console.log(`  PLUGIN    ${name} (plugin-scoped on purpose; dispatch as workflows:${name})`);
  }
  const body = readFileSync(join(agentsDir, a), "utf8");
  const fm = body.startsWith("---") ? body.slice(3, body.indexOf("\n---", 3)) : "";
  const m = fm.match(/^skills:[ \t]*(.*)$((?:\n[ \t]+-[ \t]*.*)*)/m);
  if (!m) { console.log(`  ${a}: no skills: preloads`); continue; }
  const inline = m[1].trim().replace(/^\[|\]$/g, "").split(",");
  const block = m[2].split("\n").map(l => l.replace(/^[ \t]*-[ \t]*/, ""));
  const skills = [...inline, ...block].map(s => s.trim().replace(/^["\x27]|["\x27]$/g, "")).filter(Boolean);
  for (const s of skills) {
    const sk = join(root, "skills", s, "SKILL.md");
    if (!existsSync(sk)) { console.log(`  DANGLING  ${a} -> ${s} (no skills/${s}/SKILL.md)`); bad++; continue; }
    const head = readFileSync(sk, "utf8").slice(0, 2000);
    if (/^disable-model-invocation:[ \t]*true[ \t]*$/m.test(head)) {
      console.log(`  DISABLED  ${a} -> ${s} (skill sets disable-model-invocation: true)`); bad++; continue;
    }
    console.log(`  OK        ${a} -> ${s}`);
  }
  }
}
console.log(bad ? `\n${bad} problem(s) — an unresolved preload or an unlinked agent both fail silently.`
                : `\nall preloads resolve and every agent is at its intended scope (${agents.length} agent(s)).`);
'

If an agent is UNLINKED, the fix is a symlink, never a copy — a copy goes stale on the next plugin update and nothing reports the drift. ~/dotfiles/scripts/setup-claude-symlinks.sh links every user-agents/*.md a plugin ships (and nothing from agents/); run it and re-check.

Report every unresolved preload by name, and do not claim the install is healthy while one exists. If the plugin source checkout is the current project, the authoritative check is bun tests/agent-contract.test.mjs — it asserts the whole wiring, not just the preloads.

An unresolved preload is fixed by reinstalling or updating the plugin, not by editing the installed copy under ~/.claude/skills/workflows/ — that copy is overwritten on next install.


Step (b) — Offer plansDirectory at the USER Tier (optional)

This is a preference, not a fix. The resolver honours plansDirectory at either tier and falls back to .claude/plans when it is unset, so unset is a working default and nothing is broken without it. Setting it at the user tier covers every project at once, which is usually what you want (skills/work/SKILL.md).

Read both tiers first:

rg -n '"plansDirectory"' ~/.claude/settings.json 2>/dev/null \
  || echo "plansDirectory: UNSET at the user tier (default .claude/plans applies)"

If it is already set, say so and do nothing. Only change it if the user asks, and show the current value before you do.

If unset, ask via AskUserQuestion whether to set it at the user tier, and to what:

  • ./.claude/plans — matches the resolver's own default
  • ./.planning — what the domain workflows describe
  • Leave unset — the fallback already works

Only on an explicit choice, merge exactly that one key, and merge it the safe way: parse or refuse — a settings file that fails to parse is far more likely mid-edit than garbage, so leave it byte-identical and stop rather than overwrite it — and write atomically, to a temp file in the same directory then renameSync over the target, so every sibling key survives and no interrupted write can truncate the user's settings.

PLANS=./.claude/plans bun -e '   # PLANS = the value the user chose
import { existsSync, readFileSync, writeFileSync, mkdirSync, renameSync, rmSync } from "node:fs";
import { dirname, join } from "node:path";
const p = join(process.env.HOME, ".claude", "settings.json");
let existing = {};
if (existsSync(p)) {
  const raw = readFileSync(p, "utf8");
  if (raw.trim() !== "") {
    let parsed;
    try { parsed = JSON.parse(raw); }
    catch (e) { console.error(`REFUSED: ${p} is not valid JSON (${e.message}) — not overwriting`); process.exit(1); }
    if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
      console.error(`REFUSED: ${p} is not a JSON object — not overwriting`); process.exit(1);
    }
    existing = parsed;
  }
}
const plans = process.env.PLANS;
if (existing.plansDirectory === plans) { console.log(`already ${plans} — nothing to do`); process.exit(0); }
const merged = { ...existing, plansDirectory: plans };
mkdirSync(dirname(p), { recursive: true });
const tmp = `${p}.${process.pid}.tmp`;
try { writeFileSync(tmp, JSON.stringify(merged, null, 2) + "\n", "utf8"); renameSync(tmp, p); }
catch (e) { try { rmSync(tmp, { force: true }); } catch {} ; console.error(`could not write ${p}: ${e.message}`); process.exit(1); }
console.log(`set plansDirectory = "${plans}" in ${p}`);
'

It takes effect next session. Plan mode fixes the plan's path when the session enters plan mode, so a session already running keeps writing where it started. Do not copy a plan to make a path look right — start a new session.


Step (c) — Check the Main-Thread Guard's Allowlist (REPORT ONLY)

Why. ~/.claude/hooks/main-thread-guard.sh denies loose Agent dispatches and reroutes them to farm.sh, which never loads an agent body. A denied dispatch loses the agent's own framing silently.

A user-tier agent dispatches by its BARE name, so the workflows:* glob does not cover it. Enumerate the user-tier agents and check each bare name against the allowlist case — never type one in:

G=~/.claude/hooks/main-thread-guard.sh
test -f "$G" || echo "no main-thread guard at $G — nothing to check"
CASE=$(grep -A 4 'subagent_type' "$G" 2>/dev/null | grep 'allow ;;' | head -1)
echo "current: $CASE"
for f in ~/.claude/agents/*.md; do
  n=$(basename "$f" .md)
  case "$CASE" in *"|$n|"*|*"($n|"*) echo "  OK       $n" ;; *) echo "  MISSING  $n" ;; esac
done
grep -q 'workflows:\*' <<<"$CASE" \
  && echo "workflows:* present (covers the plugin-scoped agents)" \
  || echo "workflows:* MISSING (plugin-scoped agents denied)"

DO NOT EDIT THIS FILE. It is the user's dotfiles and other sessions routinely have concurrent edits in that tree. If the entry is missing, show the one-line change and let the user make it:

      Explore|Plan|librarian|workflows:*|codex:rescue|statusline-setup|plugin-dev:*) allow ;;

Quote the file's actual current line alongside it — do not paste a line from this skill as if it were what is on disk.


Step (d) — Report

Read back what you checked; report from disk, not from intent. Silent success is fine — if everything resolves and nothing was changed, say so in a few lines and stop.

workflows install — <machine>

agents            <N> enumerated at ~/.claude/agents/
preloaded skills  all resolve            (or: name each dangling/disabled one)
plansDirectory    "<value>" at the user tier   (or: unset — default .claude/plans applies)
main-thread guard workflows:* present     (or: missing — one-line change shown above)

Name every step that was skipped as explicitly as the ones that ran. Say plainly that user settings are read at session start, so any write here takes effect in a new session.


Red Flags

About toWhy wrongDo instead
Type an agent name into a checkA literal roster stops covering agents added later — the drift this skill exists to catchreaddirSync() over both agent directories
Write a settings file you have not parsedAn overwrite destroys keys you did not put there and cannot restoreParse first; refuse on malformed JSON
Edit ~/.claude/hooks/main-thread-guard.shIt is the user's dotfiles, with concurrent edits from other sessionsShow the one-line change; let the user apply it
Configure .claude-workflows.json, a persona, or anything project-localThis is a machine setup; the opt-in's absence is the normal stateLeave the project alone
Report the install healthy with a dangling preload presentThat preload fails to a debug-log line only — nothing else will surface itName it and stop
Say a setting is live in this sessionUser settings are read once at session startTell the user to restart
Invent work when everything resolvesA check that always finds something stops being readReport clean and stop

Signals

GitHub stars
21
Forks
4
Last commit
Sep 2026
Advanced
Catalog kind
skill
Gateway key
setup-edwinhu
Source
github.com/edwinhu/workflows