/** * Shared helpers for the grok-glance hook scripts and CLI. * * Deliberately dependency-free and stdlib-only: these run on the critical path of * every Grok Build tool call, so they must start fast and never wedge a session. */ import fs from "node:fs"; import path from "node:path"; import os from "node:os"; import { fileURLToPath } from "node:url"; export const PLUGIN_ROOT = path.resolve(fileURLToPath(import.meta.url), "../.."); export const DEFAULT_PORT = 8791; /** * State lives in one fixed place so that hooks (which get GROK_PLUGIN_DATA) and the * CLI (which does not) always agree on where config, credentials and events are. */ export function glanceHome() { if (process.env.GLANCE_HOME) return path.resolve(process.env.GLANCE_HOME); return path.join(os.homedir(), ".grok", "glance"); } export function readConfig() { const file = path.join(glanceHome(), "config.json"); let raw = {}; try { raw = JSON.parse(fs.readFileSync(file, "utf8")); } catch { // No config yet, or unreadable: defaults are always usable. } const port = Number(process.env.GLANCE_PORT ?? raw.port ?? DEFAULT_PORT); return { ...raw, port: Number.isFinite(port) ? port : DEFAULT_PORT, host: raw.host ?? "127.0.0.1", }; } export function baseUrl(cfg = readConfig()) { return `http://127.0.0.1:${cfg.port}`; } /** Header the daemon expects on /hook/*; kept in step with server/src/auth.ts. */ export const HOOK_HEADER = "x-glance-hook"; /** * The shared secret that admits a caller to /hook/*. Read fresh on every invocation so a * regenerated secret is picked up without restarting anything. * * Only the daemon ever creates it. A hook must never be the thing that creates state, and a * missing secret has to degrade to "no telemetry", not "no tool call" — so this returns null * and the callers carry on. */ export function hookSecret() { try { return fs.readFileSync(path.join(glanceHome(), "hook.secret"), "utf8").trim() || null; } catch { return null; } } export function hookHeaders() { const token = hookSecret(); return token ? { [HOOK_HEADER]: token } : {}; } /** * How long the PreToolUse approval hook is allowed to run, in seconds — the `timeout` written * next to glance-approve.mjs in hooks/hooks.json. Change one, change the other. * * It bounds everything downstream: if the script outlives its hook timeout, Grok Build kills * it and the fail-open path never runs. So the daemon caps its own wait well inside it (see * APPROVAL_MAX_WAIT_MS in server/src/config.ts), and the script leaves itself 10s on top of * that to answer. */ export const APPROVAL_HOOK_TIMEOUT_SECS = 125; /** Read the hook payload that Grok Build writes to stdin. Returns {} if there is none. */ export async function readStdinJson() { if (process.stdin.isTTY) return {}; const chunks = []; try { for await (const chunk of process.stdin) chunks.push(chunk); } catch { return {}; } const text = Buffer.concat(chunks).toString("utf8").trim(); if (!text) return {}; try { return JSON.parse(text); } catch { return {}; } } /** * Grok Build also passes the event in the environment. We merge it in so a payload that * is missing fields (or absent entirely) still produces a usable event. */ export function envEnvelope(payload) { return { hookEventName: payload.hookEventName ?? process.env.GROK_HOOK_EVENT ?? "Unknown", sessionId: payload.sessionId ?? process.env.GROK_SESSION_ID ?? "unknown", workspaceRoot: payload.workspaceRoot ?? process.env.GROK_WORKSPACE_ROOT ?? payload.cwd ?? process.cwd(), cwd: payload.cwd ?? process.cwd(), hookName: process.env.GROK_HOOK_NAME ?? undefined, ...payload, }; } export async function postJson(url, body, timeoutMs, extraHeaders = {}) { const res = await fetch(url, { method: "POST", headers: { "content-type": "application/json", ...extraHeaders }, body: JSON.stringify(body), signal: AbortSignal.timeout(timeoutMs), }); const text = await res.text(); if (!text) return { status: res.status, data: null }; try { return { status: res.status, data: JSON.parse(text) }; } catch { return { status: res.status, data: null }; } } export async function isDaemonUp(cfg = readConfig(), timeoutMs = 400) { try { const res = await fetch(`${baseUrl(cfg)}/healthz`, { signal: AbortSignal.timeout(timeoutMs), }); return res.ok; } catch { return false; } } export const sleep = (ms) => new Promise((r) => setTimeout(r, ms));