fix(node): mirror official Gemini hooks

This commit is contained in:
ZacharyZhang-NY committed 2026-07-20 07:55:24 -04:00
1 parent 48a830ff4d
commit 2d8111d823
15 files changed
+1999 -144

No files matched your search

+211
View File
@@ -0,0 +1,211 @@
import {
applyEdits,
modify,
parse,
parseTree,
printParseErrorCode,
type FormattingOptions,
type JSONPath,
type Node,
type ParseError,
} from 'jsonc-parser';
import {
MANAGED_EVENTS,
managedGeminiRemovals,
readGeminiHookControls,
readGeminiHooks,
type GeminiGroup,
type GeminiHookControls,
type GeminiHooks,
type ManagedGeminiEvent,
} from './gemini-contract.js';
import { isObject, type JsonObject } from './strict-json.js';
export const GEMINI_OWNED_FILE_MARKER = '// Managed by Elydora';
export interface GeminiDocument {
readonly filePath: string;
readonly exists: boolean;
readonly raw: string;
readonly root: JsonObject;
readonly hooks: GeminiHooks;
readonly hookControls: GeminiHookControls;
readonly hasHooksContainer: boolean;
readonly ownedFile: boolean;
}
export interface RenderedGeminiDocument {
readonly document: GeminiDocument;
readonly changed: boolean;
readonly next?: string;
}
interface DocumentOptions {
readonly exists: boolean;
readonly filePath: string;
readonly raw: string;
}
function rejectDuplicateKeys(
node: Node | undefined,
label: string,
location: string[] = [],
): void {
if (!node) return;
if (node.type === 'object') {
const keys = new Set<string>();
for (const property of node.children ?? []) {
const key = String(property.children?.[0]?.value);
const childLocation = [...location, key];
if (keys.has(key)) {
throw new Error(`${label} contains duplicate field "${childLocation.join('.')}"`);
}
keys.add(key);
rejectDuplicateKeys(property.children?.[1], label, childLocation);
}
return;
}
if (node.type === 'array') {
for (const child of node.children ?? []) rejectDuplicateKeys(child, label, location);
}
}
function parseJsoncObject(raw: string, label: string): JsonObject {
const errors: ParseError[] = [];
const value: unknown = parse(raw, errors, {
allowTrailingComma: false,
disallowComments: false,
});
if (errors.length > 0) {
const details = errors
.map((error) => `${printParseErrorCode(error.error)} at offset ${error.offset}`)
.join(', ');
throw new Error(`Failed to parse ${label}: ${details}`);
}
if (!isObject(value)) throw new Error(`${label} must contain a JSON object`);
rejectDuplicateKeys(parseTree(raw, [], {
allowTrailingComma: false,
disallowComments: false,
}), label);
return value;
}
export function parseGeminiDocument(options: DocumentOptions): GeminiDocument {
const label = `Gemini CLI user settings at ${options.filePath}`;
const root = parseJsoncObject(options.raw, label);
const hasHooksContainer = Object.hasOwn(root, 'hooks');
return {
...options,
root,
hooks: readGeminiHooks(root.hooks),
hookControls: readGeminiHookControls(root.hooksConfig),
hasHooksContainer,
ownedFile: options.raw.startsWith(GEMINI_OWNED_FILE_MARKER),
};
}
export function createGeminiDocument(filePath: string): GeminiDocument {
return parseGeminiDocument({
exists: false,
filePath,
raw: `${GEMINI_OWNED_FILE_MARKER}\n{}\n`,
});
}
function formatting(raw: string): FormattingOptions {
const indentation = /\r?\n([ \t]+)\S/.exec(raw)?.[1];
const insertSpaces = !indentation?.includes('\t');
return {
eol: raw.includes('\r\n') ? '\r\n' : '\n',
insertSpaces,
tabSize: insertSpaces ? Math.max(1, indentation?.length ?? 2) : 1,
};
}
function change(
raw: string,
jsonPath: JSONPath,
value: unknown,
isArrayInsertion = false,
): string {
return applyEdits(raw, modify(raw, jsonPath, value, {
formattingOptions: formatting(raw),
isArrayInsertion,
}));
}
function currentDocument(document: GeminiDocument, raw: string): GeminiDocument {
return parseGeminiDocument({
exists: document.exists,
filePath: document.filePath,
raw,
});
}
function removeManagedEntries(
document: GeminiDocument,
raw: string,
agentId?: string,
): string {
const removals = managedGeminiRemovals(document.hooks, agentId);
for (const event of MANAGED_EVENTS) {
const eventRemovals = removals
.filter((removal) => removal.event === event)
.sort((left, right) => right.groupIndex - left.groupIndex);
for (const removal of eventRemovals) {
const groupPath: JSONPath = ['hooks', event, removal.groupIndex];
if (removal.removeGroup) {
raw = change(raw, groupPath, undefined);
continue;
}
for (const handlerIndex of [...removal.handlerIndexes].sort(
(left, right) => right - left,
)) {
raw = change(raw, [...groupPath, 'hooks', handlerIndex], undefined);
}
}
if (eventRemovals.length > 0) {
const current = currentDocument(document, raw);
if ((current.hooks[event] ?? []).length === 0) {
raw = change(raw, ['hooks', event], undefined);
}
}
}
const current = currentDocument(document, raw);
if (current.hasHooksContainer && Object.keys(current.hooks).length === 0) {
raw = change(raw, ['hooks'], undefined);
}
return raw;
}
function appendGroup(
document: GeminiDocument,
raw: string,
event: ManagedGeminiEvent,
group: GeminiGroup,
): string {
const current = currentDocument(document, raw);
if (current.hooks[event]) {
return change(raw, ['hooks', event, current.hooks[event].length], group, true);
}
return change(raw, ['hooks', event], [group]);
}
export function renderGeminiDocument(
document: GeminiDocument,
agentId: string | undefined,
additions: ReadonlyMap<ManagedGeminiEvent, GeminiGroup>,
): RenderedGeminiDocument {
let raw = removeManagedEntries(document, document.raw, agentId);
for (const event of MANAGED_EVENTS) {
const group = additions.get(event);
if (group) raw = appendGroup(document, raw, event, group);
}
const current = currentDocument(document, raw);
if (additions.size === 0
&& document.ownedFile
&& Object.keys(current.root).length === 0) {
return { document, changed: true, next: undefined };
}
return { document, changed: raw !== document.raw, next: raw };
}