UNPKG

@blundergoat/goat-flow

Version:

AI coding agent harness and local dashboard for Claude Code, OpenAI Codex, Google Antigravity, and GitHub Copilot - setup audits, guardrails, structured skills, deny hooks, and persistent learning loops.

206 lines 7.93 kB
/** * Minimal `.goat-flow/config.yaml` writer for hook toggle state. * * The writer only replaces targeted top-level blocks so comments and ordering * in the rest of the config file survive normal dashboard toggles. */ import { existsSync, mkdirSync, readFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { dump, load } from "js-yaml"; import { writeFileAtomic } from "../server/safe-exec.js"; const HOOK_ID_ALIASES = new Map([ ["gruff-on-change", "gruff-code-quality"], ["guard-destructive-shell", "deny-dangerous"], ["guard-secret-paths", "deny-dangerous"], ["guard-repository-writes", "deny-dangerous"], ]); const HOOK_BLOCK_COMMENT_LINES = new Set([ "# Togglable goat-flow hook state. Missing entries use registry defaults.", "# Manage with the dashboard Hooks page or `goat-flow hooks <enable|disable|sync>`.", ]); const REMOVED_TOP_LEVEL_BLOCK_COMMENTS = new Map([ [ "plan-guard", new Set(["# Workflow reminder settings for the plan checkbox guard."]), ], ]); /** Narrow parsed YAML values before reading the hooks block. */ function isRecord(value) { return (value !== null && typeof value === "object" && Array.isArray(value) === false); } /** Resolve the project-local goat-flow config path used by dashboard hook toggles. */ function configPath(projectPath) { return join(projectPath, ".goat-flow", "config.yaml"); } /** Read existing config text or synthesize the minimal config needed before the first toggle write. */ function readConfigText(projectPath) { const path = configPath(projectPath); if (!existsSync(path)) { return [ "# .goat-flow/config.yaml - project configuration", 'version: "1.8.0"', "", ].join("\n"); } return readFileSync(path, "utf-8"); } /** Map legacy hook ids to canonical ids so old config entries keep their state. */ function normalizeHookIdentifier(hookIdentifier) { return HOOK_ID_ALIASES.get(hookIdentifier) ?? hookIdentifier; } /** Parse explicitly configured hook states; malformed YAML uses an empty-map fallback. */ function readRawHooks(text) { let parsed; try { parsed = load(text) ?? {}; } catch { return {}; } if (!isRecord(parsed) || !isRecord(parsed.hooks)) return {}; const hooks = {}; for (const [hookId, value] of Object.entries(parsed.hooks)) { if (!isRecord(value) || typeof value.enabled !== "boolean") continue; const normalizedHookIdentifier = normalizeHookIdentifier(hookId); if (normalizedHookIdentifier !== hookId && Object.prototype.hasOwnProperty.call(hooks, normalizedHookIdentifier)) { continue; } hooks[normalizedHookIdentifier] = { enabled: value.enabled }; } return hooks; } /** Render the managed hooks block with stable ordering and the operator-facing ownership comment. */ function renderHooksBlock(hooks) { const ordered = Object.fromEntries(Object.entries(hooks).sort(([a], [b]) => a.localeCompare(b))); const dumped = dump({ hooks: ordered }, { lineWidth: 100 }).trimEnd(); return [ "# Togglable goat-flow hook state. Missing entries use registry defaults.", "# Manage with the dashboard Hooks page or `goat-flow hooks <enable|disable|sync>`.", dumped, ].join("\n"); } /** Detect top-level YAML keys so hook-block replacement preserves following config sections. */ function isTopLevelLine(line) { return /^[A-Za-z0-9_-]+:/u.test(line); } /** Replace only the managed top-level hooks block, preserving all unrelated config text. */ function replaceTopLevelHooksBlock(text, block) { const lines = text.replace(/\s*$/u, "\n").split("\n"); const start = lines.findIndex((line) => /^hooks:\s*(?:#.*)?$/u.test(line)); if (start === -1) return `${lines.join("\n").trimEnd()}\n\n${block}\n`; let prefixEnd = start; while (prefixEnd > 0 && HOOK_BLOCK_COMMENT_LINES.has(lines[prefixEnd - 1] ?? "")) { prefixEnd -= 1; } let end = start + 1; while (end < lines.length) { const line = lines[end] ?? ""; if (line.trim() !== "" && isTopLevelLine(line)) break; end += 1; } return [...lines.slice(0, prefixEnd), block, ...lines.slice(end)] .join("\n") .replace(/\n{3,}/gu, "\n\n") .trimEnd() .concat("\n"); } function topLevelBlockRange(lines, key) { if (!/^[A-Za-z0-9_-]+$/u.test(key)) return null; const start = lines.findIndex((line) => new RegExp(`^${key}:\\s*(?:#.*)?$`, "u").test(line)); if (start === -1) return null; let end = start + 1; while (end < lines.length) { const line = lines[end] ?? ""; if (line.trim() !== "" && isTopLevelLine(line)) break; end += 1; } return { start, end }; } function removablePrefixStart(lines, start, key) { const comments = REMOVED_TOP_LEVEL_BLOCK_COMMENTS.get(key); if (!comments) return start; let prefixStart = start; while (prefixStart > 0 && comments.has(lines[prefixStart - 1] ?? "")) { prefixStart -= 1; } if (prefixStart > 0 && (lines[prefixStart - 1] ?? "").trim() === "") { prefixStart -= 1; } return prefixStart; } function removeTopLevelBlockFromText(text, key) { const lines = text.replace(/\s*$/u, "\n").split("\n"); const range = topLevelBlockRange(lines, key); if (!range) return text; const prefixStart = removablePrefixStart(lines, range.start, key); return [...lines.slice(0, prefixStart), ...lines.slice(range.end)] .join("\n") .replace(/\n{3,}/gu, "\n\n") .trimEnd() .concat("\n"); } /** Return the explicitly configured hook state, excluding registry defaults. */ function readHookConfig(projectPath) { return readRawHooks(readConfigText(projectPath)); } /** * Return one hook's desired enabled state using the registry default on absence. * * @param projectPath - project whose goat-flow config stores hook overrides * @param hookId - canonical hook id to read * @param defaultEnabled - registry default to use when config omits the hook * @returns configured enabled state, or the registry default when absent */ export function readHookEnabled(projectPath, hookId, defaultEnabled) { return readHookConfig(projectPath)[hookId]?.enabled ?? defaultEnabled; } /** * Set one hook's desired enabled state in `.goat-flow/config.yaml`. * * @param projectPath - project whose goat-flow config should be written * @param hookId - canonical hook id to update * @param enabled - desired enabled state to persist */ export function setHookEnabled(projectPath, hookId, enabled) { const path = configPath(projectPath); const text = readConfigText(projectPath); const hooks = readRawHooks(text); hooks[hookId] = { enabled }; mkdirSync(dirname(path), { recursive: true }); writeFileAtomic(path, replaceTopLevelHooksBlock(text, renderHooksBlock(hooks)), projectPath); } export function removeHookConfig(projectPath, hookId) { const path = configPath(projectPath); if (!existsSync(path)) return; const text = readConfigText(projectPath); const hooks = readRawHooks(text); if (!Object.prototype.hasOwnProperty.call(hooks, hookId)) return; Reflect.deleteProperty(hooks, hookId); writeFileAtomic(path, replaceTopLevelHooksBlock(text, renderHooksBlock(hooks)), projectPath); } export function removeTopLevelConfigBlock(projectPath, key) { const path = configPath(projectPath); if (!existsSync(path)) return; const text = readConfigText(projectPath); const next = removeTopLevelBlockFromText(text, key); if (next === text) return; writeFileAtomic(path, next, projectPath); } //# sourceMappingURL=writer.js.map