diff --git a/README.md b/README.md index bbfeca6..5b6adc9 100644 --- a/README.md +++ b/README.md @@ -68,9 +68,11 @@ npx elydora install \ | Claude Code | `claudecode` | | Cursor | `cursor` | | Gemini CLI | `gemini` | -| Augment Code | `augment` | -| Kiro | `kiro` | +| Kiro CLI | `kirocli` | +| Kiro IDE | `kiroide` | | OpenCode | `opencode` | +| Copilot CLI | `copilot` | +| Letta Code | `letta` | ## API Reference diff --git a/src/cli.ts b/src/cli.ts index 23d5c84..b4e42d9 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -9,9 +9,11 @@ import { generateHookScript, generateGuardScript } from './plugins/hook-template import { claudecodePlugin } from './plugins/claudecode.js'; import { cursorPlugin } from './plugins/cursor.js'; import { geminiPlugin } from './plugins/gemini.js'; -import { augmentPlugin } from './plugins/augment.js'; -import { kiroPlugin } from './plugins/kiro.js'; +import { kirocliPlugin } from './plugins/kirocli.js'; +import { kiroidePlugin } from './plugins/kiroide.js'; import { opencodePlugin } from './plugins/opencode.js'; +import { copilotPlugin } from './plugins/copilot.js'; +import { lettaPlugin } from './plugins/letta.js'; const ELYDORA_DIR = path.join(os.homedir(), '.elydora'); @@ -19,9 +21,11 @@ const PLUGINS: ReadonlyMap = new Map([ ['claudecode', claudecodePlugin], ['cursor', cursorPlugin], ['gemini', geminiPlugin], - ['augment', augmentPlugin], - ['kiro', kiroPlugin], + ['kirocli', kirocliPlugin], + ['kiroide', kiroidePlugin], ['opencode', opencodePlugin], + ['copilot', copilotPlugin], + ['letta', lettaPlugin], ]); // --------------------------------------------------------------------------- @@ -38,7 +42,7 @@ function printUsage(): void { Usage: elydora install --agent --org_id --agent_id --private_key --kid [--token ] [--base_url ] - elydora uninstall --agent + elydora uninstall --agent [--agent_id ] elydora status elydora agents @@ -102,40 +106,43 @@ async function cmdInstall(args: string[]): Promise { console.log(`Verifying private key... Public key: ${publicKey.slice(0, 12)}...`); - // Create ~/.elydora directory structure - const agentsDir = path.join(ELYDORA_DIR, 'agents'); - const hooksDir = path.join(ELYDORA_DIR, 'hooks'); - await fsp.mkdir(agentsDir, { recursive: true }); - await fsp.mkdir(hooksDir, { recursive: true }); + // Create ~/.elydora/{agentId}/ directory + const agentDir = path.join(ELYDORA_DIR, agentId); + await fsp.mkdir(agentDir, { recursive: true }); // Write agent config - const agentConfigPath = path.join(agentsDir, `${agentName}.json`); + const agentConfigPath = path.join(agentDir, 'config.json'); const agentConfig = { org_id: orgId, agent_id: agentId, kid, base_url: baseUrl, ...(token ? { token } : {}), + agent_name: agentName, }; await fsp.writeFile(agentConfigPath, JSON.stringify(agentConfig, null, 2) + '\n', 'utf-8'); - await fsp.chmod(agentConfigPath, 0o600); + if (process.platform !== 'win32') { + await fsp.chmod(agentConfigPath, 0o600); + } console.log(` Agent config: ${agentConfigPath}`); // Write private key (chmod 600) - const keyPath = path.join(agentsDir, `${agentName}.key`); + const keyPath = path.join(agentDir, 'private.key'); await fsp.writeFile(keyPath, privateKey, { encoding: 'utf-8', mode: 0o600 }); - await fsp.chmod(keyPath, 0o600); + if (process.platform !== 'win32') { + await fsp.chmod(keyPath, 0o600); + } console.log(` Private key: ${keyPath}`); // Generate and write hook script (PostToolUse — audit logging) - const hookScriptPath = path.join(hooksDir, `${agentName}-hook.js`); - const hookScript = generateHookScript(agentName); + const hookScriptPath = path.join(agentDir, 'hook.js'); + const hookScript = generateHookScript(agentName, agentId); await fsp.writeFile(hookScriptPath, hookScript, { encoding: 'utf-8', mode: 0o755 }); console.log(` Hook script: ${hookScriptPath}`); // Generate and write guard script (PreToolUse — freeze enforcement) - const guardScriptPath = path.join(hooksDir, `${agentName}-guard.js`); - const guardScript = generateGuardScript(agentName); + const guardScriptPath = path.join(agentDir, 'guard.js'); + const guardScript = generateGuardScript(agentName, agentId); await fsp.writeFile(guardScriptPath, guardScript, { encoding: 'utf-8', mode: 0o755 }); console.log(` Guard script: ${guardScriptPath}`); @@ -164,6 +171,7 @@ async function cmdUninstall(args: string[]): Promise { args, options: { agent: { type: 'string' }, + agent_id: { type: 'string' }, }, strict: true, }); @@ -174,25 +182,49 @@ async function cmdUninstall(args: string[]): Promise { die(`Unknown agent "${agentName}". Supported: ${Array.from(SUPPORTED_AGENTS.keys()).join(', ')}`); } + let agentId = values.agent_id; + + // If --agent_id not provided, scan ~/.elydora/*/config.json for matching agent_name + if (!agentId) { + try { + const entries = await fsp.readdir(ELYDORA_DIR, { withFileTypes: true }); + for (const entry of entries) { + if (!entry.isDirectory()) continue; + const cfgPath = path.join(ELYDORA_DIR, entry.name, 'config.json'); + try { + const raw = await fsp.readFile(cfgPath, 'utf-8'); + const cfg = JSON.parse(raw); + if (cfg.agent_name === agentName) { + agentId = entry.name; + break; + } + } catch { + // Skip unreadable configs + } + } + } catch { + // ELYDORA_DIR may not exist + } + if (!agentId) { + die(`No installed agent found for "${agentName}". Use --agent_id to specify explicitly.`); + } + } + const plugin = PLUGINS.get(agentName)!; - const entry = SUPPORTED_AGENTS.get(agentName)!; + const registryEntry = SUPPORTED_AGENTS.get(agentName)!; // Uninstall agent-specific config - await plugin.uninstall(); + await plugin.uninstall(agentId); - // Remove hook and guard scripts - const hookScriptPath = path.join(ELYDORA_DIR, 'hooks', `${agentName}-hook.js`); - const guardScriptPath = path.join(ELYDORA_DIR, 'hooks', `${agentName}-guard.js`); - try { await fsp.unlink(hookScriptPath); } catch { /* Already removed */ } - try { await fsp.unlink(guardScriptPath); } catch { /* Already removed */ } + // Remove entire agent directory + const agentDir = path.join(ELYDORA_DIR, agentId); + try { + await fsp.rm(agentDir, { recursive: true, force: true }); + } catch { + // Already removed + } - // Remove agent config and key - const agentConfigPath = path.join(ELYDORA_DIR, 'agents', `${agentName}.json`); - const keyPath = path.join(ELYDORA_DIR, 'agents', `${agentName}.key`); - try { await fsp.unlink(agentConfigPath); } catch { /* */ } - try { await fsp.unlink(keyPath); } catch { /* */ } - - console.log(`Elydora audit hook uninstalled for ${entry.name}.`); + console.log(`Elydora audit hook uninstalled for ${registryEntry.name}.`); } async function cmdStatus(): Promise { @@ -200,15 +232,42 @@ async function cmdStatus(): Promise { let anyInstalled = false; + // Scan ~/.elydora/*/config.json to discover installed agents + const installedAgents: Array<{ agentId: string; agentName: string; configPath: string }> = []; + try { + const entries = await fsp.readdir(ELYDORA_DIR, { withFileTypes: true }); + for (const entry of entries) { + if (!entry.isDirectory()) continue; + const cfgPath = path.join(ELYDORA_DIR, entry.name, 'config.json'); + try { + const raw = await fsp.readFile(cfgPath, 'utf-8'); + const cfg = JSON.parse(raw); + if (cfg.agent_name && cfg.agent_id) { + installedAgents.push({ agentId: entry.name, agentName: cfg.agent_name, configPath: cfgPath }); + } + } catch { + // Skip unreadable configs + } + } + } catch { + // ELYDORA_DIR may not exist + } + for (const [name, plugin] of PLUGINS) { const st = await plugin.status(); const statusIcon = st.installed ? '[installed]' : '[not installed]'; + // Find matching installed agent(s) for this plugin + const matching = installedAgents.filter((a) => a.agentName === name); + console.log(` ${st.displayName} (${name}) ${statusIcon}`); if (st.installed || st.hookConfigured || st.hookScriptExists) { console.log(` Hook config: ${st.hookConfigured ? 'yes' : 'no'}`); console.log(` Hook script: ${st.hookScriptExists ? 'yes' : 'no'}`); console.log(` Config path: ${st.configPath}`); + for (const m of matching) { + console.log(` Agent ID: ${m.agentId}`); + } } if (st.installed) anyInstalled = true; diff --git a/src/plugins/base.ts b/src/plugins/base.ts index 2ad3666..7e64532 100644 --- a/src/plugins/base.ts +++ b/src/plugins/base.ts @@ -21,6 +21,6 @@ export interface PluginStatus { export interface AgentPlugin { install(config: InstallConfig): Promise; - uninstall(): Promise; + uninstall(agentId?: string): Promise; status(): Promise; } diff --git a/src/plugins/claudecode.ts b/src/plugins/claudecode.ts index 1cbf6e6..9c79d48 100644 --- a/src/plugins/claudecode.ts +++ b/src/plugins/claudecode.ts @@ -19,14 +19,22 @@ function buildHookCommand(scriptPath: string): string { return `node "${scriptPath}"`; } -function filterElydoraEntries(arr: Array>): Array> { +function isElydoraCommand(cmd: string, agentId?: string): boolean { + if (!cmd.includes('elydora')) return false; + if (agentId) { + return cmd.includes(agentId); + } + return true; +} + +function filterElydoraEntries(arr: Array>, agentId?: string): Array> { return arr.filter((entry) => { if (Array.isArray(entry.hooks)) { const cmds = entry.hooks as Array>; - return !cmds.some((h) => typeof h.command === 'string' && h.command.includes('elydora')); + return !cmds.some((h) => typeof h.command === 'string' && isElydoraCommand(h.command, agentId)); } if (typeof entry.command === 'string') { - return !entry.command.includes('elydora'); + return !isElydoraCommand(entry.command, agentId); } return true; }); @@ -76,7 +84,7 @@ export const claudecodePlugin: AgentPlugin = { await fsp.writeFile(configPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8'); }, - async uninstall(): Promise { + async uninstall(agentId?: string): Promise { const configPath = resolveConfigPath(); let settings: Record; @@ -91,10 +99,10 @@ export const claudecodePlugin: AgentPlugin = { if (!hooks) return; if (Array.isArray(hooks.PreToolUse)) { - hooks.PreToolUse = filterElydoraEntries(hooks.PreToolUse as Array>); + hooks.PreToolUse = filterElydoraEntries(hooks.PreToolUse as Array>, agentId); } if (Array.isArray(hooks.PostToolUse)) { - hooks.PostToolUse = filterElydoraEntries(hooks.PostToolUse as Array>); + hooks.PostToolUse = filterElydoraEntries(hooks.PostToolUse as Array>, agentId); } await fsp.writeFile(configPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8'); @@ -102,9 +110,9 @@ export const claudecodePlugin: AgentPlugin = { async status(): Promise { const configPath = resolveConfigPath(); - const hookScriptPath = path.join(os.homedir(), '.elydora', 'hooks', `${AGENT_KEY}-hook.js`); let hookConfigured = false; + let hookScriptPath = ''; try { const raw = await fsp.readFile(configPath, 'utf-8'); const settings = JSON.parse(raw); @@ -122,17 +130,33 @@ export const claudecodePlugin: AgentPlugin = { }); }; hookConfigured = checkArr(hooks.PreToolUse) && checkArr(hooks.PostToolUse); + + // Extract hook script path from PostToolUse command + if (hookConfigured && Array.isArray(hooks.PostToolUse)) { + for (const e of hooks.PostToolUse as Array>) { + if (Array.isArray(e.hooks)) { + for (const h of e.hooks as Array>) { + const cmd = h.command as string; + if (cmd && cmd.includes('elydora')) { + hookScriptPath = cmd.replace(/^node\s+"?/, '').replace(/"?\s*$/, ''); + } + } + } + } + } } } catch { // Config not readable } let hookScriptExists = false; - try { - await fsp.access(hookScriptPath); - hookScriptExists = true; - } catch { - // File doesn't exist + if (hookScriptPath) { + try { + await fsp.access(hookScriptPath); + hookScriptExists = true; + } catch { + // File doesn't exist + } } return { diff --git a/src/plugins/copilot.ts b/src/plugins/copilot.ts new file mode 100644 index 0000000..b5870b1 --- /dev/null +++ b/src/plugins/copilot.ts @@ -0,0 +1,164 @@ +import fsp from 'node:fs/promises'; +import path from 'node:path'; +import type { AgentPlugin, InstallConfig, PluginStatus } from './base.js'; +import { SUPPORTED_AGENTS } from './registry.js'; + +const AGENT_KEY = 'copilot'; +const entry = SUPPORTED_AGENTS.get(AGENT_KEY)!; + +function resolveConfigDir(): string { + return path.join(process.cwd(), entry.configDir); +} + +function resolveConfigPath(): string { + return path.join(resolveConfigDir(), entry.configFile); +} + +function buildHookCommand(scriptPath: string): string { + return `node "${scriptPath}"`; +} + +function isElydoraCommand(cmd: string, agentId?: string): boolean { + if (!cmd.includes('elydora')) return false; + if (agentId) { + return cmd.includes(agentId); + } + return true; +} + +function filterElydoraEntries(arr: Array>, agentId?: string): Array> { + return arr.filter((h) => { + if (typeof h.bash === 'string' && isElydoraCommand(h.bash, agentId)) return false; + if (typeof h.powershell === 'string' && isElydoraCommand(h.powershell, agentId)) return false; + return true; + }); +} + +export const copilotPlugin: AgentPlugin = { + async install(config: InstallConfig): Promise { + const configDir = resolveConfigDir(); + await fsp.mkdir(configDir, { recursive: true }); + + const configPath = resolveConfigPath(); + let settings: Record = { version: 1 }; + + try { + const raw = await fsp.readFile(configPath, 'utf-8'); + settings = JSON.parse(raw); + } catch { + // Start fresh + } + + settings.version = 1; + + if (!settings.hooks || typeof settings.hooks !== 'object') { + settings.hooks = {}; + } + const hooks = settings.hooks as Record; + + // --- preToolUse (guard — freeze enforcement) --- + if (!Array.isArray(hooks.preToolUse)) { + hooks.preToolUse = []; + } + const preFiltered = filterElydoraEntries(hooks.preToolUse as Array>); + preFiltered.push({ + type: 'command', + bash: buildHookCommand(config.guardScriptPath), + powershell: buildHookCommand(config.guardScriptPath), + timeoutSec: 5, + }); + hooks.preToolUse = preFiltered; + + // --- postToolUse (audit logging) --- + if (!Array.isArray(hooks.postToolUse)) { + hooks.postToolUse = []; + } + const postFiltered = filterElydoraEntries(hooks.postToolUse as Array>); + postFiltered.push({ + type: 'command', + bash: buildHookCommand(config.hookScriptPath), + powershell: buildHookCommand(config.hookScriptPath), + timeoutSec: 5, + }); + hooks.postToolUse = postFiltered; + + settings.hooks = hooks; + await fsp.writeFile(configPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8'); + }, + + async uninstall(agentId?: string): Promise { + const configPath = resolveConfigPath(); + + let settings: Record; + try { + const raw = await fsp.readFile(configPath, 'utf-8'); + settings = JSON.parse(raw); + } catch { + return; + } + + const hooks = settings.hooks as Record | undefined; + if (!hooks) return; + + if (Array.isArray(hooks.preToolUse)) { + hooks.preToolUse = filterElydoraEntries(hooks.preToolUse as Array>, agentId); + } + if (Array.isArray(hooks.postToolUse)) { + hooks.postToolUse = filterElydoraEntries(hooks.postToolUse as Array>, agentId); + } + + await fsp.writeFile(configPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8'); + }, + + async status(): Promise { + const configPath = resolveConfigPath(); + + let hookConfigured = false; + let hookScriptPath = ''; + try { + const raw = await fsp.readFile(configPath, 'utf-8'); + const settings = JSON.parse(raw); + const hooks = settings.hooks as Record | undefined; + if (hooks) { + const check = (arr: unknown) => + Array.isArray(arr) && (arr as Array>).some( + (h) => + (typeof h.bash === 'string' && h.bash.includes('elydora')) || + (typeof h.powershell === 'string' && h.powershell.includes('elydora')), + ); + hookConfigured = check(hooks.preToolUse) && check(hooks.postToolUse); + + // Extract hook script path from postToolUse bash/powershell command + if (hookConfigured && Array.isArray(hooks.postToolUse)) { + for (const h of hooks.postToolUse as Array>) { + const cmd = (h.bash || h.powershell) as string; + if (cmd && cmd.includes('elydora')) { + hookScriptPath = cmd.replace(/^node\s+"?/, '').replace(/"?\s*$/, ''); + } + } + } + } + } catch { + // Config not readable + } + + let hookScriptExists = false; + if (hookScriptPath) { + try { + await fsp.access(hookScriptPath); + hookScriptExists = true; + } catch { + // File doesn't exist + } + } + + return { + installed: hookConfigured && hookScriptExists, + agentName: AGENT_KEY, + displayName: entry.name, + hookConfigured, + hookScriptExists, + configPath, + }; + }, +}; diff --git a/src/plugins/cursor.ts b/src/plugins/cursor.ts index a51fa24..eb02da7 100644 --- a/src/plugins/cursor.ts +++ b/src/plugins/cursor.ts @@ -20,9 +20,17 @@ function buildHookCommand(scriptPath: string): string { return `node "${scriptPath}"`; } -function filterElydoraEntries(arr: Array>): Array> { +function isElydoraCommand(cmd: string, agentId?: string): boolean { + if (!cmd.includes('elydora')) return false; + if (agentId) { + return cmd.includes(agentId); + } + return true; +} + +function filterElydoraEntries(arr: Array>, agentId?: string): Array> { return arr.filter( - (h) => typeof h.command === 'string' && !h.command.includes('elydora'), + (h) => typeof h.command === 'string' && !isElydoraCommand(h.command, agentId), ); } @@ -63,10 +71,11 @@ export const cursorPlugin: AgentPlugin = { hooks.postToolUse = postFiltered; settings.hooks = hooks; + settings.version = 1; await fsp.writeFile(configPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8'); }, - async uninstall(): Promise { + async uninstall(agentId?: string): Promise { const configPath = resolveConfigPath(); let settings: Record; @@ -81,10 +90,10 @@ export const cursorPlugin: AgentPlugin = { if (!hooks) return; if (Array.isArray(hooks.preToolUse)) { - hooks.preToolUse = filterElydoraEntries(hooks.preToolUse as Array>); + hooks.preToolUse = filterElydoraEntries(hooks.preToolUse as Array>, agentId); } if (Array.isArray(hooks.postToolUse)) { - hooks.postToolUse = filterElydoraEntries(hooks.postToolUse as Array>); + hooks.postToolUse = filterElydoraEntries(hooks.postToolUse as Array>, agentId); } await fsp.writeFile(configPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8'); @@ -92,9 +101,9 @@ export const cursorPlugin: AgentPlugin = { async status(): Promise { const configPath = resolveConfigPath(); - const hookScriptPath = path.join(os.homedir(), '.elydora', 'hooks', `${AGENT_KEY}-hook.js`); let hookConfigured = false; + let hookScriptPath = ''; try { const raw = await fsp.readFile(configPath, 'utf-8'); const settings = JSON.parse(raw); @@ -105,17 +114,29 @@ export const cursorPlugin: AgentPlugin = { (h) => typeof h.command === 'string' && h.command.includes('elydora'), ); hookConfigured = check(hooks.preToolUse) && check(hooks.postToolUse); + + // Extract hook script path from postToolUse command + if (hookConfigured && Array.isArray(hooks.postToolUse)) { + for (const h of hooks.postToolUse as Array>) { + const cmd = h.command as string; + if (cmd && cmd.includes('elydora')) { + hookScriptPath = cmd.replace(/^node\s+"?/, '').replace(/"?\s*$/, ''); + } + } + } } } catch { // Config not readable } let hookScriptExists = false; - try { - await fsp.access(hookScriptPath); - hookScriptExists = true; - } catch { - // File doesn't exist + if (hookScriptPath) { + try { + await fsp.access(hookScriptPath); + hookScriptExists = true; + } catch { + // File doesn't exist + } } return { diff --git a/src/plugins/gemini.ts b/src/plugins/gemini.ts index 256a561..62da261 100644 --- a/src/plugins/gemini.ts +++ b/src/plugins/gemini.ts @@ -19,13 +19,21 @@ function buildHookCommand(scriptPath: string): string { return `node "${scriptPath}"`; } -function filterElydoraEntries(arr: Array>): Array> { +function isElydoraCommand(cmd: string, agentId?: string): boolean { + if (!cmd.includes('elydora')) return false; + if (agentId) { + return cmd.includes(agentId); + } + return true; +} + +function filterElydoraEntries(arr: Array>, agentId?: string): Array> { return arr.filter((entry) => { if (Array.isArray(entry.hooks)) { const cmds = entry.hooks as Array>; - return !cmds.some((h) => typeof h.command === 'string' && h.command.includes('elydora')); + return !cmds.some((h) => typeof h.command === 'string' && isElydoraCommand(h.command, agentId)); } - if (typeof entry.command === 'string') return !entry.command.includes('elydora'); + if (typeof entry.command === 'string') return !isElydoraCommand(entry.command, agentId); return true; }); } @@ -74,7 +82,7 @@ export const geminiPlugin: AgentPlugin = { await fsp.writeFile(configPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8'); }, - async uninstall(): Promise { + async uninstall(agentId?: string): Promise { const configPath = resolveConfigPath(); let settings: Record; @@ -89,10 +97,10 @@ export const geminiPlugin: AgentPlugin = { if (!hooks) return; if (Array.isArray(hooks.BeforeTool)) { - hooks.BeforeTool = filterElydoraEntries(hooks.BeforeTool as Array>); + hooks.BeforeTool = filterElydoraEntries(hooks.BeforeTool as Array>, agentId); } if (Array.isArray(hooks.AfterTool)) { - hooks.AfterTool = filterElydoraEntries(hooks.AfterTool as Array>); + hooks.AfterTool = filterElydoraEntries(hooks.AfterTool as Array>, agentId); } await fsp.writeFile(configPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8'); @@ -100,9 +108,9 @@ export const geminiPlugin: AgentPlugin = { async status(): Promise { const configPath = resolveConfigPath(); - const hookScriptPath = path.join(os.homedir(), '.elydora', 'hooks', `${AGENT_KEY}-hook.js`); let hookConfigured = false; + let hookScriptPath = ''; try { const raw = await fsp.readFile(configPath, 'utf-8'); const settings = JSON.parse(raw); @@ -120,17 +128,33 @@ export const geminiPlugin: AgentPlugin = { }); }; hookConfigured = checkArr(hooks.BeforeTool) && checkArr(hooks.AfterTool); + + // Extract hook script path from AfterTool command + if (hookConfigured && Array.isArray(hooks.AfterTool)) { + for (const e of hooks.AfterTool as Array>) { + if (Array.isArray(e.hooks)) { + for (const h of e.hooks as Array>) { + const cmd = h.command as string; + if (cmd && cmd.includes('elydora')) { + hookScriptPath = cmd.replace(/^node\s+"?/, '').replace(/"?\s*$/, ''); + } + } + } + } + } } } catch { // Config not readable } let hookScriptExists = false; - try { - await fsp.access(hookScriptPath); - hookScriptExists = true; - } catch { - // File doesn't exist + if (hookScriptPath) { + try { + await fsp.access(hookScriptPath); + hookScriptExists = true; + } catch { + // File doesn't exist + } } return { diff --git a/src/plugins/hook-template.ts b/src/plugins/hook-template.ts index ef2e7a4..3d1de7d 100644 --- a/src/plugins/hook-template.ts +++ b/src/plugins/hook-template.ts @@ -3,7 +3,7 @@ * inline so it has zero runtime dependency on the SDK. */ -export function generateHookScript(agentName: string): string { +export function generateHookScript(agentName: string, agentId: string): string { return `#!/usr/bin/env node 'use strict'; @@ -17,11 +17,12 @@ const fs = require('node:fs'); const path = require('node:path'); const os = require('node:os'); const AGENT_NAME = ${JSON.stringify(agentName)}; +const AGENT_ID = ${JSON.stringify(agentId)}; const ELYDORA_DIR = path.join(os.homedir(), '.elydora'); -const CONFIG_PATH = path.join(ELYDORA_DIR, 'agents', AGENT_NAME + '.json'); -const KEY_PATH = path.join(ELYDORA_DIR, 'agents', AGENT_NAME + '.key'); -const CHAIN_STATE_PATH = path.join(ELYDORA_DIR, 'chain-state.json'); -const ERROR_LOG_PATH = path.join(ELYDORA_DIR, 'error.log'); +const CONFIG_PATH = path.join(ELYDORA_DIR, AGENT_ID, 'config.json'); +const KEY_PATH = path.join(ELYDORA_DIR, AGENT_ID, 'private.key'); +const CHAIN_STATE_PATH = path.join(ELYDORA_DIR, AGENT_ID, 'chain-state.json'); +const ERROR_LOG_PATH = path.join(ELYDORA_DIR, AGENT_ID, 'error.log'); // --------------------------------------------------------------------------- // Embedded crypto (from @elydora/sdk src/crypto.ts + src/utils.ts) @@ -302,7 +303,7 @@ main(); * - Fails open: if the API is unreachable, tool execution is allowed * - If frozen: exits with code 1 to block the tool */ -export function generateGuardScript(agentName: string): string { +export function generateGuardScript(agentName: string, agentId: string): string { return `#!/usr/bin/env node 'use strict'; @@ -316,9 +317,10 @@ const path = require('node:path'); const os = require('node:os'); const AGENT_NAME = ${JSON.stringify(agentName)}; +const AGENT_ID = ${JSON.stringify(agentId)}; const ELYDORA_DIR = path.join(os.homedir(), '.elydora'); -const CONFIG_PATH = path.join(ELYDORA_DIR, 'agents', AGENT_NAME + '.json'); -const STATUS_CACHE_PATH = path.join(ELYDORA_DIR, 'agents', AGENT_NAME + '.status.json'); +const CONFIG_PATH = path.join(ELYDORA_DIR, AGENT_ID, 'config.json'); +const STATUS_CACHE_PATH = path.join(ELYDORA_DIR, AGENT_ID, 'status-cache.json'); const CACHE_TTL_MS = 60000; // 60 seconds // Discard stdin immediately so the process can exit cleanly diff --git a/src/plugins/kirocli.ts b/src/plugins/kirocli.ts new file mode 100644 index 0000000..978fa05 --- /dev/null +++ b/src/plugins/kirocli.ts @@ -0,0 +1,173 @@ +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 { SUPPORTED_AGENTS } from './registry.js'; + +const AGENT_KEY = 'kirocli'; +const entry = SUPPORTED_AGENTS.get(AGENT_KEY)!; + +function resolveConfigDir(): string { + return entry.configDir.replace(/^~/, os.homedir()); +} + +function resolveConfigPath(): string { + return path.join(resolveConfigDir(), entry.configFile); +} + +function buildHookCommand(scriptPath: string): string { + return `node "${scriptPath}"`; +} + +function isElydoraCommand(cmd: string, agentId?: string): boolean { + if (!cmd.includes('elydora')) return false; + if (agentId) { + return cmd.includes(agentId); + } + return true; +} + +function filterElydoraEntries(arr: Array>, agentId?: string): Array> { + return arr.filter((entry) => { + if (Array.isArray(entry.hooks)) { + const cmds = entry.hooks as Array>; + return !cmds.some((h) => typeof h.command === 'string' && isElydoraCommand(h.command, agentId)); + } + if (typeof entry.command === 'string') { + return !isElydoraCommand(entry.command, agentId); + } + return true; + }); +} + +export const kirocliPlugin: AgentPlugin = { + async install(config: InstallConfig): Promise { + const configDir = resolveConfigDir(); + await fsp.mkdir(configDir, { recursive: true }); + + const configPath = resolveConfigPath(); + let settings: Record = {}; + + try { + const raw = await fsp.readFile(configPath, 'utf-8'); + settings = JSON.parse(raw); + } catch { + // File doesn't exist or isn't valid JSON — start fresh + } + + if (!settings.hooks || typeof settings.hooks !== 'object') { + settings.hooks = {}; + } + const hooks = settings.hooks as Record; + + // --- PreToolUse (guard — freeze enforcement) --- + if (!Array.isArray(hooks.PreToolUse)) { + hooks.PreToolUse = []; + } + const preFiltered = filterElydoraEntries(hooks.PreToolUse as Array>); + preFiltered.push({ + matcher: '*', + hooks: [{ type: 'command', command: buildHookCommand(config.guardScriptPath), timeout_ms: 5000 }], + }); + hooks.PreToolUse = preFiltered; + + // --- PostToolUse (audit logging) --- + if (!Array.isArray(hooks.PostToolUse)) { + hooks.PostToolUse = []; + } + const postFiltered = filterElydoraEntries(hooks.PostToolUse as Array>); + postFiltered.push({ + matcher: '*', + hooks: [{ type: 'command', command: buildHookCommand(config.hookScriptPath), timeout_ms: 5000 }], + }); + hooks.PostToolUse = postFiltered; + + settings.hooks = hooks; + await fsp.writeFile(configPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8'); + }, + + async uninstall(agentId?: string): Promise { + const configPath = resolveConfigPath(); + + let settings: Record; + try { + const raw = await fsp.readFile(configPath, 'utf-8'); + settings = JSON.parse(raw); + } catch { + return; // Nothing to uninstall + } + + const hooks = settings.hooks as Record | undefined; + if (!hooks) return; + + if (Array.isArray(hooks.PreToolUse)) { + hooks.PreToolUse = filterElydoraEntries(hooks.PreToolUse as Array>, agentId); + } + if (Array.isArray(hooks.PostToolUse)) { + hooks.PostToolUse = filterElydoraEntries(hooks.PostToolUse as Array>, agentId); + } + + await fsp.writeFile(configPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8'); + }, + + async status(): Promise { + const configPath = resolveConfigPath(); + + let hookConfigured = false; + let hookScriptPath = ''; + try { + const raw = await fsp.readFile(configPath, 'utf-8'); + const settings = JSON.parse(raw); + const hooks = settings.hooks as Record | undefined; + if (hooks) { + const checkArr = (arr: unknown) => { + if (!Array.isArray(arr)) return false; + return (arr as Array>).some((entry) => { + if (Array.isArray(entry.hooks)) { + return (entry.hooks as Array>).some( + (h) => typeof h.command === 'string' && h.command.includes('elydora'), + ); + } + return typeof entry.command === 'string' && entry.command.includes('elydora'); + }); + }; + hookConfigured = checkArr(hooks.PreToolUse) && checkArr(hooks.PostToolUse); + + // Extract hook script path from PostToolUse command + if (hookConfigured && Array.isArray(hooks.PostToolUse)) { + for (const e of hooks.PostToolUse as Array>) { + if (Array.isArray(e.hooks)) { + for (const h of e.hooks as Array>) { + const cmd = h.command as string; + if (cmd && cmd.includes('elydora')) { + hookScriptPath = cmd.replace(/^node\s+"?/, '').replace(/"?\s*$/, ''); + } + } + } + } + } + } + } catch { + // Config not readable + } + + let hookScriptExists = false; + if (hookScriptPath) { + try { + await fsp.access(hookScriptPath); + hookScriptExists = true; + } catch { + // File doesn't exist + } + } + + return { + installed: hookConfigured && hookScriptExists, + agentName: AGENT_KEY, + displayName: entry.name, + hookConfigured, + hookScriptExists, + configPath, + }; + }, +}; diff --git a/src/plugins/kiroide.ts b/src/plugins/kiroide.ts new file mode 100644 index 0000000..1fc958e --- /dev/null +++ b/src/plugins/kiroide.ts @@ -0,0 +1,94 @@ +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 { SUPPORTED_AGENTS } from './registry.js'; + +const AGENT_KEY = 'kiroide'; +const entry = SUPPORTED_AGENTS.get(AGENT_KEY)!; + +function resolveConfigDir(): string { + // .kiro/hooks is project-relative, but for global install use home dir + return path.join(os.homedir(), entry.configDir); +} + +function resolveConfigPath(): string { + return path.join(resolveConfigDir(), entry.configFile); +} + +export const kiroidePlugin: AgentPlugin = { + async install(config: InstallConfig): Promise { + const configDir = resolveConfigDir(); + await fsp.mkdir(configDir, { recursive: true }); + + const configPath = resolveConfigPath(); + + const hookConfig = { + name: 'Elydora Audit', + description: 'Sends tool-use events to the Elydora tamper-evident audit platform', + version: '1.0.0', + hooks: { + pre_tool_use: { + command: `node "${config.guardScriptPath}"`, + timeout_ms: 5000, + }, + post_tool_use: { + command: `node "${config.hookScriptPath}"`, + timeout_ms: 5000, + }, + }, + }; + + await fsp.writeFile(configPath, JSON.stringify(hookConfig, null, 2) + '\n', 'utf-8'); + }, + + async uninstall(_agentId?: string): Promise { + const configPath = resolveConfigPath(); + try { + await fsp.unlink(configPath); + } catch { + // Already removed + } + }, + + async status(): Promise { + const configPath = resolveConfigPath(); + + let hookConfigured = false; + let hookScriptPath = ''; + try { + const raw = await fsp.readFile(configPath, 'utf-8'); + const config = JSON.parse(raw); + hookConfigured = !!(config.hooks && config.hooks.pre_tool_use && config.hooks.post_tool_use); + + // Extract hook script path from post_tool_use command + if (hookConfigured && config.hooks.post_tool_use) { + const cmd = config.hooks.post_tool_use.command as string; + if (cmd && cmd.includes('elydora')) { + hookScriptPath = cmd.replace(/^node\s+"?/, '').replace(/"?\s*$/, ''); + } + } + } catch { + // Config not readable + } + + let hookScriptExists = false; + if (hookScriptPath) { + try { + await fsp.access(hookScriptPath); + hookScriptExists = true; + } catch { + // File doesn't exist + } + } + + return { + installed: hookConfigured && hookScriptExists, + agentName: AGENT_KEY, + displayName: entry.name, + hookConfigured, + hookScriptExists, + configPath, + }; + }, +}; diff --git a/src/plugins/augment.ts b/src/plugins/letta.ts similarity index 73% rename from src/plugins/augment.ts rename to src/plugins/letta.ts index 1919f81..e6d4d0e 100644 --- a/src/plugins/augment.ts +++ b/src/plugins/letta.ts @@ -4,7 +4,7 @@ import os from 'node:os'; import type { AgentPlugin, InstallConfig, PluginStatus } from './base.js'; import { SUPPORTED_AGENTS } from './registry.js'; -const AGENT_KEY = 'augment'; +const AGENT_KEY = 'letta'; const entry = SUPPORTED_AGENTS.get(AGENT_KEY)!; function resolveConfigDir(): string { @@ -19,18 +19,28 @@ function buildHookCommand(scriptPath: string): string { return `node "${scriptPath}"`; } -function filterElydoraEntries(arr: Array>): Array> { +function isElydoraCommand(cmd: string, agentId?: string): boolean { + if (!cmd.includes('elydora')) return false; + if (agentId) { + return cmd.includes(agentId); + } + return true; +} + +function filterElydoraEntries(arr: Array>, agentId?: string): Array> { return arr.filter((entry) => { if (Array.isArray(entry.hooks)) { const cmds = entry.hooks as Array>; - return !cmds.some((h) => typeof h.command === 'string' && h.command.includes('elydora')); + return !cmds.some((h) => typeof h.command === 'string' && isElydoraCommand(h.command, agentId)); + } + if (typeof entry.command === 'string') { + return !isElydoraCommand(entry.command, agentId); } - if (typeof entry.command === 'string') return !entry.command.includes('elydora'); return true; }); } -export const augmentPlugin: AgentPlugin = { +export const lettaPlugin: AgentPlugin = { async install(config: InstallConfig): Promise { const configDir = resolveConfigDir(); await fsp.mkdir(configDir, { recursive: true }); @@ -42,7 +52,7 @@ export const augmentPlugin: AgentPlugin = { const raw = await fsp.readFile(configPath, 'utf-8'); settings = JSON.parse(raw); } catch { - // Start fresh + // File doesn't exist or isn't valid JSON — start fresh } if (!settings.hooks || typeof settings.hooks !== 'object') { @@ -56,6 +66,7 @@ export const augmentPlugin: AgentPlugin = { } const preFiltered = filterElydoraEntries(hooks.PreToolUse as Array>); preFiltered.push({ + matcher: '*', hooks: [{ type: 'command', command: buildHookCommand(config.guardScriptPath) }], }); hooks.PreToolUse = preFiltered; @@ -66,6 +77,7 @@ export const augmentPlugin: AgentPlugin = { } const postFiltered = filterElydoraEntries(hooks.PostToolUse as Array>); postFiltered.push({ + matcher: '*', hooks: [{ type: 'command', command: buildHookCommand(config.hookScriptPath) }], }); hooks.PostToolUse = postFiltered; @@ -74,7 +86,7 @@ export const augmentPlugin: AgentPlugin = { await fsp.writeFile(configPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8'); }, - async uninstall(): Promise { + async uninstall(agentId?: string): Promise { const configPath = resolveConfigPath(); let settings: Record; @@ -82,17 +94,17 @@ export const augmentPlugin: AgentPlugin = { const raw = await fsp.readFile(configPath, 'utf-8'); settings = JSON.parse(raw); } catch { - return; + return; // Nothing to uninstall } const hooks = settings.hooks as Record | undefined; if (!hooks) return; if (Array.isArray(hooks.PreToolUse)) { - hooks.PreToolUse = filterElydoraEntries(hooks.PreToolUse as Array>); + hooks.PreToolUse = filterElydoraEntries(hooks.PreToolUse as Array>, agentId); } if (Array.isArray(hooks.PostToolUse)) { - hooks.PostToolUse = filterElydoraEntries(hooks.PostToolUse as Array>); + hooks.PostToolUse = filterElydoraEntries(hooks.PostToolUse as Array>, agentId); } await fsp.writeFile(configPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8'); @@ -100,9 +112,9 @@ export const augmentPlugin: AgentPlugin = { async status(): Promise { const configPath = resolveConfigPath(); - const hookScriptPath = path.join(os.homedir(), '.elydora', 'hooks', `${AGENT_KEY}-hook.js`); let hookConfigured = false; + let hookScriptPath = ''; try { const raw = await fsp.readFile(configPath, 'utf-8'); const settings = JSON.parse(raw); @@ -120,17 +132,33 @@ export const augmentPlugin: AgentPlugin = { }); }; hookConfigured = checkArr(hooks.PreToolUse) && checkArr(hooks.PostToolUse); + + // Extract hook script path from PostToolUse command + if (hookConfigured && Array.isArray(hooks.PostToolUse)) { + for (const e of hooks.PostToolUse as Array>) { + if (Array.isArray(e.hooks)) { + for (const h of e.hooks as Array>) { + const cmd = h.command as string; + if (cmd && cmd.includes('elydora')) { + hookScriptPath = cmd.replace(/^node\s+"?/, '').replace(/"?\s*$/, ''); + } + } + } + } + } } } catch { // Config not readable } let hookScriptExists = false; - try { - await fsp.access(hookScriptPath); - hookScriptExists = true; - } catch { - // File doesn't exist + if (hookScriptPath) { + try { + await fsp.access(hookScriptPath); + hookScriptExists = true; + } catch { + // File doesn't exist + } } return { diff --git a/src/plugins/opencode.ts b/src/plugins/opencode.ts index cb06f76..59ba42a 100644 --- a/src/plugins/opencode.ts +++ b/src/plugins/opencode.ts @@ -8,7 +8,6 @@ const AGENT_KEY = 'opencode'; const entry = SUPPORTED_AGENTS.get(AGENT_KEY)!; function resolveConfigDir(): string { - // .opencode/plugins is project-relative, but for global install use home dir return path.join(os.homedir(), entry.configDir); } @@ -20,50 +19,54 @@ function generatePluginScript(hookScriptPath: string, guardScriptPath: string): return `// Elydora Audit Plugin for OpenCode // Generated by @elydora/sdk — DO NOT EDIT -const { spawn, spawnSync } = require('node:child_process'); +import { spawn, spawnSync } from 'node:child_process'; -module.exports = { - name: 'elydora-audit', - version: '1.0.0', - description: 'Sends tool-use events to the Elydora tamper-evident audit platform', - - hooks: { - preToolUse(context) { - // Synchronous guard — blocks tool if agent is frozen +export const ElydoraAuditPlugin = async (ctx) => { + return { + "tool.execute.before": async (input, output) => { + // Guard — blocks tool if agent is frozen try { const result = spawnSync('node', ['__ELYDORA_GUARD_SCRIPT_PATH__'], { timeout: 5000, stdio: ['pipe', 'ignore', 'pipe'], }); - if (result.status !== 0) { + if (result.status === 2) { const msg = result.stderr ? result.stderr.toString().trim() : 'Agent is frozen by Elydora.'; - return { blocked: true, reason: msg }; + // Signal denial by setting a blocked flag + if (output && typeof output === 'object') { + output.blocked = true; + output.reason = msg; + } } } catch { // Fail-open — allow if guard can't run } }, - postToolUse(context) { + "tool.execute.after": async (input, output) => { try { - const input = JSON.stringify({ - tool_name: context.toolName || context.tool_name || 'unknown', - tool_input: context.toolInput || context.tool_input || {}, - session_id: context.sessionId || context.session_id || 'unknown', + const toolName = input?.tool || 'unknown'; + const toolInput = output?.args || input?.args || {}; + const sessionId = ctx?.project?.name || 'unknown'; + + const data = JSON.stringify({ + tool_name: toolName, + tool_input: toolInput, + session_id: sessionId, }); const child = spawn('node', ['__ELYDORA_HOOK_SCRIPT_PATH__'], { detached: true, stdio: ['pipe', 'ignore', 'ignore'], }); - child.stdin.write(input); + child.stdin.write(data); child.stdin.end(); child.unref(); } catch { // Fire-and-forget — never block the developer } }, - }, + }; }; `; } @@ -81,7 +84,7 @@ export const opencodePlugin: AgentPlugin = { await fsp.writeFile(configPath, script, 'utf-8'); }, - async uninstall(): Promise { + async uninstall(_agentId?: string): Promise { const configPath = resolveConfigPath(); try { await fsp.unlink(configPath); @@ -92,22 +95,31 @@ export const opencodePlugin: AgentPlugin = { async status(): Promise { const configPath = resolveConfigPath(); - const hookScriptPath = path.join(os.homedir(), '.elydora', 'hooks', `${AGENT_KEY}-hook.js`); let hookConfigured = false; + let hookScriptPath = ''; try { - await fsp.access(configPath); + const raw = await fsp.readFile(configPath, 'utf-8'); hookConfigured = true; + + // Extract hook script path from plugin script content + // The plugin script contains a spawn call like: spawn('node', ['']) + const match = raw.match(/spawn\('node',\s*\['([^']*elydora[^']*)'\]/); + if (match) { + hookScriptPath = match[1]; + } } catch { // File doesn't exist } let hookScriptExists = false; - try { - await fsp.access(hookScriptPath); - hookScriptExists = true; - } catch { - // File doesn't exist + if (hookScriptPath) { + try { + await fsp.access(hookScriptPath); + hookScriptExists = true; + } catch { + // File doesn't exist + } } return { diff --git a/src/plugins/registry.ts b/src/plugins/registry.ts index 584cbef..26cc996 100644 --- a/src/plugins/registry.ts +++ b/src/plugins/registry.ts @@ -8,7 +8,9 @@ export const SUPPORTED_AGENTS: ReadonlyMap = new Map ['claudecode', { name: 'Claude Code', configDir: '~/.claude', configFile: 'settings.json' }], ['cursor', { name: 'Cursor', configDir: '.cursor', configFile: 'hooks.json' }], ['gemini', { name: 'Gemini CLI', configDir: '~/.gemini', configFile: 'settings.json' }], - ['augment', { name: 'Augment Code', configDir: '~/.augment', configFile: 'settings.json' }], - ['kiro', { name: 'Kiro', configDir: '.kiro/hooks', configFile: 'elydora-audit.kiro.hook' }], - ['opencode', { name: 'OpenCode', configDir: '.opencode/plugins', configFile: 'elydora-audit.js' }], + ['kirocli', { name: 'Kiro CLI', configDir: '~/.kiro', configFile: 'settings.json' }], + ['kiroide', { name: 'Kiro IDE', configDir: '.kiro/hooks', configFile: 'elydora-audit.kiro.hook' }], + ['opencode', { name: 'OpenCode', configDir: '.config/opencode/plugins', configFile: 'elydora-audit.mjs' }], + ['copilot', { name: 'Copilot CLI', configDir: '.github/hooks', configFile: 'hooks.json' }], + ['letta', { name: 'Letta Code', configDir: '~/.letta', configFile: 'settings.json' }], ]);