#!/usr/bin/env node /** * `/impeccable hooks ` — manage the design hook * via .impeccable/hook.json and .impeccable/hook.local.json in the current * project. * * Usage: * node hook-admin.mjs status # print current state * node hook-admin.mjs on # set enabled: true * node hook-admin.mjs off # set enabled: false * node hook-admin.mjs ignore-rule # append to ignoreRules * node hook-admin.mjs ignore-rule overused-font --all-values * node hook-admin.mjs ignore-file # append to ignoreFiles * node hook-admin.mjs ignore-value # append to shared ignoreValues * node hook-admin.mjs ignore-value --local * node hook-admin.mjs reset # remove all config + cache * * Designed to be invoked by the LLM from the reference/hooks.md flow. * Output is human-readable; the harness will pass it back to the user. */ import fs from 'node:fs'; import path from 'node:path'; import { getConfigPath, getLocalConfigPath, getCachePath, getPendingPath, readConfig, DEFAULT_CONFIG, ensureHookGitExcludes, normalizeIgnoreValue, normalizeIgnoreValueEntries, } from './hook-lib.mjs'; const ACTIONS = new Set(['status', 'on', 'off', 'ignore-rule', 'ignore-file', 'ignore-value', 'reset']); function readRawConfigFile(filePath) { if (!fs.existsSync(filePath)) return { exists: false, malformed: false, raw: null }; try { return { exists: true, malformed: false, raw: JSON.parse(fs.readFileSync(filePath, 'utf-8')) }; } catch { return { exists: true, malformed: true, raw: null }; } } function readRawConfig(cwd, opts = {}) { const filePath = opts.local ? getLocalConfigPath(cwd) : getConfigPath(cwd); return readRawConfigFile(filePath).raw; } function writeConfig(cwd, config, opts = {}) { const filePath = opts.local ? getLocalConfigPath(cwd) : getConfigPath(cwd); if (opts.local) ensureHookGitExcludes(cwd); fs.mkdirSync(path.dirname(filePath), { recursive: true }); fs.writeFileSync(filePath, JSON.stringify(config, null, 2) + '\n'); return filePath; } function mergeConfig(existing) { // Persist the full shape so /impeccable hooks edits leave a complete file // for the user to see, not an unhelpful `{"enabled":false}`. const base = existing && typeof existing === 'object' ? existing : {}; return { enabled: base.enabled === false ? false : true, ignoreRules: Array.isArray(base.ignoreRules) ? Array.from(new Set(base.ignoreRules.map(String))) : [], ignoreFiles: Array.isArray(base.ignoreFiles) ? Array.from(new Set(base.ignoreFiles.map(String))) : [], ignoreValues: normalizeIgnoreValueEntries(base.ignoreValues || []), limits: { maxFindings: Number.isFinite(base?.limits?.maxFindings) ? base.limits.maxFindings : DEFAULT_CONFIG.limits.maxFindings, maxChars: Number.isFinite(base?.limits?.maxChars) ? base.limits.maxChars : DEFAULT_CONFIG.limits.maxChars, }, }; } function mergeLocalConfig(existing) { const base = existing && typeof existing === 'object' ? existing : {}; const out = {}; if (Object.prototype.hasOwnProperty.call(base, 'enabled')) { out.enabled = base.enabled === false ? false : true; } if (Array.isArray(base.ignoreRules)) { out.ignoreRules = Array.from(new Set(base.ignoreRules.map(String))); } if (Array.isArray(base.ignoreFiles)) { out.ignoreFiles = Array.from(new Set(base.ignoreFiles.map(String))); } out.ignoreValues = normalizeIgnoreValueEntries(base.ignoreValues || []); if (base.limits && typeof base.limits === 'object') { const limits = {}; if (Number.isFinite(base.limits.maxFindings)) limits.maxFindings = base.limits.maxFindings; if (Number.isFinite(base.limits.maxChars)) limits.maxChars = base.limits.maxChars; if (Object.keys(limits).length) out.limits = limits; } return out; } function statusReport(cwd) { const shared = readRawConfigFile(getConfigPath(cwd)); const local = readRawConfigFile(getLocalConfigPath(cwd)); const cfg = readConfig(cwd); const envKill = process.env.IMPECCABLE_HOOK_DISABLED; const envState = envKill ? `IMPECCABLE_HOOK_DISABLED=${envKill}` : 'unset'; const cfgPath = path.relative(cwd, getConfigPath(cwd)) || '.impeccable/hook.json'; const localPath = path.relative(cwd, getLocalConfigPath(cwd)) || '.impeccable/hook.local.json'; const cachePath = path.relative(cwd, getCachePath(cwd)) || '.impeccable/hook.cache.json'; const fileState = (info, relPath, absent) => { if (info.malformed) return `${relPath} (malformed; ignored)`; if (info.exists) return relPath; return `${relPath} (${absent})`; }; const ignoreValues = cfg.ignoreValues.map((entry) => `${entry.rule}=${entry.value}`); const lines = [ `Impeccable design hook`, ` state: ${cfg.enabled ? 'enabled' : 'disabled'}`, ` shared file: ${fileState(shared, cfgPath, 'using defaults; file not present')}`, ` local file: ${fileState(local, localPath, 'not present')}`, ` ignoreRules: ${cfg.ignoreRules.length ? cfg.ignoreRules.join(', ') : '(none)'}`, ` ignoreFiles: ${cfg.ignoreFiles.length ? cfg.ignoreFiles.join(', ') : '(none)'}`, ` ignoreValues: ${ignoreValues.length ? ignoreValues.join(', ') : '(none)'}`, ` maxFindings: ${cfg.limits.maxFindings}`, ` maxChars: ${cfg.limits.maxChars}`, ` env override: ${envState}`, ` cache file: ${fs.existsSync(getCachePath(cwd)) ? cachePath : `${cachePath} (not present)`}`, ]; return lines.join('\n'); } function setEnabled(cwd, value) { const config = mergeConfig(readRawConfig(cwd)); config.enabled = value; const target = writeConfig(cwd, config); return `Design hook ${value ? 'enabled' : 'disabled'} for this project (wrote ${path.relative(cwd, target) || target}).`; } function normalizeRuleId(rule) { return String(rule || '').trim().toLowerCase(); } function parseIgnoreRuleArgs(args) { const positionals = []; let allValues = false; for (let i = 0; i < args.length; i++) { const arg = String(args[i] || ''); if (arg === '--all-values') { allValues = true; } else if (arg === '--reason') { while (i + 1 < args.length && !String(args[i + 1]).startsWith('--')) i++; } else if (arg.startsWith('--reason=')) { // Accepted for command symmetry; ignoreRules stores rule ids only. } else if (arg.startsWith('--')) { throw new Error(`Unknown ignore-rule flag: ${arg}`); } else { positionals.push(arg); } } return { rule: normalizeRuleId(positionals[0]), allValues, }; } function addIgnoreRule(cwd, args) { const parsed = parseIgnoreRuleArgs(args); const rule = parsed.rule; if (!rule) throw new Error('Pass a rule id, e.g. /impeccable hooks ignore-rule side-tab'); if (rule === 'overused-font' && !parsed.allValues) { throw new Error('overused-font is value-specific by default. Use /impeccable hooks ignore-value overused-font for a confirmed font, or /impeccable hooks ignore-rule overused-font --all-values only when the user asked to ignore overused fonts generally.'); } const config = mergeConfig(readRawConfig(cwd)); if (!config.ignoreRules.includes(rule)) config.ignoreRules.push(rule); writeConfig(cwd, config); return `Added "${rule}" to ignoreRules. Current: ${config.ignoreRules.join(', ')}`; } function addIgnoreFile(cwd, glob) { if (!glob) throw new Error('Pass a glob, e.g. /impeccable hooks ignore-file "src/legacy/**"'); const config = mergeConfig(readRawConfig(cwd)); if (!config.ignoreFiles.includes(glob)) config.ignoreFiles.push(glob); writeConfig(cwd, config); return `Added "${glob}" to ignoreFiles. Current: ${config.ignoreFiles.join(', ')}`; } function parseIgnoreValueArgs(args) { const positionals = []; let shared = false; let local = false; let reason = ''; for (let i = 0; i < args.length; i++) { const arg = args[i]; if (arg === '--shared') { shared = true; } else if (arg === '--local') { local = true; } else if (arg === '--reason') { const chunks = []; while (i + 1 < args.length && !String(args[i + 1]).startsWith('--')) { chunks.push(args[++i]); } reason = chunks.join(' ').trim(); } else if (String(arg).startsWith('--reason=')) { reason = String(arg).slice('--reason='.length).trim(); } else { positionals.push(arg); } } const [rule, ...valueParts] = positionals; return { rule: String(rule || '').trim().toLowerCase(), value: normalizeIgnoreValue(valueParts.join(' ')), shared, local, reason, }; } function addIgnoreValue(cwd, args) { const parsed = parseIgnoreValueArgs(args); if (!parsed.rule || !parsed.value) { throw new Error('Pass a rule id and value, e.g. /impeccable hooks ignore-value overused-font Inter'); } if (parsed.shared && parsed.local) { throw new Error('Pass only one scope flag: --shared or --local'); } const local = parsed.local; const config = local ? mergeLocalConfig(readRawConfig(cwd, { local: true })) : mergeConfig(readRawConfig(cwd, { local: false })); const key = `${parsed.rule}\0${parsed.value}`; const existing = config.ignoreValues.find((entry) => `${entry.rule}\0${entry.value}` === key); if (existing) { if (parsed.reason) existing.reason = parsed.reason; } else { const entry = { rule: parsed.rule, value: parsed.value, createdAt: new Date().toISOString(), }; if (parsed.reason) entry.reason = parsed.reason; config.ignoreValues.push(entry); } const target = writeConfig(cwd, config, { local }); const scope = local ? 'local ignoreValues' : 'shared ignoreValues'; return `Added ${parsed.rule}=${parsed.value} to ${scope} (${path.relative(cwd, target) || target}).`; } function reset(cwd) { const removed = []; for (const filePath of [getConfigPath(cwd), getLocalConfigPath(cwd), getCachePath(cwd), getPendingPath(cwd)]) { try { if (fs.existsSync(filePath)) { fs.unlinkSync(filePath); removed.push(path.relative(cwd, filePath) || filePath); } } catch { /* ignore */ } } return removed.length ? `Reset design hook config and cache (removed: ${removed.join(', ')}).` : 'No hook config or cache to remove. Already at defaults.'; } function main() { const [, , actionArg, ...rest] = process.argv; const action = (actionArg || 'status').toLowerCase(); const cwd = process.cwd(); if (!ACTIONS.has(action)) { process.stderr.write(`Unknown action: ${action}\nValid: ${Array.from(ACTIONS).join(', ')}\n`); process.exit(1); } try { let out = ''; switch (action) { case 'status': out = statusReport(cwd); break; case 'on': out = setEnabled(cwd, true); break; case 'off': out = setEnabled(cwd, false); break; case 'ignore-rule': out = addIgnoreRule(cwd, rest); break; case 'ignore-file': out = addIgnoreFile(cwd, rest[0]); break; case 'ignore-value': out = addIgnoreValue(cwd, rest); break; case 'reset': out = reset(cwd); break; } process.stdout.write(out + '\n'); } catch (err) { process.stderr.write(`Error: ${err.message || err}\n`); process.exit(1); } } main();