/// /** * The Claude skill (UIG-20): search the catalogue before building anything. * * Its text lives once, in this package, as `skill/estiva-ui.md` (Katerina, * 17 September 2026: the same gate text pasted into two repositories is a * defect). Claude Code only loads a skill from a `SKILL.md` under a folder's * `.claude/skills/`, and no plugin source can point into `node_modules`, so each * repository commits a loader: the trigger below, and one line that reads the * installed package's text into the skill when it opens (`` !`…` `` runs before * Claude sees the skill). `estiva-ui skill` writes it; `estiva-ui check` fails * when a repository's loader is missing or is not what this writes. * * Measured with fresh sessions of Claude Code 2.1.215 (UIG-20), three things * shape that line and the settings beside it: * * - An injected command runs without a prompt only when Claude Code can read it * statically as read-only: a plain `cat` of a plain path. A path with `..` * ("may follow a symlink outside the working directory"), a `$(…)`, or a * `node -e` are all refused, and a refused command stops the skill opening. * So the path starts at `${CLAUDE_PROJECT_DIR}`, the folder the session * started in, as the editor gate's hook does: a session started above or * below the repository gets neither. * - An `allowed-tools` line makes opening the skill itself ask for permission * ("Execute skill"). So the search is allowed in the repository's committed * `.claude/settings.json` instead, and the skill carries no `allowed-tools`. * - Claude reads a skill's description before it opens the skill, so the * trigger is written into the loader; it is the one part that cannot be * pulled in later. */ import { existsSync, readFileSync } from 'node:fs' import { dirname, join, relative, resolve, sep } from 'node:path' export const SKILL_NAME = 'estiva-ui' /** When Claude opens the skill by itself. Kept under Claude Code's 1,536 characters. */ export const SKILL_DESCRIPTION = 'Use before building, adding or changing anything on screen in an app that uses @estiva-app/ui, or in @estiva-app/ui itself: ' + 'a screen, panel, list, row, header, dialog, menu, empty state, a new component, or how something looks. ' + 'Also when someone asks for a component in plain words ("I need a…", "add a…", "is there a…"). ' + "It searches every catalogue at once, the package's and each app's Storybook, for a part that already does the job, " + 'and lists the mistakes this codebase keeps making.' /** The search, allowed without a prompt in the folder's committed `.claude/settings.json`. */ export const SEARCH_RULES = ['Bash(npm run ui:find *)', 'Bash(npm --prefix * run ui:find *)', 'PowerShell(npm run ui:find *)', 'PowerShell(npm --prefix * run ui:find *)'] /** The skill's text, inside a package folder. */ export const skillTextPath = (packageRoot: string) => join(packageRoot, 'skill', `${SKILL_NAME}.md`) /** Where a folder keeps its loader. */ export const loaderPath = (folder: string) => join(folder, '.claude', 'skills', SKILL_NAME, 'SKILL.md') /** Where a folder keeps the settings that allow the search. */ export const settingsPath = (folder: string) => join(folder, '.claude', 'settings.json') /** The loader for `folder`, reading the text from `packageRoot`, from where a session in `folder` starts. */ export function loaderText(folder: string, packageRoot: string): string { const fromTop = relative(folder, skillTextPath(packageRoot)).split(sep).join('/') return [ '---', `name: ${SKILL_NAME}`, `description: ${JSON.stringify(SKILL_DESCRIPTION)}`, '---', '', '', '', `!\`cat "\${CLAUDE_PROJECT_DIR}/${fromTop}"\``, '', ].join('\n') } type Settings = { permissions?: { allow?: string[] } & Record } & Record const readSettings = (folder: string): Settings | null => { const path = settingsPath(folder) if (!existsSync(path)) return {} try { return JSON.parse(readFileSync(path, 'utf8')) as Settings } catch { return null } } /** `folder`'s settings with the search allowed, everything else as it was; null when they cannot be read. */ export function settingsWithSearch(folder: string): string | null { const settings = readSettings(folder) if (!settings) return null const allow = settings.permissions?.allow ?? [] const missing = SEARCH_RULES.filter((rule) => !allow.includes(rule)) if (!missing.length && existsSync(settingsPath(folder))) return null settings.permissions = { ...settings.permissions, allow: [...allow, ...missing] } return `${JSON.stringify(settings, null, 2)}\n` } /** The repository `from` is in: the nearest folder with `.git`, or `from` when there is none. */ export function repositoryOf(from: string): string { let at = resolve(from) for (;;) { if (existsSync(join(at, '.git'))) return at const up = dirname(at) if (up === at) return resolve(from) at = up } } /** What is wrong with `folder`'s loader, or null when it is what `loaderText` writes. */ export function loaderProblem(folder: string, packageRoot: string): string | null { const path = loaderPath(folder) const shown = relative(process.cwd(), path).split(sep).join('/') || path if (!existsSync(path)) return `${shown} is missing: run "estiva-ui skill" and commit it` if (readFileSync(path, 'utf8').replace(/\r\n/g, '\n') !== loaderText(folder, packageRoot)) return `${shown} is not what the package writes: run "estiva-ui skill" and commit it` if (!existsSync(skillTextPath(packageRoot))) return `${shown} reads ${skillTextPath(packageRoot)}, which is not there` const allow = readSettings(folder)?.permissions?.allow ?? [] const missing = SEARCH_RULES.filter((rule) => !allow.includes(rule)) if (missing.length) return `.claude/settings.json does not allow the search (${missing.join(', ')}): run "estiva-ui skill" and commit it` return null }