Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion src/cli/embeddings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { userInfo } from "node:os";
import { join } from "node:path";
import { HOME, ensureDir, log, pkgRoot, symlinkForce, warn, writeJson } from "./util.js";
import { pidPathFor, socketPathFor } from "../embeddings/protocol.js";
import { getEmbeddingsEnabled, setEmbeddingsEnabled } from "../user-config.js";
import { assertUserConfigWritable, getEmbeddingsEnabled, setEmbeddingsEnabled } from "../user-config.js";
import { ensureGraphDeps } from "./graph-deps.js";

/**
Expand Down Expand Up @@ -176,6 +176,7 @@ function linkAgent(install: AgentInstall): void {
* prior `disable`).
*/
export function installEmbeddings(): void {
assertUserConfigWritable();
ensureSharedDeps();
// Provision the code-graph parsers into the same shared dir so the
// graph-on-stop hook (which symlinks here) can auto-build the graph.
Expand Down Expand Up @@ -216,6 +217,7 @@ export function enableEmbeddings(): void {
* effect immediately. Counterpart to `install`.
*/
export function uninstallEmbeddings(opts?: { prune?: boolean }): void {
assertUserConfigWritable();
const installs = findHivemindInstalls();
for (const inst of installs) {
const link = join(inst.pluginDir, "node_modules");
Expand Down
77 changes: 41 additions & 36 deletions src/cli/install-codex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,19 +170,33 @@ export function mergeHooks(
}

// Filesystem-bound wrapper: reads HOOKS_PATH (if present) and feeds the
// parsed result to the pure mergeHooks. Catches malformed JSON and warns.
// parsed result to the pure mergeHooks. Malformed JSON is an install blocker:
// replacing it with an empty object would destroy user hooks.
// Also surfaces a warning listing any foreign-path hivemind entries
// stripped (e.g. a dev clone wired in under a different directory).
function mergeHooksJson(ours: Record<string, unknown>): Record<string, unknown> {
let existing: Record<string, unknown> = {};
function readHooksJson(): Record<string, unknown> {
let raw: string;
try {
if (existsSync(HOOKS_PATH)) {
const parsed = JSON.parse(readFileSync(HOOKS_PATH, "utf-8"));
if (parsed && typeof parsed === "object") existing = parsed as Record<string, unknown>;
}
raw = readFileSync(HOOKS_PATH, "utf-8");
} catch (err) {
if (typeof err === "object" && err !== null && "code" in err && err.code === "ENOENT") return {};
const detail = err instanceof Error && err.message ? `: ${err.message}` : "";
throw new Error(`Codex hooks config at ${HOOKS_PATH} could not be read${detail}`);
}
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
warn(` Codex ${HOOKS_PATH} unparseable — ignoring prior content`);
throw new Error(`Codex hooks config at ${HOOKS_PATH} is not valid JSON; fix or remove it, then rerun.`);
}
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
throw new Error(`Codex hooks config at ${HOOKS_PATH} must contain a JSON object; fix or remove it, then rerun.`);
}
return parsed as Record<string, unknown>;
}

function mergeHooksJson(ours: Record<string, unknown>): Record<string, unknown> {
const existing = readHooksJson();
reportForeignHivemindHooks(existing);
return mergeHooks(existing, ours);
}
Expand Down Expand Up @@ -299,6 +313,9 @@ export function installCodex(): void {
throw new Error(`Codex bundle missing at ${srcBundle}. Run 'npm run build' first.`);
}

// Validate before copying payloads or changing Codex feature flags.
const mergedHooks = mergeHooksJson(buildHooksJson());

ensureDir(PLUGIN_DIR);
reportPruned("Codex", syncDir(srcBundle, join(PLUGIN_DIR, "bundle")));
if (existsSync(srcSkills)) reportPruned("Codex", syncDir(srcSkills, join(PLUGIN_DIR, "skills")));
Expand All @@ -309,7 +326,7 @@ export function installCodex(): void {
// Codex fingerprints and re-triggers its "Hooks need review" trust prompt on
// every install/update. Skipping the no-op write keeps the user from being
// re-prompted each time.
if (!writeJsonIfChanged(HOOKS_PATH, mergeHooksJson(buildHooksJson()))) {
if (!writeJsonIfChanged(HOOKS_PATH, mergedHooks)) {
log(` Codex hooks.json unchanged — skipped rewrite (no re-trust prompt)`);
}

Expand Down Expand Up @@ -342,34 +359,22 @@ export function installCodex(): void {
}

export function uninstallCodex(): void {
if (existsSync(HOOKS_PATH)) {
// Symmetric with install: strip ONLY our hivemind entries via mergeHooks.
// The pre-fix unconditional unlinkSync(HOOKS_PATH) destroyed any user-
// defined hooks (e.g. a custom Notification handler) that lived alongside
// ours. mergeHooks(existing, { hooks: {} }) preserves the user's events
// and removes only the ones whose command points into PLUGIN_DIR/bundle/.
let existing: Record<string, unknown> = {};
try {
const raw = JSON.parse(readFileSync(HOOKS_PATH, "utf-8"));
if (raw && typeof raw === "object") existing = raw as Record<string, unknown>;
} catch {
// Malformed JSON: fall back to deleting the file rather than guess at
// intent. Same behavior as pre-fix; user can recreate cleanly.
// Symmetric with install: strip ONLY our hivemind entries via mergeHooks.
// The pre-fix unconditional unlinkSync(HOOKS_PATH) destroyed any user-
// defined hooks (e.g. a custom Notification handler) that lived alongside
// ours. Validate first so malformed/unreadable config leaves every other
// uninstall target untouched and propagates a failure to the dispatcher.
const existing = readHooksJson();
if (Object.keys(existing).length > 0) {
const stripped = mergeHooks(existing, { hooks: {} });
const survivingHooks = (stripped.hooks ?? {}) as Record<string, unknown[]>;
const otherTopLevelKeys = Object.keys(stripped).filter(k => k !== "hooks");
if (Object.keys(survivingHooks).length === 0 && otherTopLevelKeys.length === 0) {
unlinkSync(HOOKS_PATH);
log(` Codex removed unparseable ${HOOKS_PATH}`);
existing = {};
}
if (Object.keys(existing).length > 0) {
const stripped = mergeHooks(existing, { hooks: {} });
const survivingHooks = (stripped.hooks ?? {}) as Record<string, unknown[]>;
const otherTopLevelKeys = Object.keys(stripped).filter(k => k !== "hooks");
if (Object.keys(survivingHooks).length === 0 && otherTopLevelKeys.length === 0) {
unlinkSync(HOOKS_PATH);
log(` Codex removed ${HOOKS_PATH}`);
} else {
writeJson(HOOKS_PATH, stripped);
log(` Codex stripped hivemind hooks from ${HOOKS_PATH}`);
}
log(` Codex removed ${HOOKS_PATH}`);
} else {
writeJson(HOOKS_PATH, stripped);
log(` Codex stripped hivemind hooks from ${HOOKS_PATH}`);
}
}
if (existsSync(SKILL_LINK)) {
Expand Down
34 changes: 30 additions & 4 deletions src/cli/install-cursor.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { existsSync, lstatSync, rmSync, unlinkSync } from "node:fs";
import { existsSync, lstatSync, rmSync, unlinkSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { HOME, pkgRoot, ensureDir, syncDir, readJson, writeJson, writeJsonIfChanged, symlinkForce, writeVersionStamp, log, reportPruned } from "./util.js";
import { HOME, pkgRoot, ensureDir, syncDir, writeJson, writeJsonIfChanged, symlinkForce, writeVersionStamp, log, reportPruned } from "./util.js";
import { getVersion } from "./version.js";

// Cursor 1.7+ hooks API: https://cursor.com/docs/agent/hooks
Expand Down Expand Up @@ -84,6 +84,27 @@ function mergeHooks(existing: Record<string, unknown> | null): Record<string, un
return root as unknown as Record<string, unknown>;
}

function readHooksConfig(): Record<string, unknown> | null {
let raw: string;
try {
raw = readFileSync(HOOKS_PATH, "utf-8");
} catch (err) {
if (typeof err === "object" && err !== null && "code" in err && err.code === "ENOENT") return null;
const detail = err instanceof Error && err.message ? `: ${err.message}` : "";
throw new Error(`Cursor hooks config at ${HOOKS_PATH} could not be read${detail}`);
}
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
throw new Error(`Cursor hooks config at ${HOOKS_PATH} is not valid JSON; fix or remove it, then rerun.`);
}
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
throw new Error(`Cursor hooks config at ${HOOKS_PATH} must contain a JSON object; fix or remove it, then rerun.`);
}
return parsed as Record<string, unknown>;
}

export function stripHooksFromConfig(existing: Record<string, unknown> | null): Record<string, unknown> | null {
if (!existing) return null;
const root = existing as { hooks?: Record<string, unknown[]> };
Expand All @@ -104,10 +125,13 @@ export function installCursor(): void {
throw new Error(`Cursor bundle missing at ${srcBundle}. Run 'npm run build' first.`);
}

// Validate before copying payloads. A malformed user hooks file cannot be
// merged safely and must not be replaced with a fresh Hivemind config.
const existing = readHooksConfig();

ensureDir(PLUGIN_DIR);
reportPruned("Cursor", syncDir(srcBundle, join(PLUGIN_DIR, "bundle")));

const existing = readJson<Record<string, unknown>>(HOOKS_PATH);
const merged = mergeHooks(existing);
// Idempotent (same rationale as codex): skip the rewrite when unchanged so
// we don't perturb the hooks.json Cursor/Codex-style trust fingerprints.
Expand All @@ -125,7 +149,9 @@ export function installCursor(): void {
}

export function uninstallCursor(): void {
const existing = readJson<Record<string, unknown>>(HOOKS_PATH);
// Validation errors propagate to the dispatcher, which reports FAILED.
// Nothing is removed or rewritten before this read succeeds.
const existing = readHooksConfig();
if (!existing) {
log(" Cursor no hooks.json to clean");
return;
Expand Down
61 changes: 43 additions & 18 deletions src/cli/install-hermes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,20 +160,37 @@ function stripHivemindHooks(existing: Record<string, HermesHookEntry[]> | undefi
return Object.keys(out).length > 0 ? out : undefined;
}

function readConfig(): HermesConfig {
if (!existsSync(CONFIG_PATH)) return {};
function readConfig(): HermesConfig | null {
let raw: string;
try {
const raw = readFileSync(CONFIG_PATH, "utf-8");
const parsed = yaml.load(raw);
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
return parsed as HermesConfig;
}
return {};
raw = readFileSync(CONFIG_PATH, "utf-8");
} catch (err) {
if (typeof err === "object" && err !== null && "code" in err && err.code === "ENOENT") return null;
const detail = err instanceof Error && err.message ? `: ${err.message}` : "";
throw new Error(`Hermes config at ${CONFIG_PATH} could not be read${detail}`);
}

let parsed: unknown;
let rootNodeIsEmpty = true;
try {
parsed = yaml.load(raw, {
// js-yaml represents both an empty document and an explicit null scalar
// as null. The final parser close event retains the root node metadata,
// so use it to distinguish those cases without reimplementing YAML.
listener: (event, state) => {
if (event !== "close") return;
const node = state as typeof state & { tag?: string | null; anchor?: string | null };
rootNodeIsEmpty = node.kind == null && node.tag == null && node.anchor == null;
},
});
} catch {
// Malformed YAML — back off to empty so we don't clobber, but the user
// will see our entry get appended fresh.
return {};
throw new Error(`Hermes config at ${CONFIG_PATH} is not valid YAML; fix or remove it, then rerun.`);
}
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
return parsed as HermesConfig;
}
if ((parsed === undefined || parsed === null) && rootNodeIsEmpty) return {};
throw new Error(`Hermes config at ${CONFIG_PATH} must contain a YAML mapping; fix or remove it, then rerun.`);
}

function writeConfig(cfg: HermesConfig): void {
Expand All @@ -200,6 +217,15 @@ function packagedSkillNames(): string[] {
}

export function installHermes(): void {
const srcBundle = join(pkgRoot(), "harnesses", "hermes", "bundle");
if (!existsSync(srcBundle)) {
throw new Error(`Hermes bundle missing at ${srcBundle}. Run 'npm run build' first.`);
}

// Validate before touching any owned payload directories. Treating a
// malformed config as empty would destroy every user setting on write.
const cfg = readConfig() ?? {};

// 1. Skills — agent context. hivemind-memory is written inline; everything
// else the installer ever put in that dir (an older version's
// templates/) is pruned so only the current skill body remains.
Expand All @@ -218,10 +244,6 @@ export function installHermes(): void {
}

// 2. Hook bundle — auto-capture via Hermes shell-hooks.
const srcBundle = join(pkgRoot(), "harnesses", "hermes", "bundle");
if (!existsSync(srcBundle)) {
throw new Error(`Hermes bundle missing at ${srcBundle}. Run 'npm run build' first.`);
}
ensureDir(HIVEMIND_DIR);
reportPruned("Hermes", syncDir(srcBundle, BUNDLE_DIR));
const pluginNm = join(HIVEMIND_DIR, "node_modules");
Expand All @@ -238,7 +260,6 @@ export function installHermes(): void {

// Update config.yaml with mcp_servers + hooks + hooks_auto_accept.
// Preserves any pre-existing user configuration.
const cfg = readConfig();
if (!cfg.mcp_servers || typeof cfg.mcp_servers !== "object") cfg.mcp_servers = {};
cfg.mcp_servers[SERVER_KEY] = {
command: "node",
Expand All @@ -253,6 +274,11 @@ export function installHermes(): void {
}

export function uninstallHermes(): void {
// Validate before removing any owned payload. If the config cannot be
// understood, uninstall must fail as one untouched operation rather than
// leave hooks pointing at a bundle that has already been deleted.
const cfg = readConfig();

for (const dir of [SKILLS_DIR, ...packagedSkillNames().map(n => join(SKILLS_ROOT, n))]) {
// A symlink at one of our names is the user's, never something we wrote.
if (!existsSync(dir) || isLink(dir)) continue;
Expand All @@ -265,8 +291,7 @@ export function uninstallHermes(): void {
log(` Hermes removed ${HIVEMIND_DIR}`);
}

if (existsSync(CONFIG_PATH)) {
const cfg = readConfig();
if (cfg) {
let touched = false;
if (cfg.mcp_servers && typeof cfg.mcp_servers === "object" && SERVER_KEY in cfg.mcp_servers) {
delete cfg.mcp_servers[SERVER_KEY];
Expand Down
Loading