#!/usr/bin/env node // Compatibility adapter for preserved specialist modules. // config.json is the only writable config authority. A legacy config.yaml may // be read as a migration fallback, but this command never writes YAML. import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { createHash } from "node:crypto"; import { spawnSync } from "node:child_process"; import { fileURLToPath } from "node:url"; import { configGet, configSet, ensureConfig, loadConfig, parseConfigValue, readLegacyConfig, } from "../runtime/config.js"; import { ensureManagedHome, withRuntimeLifecycleLock } from "../runtime/managed-home.js"; const scriptDir = path.dirname(fileURLToPath(import.meta.url)); const env = process.env.GSTACK_HOME ? process.env : process.env.GSTACK_STATE_ROOT ? { ...process.env, GSTACK_HOME: process.env.GSTACK_STATE_ROOT } : process.env.GSTACK_STATE_DIR ? { ...process.env, GSTACK_HOME: process.env.GSTACK_STATE_DIR } : process.env; const home = path.resolve(env.GSTACK_HOME || path.join(os.homedir(), ".gstack")); const legacyConfig = path.join(home, "config.yaml"); const defaults = Object.freeze({ proactive: true, routing_declined: false, telemetry: "off", auto_upgrade: false, // Skill installation and update discovery belong to the Agent Skills CLI. // GStack never performs a passive network update check by default. update_check: false, skill_prefix: false, checkpoint_mode: "explicit", checkpoint_push: false, explain_level: "default", codex_reviews: "enabled", gstack_contributor: false, skip_eng_review: false, workspace_root: path.join(os.homedir(), "conductor", "workspaces"), cross_project_learnings: "", artifacts_sync_mode: "off", artifacts_sync_mode_prompted: false, plan_tune_hooks: "prompt", redact_repo_visibility: "", redact_prepush_hook: false, salience_allowlist: "", }); const [command, ...args] = process.argv.slice(2); try { switch (command) { case "get": await getCommand(args); break; case "set": await setCommand(args); break; case "list": await listCommand(); break; case "defaults": printEntries(defaults); break; case "endpoint-hash": process.stdout.write(endpointHash()); break; case "resolve-user-slug": await resolveUserSlug(); break; case "gbrain-refresh": await refreshGbrainDetection(); break; default: usage(); process.exitCode = 1; } } catch (error) { process.stderr.write(`gstack-config: ${error?.message ?? error}\n`); process.exitCode = 1; } async function getCommand(args) { if (args.length !== 1) throw new Error("Usage: gstack-config get "); const key = validateKey(args[0]); let value; if (await exists(path.join(home, "config.json"))) value = await configGet(home, key); if (value === undefined && await exists(legacyConfig)) value = await readLegacyValue(key); if (value === undefined) value = defaultFor(key); process.stdout.write(formatValue(value)); } async function setCommand(args) { if (args.length !== 2) throw new Error("Usage: gstack-config set "); const key = validateKey(args[0]); const raw = validateClosedValue(key, args[1]); await mutateConfigHome(() => configSet(home, key, parseConfigValue(raw))); } async function listCommand() { const stored = await exists(path.join(home, "config.json")) ? await loadConfig(home) : await readLegacyConfig(home); const flattened = { ...defaults, ...flatten(stored) }; printEntries(flattened); } function defaultFor(key) { if (/^brain_trust_policy(?:@|$)/.test(key)) return "unset"; return Object.hasOwn(defaults, key) ? defaults[key] : ""; } function validateKey(key) { if (typeof key !== "string" || !/^[a-zA-Z0-9_]+(?:@[a-f0-9]+)?$/.test(key)) { throw new Error("key must contain only alphanumeric characters, underscores, and an optional @ suffix"); } return key; } function validateClosedValue(key, value) { const domains = [ [/^brain_trust_policy(?:@|$)/, ["personal", "shared", "unset"], "unset"], [/^explain_level$/, ["default", "terse"], "default"], [/^artifacts_sync_mode$/, ["off", "artifacts-only", "full"], "off"], [/^redact_repo_visibility$/, ["public", "private", "unknown"], "unknown"], [/^redact_prepush_hook$/, ["true", "false"], "false"], [/^plan_tune_hooks$/, ["prompt", "yes", "no"], "prompt"], ]; if (key === "codex_reviews" && !["enabled", "disabled"].includes(value)) { throw new Error(`codex_reviews '${value}' not recognized. Valid values: enabled, disabled. Existing value left unchanged.`); } for (const [pattern, allowed, fallback] of domains) { if (pattern.test(key) && !allowed.includes(value)) { process.stderr.write(`Warning: ${key} '${value}' not recognized. Valid values: ${allowed.join(", ")}. Using ${fallback}.\n`); return fallback; } } return value; } async function readLegacyValue(key) { const content = await fs.readFile(legacyConfig, "utf8"); let found; for (const line of content.split(/\r?\n/)) { const match = line.match(/^([A-Za-z0-9_]+(?:@[a-f0-9]+)?):\s*(.*?)\s*(?:#.*)?$/); if (match?.[1] === key) found = parseConfigValue(unquote(match[2])); } return found; } function unquote(value) { if (value.length >= 2 && ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'")))) return value.slice(1, -1); return value; } function endpointHash() { const endpoint = env.GSTACK_GBRAIN_ENDPOINT || env.GBRAIN_URL || ""; return endpoint ? createHash("sha256").update(endpoint).digest("hex").slice(0, 8) : "local"; } async function resolveUserSlug() { const key = `user_slug_at_${endpointHash()}`; if (await exists(path.join(home, "config.json"))) { const stored = await configGet(home, key); if (typeof stored === "string" && stored) { process.stdout.write(stored); return; } } const user = sanitizeSlug(env.USER || ""); const email = spawnSync("git", ["config", "user.email"], { encoding: "utf8" }).stdout?.trim(); const fallback = email ? `email-${sha8(email)}` : `anonymous-${sha8(os.hostname() || "unknown")}`; const slug = user || fallback; await mutateConfigHome(() => configSet(home, key, slug)); process.stdout.write(slug); } async function refreshGbrainDetection() { await mutateConfigHome(async () => { const detector = path.join(scriptDir, "gstack-gbrain-detect"); const result = spawnSync(detector, [], { encoding: "utf8", env }); const payload = result.status === 0 && result.stdout.trim() ? result.stdout.trim() : '{"gbrain_on_path":false,"gbrain_local_status":"no-cli"}'; JSON.parse(payload); const target = path.join(home, "gbrain-detection.json"); const temporary = `${target}.tmp-${process.pid}`; await fs.writeFile(temporary, `${payload}\n`, { mode: 0o600 }); await fs.rename(temporary, target); }); process.stdout.write("GBrain detection refreshed. Re-run the standard Agent Skills installer if skill content must change.\n"); } async function mutateConfigHome(callback) { return withRuntimeLifecycleLock(home, async () => { await ensureManagedHome(home); await ensureConfig(home); return callback(); }); } function sanitizeSlug(value) { return value.toLowerCase().replace(/\s+/g, "-").replace(/[^a-z0-9-]/g, ""); } function sha8(value) { return createHash("sha256").update(value).digest("hex").slice(0, 8); } function flatten(value, prefix = "", output = {}) { for (const [key, child] of Object.entries(value ?? {})) { const name = prefix ? `${prefix}.${key}` : key; if (child && typeof child === "object" && !Array.isArray(child)) flatten(child, name, output); else output[name] = child; } return output; } function printEntries(entries) { for (const key of Object.keys(entries).sort()) { process.stdout.write(`${key}: ${formatValue(entries[key])}\n`); } } function formatValue(value) { if (value === undefined || value === null) return ""; return typeof value === "string" ? value : JSON.stringify(value); } async function exists(target) { return fs.lstat(target).then(() => true, (error) => { if (error?.code === "ENOENT") return false; throw error; }); } function usage() { process.stderr.write("Usage: gstack-config {get|set|list|defaults|endpoint-hash|resolve-user-slug|gbrain-refresh} [key] [value]\n"); }