Loading source
Pulling the file list, source metadata, and syntax-aware rendering for this listing.
Source from repo
One-time setup that gathers your project's design context and saves it to CLAUDE.md for future sessions.
Files
Skill
Size
Entrypoint
Format
Open file
Syntax-highlighted preview of this file as included in the skill package.
scripts/context.mjs
1/**2* Context loader: prints PRODUCT.md (and DESIGN.md if present) as one3* markdown block on stdout, or exits with empty stdout when no PRODUCT.md4* is found anywhere. The skill keys off "empty stdout" to branch into the5* init flow.6*7* Path resolution (first match wins):8* 1. cwd, if PRODUCT.md or DESIGN.md is there9* 2. .agents/context/ then docs/10* 3. $IMPECCABLE_CONTEXT_DIR (absolute or cwd-relative) — power-user11* escape hatch, only consulted when defaults are empty12* 4. cwd as a "nothing found" default13*14* `resolveContextDir()` and `loadContext()` are also exported for the15* server-side scripts (live.mjs, live-server.mjs) that need the structured16* shape rather than the markdown block.17*/18import fs from 'node:fs';19import os from 'node:os';20import path from 'node:path';21import { fileURLToPath } from 'node:url';2223const PRODUCT_NAMES = ['PRODUCT.md', 'Product.md', 'product.md'];24const DESIGN_NAMES = ['DESIGN.md', 'Design.md', 'design.md'];25const FALLBACK_DIRS = ['.agents/context', 'docs'];2627// ─── Update check ──────────────────────────────────────────────────────────28// Piggyback a lightweight skill-version check on the once-per-session boot.29// When a newer skill ships, append an UPDATE_AVAILABLE directive so the agent30// can offer `npx impeccable update`. Everything here is best-effort and31// silent on failure: a network problem, sandbox, or missing cache must never32// block context output or print an error.3334const UPDATE_HOST = (process.env.IMPECCABLE_UPDATE_HOST || 'https://impeccable.style').replace(/\/$/, '');35const UPDATE_CACHE_PATH =36process.env.IMPECCABLE_UPDATE_CACHE || path.join(os.homedir(), '.impeccable', 'update-check.json');37const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; // throttle the network poll to once a day38const RENOTIFY_INTERVAL_MS = 7 * 24 * 60 * 60 * 1000; // don't re-surface the same version for a week39const FETCH_TIMEOUT_MS = 1200;4041export function resolveContextDir(cwd = process.cwd()) {42if (firstExisting(cwd, [...PRODUCT_NAMES, ...DESIGN_NAMES])) {43return cwd;44}45for (const rel of FALLBACK_DIRS) {46const candidate = path.resolve(cwd, rel);47if (firstExisting(candidate, [...PRODUCT_NAMES, ...DESIGN_NAMES])) {48return candidate;49}50}51const envDir = process.env.IMPECCABLE_CONTEXT_DIR;52if (envDir && envDir.trim()) {53const trimmed = envDir.trim();54return path.isAbsolute(trimmed) ? trimmed : path.resolve(cwd, trimmed);55}56return cwd;57}5859export function loadContext(cwd = process.cwd()) {60const contextDir = resolveContextDir(cwd);61const productPath = firstExisting(contextDir, PRODUCT_NAMES);62const designPath = firstExisting(contextDir, DESIGN_NAMES);63const product = productPath ? safeRead(productPath) : null;64const design = designPath ? safeRead(designPath) : null;65return {66hasProduct: !!product,67product,68productPath: productPath ? path.relative(cwd, productPath) : null,69hasDesign: !!design,70design,71designPath: designPath ? path.relative(cwd, designPath) : null,72contextDir,73};74}7576function firstExisting(dir, names) {77for (const name of names) {78const abs = path.join(dir, name);79if (fs.existsSync(abs)) return abs;80}81return null;82}8384function safeRead(p) {85try {86return fs.readFileSync(p, 'utf-8');87} catch {88return null;89}90}9192/**93* Pull the register (`brand` or `product`) out of PRODUCT.md by looking94* for a `## Register` section and reading the first non-empty line that95* follows it. Returns null when the file is legacy / register-less.96*/97export function extractRegister(product) {98if (!product) return null;99const lines = product.split('\n');100for (let i = 0; i < lines.length; i++) {101if (/^##\s+Register\b/i.test(lines[i].trim())) {102for (let j = i + 1; j < lines.length; j++) {103const next = lines[j].trim();104if (!next) continue;105const word = next.toLowerCase();106if (word === 'brand' || word === 'product') return word;107return null;108}109}110}111return null;112}113114/**115* Read the installed skill's own version from the sibling SKILL.md frontmatter116* (this file lives at `<skill>/scripts/context.mjs`). Returns null when the117* frontmatter is missing or unreadable.118*/119function readLocalSkillVersion() {120try {121const here = path.dirname(fileURLToPath(import.meta.url));122const skillMd = path.join(here, '..', 'SKILL.md');123const content = fs.readFileSync(skillMd, 'utf-8');124const match = content.match(/^version:\s*(.+)$/m);125return match ? match[1].trim().replace(/^["']|["']$/g, '') : null;126} catch {127return null;128}129}130131function readUpdateCache() {132try {133return JSON.parse(fs.readFileSync(UPDATE_CACHE_PATH, 'utf-8'));134} catch {135return {};136}137}138139function writeUpdateCache(cache) {140try {141fs.mkdirSync(path.dirname(UPDATE_CACHE_PATH), { recursive: true });142fs.writeFileSync(UPDATE_CACHE_PATH, JSON.stringify(cache));143} catch {144// Best-effort: a read-only home dir just means we re-poll next session.145}146}147148/** Compare dotted numeric versions. Returns >0 when a is newer than b. */149function compareSemver(a, b) {150const pa = String(a).split('.').map(n => parseInt(n, 10) || 0);151const pb = String(b).split('.').map(n => parseInt(n, 10) || 0);152for (let i = 0; i < Math.max(pa.length, pb.length); i++) {153const diff = (pa[i] || 0) - (pb[i] || 0);154if (diff !== 0) return diff;155}156return 0;157}158159async function fetchLatestSkillVersion() {160try {161const res = await fetch(`${UPDATE_HOST}/api/version`, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });162if (!res.ok) return null;163const data = await res.json();164return typeof data?.skills === 'string' ? data.skills : null;165} catch {166return null; // offline, sandboxed, timed out, or bad JSON: all non-fatal167}168}169170function buildUpdateDirective(localVersion, latestVersion) {171return (172`UPDATE_AVAILABLE: A newer Impeccable skill is available ` +173`(installed v${localVersion}, latest v${latestVersion}). ` +174`Before continuing, ask the user once: "A newer Impeccable (v${latestVersion}) is available. ` +175`Update now? It runs \`npx impeccable update\`." ` +176`If they agree, run \`npx impeccable update\` (the update applies to the next session, not this one). ` +177`Either way, continue the current task without waiting, and do not raise this again.`178);179}180181/**182* Best-effort update directive for the boot output. Returns a string to append183* or null. Polls the version endpoint at most once per day (cached globally in184* the user's home dir) and re-surfaces a given version at most once per week so185* the agent never nags. Opt out entirely with IMPECCABLE_NO_UPDATE_CHECK=1.186*/187// Read the unified config's top-level `updateCheck` (local overrides shared).188// Inlined rather than importing hook-lib so the boot path stays lightweight.189function updateCheckDisabledByConfig(cwd = process.cwd()) {190let value;191for (const name of ['config.json', 'config.local.json']) {192try {193const raw = JSON.parse(fs.readFileSync(path.join(cwd, '.impeccable', name), 'utf-8'));194if (raw && typeof raw === 'object' && typeof raw.updateCheck === 'boolean') value = raw.updateCheck;195} catch { /* missing or malformed: ignore */ }196}197return value === false;198}199200async function computeUpdateDirective(now = Date.now()) {201try {202if (process.env.IMPECCABLE_NO_UPDATE_CHECK) return null;203if (updateCheckDisabledByConfig()) return null;204const localVersion = readLocalSkillVersion();205if (!localVersion) return null;206207const cache = readUpdateCache();208209// Poll the network only when the throttle window has elapsed. Stamp210// lastCheck even on failure so an offline machine doesn't poll every boot.211if (!cache.lastCheck || now - cache.lastCheck > CHECK_INTERVAL_MS) {212const latest = await fetchLatestSkillVersion();213cache.lastCheck = now;214if (latest) cache.latestVersion = latest;215writeUpdateCache(cache);216}217218const latest = cache.latestVersion;219if (!latest || compareSemver(latest, localVersion) <= 0) return null;220221// Anti-nag: surface a given version at most once per RENOTIFY window.222if (cache.notifiedVersion === latest && cache.notifiedAt && now - cache.notifiedAt < RENOTIFY_INTERVAL_MS) {223return null;224}225cache.notifiedVersion = latest;226cache.notifiedAt = now;227writeUpdateCache(cache);228229return buildUpdateDirective(localVersion, latest);230} catch {231return null;232}233}234235async function cli() {236const ctx = loadContext(process.cwd());237const updateDirective = await computeUpdateDirective();238239if (!ctx.hasProduct) {240// Direct stdout message instead of relying on empty output as a signal241// — cheap models miss the empty case more often than the explicit one.242const parts = [243'NO_PRODUCT_MD: This project has no PRODUCT.md yet. ' +244'Stop the current task, load reference/init.md, and follow its ' +245'instructions to write PRODUCT.md before resuming.',246];247if (updateDirective) parts.push(updateDirective);248process.stdout.write(parts.join('\n\n---\n\n') + '\n');249process.exit(0);250}251const parts = [`# PRODUCT.md\n\n${ctx.product.trim()}`];252if (ctx.hasDesign) {253parts.push(`# DESIGN.md\n\n${ctx.design.trim()}`);254}255const register = extractRegister(ctx.product);256const next = register257? `NEXT STEP: This project's register is \`${register}\`. You MUST now read \`reference/${register}.md\` before producing any design output.`258: `NEXT STEP: You MUST now read the matching register reference (\`reference/brand.md\` or \`reference/product.md\`) before producing any design output. Pick based on PRODUCT.md above.`;259parts.push(next);260if (updateDirective) parts.push(updateDirective);261process.stdout.write(parts.join('\n\n---\n\n') + '\n');262}263264// Run cli() only when this module is the entry point. Compare realpaths265// rather than endsWith(): a loose suffix match also fires for unrelated266// scripts like `load-context.mjs`, and realpath tolerates symlinked267// invocation (the test harness symlinks the skill dir).268function invokedAsScript() {269const arg = process.argv[1];270if (!arg) return false;271try {272return fs.realpathSync(arg) === fs.realpathSync(fileURLToPath(import.meta.url));273} catch {274return false;275}276}277278if (invokedAsScript()) {279cli();280}281