Skip to content

@memhtml/integrations

@memhtml/integrations wires a coding agent to a memhtml store. For each host in HOSTS (Claude Code, Codex CLI, Cursor and OpenCode) and each scope in SCOPES (user, rooted at $HOME, or project, rooted at a git top level), it can write an MCP server entry, hooks, an instruction block and a skill, and it records what it wrote in a receipt. It renders text and edits files, and it opens no store itself; doctor starts the MCP server as a child process to check it. It depends on @memhtml/contracts for the recall discipline text, effect for its one error class, and jsonc-parser for JSON edits. Its one user is the CLI in apps/cli: the memhtml integrations commands, the memhtml hook engine that every installed hook runs, and the manifest and argument checks, which read HOSTS and HOOK_EVENTS. The CLI resolves $HOME, the project root and the store root and passes them in.

hostSpec gives the HostSpec for one host at one scope: the absolute path of each host file install touches, how each one is edited (McpTarget, HooksTarget, InstructionTarget), and the host’s manual nextSteps. The hosts differ. Cursor gets no user-level instruction file, so instruction is null there, and at project scope it gets a whole-file rule, .cursor/rules/memhtml.mdc. OpenCode’s hooks are a generated plugin file. Codex keeps its MCP entry in a fenced region of config.toml, a file people also edit by hand. Codex, Cursor and OpenCode share one skill file under .agents/skills/memhtml/. detectHosts returns, in HOSTS order, the hosts whose directory in DETECT_DIRS exists under $HOME, which is how memhtml integrations install with no host picks. DEFAULT_TRACE_ROOT gives only Claude Code a default transcript root, ~/.claude, and the indexing hooks are written only when a root is known.

cliCommand, mcpCommand and hookCommand build every CommandLine from one BinaryLocation. By default the recorded node runs the recorded entry script, so the command does not depend on PATH; with bare the command is memhtml or memhtml-mcp. A hook runs the CLI with hook <event> --host <host> --repo <store>, plus --trace-root when HookCommandOptions names one. shellJoin and shellQuote turn a line into the shell string that Claude Code, Codex and Cursor store. claudeMcpEntry, cursorMcpEntry, opencodeMcpEntry and codexMcpToml render the MCP entry with the env they are given, which install fills with MEMHTML_ROOT only. In mode none, claudeHooks, codexHooks and cursorHooks return no hooks. Otherwise claudeHooks returns SessionStart, and in mode all it adds UserPromptSubmit, plus PreCompact and SessionEnd when there is a transcript root; codexHooks returns the first two only, and cursorHooks returns sessionStart alone. claudeOwned, also exported as codexOwned and cursorOwned, decides whether a hooks entry is ours: every command in it must have the hook <event> --host <host> shape and name the recorded entry script, or start with memhtml hook when installed bare.

renderOpenCodePlugin is OpenCode’s hook surface: a generated module that imports only node:child_process and runs the recorded command itself. It wires chat.message for per-prompt recall in mode all, experimental.session.compacting to add the session-start pack to a compaction, and a session.idle indexer only when there is a transcript root. In the plugin, any error, nonzero exit or 1500 ms timeout counts as no output. renderInstructionBlock is the body of the block install fences into a host’s instruction file: the store path, how to reach it, and the recall discipline from @memhtml/contracts/guidance. renderCursorRules wraps the same body as a Cursor rule with alwaysApply: true, and renderClaudeImportBlock is the @AGENTS.md import line. renderSkill renders SKILL.md with the same discipline and a table of CLI commands and MCP tools. renderShellSnippet and rcFileFor serve memhtml integrations shell, whose snippet exports MEMHTML_ROOT and adds a directory to PATH for a person at a terminal.

renderHookOutput is what memhtml hook prints for each host and event. Claude Code and OpenCode get plain text, Codex gets hookSpecificOutput.additionalContext JSON with the event names in CODEX_EVENT_NAMES, and Cursor gets an additional_context JSON object at session start. It returns the empty string for blank text and for an event the host takes no context on: compaction and session end on every host, and per-prompt on Cursor. promptOf and cwdOf read the prompt and the working directory from a host’s hook payload, and cwdOf falls back to the first of Cursor’s workspace_roots.

The file layer edits a shared file’s text rather than parsing it and printing it again. upsertJsonPath, removeJsonPath, upsertArrayEntries and removeArrayEntries edit JSON or JSONC as text splices at jsonc-parser offsets, so comments, key order and the layout of other members stay as they were, and a document that does not parse is left without the edit. A removal that empties a nested object, such as mcpServers, takes that object out too, with any comment inside it. fenced builds the region operations behind the Markdown block (upsertFencedBlock), the Codex TOML region (upsertFencedToml) and the shell rc region (upsertShellFence): replace the lines between two marker lines, or append a new region. Duplicated, unpaired or out-of-order markers throw instead of being repaired. hasUnfencedTable finds a hand-written [mcp_servers.memhtml] table outside the fence. writeTextAtomic writes a temporary file beside the target and renames it into place, keeping an existing file’s permission bits, so a reader of that file sees the old text or the new text. snapshotText and restoreText save and put back a file’s text and mode. assertNoSymlinkComponents, which install runs on every target and on the receipt path, refuses a path when any part of it that exists is a symlink. sha256 is the hash behind every receipt claim.

A receipt records what install owns, one per host per scope, at the path receiptPath names: ~/.config/memhtml/integrations/<host>.json at user scope and .memhtml-integrations/<host>.json at a project’s root. Each ReceiptEntry holds a path, a role from MANAGED_ROLES, a kind and the SHA-256 of the owned bytes. A file entry owns the whole file. A fragment entry owns a JSON key, a fenced region or the memhtml entries in a hooks object, inside a file other tools also write. inspectReceiptFile tells an absent receipt from a valid or an invalid one. Invalid covers text that is not a receipt, an entry path outside the receipt’s root, and a receipt stored where its own host, scope and root would not put it. readReceipt folds absent and invalid into null, for readers that only display. compareEntries re-hashes each entry through a reader the caller supplies and answers installed or modified, with a ReceiptDrift row for each entry that changed or is gone. receiptDriftReader is that reader, built from the receipt alone through editorFor, and install, uninstall, listIntegrations and doctor all use it.

planInstall turns InstallOptions into an InstallPlan: one PlannedWrite per artifact, each carrying pure apply, strip and ownedNow functions of a file’s current text. It reads disk only for the existing receipt and, for Claude Code at project scope, for AGENTS.md and CLAUDE.md: when AGENTS.md exists, the block goes there and CLAUDE.md gets an @AGENTS.md import, unless it already holds only that line. install runs one plan, for one host at one scope. It throws IntegrationModified and writes nothing when this host’s receipt cannot be read or an entry in it no longer matches the disk, when something already sits where an artifact goes and that receipt does not claim it, or when Codex’s config.toml has a hand-written memhtml table. With force it goes ahead and keeps the old text beside the file, in a copy named with .memhtml-backup- and a timestamp. An artifact whose bytes already equal the plan is not refused, which is how three hosts share one skill file. Then it snapshots every target, writes the files, and writes the receipt last. On a throw in that phase it restores the snapshots as best it can, removes its backups and rethrows. With dryRun it returns the InstallReport with each file’s new text in contents and writes nothing. A run that would change nothing writes nothing, not even the receipt, and reports changed: false. A re-run with a narrower hooks mode removes, in the same call, the hooks that mode no longer wants.

