diff --git a/AGENTS.md b/AGENTS.md index e5fd69c..0d21421 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,6 +23,7 @@ This repository owns the published `@elydora/sdk` package, its `elydora` CLI, lo - Report installation as healthy only when a complete hook contract references both generated runtime scripts and both scripts exist. - Model stable, legacy, and early-access hook generations as explicit contracts. Keep their activation requirements visible in CLI output and README guidance. - Keep Grok Build writes inside its native global `$GROK_HOME/hooks/*.json` contract. Treat Claude Code and Cursor compatibility files plus project `.grok/hooks` as read-only integration sources. +- Write Auggie hooks only to `~/.augment/settings.json`; keep system and workspace settings read-only. Generate `.cmd` wrappers on Windows and `.sh` wrappers on Unix because Auggie dispatches supported script paths, and express hook timeouts in milliseconds. ## Code Quality diff --git a/README.md b/README.md index 6f3bcd2..42ac3a6 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,7 @@ npx elydora install \ | Agent | Key | |-------|-----| +| Augment Code CLI | `augment` | | Claude Code | `claudecode` | | OpenAI Codex | `codex` | | Kimi Code | `kimi` | @@ -83,6 +84,8 @@ Kimi installation writes the strict hook contract to each detected runtime: Kimi Grok Build installation writes native global hooks to `$GROK_HOME/hooks/elydora-audit.json` (default `~/.grok/hooks/elydora-audit.json`). Project hooks still follow Grok's `/hooks-trust` workflow; Elydora leaves project, Claude Code, and Cursor compatibility files unchanged. +Auggie installation writes user-level hooks to `~/.augment/settings.json` and creates the `.cmd` or `.sh` wrappers required by its command runner. System and workspace settings remain unchanged. Sessions started with `--augment-cache-dir` load settings from that alternate directory. + Kiro CLI installation covers both runtime contracts. Kiro CLI v2 uses the generated custom agent through `kiro-cli --agent elydora-audit`. Kiro CLI v3 loads the global standalone hooks when started with `kiro-cli --v3`. ## API Reference diff --git a/src/cli.ts b/src/cli.ts index ddb0a10..0e0db8f 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -6,6 +6,7 @@ import { derivePublicKey } from './crypto.js'; import { SUPPORTED_AGENTS } from './plugins/registry.js'; import type { AgentPlugin, InstallConfig } from './plugins/base.js'; import { generateHookScript, generateGuardScript } from './plugins/hook-template.js'; +import { augmentPlugin } from './plugins/augment.js'; import { claudecodePlugin } from './plugins/claudecode.js'; import { cursorPlugin } from './plugins/cursor.js'; import { geminiPlugin } from './plugins/gemini.js'; @@ -21,6 +22,7 @@ import { grokPlugin } from './plugins/grok.js'; const ELYDORA_DIR = path.join(os.homedir(), '.elydora'); const PLUGINS: ReadonlyMap = new Map([ + ['augment', augmentPlugin], ['claudecode', claudecodePlugin], ['cursor', cursorPlugin], ['gemini', geminiPlugin], diff --git a/src/plugins/augment-contract.ts b/src/plugins/augment-contract.ts new file mode 100644 index 0000000..c8147f0 --- /dev/null +++ b/src/plugins/augment-contract.ts @@ -0,0 +1,316 @@ +import os from 'node:os'; +import path from 'node:path'; + +export const AGENT_KEY = 'augment'; +export const GUARD_SCRIPT = 'guard.js'; +export const AUDIT_SCRIPT = 'hook.js'; +export const HOOK_TIMEOUT_MILLISECONDS = 10_000; + +const WRAPPER_EXTENSION = process.platform === 'win32' ? '.cmd' : '.sh'; +export const GUARD_WRAPPER = `augment-guard${WRAPPER_EXTENSION}`; +export const AUDIT_WRAPPER = `augment-hook${WRAPPER_EXTENSION}`; + +const TOOL_EVENTS = new Set(['PreToolUse', 'PostToolUse']); +const SESSION_EVENTS = new Set([ + 'Stop', + 'SessionStart', + 'SessionEnd', + 'Notification', + 'PromptSubmit', +]); + +export type JsonObject = Record; + +export interface AugmentHandler extends JsonObject { + readonly type: 'command'; + readonly command: string; + readonly args?: string[]; + readonly timeout?: number; +} + +export interface AugmentGroup extends JsonObject { + readonly matcher?: string; + readonly hooks: AugmentHandler[]; +} + +export type AugmentHooks = Record; + +export interface AugmentDocument { + readonly exists: boolean; + readonly configPath: string; + readonly root: JsonObject; + readonly hooks: AugmentHooks; +} + +export interface RuntimeContract { + readonly agentId: string; + readonly guardPath: string; + readonly auditPath: string; + readonly guardWrapperPath: string; + readonly auditWrapperPath: string; +} + +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 hasOwn(value: JsonObject, key: string): boolean { + return Object.prototype.hasOwnProperty.call(value, key); +} + +function quotePosix(value: string): string { + return `'${value.replaceAll("'", `'"'"'`)}'`; +} + +function quoteWindows(value: string): string { + return `"${value.replaceAll('"', '\\"')}"`; +} + +function quoteBatch(value: string): string { + return `"${value.replaceAll('%', '%%')}"`; +} + +function readWindowsArgument(command: string, start: number): ParsedArgument | undefined { + if (command[start] !== '"') return undefined; + let value = ''; + for (let index = start + 1; index < command.length; index += 1) { + if (command[index] === '\\' && command[index + 1] === '"') { + value += '"'; + index += 1; + continue; + } + if (command[index] === '"') return { value, next: index + 1 }; + value += command[index]; + } + return undefined; +} + +const POSIX_APOSTROPHE = `'"'"'`; + +function readPosixArgument(command: string, start: number): ParsedArgument | undefined { + if (command[start] !== "'") return undefined; + let value = ''; + for (let index = start + 1; index < command.length;) { + if (command.startsWith(POSIX_APOSTROPHE, index)) { + value += "'"; + index += POSIX_APOSTROPHE.length; + continue; + } + if (command[index] === "'") return { value, next: index + 1 }; + value += command[index]; + index += 1; + } + return undefined; +} + +function parseWrapperCommand(command: string): string | undefined { + const readArgument = process.platform === 'win32' ? readWindowsArgument : readPosixArgument; + const parsed = readArgument(command, 0); + return parsed?.next === command.length && parsed.value ? parsed.value : undefined; +} + +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 wrapperPaths(agentId: string): { + readonly guardPath: string; + readonly auditPath: string; +} { + const agentDirectory = path.join(os.homedir(), '.elydora', agentId); + return { + guardPath: path.join(agentDirectory, GUARD_WRAPPER), + auditPath: path.join(agentDirectory, AUDIT_WRAPPER), + }; +} + +export function buildHandler(wrapperPath: string): AugmentHandler { + const quote = process.platform === 'win32' ? quoteWindows : quotePosix; + return { + type: 'command', + command: quote(wrapperPath), + timeout: HOOK_TIMEOUT_MILLISECONDS, + }; +} + +export function buildWrapper(runtimePath: string): string { + if (process.platform === 'win32') { + return `@echo off\r\n${quoteBatch(process.execPath)} ${quoteBatch(runtimePath)}\r\nexit /b %errorlevel%\r\n`; + } + return `#!/bin/sh\nexec ${quotePosix(process.execPath)} ${quotePosix(runtimePath)}\n`; +} + +function managedAgentId(handler: AugmentHandler, wrapperName: string): string | undefined { + if (handler.type !== 'command' + || handler.timeout !== HOOK_TIMEOUT_MILLISECONDS + || typeof handler.command !== 'string' + || hasOwn(handler, 'args')) return undefined; + const wrapperPath = parseWrapperCommand(handler.command); + if (!wrapperPath) return undefined; + const actualName = path.basename(wrapperPath); + const namesMatch = process.platform === 'win32' + ? actualName.toLowerCase() === wrapperName.toLowerCase() + : actualName === wrapperName; + if (!namesMatch) return undefined; + const agentDirectory = path.dirname(wrapperPath); + if (!samePath(path.dirname(agentDirectory), path.join(os.homedir(), '.elydora'))) return undefined; + const agentId = path.basename(agentDirectory); + return agentId && agentId !== '.' && agentId !== '..' ? agentId : undefined; +} + +function validateHandler( + value: unknown, + event: string, + groupIndex: number, + handlerIndex: number, +): AugmentHandler { + const label = `Auggie settings handler hooks.${event}[${groupIndex}].hooks[${handlerIndex}]`; + if (!isObject(value)) throw new Error(`${label} must be an object`); + if (value.type !== 'command') throw new Error(`${label} type must be "command"`); + if (typeof value.command !== 'string' || value.command.length === 0) { + throw new Error(`${label} requires a non-empty command`); + } + if (value.args !== undefined + && (!Array.isArray(value.args) || !value.args.every((argument) => typeof argument === 'string'))) { + throw new Error(`${label} args must be an array of strings`); + } + if (value.timeout !== undefined + && (typeof value.timeout !== 'number' || !Number.isFinite(value.timeout) || value.timeout <= 0)) { + throw new Error(`${label} timeout must be a positive finite number`); + } + return value as AugmentHandler; +} + +function validateMetadata(value: unknown, label: string): void { + if (!isObject(value)) throw new Error(`${label} metadata must be an object`); + for (const key of ['includeConversationData', 'includeMCPMetadata', 'includeUserContext']) { + if (value[key] !== undefined && typeof value[key] !== 'boolean') { + throw new Error(`${label} metadata.${key} must be a boolean`); + } + } +} + +function validateGroup(value: unknown, event: string, groupIndex: number): AugmentGroup { + const label = `Auggie settings group hooks.${event}[${groupIndex}]`; + if (!isObject(value)) throw new Error(`${label} must be an object`); + if (SESSION_EVENTS.has(event) && hasOwn(value, 'matcher')) { + throw new Error(`${label} matcher is only supported for tool events`); + } + if (value.matcher !== undefined) { + if (typeof value.matcher !== 'string') throw new Error(`${label} matcher must be a string`); + try { + new RegExp(value.matcher); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(`${label} matcher must be a valid regular expression: ${message}`); + } + } + if (value.metadata !== undefined) validateMetadata(value.metadata, label); + if (!Array.isArray(value.hooks)) throw new Error(`${label} must contain a hooks array`); + const handlers = value.hooks.map( + (handler, handlerIndex) => validateHandler(handler, event, groupIndex, handlerIndex), + ); + return { ...value, hooks: handlers } as AugmentGroup; +} + +export function readHooks(root: JsonObject): AugmentHooks { + if (root.hooks === undefined) return {}; + if (!isObject(root.hooks)) throw new Error('Auggie settings field "hooks" must be an object'); + const hooks: AugmentHooks = {}; + for (const [event, value] of Object.entries(root.hooks)) { + if (!TOOL_EVENTS.has(event) && !SESSION_EVENTS.has(event)) { + throw new Error(`Auggie settings field "hooks.${event}" uses an unsupported event`); + } + if (!Array.isArray(value)) { + throw new Error(`Auggie settings field "hooks.${event}" must be an array`); + } + hooks[event] = value.map((group, index) => validateGroup(group, event, index)); + } + return hooks; +} + +function removeManaged( + groups: AugmentGroup[], + wrapperName: string, + agentId?: string, +): { readonly groups: AugmentGroup[]; readonly changed: boolean } { + let changed = false; + const result: AugmentGroup[] = []; + for (const group of groups) { + let groupChanged = false; + const handlers = group.hooks.filter((handler) => { + const managedId = managedAgentId(handler, wrapperName); + const remove = managedId !== undefined + && (agentId === undefined || sameAgentId(managedId, agentId)); + if (remove) { + changed = true; + groupChanged = true; + } + return !remove; + }); + if (!groupChanged) result.push(group); + else if (handlers.length > 0) result.push({ ...group, hooks: handlers }); + } + return { groups: result, changed }; +} + +export function removeManagedHooks(hooks: AugmentHooks, agentId?: string): { + readonly hooks: AugmentHooks; + readonly changed: boolean; +} { + const next = { ...hooks }; + let changed = false; + for (const [event, wrapperName] of [ + ['PreToolUse', GUARD_WRAPPER], + ['PostToolUse', AUDIT_WRAPPER], + ] as const) { + const result = removeManaged(next[event] ?? [], wrapperName, agentId); + if (!result.changed) continue; + changed = true; + if (result.groups.length > 0) next[event] = result.groups; + else delete next[event]; + } + return { hooks: next, changed }; +} + +function managedIds(groups: AugmentGroup[], wrapperName: string): Set { + const ids = new Set(); + for (const group of groups) { + for (const handler of group.hooks) { + const agentId = managedAgentId(handler, wrapperName); + if (agentId) ids.add(agentId); + } + } + return ids; +} + +export function runtimeContracts(hooks: AugmentHooks): RuntimeContract[] { + const guards = managedIds(hooks.PreToolUse ?? [], GUARD_WRAPPER); + const audits = managedIds(hooks.PostToolUse ?? [], AUDIT_WRAPPER); + const root = path.join(os.homedir(), '.elydora'); + return [...guards] + .filter((agentId) => [...audits].some((auditId) => sameAgentId(auditId, agentId))) + .map((agentId) => { + const agentDirectory = path.join(root, agentId); + return { + agentId, + guardPath: path.join(agentDirectory, GUARD_SCRIPT), + auditPath: path.join(agentDirectory, AUDIT_SCRIPT), + guardWrapperPath: path.join(agentDirectory, GUARD_WRAPPER), + auditWrapperPath: path.join(agentDirectory, AUDIT_WRAPPER), + }; + }); +} diff --git a/src/plugins/augment-io.ts b/src/plugins/augment-io.ts new file mode 100644 index 0000000..d1af151 --- /dev/null +++ b/src/plugins/augment-io.ts @@ -0,0 +1,182 @@ +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 { + AGENT_KEY, + type AugmentDocument, + type JsonObject, + type RuntimeContract, + isObject, + readHooks, +} from './augment-contract.js'; + +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; +} + +export function resolveConfigPath(): string { + return path.join(os.homedir(), '.augment', 'settings.json'); +} + +async function readJsonObject(filePath: string, label: string): Promise { + let raw: string; + try { + raw = 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), + }); + } + let value: unknown; + try { + value = JSON.parse(raw); + } catch (error) { + throw new Error(`Failed to parse ${label} at ${filePath}: ${errorMessage(error)}`, { + cause: asError(error), + }); + } + if (!isObject(value)) throw new Error(`${label} at ${filePath} must contain a JSON object`); + return value; +} + +export async function readConfig(): Promise { + const configPath = resolveConfigPath(); + const root = await readJsonObject(configPath, 'Auggie settings'); + if (!root) return { exists: false, configPath, root: {}, hooks: {} }; + return { exists: true, configPath, root, hooks: readHooks(root) }; +} + +async function failWrite( + handle: FileHandle | undefined, + tempPath: string, + label: string, + cause: unknown, +): Promise { + const errors = [asError(cause)]; + if (handle) { + try { + await handle.close(); + } catch (error) { + errors.push(asError(error)); + } + } + try { + await fsp.unlink(tempPath); + } catch (error) { + if (!hasErrorCode(error, 'ENOENT')) errors.push(asError(error)); + } + const message = `Write ${label}: ${errorMessage(cause)}`; + if (errors.length > 1) throw new AggregateError(errors, message); + throw new Error(message, { cause: errors[0] }); +} + +async function writeAtomic( + filePath: string, + contents: string, + mode: number, + label: string, +): Promise { + const directory = path.dirname(filePath); + await fsp.mkdir(directory, { recursive: true, mode: 0o700 }); + const tempPath = path.join(directory, `.${path.basename(filePath)}.${randomUUID()}.tmp`); + let handle: FileHandle | undefined; + try { + handle = await fsp.open(tempPath, 'wx', mode); + await handle.writeFile(contents, 'utf-8'); + await handle.sync(); + await handle.close(); + handle = undefined; + await fsp.rename(tempPath, filePath); + } catch (error) { + await failWrite(handle, tempPath, `${label} at ${filePath}`, error); + } +} + +export async function writeConfig(configPath: string, root: JsonObject): Promise { + await writeAtomic( + configPath, + JSON.stringify(root, null, 2) + '\n', + 0o600, + 'Auggie settings', + ); +} + +export async function writeWrapper(wrapperPath: string, contents: string): Promise { + await writeAtomic(wrapperPath, contents, 0o700, 'Auggie hook wrapper'); +} + +export async function removeConfig(configPath: string): Promise { + try { + await fsp.unlink(configPath); + } catch (error) { + if (hasErrorCode(error, 'ENOENT')) return; + throw new Error(`Remove Auggie settings at ${configPath}: ${errorMessage(error)}`, { + cause: asError(error), + }); + } +} + +export async function regularFileExists(filePath: string, label: string): Promise { + 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 { + if (!filePath) throw new Error(`${label} path is required`); + if (!await regularFileExists(filePath, label)) throw new Error(`${label} is missing: ${filePath}`); +} + +function sameAgentId(left: string, right: string): boolean { + return process.platform === 'win32' ? left.toLowerCase() === right.toLowerCase() : left === right; +} + +export async function runtimeFilesExist(contracts: RuntimeContract[]): Promise { + const root = path.join(os.homedir(), '.elydora'); + let entries: Array<{ isDirectory(): boolean; name: string }>; + try { + entries = await fsp.readdir(root, { withFileTypes: true }); + } catch (error) { + if (hasErrorCode(error, 'ENOENT')) return false; + throw new Error(`Read Elydora runtime directory at ${root}: ${errorMessage(error)}`, { + cause: asError(error), + }); + } + for (const contract of contracts) { + const directory = entries.find( + (item) => item.isDirectory() && sameAgentId(item.name, contract.agentId), + ); + if (!directory) continue; + const agentDirectory = path.join(root, directory.name); + const runtimeConfigPath = path.join(agentDirectory, 'config.json'); + const runtimeConfig = await readJsonObject(runtimeConfigPath, 'Elydora runtime config'); + if (!runtimeConfig || runtimeConfig.agent_name !== AGENT_KEY) continue; + const files = [ + [contract.guardPath, 'Elydora guard runtime'], + [contract.auditPath, 'Elydora audit runtime'], + [contract.guardWrapperPath, 'Auggie guard wrapper'], + [contract.auditWrapperPath, 'Auggie audit wrapper'], + ] as const; + const existence = await Promise.all( + files.map(([filePath, label]) => regularFileExists(filePath, label)), + ); + if (existence.every(Boolean)) return true; + } + return false; +} diff --git a/src/plugins/augment.ts b/src/plugins/augment.ts new file mode 100644 index 0000000..7b44174 --- /dev/null +++ b/src/plugins/augment.ts @@ -0,0 +1,75 @@ +import type { AgentPlugin, InstallConfig, PluginStatus } from './base.js'; +import { SUPPORTED_AGENTS } from './registry.js'; +import { + AGENT_KEY, + buildHandler, + buildWrapper, + removeManagedHooks, + runtimeContracts, + wrapperPaths, +} from './augment-contract.js'; +import { + readConfig, + removeConfig, + requireRuntime, + runtimeFilesExist, + writeConfig, + writeWrapper, +} from './augment-io.js'; + +const entry = SUPPORTED_AGENTS.get(AGENT_KEY)!; + +export const augmentPlugin: AgentPlugin = { + async install(config: InstallConfig): Promise { + if (!config.agentId) throw new Error('agentId is required'); + const document = await readConfig(); + await requireRuntime(config.guardScriptPath, 'Elydora guard runtime'); + await requireRuntime(config.hookScriptPath, 'Elydora audit runtime'); + + const wrappers = wrapperPaths(config.agentId); + await writeWrapper(wrappers.guardPath, buildWrapper(config.guardScriptPath)); + await writeWrapper(wrappers.auditPath, buildWrapper(config.hookScriptPath)); + + const cleaned = removeManagedHooks(document.hooks).hooks; + const hooks = { + ...cleaned, + PreToolUse: [ + ...(cleaned.PreToolUse ?? []), + { matcher: '.*', hooks: [buildHandler(wrappers.guardPath)] }, + ], + PostToolUse: [ + ...(cleaned.PostToolUse ?? []), + { matcher: '.*', hooks: [buildHandler(wrappers.auditPath)] }, + ], + }; + await writeConfig(document.configPath, { ...document.root, hooks }); + console.log(' Auggie: user-level PreToolUse and PostToolUse hooks installed.'); + }, + + async uninstall(agentId?: string): Promise { + const document = await readConfig(); + if (!document.exists) return; + const result = removeManagedHooks(document.hooks, agentId); + if (!result.changed) return; + const root = { ...document.root }; + if (Object.keys(result.hooks).length > 0) root.hooks = result.hooks; + else delete root.hooks; + if (Object.keys(root).length === 0) await removeConfig(document.configPath); + else await writeConfig(document.configPath, root); + }, + + async status(): Promise { + const document = await readConfig(); + const contracts = runtimeContracts(document.hooks); + const hookConfigured = contracts.length > 0; + const hookScriptExists = hookConfigured ? await runtimeFilesExist(contracts) : false; + return { + installed: hookConfigured && hookScriptExists, + agentName: AGENT_KEY, + displayName: entry.name, + hookConfigured, + hookScriptExists, + configPath: document.configPath, + }; + }, +}; diff --git a/src/plugins/registry.ts b/src/plugins/registry.ts index 893995b..5697f6d 100644 --- a/src/plugins/registry.ts +++ b/src/plugins/registry.ts @@ -5,6 +5,7 @@ export interface AgentRegistryEntry { } export const SUPPORTED_AGENTS: ReadonlyMap = new Map([ + ['augment', { name: 'Augment Code CLI', configDir: '~/.augment', configFile: 'settings.json' }], ['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' }], diff --git a/test/augment-plugin.test.mjs b/test/augment-plugin.test.mjs new file mode 100644 index 0000000..f07f3f4 --- /dev/null +++ b/test/augment-plugin.test.mjs @@ -0,0 +1,453 @@ +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import { mkdtemp, mkdir, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; +import { pathToFileURL } from 'node:url'; + +const pluginModuleUrl = pathToFileURL(path.resolve('dist/plugins/augment.js')).href; +const registryModuleUrl = pathToFileURL(path.resolve('dist/plugins/registry.js')).href; +const cliPath = path.resolve('dist/cli.js'); +const wrapperExtension = process.platform === 'win32' ? '.cmd' : '.sh'; +const guardWrapperName = `augment-guard${wrapperExtension}`; +const auditWrapperName = `augment-hook${wrapperExtension}`; + +function runNode(args, env, input = '', cwd) { + return new Promise((resolve, reject) => { + const child = spawn(process.execPath, args, { + cwd, + env: { ...process.env, ...env }, + stdio: ['pipe', 'pipe', 'pipe'], + }); + let stdout = ''; + let stderr = ''; + child.stdout.on('data', (chunk) => { stdout += chunk; }); + child.stderr.on('data', (chunk) => { stderr += chunk; }); + child.once('error', reject); + child.once('close', (code) => resolve({ code, stdout, stderr })); + child.stdin.end(input); + }); +} + +function runCommand(command, input = '') { + return new Promise((resolve, reject) => { + const child = spawn(command, { shell: true, stdio: ['pipe', 'pipe', 'pipe'] }); + let stdout = ''; + let stderr = ''; + child.stdout.on('data', (chunk) => { stdout += chunk; }); + child.stderr.on('data', (chunk) => { stderr += chunk; }); + child.once('error', reject); + child.once('close', (code) => resolve({ code, stdout, stderr })); + child.stdin.end(input); + }); +} + +function quotePosix(value) { + return `'${value.replaceAll("'", `'"'"'`)}'`; +} + +function quoteWindows(value) { + return `"${value.replaceAll('"', '\\"')}"`; +} + +function generatedCommand(wrapperPath) { + return process.platform === 'win32' ? quoteWindows(wrapperPath) : quotePosix(wrapperPath); +} + +async function runPlugin(fixture, method, argument) { + const script = ` + import { augmentPlugin } from ${JSON.stringify(pluginModuleUrl)}; + const argument = JSON.parse(process.env.ELYDORA_TEST_ARGUMENT); + const result = await augmentPlugin[process.env.ELYDORA_TEST_METHOD](argument); + if (result !== undefined) console.log(JSON.stringify(result)); + `; + return runNode( + ['--input-type=module', '--eval', script], + { + HOME: fixture.homeDir, + USERPROFILE: fixture.homeDir, + ELYDORA_TEST_ARGUMENT: JSON.stringify(argument), + ELYDORA_TEST_METHOD: method, + }, + '', + fixture.workspaceDir, + ); +} + +async function createFixture({ existingSettings, guardSource, hookSource } = {}) { + const rootDir = await mkdtemp(path.join(os.tmpdir(), 'elydora-augment-')); + const homeDir = path.join(rootDir, "home with spaces and 'quote"); + const workspaceDir = path.join(homeDir, 'workspace'); + const agentDir = path.join(homeDir, '.elydora', 'agent-1'); + const configPath = path.join(homeDir, '.augment', 'settings.json'); + const guardScriptPath = path.join(agentDir, 'guard.js'); + const hookScriptPath = path.join(agentDir, 'hook.js'); + const guardWrapperPath = path.join(agentDir, guardWrapperName); + const auditWrapperPath = path.join(agentDir, auditWrapperName); + await mkdir(workspaceDir, { recursive: true }); + await mkdir(agentDir, { recursive: true }); + await writeFile( + guardScriptPath, + guardSource ?? "process.stderr.write('Agent is frozen by Elydora.'); process.exit(2);\n", + ); + await writeFile(hookScriptPath, hookSource ?? 'process.exit(0);\n'); + await writeFile(path.join(agentDir, 'config.json'), JSON.stringify({ + agent_id: 'agent-1', + agent_name: 'augment', + })); + if (existingSettings !== undefined) { + await mkdir(path.dirname(configPath), { recursive: true }); + await writeFile(configPath, typeof existingSettings === 'string' + ? existingSettings + : JSON.stringify(existingSettings, null, 2)); + } + const fixture = { + agentDir, + auditWrapperPath, + configPath, + guardScriptPath, + guardWrapperPath, + homeDir, + hookScriptPath, + workspaceDir, + async close() { + await rm(rootDir, { recursive: true, force: true }); + }, + }; + fixture.installResult = await runPlugin(fixture, 'install', { + agentName: 'augment', + agentId: 'agent-1', + baseUrl: 'https://api.elydora.com', + guardScriptPath, + hookScriptPath, + }); + return fixture; +} + +function managedHandler(settings, event, wrapperPath) { + const command = generatedCommand(wrapperPath); + for (const group of settings.hooks?.[event] ?? []) { + const handler = group.hooks.find((candidate) => candidate.command === command); + if (handler) return handler; + } + return undefined; +} + +test('Augment Code CLI is registered in the SDK and CLI', async () => { + const { SUPPORTED_AGENTS } = await import(registryModuleUrl); + assert.deepEqual(SUPPORTED_AGENTS.get('augment'), { + name: 'Augment Code CLI', + configDir: '~/.augment', + configFile: 'settings.json', + }); + const homeDir = await mkdtemp(path.join(os.tmpdir(), 'elydora-augment-cli-')); + const workspaceDir = path.join(homeDir, 'workspace'); + await mkdir(workspaceDir); + try { + const result = await runNode(['--no-warnings', cliPath, 'status'], { + HOME: homeDir, + USERPROFILE: homeDir, + }, '', workspaceDir); + assert.equal(result.code, 0, result.stderr); + assert.match(result.stdout, /Augment Code CLI \(augment\)/); + } finally { + await rm(homeDir, { recursive: true, force: true }); + } +}); + +test('Auggie install preserves user settings and is idempotent', async () => { + const existing = { + telemetryEnabled: false, + hooks: { + SessionStart: [{ + hooks: [{ type: 'command', command: 'existing-command', args: ['one'], timeout: 5_000 }], + metadata: { includeUserContext: true }, + label: 'keep group metadata', + }], + PreToolUse: [{ + matcher: 'launch-process', + hooks: [{ type: 'command', command: 'user-command' }], + }], + }, + }; + const fixture = await createFixture({ existingSettings: existing }); + try { + assert.equal(fixture.installResult.code, 0, fixture.installResult.stderr); + const second = await runPlugin(fixture, 'install', { + agentName: 'augment', + agentId: 'agent-1', + guardScriptPath: fixture.guardScriptPath, + hookScriptPath: fixture.hookScriptPath, + }); + assert.equal(second.code, 0, second.stderr); + const settings = JSON.parse(await readFile(fixture.configPath, 'utf-8')); + assert.equal(settings.telemetryEnabled, false); + assert.deepEqual(settings.hooks.SessionStart, existing.hooks.SessionStart); + assert.deepEqual(settings.hooks.PreToolUse[0], existing.hooks.PreToolUse[0]); + assert.equal(settings.hooks.PreToolUse.length, 2); + assert.equal(settings.hooks.PostToolUse.length, 1); + assert.equal(settings.hooks.PreToolUse[1].matcher, '.*'); + for (const [event, wrapperPath] of [ + ['PreToolUse', fixture.guardWrapperPath], + ['PostToolUse', fixture.auditWrapperPath], + ]) { + const handler = managedHandler(settings, event, wrapperPath); + assert.deepEqual(Object.keys(handler).sort(), ['command', 'timeout', 'type']); + assert.equal(handler.type, 'command'); + assert.equal(handler.timeout, 10_000); + } + const guardWrapper = await readFile(fixture.guardWrapperPath, 'utf-8'); + const auditWrapper = await readFile(fixture.auditWrapperPath, 'utf-8'); + assert.match(guardWrapper, new RegExp(path.basename(fixture.guardScriptPath).replace('.', '\\.'))); + assert.match(auditWrapper, new RegExp(path.basename(fixture.hookScriptPath).replace('.', '\\.'))); + if (process.platform !== 'win32') { + assert.equal((await stat(fixture.guardWrapperPath)).mode & 0o111, 0o100); + assert.match(guardWrapper, /^#!\/bin\/sh\nexec /); + } else { + assert.match(guardWrapper, /^@echo off\r?\n/); + assert.match(guardWrapper, /exit \/b %errorlevel%/); + } + await assert.rejects( + readFile(path.join(fixture.workspaceDir, '.augment', 'settings.json')), + { code: 'ENOENT' }, + ); + await assert.rejects( + readFile(path.join(fixture.workspaceDir, '.augment', 'settings.local.json')), + { code: 'ENOENT' }, + ); + } finally { + await fixture.close(); + } +}); + +test('Auggie wrappers block freezes and forward official input byte-for-byte', async () => { + const capturePath = path.join(os.tmpdir(), `elydora-augment-event-${process.pid}-${Date.now()}.json`); + const hookSource = ` + const fs = require('node:fs'); + const chunks = []; + process.stdin.on('data', (chunk) => chunks.push(chunk)); + process.stdin.on('end', () => fs.writeFileSync(${JSON.stringify(capturePath)}, Buffer.concat(chunks))); + `; + const fixture = await createFixture({ hookSource }); + try { + assert.equal(fixture.installResult.code, 0, fixture.installResult.stderr); + const settings = JSON.parse(await readFile(fixture.configPath, 'utf-8')); + const guard = managedHandler(settings, 'PreToolUse', fixture.guardWrapperPath); + const audit = managedHandler(settings, 'PostToolUse', fixture.auditWrapperPath); + const prePayload = JSON.stringify({ + hook_event_name: 'PreToolUse', + conversation_id: 'conversation-1', + workspace_roots: [fixture.workspaceDir], + tool_name: 'launch-process', + tool_input: { command: 'echo test' }, + is_mcp_tool: false, + }); + const guardResult = await runCommand(guard.command, prePayload); + assert.equal(guardResult.code, 2); + assert.match(guardResult.stderr, /Agent is frozen by Elydora/); + const postPayload = JSON.stringify({ + hook_event_name: 'PostToolUse', + conversation_id: 'conversation-1', + workspace_roots: [fixture.workspaceDir], + tool_name: 'launch-process', + tool_input: { command: 'echo test' }, + tool_output: 'test', + is_mcp_tool: false, + }); + const auditResult = await runCommand(audit.command, postPayload); + assert.equal(auditResult.code, 0, auditResult.stderr); + assert.equal(await readFile(capturePath, 'utf-8'), postPayload); + } finally { + await fixture.close(); + await rm(capturePath, { force: true }); + } +}); + +test('Auggie status requires a complete pair, core runtimes, and wrappers', async () => { + const fixture = await createFixture(); + try { + assert.equal(fixture.installResult.code, 0, fixture.installResult.stderr); + let status = await runPlugin(fixture, 'status', null); + assert.equal(JSON.parse(status.stdout).installed, true); + await rm(fixture.guardWrapperPath); + status = await runPlugin(fixture, 'status', null); + assert.equal(JSON.parse(status.stdout).installed, false); + await runPlugin(fixture, 'install', { + agentName: 'augment', + agentId: 'agent-1', + guardScriptPath: fixture.guardScriptPath, + hookScriptPath: fixture.hookScriptPath, + }); + await rm(fixture.hookScriptPath); + status = await runPlugin(fixture, 'status', null); + assert.equal(JSON.parse(status.stdout).installed, false); + } finally { + await fixture.close(); + } +}); + +test('Auggie uninstall removes exact ownership and preserves user groups', async () => { + const fixture = await createFixture({ existingSettings: { + owner: 'user', + hooks: { Notification: [] }, + } }); + try { + assert.equal(fixture.installResult.code, 0, fixture.installResult.stderr); + const settings = JSON.parse(await readFile(fixture.configPath, 'utf-8')); + settings.hooks.PreToolUse.unshift({ hooks: [], label: 'keep empty group' }); + settings.hooks.PreToolUse[1].hooks.push({ type: 'command', command: 'user-command' }); + settings.hooks.PreToolUse.push({ hooks: [{ + type: 'command', + command: generatedCommand(`${fixture.guardWrapperPath}.backup`), + timeout: 10_000, + }] }); + settings.hooks.PreToolUse.push({ hooks: [{ + type: 'command', + command: generatedCommand(path.join(fixture.agentDir, '..', 'agent-10', guardWrapperName)), + timeout: 10_000, + }] }); + await writeFile(fixture.configPath, JSON.stringify(settings, null, 2)); + const uninstallId = process.platform === 'win32' ? 'AGENT-1' : 'agent-1'; + const result = await runPlugin(fixture, 'uninstall', uninstallId); + assert.equal(result.code, 0, result.stderr); + const remaining = JSON.parse(await readFile(fixture.configPath, 'utf-8')); + assert.equal(remaining.owner, 'user'); + assert.deepEqual(remaining.hooks.Notification, []); + assert.equal(remaining.hooks.PreToolUse.length, 4); + assert.deepEqual(remaining.hooks.PreToolUse[0], { hooks: [], label: 'keep empty group' }); + assert.equal(remaining.hooks.PreToolUse[1].hooks[0].command, 'user-command'); + assert.match(JSON.stringify(remaining), /augment-guard.*backup/); + assert.match(JSON.stringify(remaining), /agent-10/); + assert.equal(remaining.hooks.PostToolUse, undefined); + } finally { + await fixture.close(); + } +}); + +test('Auggie install replaces stale Elydora handlers and preserves empty groups', async () => { + const fixture = await createFixture(); + try { + assert.equal(fixture.installResult.code, 0, fixture.installResult.stderr); + const settings = JSON.parse(await readFile(fixture.configPath, 'utf-8')); + settings.hooks.PreToolUse.unshift({ hooks: [], label: 'keep empty group' }); + for (const [event, wrapperName] of [ + ['PreToolUse', guardWrapperName], + ['PostToolUse', auditWrapperName], + ]) { + settings.hooks[event].push({ hooks: [{ + type: 'command', + command: generatedCommand(path.join(fixture.agentDir, '..', 'agent-old', wrapperName)), + timeout: 10_000, + }] }); + } + await writeFile(fixture.configPath, JSON.stringify(settings, null, 2)); + const result = await runPlugin(fixture, 'install', { + agentName: 'augment', + agentId: 'agent-1', + guardScriptPath: fixture.guardScriptPath, + hookScriptPath: fixture.hookScriptPath, + }); + assert.equal(result.code, 0, result.stderr); + const current = JSON.parse(await readFile(fixture.configPath, 'utf-8')); + assert.doesNotMatch(JSON.stringify(current), /agent-old/); + assert.deepEqual(current.hooks.PreToolUse[0], { hooks: [], label: 'keep empty group' }); + assert.equal(current.hooks.PreToolUse.length, 2); + assert.equal(current.hooks.PostToolUse.length, 1); + } finally { + await fixture.close(); + } +}); + +test('Auggie uninstall removes settings owned entirely by Elydora', async () => { + const fixture = await createFixture(); + try { + assert.equal(fixture.installResult.code, 0, fixture.installResult.stderr); + const result = await runPlugin(fixture, 'uninstall', 'agent-1'); + assert.equal(result.code, 0, result.stderr); + await assert.rejects(readFile(fixture.configPath), { code: 'ENOENT' }); + } finally { + await fixture.close(); + } +}); + +test('Auggie rejects malformed settings and invalid hook contracts before writes', async () => { + const cases = [ + '{ malformed', + 'null', + '[]', + { hooks: null }, + { hooks: { UnknownEvent: [] } }, + { hooks: { PreToolUse: null } }, + { hooks: { PreToolUse: [null] } }, + { hooks: { SessionStart: [{ matcher: '.*', hooks: [] }] } }, + { hooks: { PreToolUse: [{ matcher: '[', hooks: [] }] } }, + { hooks: { PreToolUse: [{ hooks: null }] } }, + { hooks: { PreToolUse: [{ hooks: [{ type: 'http', command: 'x' }] }] } }, + { hooks: { PreToolUse: [{ hooks: [{ type: 'command', command: '', args: [] }] }] } }, + { hooks: { PreToolUse: [{ hooks: [{ type: 'command', command: 'x', args: [1] }] }] } }, + { hooks: { PreToolUse: [{ hooks: [{ type: 'command', command: 'x', timeout: 0 }] }] } }, + { hooks: { PreToolUse: [{ hooks: [], metadata: { includeUserContext: 'yes' } }] } }, + ]; + for (const existingSettings of cases) { + const fixture = await createFixture({ existingSettings }); + try { + assert.equal(fixture.installResult.code, 1, `${JSON.stringify(existingSettings)}\n${fixture.installResult.stderr}`); + const expected = typeof existingSettings === 'string' + ? existingSettings + : JSON.stringify(existingSettings, null, 2); + assert.equal(await readFile(fixture.configPath, 'utf-8'), expected); + await assert.rejects(readFile(fixture.guardWrapperPath), { code: 'ENOENT' }); + await assert.rejects(readFile(fixture.auditWrapperPath), { code: 'ENOENT' }); + } finally { + await fixture.close(); + } + } +}); + +test('Auggie install rejects missing runtimes before creating files', async () => { + const homeDir = await mkdtemp(path.join(os.tmpdir(), 'elydora-augment-missing-')); + const workspaceDir = path.join(homeDir, 'workspace'); + const agentDir = path.join(homeDir, '.elydora', 'agent-1'); + const fixture = { homeDir, workspaceDir }; + await mkdir(workspaceDir); + try { + const result = await runPlugin(fixture, 'install', { + agentName: 'augment', + agentId: 'agent-1', + guardScriptPath: path.join(agentDir, 'guard.js'), + hookScriptPath: path.join(agentDir, 'hook.js'), + }); + assert.equal(result.code, 1); + assert.match(result.stderr, /runtime is missing/i); + await assert.rejects(readFile(path.join(homeDir, '.augment', 'settings.json')), { code: 'ENOENT' }); + await assert.rejects(readFile(path.join(agentDir, guardWrapperName)), { code: 'ENOENT' }); + } finally { + await rm(homeDir, { recursive: true, force: true }); + } +}); + +test('Auggie status surfaces malformed referenced runtime metadata', async () => { + const fixture = await createFixture(); + try { + assert.equal(fixture.installResult.code, 0, fixture.installResult.stderr); + await writeFile(path.join(fixture.agentDir, 'config.json'), '{ malformed'); + const status = await runPlugin(fixture, 'status', null); + assert.equal(status.code, 1); + assert.match(status.stderr, /parse Elydora runtime config/i); + } finally { + await fixture.close(); + } +}); + +test('Auggie atomic writes leave no temporary files', async () => { + const fixture = await createFixture(); + try { + assert.equal(fixture.installResult.code, 0, fixture.installResult.stderr); + for (const directory of [fixture.agentDir, path.dirname(fixture.configPath)]) { + assert.equal((await readdir(directory)).some((name) => name.endsWith('.tmp')), false); + } + } finally { + await fixture.close(); + } +});