fix(opencode): install discoverable atomic plugin
This commit is contained in:
1 parent
3f43f4aad5
commit
69a75153f5
15 files changed
+1554
-348
No files matched your search
@@ -12,8 +12,8 @@ import {
|
||||
type KiroIdeDocument,
|
||||
type KiroIdeRuntimeContract,
|
||||
} from './kiroide-contract.js';
|
||||
import { runtimeFilesExist } from './kiroide-io.js';
|
||||
import { inspectPhysicalDirectory, readPhysicalFile } from './managed-files.js';
|
||||
import { managedRuntimeFilesExist } from './managed-runtime-status.js';
|
||||
|
||||
const MAX_HOOK_BYTES = 512 * 1024;
|
||||
|
||||
@@ -64,5 +64,5 @@ export async function readKiroCliSources(): Promise<KiroCliSources> {
|
||||
export async function kiroCliRuntimeFilesExist(
|
||||
contract: KiroIdeRuntimeContract,
|
||||
): Promise<boolean> {
|
||||
return runtimeFilesExist(contract, AGENT_KEY);
|
||||
return managedRuntimeFilesExist(contract, AGENT_KEY);
|
||||
}
|
||||
@@ -1,12 +1,5 @@
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { generateGuardScript } from './guard-template.js';
|
||||
import { generateHookScript } from './hook-template.js';
|
||||
import { sameKiroIdeAgentId, sameKiroIdePath } from './kiroide-command.js';
|
||||
import {
|
||||
AGENT_KEY,
|
||||
AUDIT_SCRIPT,
|
||||
GUARD_SCRIPT,
|
||||
createKiroIdeDocument,
|
||||
legacyKiroIdeRuntimeContract,
|
||||
parseKiroIdeDocument,
|
||||
@@ -16,10 +9,6 @@ import {
|
||||
type KiroIdeRuntimeContract,
|
||||
} from './kiroide-contract.js';
|
||||
import { inspectPhysicalDirectory, readPhysicalFile } from './managed-files.js';
|
||||
import { parseStrictJsonObject, type JsonObject } from './strict-json.js';
|
||||
|
||||
const MAX_SECRET_BYTES = 64 * 1024;
|
||||
const MAX_CONFIG_BYTES = 512 * 1024;
|
||||
|
||||
export interface LegacyKiroIdeDocument {
|
||||
readonly exists: boolean;
|
||||
@@ -83,88 +72,3 @@ export async function requirePhysicalLegacyDirectory(
|
||||
throw new Error(`Legacy Kiro IDE hooks directory is missing: ${hooksDirectory}`);
|
||||
}
|
||||
}
|
||||
|
||||
function requireString(value: unknown, field: string, configPath: string): string {
|
||||
if (typeof value !== 'string' || value.trim().length === 0) {
|
||||
throw new Error(`Elydora runtime config ${field} is invalid: ${configPath}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function validateRuntimeConfig(
|
||||
config: JsonObject,
|
||||
contract: KiroIdeRuntimeContract,
|
||||
configPath: string,
|
||||
agentKey: string,
|
||||
): void {
|
||||
const supported = new Set(['org_id', 'agent_id', 'kid', 'base_url', 'token', 'agent_name']);
|
||||
const extra = Object.keys(config).find((key) => !supported.has(key));
|
||||
if (extra) throw new Error(`Elydora runtime config has unsupported field "${extra}": ${configPath}`);
|
||||
requireString(config.org_id, 'org_id', configPath);
|
||||
requireString(config.kid, 'kid', configPath);
|
||||
const agentId = requireString(config.agent_id, 'agent_id', configPath);
|
||||
if (!sameKiroIdeAgentId(agentId, contract.agentId) || config.agent_name !== agentKey) {
|
||||
throw new Error(`Elydora runtime identity does not match ${agentKey} hooks: ${configPath}`);
|
||||
}
|
||||
if (config.token !== undefined) requireString(config.token, 'token', configPath);
|
||||
const rawBaseUrl = requireString(config.base_url, 'base_url', configPath);
|
||||
let baseUrl: URL;
|
||||
try {
|
||||
baseUrl = new URL(rawBaseUrl);
|
||||
} catch (error) {
|
||||
throw new Error(`Elydora runtime config base_url is invalid: ${configPath}`, {
|
||||
cause: error instanceof Error ? error : new Error(String(error)),
|
||||
});
|
||||
}
|
||||
if (!['http:', 'https:'].includes(baseUrl.protocol)
|
||||
|| !baseUrl.hostname
|
||||
|| baseUrl.username
|
||||
|| baseUrl.password
|
||||
|| baseUrl.search
|
||||
|| baseUrl.hash) {
|
||||
throw new Error(`Elydora runtime config base_url is invalid: ${configPath}`);
|
||||
}
|
||||
}
|
||||
|
||||
function validatePrivateKey(contents: string, keyPath: string): void {
|
||||
const bytes = Buffer.from(contents, 'base64url');
|
||||
if (bytes.length !== 32 || bytes.toString('base64url') !== contents) {
|
||||
throw new Error(`Elydora private key is invalid: ${keyPath}`);
|
||||
}
|
||||
}
|
||||
|
||||
function validContractPaths(contract: KiroIdeRuntimeContract): boolean {
|
||||
const agentDirectory = path.dirname(contract.guardPath);
|
||||
return sameKiroIdePath(path.dirname(agentDirectory), path.join(os.homedir(), '.elydora'))
|
||||
&& sameKiroIdePath(contract.guardPath, path.join(agentDirectory, GUARD_SCRIPT))
|
||||
&& sameKiroIdePath(contract.auditPath, path.join(agentDirectory, AUDIT_SCRIPT));
|
||||
}
|
||||
|
||||
export async function runtimeFilesExist(
|
||||
contract: KiroIdeRuntimeContract,
|
||||
agentKey = AGENT_KEY,
|
||||
): Promise<boolean> {
|
||||
if (!validContractPaths(contract)) return false;
|
||||
const runtimeRoot = path.join(os.homedir(), '.elydora');
|
||||
const agentDirectory = path.dirname(contract.guardPath);
|
||||
if (!await inspectPhysicalDirectory(runtimeRoot, 'Elydora runtime directory')) return false;
|
||||
if (!await inspectPhysicalDirectory(agentDirectory, 'Elydora agent runtime directory')) return false;
|
||||
const configPath = path.join(agentDirectory, 'config.json');
|
||||
const keyPath = path.join(agentDirectory, 'private.key');
|
||||
const [config, key, guard, audit] = await Promise.all([
|
||||
readPhysicalFile(configPath, 'Elydora runtime config', MAX_CONFIG_BYTES),
|
||||
readPhysicalFile(keyPath, 'Elydora private key', MAX_SECRET_BYTES),
|
||||
readPhysicalFile(contract.guardPath, 'Elydora guard runtime'),
|
||||
readPhysicalFile(contract.auditPath, 'Elydora audit runtime'),
|
||||
]);
|
||||
if (!config || !key || !guard || !audit) return false;
|
||||
validateRuntimeConfig(
|
||||
parseStrictJsonObject(config.contents, `Elydora runtime config at ${configPath}`),
|
||||
contract,
|
||||
configPath,
|
||||
agentKey,
|
||||
);
|
||||
validatePrivateKey(key.contents, keyPath);
|
||||
return guard.contents === generateGuardScript(agentKey, contract.agentId)
|
||||
&& audit.contents === generateHookScript(agentKey, contract.agentId, { nativePayload: true });
|
||||
}
|
||||
@@ -14,7 +14,8 @@ import {
|
||||
prepareKiroIdeInstallation,
|
||||
prepareKiroIdeUninstall,
|
||||
} from './kiroide-installation.js';
|
||||
import { readKiroIdeSources, runtimeFilesExist } from './kiroide-io.js';
|
||||
import { readKiroIdeSources } from './kiroide-io.js';
|
||||
import { managedRuntimeFilesExist } from './managed-runtime-status.js';
|
||||
import { SUPPORTED_AGENTS } from './registry.js';
|
||||
|
||||
const entry = SUPPORTED_AGENTS.get(AGENT_KEY)!;
|
||||
@@ -60,7 +61,9 @@ export const kiroidePlugin: AgentPlugin = {
|
||||
const contracts = kiroIdeRuntimeContracts(sources.document.hooks);
|
||||
const hookConfigured = contracts.length > 0;
|
||||
const hookScriptExists = hookConfigured
|
||||
&& (await Promise.all(contracts.map((contract) => runtimeFilesExist(contract)))).every(Boolean);
|
||||
&& (await Promise.all(contracts.map((contract) => (
|
||||
managedRuntimeFilesExist(contract, AGENT_KEY)
|
||||
)))).every(Boolean);
|
||||
return {
|
||||
installed: hookConfigured && hookScriptExists,
|
||||
agentName: AGENT_KEY,
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { generateGuardScript, type GuardScriptOptions } from './guard-template.js';
|
||||
import { generateHookScript, type HookScriptOptions } from './hook-template.js';
|
||||
import { inspectPhysicalDirectory, readPhysicalFile } from './managed-files.js';
|
||||
import { samePath } from './managed-installation.js';
|
||||
import { parseStrictJsonObject, type JsonObject } from './strict-json.js';
|
||||
|
||||
const MAX_SECRET_BYTES = 64 * 1024;
|
||||
const MAX_CONFIG_BYTES = 512 * 1024;
|
||||
const GUARD_SCRIPT = 'guard.js';
|
||||
const AUDIT_SCRIPT = 'hook.js';
|
||||
|
||||
export interface ManagedRuntimeContract {
|
||||
readonly agentId: string;
|
||||
readonly guardPath: string;
|
||||
readonly auditPath: string;
|
||||
}
|
||||
|
||||
export interface ManagedRuntimeStatusOptions {
|
||||
readonly guardOptions?: GuardScriptOptions;
|
||||
readonly auditOptions?: HookScriptOptions;
|
||||
}
|
||||
|
||||
function sameAgentId(left: string, right: string): boolean {
|
||||
return process.platform === 'win32' ? left.toLowerCase() === right.toLowerCase() : left === right;
|
||||
}
|
||||
|
||||
function requireString(value: unknown, field: string, configPath: string): string {
|
||||
if (typeof value !== 'string' || value.trim().length === 0) {
|
||||
throw new Error(`Elydora runtime config ${field} is invalid: ${configPath}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function validateRuntimeConfig(
|
||||
config: JsonObject,
|
||||
contract: ManagedRuntimeContract,
|
||||
configPath: string,
|
||||
agentKey: string,
|
||||
): void {
|
||||
const supported = new Set(['org_id', 'agent_id', 'kid', 'base_url', 'token', 'agent_name']);
|
||||
const extra = Object.keys(config).find((key) => !supported.has(key));
|
||||
if (extra) throw new Error(`Elydora runtime config has unsupported field "${extra}": ${configPath}`);
|
||||
requireString(config.org_id, 'org_id', configPath);
|
||||
requireString(config.kid, 'kid', configPath);
|
||||
const agentId = requireString(config.agent_id, 'agent_id', configPath);
|
||||
if (!sameAgentId(agentId, contract.agentId) || config.agent_name !== agentKey) {
|
||||
throw new Error(`Elydora runtime identity does not match ${agentKey} hooks: ${configPath}`);
|
||||
}
|
||||
if (config.token !== undefined) requireString(config.token, 'token', configPath);
|
||||
const rawBaseUrl = requireString(config.base_url, 'base_url', configPath);
|
||||
let baseUrl: URL;
|
||||
try {
|
||||
baseUrl = new URL(rawBaseUrl);
|
||||
} catch (error) {
|
||||
throw new Error(`Elydora runtime config base_url is invalid: ${configPath}`, {
|
||||
cause: error instanceof Error ? error : new Error(String(error)),
|
||||
});
|
||||
}
|
||||
if (!['http:', 'https:'].includes(baseUrl.protocol)
|
||||
|| !baseUrl.hostname
|
||||
|| baseUrl.username
|
||||
|| baseUrl.password
|
||||
|| baseUrl.search
|
||||
|| baseUrl.hash) {
|
||||
throw new Error(`Elydora runtime config base_url is invalid: ${configPath}`);
|
||||
}
|
||||
}
|
||||
|
||||
function validatePrivateKey(contents: string, keyPath: string): void {
|
||||
const bytes = Buffer.from(contents, 'base64url');
|
||||
if (bytes.length !== 32 || bytes.toString('base64url') !== contents) {
|
||||
throw new Error(`Elydora private key is invalid: ${keyPath}`);
|
||||
}
|
||||
}
|
||||
|
||||
function validContractPaths(contract: ManagedRuntimeContract): boolean {
|
||||
const runtimeRoot = path.join(os.homedir(), '.elydora');
|
||||
const agentDirectory = path.dirname(contract.guardPath);
|
||||
return samePath(path.dirname(agentDirectory), runtimeRoot)
|
||||
&& sameAgentId(path.basename(agentDirectory), contract.agentId)
|
||||
&& samePath(contract.guardPath, path.join(agentDirectory, GUARD_SCRIPT))
|
||||
&& samePath(contract.auditPath, path.join(agentDirectory, AUDIT_SCRIPT));
|
||||
}
|
||||
|
||||
export async function managedRuntimeFilesExist(
|
||||
contract: ManagedRuntimeContract,
|
||||
agentKey: string,
|
||||
options: ManagedRuntimeStatusOptions = {},
|
||||
): Promise<boolean> {
|
||||
if (!validContractPaths(contract)) return false;
|
||||
const runtimeRoot = path.join(os.homedir(), '.elydora');
|
||||
const agentDirectory = path.dirname(contract.guardPath);
|
||||
if (!await inspectPhysicalDirectory(runtimeRoot, 'Elydora runtime directory')) return false;
|
||||
if (!await inspectPhysicalDirectory(agentDirectory, 'Elydora agent runtime directory')) return false;
|
||||
const configPath = path.join(agentDirectory, 'config.json');
|
||||
const keyPath = path.join(agentDirectory, 'private.key');
|
||||
const [config, key, guard, audit] = await Promise.all([
|
||||
readPhysicalFile(configPath, 'Elydora runtime config', MAX_CONFIG_BYTES),
|
||||
readPhysicalFile(keyPath, 'Elydora private key', MAX_SECRET_BYTES),
|
||||
readPhysicalFile(contract.guardPath, 'Elydora guard runtime'),
|
||||
readPhysicalFile(contract.auditPath, 'Elydora audit runtime'),
|
||||
]);
|
||||
if (!config || !key || !guard || !audit) return false;
|
||||
validateRuntimeConfig(
|
||||
parseStrictJsonObject(config.contents, `Elydora runtime config at ${configPath}`),
|
||||
contract,
|
||||
configPath,
|
||||
agentKey,
|
||||
);
|
||||
validatePrivateKey(key.contents, keyPath);
|
||||
return guard.contents === generateGuardScript(agentKey, contract.agentId, options.guardOptions)
|
||||
&& audit.contents === generateHookScript(
|
||||
agentKey,
|
||||
contract.agentId,
|
||||
options.auditOptions ?? { nativePayload: true },
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,405 @@
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import type { HookScriptOptions } from './hook-template.js';
|
||||
import { samePath } from './managed-installation.js';
|
||||
import { parseStrictJsonObject } from './strict-json.js';
|
||||
|
||||
export const AGENT_KEY = 'opencode';
|
||||
export const AUDIT_SCRIPT = 'hook.js';
|
||||
export const GUARD_SCRIPT = 'guard.js';
|
||||
export const PLUGIN_FILE = 'elydora-audit.js';
|
||||
export const LEGACY_PLUGIN_FILE = 'elydora-audit.mjs';
|
||||
export const OPENCODE_AUDIT_OPTIONS = Object.freeze({
|
||||
failClosed: true,
|
||||
nativePayload: true,
|
||||
}) satisfies HookScriptOptions;
|
||||
|
||||
const METADATA_MARKER = '// @elydora-opencode-plugin ';
|
||||
const METADATA_VERSION = 1;
|
||||
const MAX_RUNTIME_STDERR_BYTES = 64 * 1024;
|
||||
const GUARD_TIMEOUT_MILLISECONDS = 5_000;
|
||||
const AUDIT_TIMEOUT_MILLISECONDS = 7_000;
|
||||
|
||||
export interface OpenCodePaths {
|
||||
readonly homeDirectory: string;
|
||||
readonly configRoot: string;
|
||||
readonly configDirectory: string;
|
||||
readonly pluginsDirectory: string;
|
||||
readonly pluginPath: string;
|
||||
readonly legacyPluginPath: string;
|
||||
}
|
||||
|
||||
export interface OpenCodeRuntimeContract {
|
||||
readonly agentId: string;
|
||||
readonly executablePath: string;
|
||||
readonly guardPath: string;
|
||||
readonly auditPath: string;
|
||||
}
|
||||
|
||||
export interface OpenCodeLegacyContract {
|
||||
readonly agentId: string;
|
||||
readonly guardPath: string;
|
||||
readonly auditPath: string;
|
||||
}
|
||||
|
||||
interface OpenCodePluginMetadata {
|
||||
readonly version: 1;
|
||||
readonly agentName: 'opencode';
|
||||
readonly agentId: string;
|
||||
readonly executablePath: string;
|
||||
readonly guardPath: string;
|
||||
readonly auditPath: string;
|
||||
}
|
||||
|
||||
export function sameOpenCodeAgentId(left: string, right: string): boolean {
|
||||
return process.platform === 'win32' ? left.toLowerCase() === right.toLowerCase() : left === right;
|
||||
}
|
||||
|
||||
export function resolveOpenCodePaths(
|
||||
homeDirectory = os.homedir(),
|
||||
xdgConfigHome = process.env.XDG_CONFIG_HOME,
|
||||
): OpenCodePaths {
|
||||
const resolvedHome = path.resolve(homeDirectory);
|
||||
const configRoot = xdgConfigHome || path.join(resolvedHome, '.config');
|
||||
if (!path.isAbsolute(configRoot)) {
|
||||
throw new Error('XDG_CONFIG_HOME must be an absolute path for OpenCode integration');
|
||||
}
|
||||
const configDirectory = path.join(configRoot, 'opencode');
|
||||
const pluginsDirectory = path.join(configDirectory, 'plugins');
|
||||
return {
|
||||
homeDirectory: resolvedHome,
|
||||
configRoot: path.normalize(configRoot),
|
||||
configDirectory,
|
||||
pluginsDirectory,
|
||||
pluginPath: path.join(pluginsDirectory, PLUGIN_FILE),
|
||||
legacyPluginPath: path.join(pluginsDirectory, LEGACY_PLUGIN_FILE),
|
||||
};
|
||||
}
|
||||
|
||||
function validAgentSegment(agentId: string): boolean {
|
||||
return Boolean(agentId)
|
||||
&& agentId !== '.'
|
||||
&& agentId !== '..'
|
||||
&& path.basename(agentId) === agentId;
|
||||
}
|
||||
|
||||
function isNodeExecutable(filePath: string): boolean {
|
||||
const basename = path.basename(filePath).toLowerCase();
|
||||
return basename === 'node' || basename === 'node.exe';
|
||||
}
|
||||
|
||||
function validateRuntimePaths(metadata: OpenCodePluginMetadata): void {
|
||||
if (!validAgentSegment(metadata.agentId)) {
|
||||
throw new Error('OpenCode plugin metadata contains an invalid agentId');
|
||||
}
|
||||
if (!path.isAbsolute(metadata.executablePath) || !isNodeExecutable(metadata.executablePath)) {
|
||||
throw new Error('OpenCode plugin metadata executablePath must reference Node.js');
|
||||
}
|
||||
const agentDirectory = path.join(os.homedir(), '.elydora', metadata.agentId);
|
||||
if (!samePath(metadata.guardPath, path.join(agentDirectory, GUARD_SCRIPT))
|
||||
|| !samePath(metadata.auditPath, path.join(agentDirectory, AUDIT_SCRIPT))) {
|
||||
throw new Error('OpenCode plugin metadata references an unexpected runtime path');
|
||||
}
|
||||
}
|
||||
|
||||
function validateMetadata(value: unknown): OpenCodePluginMetadata {
|
||||
const object = value as Record<string, unknown>;
|
||||
if (!object || typeof object !== 'object' || Array.isArray(object)) {
|
||||
throw new Error('OpenCode plugin metadata must be an object');
|
||||
}
|
||||
if (Object.keys(object).sort().join(',') !== 'agentId,agentName,auditPath,executablePath,guardPath,version') {
|
||||
throw new Error('OpenCode plugin metadata contains an unexpected field set');
|
||||
}
|
||||
if (object.version !== METADATA_VERSION) throw new Error('OpenCode plugin metadata version must be 1');
|
||||
if (object.agentName !== AGENT_KEY) throw new Error('OpenCode plugin metadata agentName must be opencode');
|
||||
for (const field of ['agentId', 'executablePath', 'guardPath', 'auditPath']) {
|
||||
if (typeof object[field] !== 'string' || object[field].length === 0) {
|
||||
throw new Error(`OpenCode plugin metadata ${field} must be a non-empty string`);
|
||||
}
|
||||
}
|
||||
const metadata = object as unknown as OpenCodePluginMetadata;
|
||||
validateRuntimePaths(metadata);
|
||||
return metadata;
|
||||
}
|
||||
|
||||
function encodeMetadata(metadata: OpenCodePluginMetadata): string {
|
||||
return Buffer.from(JSON.stringify(metadata), 'utf-8').toString('base64url');
|
||||
}
|
||||
|
||||
export function buildOpenCodeMetadata(
|
||||
agentId: string,
|
||||
executablePath: string,
|
||||
guardPath: string,
|
||||
auditPath: string,
|
||||
): OpenCodeRuntimeContract {
|
||||
return validateMetadata({
|
||||
version: METADATA_VERSION,
|
||||
agentName: AGENT_KEY,
|
||||
agentId,
|
||||
executablePath,
|
||||
guardPath,
|
||||
auditPath,
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeGeneratedSource(source: string): string {
|
||||
return source.replaceAll('\r\n', '\n');
|
||||
}
|
||||
|
||||
export function buildOpenCodePlugin(contract: OpenCodeRuntimeContract): string {
|
||||
const metadata = validateMetadata({
|
||||
version: METADATA_VERSION,
|
||||
agentName: AGENT_KEY,
|
||||
...contract,
|
||||
});
|
||||
return normalizeGeneratedSource(`// Elydora Audit Plugin for OpenCode
|
||||
${METADATA_MARKER}${encodeMetadata(metadata)}
|
||||
// Generated by @elydora/sdk. DO NOT EDIT.
|
||||
|
||||
import { spawn, spawnSync } from 'node:child_process';
|
||||
|
||||
const RUNTIME_EXECUTABLE = ${JSON.stringify(metadata.executablePath)};
|
||||
const GUARD_SCRIPT_PATH = ${JSON.stringify(metadata.guardPath)};
|
||||
const AUDIT_SCRIPT_PATH = ${JSON.stringify(metadata.auditPath)};
|
||||
const MAX_STDERR_BYTES = ${MAX_RUNTIME_STDERR_BYTES};
|
||||
const GUARD_TIMEOUT_MS = ${GUARD_TIMEOUT_MILLISECONDS};
|
||||
const AUDIT_TIMEOUT_MS = ${AUDIT_TIMEOUT_MILLISECONDS};
|
||||
|
||||
function eventPayload(eventName, input, output) {
|
||||
return JSON.stringify({
|
||||
hook_event_name: eventName,
|
||||
tool_name: input?.tool || 'unknown',
|
||||
tool_input: eventName === 'tool.execute.before' ? output?.args : input?.args,
|
||||
session_id: input?.sessionID || 'unknown',
|
||||
call_id: input?.callID || 'unknown',
|
||||
input,
|
||||
output,
|
||||
});
|
||||
}
|
||||
|
||||
function runGuard(input, output) {
|
||||
let payload;
|
||||
try {
|
||||
payload = eventPayload('tool.execute.before', input, output);
|
||||
} catch (error) {
|
||||
throw new Error('Elydora guard input serialization failed: ' + error.message);
|
||||
}
|
||||
const result = spawnSync(RUNTIME_EXECUTABLE, [GUARD_SCRIPT_PATH], {
|
||||
input: payload,
|
||||
encoding: 'utf-8',
|
||||
maxBuffer: MAX_STDERR_BYTES,
|
||||
timeout: GUARD_TIMEOUT_MS,
|
||||
windowsHide: true,
|
||||
});
|
||||
if (result.error) throw new Error('Elydora guard failed: ' + result.error.message);
|
||||
const stderr = String(result.stderr || '').trim();
|
||||
if (result.status !== 0) {
|
||||
const detail = stderr || (result.signal
|
||||
? 'Guard terminated by signal ' + result.signal
|
||||
: 'Guard exited with status ' + result.status);
|
||||
throw new Error((result.status === 2 ? '' : 'Elydora guard failed: ') + detail);
|
||||
}
|
||||
if (stderr) console.error(stderr);
|
||||
}
|
||||
|
||||
function runAudit(input, output) {
|
||||
let payload;
|
||||
try {
|
||||
payload = eventPayload('tool.execute.after', input, output);
|
||||
} catch (error) {
|
||||
console.error('[elydora] OpenCode audit input serialization failed:', error.message);
|
||||
return Promise.resolve();
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
const child = spawn(RUNTIME_EXECUTABLE, [AUDIT_SCRIPT_PATH], {
|
||||
stdio: ['pipe', 'ignore', 'pipe'],
|
||||
windowsHide: true,
|
||||
});
|
||||
const stderr = [];
|
||||
let stderrBytes = 0;
|
||||
let truncated = false;
|
||||
let settled = false;
|
||||
let timer;
|
||||
|
||||
const report = (message) => {
|
||||
console.error('[elydora] OpenCode audit runtime failed:', message);
|
||||
};
|
||||
const complete = (failure) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
if (timer) clearTimeout(timer);
|
||||
if (failure) report(failure);
|
||||
resolve();
|
||||
};
|
||||
const stderrText = () => {
|
||||
const detail = Buffer.concat(stderr).toString('utf-8').trim();
|
||||
return truncated ? detail + '\\n[stderr truncated]' : detail;
|
||||
};
|
||||
|
||||
child.stderr.on('data', (chunk) => {
|
||||
const value = Buffer.from(chunk);
|
||||
const remaining = MAX_STDERR_BYTES - stderrBytes;
|
||||
if (remaining <= 0) {
|
||||
truncated = true;
|
||||
return;
|
||||
}
|
||||
stderr.push(value.subarray(0, remaining));
|
||||
stderrBytes += Math.min(value.length, remaining);
|
||||
if (value.length > remaining) truncated = true;
|
||||
});
|
||||
child.once('error', (error) => complete(error.message));
|
||||
child.stdin.once('error', (error) => {
|
||||
child.kill();
|
||||
complete('failed to write hook input: ' + error.message);
|
||||
});
|
||||
child.once('close', (code, signal) => {
|
||||
const detail = stderrText();
|
||||
if (code !== 0 || signal) {
|
||||
complete(detail || (signal
|
||||
? 'runtime terminated by signal ' + signal
|
||||
: 'runtime exited with code ' + code));
|
||||
return;
|
||||
}
|
||||
if (detail) report(detail);
|
||||
complete();
|
||||
});
|
||||
timer = setTimeout(() => {
|
||||
child.kill();
|
||||
complete('runtime timed out after ' + AUDIT_TIMEOUT_MS + 'ms');
|
||||
}, AUDIT_TIMEOUT_MS);
|
||||
try {
|
||||
child.stdin.end(payload);
|
||||
} catch (error) {
|
||||
child.kill();
|
||||
complete('failed to write hook input: ' + error.message);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export const ElydoraAuditPlugin = async () => ({
|
||||
'tool.execute.before': async (input, output) => {
|
||||
runGuard(input, output);
|
||||
},
|
||||
'tool.execute.after': async (input, output) => {
|
||||
await runAudit(input, output);
|
||||
},
|
||||
});
|
||||
`);
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
export function parseOpenCodePlugin(
|
||||
filePath: string,
|
||||
source: string,
|
||||
): OpenCodeRuntimeContract | undefined {
|
||||
const markerLine = source.split(/\r?\n/, 3)[1];
|
||||
if (!markerLine?.startsWith(METADATA_MARKER)) return undefined;
|
||||
let metadata: OpenCodePluginMetadata;
|
||||
try {
|
||||
const encoded = markerLine.slice(METADATA_MARKER.length);
|
||||
const decoded = Buffer.from(encoded, 'base64url').toString('utf-8');
|
||||
if (Buffer.from(decoded, 'utf-8').toString('base64url') !== encoded) {
|
||||
throw new Error('metadata encoding is not canonical base64url');
|
||||
}
|
||||
metadata = validateMetadata(parseStrictJsonObject(decoded, 'OpenCode plugin metadata'));
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to parse Elydora OpenCode plugin metadata at ${filePath}: ${errorMessage(error)}`, {
|
||||
cause: error instanceof Error ? error : new Error(String(error)),
|
||||
});
|
||||
}
|
||||
if (source !== buildOpenCodePlugin(metadata)) {
|
||||
throw new Error(`Elydora OpenCode plugin at ${filePath} does not match the managed template`);
|
||||
}
|
||||
return metadata;
|
||||
}
|
||||
|
||||
export function buildLegacyOpenCodePlugin(auditPath: string, guardPath: string): string {
|
||||
return normalizeGeneratedSource(`// Elydora Audit Plugin for OpenCode
|
||||
// Generated by @elydora/sdk — DO NOT EDIT
|
||||
|
||||
import { spawn, spawnSync } from 'node:child_process';
|
||||
|
||||
const HOOK_SCRIPT_PATH = ${JSON.stringify(auditPath)};
|
||||
const GUARD_SCRIPT_PATH = ${JSON.stringify(guardPath)};
|
||||
|
||||
export const ElydoraAuditPlugin = async (ctx) => {
|
||||
return {
|
||||
"tool.execute.before": async () => {
|
||||
// Guard — blocks tool if agent is frozen
|
||||
let result;
|
||||
try {
|
||||
result = spawnSync('node', [GUARD_SCRIPT_PATH], {
|
||||
timeout: 5000,
|
||||
stdio: ['pipe', 'ignore', 'pipe'],
|
||||
});
|
||||
} catch (error) {
|
||||
throw new Error('Elydora guard failed: ' + error.message);
|
||||
}
|
||||
|
||||
if (result.error) {
|
||||
throw new Error('Elydora guard failed: ' + result.error.message);
|
||||
}
|
||||
if (result.status !== 0) {
|
||||
const message = result.stderr?.toString().trim() || 'Guard exited with status ' + result.status;
|
||||
const prefix = result.status === 2 ? '' : 'Elydora guard failed: ';
|
||||
throw new Error(prefix + message);
|
||||
}
|
||||
},
|
||||
|
||||
"tool.execute.after": async (input, output) => {
|
||||
const data = JSON.stringify({
|
||||
tool_name: input?.tool || 'unknown',
|
||||
tool_input: input?.args || output?.args || {},
|
||||
session_id: input?.sessionID || ctx?.project?.name || 'unknown',
|
||||
});
|
||||
const child = spawn('node', [HOOK_SCRIPT_PATH], {
|
||||
detached: true,
|
||||
stdio: ['pipe', 'ignore', 'ignore'],
|
||||
});
|
||||
child.on('error', (error) => {
|
||||
console.error('[elydora] audit hook failed:', error.message);
|
||||
});
|
||||
child.stdin.on('error', (error) => {
|
||||
console.error('[elydora] audit input failed:', error.message);
|
||||
});
|
||||
child.stdin.end(data);
|
||||
child.unref();
|
||||
},
|
||||
};
|
||||
};
|
||||
`);
|
||||
}
|
||||
|
||||
function legacyRuntimeContract(
|
||||
guardPath: string,
|
||||
auditPath: string,
|
||||
): OpenCodeLegacyContract | undefined {
|
||||
if (!path.isAbsolute(guardPath) || !path.isAbsolute(auditPath)) return undefined;
|
||||
const agentDirectory = path.dirname(guardPath);
|
||||
if (!samePath(path.dirname(agentDirectory), path.join(os.homedir(), '.elydora'))
|
||||
|| !samePath(auditPath, path.join(agentDirectory, AUDIT_SCRIPT))
|
||||
|| !samePath(guardPath, path.join(agentDirectory, GUARD_SCRIPT))) return undefined;
|
||||
const agentId = path.basename(agentDirectory);
|
||||
return validAgentSegment(agentId) ? { agentId, guardPath, auditPath } : undefined;
|
||||
}
|
||||
|
||||
export function parseLegacyOpenCodePlugin(source: string): OpenCodeLegacyContract | undefined {
|
||||
const normalized = normalizeGeneratedSource(source);
|
||||
const auditMatch = normalized.match(/^const HOOK_SCRIPT_PATH = ("(?:\\.|[^"\\])*");$/m);
|
||||
const guardMatch = normalized.match(/^const GUARD_SCRIPT_PATH = ("(?:\\.|[^"\\])*");$/m);
|
||||
if (!auditMatch || !guardMatch) return undefined;
|
||||
let auditPath: unknown;
|
||||
let guardPath: unknown;
|
||||
try {
|
||||
auditPath = JSON.parse(auditMatch[1]);
|
||||
guardPath = JSON.parse(guardMatch[1]);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
if (typeof auditPath !== 'string' || typeof guardPath !== 'string'
|
||||
|| normalized !== buildLegacyOpenCodePlugin(auditPath, guardPath)) return undefined;
|
||||
return legacyRuntimeContract(guardPath, auditPath);
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import path from 'node:path';
|
||||
import type { InstallConfig } from './base.js';
|
||||
import {
|
||||
AGENT_KEY,
|
||||
AUDIT_SCRIPT,
|
||||
GUARD_SCRIPT,
|
||||
OPENCODE_AUDIT_OPTIONS,
|
||||
buildOpenCodeMetadata,
|
||||
buildOpenCodePlugin,
|
||||
sameOpenCodeAgentId,
|
||||
} from './opencode-contract.js';
|
||||
import type { OpenCodePluginFile, OpenCodeSources } from './opencode-io.js';
|
||||
import {
|
||||
commitManagedInstallation,
|
||||
preflightManagedInstallation,
|
||||
prepareManagedInstallation,
|
||||
type ManagedHookSource,
|
||||
type ManagedRuntimePaths,
|
||||
type PreparedManagedInstallation,
|
||||
type RenameFile,
|
||||
} from './managed-installation.js';
|
||||
import {
|
||||
commitManagedTransaction,
|
||||
prepareManagedFileChange,
|
||||
type ManagedFileChange,
|
||||
type PreparedManagedTransaction,
|
||||
} from './managed-transaction.js';
|
||||
|
||||
const DISPLAY_NAME = 'OpenCode';
|
||||
const DIRECTORY_LABEL = 'OpenCode plugins directory';
|
||||
|
||||
export type OpenCodeRuntimePaths = ManagedRuntimePaths;
|
||||
export type PreparedOpenCodeInstallation = PreparedManagedInstallation;
|
||||
export type { RenameFile };
|
||||
|
||||
export async function preflightOpenCodeInstallation(
|
||||
config: InstallConfig,
|
||||
sources: OpenCodeSources,
|
||||
): Promise<OpenCodeRuntimePaths> {
|
||||
return preflightManagedInstallation({
|
||||
agentKey: AGENT_KEY,
|
||||
hookLocations: [{
|
||||
directoryLabel: DIRECTORY_LABEL,
|
||||
filePath: sources.paths.pluginPath,
|
||||
}],
|
||||
config,
|
||||
}, GUARD_SCRIPT, AUDIT_SCRIPT);
|
||||
}
|
||||
|
||||
export async function prepareOpenCodeInstallation(
|
||||
config: InstallConfig,
|
||||
sources: OpenCodeSources,
|
||||
): Promise<PreparedOpenCodeInstallation> {
|
||||
const paths = await preflightOpenCodeInstallation(config, sources);
|
||||
const metadata = buildOpenCodeMetadata(
|
||||
config.agentId,
|
||||
process.execPath,
|
||||
paths.guardPath,
|
||||
paths.auditPath,
|
||||
);
|
||||
const hookSources: ManagedHookSource[] = [{
|
||||
directoryLabel: DIRECTORY_LABEL,
|
||||
label: 'OpenCode plugin',
|
||||
filePath: sources.current.filePath,
|
||||
expectedSource: sources.current.source,
|
||||
expectedSnapshot: sources.current.snapshot,
|
||||
source: buildOpenCodePlugin(metadata),
|
||||
}];
|
||||
if (sources.legacy.contract) {
|
||||
hookSources.push({
|
||||
directoryLabel: DIRECTORY_LABEL,
|
||||
label: 'legacy OpenCode plugin',
|
||||
filePath: sources.legacy.filePath,
|
||||
expectedSource: sources.legacy.source,
|
||||
expectedSnapshot: sources.legacy.snapshot,
|
||||
});
|
||||
}
|
||||
return prepareManagedInstallation({
|
||||
agentKey: AGENT_KEY,
|
||||
displayName: DISPLAY_NAME,
|
||||
hookSources,
|
||||
config,
|
||||
auditOptions: OPENCODE_AUDIT_OPTIONS,
|
||||
}, GUARD_SCRIPT, AUDIT_SCRIPT);
|
||||
}
|
||||
|
||||
export async function commitOpenCodeInstallation(
|
||||
prepared: PreparedOpenCodeInstallation,
|
||||
renameFile?: RenameFile,
|
||||
): Promise<void> {
|
||||
await commitManagedInstallation(prepared, renameFile);
|
||||
}
|
||||
|
||||
function ownedByAgent<TContract extends { readonly agentId: string }>(
|
||||
file: OpenCodePluginFile<TContract>,
|
||||
agentId?: string,
|
||||
): boolean {
|
||||
return file.contract !== undefined
|
||||
&& (agentId === undefined || sameOpenCodeAgentId(file.contract.agentId, agentId));
|
||||
}
|
||||
|
||||
export async function prepareOpenCodeUninstall(
|
||||
sources: OpenCodeSources,
|
||||
agentId?: string,
|
||||
): Promise<PreparedManagedTransaction> {
|
||||
const owned = [sources.current, sources.legacy].filter((file) => ownedByAgent(file, agentId));
|
||||
const changes = await Promise.all(owned.map((file) => prepareManagedFileChange({
|
||||
filePath: file.filePath,
|
||||
label: file.filePath === sources.paths.pluginPath ? 'OpenCode plugin' : 'legacy OpenCode plugin',
|
||||
next: undefined,
|
||||
mode: 0o600,
|
||||
expectedSource: file.source,
|
||||
expectedSnapshot: file.snapshot,
|
||||
verifyExpectedSource: true,
|
||||
})));
|
||||
return {
|
||||
displayName: DISPLAY_NAME,
|
||||
operation: 'uninstall',
|
||||
directories: owned.map((file) => ({
|
||||
path: path.dirname(file.filePath),
|
||||
label: DIRECTORY_LABEL,
|
||||
})),
|
||||
changes: changes.filter((change): change is ManagedFileChange => change !== undefined),
|
||||
};
|
||||
}
|
||||
|
||||
export async function commitOpenCodeUninstall(
|
||||
prepared: PreparedManagedTransaction,
|
||||
renameFile?: RenameFile,
|
||||
): Promise<void> {
|
||||
await commitManagedTransaction(prepared, renameFile);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import fsp from 'node:fs/promises';
|
||||
import {
|
||||
AGENT_KEY,
|
||||
OPENCODE_AUDIT_OPTIONS,
|
||||
parseLegacyOpenCodePlugin,
|
||||
parseOpenCodePlugin,
|
||||
resolveOpenCodePaths,
|
||||
type OpenCodeLegacyContract,
|
||||
type OpenCodePaths,
|
||||
type OpenCodeRuntimeContract,
|
||||
} from './opencode-contract.js';
|
||||
import { inspectPhysicalDirectory, readPhysicalFile, type FileSnapshot } from './managed-files.js';
|
||||
import { managedRuntimeFilesExist } from './managed-runtime-status.js';
|
||||
|
||||
const MAX_PLUGIN_BYTES = 512 * 1024;
|
||||
|
||||
export interface OpenCodePluginFile<TContract> {
|
||||
readonly exists: boolean;
|
||||
readonly filePath: string;
|
||||
readonly source?: string;
|
||||
readonly snapshot?: FileSnapshot;
|
||||
readonly contract?: TContract;
|
||||
}
|
||||
|
||||
export interface OpenCodeSources {
|
||||
readonly paths: OpenCodePaths;
|
||||
readonly current: OpenCodePluginFile<OpenCodeRuntimeContract>;
|
||||
readonly legacy: OpenCodePluginFile<OpenCodeLegacyContract>;
|
||||
}
|
||||
|
||||
async function inspectOpenCodeDirectories(paths: OpenCodePaths): Promise<void> {
|
||||
await inspectPhysicalDirectory(paths.homeDirectory, 'OpenCode home directory');
|
||||
await inspectPhysicalDirectory(paths.configRoot, 'OpenCode XDG config directory');
|
||||
await inspectPhysicalDirectory(paths.configDirectory, 'OpenCode config directory');
|
||||
await inspectPhysicalDirectory(paths.pluginsDirectory, 'OpenCode plugins directory');
|
||||
}
|
||||
|
||||
async function readPluginFile<TContract>(
|
||||
filePath: string,
|
||||
label: string,
|
||||
parse: (filePath: string, source: string) => TContract | undefined,
|
||||
): Promise<OpenCodePluginFile<TContract>> {
|
||||
const snapshot = await readPhysicalFile(filePath, label, MAX_PLUGIN_BYTES);
|
||||
if (!snapshot) return { exists: false, filePath };
|
||||
return {
|
||||
exists: true,
|
||||
filePath,
|
||||
source: snapshot.contents,
|
||||
snapshot,
|
||||
contract: parse(filePath, snapshot.contents),
|
||||
};
|
||||
}
|
||||
|
||||
export async function readOpenCodeSources(): Promise<OpenCodeSources> {
|
||||
const paths = resolveOpenCodePaths();
|
||||
await inspectOpenCodeDirectories(paths);
|
||||
const [current, legacy] = await Promise.all([
|
||||
readPluginFile(paths.pluginPath, 'OpenCode plugin', parseOpenCodePlugin),
|
||||
readPluginFile(
|
||||
paths.legacyPluginPath,
|
||||
'legacy OpenCode plugin',
|
||||
(_filePath, source) => parseLegacyOpenCodePlugin(source),
|
||||
),
|
||||
]);
|
||||
return { paths, current, legacy };
|
||||
}
|
||||
|
||||
export function requireAvailableOpenCodePlugin(
|
||||
file: OpenCodePluginFile<OpenCodeRuntimeContract>,
|
||||
): void {
|
||||
if (file.exists && !file.contract) {
|
||||
throw new Error(`OpenCode plugin path is owned by another integration: ${file.filePath}`);
|
||||
}
|
||||
}
|
||||
|
||||
function hasCode(error: unknown, code: string): boolean {
|
||||
return typeof error === 'object' && error !== null && 'code' in error && error.code === code;
|
||||
}
|
||||
|
||||
async function executableExists(filePath: string): Promise<boolean> {
|
||||
try {
|
||||
return (await fsp.stat(filePath)).isFile();
|
||||
} catch (error) {
|
||||
if (hasCode(error, 'ENOENT') || hasCode(error, 'ENOTDIR')) return false;
|
||||
throw new Error(`Inspect OpenCode Node.js runtime at ${filePath}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`, { cause: error instanceof Error ? error : new Error(String(error)) });
|
||||
}
|
||||
}
|
||||
|
||||
export async function openCodeRuntimeFilesExist(
|
||||
contract: OpenCodeRuntimeContract,
|
||||
): Promise<boolean> {
|
||||
return await executableExists(contract.executablePath)
|
||||
&& await managedRuntimeFilesExist(contract, AGENT_KEY, {
|
||||
auditOptions: OPENCODE_AUDIT_OPTIONS,
|
||||
});
|
||||
}
|
||||
+40
-111
@@ -1,134 +1,63 @@
|
||||
import fsp from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
import type { AgentPlugin, InstallConfig, PluginStatus } from './base.js';
|
||||
import { AGENT_KEY } from './opencode-contract.js';
|
||||
import {
|
||||
commitOpenCodeInstallation,
|
||||
commitOpenCodeUninstall,
|
||||
preflightOpenCodeInstallation,
|
||||
prepareOpenCodeInstallation,
|
||||
prepareOpenCodeUninstall,
|
||||
} from './opencode-installation.js';
|
||||
import {
|
||||
openCodeRuntimeFilesExist,
|
||||
readOpenCodeSources,
|
||||
requireAvailableOpenCodePlugin,
|
||||
} from './opencode-io.js';
|
||||
import { SUPPORTED_AGENTS } from './registry.js';
|
||||
|
||||
const AGENT_KEY = 'opencode';
|
||||
const entry = SUPPORTED_AGENTS.get(AGENT_KEY)!;
|
||||
|
||||
function resolveConfigDir(): string {
|
||||
return path.join(os.homedir(), entry.configDir);
|
||||
}
|
||||
|
||||
function resolveConfigPath(): string {
|
||||
return path.join(resolveConfigDir(), entry.configFile);
|
||||
}
|
||||
|
||||
function generatePluginScript(hookScriptPath: string, guardScriptPath: string): string {
|
||||
return `// Elydora Audit Plugin for OpenCode
|
||||
// Generated by @elydora/sdk — DO NOT EDIT
|
||||
|
||||
import { spawn, spawnSync } from 'node:child_process';
|
||||
|
||||
const HOOK_SCRIPT_PATH = ${JSON.stringify(hookScriptPath)};
|
||||
const GUARD_SCRIPT_PATH = ${JSON.stringify(guardScriptPath)};
|
||||
|
||||
export const ElydoraAuditPlugin = async (ctx) => {
|
||||
return {
|
||||
"tool.execute.before": async () => {
|
||||
// Guard — blocks tool if agent is frozen
|
||||
let result;
|
||||
try {
|
||||
result = spawnSync('node', [GUARD_SCRIPT_PATH], {
|
||||
timeout: 5000,
|
||||
stdio: ['pipe', 'ignore', 'pipe'],
|
||||
});
|
||||
} catch (error) {
|
||||
throw new Error('Elydora guard failed: ' + error.message);
|
||||
}
|
||||
|
||||
if (result.error) {
|
||||
throw new Error('Elydora guard failed: ' + result.error.message);
|
||||
}
|
||||
if (result.status !== 0) {
|
||||
const message = result.stderr?.toString().trim() || 'Guard exited with status ' + result.status;
|
||||
const prefix = result.status === 2 ? '' : 'Elydora guard failed: ';
|
||||
throw new Error(prefix + message);
|
||||
}
|
||||
},
|
||||
|
||||
"tool.execute.after": async (input, output) => {
|
||||
const data = JSON.stringify({
|
||||
tool_name: input?.tool || 'unknown',
|
||||
tool_input: input?.args || output?.args || {},
|
||||
session_id: input?.sessionID || ctx?.project?.name || 'unknown',
|
||||
});
|
||||
const child = spawn('node', [HOOK_SCRIPT_PATH], {
|
||||
detached: true,
|
||||
stdio: ['pipe', 'ignore', 'ignore'],
|
||||
});
|
||||
child.on('error', (error) => {
|
||||
console.error('[elydora] audit hook failed:', error.message);
|
||||
});
|
||||
child.stdin.on('error', (error) => {
|
||||
console.error('[elydora] audit input failed:', error.message);
|
||||
});
|
||||
child.stdin.end(data);
|
||||
child.unref();
|
||||
},
|
||||
};
|
||||
};
|
||||
`;
|
||||
function validateSources(sources: Awaited<ReturnType<typeof readOpenCodeSources>>): void {
|
||||
requireAvailableOpenCodePlugin(sources.current);
|
||||
}
|
||||
|
||||
export const opencodePlugin: AgentPlugin = {
|
||||
async install(config: InstallConfig): Promise<void> {
|
||||
const configDir = resolveConfigDir();
|
||||
await fsp.mkdir(configDir, { recursive: true });
|
||||
managesRuntime: true,
|
||||
|
||||
const configPath = resolveConfigPath();
|
||||
const script = generatePluginScript(config.hookScriptPath, config.guardScriptPath);
|
||||
|
||||
await fsp.writeFile(configPath, script, 'utf-8');
|
||||
async preflightInstall(config: InstallConfig): Promise<void> {
|
||||
const sources = await readOpenCodeSources();
|
||||
validateSources(sources);
|
||||
await preflightOpenCodeInstallation(config, sources);
|
||||
},
|
||||
|
||||
async uninstall(_agentId?: string): Promise<void> {
|
||||
const configPath = resolveConfigPath();
|
||||
try {
|
||||
await fsp.unlink(configPath);
|
||||
} catch {
|
||||
// Already removed
|
||||
}
|
||||
async install(config: InstallConfig): Promise<void> {
|
||||
const sources = await readOpenCodeSources();
|
||||
validateSources(sources);
|
||||
const prepared = await prepareOpenCodeInstallation(config, sources);
|
||||
await commitOpenCodeInstallation(prepared);
|
||||
console.log(` OpenCode global plugin: ${sources.paths.pluginPath}`);
|
||||
console.log(' OpenCode activation: restart active OpenCode sessions.');
|
||||
},
|
||||
|
||||
async uninstall(agentId?: string): Promise<void> {
|
||||
const sources = await readOpenCodeSources();
|
||||
const prepared = await prepareOpenCodeUninstall(sources, agentId);
|
||||
await commitOpenCodeUninstall(prepared);
|
||||
},
|
||||
|
||||
async status(): Promise<PluginStatus> {
|
||||
const configPath = resolveConfigPath();
|
||||
|
||||
let hookConfigured = false;
|
||||
let hookScriptPath = '';
|
||||
let guardScriptPath = '';
|
||||
try {
|
||||
const raw = await fsp.readFile(configPath, 'utf-8');
|
||||
const hookMatch = raw.match(/const HOOK_SCRIPT_PATH = ("(?:\\.|[^"\\])*");/);
|
||||
const guardMatch = raw.match(/const GUARD_SCRIPT_PATH = ("(?:\\.|[^"\\])*");/);
|
||||
if (hookMatch && guardMatch) {
|
||||
hookScriptPath = JSON.parse(hookMatch[1]);
|
||||
guardScriptPath = JSON.parse(guardMatch[1]);
|
||||
hookConfigured = true;
|
||||
}
|
||||
} catch {
|
||||
// File doesn't exist
|
||||
}
|
||||
|
||||
let hookScriptExists = false;
|
||||
if (hookScriptPath && guardScriptPath) {
|
||||
try {
|
||||
await fsp.access(hookScriptPath);
|
||||
await fsp.access(guardScriptPath);
|
||||
hookScriptExists = true;
|
||||
} catch {
|
||||
// File doesn't exist
|
||||
}
|
||||
}
|
||||
|
||||
const sources = await readOpenCodeSources();
|
||||
validateSources(sources);
|
||||
const contract = sources.legacy.contract ? undefined : sources.current.contract;
|
||||
const hookConfigured = contract !== undefined;
|
||||
const hookScriptExists = contract !== undefined
|
||||
&& await openCodeRuntimeFilesExist(contract);
|
||||
return {
|
||||
installed: hookConfigured && hookScriptExists,
|
||||
agentName: AGENT_KEY,
|
||||
displayName: entry.name,
|
||||
hookConfigured,
|
||||
hookScriptExists,
|
||||
configPath,
|
||||
configPath: sources.paths.pluginPath,
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -11,7 +11,7 @@ export const SUPPORTED_AGENTS: ReadonlyMap<string, AgentRegistryEntry> = new Map
|
||||
['gemini', { name: 'Gemini CLI', configDir: '~/.gemini', configFile: 'settings.json' }],
|
||||
['kirocli', { name: 'Kiro CLI', configDir: '~/.kiro/hooks', configFile: 'elydora-audit.json' }],
|
||||
['kiroide', { name: 'Kiro IDE', configDir: '.kiro/hooks', configFile: 'elydora-audit.json' }],
|
||||
['opencode', { name: 'OpenCode', configDir: '.config/opencode/plugins', configFile: 'elydora-audit.mjs' }],
|
||||
['opencode', { name: 'OpenCode', configDir: '.config/opencode/plugins', configFile: 'elydora-audit.js' }],
|
||||
['copilot', { name: 'GitHub Copilot CLI', configDir: '~/.copilot/hooks', configFile: 'elydora-audit.json' }],
|
||||
['letta', { name: 'Letta Code', configDir: '~/.letta', configFile: 'settings.json' }],
|
||||
['codex', { name: 'OpenAI Codex', configDir: '~/.codex', configFile: 'hooks.json' }],
|
||||
|
||||
Reference in new issue
Block a user