uninstall removes what one host’s receipt at one scope claims. It re-hashes every entry first and throws IntegrationModified, removing nothing, if any entry changed or is gone; it has no force option. It also throws, removing nothing, when the host’s receipt, or another host’s at the same scope, cannot be read. A path that another host’s receipt at the same scope also claims, such as the shared skill, is left as it is. A file that is wholly ours, or that strip leaves empty, is deleted even if it existed before install, and the receipt goes last. With no receipt file the call succeeds as not-installed with changed: false. listIntegrations returns an IntegrationRow for each host at user scope, and at project scope when given a root. The state comes from the receipts and a re-hash of what they claim, never from a config entry that only looks like ours: a hand-written MCP entry with no receipt reads not-installed, and a receipt that cannot be read reads modified.

locateBinary resolves the BinaryLocation an install records: process.execPath, the entry script the caller passes, and the memhtml-mcp script found from it through MCP_CANDIDATES or named by mcpOverride, both scripts with symlinks resolved. It throws rather than return a location with no server. doctor checks one host at one scope against its receipt and returns a DoctorResult of DoctorCheck rows. The recorded binaries must exist, the CLI’s manifest must report the recorded version, the store root must hold .memhtml/, every receipt entry must still match, the hooks, instruction block and skill must be present, and a transcript root that a hook names must be listable. Then the server must complete an MCP initialize handshake and give MCP_SERVER_NAME as its name. An absent or unreadable receipt returns one failing receipt row. The handshake is not attempted, and its row fails, when the store root has no .memhtml/, because starting the server would create one. The two checks that start a process go through DoctorProbes, which default to spawnVersion and spawnHandshake, bounded by VERSION_TIMEOUT_MS and HANDSHAKE_TIMEOUT_MS, and a caller can pass its own. Each probe gets a command line built from the receipt, so after a bare install the defaults run memhtml and memhtml-mcp from doctor’s own PATH, not the recorded scripts. On Codex it adds a hook-trust row that is always ok and only reminds the operator to trust the hooks in Codex.

The package publishes one import path, @memhtml/integrations. Its index.ts re-exports every module with export *, partly through the barrels primitives.ts (the file layer), render.ts (the content layer) and transaction.ts (install, uninstall, list, doctor and locate), so every name in the table is on this page. The inherited signature of IntegrationModified also names YieldableError and MakeOptions, which come from effect.

Module What it holds
types.ts HOSTS, HostId, isHostId, SCOPES, Scope, HOOK_EVENTS, HookEvent, isHookEvent, HOOK_MODES, HookMode, BinaryLocation, CommandLine, InstallOptions, ManagedWrite, MANAGED_ROLES, ManagedRole, Receipt, ReceiptEntry, INSTALL_STATES, InstallState and DoctorCheck.
errors.ts IntegrationModified, a Schema.TaggedError class from effect carrying host, path and detail.
dialect.ts renderHookOutput, CODEX_EVENT_NAMES, promptOf and cwdOf.
hosts.ts McpTarget, HooksTarget, InstructionTarget, HostSpec, DISPLAY_NAMES, DETECT_DIRS, DEFAULT_TRACE_ROOT, CODEX_MCP_TABLE, hostSpec and detectHosts.
command-line.ts cliCommand, mcpCommand, HookCommandOptions, hookCommand, shellQuote and shellJoin.
render/mcp.ts MCP_SERVER_KEY, JsonMcpEntry, OpenCodeMcpEntry, claudeMcpEntry, cursorMcpEntry, opencodeMcpEntry and codexMcpToml.
render/hooks.ts CommandHook, CommandHookGroup, CursorHookEntry, CURSOR_HOOKS_VERSION, claudeHooks, codexHooks, cursorHooks, claudeOwned, codexOwned and cursorOwned.
render/plugin.ts OPENCODE_PLUGIN_FILE and renderOpenCodePlugin.
render/block.ts InstructionBlockOptions, renderInstructionBlock, renderCursorRules and renderClaudeImportBlock.
render/skill.ts SKILL_DIR_NAME, SkillOptions and renderSkill.
shell.ts ShellSnippetOptions, renderShellSnippet and rcFileFor.
fs.ts sha256, readTextOrNull, writeTextAtomic, TextSnapshot, snapshotText, restoreText, isDirectory, exists, realpathOrSelf, assertNoSymlinkComponents and expandHome.
adapters/json.ts readJsonPath, upsertJsonPath, removeJsonPath, upsertArrayEntries, removeArrayEntries and parseJsonc.
adapters/fenced.ts FencedText, fenced and linesOutsideFence.
adapters/markdown.ts BLOCK_START, BLOCK_END, upsertFencedBlock, removeFencedBlock, readFencedBlock and isClaudeImportOnly.
adapters/toml.ts FENCE_START, FENCE_END, upsertFencedToml, removeFencedToml, readFencedToml and hasUnfencedTable.
adapters/shellrc.ts SHELL_FENCE_START, SHELL_FENCE_END, upsertShellFence, removeShellFence and readShellFence.
receipt.ts receiptPath, ReceiptFileInspection, inspectReceiptFile, readReceipt, writeReceipt, removeReceipt, ReceiptDrift, EntryComparison and compareEntries.
install.ts ENTRY_ACTIONS, EntryAction, PlannedWrite, InstallPlan, EntryReport, InstallReport, Editor, editorFor, claimOf, receiptDriftReader, planInstall and install.
uninstall.ts UninstallReport and uninstall.
list.ts IntegrationRow and listIntegrations.
locate.ts MCP_CANDIDATES, LocateInput and locateBinary.
doctor.ts ProbeResult, DoctorProbes, DoctorOptions, DoctorResult, HANDSHAKE_TIMEOUT_MS, VERSION_TIMEOUT_MS, MCP_PROTOCOL_VERSION, MCP_SERVER_NAME, spawnVersion, spawnHandshake, DEFAULT_PROBES, traceRootIn and doctor.
index.ts The re-exports above: dialect.ts, errors.ts, hosts.ts and types.ts directly, the rest through primitives.ts, render.ts and transaction.ts.

A receipt-owned file or entry no longer matches its receipt. Install refuses without --force, and uninstall refuses always, so a human’s edit to a config file survives both. path is the managed file, detail names what differs (a hash, a missing entry, an unmanaged sibling entry).

  • object & YieldableError<this>
new IntegrationModified(...args): IntegrationModified;

…[props: object, options?: MakeOptions]

IntegrationModified

Schema.TaggedError<IntegrationModified>()( "IntegrationModified", { host: Schema.String, path: Schema.String, detail: Schema.String } ).constructor

readonly detail: string = Schema.String;

Schema.TaggedError<IntegrationModified>()( "IntegrationModified", { host: Schema.String, path: Schema.String, detail: Schema.String } ).detail

readonly host: string = Schema.String;

Schema.TaggedError<IntegrationModified>()( "IntegrationModified", { host: Schema.String, path: Schema.String, detail: Schema.String } ).host

