feat: sync Node Qwen Code hook integration

This commit is contained in:
ZacharyZhang-NY committed 2026-07-19 12:20:39 -04:00
1 parent 937bda3589
commit 52863399d5
14 files changed
+1659 -2

No files matched your search

+237
View File
@@ -0,0 +1,237 @@
import {
applyEdits,
modify,
parse,
parseTree,
printParseErrorCode,
type FormattingOptions,
type JSONPath,
type Node,
type ParseError,
} from "jsonc-parser";
import {
TOOL_EVENTS,
type JsonObject,
type QwenGroup,
type QwenHookSettings,
type ToolEvent,
isObject,
managedRemovals,
readHooks,
} from "./qwen-contract.js";
export const OWNED_FILE_MARKER = "// Managed by Elydora";
export interface QwenDocument {
readonly filePath: string;
readonly exists: boolean;
readonly raw: string;
readonly root: JsonObject;
readonly hooks: QwenHookSettings;
readonly hasHooksContainer: boolean;
readonly hooksDisabled: boolean;
readonly ownedFile: boolean;
}
export interface RenderedDocument {
readonly document: QwenDocument;
readonly changed: boolean;
readonly next?: string;
}
interface DocumentOptions {
readonly exists: boolean;
readonly filePath: string;
readonly raw: string;
}
function rejectDuplicateKeys(
node: Node | undefined,
label: string,
path: 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 location = [...path, key];
if (keys.has(key))
throw new Error(
`${label} contains duplicate field "${location.join(".")}"`,
);
keys.add(key);
rejectDuplicateKeys(property.children?.[1], label, location);
}
return;
}
if (node.type === "array") {
for (const child of node.children ?? [])
rejectDuplicateKeys(child, label, path);
}
}
function parseJsonWithComments(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 parseDocument(options: DocumentOptions): QwenDocument {
const label = `Qwen Code settings at ${options.filePath}`;
const root = parseJsonWithComments(options.raw, label);
if (
root.disableAllHooks !== undefined &&
typeof root.disableAllHooks !== "boolean"
) {
throw new Error(`${label} field "disableAllHooks" must be a boolean`);
}
const hasHooksContainer = Object.hasOwn(root, "hooks");
const hooks = hasHooksContainer
? readHooks(root.hooks, `${label} field "hooks"`)
: {};
return {
...options,
root,
hooks,
hasHooksContainer,
hooksDisabled: root.disableAllHooks === true,
ownedFile: options.raw.startsWith(OWNED_FILE_MARKER),
};
}
export function createOwnedDocument(filePath: string): QwenDocument {
return parseDocument({
exists: false,
filePath,
raw: `${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: QwenDocument, raw: string): QwenDocument {
return parseDocument({
exists: document.exists,
filePath: document.filePath,
raw,
});
}
function removeManagedEntries(
document: QwenDocument,
raw: string,
agentId?: string,
): string {
const removals = managedRemovals(document.hooks, agentId);
for (const event of TOOL_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: QwenDocument,
raw: string,
event: ToolEvent,
group: QwenGroup,
): 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 renderDocument(
document: QwenDocument,
agentId: string | undefined,
additions: ReadonlyMap<ToolEvent, QwenGroup>,
): RenderedDocument {
let raw = removeManagedEntries(document, document.raw, agentId);
for (const event of TOOL_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 };
}
+392
View File
@@ -0,0 +1,392 @@
import os from "node:os";
import path from "node:path";
export const AGENT_KEY = "qwen";
export const GUARD_SCRIPT = "guard.js";
export const AUDIT_SCRIPT = "hook.js";
export const HOOK_TIMEOUT_MS = 10_000;
export const TOOL_EVENTS = ["PreToolUse", "PostToolUse"] as const;
const EVENT_NAMES = new Set([
"PreToolUse",
"PostToolUse",
"PostToolUseFailure",
"PostToolBatch",
"Notification",
"UserPromptSubmit",
"UserPromptExpansion",
"SessionStart",
"Stop",
"MessageDisplay",
"SubagentStart",
"SubagentStop",
"PreCompact",
"PostCompact",
"SessionEnd",
"PermissionRequest",
"PermissionDenied",
"StopFailure",
"TodoCreated",
"TodoCompleted",
"InstructionsLoaded",
]);
const CONFIG_FIELDS = new Set(["enabled", "disabled", "notifications"]);
const HANDLER_KEYS = ["command", "shell", "timeout", "type"];
const GROUP_KEYS = ["hooks", "matcher"];
export type JsonObject = Record<string, unknown>;
export type ToolEvent = (typeof TOOL_EVENTS)[number];
export interface QwenCommandHandler extends JsonObject {
readonly type: "command";
readonly command: string;
readonly timeout?: number;
readonly shell?: "bash" | "powershell";
}
export interface QwenGroup extends JsonObject {
readonly matcher?: string;
readonly sequential?: boolean;
readonly hooks: JsonObject[];
}
export interface QwenHookSettings extends JsonObject {
readonly PreToolUse?: QwenGroup[];
readonly PostToolUse?: QwenGroup[];
}
export interface RuntimeContract {
readonly agentId: string;
readonly guardPath: string;
readonly auditPath: string;
}
export interface ManagedRemoval {
readonly event: ToolEvent;
readonly groupIndex: number;
readonly handlerIndexes: number[];
readonly removeGroup: boolean;
}
interface ParsedArgument {
readonly value: string;
readonly next: number;
}
export function isObject(value: unknown): value is JsonObject {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function quotePosix(value: string): string {
return `'${value.replaceAll("'", `'"'"'`)}'`;
}
function quotePowerShell(value: string): string {
return `'${value.replaceAll("'", "''")}'`;
}
export function buildCommand(scriptPath: string): string {
if (process.platform === "win32") {
return `& ${quotePowerShell(process.execPath)} ${quotePowerShell(scriptPath)}; exit $LASTEXITCODE`;
}
return `${quotePosix(process.execPath)} ${quotePosix(scriptPath)}`;
}
export function buildGroup(scriptPath: string): QwenGroup {
return {
matcher: "*",
hooks: [
{
type: "command",
command: buildCommand(scriptPath),
shell: process.platform === "win32" ? "powershell" : "bash",
timeout: HOOK_TIMEOUT_MS,
},
],
};
}
function validateRegex(value: string, label: string): void {
if (value.trim() === "" || value.trim() === "*") return;
try {
new RegExp(value);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw new Error(`${label} must be a valid regular expression: ${message}`);
}
}
function optionalString(value: JsonObject, key: string, label: string): void {
if (value[key] !== undefined && typeof value[key] !== "string") {
throw new Error(`${label} field "${key}" must be a string`);
}
}
function optionalBoolean(value: JsonObject, key: string, label: string): void {
if (value[key] !== undefined && typeof value[key] !== "boolean") {
throw new Error(`${label} field "${key}" must be a boolean`);
}
}
function optionalStringMap(
value: JsonObject,
key: string,
label: string,
): void {
const item = value[key];
if (item === undefined) return;
if (
!isObject(item) ||
Object.values(item).some((entry) => typeof entry !== "string")
) {
throw new Error(`${label} field "${key}" must contain string values`);
}
}
function validateHandler(value: unknown, label: string): JsonObject {
if (!isObject(value)) throw new Error(`${label} must be an object`);
if (
value.type !== "command" &&
value.type !== "http" &&
value.type !== "prompt"
) {
throw new Error(`${label} type must be "command", "http", or "prompt"`);
}
if (
value.timeout !== undefined &&
(typeof value.timeout !== "number" ||
!Number.isFinite(value.timeout) ||
value.timeout < 0)
) {
throw new Error(`${label} timeout must be a non-negative finite number`);
}
for (const key of ["name", "description", "statusMessage", "source"]) {
optionalString(value, key, label);
}
if (value.type === "command") {
if (typeof value.command !== "string" || value.command.length === 0) {
throw new Error(`${label} command must be a non-empty string`);
}
optionalStringMap(value, "env", label);
optionalBoolean(value, "async", label);
if (
value.shell !== undefined &&
value.shell !== "bash" &&
value.shell !== "powershell"
) {
throw new Error(`${label} shell must be "bash" or "powershell"`);
}
}
if (value.type === "http") {
if (typeof value.url !== "string" || value.url.length === 0) {
throw new Error(`${label} url must be a non-empty string`);
}
optionalStringMap(value, "headers", label);
optionalBoolean(value, "once", label);
optionalString(value, "if", label);
if (
value.allowedEnvVars !== undefined &&
(!Array.isArray(value.allowedEnvVars) ||
value.allowedEnvVars.some((item) => typeof item !== "string"))
) {
throw new Error(`${label} allowedEnvVars must be an array of strings`);
}
}
if (value.type === "prompt") {
if (typeof value.prompt !== "string" || value.prompt.length === 0) {
throw new Error(`${label} prompt must be a non-empty string`);
}
optionalString(value, "model", label);
}
return value;
}
function validateGroup(
value: unknown,
label: string,
index: number,
): QwenGroup {
const location = `${label}[${index}]`;
if (!isObject(value)) throw new Error(`${location} must be an object`);
if (value.matcher !== undefined) {
if (typeof value.matcher !== "string")
throw new Error(`${location} matcher must be a string`);
validateRegex(value.matcher, `${location} matcher`);
}
if (value.sequential !== undefined && typeof value.sequential !== "boolean") {
throw new Error(`${location} sequential must be a boolean`);
}
if (!Array.isArray(value.hooks))
throw new Error(`${location} must contain a hooks array`);
const hooks = value.hooks.map((handler, handlerIndex) =>
validateHandler(handler, `${location}.hooks[${handlerIndex}]`),
);
return { ...value, hooks } as QwenGroup;
}
export function readHooks(value: unknown, label: string): QwenHookSettings {
if (!isObject(value)) throw new Error(`${label} must contain a JSON object`);
const settings: QwenHookSettings = { ...value };
for (const [event, definitions] of Object.entries(value)) {
if (CONFIG_FIELDS.has(event)) continue;
if (!EVENT_NAMES.has(event))
throw new Error(`${label} contains unsupported field "${event}"`);
if (!Array.isArray(definitions))
throw new Error(`${label} field "${event}" must be an array`);
settings[event] = definitions.map((definition, index) =>
validateGroup(definition, `${label} field "${event}"`, index),
);
}
return settings;
}
function readQuotedArgument(
command: string,
start: number,
): ParsedArgument | undefined {
if (command[start] !== "'") return undefined;
let value = "";
for (let index = start + 1; index < command.length; ) {
if (process.platform === "win32" && command.startsWith("''", index)) {
value += "'";
index += 2;
continue;
}
if (process.platform !== "win32" && command.startsWith(`'"'"'`, index)) {
value += "'";
index += 5;
continue;
}
if (command[index] === "'") return { value, next: index + 1 };
value += command[index];
index += 1;
}
return undefined;
}
function parseGeneratedCommand(
command: string,
): readonly [string, string] | undefined {
if (process.platform === "win32" && !command.startsWith("& "))
return undefined;
const start = process.platform === "win32" ? 2 : 0;
const executable = readQuotedArgument(command, start);
if (!executable || command[executable.next] !== " ") return undefined;
const script = readQuotedArgument(command, executable.next + 1);
const expectedSuffix =
process.platform === "win32" ? "; exit $LASTEXITCODE" : "";
if (
!script ||
command.slice(script.next) !== expectedSuffix ||
!executable.value ||
!script.value
)
return undefined;
return [executable.value, script.value];
}
function samePath(left: string, right: string): boolean {
const normalizedLeft = path.resolve(left);
const normalizedRight = path.resolve(right);
return process.platform === "win32"
? normalizedLeft.toLowerCase() === normalizedRight.toLowerCase()
: normalizedLeft === normalizedRight;
}
function sameAgentId(left: string, right: string): boolean {
return process.platform === "win32"
? left.toLowerCase() === right.toLowerCase()
: left === right;
}
export function managedAgentId(
handler: JsonObject,
scriptName: string,
): string | undefined {
if (
Object.keys(handler).sort().join("\0") !== HANDLER_KEYS.join("\0") ||
handler.type !== "command" ||
handler.timeout !== HOOK_TIMEOUT_MS ||
handler.shell !== (process.platform === "win32" ? "powershell" : "bash") ||
typeof handler.command !== "string"
)
return undefined;
const parsed = parseGeneratedCommand(handler.command);
if (!parsed || !samePath(parsed[0], process.execPath)) return undefined;
const scriptPath = parsed[1];
if (path.basename(scriptPath) !== scriptName) return undefined;
const agentDirectory = path.dirname(scriptPath);
if (
!samePath(path.dirname(agentDirectory), path.join(os.homedir(), ".elydora"))
)
return undefined;
const agentId = path.basename(agentDirectory);
return agentId && agentId !== "." && agentId !== ".." ? agentId : undefined;
}
function exactOwnedGroup(group: QwenGroup, managedIndexes: number[]): boolean {
return (
Object.keys(group).sort().join("\0") === GROUP_KEYS.join("\0") &&
group.matcher === "*" &&
managedIndexes.length > 0 &&
managedIndexes.length === group.hooks.length
);
}
export function managedRemovals(
settings: QwenHookSettings,
agentId?: string,
): ManagedRemoval[] {
const removals: ManagedRemoval[] = [];
for (const [event, scriptName] of [
["PreToolUse", GUARD_SCRIPT],
["PostToolUse", AUDIT_SCRIPT],
] as const) {
(settings[event] ?? []).forEach((group, groupIndex) => {
const handlerIndexes = group.hooks.flatMap((handler, handlerIndex) => {
const managedId = managedAgentId(handler, scriptName);
return managedId &&
(agentId === undefined || sameAgentId(managedId, agentId))
? [handlerIndex]
: [];
});
if (handlerIndexes.length > 0) {
removals.push({
event,
groupIndex,
handlerIndexes,
removeGroup: exactOwnedGroup(group, handlerIndexes),
});
}
});
}
return removals;
}
function managedIds(groups: QwenGroup[], scriptName: string): Set<string> {
const ids = new Set<string>();
for (const group of groups) {
for (const handler of group.hooks) {
const agentId = managedAgentId(handler, scriptName);
if (agentId) ids.add(agentId);
}
}
return ids;
}
export function runtimeContracts(
settings: QwenHookSettings,
): RuntimeContract[] {
const guards = managedIds(settings.PreToolUse ?? [], GUARD_SCRIPT);
const audits = managedIds(settings.PostToolUse ?? [], AUDIT_SCRIPT);
const root = path.join(os.homedir(), ".elydora");
return [...guards]
.filter((agentId) =>
[...audits].some((auditId) => sameAgentId(auditId, agentId)),
)
.map((agentId) => ({
agentId,
guardPath: path.join(root, agentId, GUARD_SCRIPT),
auditPath: path.join(root, agentId, AUDIT_SCRIPT),
}));
}
+344
View File
@@ -0,0 +1,344 @@
import { randomUUID } from "node:crypto";
import fsp from "node:fs/promises";
import type { FileHandle } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { parse as parseDotenv } from "dotenv";
import {
AGENT_KEY,
type JsonObject,
type RuntimeContract,
isObject,
} from "./qwen-contract.js";
import {
type QwenDocument,
type RenderedDocument,
createOwnedDocument,
parseDocument,
} from "./qwen-config.js";
interface FileChange {
readonly filePath: string;
readonly label: string;
readonly original?: string;
readonly next?: string;
}
interface StagedChange {
readonly change: FileChange;
readonly tempPath?: string;
readonly rollbackPath?: string;
committed: boolean;
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
function asError(error: unknown): Error {
return error instanceof Error ? error : new Error(String(error));
}
function hasErrorCode(error: unknown, code: string): boolean {
return isObject(error) && error.code === code;
}
async function readOptional(
filePath: string,
label: string,
): Promise<string | undefined> {
try {
return await fsp.readFile(filePath, "utf-8");
} catch (error) {
if (hasErrorCode(error, "ENOENT")) return undefined;
throw new Error(`Read ${label} at ${filePath}: ${errorMessage(error)}`, {
cause: asError(error),
});
}
}
function defaultQwenHome(): string {
const homeDir = os.homedir();
return homeDir
? path.join(homeDir, ".qwen")
: path.join(os.tmpdir(), ".qwen");
}
function resolveStoragePath(value: string): string {
let resolved = value;
if (
resolved === "~" ||
resolved.startsWith("~/") ||
resolved.startsWith("~\\")
) {
const segments =
resolved === "~"
? []
: resolved
.slice(2)
.split(/[/\\]+/)
.filter(Boolean);
resolved = path.join(os.homedir(), ...segments);
}
return path.isAbsolute(resolved) ? resolved : path.resolve(resolved);
}
async function qwenHomeFromEnvFile(
filePath: string,
): Promise<string | undefined> {
const raw = await readOptional(filePath, "Qwen home environment");
if (raw === undefined) return undefined;
const value = parseDotenv(raw).QWEN_HOME;
return value || undefined;
}
export async function resolveQwenHome(): Promise<string> {
const initialValue = process.env.QWEN_HOME;
const initialHome = initialValue
? resolveStoragePath(initialValue)
: defaultQwenHome();
if (Object.hasOwn(process.env, "QWEN_HOME")) return initialHome;
const candidates = [
path.join(initialHome, ".env"),
path.join(path.dirname(initialHome), ".env"),
];
for (const candidate of candidates) {
const discovered = await qwenHomeFromEnvFile(candidate);
if (discovered) return resolveStoragePath(discovered);
}
return initialHome;
}
export async function readDocument(): Promise<QwenDocument> {
const configPath = path.join(await resolveQwenHome(), "settings.json");
const raw = await readOptional(configPath, "Qwen Code settings");
return raw === undefined
? createOwnedDocument(configPath)
: parseDocument({ exists: true, filePath: configPath, raw });
}
function fileChange(rendered: RenderedDocument): FileChange | undefined {
if (!rendered.changed) return undefined;
return {
filePath: rendered.document.filePath,
label: "Qwen Code settings",
original: rendered.document.exists ? rendered.document.raw : undefined,
next: rendered.next,
};
}
async function unlinkOptional(filePath: string): Promise<void> {
try {
await fsp.unlink(filePath);
} catch (error) {
if (!hasErrorCode(error, "ENOENT")) throw error;
}
}
async function writeExclusive(
filePath: string,
contents: string,
label: string,
): Promise<void> {
let handle: FileHandle | undefined;
try {
handle = await fsp.open(filePath, "wx", 0o600);
await handle.writeFile(contents, "utf-8");
await handle.sync();
await handle.close();
} catch (error) {
const failures = [asError(error)];
if (handle) {
try {
await handle.close();
} catch (closeError) {
failures.push(asError(closeError));
}
}
try {
await unlinkOptional(filePath);
} catch (cleanupError) {
failures.push(asError(cleanupError));
}
if (failures.length > 1)
throw new AggregateError(failures, `Stage ${label}`);
throw new Error(`Stage ${label} at ${filePath}: ${errorMessage(error)}`, {
cause: failures[0],
});
}
}
async function assertUnchanged(change: FileChange): Promise<void> {
const current = await readOptional(change.filePath, change.label);
if (current !== change.original) {
throw new Error(
`${change.label} changed during installation: ${change.filePath}`,
);
}
}
async function stage(change: FileChange): Promise<StagedChange> {
await assertUnchanged(change);
const directory = path.dirname(change.filePath);
await fsp.mkdir(directory, { recursive: true, mode: 0o700 });
const token = randomUUID();
const tempPath =
change.next === undefined
? undefined
: path.join(directory, `.${path.basename(change.filePath)}.${token}.tmp`);
const rollbackPath =
change.original === undefined
? undefined
: path.join(
directory,
`.${path.basename(change.filePath)}.${token}.rollback`,
);
try {
if (tempPath) await writeExclusive(tempPath, change.next!, change.label);
if (rollbackPath && change.next !== undefined) {
await writeExclusive(
rollbackPath,
change.original!,
`${change.label} rollback`,
);
}
} catch (error) {
const failures = [asError(error)];
for (const stagedPath of [tempPath, rollbackPath]) {
if (!stagedPath) continue;
try {
await unlinkOptional(stagedPath);
} catch (cleanupError) {
failures.push(asError(cleanupError));
}
}
if (failures.length > 1)
throw new AggregateError(failures, `Stage ${change.label}`);
throw error;
}
return { change, tempPath, rollbackPath, committed: false };
}
async function commit(staged: StagedChange): Promise<void> {
await assertUnchanged(staged.change);
if (staged.change.next === undefined) {
if (!staged.rollbackPath)
throw new Error(`Missing rollback path for ${staged.change.label}`);
await fsp.rename(staged.change.filePath, staged.rollbackPath);
} else {
if (!staged.tempPath)
throw new Error(`Missing staged file for ${staged.change.label}`);
await fsp.rename(staged.tempPath, staged.change.filePath);
}
staged.committed = true;
}
async function rollback(staged: StagedChange): Promise<void> {
if (!staged.committed) return;
if (staged.change.original === undefined) {
await unlinkOptional(staged.change.filePath);
return;
}
if (!staged.rollbackPath)
throw new Error(`Missing rollback data for ${staged.change.label}`);
await fsp.rename(staged.rollbackPath, staged.change.filePath);
}
async function cleanup(staged: StagedChange): Promise<void> {
const stagedPaths = [staged.tempPath, staged.rollbackPath].filter(
(filePath): filePath is string => filePath !== undefined,
);
await Promise.all(stagedPaths.map(unlinkOptional));
}
export async function writeDocument(rendered: RenderedDocument): Promise<void> {
const change = fileChange(rendered);
if (!change) return;
const staged = await stage(change);
try {
await commit(staged);
} catch (error) {
const failures = [asError(error)];
try {
await rollback(staged);
} catch (rollbackError) {
failures.push(asError(rollbackError));
}
try {
await cleanup(staged);
} catch (cleanupError) {
failures.push(asError(cleanupError));
}
if (failures.length > 1)
throw new AggregateError(failures, "Write Qwen Code settings");
throw new Error(`Write Qwen Code settings: ${errorMessage(error)}`, {
cause: failures[0],
});
}
await cleanup(staged);
}
export async function regularFileExists(
filePath: string,
label: string,
): Promise<boolean> {
try {
return (await fsp.stat(filePath)).isFile();
} catch (error) {
if (hasErrorCode(error, "ENOENT") || hasErrorCode(error, "ENOTDIR"))
return false;
throw new Error(`Read ${label} at ${filePath}: ${errorMessage(error)}`, {
cause: asError(error),
});
}
}
export async function requireRuntime(
filePath: string,
label: string,
): Promise<void> {
if (!filePath) throw new Error(`${label} path is required`);
if (!(await regularFileExists(filePath, label)))
throw new Error(`${label} is missing: ${filePath}`);
}
async function readRuntimeConfig(
filePath: string,
): Promise<JsonObject | undefined> {
const raw = await readOptional(filePath, "Elydora runtime config");
if (raw === undefined) return undefined;
let value: unknown;
try {
value = JSON.parse(raw);
} catch (error) {
throw new Error(
`Failed to parse Elydora runtime config at ${filePath}: ${errorMessage(error)}`,
{
cause: asError(error),
},
);
}
if (!isObject(value))
throw new Error(
`Elydora runtime config at ${filePath} must contain a JSON object`,
);
return value;
}
export async function runtimeFilesExist(
contracts: RuntimeContract[],
): Promise<boolean> {
for (const contract of contracts) {
const agentDirectory = path.dirname(contract.guardPath);
const runtimeConfig = await readRuntimeConfig(
path.join(agentDirectory, "config.json"),
);
if (!runtimeConfig || runtimeConfig.agent_name !== AGENT_KEY) continue;
const files = await Promise.all([
regularFileExists(contract.guardPath, "Elydora guard runtime"),
regularFileExists(contract.auditPath, "Elydora audit runtime"),
]);
if (files.every(Boolean)) return true;
}
return false;
}
+57
View File
@@ -0,0 +1,57 @@
import type { AgentPlugin, InstallConfig, PluginStatus } from "./base.js";
import {
AGENT_KEY,
buildGroup,
runtimeContracts,
type QwenGroup,
type ToolEvent,
} from "./qwen-contract.js";
import { renderDocument } from "./qwen-config.js";
import {
readDocument,
requireRuntime,
runtimeFilesExist,
writeDocument,
} from "./qwen-io.js";
import { SUPPORTED_AGENTS } from "./registry.js";
const entry = SUPPORTED_AGENTS.get(AGENT_KEY)!;
export const qwenPlugin: AgentPlugin = {
async install(config: InstallConfig): Promise<void> {
if (!config.agentId) throw new Error("agentId is required");
const document = await readDocument();
await requireRuntime(config.guardScriptPath, "Elydora guard runtime");
await requireRuntime(config.hookScriptPath, "Elydora audit runtime");
const additions = new Map<ToolEvent, QwenGroup>([
["PreToolUse", buildGroup(config.guardScriptPath)],
["PostToolUse", buildGroup(config.hookScriptPath)],
]);
await writeDocument(renderDocument(document, undefined, additions));
console.log(` Qwen Code: user hooks installed at ${document.filePath}`);
console.log(" Qwen Code: run /hooks to review the Elydora hook changes.");
},
async uninstall(agentId?: string): Promise<void> {
const document = await readDocument();
if (!document.exists) return;
await writeDocument(renderDocument(document, agentId, new Map()));
},
async status(): Promise<PluginStatus> {
const document = await readDocument();
const contracts = runtimeContracts(document.hooks);
const hookConfigured = !document.hooksDisabled && contracts.length > 0;
const hookScriptExists = hookConfigured
? await runtimeFilesExist(contracts)
: false;
return {
installed: hookConfigured && hookScriptExists,
agentName: AGENT_KEY,
displayName: entry.name,
hookConfigured,
hookScriptExists,
configPath: document.filePath,
};
},
};
+1
View File
@@ -19,4 +19,5 @@ export const SUPPORTED_AGENTS: ReadonlyMap<string, AgentRegistryEntry> = new Map
['droid', { name: 'Factory Droid', configDir: '~/.factory', configFile: 'hooks.json' }],
['kimi', { name: 'Kimi Code', configDir: '~/.kimi-code', configFile: 'config.toml' }],
['grok', { name: 'Grok Build', configDir: '~/.grok/hooks', configFile: 'elydora-audit.json' }],
['qwen', { name: 'Qwen Code', configDir: '~/.qwen', configFile: 'settings.json' }],
]);