"""Kiro CLI v2 custom-agent and v3 standalone-hook integration.""" from __future__ import annotations import os import shlex import subprocess import sys from typing import Any, Dict, List, Optional, Tuple from ._file_io import read_json, regular_file_exists, remove_file, write_json_atomic, write_text_atomic from .base import AgentPlugin, InstallConfig, PluginStatus from .hook_template import generate_hook_script AGENT_KEY = "kirocli" V2_AGENT_NAME = "elydora-audit" V2_DESCRIPTION = "Kiro CLI with Elydora audit and freeze enforcement" V3_GUARD_NAME = "elydora-guard" V3_AUDIT_NAME = "elydora-audit" ELYDORA_DIR = os.path.join(os.path.expanduser("~"), ".elydora") V2_AGENT_PATH = os.path.join( os.path.expanduser("~"), ".kiro", "agents", f"{V2_AGENT_NAME}.json" ) V3_HOOKS_PATH = os.path.join( os.path.expanduser("~"), ".kiro", "hooks", "elydora-audit.json" ) JsonObject = Dict[str, Any] HookContract = Tuple[str, str, str] class KiroCliPlugin(AgentPlugin): """Install Elydora into both supported Kiro CLI hook generations.""" def install(self, config: InstallConfig) -> None: agent_id = config.get("agent_id", "") guard_path = config.get("guard_script_path", "") if not agent_id: raise ValueError("agent_id is required") if not guard_path: raise ValueError("guard_script_path is required") if not regular_file_exists(guard_path, "Elydora guard runtime"): raise FileNotFoundError(f"Elydora guard runtime is missing: {guard_path}") v2_settings = read_json(V2_AGENT_PATH, "Kiro CLI v2 agent config") or {} v3_settings = read_json(V3_HOOKS_PATH, "Kiro CLI v3 hooks config") or {} v2_hooks = _hooks_object(v2_settings, "Kiro CLI v2 agent config") current_v3_hooks = _v3_hooks(v3_settings) v2_hooks["preToolUse"] = [ *_without_v2_hooks( _hook_entries(v2_hooks, "preToolUse", "Kiro CLI v2 agent config") ), _build_v2_hook(guard_path), ] agent_dir = os.path.join(ELYDORA_DIR, agent_id) hook_path = os.path.join(agent_dir, "hook.py") v2_hooks["postToolUse"] = [ *_without_v2_hooks( _hook_entries(v2_hooks, "postToolUse", "Kiro CLI v2 agent config") ), _build_v2_hook(hook_path), ] next_v2_settings: JsonObject = { "name": V2_AGENT_NAME, "description": V2_DESCRIPTION, "tools": ["*"], "includeMcpJson": True, **v2_settings, "hooks": v2_hooks, } next_v3_settings: JsonObject = { **v3_settings, "version": "v1", "hooks": [ *[hook for hook in current_v3_hooks if not _is_managed_v3_hook(hook)], _build_v3_hook( V3_GUARD_NAME, "Block tool use when the Elydora agent is frozen", "PreToolUse", guard_path, ), _build_v3_hook( V3_AUDIT_NAME, "Record tool use in the Elydora audit trail", "PostToolUse", hook_path, ), ], } runtime_config = { "org_id": config.get("org_id", ""), "agent_id": agent_id, "kid": config.get("kid", ""), "base_url": config.get("base_url", "https://api.elydora.com"), "token": config.get("token", ""), "agent_name": config.get("agent_name", AGENT_KEY), } hook_script = generate_hook_script( org_id=config.get("org_id", ""), agent_id=agent_id, kid=config.get("kid", ""), base_url=config.get("base_url", "https://api.elydora.com"), ) write_json_atomic( os.path.join(agent_dir, "config.json"), runtime_config, 0o600, "Elydora runtime config", ) write_text_atomic( os.path.join(agent_dir, "private.key"), config.get("private_key", ""), 0o600, "Elydora private key", ) write_text_atomic(hook_path, hook_script, 0o700, "Elydora audit runtime") write_json_atomic(V2_AGENT_PATH, next_v2_settings, 0o600, "Kiro CLI v2 agent config") write_json_atomic(V3_HOOKS_PATH, next_v3_settings, 0o600, "Kiro CLI v3 hooks config") print('Kiro CLI v2: start with "kiro-cli --agent elydora-audit".') print('Kiro CLI v3: start with "kiro-cli --v3"; global hooks load automatically.') def uninstall(self, agent_id: str = "") -> None: v2_settings = read_json(V2_AGENT_PATH, "Kiro CLI v2 agent config") v3_settings = read_json(V3_HOOKS_PATH, "Kiro CLI v3 hooks config") next_v2: Optional[Tuple[JsonObject, bool]] = None if v2_settings is not None: v2_hooks = _hooks_object(v2_settings, "Kiro CLI v2 agent config") current_pre = _hook_entries(v2_hooks, "preToolUse", "Kiro CLI v2 agent config") current_post = _hook_entries(v2_hooks, "postToolUse", "Kiro CLI v2 agent config") next_pre = _without_v2_hooks(current_pre, agent_id) next_post = _without_v2_hooks(current_post, agent_id) if next_pre != current_pre or next_post != current_post: v2_hooks["preToolUse"] = next_pre v2_hooks["postToolUse"] = next_post next_v2 = ( {**v2_settings, "hooks": v2_hooks}, _is_owned_v2(v2_settings, v2_hooks), ) next_v3: Optional[Tuple[JsonObject, bool]] = None if v3_settings is not None: current_hooks = _v3_hooks(v3_settings) v3_hooks = [ hook for hook in current_hooks if not _is_managed_v3_hook(hook, agent_id) ] if v3_hooks != current_hooks: owned = all(key in {"version", "hooks"} for key in v3_settings) next_v3 = ({**v3_settings, "hooks": v3_hooks}, owned and not v3_hooks) if next_v2 is not None: settings, remove = next_v2 if remove: remove_file(V2_AGENT_PATH, "Kiro CLI v2 agent config") else: write_json_atomic(V2_AGENT_PATH, settings, 0o600, "Kiro CLI v2 agent config") if next_v3 is not None: settings, remove = next_v3 if remove: remove_file(V3_HOOKS_PATH, "Kiro CLI v3 hooks config") else: write_json_atomic(V3_HOOKS_PATH, settings, 0o600, "Kiro CLI v3 hooks config") print("Elydora hooks uninstalled from Kiro CLI.") def status(self) -> PluginStatus: v2_settings = read_json(V2_AGENT_PATH, "Kiro CLI v2 agent config") v3_settings = read_json(V3_HOOKS_PATH, "Kiro CLI v3 hooks config") contracts: List[HookContract] = [] if v2_settings is not None: v2_hooks = _hooks_object(v2_settings, "Kiro CLI v2 agent config") guard = _find_v2_command( _hook_entries(v2_hooks, "preToolUse", "Kiro CLI v2 agent config"), "guard.py", ) audit = _find_v2_command( _hook_entries(v2_hooks, "postToolUse", "Kiro CLI v2 agent config"), "hook.py", ) if guard and audit: contracts.append((guard, audit, V2_AGENT_PATH)) if v3_settings is not None: v3_hooks = _v3_hooks(v3_settings) guard = _find_v3_command(v3_hooks, V3_GUARD_NAME, "guard.py") audit = _find_v3_command(v3_hooks, V3_AUDIT_NAME, "hook.py") if guard and audit: contracts.append((guard, audit, V3_HOOKS_PATH)) if not contracts: return PluginStatus(installed=False, agent=AGENT_KEY, details="Not installed") config_path = contracts[-1][2] installed = _runtime_scripts_exist(contracts) details = ( f"Config: {config_path}" if installed else f"Configured at {config_path}; runtime scripts missing" ) return PluginStatus(installed=installed, agent=AGENT_KEY, details=details) def _build_command(script_path: str) -> str: arguments = [sys.executable, script_path] return subprocess.list2cmdline(arguments) if os.name == "nt" else shlex.join(arguments) def _hooks_object(settings: JsonObject, label: str) -> JsonObject: if "hooks" not in settings: return {} value = settings["hooks"] if not isinstance(value, dict): raise ValueError(f'{label} field "hooks" must be an object') return dict(value) def _hook_entries(hooks: JsonObject, event: str, label: str) -> List[JsonObject]: value = hooks.get(event) if value is None: return [] if not isinstance(value, list) or not all(isinstance(item, dict) for item in value): raise ValueError(f'{label} field "hooks.{event}" must be an array of objects') return value def _is_managed_command(command: Any, script_name: str, agent_id: str = "") -> bool: if not isinstance(command, str): return False normalized = command.lower() if ".elydora" not in normalized or script_name not in normalized: return False return not agent_id or agent_id in command def _without_v2_hooks( entries: List[JsonObject], agent_id: str = "" ) -> List[JsonObject]: return [ hook for hook in entries if not _is_managed_command(hook.get("command"), "guard.py", agent_id) and not _is_managed_command(hook.get("command"), "hook.py", agent_id) ] def _build_v2_hook(script_path: str) -> JsonObject: return { "matcher": "*", "command": _build_command(script_path), "timeout_ms": 5000, } def _is_owned_v2(settings: JsonObject, hooks: JsonObject) -> bool: config_keys = {"name", "description", "tools", "includeMcpJson", "hooks"} hook_keys = {"preToolUse", "postToolUse"} return ( all(key in config_keys for key in settings) and settings.get("name") == V2_AGENT_NAME and settings.get("description") == V2_DESCRIPTION and settings.get("tools") == ["*"] and settings.get("includeMcpJson") is True and all(key in hook_keys for key in hooks) and all(isinstance(value, list) and not value for value in hooks.values()) ) def _v3_hooks(settings: JsonObject) -> List[JsonObject]: if "version" in settings and settings["version"] != "v1": raise ValueError('Kiro CLI v3 hooks config field "version" must be "v1"') if "hooks" not in settings: return [] hooks = settings["hooks"] if not isinstance(hooks, list) or not all(isinstance(item, dict) for item in hooks): raise ValueError('Kiro CLI v3 hooks config field "hooks" must be an array of objects') return hooks def _v3_action_command(hook: JsonObject) -> Any: action = hook.get("action") return action.get("command") if isinstance(action, dict) else None def _is_managed_v3_hook(hook: JsonObject, agent_id: str = "") -> bool: if hook.get("name") == V3_GUARD_NAME: return _is_managed_command(_v3_action_command(hook), "guard.py", agent_id) if hook.get("name") == V3_AUDIT_NAME: return _is_managed_command(_v3_action_command(hook), "hook.py", agent_id) return False def _build_v3_hook( name: str, description: str, trigger: str, script_path: str, ) -> JsonObject: return { "name": name, "description": description, "trigger": trigger, "matcher": ".*", "action": {"type": "command", "command": _build_command(script_path)}, "timeout": 5, "enabled": True, } def _find_v2_command(entries: List[JsonObject], script_name: str) -> Optional[str]: for hook in entries: command = hook.get("command") if _is_managed_command(command, script_name): return command return None def _find_v3_command( entries: List[JsonObject], name: str, script_name: str ) -> Optional[str]: for hook in entries: command = _v3_action_command(hook) if hook.get("name") == name and _is_managed_command(command, script_name): return command return None def _runtime_scripts_exist(contracts: List[HookContract]) -> bool: try: with os.scandir(ELYDORA_DIR) as iterator: entries = sorted(iterator, key=lambda entry: entry.name) except FileNotFoundError: return False except OSError as error: raise OSError(f"Read Elydora runtime directory at {ELYDORA_DIR}: {error}") from error for entry in entries: try: is_directory = entry.is_dir(follow_symlinks=False) except OSError as error: raise OSError(f"Read Elydora runtime entry at {entry.path}: {error}") from error if not is_directory: continue guard_path = os.path.join(entry.path, "guard.py") hook_path = os.path.join(entry.path, "hook.py") references_runtime = any( guard_path in guard and hook_path in audit for guard, audit, _path in contracts ) if not references_runtime: continue config_path = os.path.join(entry.path, "config.json") config = read_json(config_path, "Elydora runtime config") if config is None or config.get("agent_name") != AGENT_KEY: continue return regular_file_exists( guard_path, "Elydora guard runtime" ) and regular_file_exists(hook_path, "Elydora audit runtime") return False