readonly path: string = Schema.String;

Schema.TaggedError<IntegrationModified>()( "IntegrationModified", { host: Schema.String, path: Schema.String, detail: Schema.String } ).path

How the installed configs invoke this binary. absolute records process.execPath plus the resolved entry script, so a GUI host with no shell PATH still finds it; bare writes memhtml and memhtml-mcp and leaves PATH to the operator.

readonly cli: string;

The memhtml entry script, absolute and realpath-resolved.

readonly mcp: string;

The memhtml-mcp entry script, absolute and realpath-resolved.

readonly node: string;

The node executable, absolute.

readonly version: string;

The version the binary reports, recorded so doctor can tell an upgrade from a move.


One command handler inside a Claude Code or Codex hook group.

readonly command: string;
readonly timeout: number;

Seconds. A recall hook shares the turn’s latency budget; an indexer does not.

readonly type: "command";

One entry of a Claude Code / Codex event array. matcher is deliberately ABSENT: both hosts treat a missing matcher as “every source”, and writing matcher: "*" would make the entry look like a filter that had been narrowed.

readonly hooks: readonly CommandHook[];

One command line the host will run: the MCP server, or a hook.

readonly args: readonly string[];
readonly command: string;

One entry of a Cursor event array: a bare command string, no group and no timeout field.

readonly command: string;

One row of integrations doctor.

readonly detail: string;
readonly name: string;
readonly ok: boolean;
readonly suggestions: readonly string[];

readonly probes?: Partial<DoctorProbes>;

Override either probe; the rest fall back to the spawning defaults.


The two checks that need a child process.

Split into two named probes rather than one generic spawn, because the two consume their child completely differently — one reads a JSON envelope off a process that exits, the other holds a long-lived stdio server open through a two-message handshake — and a caller handed one function could not tell which behavior a row wanted.

readonly handshake: (mcp, env) => Promise<ProbeResult>;

Complete an MCP initialize handshake with the server this command line names.

CommandLine

Readonly<Record<string, string>>

Promise<ProbeResult>

readonly version: (cli, env) => Promise<ProbeResult>;

Run <cli> manifest and report the version it prints.

CommandLine

Readonly<Record<string, string>>

Promise<ProbeResult>


readonly checks: readonly DoctorCheck[];
readonly healthy: boolean;

How one artifact’s bytes are read back out of a file and removed from it.

readonly ownedNow: (current) => string | null;

string | null

string | null

readonly strip: (current) => string | null;

string | null

string | null


What compareEntries reports: the install state, and each entry whose hash on disk differs from the receipt.

readonly drift: readonly ReceiptDrift[];
readonly state: "not-installed" | "installed" | "modified";

readonly action: "created" | "updated" | "unchanged" | "planned" | "removed";
readonly kind: "file" | "fragment";
readonly path: string;
readonly role:
| "mcp-entry"
| "hooks"
| "instruction-block"
| "skill"
| "plugin"
| "rules";

The three operations a fenced file supports, bound to one pair of markers.

readonly endMarker: string;
readonly has: (text) => boolean;

True when the file carries a well-formed fence. Throws on malformed markers, like the rest.

string | null

boolean

readonly read: (text) => string | null;

The body between the markers, or null when the file carries no fence.

string | null

string | null

readonly remove: (text) => string;

Drop the fenced region and the blank line separating it. "" when the fence was the whole file.

string | null

string

readonly startMarker: string;
readonly upsert: (text, body) => string;

Replace the fenced region with body, or append a fence when the file has none.

string | null

string

string


What a hook command line needs beyond the event: which store, and where transcripts live.

readonly memhtmlRoot: string;

The store the hook reads, passed as the global --repo so the hook never depends on cwd or env.

readonly traceRoot?: string;

Where this host writes transcripts, when a format exists for it; absent omits the flag.


Everything install needs to know about one host at one scope. Absolute paths throughout.

readonly displayName: string;
readonly hooks: HooksTarget;
readonly host: "claude" | "codex" | "cursor" | "opencode";
readonly instruction: InstructionTarget | null;

null for Cursor at user scope: it has no user-level instruction file this build writes.

readonly mcp: McpTarget;
readonly nextSteps: readonly string[];

The host’s manual half: a restart, a trust step, an approval prompt.

readonly root: string;

$HOME at user scope, the git top level at project scope.

readonly scope: "user" | "project";
readonly skill: string;

The SKILL.md install writes, absolute.


readonly bareCommand: boolean;
readonly binary: BinaryLocation;
readonly dryRun: boolean;
readonly force: boolean;
readonly hooks: "all" | "session" | "none";
readonly host: "claude" | "codex" | "cursor" | "opencode";
readonly memhtmlRoot: string;

The store the integration points at. Absolute.

readonly root: string;

$HOME for user scope, the git top level for project scope. Absolute.

readonly scope: "user" | "project";
readonly traceRoot?: string;

Where this host writes transcripts, when known; absent means no indexing hooks.


readonly host: "claude" | "codex" | "cursor" | "opencode";
readonly memhtmlRoot: string;
readonly nextSteps: readonly string[];
readonly previous: Receipt | null;

The receipt as it stands, or null when this host is not installed at this scope.

readonly previousProblem?: string;

Set when a receipt is present but unreadable, which only --force gets past; it is backed up.

readonly receiptPath: string;
readonly root: string;
readonly scope: "user" | "project";
readonly writes: readonly PlannedWrite[];

readonly backups: readonly string[];

Prior bytes kept beside a file --force overwrote, absolute.

readonly changed: boolean;

False when every managed fragment already matched the plan and the receipt already described it.

readonly contents?: Readonly<Record<string, string>>;

The whole new text of every file, dry run only.

readonly dryRun: boolean;
readonly entries: readonly EntryReport[];
readonly host: "claude" | "codex" | "cursor" | "opencode";
readonly memhtmlRoot: string;
readonly nextSteps: readonly string[];
readonly receiptPath: string;
readonly root: string;
readonly scope: "user" | "project";

readonly cli: CommandLine;

How this host invokes the CLI, rendered as the shell string an operator could paste.

readonly hasMcp: boolean;

Whether an MCP entry was installed; when false the block names the CLI only.

readonly host: "claude" | "codex" | "cursor" | "opencode";

The host this block is rendered for. The body is byte-identical across the four hosts today, and the parameter is here so a host-specific line has exactly one place to land rather than being spliced in by whoever needs it first.

readonly memhtmlRoot: string;

The store, absolute, named in the prose so the agent can see which repo it is remembering into.


readonly detail?: string;

What is wrong, on a modified row: the drifted path, or why the receipt cannot be read.

readonly host: "claude" | "codex" | "cursor" | "opencode";
readonly memhtmlRoot?: string;

The store it points at, when a receipt says.

readonly receiptPath: string;
readonly root: string;

$HOME at user scope, the git top level at project scope.

readonly scope: "user" | "project";
readonly state: "not-installed" | "installed" | "modified";
readonly version?: string;

The memhtml version that installed it, when a receipt says.


What the installer merges into mcpServers for Claude Code and Cursor.

readonly args?: readonly string[];

Omitted entirely when the command needs no arguments, rather than written as [].

readonly command: string;
readonly env: Readonly<Record<string, string>>;

readonly entry: string;

The memhtml entry script this process was started as, normally process.argv[1].

readonly mcpOverride?: string;

An explicit memhtml-mcp path, for a deployment that does not keep the two bins together.

readonly version: string;

The version to record, normally the CLI manifest’s.


One thing install writes. kind says how ownership is checked: a file is wholly ours and its SHA-256 is the receipt’s claim; a fragment lives inside a file other tools also own (a JSON key, a TOML fence, a Markdown block, a hooks array entry) and only the fragment’s rendered bytes are claimed.

readonly content: string;

The bytes install owns: the whole file, or the fragment as rendered.

readonly kind: "file" | "fragment";
readonly path: string;

Absolute path of the file touched.

readonly role:
| "mcp-entry"
| "hooks"
| "instruction-block"
| "skill"
| "plugin"
| "rules";

What the fragment is, for reports: mcp-entry, hooks, instruction-block, skill, plugin, rules.


What the installer merges into mcp for OpenCode.

readonly command: readonly string[];

OpenCode’s one-array form: the command and its arguments together.

readonly enabled: boolean;
readonly environment: Readonly<Record<string, string>>;
readonly type: "local";

One artifact, plus the three total functions that make it a transaction step.

readonly apply: (current) => string | null;

The whole new file, given its current text (null when absent); null means the file goes.

string | null

string | null

readonly content: string;

The bytes install owns: the whole file, or the fragment as rendered.

ManagedWrite.content

readonly kind: "file" | "fragment";

ManagedWrite.kind

readonly ownedNow: (current) => string | null;

The bytes we claim as they are on disk NOW, or null when our part is absent.

string | null

string | null

readonly path: string;

Absolute path of the file touched.

ManagedWrite.path

readonly retire?: true;

A write that REMOVES an artifact the previous receipt claims and claims nothing itself: the hooks or plugin of an install being re-run with --hooks none. Reported as removed, absent from the new receipt. Without it the new receipt would drop the entry while the hooks stayed armed on disk, with no record left to remove them by.

readonly role:
| "mcp-entry"
| "hooks"
| "instruction-block"
| "skill"
| "plugin"
| "rules";

What the fragment is, for reports: mcp-entry, hooks, instruction-block, skill, plugin, rules.

ManagedWrite.role

readonly strip: (current) => string | null;

The file with our part removed; null when nothing of the caller’s would be left.

string | null

string | null


What one probe answers. value is the version a manifest probe read, when it read one.

readonly detail: string;
readonly ok: boolean;
readonly value?: string;

The receipt: ownership proof and the record of what to remove. One per host per scope, at receiptPath(...). files maps the absolute path plus role to the SHA-256 of the owned bytes.

readonly bareCommand: boolean;
readonly binary: BinaryLocation;
readonly entries: readonly ReceiptEntry[];
readonly hooks: "all" | "session" | "none";
readonly host: "claude" | "codex" | "cursor" | "opencode";
readonly installedAt: string;
readonly memhtmlRoot: string;
readonly package: "memhtml";
readonly root: string;
readonly scope: "user" | "project";
readonly version: string;

One entry whose bytes on disk no longer hash to what the receipt claims. null means gone.

readonly actual: string | null;
readonly entry: ReceiptEntry;

readonly kind: "file" | "fragment";
readonly path: string;
readonly role:
| "mcp-entry"
| "hooks"
| "instruction-block"
| "skill"
| "plugin"
| "rules";
readonly sha256: string;

What a receipt file turned out to be. problem is present exactly when state is invalid.

readonly problem?: string;
readonly receipt: Receipt | null;
readonly state: "absent" | "valid" | "invalid";

readonly binDir: string;

The directory holding the memhtml and memhtml-mcp binaries, absolute.

readonly memhtmlRoot: string;

The store, absolute.


readonly cli: CommandLine;

How this host invokes the CLI; every row of the command table is rendered from it.

readonly memhtmlRoot: string;

The store, absolute.


What a file looked like before install touched it. text: null records “there was no file”.

readonly mode: number | null;
readonly path: string;
readonly text: string | null;

readonly changed: boolean;

False when there was nothing to remove, so an uninstall is re-runnable and says so.

readonly host: "claude" | "codex" | "cursor" | "opencode";
readonly receiptPath: string;
readonly removed: readonly EntryReport[];
readonly root: string;
readonly scope: "user" | "project";
readonly state: "not-installed" | "installed" | "modified";

not-installed when there was no receipt, installed when the removal ran.

type EntryAction = typeof ENTRY_ACTIONS[number];

type HookEvent = typeof HOOK_EVENTS[number];

type HookMode = typeof HOOK_MODES[number];

type HooksTarget =
| {
file: string;
format: "json-arrays";
keyPath: ReadonlyArray<string>;
versionKey?: {
keyPath: ReadonlyArray<string>;
value: number;
};
}
| {
file: string;
format: "plugin-file";
};

Where a host’s hooks go.

json-arrays is Claude Code, Codex, and Cursor: one object under keyPath whose keys are the host’s own event names and whose values are arrays install appends to entry by entry. versionKey is Cursor’s {"version": 1} preamble, which the file needs and which is not an array.

plugin-file is OpenCode, where the hook surface is a JavaScript module install generates whole.

{
file: string;
format: "json-arrays";
keyPath: ReadonlyArray<string>;
versionKey?: {
keyPath: ReadonlyArray<string>;
value: number;
};
}
readonly file: string;
readonly format: "json-arrays";
readonly keyPath: ReadonlyArray<string>;
readonly versionKey?: object;

A scalar the file must carry beside the hooks, when the host versions its hooks file.

readonly keyPath: ReadonlyArray<string>;
readonly value: number;

{
file: string;
format: "plugin-file";
}

type HostId = typeof HOSTS[number];

type InstallState = typeof INSTALL_STATES[number];

type InstructionTarget =
| {
agentsFile?: string;
file: string;
format: "markdown-block";
}
| {
file: string;
format: "cursor-rules";
};

Where the instruction prose goes.

markdown-block is a fenced region inside a file the operator also writes. cursor-rules is a whole file of ours, because Cursor reads .cursor/rules/*.mdc and each rule file is one rule with its own frontmatter — there is nothing to merge into.

agentsFile is Claude Code’s project-scope fork, and it is present only there. Claude Code reads CLAUDE.md and not AGENTS.md, while every other host in a repository reads AGENTS.md, so a repo that already has one gets the block THERE and a @AGENTS.md import in CLAUDE.md — one copy of the prose, visible to both. The transaction layer decides between the two by looking at disk, since which file exists is not a property of the host.


type ManagedRole = typeof MANAGED_ROLES[number];

type McpTarget =
| {
file: string;
format: "json";
keyPath: ReadonlyArray<string>;
}
| {
file: string;
format: "toml-fence";
table: string;
};

Where a host’s MCP server entry goes, and what kind of document it is.

json carries the key path the entry lands at, so the transaction layer never spells a vendor’s key. toml-fence carries the table NAME instead, which is what hasUnfencedTable needs to refuse a hand-written twin of our entry: the fence cannot see structure, so the collision check is by name.


type Scope = typeof SCOPES[number];
const BLOCK_END: "<!-- MEMHTML:END -->" = "<!-- MEMHTML:END -->";

const BLOCK_START: "<!-- MEMHTML:START -->" = "<!-- MEMHTML:START -->";

const CODEX_EVENT_NAMES: Readonly<Record<HookEvent, string>>;

Codex spells its event names the way Claude Code does.


const CODEX_MCP_TABLE: "mcp_servers.memhtml";

Codex’s table name, the one hasUnfencedTable refuses a hand-written copy of.


const codexOwned: (entry, binary?, bare?) => boolean = claudeOwned;

Codex writes Claude Code’s entry shape, so ownership is decided the same way.

Is this entry from an existing hooks file one of OURS? Used by install to replace and by uninstall to remove, so a human’s own SessionStart hook survives both.

The signal is the invocation itself: the hook <event> --host <host> shape only hookCommand renders, then the program. Installed bare, the program is the literal memhtml the line starts with; installed absolute, it is the entry script the receipt names. EVERY command in the entry must match — a group someone else has added a second handler to is not ours to rewrite — and an entry carrying no command at all is never ours.

unknown

BinaryLocation

boolean

boolean


const CURSOR_HOOKS_VERSION: 1 = 1;

hooks.json for Cursor is versioned; the installer writes this when it creates the file.


const cursorOwned: (entry, binary?, bare?) => boolean = claudeOwned;

Cursor’s entry is a bare { command }, which commandsOf already reads.

Is this entry from an existing hooks file one of OURS? Used by install to replace and by uninstall to remove, so a human’s own SessionStart hook survives both.

The signal is the invocation itself: the hook <event> --host <host> shape only hookCommand renders, then the program. Installed bare, the program is the literal memhtml the line starts with; installed absolute, it is the entry script the receipt names. EVERY command in the entry must match — a group someone else has added a second handler to is not ours to rewrite — and an entry carrying no command at all is never ours.

unknown

BinaryLocation

boolean

boolean


const DEFAULT_PROBES: DoctorProbes;

const DEFAULT_TRACE_ROOT: Readonly<Record<HostId, string | null>>;

Where each host writes transcripts, ~-relative, or null when memhtml cannot read them.

Claude Code alone, because @memhtml/traces reads Claude Code’s JSONL transcript format and no other. A null host installs no indexing hook rather than one with nothing to read; an operator who knows better says so with MEMHTML_TRACE_ROOT, which the CLI passes through for any host.


const DETECT_DIRS: Readonly<Record<HostId, string>>;

The directory whose existence means a host is installed on this machine, relative to $HOME.

integrations install with no host argument wires every host this finds, so the rule has to be one a host creates itself on first run and never as a side effect of something else.


const DISPLAY_NAMES: Readonly<Record<HostId, string>>;

What each host calls itself, for a report a human reads.


const ENTRY_ACTIONS: readonly ["created", "updated", "unchanged", "planned", "removed"];

What happened to one managed path. planned is the dry-run answer and removed is uninstall’s, so one vocabulary describes every row either verb reports.


const FENCE_END: "# MEMHTML:END" = "# MEMHTML:END";

const FENCE_START: "# MEMHTML:START" = "# MEMHTML:START";

const HANDSHAKE_TIMEOUT_MS: 20000 = 20_000;

How long the handshake may take. A stdio server that has not answered by then is not wired.

Twice the version probe, because it does strictly more: initialize is answered only after the server has loaded the CLI’s module graph and opened and migrated the store’s database. On a loaded box (the full mise run check on a 2-vCPU runner) that cold start exceeded 5 s on 2026-09-14 and 2026-09-22, against a server that answered in under a second alone.


const HOOK_EVENTS: readonly ["session-start", "user-prompt-submit", "pre-compact", "session-end"];

The lifecycle events memhtml hook understands, host-neutral. Each host’s installer maps these onto the host’s own event names (SessionStart, sessionStart, session.created, …) and the hook engine maps the host’s stdin payload back.


const HOOK_MODES: readonly ["all", "session", "none"];

Which hooks integrations install writes.


const HOSTS: readonly ["claude", "codex", "cursor", "opencode"];

The four coding agents this package can wire, in the order the docs present them.


const INSTALL_STATES: readonly ["not-installed", "installed", "modified"];

const MANAGED_ROLES: readonly ["mcp-entry", "hooks", "instruction-block", "skill", "plugin", "rules"];

const MCP_CANDIDATES: readonly ["./memhtml-mcp.mjs", "../../mcp/dist/bin.js"];

Where memhtml-mcp sits relative to the memhtml entry script, in each layout that ships.

The same two candidates apps/cli/src/serve.ts walks, and for the same reason: the two apps are one build, and where that build puts them differs. ./memhtml-mcp.mjs is the published package, where both bins are entry points of one bundle and land beside each other; ../../mcp/dist/bin.js is the workspace, where apps/cli/dist/bin.js reaches apps/mcp/dist/bin.js two directories up and across.


const MCP_PROTOCOL_VERSION: "2025-06-18" = "2025-06-18";

The protocol revision memhtml-mcp speaks, and the only adapter it ships.


const MCP_SERVER_KEY: "memhtml" = "memhtml";

The key this entry is stored under in every host’s server map.


const MCP_SERVER_NAME: "memhtml" = "memhtml";

The name serverInfo must carry for the handshake to be with OUR server.


const OPENCODE_PLUGIN_FILE: "memhtml.js" = "memhtml.js";

The file name inside the host’s plugins directory.


const SCOPES: readonly ["user", "project"];

user anchors at $HOME; project anchors at a git repository’s top level.


const SHELL_FENCE_END: "# MEMHTML:END" = "# MEMHTML:END";

const SHELL_FENCE_START: "# MEMHTML:START" = "# MEMHTML:START";

const SKILL_DIR_NAME: "memhtml" = "memhtml";

The skill’s directory name, which must equal the frontmatter name.


const spawnHandshake: DoctorProbes["handshake"];

Spawn the recorded server and complete initialize.

The check that catches a config which READS correctly and starts nothing: a moved entry script, a node that no longer exists, a bundle missing an asset. Every failure mode resolves rather than rejects, so one bad row cannot take the report down with it, and the child is killed on every path — a doctor that left a server holding the store’s database open would be worse than no doctor.


const spawnVersion: DoctorProbes["version"];

<cli> manifest, read for its version.

manifest is the one command that answers with no store, no database, and no credential, which is why it is the probe: a version check that opened the store would fail for reasons about the store.


const VERSION_TIMEOUT_MS: 10000 = 10_000;

How long the version probe may take. manifest opens nothing, so this is generous already.

function assertNoSymlinkComponents(path): Promise<void>;

Refuse path when any component that already exists is a symlink.

Walking stops at the first component that does not exist, because nothing below a missing directory exists either. The rule is openwiki’s, learned the hard way: an installer must never follow a symlink into a place it did not intend to write — a ~/.claude linked into a dotfiles repo turns a config edit into a commit in someone else’s tree.

string

Promise<void>


function claimOf(
receipt,
path,
role
): ReceiptEntry | undefined;

Which entry of a receipt claims this path and role, if any.

Receipt | null

string

| "mcp-entry" | "hooks" | "instruction-block" | "skill" | "plugin" | "rules"

ReceiptEntry | undefined


function claudeHooks(
binary,
bare,
mode,
opts
): Record<string, CommandHookGroup[]>;

Claude Code’s hooks object in ~/.claude/settings.json (or .claude/settings.json at project scope). PreCompact and SessionEnd appear only when a transcript root is known, since the indexer has nothing to read without one.

BinaryLocation

boolean

"all" | "session" | "none"

HookCommandOptions

Record<string, CommandHookGroup[]>


function claudeMcpEntry(mcp, env): JsonMcpEntry;

Claude Code’s mcpServers.memhtml. args is omitted when empty because claude mcp get prints the entry back and an empty array reads as a truncated command.

CommandLine

Readonly<Record<string, string>>

JsonMcpEntry


function claudeOwned(
entry,
binary?,
bare?
): boolean;

Is this entry from an existing hooks file one of OURS? Used by install to replace and by uninstall to remove, so a human’s own SessionStart hook survives both.

The signal is the invocation itself: the hook <event> --host <host> shape only hookCommand renders, then the program. Installed bare, the program is the literal memhtml the line starts with; installed absolute, it is the entry script the receipt names. EVERY command in the entry must match — a group someone else has added a second handler to is not ours to rewrite — and an entry carrying no command at all is never ours.

unknown

BinaryLocation

boolean

boolean


function cliCommand(binary, bare): CommandLine;

The memhtml CLI. bare writes memhtml and leaves resolution to PATH; the default runs the recorded node against the recorded entry script, which is what survives a GUI host with no shell PATH.

BinaryLocation

boolean

CommandLine


function codexHooks(
binary,
bare,
mode,
opts
): Record<string, CommandHookGroup[]>;

Codex’s hooks object in ~/.codex/hooks.json. Same entry shape and event names as Claude Code’s, and no compaction hook: there is no Codex transcript reader to index with.

The entries stay inert until the operator trusts them in /hooks — trust is keyed to the hook’s hash, so nothing an installer writes can pre-approve itself. integrations doctor says so rather than pretending to verify it.

BinaryLocation

boolean

"all" | "session" | "none"

HookCommandOptions

Record<string, CommandHookGroup[]>


function codexMcpToml(mcp, env): string;

Codex’s [mcp_servers.memhtml] table plus its env sub-table, as the text the installer places inside its # MEMHTML:START / # MEMHTML:END fence in ~/.codex/config.toml.

The env table is a separate [mcp_servers.memhtml.env] header rather than an inline table, so a long absolute path never runs past a line the operator has to read. It comes LAST, because every key after a table header belongs to that table.

CommandLine

Readonly<Record<string, string>>

string


function compareEntries(entries, current): Promise<EntryComparison>;

Hash every entry against disk and report installed or modified.

current is supplied by the caller because only it knows how to extract a fragment — the SHA-256 of a whole file for kind: "file", of the rendered fence or JSON entry for kind: "fragment" — and it answers null when the file or the fragment is gone. A missing fragment is drift rather than a separate state: from the operator’s side, “someone deleted our hook” and “someone edited our hook” both mean the config no longer matches the receipt, and both must block an uninstall that would delete their work.

not-installed is never returned here. That is the absence of a receipt, which is the caller’s question, not this function’s.

readonly ReceiptEntry[]

(entry) => Promise<string | null>

Promise<EntryComparison>


function cursorHooks(
binary,
bare,
mode,
opts
): Record<string, CursorHookEntry[]>;

Cursor’s hooks object in ~/.cursor/hooks.json, whose file wraps this as { version: CURSOR_HOOKS_VERSION, hooks: … }. One event only: sessionStart returns additional_context, and it is the sole Cursor event that can put anything in front of the model.

BinaryLocation

boolean

"all" | "session" | "none"

HookCommandOptions

Record<string, CursorHookEntry[]>


function cursorMcpEntry(mcp, env): JsonMcpEntry;

Cursor’s mcp.json takes the same object as Claude Code’s, key for key.

CommandLine

Readonly<Record<string, string>>

JsonMcpEntry


function cwdOf(payload): string | undefined;

The working directory a session runs in, when the payload carries one. Claude Code and Codex send cwd; Cursor sends workspace_roots (the first is used); the OpenCode plugin sends cwd.

unknown

string | undefined


function detectHosts(home): Promise<readonly ("claude" | "codex" | "cursor" | "opencode")[]>;

Which hosts this machine has, in HOSTS order, by DETECT_DIRS.

A directory rather than a binary on PATH: three of the four hosts ship as a GUI app or through a version manager, so the config directory is the artifact that exists on every install path. A host whose directory is absent is not wired, which is what makes integrations install with no argument a statement about this machine rather than a guess.

string

Promise<readonly ("claude" | "codex" | "cursor" | "opencode")[]>


function doctor(
host,
scope,
root,
opts?
): Promise<DoctorResult>;

Every check for one host at one scope.

An absent or unreadable receipt answers with that ONE row and stops: nothing else is knowable without it, and a report of twelve failures whose single cause is “not installed” is a report nobody reads to the end.

"claude" | "codex" | "cursor" | "opencode"

"user" | "project"

string

DoctorOptions = {}

Promise<DoctorResult>


function editorFor(
spec,
entry,
binary,
bare
): Editor;

The editor for one receipt entry, derived from the host’s spec and the receipt’s own binary.

The binary comes from the RECEIPT rather than from what resolves today, because ownership of a hooks entry is decided by the command line that was written: an upgrade moves the entry script, and an entry matched against the new path would read as somebody else’s.

HostSpec

Pick<ReceiptEntry, "role" | "kind">

BinaryLocation

boolean

Editor


function exists(path): Promise<boolean>;

True when something exists at path. lstat, so a dangling symlink counts as existing — install must treat a broken link as an obstacle rather than as free space.

string

Promise<boolean>


function expandHome(path, home): string;

~ and ~/… resolved against home. Nothing else is expanded: $VAR and ~other are left alone, since a config value that looks like a shell expression is not one by the time this package sees it.

string

string

string


function fenced(startMarker, endMarker): FencedText;

Build the fence operations for one marker pair. startMarker and endMarker are matched against a line’s trimmed content, so an indented marker still counts — the markers our own renderers write are flush left, but a hand-reindented file must not silently grow a second region.

string

string

FencedText


function hasUnfencedTable(text, table): boolean;

True when a [table] or [table.child] header appears outside our fence — the operator’s own entry.

Called with mcp_servers.memhtml, so it also catches [mcp_servers.memhtml.env] written by hand without its parent, which is legal TOML and would still collide with the block we render.

string | null

string

boolean


function hookCommand(
binary,
bare,
event,
host,
opts
): CommandLine;

One installed hook’s command line: the CLI, then hook <event> --host <host> --repo <root>.

--repo is always written even though the store could come from MEMHTML_ROOT, because a hook is spawned by the host, not by a shell, and three of the four hosts do not pass a shell environment through. --trace-root appears only when the caller knows where this host’s transcripts land.

BinaryLocation

boolean

"session-start" | "user-prompt-submit" | "pre-compact" | "session-end"

"claude" | "codex" | "cursor" | "opencode"

HookCommandOptions

CommandLine


function hostSpec(
host,
scope,
root
): HostSpec;

The plan for one host at one scope.

root is $HOME for user and the git top level for project, and nothing here reads the other one: a project install is entirely inside the repository, which is what lets it be committed.

"claude" | "codex" | "cursor" | "opencode"

"user" | "project"

string

HostSpec


function inspectReceiptFile(path): Promise<ReceiptFileInspection>;

Absent, valid, or invalid-with-a-reason. The only reader that can tell those three apart.

string

Promise<ReceiptFileInspection>


function install(options): Promise<InstallReport>;

Wire one host at one scope.

The order is the contract, and each step exists because skipping it produced a real failure mode: validate the root, refuse a symlinked path, re-hash the receipt, refuse an artifact we do not own, then — only then — snapshot, write, and record.

InstallOptions

Promise<InstallReport>


function isClaudeImportOnly(text): boolean;

True when the file is nothing but Claude Code’s @AGENTS.md import.

Such a CLAUDE.md is a pointer, not a document: the operator has chosen to keep one instruction file and have Claude Code import it. Writing our block into the pointer would duplicate what AGENTS.md already carries, so install writes to AGENTS.md and leaves this file untouched — openwiki’s issue #841, where the installer helpfully appended to the import file and every session then saw the block twice.

string | null

boolean


function isDirectory(path): Promise<boolean>;

True when path resolves to a directory. A missing path is false, not an error.

string

Promise<boolean>


function isHookEvent(value): value is "session-start" | "user-prompt-submit" | "pre-compact" | "session-end";

string

value is “session-start” | “user-prompt-submit” | “pre-compact” | “session-end”


function isHostId(value): value is "claude" | "codex" | "cursor" | "opencode";

string

value is “claude” | “codex” | “cursor” | “opencode”


function linesOutsideFence(
text,
startMarker,
endMarker
): readonly string[];

The lines of text that sit OUTSIDE the fence. Used by the TOML adapter to tell an entry we own from one the operator wrote by hand. Malformed markers are tolerated here — this is a read used to build a refusal message, and it must not throw before the caller can report anything.

string | null

string

string

readonly string[]


function listIntegrations(home, projectRoot?): Promise<readonly IntegrationRow[]>;

Every host at user scope, and every host at project scope when a repository root is given.

HOSTS order throughout, so two runs on one machine read the same way and a diff of the output is a diff of the states.

string

string

Promise<readonly IntegrationRow[]>


function locateBinary(input): Promise<BinaryLocation>;

The binary an install records.

Both entry scripts are realpath-resolved, so a config written through a version manager’s shim names the file rather than the shim — a shim that moves on the next nvm use leaves a host config pointing at nothing, and the failure surfaces as an MCP server that will not start.

A missing server script throws a plain Error naming pnpm build rather than returning a partial location. An install that wired hooks and no MCP entry would be a half-integration whose receipt claims a server nothing can spawn, which is exactly the state doctor exists to find and which install should never create.

LocateInput

Promise<BinaryLocation>


function mcpCommand(binary, bare): CommandLine;

The memhtml-mcp stdio server, the command a host’s mcpServers entry spawns.

BinaryLocation

boolean

CommandLine


function opencodeMcpEntry(mcp, env): OpenCodeMcpEntry;

OpenCode’s mcp.memhtml: one command array, environment rather than env, explicitly enabled.

CommandLine

Readonly<Record<string, string>>

OpenCodeMcpEntry


function parseJsonc(text): unknown;

The whole document as a value, or undefined when it does not parse. Comments are allowed.

string

unknown


function planInstall(options): Promise<InstallPlan>;

Every artifact one install would write, as pure functions, plus the receipt it would replace.

Reads disk in exactly two places, and both are questions a renderer cannot answer: whether this host is already installed (the receipt, which decides which command lines count as ours), and whether a project already keeps its instructions in AGENTS.md (which decides where Claude Code’s block lands).

InstallOptions

Promise<InstallPlan>


function promptOf(payload): string | undefined;

The prompt text inside a host’s hook payload, for user-prompt-submit. Claude Code and Codex both send prompt; the OpenCode plugin sends the same key by construction. Absent or blank means the hook has nothing to search for and prints nothing.

unknown

string | undefined


function rcFileFor(shell, home): string;

The rc file to append to, from $SHELL. zsh reads ~/.zshrc for interactive shells and bash reads ~/.bashrc; an unknown or absent $SHELL gets ~/.bashrc, which is the safer guess because bash is the shell a script inherits.

string | undefined

string

string


function readFencedBlock(text): string | null;

The body between the markers, or null when the file carries no block. Throws on malformed markers.

string | null

string | null


function readFencedToml(text): string | null;

The TOML we own, or null when the file carries no fence. Throws on malformed markers.

string | null

string | null


function readJsonPath(text, segments): unknown;

The value at segments, or undefined when the path is absent or the document does not parse. A JSON null comes back as null, so absent and present-but-null stay distinguishable.

string | null

readonly (string | number)[]

unknown


function readReceipt(path): Promise<Receipt | null>;

The receipt, or null when there is none or it cannot be trusted.

string

Promise<Receipt | null>


function readShellFence(text): string | null;

The snippet we own, or null when the rc file carries no fence. Throws on malformed markers.

string | null

string | null


function readTextOrNull(path): Promise<string | null>;

The file’s text, or null when it does not exist. Any other failure — a directory in the way, a permission error — throws, because “unreadable” and “absent” lead to different installer decisions.

string

Promise<string | null>


function realpathOrSelf(path): Promise<string>;

path with every symlink resolved, or path itself when it cannot be resolved (it may not exist).

string

Promise<string>


function receiptDriftReader(receipt): (entry) => Promise<string | null>;

Re-hash every receipt claim against disk.

Handed to compareEntries, which is what turns the result into installed or modified. The reader is built from the receipt alone — its host, scope, root, binary — so an entry the CURRENT plan no longer renders is still checked, which is what stops an uninstall from deleting a leftover a human has since edited.

Receipt

(entry) => Promise<string | null>


function receiptPath(
scope,
root,
host
): string;

User scope hides the receipt under .config; project scope keeps it visible at the repo root.

"user" | "project"

string

"claude" | "codex" | "cursor" | "opencode"

string


function removeArrayEntries(
text,
segments,
owned
): string;

Drop every entry owned matches from the array at segments, and drop the key itself once nothing is left — an uninstall that leaves "SessionStart": [] behind has not restored the file.

string | null

readonly (string | number)[]

(entry) => boolean

string


function removeFencedBlock(text): string;

Drop the managed block. Returns "" when the block was the entire file, which is the caller’s signal that it may delete the file instead of leaving an empty one behind.

string | null

string


function removeFencedToml(text): string;

Drop the fenced region and the blank line before it. A file that was only the fence becomes "".

string | null

string


function removeJsonPath(text, segments): string;

Remove segments. A path that is already absent is a no-op, not an error.

string | null

readonly (string | number)[]

string


function removeReceipt(path): Promise<void>;

Delete the receipt. Absent is success: uninstall must be re-runnable.

string

Promise<void>


function removeShellFence(text): string;

Drop the fenced snippet and its separating blank line. "" when the fence was the whole file.

string | null

string


function renderClaudeImportBlock(): string;

The body for a project CLAUDE.md when the block itself lives in AGENTS.md.

Claude Code does not read AGENTS.md; it reads CLAUDE.md, where a line beginning @ is an IMPORT (resolved up to four hops deep). A Markdown link would render as text the model may or may not follow, which is why this is @AGENTS.md and not [AGENTS.md](./AGENTS.md).

string


function renderCursorRules(block): string;

The Cursor rules file. Cursor reads .cursor/rules/*.mdc and IGNORES a plain .md, and only alwaysApply: true puts a rule in front of every request rather than waiting for its description to match. The description is quoted because it contains a colon-space, which a plain YAML scalar cannot.

string

string


function renderHookOutput(
host,
event,
text
): string;

How each host reads a hook’s stdout, per event, as of the vendor docs read 2026-09-13.

Claude Code: plain stdout enters the model’s context on SessionStart and UserPromptSubmit; the other events ignore stdout (a PreCompact/SessionEnd hook can only observe). Codex: the same two events accept hookSpecificOutput.additionalContext JSON, which is also valid Claude Code output, so Codex gets the JSON form to be safe on both. Cursor: sessionStart returns {additional_context}; beforeSubmitPrompt cannot inject at all, so a Cursor per-prompt hook is never installed and the dialect returns nothing for it. OpenCode: the generated plugin calls this binary itself and pushes the text as a message part, so the plugin wants plain text.

An empty text renders as an empty string on every host: a hook that has nothing to add says nothing, which every host treats as “no change”.

"claude" | "codex" | "cursor" | "opencode"

"session-start" | "user-prompt-submit" | "pre-compact" | "session-end"

string

string


function renderInstructionBlock(opts): string;

The block body: what the store is, how to reach it, the five discipline rules, and where the text came from. Short by construction — under forty lines — because it is prepended to every session on hosts that read their instruction file eagerly.

InstructionBlockOptions

string


function renderOpenCodePlugin(
binary,
bare,
mode,
opts
): string;

Render the plugin for one install.

mode prunes handlers rather than flags them off: all wires the per-message recall, the compaction context, and the idle indexer; session drops the per-message recall (the compaction and idle handlers remain, since neither runs per turn); none renders NOTHING — the empty string, so the installer writes no plugin file at all. The idle handler exists only when a transcript root is known, because indexing needs something to read.

BinaryLocation

boolean

"all" | "session" | "none"

HookCommandOptions

string


function renderShellSnippet(opts): string;

Two exports and two comments. The PATH line is guarded with a case on :$PATH: rather than appended unconditionally, because an rc file is sourced once per shell and a nested shell would otherwise accumulate the same directory over and over.

ShellSnippetOptions

string


function renderSkill(opts): string;

SkillOptions

string


function restoreText(snapshot): Promise<void>;

Put path back exactly as snapshotText found it. A snapshot of an absent file removes whatever is there now, so a rolled-back install leaves no file it created — the case that makes a failed install indistinguishable from one that never ran.

TextSnapshot

Promise<void>


function sha256(text): string;

Hex SHA-256 of text as UTF-8. Every receipt claim and every drift check is this one function.

string

string


function shellJoin(line): string;

A command line as one shell string. Claude Code, Codex, and Cursor all store a hook as a command STRING and run it through a shell, so an unquoted space in the binary path is a two-word command that silently does nothing. The OpenCode plugin gets the argv array instead and never needs this.

CommandLine

string


function shellQuote(value): string;

One argument, quoted for sh. A single quote cannot be escaped inside single quotes, so it is spliced as '"'"': close, one double-quoted quote, reopen.

string

string


function snapshotText(path): Promise<TextSnapshot>;

Capture path’s bytes and permission bits, or the fact that it does not exist.

string

Promise<TextSnapshot>


function traceRootIn(text): string | undefined;

The transcript root an installed hook names, or undefined when none does.

Read out of the installed command line rather than out of the receipt, because the receipt has no field for it: the hook line IS the record of what install decided, in both spellings it ships — a shell string for the three hosts that store a hook as text, and a JSON argv array inside the OpenCode plugin.

string

string | undefined


function uninstall(
host,
scope,
root
): Promise<UninstallReport>;

Unwire one host at one scope.

Absent receipt is SUCCESS with state: "not-installed", so the call is re-runnable and a machine-teardown script does not have to ask first.

"claude" | "codex" | "cursor" | "opencode"

"user" | "project"

string

Promise<UninstallReport>


function upsertArrayEntries(
text,
segments,
entries,
owned
): string;

Rewrite the array at segments as “everything the operator put there, then everything we put there”.

This is how a hooks array is edited: owned recognizes our own entries by their command string, they are dropped, and entries is appended. Re-running install is therefore idempotent — the second run removes exactly what the first added and appends the same thing — and a hook the operator wrote by hand keeps its position among the survivors AND its own bytes, down to whether it was written on one line.

An existing value that is not an array is treated as absent and replaced, since a hooks.SessionStart that is an object is not something we can merge into.

string | null

readonly (string | number)[]

readonly unknown[]

(entry) => boolean

string


function upsertFencedBlock(text, body): string;

Replace the managed block with body, or append it after a blank line. The markers each occupy their own line so the body is addressable by line number in a diff. Malformed or duplicated markers throw.

string | null

string

string


function upsertFencedToml(text, block): string;

Replace the fenced region with block, or append one after a blank line. Malformed markers — a start with no end, a duplicate of either — throw an Error naming which marker is wrong and how many were found, since a repair guess can delete the operator’s own tables.

string | null

string

string


function upsertJsonPath(
text,
segments,
value
): string;

Set segments to value, creating every missing intermediate object on the way. Comments, key order, and the existing indentation of untouched lines all survive, and a document that does not parse is left exactly as it is rather than rewritten into one that does.

string | null

readonly (string | number)[]

unknown

string


function upsertShellFence(text, body): string;

Replace the fenced snippet with body, or append it after a blank line. Malformed markers throw.

string | null

string

string


function writeReceipt(path, receipt): Promise<void>;

Write the receipt atomically, pretty-printed, so a diff of one reads as a diff of one thing.

string

Receipt

Promise<void>


function writeTextAtomic(
path,
text,
mode?
): Promise<void>;

Write text to path so a concurrent reader sees either the old file or the new one: parent directories are created, the bytes go to a temp sibling, and rename publishes them.

The temp file is a sibling rather than a $TMPDIR entry on purpose — rename across filesystems fails with EXDEV, and ~/.claude.json and /tmp are routinely on different ones.

With mode omitted an existing file’s permission bits carry over, which matters for the shell rc files and any config an operator has deliberately tightened. chmod happens on the temp file before the rename, so the mode is never briefly wrong at the real path, and it is set explicitly rather than through writeFile’s mode option because that one is masked by the process umask.

string

string

number

Promise<void>