diff --git a/README.md b/README.md index 3d54908..8c6aff8 100644 --- a/README.md +++ b/README.md @@ -88,10 +88,12 @@ elydora install \ | Agent | Key | |-------|-----| | Claude Code | `claudecode` | +| Copilot CLI | `copilot` | | Cursor | `cursor` | | Gemini CLI | `gemini` | -| Augment Code | `augment` | -| Kiro | `kiro` | +| Kiro CLI | `kirocli` | +| Kiro IDE | `kiroide` | +| Letta Code | `letta` | | OpenCode | `opencode` | ## API Reference diff --git a/cmd/elydora/main.go b/cmd/elydora/main.go index 1d34afc..25abc90 100644 --- a/cmd/elydora/main.go +++ b/cmd/elydora/main.go @@ -1,6 +1,7 @@ package main import ( + "encoding/json" "flag" "fmt" "os" @@ -91,12 +92,12 @@ func cmdInstall(args []string) { } // Generate guard script (PreToolUse — freeze enforcement) - guardScriptPath, err := guardScriptPathForAgent(*agent) + guardScriptPath, err := guardScriptPathForAgent(*agentID) if err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) } - guardScript := plugins.GenerateGuardScript(*agent) + guardScript := plugins.GenerateGuardScript(*agent, *agentID) if err := os.MkdirAll(filepath.Dir(guardScriptPath), 0755); err != nil { fmt.Fprintf(os.Stderr, "Error creating guard script directory: %v\n", err) os.Exit(1) @@ -131,6 +132,7 @@ func cmdInstall(args []string) { func cmdUninstall(args []string) { fs := flag.NewFlagSet("uninstall", flag.ExitOnError) agent := fs.String("agent", "", "Agent name (required)") + agentID := fs.String("agent-id", "", "Agent ID (if omitted, scans ~/.elydora/*/config.json for matching agent_name)") fs.Parse(args) @@ -147,18 +149,66 @@ func cmdUninstall(args []string) { os.Exit(1) } - if err := plugin.Uninstall(); err != nil { + // Resolve agentID: if not given, scan ~/.elydora/*/config.json for matching agent_name + resolvedAgentID := *agentID + if resolvedAgentID == "" { + resolvedAgentID = findAgentIDByName(*agent) + if resolvedAgentID == "" { + fmt.Fprintf(os.Stderr, "Warning: could not find agent ID for %q in ~/.elydora/*/config.json\n", *agent) + } + } + + if err := plugin.Uninstall(resolvedAgentID); err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) } - // Also remove the guard script - guardPath, _ := guardScriptPathForAgent(*agent) - if guardPath != "" { - os.Remove(guardPath) + // Remove the per-agent directory + if resolvedAgentID != "" { + home, err := os.UserHomeDir() + if err == nil { + agentDir := filepath.Join(home, ".elydora", resolvedAgentID) + if err := os.RemoveAll(agentDir); err != nil { + fmt.Fprintf(os.Stderr, "Warning: failed to remove agent directory %s: %v\n", agentDir, err) + } else { + fmt.Printf(" Removed agent directory: %s\n", agentDir) + } + } } } +// findAgentIDByName scans ~/.elydora/*/config.json for a config whose agent_name +// matches the given name and returns the directory name (agent ID). +func findAgentIDByName(agentName string) string { + home, err := os.UserHomeDir() + if err != nil { + return "" + } + elydoraDir := filepath.Join(home, ".elydora") + entries, err := os.ReadDir(elydoraDir) + if err != nil { + return "" + } + for _, entry := range entries { + if !entry.IsDir() { + continue + } + configPath := filepath.Join(elydoraDir, entry.Name(), "config.json") + data, err := os.ReadFile(configPath) + if err != nil { + continue + } + var config map[string]interface{} + if err := json.Unmarshal(data, &config); err != nil { + continue + } + if name, _ := config["agent_name"].(string); name == agentName { + return entry.Name() + } + } + return "" +} + // --------------------------------------------------------------------------- // status // --------------------------------------------------------------------------- @@ -219,7 +269,7 @@ func cmdAgents() { names := sortedAgentNames() for _, name := range names { entry := plugins.SupportedAgents[name] - configPath := entry.ConfigDir + "/" + entry.ConfigFile + configPath := entry.ConfigDir + "/" + entry.ConfigFile // display only, not a filesystem path fmt.Printf(" %-14s %-20s %s\n", name, entry.Name, configPath) } } @@ -233,11 +283,11 @@ func sortedAgentNames() []string { return names } -// guardScriptPathForAgent returns the path to ~/.elydora/hooks/-guard.js. -func guardScriptPathForAgent(agentName string) (string, error) { +// guardScriptPathForAgent returns the path to ~/.elydora//guard.js. +func guardScriptPathForAgent(agentId string) (string, error) { home, err := os.UserHomeDir() if err != nil { return "", fmt.Errorf("resolve home directory: %w", err) } - return filepath.Join(home, ".elydora", "hooks", agentName+"-guard.js"), nil + return filepath.Join(home, ".elydora", agentId, "guard.js"), nil } diff --git a/cmd/elydora/plugins/base.go b/cmd/elydora/plugins/base.go index 56acbaf..c6794c5 100644 --- a/cmd/elydora/plugins/base.go +++ b/cmd/elydora/plugins/base.go @@ -26,6 +26,6 @@ type PluginStatus struct { // AgentPlugin is the interface that every agent integration must implement. type AgentPlugin interface { Install(config InstallConfig) error - Uninstall() error + Uninstall(agentID string) error Status() (PluginStatus, error) } diff --git a/cmd/elydora/plugins/claudecode.go b/cmd/elydora/plugins/claudecode.go index 0e55864..24ce651 100644 --- a/cmd/elydora/plugins/claudecode.go +++ b/cmd/elydora/plugins/claudecode.go @@ -3,6 +3,7 @@ package plugins import ( "fmt" "os" + "path/filepath" "strings" ) @@ -11,7 +12,7 @@ import ( type ClaudeCodePlugin struct{} func (p *ClaudeCodePlugin) Install(config InstallConfig) error { - scriptPath, err := hookScriptPath("claudecode") + scriptPath, err := hookScriptPath(config.AgentID) if err != nil { return err } @@ -25,7 +26,7 @@ func (p *ClaudeCodePlugin) Install(config InstallConfig) error { guardPath := config.GuardScriptPath if guardPath == "" { - guardPath, err = guardScriptPath("claudecode") + guardPath, err = guardScriptPath(config.AgentID) if err != nil { return err } @@ -35,7 +36,7 @@ func (p *ClaudeCodePlugin) Install(config InstallConfig) error { if err != nil { return err } - configPath := configDir + "/settings.json" + configPath := filepath.Join(configDir, "settings.json") settings, err := readJSONFile(configPath) if err != nil { @@ -100,12 +101,12 @@ func (p *ClaudeCodePlugin) Install(config InstallConfig) error { return nil } -func (p *ClaudeCodePlugin) Uninstall() error { +func (p *ClaudeCodePlugin) Uninstall(agentID string) error { configDir, err := expandHome("~/.claude") if err != nil { return err } - configPath := configDir + "/settings.json" + configPath := filepath.Join(configDir, "settings.json") settings, err := readJSONFile(configPath) if err != nil { @@ -162,29 +163,26 @@ func (p *ClaudeCodePlugin) Uninstall() error { return err } - scriptPath, _ := hookScriptPath("claudecode") - if scriptPath != "" { - os.Remove(scriptPath) - } - gPath, _ := guardScriptPath("claudecode") - if gPath != "" { - os.Remove(gPath) + if agentID != "" { + scriptPath, _ := hookScriptPath(agentID) + if scriptPath != "" { + os.Remove(scriptPath) + } + gPath, _ := guardScriptPath(agentID) + if gPath != "" { + os.Remove(gPath) + } } fmt.Println("Uninstalled Elydora hook for Claude Code.") return nil } func (p *ClaudeCodePlugin) Status() (PluginStatus, error) { - scriptPath, err := hookScriptPath("claudecode") - if err != nil { - return PluginStatus{}, err - } - configDir, err := expandHome("~/.claude") if err != nil { return PluginStatus{}, err } - configPath := configDir + "/settings.json" + configPath := filepath.Join(configDir, "settings.json") status := PluginStatus{ AgentName: "claudecode", @@ -192,12 +190,7 @@ func (p *ClaudeCodePlugin) Status() (PluginStatus, error) { ConfigPath: configPath, } - // Check if hook script exists - if _, err := os.Stat(scriptPath); err == nil { - status.HookScriptExists = true - } - - // Check if hook is configured in settings + // Check if hook is configured in settings and extract hook script path settings, err := readJSONFile(configPath) if err != nil { return status, nil @@ -208,12 +201,53 @@ func (p *ClaudeCodePlugin) Status() (PluginStatus, error) { preConfigured := hasElydoraEntry(hooks["PreToolUse"]) postConfigured := hasElydoraEntry(hooks["PostToolUse"]) status.HookConfigured = preConfigured && postConfigured + + // Extract hook script path from the configured command + scriptPath := extractElydoraScriptPath(hooks["PostToolUse"]) + if scriptPath != "" { + if _, err := os.Stat(scriptPath); err == nil { + status.HookScriptExists = true + } + } } status.Installed = status.HookConfigured && status.HookScriptExists return status, nil } +// extractElydoraScriptPath extracts the script path from a hook array's Elydora command entry. +func extractElydoraScriptPath(hookArray interface{}) string { + arr, _ := hookArray.([]interface{}) + for _, entry := range arr { + if m, ok := entry.(map[string]interface{}); ok { + // New format: { "hooks": [{ "type": "command", "command": "node /path/to/hook.js" }] } + if innerHooks, ok := m["hooks"].([]interface{}); ok { + for _, h := range innerHooks { + if hm, ok := h.(map[string]interface{}); ok { + if cmd, _ := hm["command"].(string); strings.Contains(cmd, "elydora") { + return extractPathFromNodeCommand(cmd) + } + } + } + } + // Old format: { "command": "node /path/to/hook.js" } + if cmd, _ := m["command"].(string); strings.Contains(cmd, "elydora") { + return extractPathFromNodeCommand(cmd) + } + } + } + return "" +} + +// extractPathFromNodeCommand extracts the file path from a "node /path/to/script.js" command. +func extractPathFromNodeCommand(cmd string) string { + cmd = strings.TrimSpace(cmd) + if strings.HasPrefix(cmd, "node ") { + return strings.TrimSpace(cmd[5:]) + } + return "" +} + // hasElydoraEntry checks if a hook array (interface{}) contains an Elydora entry. func hasElydoraEntry(hookArray interface{}) bool { arr, _ := hookArray.([]interface{}) diff --git a/cmd/elydora/plugins/copilot.go b/cmd/elydora/plugins/copilot.go new file mode 100644 index 0000000..ae5cad6 --- /dev/null +++ b/cmd/elydora/plugins/copilot.go @@ -0,0 +1,261 @@ +package plugins + +import ( + "fmt" + "os" + "path/filepath" + "strings" +) + +// CopilotPlugin manages the Elydora audit hook for GitHub Copilot CLI. +// It writes/merges into .github/hooks/hooks.json (project-relative) using +// hooks.preToolUse[]/postToolUse[] with bash/powershell command fields. +type CopilotPlugin struct{} + +func (p *CopilotPlugin) configPath() (string, error) { + cwd, err := os.Getwd() + if err != nil { + return "", fmt.Errorf("resolve working directory: %w", err) + } + return filepath.Join(cwd, ".github", "hooks", "hooks.json"), nil +} + +func (p *CopilotPlugin) Install(config InstallConfig) error { + scriptPath, err := hookScriptPath(config.AgentID) + if err != nil { + return err + } + if config.HookScript != "" { + scriptPath = config.HookScript + } + + if err := GenerateHookScript(scriptPath, config); err != nil { + return fmt.Errorf("generate hook script: %w", err) + } + + guardPath := config.GuardScriptPath + if guardPath == "" { + guardPath, err = guardScriptPath(config.AgentID) + if err != nil { + return err + } + } + + configPath, err := p.configPath() + if err != nil { + return err + } + + settings, err := readJSONFile(configPath) + if err != nil { + return err + } + + // Ensure version field is set + settings["version"] = float64(1) + + // Ensure hooks object exists + hooks, _ := settings["hooks"].(map[string]interface{}) + if hooks == nil { + hooks = make(map[string]interface{}) + } + + // --- preToolUse (guard — freeze enforcement) --- + preToolUse, _ := hooks["preToolUse"].([]interface{}) + var preFiltered []interface{} + for _, entry := range preToolUse { + if m, ok := entry.(map[string]interface{}); ok { + if isCopilotElydoraEntry(m) { + continue + } + } + preFiltered = append(preFiltered, entry) + } + guardEntry := map[string]interface{}{ + "type": "command", + "bash": "node " + guardPath, + "powershell": "node " + guardPath, + "timeoutSec": float64(5), + } + preFiltered = append(preFiltered, guardEntry) + hooks["preToolUse"] = preFiltered + + // --- postToolUse (audit logging) --- + postToolUse, _ := hooks["postToolUse"].([]interface{}) + var postFiltered []interface{} + for _, entry := range postToolUse { + if m, ok := entry.(map[string]interface{}); ok { + if isCopilotElydoraEntry(m) { + continue + } + } + postFiltered = append(postFiltered, entry) + } + hookEntry := map[string]interface{}{ + "type": "command", + "bash": "node " + scriptPath, + "powershell": "node " + scriptPath, + "timeoutSec": float64(5), + } + postFiltered = append(postFiltered, hookEntry) + hooks["postToolUse"] = postFiltered + + settings["hooks"] = hooks + + if err := writeJSONFile(configPath, settings); err != nil { + return err + } + fmt.Printf("Installed Elydora hook for Copilot CLI at %s\n", configPath) + return nil +} + +func (p *CopilotPlugin) Uninstall(agentID string) error { + configPath, err := p.configPath() + if err != nil { + return err + } + + settings, err := readJSONFile(configPath) + if err != nil { + return err + } + + hooks, _ := settings["hooks"].(map[string]interface{}) + if hooks == nil { + fmt.Println("No Copilot CLI hooks found.") + return nil + } + + // Remove preToolUse Elydora entries + preToolUse, _ := hooks["preToolUse"].([]interface{}) + var preFiltered []interface{} + for _, entry := range preToolUse { + if m, ok := entry.(map[string]interface{}); ok { + if isCopilotElydoraEntry(m) { + continue + } + } + preFiltered = append(preFiltered, entry) + } + if len(preFiltered) == 0 { + delete(hooks, "preToolUse") + } else { + hooks["preToolUse"] = preFiltered + } + + // Remove postToolUse Elydora entries + postToolUse, _ := hooks["postToolUse"].([]interface{}) + var postFiltered []interface{} + for _, entry := range postToolUse { + if m, ok := entry.(map[string]interface{}); ok { + if isCopilotElydoraEntry(m) { + continue + } + } + postFiltered = append(postFiltered, entry) + } + if len(postFiltered) == 0 { + delete(hooks, "postToolUse") + } else { + hooks["postToolUse"] = postFiltered + } + + if len(hooks) == 0 { + delete(settings, "hooks") + } else { + settings["hooks"] = hooks + } + + if err := writeJSONFile(configPath, settings); err != nil { + return err + } + + if agentID != "" { + scriptPath, _ := hookScriptPath(agentID) + if scriptPath != "" { + os.Remove(scriptPath) + } + gPath, _ := guardScriptPath(agentID) + if gPath != "" { + os.Remove(gPath) + } + } + fmt.Println("Uninstalled Elydora hook for Copilot CLI.") + return nil +} + +func (p *CopilotPlugin) Status() (PluginStatus, error) { + configPath, err := p.configPath() + if err != nil { + return PluginStatus{}, err + } + + status := PluginStatus{ + AgentName: "copilot", + DisplayName: "Copilot CLI", + ConfigPath: configPath, + } + + settings, err := readJSONFile(configPath) + if err != nil { + return status, nil + } + + hooks, _ := settings["hooks"].(map[string]interface{}) + if hooks != nil { + preConfigured := hasCopilotElydoraEntry(hooks["preToolUse"]) + postConfigured := hasCopilotElydoraEntry(hooks["postToolUse"]) + status.HookConfigured = preConfigured && postConfigured + + // Extract hook script path from the configured command + scriptPath := extractCopilotElydoraScriptPath(hooks["postToolUse"]) + if scriptPath != "" { + if _, err := os.Stat(scriptPath); err == nil { + status.HookScriptExists = true + } + } + } + + status.Installed = status.HookConfigured && status.HookScriptExists + return status, nil +} + +// isCopilotElydoraEntry checks if a Copilot hook entry (bash/powershell fields) is an Elydora hook. +func isCopilotElydoraEntry(m map[string]interface{}) bool { + if bash, _ := m["bash"].(string); strings.Contains(bash, "elydora") { + return true + } + if ps, _ := m["powershell"].(string); strings.Contains(ps, "elydora") { + return true + } + return false +} + +// hasCopilotElydoraEntry checks if a Copilot hook array contains an Elydora entry. +func hasCopilotElydoraEntry(hookArray interface{}) bool { + arr, _ := hookArray.([]interface{}) + for _, entry := range arr { + if m, ok := entry.(map[string]interface{}); ok { + if isCopilotElydoraEntry(m) { + return true + } + } + } + return false +} + +// extractCopilotElydoraScriptPath extracts the script path from a Copilot hook array's Elydora command. +func extractCopilotElydoraScriptPath(hookArray interface{}) string { + arr, _ := hookArray.([]interface{}) + for _, entry := range arr { + if m, ok := entry.(map[string]interface{}); ok { + if bash, _ := m["bash"].(string); strings.Contains(bash, "elydora") { + return extractPathFromNodeCommand(bash) + } + if ps, _ := m["powershell"].(string); strings.Contains(ps, "elydora") { + return extractPathFromNodeCommand(ps) + } + } + } + return "" +} diff --git a/cmd/elydora/plugins/cursor.go b/cmd/elydora/plugins/cursor.go index b1e131f..0fccc73 100644 --- a/cmd/elydora/plugins/cursor.go +++ b/cmd/elydora/plugins/cursor.go @@ -20,7 +20,7 @@ func (p *CursorPlugin) configPath() (string, error) { } func (p *CursorPlugin) Install(config InstallConfig) error { - scriptPath, err := hookScriptPath("cursor") + scriptPath, err := hookScriptPath(config.AgentID) if err != nil { return err } @@ -34,7 +34,7 @@ func (p *CursorPlugin) Install(config InstallConfig) error { guardPath := config.GuardScriptPath if guardPath == "" { - guardPath, err = guardScriptPath("cursor") + guardPath, err = guardScriptPath(config.AgentID) if err != nil { return err } @@ -99,12 +99,7 @@ func (p *CursorPlugin) Install(config InstallConfig) error { return nil } -func (p *CursorPlugin) Uninstall() error { - scriptPath, err := hookScriptPath("cursor") - if err != nil { - return err - } - +func (p *CursorPlugin) Uninstall(agentID string) error { configPath, err := p.configPath() if err != nil { return err @@ -165,21 +160,21 @@ func (p *CursorPlugin) Uninstall() error { return err } - os.Remove(scriptPath) - gPath, _ := guardScriptPath("cursor") - if gPath != "" { - os.Remove(gPath) + if agentID != "" { + scriptPath, _ := hookScriptPath(agentID) + if scriptPath != "" { + os.Remove(scriptPath) + } + gPath, _ := guardScriptPath(agentID) + if gPath != "" { + os.Remove(gPath) + } } fmt.Println("Uninstalled Elydora hook for Cursor.") return nil } func (p *CursorPlugin) Status() (PluginStatus, error) { - scriptPath, err := hookScriptPath("cursor") - if err != nil { - return PluginStatus{}, err - } - configPath, err := p.configPath() if err != nil { return PluginStatus{}, err @@ -191,10 +186,6 @@ func (p *CursorPlugin) Status() (PluginStatus, error) { ConfigPath: configPath, } - if _, err := os.Stat(scriptPath); err == nil { - status.HookScriptExists = true - } - settings, err := readJSONFile(configPath) if err != nil { return status, nil @@ -205,12 +196,33 @@ func (p *CursorPlugin) Status() (PluginStatus, error) { preConfigured := hasCursorElydoraEntry(hooks["preToolUse"]) postConfigured := hasCursorElydoraEntry(hooks["postToolUse"]) status.HookConfigured = preConfigured && postConfigured + + // Extract hook script path from the configured command + scriptPath := extractCursorElydoraScriptPath(hooks["postToolUse"]) + if scriptPath != "" { + if _, err := os.Stat(scriptPath); err == nil { + status.HookScriptExists = true + } + } } status.Installed = status.HookConfigured && status.HookScriptExists return status, nil } +// extractCursorElydoraScriptPath extracts the script path from a Cursor hook array's Elydora command entry. +func extractCursorElydoraScriptPath(hookArray interface{}) string { + arr, _ := hookArray.([]interface{}) + for _, entry := range arr { + if m, ok := entry.(map[string]interface{}); ok { + if cmd, _ := m["command"].(string); strings.Contains(cmd, "elydora") { + return extractPathFromNodeCommand(cmd) + } + } + } + return "" +} + // hasCursorElydoraEntry checks if a Cursor hook array (flat format) contains an Elydora entry. func hasCursorElydoraEntry(hookArray interface{}) bool { arr, _ := hookArray.([]interface{}) diff --git a/cmd/elydora/plugins/gemini.go b/cmd/elydora/plugins/gemini.go index 2276ed4..88ba0a0 100644 --- a/cmd/elydora/plugins/gemini.go +++ b/cmd/elydora/plugins/gemini.go @@ -3,6 +3,7 @@ package plugins import ( "fmt" "os" + "path/filepath" ) // GeminiPlugin manages the Elydora audit hook for Gemini CLI. @@ -10,7 +11,7 @@ import ( type GeminiPlugin struct{} func (p *GeminiPlugin) Install(config InstallConfig) error { - scriptPath, err := hookScriptPath("gemini") + scriptPath, err := hookScriptPath(config.AgentID) if err != nil { return err } @@ -24,7 +25,7 @@ func (p *GeminiPlugin) Install(config InstallConfig) error { guardPath := config.GuardScriptPath if guardPath == "" { - guardPath, err = guardScriptPath("gemini") + guardPath, err = guardScriptPath(config.AgentID) if err != nil { return err } @@ -34,7 +35,7 @@ func (p *GeminiPlugin) Install(config InstallConfig) error { if err != nil { return err } - configPath := configDir + "/settings.json" + configPath := filepath.Join(configDir, "settings.json") settings, err := readJSONFile(configPath) if err != nil { @@ -99,12 +100,12 @@ func (p *GeminiPlugin) Install(config InstallConfig) error { return nil } -func (p *GeminiPlugin) Uninstall() error { +func (p *GeminiPlugin) Uninstall(agentID string) error { configDir, err := expandHome("~/.gemini") if err != nil { return err } - configPath := configDir + "/settings.json" + configPath := filepath.Join(configDir, "settings.json") settings, err := readJSONFile(configPath) if err != nil { @@ -161,29 +162,26 @@ func (p *GeminiPlugin) Uninstall() error { return err } - scriptPath, _ := hookScriptPath("gemini") - if scriptPath != "" { - os.Remove(scriptPath) - } - gPath, _ := guardScriptPath("gemini") - if gPath != "" { - os.Remove(gPath) + if agentID != "" { + scriptPath, _ := hookScriptPath(agentID) + if scriptPath != "" { + os.Remove(scriptPath) + } + gPath, _ := guardScriptPath(agentID) + if gPath != "" { + os.Remove(gPath) + } } fmt.Println("Uninstalled Elydora hook for Gemini CLI.") return nil } func (p *GeminiPlugin) Status() (PluginStatus, error) { - scriptPath, err := hookScriptPath("gemini") - if err != nil { - return PluginStatus{}, err - } - configDir, err := expandHome("~/.gemini") if err != nil { return PluginStatus{}, err } - configPath := configDir + "/settings.json" + configPath := filepath.Join(configDir, "settings.json") status := PluginStatus{ AgentName: "gemini", @@ -191,10 +189,6 @@ func (p *GeminiPlugin) Status() (PluginStatus, error) { ConfigPath: configPath, } - if _, err := os.Stat(scriptPath); err == nil { - status.HookScriptExists = true - } - settings, err := readJSONFile(configPath) if err != nil { return status, nil @@ -205,6 +199,14 @@ func (p *GeminiPlugin) Status() (PluginStatus, error) { preConfigured := hasElydoraEntry(hooks["BeforeTool"]) postConfigured := hasElydoraEntry(hooks["AfterTool"]) status.HookConfigured = preConfigured && postConfigured + + // Extract hook script path from the configured command + scriptPath := extractElydoraScriptPath(hooks["AfterTool"]) + if scriptPath != "" { + if _, err := os.Stat(scriptPath); err == nil { + status.HookScriptExists = true + } + } } status.Installed = status.HookConfigured && status.HookScriptExists diff --git a/cmd/elydora/plugins/hooktemplate.go b/cmd/elydora/plugins/hooktemplate.go index f7911f2..d7bf2f3 100644 --- a/cmd/elydora/plugins/hooktemplate.go +++ b/cmd/elydora/plugins/hooktemplate.go @@ -7,7 +7,7 @@ import ( ) // GenerateHookScript writes a self-contained Node.js hook script to disk. -// The script reads config/key from ~/.elydora/agents/ at runtime, persists +// The script reads config/key from ~/.elydora// at runtime, persists // chain state, and performs fire-and-forget EOR submission with inline Ed25519 crypto. // It always exits 0 so it never blocks the host agent. func GenerateHookScript(destPath string, config InstallConfig) error { @@ -21,7 +21,7 @@ func GenerateHookScript(destPath string, config InstallConfig) error { return fmt.Errorf("write agent config: %w", err) } - script := buildHookScript(config.AgentName) + script := buildHookScript(config.AgentName, config.AgentID) if err := os.WriteFile(destPath, []byte(script), 0755); err != nil { return fmt.Errorf("write hook script: %w", err) } @@ -29,16 +29,16 @@ func GenerateHookScript(destPath string, config InstallConfig) error { } // writeAgentConfig persists the agent configuration and private key to -// ~/.elydora/agents/.json and ~/.elydora/agents/.key so that +// ~/.elydora//config.json and ~/.elydora//private.key so that // the generated hook script can read them at runtime without embedding secrets. func writeAgentConfig(config InstallConfig) error { home, err := os.UserHomeDir() if err != nil { return fmt.Errorf("resolve home directory: %w", err) } - agentsDir := filepath.Join(home, ".elydora", "agents") - if err := os.MkdirAll(agentsDir, 0755); err != nil { - return fmt.Errorf("create agents directory: %w", err) + agentDir := filepath.Join(home, ".elydora", config.AgentID) + if err := os.MkdirAll(agentDir, 0755); err != nil { + return fmt.Errorf("create agent directory: %w", err) } baseURL := config.BaseURL @@ -51,16 +51,19 @@ func writeAgentConfig(config InstallConfig) error { "agent_id": %q, "kid": %q, "base_url": %q, - "token": %q + "token": %q, + "agent_name": %q } -`, config.OrgID, config.AgentID, config.KID, baseURL, config.Token) +`, config.OrgID, config.AgentID, config.KID, baseURL, config.Token, config.AgentName) - configPath := filepath.Join(agentsDir, config.AgentName+".json") + configPath := filepath.Join(agentDir, "config.json") + // Note: 0600 permissions are a no-op on Windows; Go's os.Chmod only handles the read-only bit on Windows. if err := os.WriteFile(configPath, []byte(configJSON), 0600); err != nil { return fmt.Errorf("write agent config: %w", err) } - keyPath := filepath.Join(agentsDir, config.AgentName+".key") + keyPath := filepath.Join(agentDir, "private.key") + // Note: 0600 permissions are a no-op on Windows; Go's os.Chmod only handles the read-only bit on Windows. if err := os.WriteFile(keyPath, []byte(config.PrivateKey+"\n"), 0600); err != nil { return fmt.Errorf("write agent key: %w", err) } @@ -72,7 +75,7 @@ func writeAgentConfig(config InstallConfig) error { // whether the agent is frozen and blocks tool execution if so. // It caches the agent status locally (60s TTL) to avoid hitting the API on every // tool call, and fails open if the API is unreachable or config is missing. -func GenerateGuardScript(agentName string) string { +func GenerateGuardScript(agentName string, agentId string) string { return fmt.Sprintf(`#!/usr/bin/env node 'use strict'; @@ -86,9 +89,10 @@ const path = require('node:path'); const os = require('node:os'); const AGENT_NAME = %q; +const AGENT_ID = %q; 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 // Consume stdin so the parent process doesn't block on a full pipe @@ -166,10 +170,10 @@ async function main() { } main(); -`, agentName, agentName) +`, agentName, agentName, agentId) } -func buildHookScript(agentName string) string { +func buildHookScript(agentName string, agentId string) string { return fmt.Sprintf(`#!/usr/bin/env node 'use strict'; @@ -184,11 +188,12 @@ const path = require('node:path'); const os = require('node:os'); const AGENT_NAME = %q; +const AGENT_ID = %q; 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) @@ -461,5 +466,5 @@ async function main() { } main(); -`, agentName, agentName) +`, agentName, agentName, agentId) } diff --git a/cmd/elydora/plugins/jsonutil.go b/cmd/elydora/plugins/jsonutil.go index b2f90d2..5d017a9 100644 --- a/cmd/elydora/plugins/jsonutil.go +++ b/cmd/elydora/plugins/jsonutil.go @@ -53,20 +53,20 @@ func writeJSONFile(path string, data map[string]interface{}) error { return nil } -// hookScriptPath returns the default path for the hook script inside ~/.elydora/hooks/. -func hookScriptPath(agentName string) (string, error) { +// hookScriptPath returns the default path for the hook script inside ~/.elydora//. +func hookScriptPath(agentId string) (string, error) { home, err := os.UserHomeDir() if err != nil { return "", fmt.Errorf("resolve home directory: %w", err) } - return filepath.Join(home, ".elydora", "hooks", agentName+"-hook.js"), nil + return filepath.Join(home, ".elydora", agentId, "hook.js"), nil } -// guardScriptPath returns the default path for the guard script inside ~/.elydora/hooks/. -func guardScriptPath(agentName string) (string, error) { +// guardScriptPath returns the default path for the guard script inside ~/.elydora//. +func guardScriptPath(agentId string) (string, error) { home, err := os.UserHomeDir() if err != nil { return "", fmt.Errorf("resolve home directory: %w", err) } - return filepath.Join(home, ".elydora", "hooks", agentName+"-guard.js"), nil + return filepath.Join(home, ".elydora", agentId, "guard.js"), nil } diff --git a/cmd/elydora/plugins/kirocli.go b/cmd/elydora/plugins/kirocli.go new file mode 100644 index 0000000..61d6aa2 --- /dev/null +++ b/cmd/elydora/plugins/kirocli.go @@ -0,0 +1,224 @@ +package plugins + +import ( + "fmt" + "os" + "path/filepath" +) + +// KiroCliPlugin manages the Elydora audit hook for Kiro CLI. +// It merges PreToolUse/PostToolUse hooks into ~/.kiro/settings.json. +type KiroCliPlugin struct{} + +func (p *KiroCliPlugin) configPath() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("resolve home directory: %w", err) + } + return filepath.Join(home, ".kiro", "settings.json"), nil +} + +func (p *KiroCliPlugin) Install(config InstallConfig) error { + scriptPath, err := hookScriptPath(config.AgentID) + if err != nil { + return err + } + if config.HookScript != "" { + scriptPath = config.HookScript + } + + if err := GenerateHookScript(scriptPath, config); err != nil { + return fmt.Errorf("generate hook script: %w", err) + } + + guardPath := config.GuardScriptPath + if guardPath == "" { + guardPath, err = guardScriptPath(config.AgentID) + if err != nil { + return err + } + } + + configPath, err := p.configPath() + if err != nil { + return err + } + + settings, err := readJSONFile(configPath) + if err != nil { + return err + } + + hooks, _ := settings["hooks"].(map[string]interface{}) + if hooks == nil { + hooks = make(map[string]interface{}) + } + + // --- PreToolUse (guard — freeze enforcement) --- + preToolUse, _ := hooks["PreToolUse"].([]interface{}) + var preFiltered []interface{} + for _, entry := range preToolUse { + if m, ok := entry.(map[string]interface{}); ok { + if isElydoraHookEntry(m) { + continue + } + } + preFiltered = append(preFiltered, entry) + } + guardEntry := map[string]interface{}{ + "matcher": "*", + "hooks": []interface{}{ + map[string]interface{}{ + "type": "command", + "command": "node " + guardPath, + "timeout_ms": float64(5000), + }, + }, + } + preFiltered = append(preFiltered, guardEntry) + hooks["PreToolUse"] = preFiltered + + // --- PostToolUse (audit logging) --- + postToolUse, _ := hooks["PostToolUse"].([]interface{}) + var postFiltered []interface{} + for _, entry := range postToolUse { + if m, ok := entry.(map[string]interface{}); ok { + if isElydoraHookEntry(m) { + continue + } + } + postFiltered = append(postFiltered, entry) + } + hookEntry := map[string]interface{}{ + "matcher": "*", + "hooks": []interface{}{ + map[string]interface{}{ + "type": "command", + "command": "node " + scriptPath, + "timeout_ms": float64(5000), + }, + }, + } + postFiltered = append(postFiltered, hookEntry) + hooks["PostToolUse"] = postFiltered + + settings["hooks"] = hooks + + if err := writeJSONFile(configPath, settings); err != nil { + return err + } + fmt.Printf("Installed Elydora hook for Kiro CLI at %s\n", configPath) + return nil +} + +func (p *KiroCliPlugin) Uninstall(agentID string) error { + configPath, err := p.configPath() + if err != nil { + return err + } + + settings, err := readJSONFile(configPath) + if err != nil { + return err + } + + hooks, _ := settings["hooks"].(map[string]interface{}) + if hooks == nil { + fmt.Println("No Kiro CLI hooks found.") + return nil + } + + // Remove PreToolUse Elydora entries + preToolUse, _ := hooks["PreToolUse"].([]interface{}) + var preFiltered []interface{} + for _, entry := range preToolUse { + if m, ok := entry.(map[string]interface{}); ok { + if isElydoraHookEntry(m) { + continue + } + } + preFiltered = append(preFiltered, entry) + } + if len(preFiltered) == 0 { + delete(hooks, "PreToolUse") + } else { + hooks["PreToolUse"] = preFiltered + } + + // Remove PostToolUse Elydora entries + postToolUse, _ := hooks["PostToolUse"].([]interface{}) + var postFiltered []interface{} + for _, entry := range postToolUse { + if m, ok := entry.(map[string]interface{}); ok { + if isElydoraHookEntry(m) { + continue + } + } + postFiltered = append(postFiltered, entry) + } + if len(postFiltered) == 0 { + delete(hooks, "PostToolUse") + } else { + hooks["PostToolUse"] = postFiltered + } + + if len(hooks) == 0 { + delete(settings, "hooks") + } else { + settings["hooks"] = hooks + } + + if err := writeJSONFile(configPath, settings); err != nil { + return err + } + + if agentID != "" { + scriptPath, _ := hookScriptPath(agentID) + if scriptPath != "" { + os.Remove(scriptPath) + } + gPath, _ := guardScriptPath(agentID) + if gPath != "" { + os.Remove(gPath) + } + } + fmt.Println("Uninstalled Elydora hook for Kiro CLI.") + return nil +} + +func (p *KiroCliPlugin) Status() (PluginStatus, error) { + configPath, err := p.configPath() + if err != nil { + return PluginStatus{}, err + } + + status := PluginStatus{ + AgentName: "kirocli", + DisplayName: "Kiro CLI", + ConfigPath: configPath, + } + + settings, err := readJSONFile(configPath) + if err != nil { + return status, nil + } + + hooks, _ := settings["hooks"].(map[string]interface{}) + if hooks != nil { + preConfigured := hasElydoraEntry(hooks["PreToolUse"]) + postConfigured := hasElydoraEntry(hooks["PostToolUse"]) + status.HookConfigured = preConfigured && postConfigured + + // Extract hook script path from the configured command + scriptPath := extractElydoraScriptPath(hooks["PostToolUse"]) + if scriptPath != "" { + if _, err := os.Stat(scriptPath); err == nil { + status.HookScriptExists = true + } + } + } + + status.Installed = status.HookConfigured && status.HookScriptExists + return status, nil +} + diff --git a/cmd/elydora/plugins/kiro.go b/cmd/elydora/plugins/kiroide.go similarity index 63% rename from cmd/elydora/plugins/kiro.go rename to cmd/elydora/plugins/kiroide.go index 270086e..4025e31 100644 --- a/cmd/elydora/plugins/kiro.go +++ b/cmd/elydora/plugins/kiroide.go @@ -7,11 +7,11 @@ import ( "path/filepath" ) -// KiroPlugin manages the Elydora audit hook for Kiro. +// KiroIdePlugin manages the Elydora audit hook for Kiro IDE. // It writes a .kiro/hooks/elydora-audit.kiro.hook JSON file in the home directory. -type KiroPlugin struct{} +type KiroIdePlugin struct{} -func (p *KiroPlugin) configPath() (string, error) { +func (p *KiroIdePlugin) configPath() (string, error) { home, err := os.UserHomeDir() if err != nil { return "", fmt.Errorf("resolve home directory: %w", err) @@ -19,8 +19,8 @@ func (p *KiroPlugin) configPath() (string, error) { return filepath.Join(home, ".kiro", "hooks", "elydora-audit.kiro.hook"), nil } -func (p *KiroPlugin) Install(config InstallConfig) error { - scriptPath, err := hookScriptPath("kiro") +func (p *KiroIdePlugin) Install(config InstallConfig) error { + scriptPath, err := hookScriptPath(config.AgentID) if err != nil { return err } @@ -34,7 +34,7 @@ func (p *KiroPlugin) Install(config InstallConfig) error { guardPath := config.GuardScriptPath if guardPath == "" { - guardPath, err = guardScriptPath("kiro") + guardPath, err = guardScriptPath(config.AgentID) if err != nil { return err } @@ -49,16 +49,16 @@ func (p *KiroPlugin) Install(config InstallConfig) error { return fmt.Errorf("create directory %s: %w", dir, err) } - content := buildKiroHookFile(scriptPath, guardPath) + content := buildKiroIdeHookFile(scriptPath, guardPath) if err := os.WriteFile(hookFile, []byte(content), 0644); err != nil { return fmt.Errorf("write %s: %w", hookFile, err) } - fmt.Printf("Installed Elydora hook for Kiro at %s\n", hookFile) + fmt.Printf("Installed Elydora hook for Kiro IDE at %s\n", hookFile) return nil } -func buildKiroHookFile(scriptPath, guardPath string) string { +func buildKiroIdeHookFile(scriptPath, guardPath string) string { hookConfig := map[string]interface{}{ "name": "Elydora Audit", "hooks": map[string]interface{}{ @@ -76,7 +76,7 @@ func buildKiroHookFile(scriptPath, guardPath string) string { return string(encoded) + "\n" } -func (p *KiroPlugin) Uninstall() error { +func (p *KiroIdePlugin) Uninstall(agentID string) error { hookFile, err := p.configPath() if err != nil { return err @@ -85,40 +85,33 @@ func (p *KiroPlugin) Uninstall() error { return fmt.Errorf("remove %s: %w", hookFile, err) } - scriptPath, _ := hookScriptPath("kiro") - if scriptPath != "" { - os.Remove(scriptPath) - } - gPath, _ := guardScriptPath("kiro") - if gPath != "" { - os.Remove(gPath) + if agentID != "" { + scriptPath, _ := hookScriptPath(agentID) + if scriptPath != "" { + os.Remove(scriptPath) + } + gPath, _ := guardScriptPath(agentID) + if gPath != "" { + os.Remove(gPath) + } } - fmt.Println("Uninstalled Elydora hook for Kiro.") + fmt.Println("Uninstalled Elydora hook for Kiro IDE.") return nil } -func (p *KiroPlugin) Status() (PluginStatus, error) { - scriptPath, err := hookScriptPath("kiro") - if err != nil { - return PluginStatus{}, err - } - +func (p *KiroIdePlugin) Status() (PluginStatus, error) { hookFile, err := p.configPath() if err != nil { return PluginStatus{}, err } status := PluginStatus{ - AgentName: "kiro", - DisplayName: "Kiro", + AgentName: "kiroide", + DisplayName: "Kiro IDE", ConfigPath: hookFile, } - if _, err := os.Stat(scriptPath); err == nil { - status.HookScriptExists = true - } - // Check if hook file exists and contains both pre_tool_use and post_tool_use data, err := os.ReadFile(hookFile) if err == nil { @@ -129,6 +122,18 @@ func (p *KiroPlugin) Status() (PluginStatus, error) { _, hasPre := hooks["pre_tool_use"] _, hasPost := hooks["post_tool_use"] status.HookConfigured = hasPre && hasPost + + // Extract hook script path from the post_tool_use command + if postHook, ok := hooks["post_tool_use"].(map[string]interface{}); ok { + if cmd, _ := postHook["command"].(string); cmd != "" { + scriptPath := extractPathFromNodeCommand(cmd) + if scriptPath != "" { + if _, err := os.Stat(scriptPath); err == nil { + status.HookScriptExists = true + } + } + } + } } } } diff --git a/cmd/elydora/plugins/augment.go b/cmd/elydora/plugins/letta.go similarity index 71% rename from cmd/elydora/plugins/augment.go rename to cmd/elydora/plugins/letta.go index 2f107c7..d113d98 100644 --- a/cmd/elydora/plugins/augment.go +++ b/cmd/elydora/plugins/letta.go @@ -3,14 +3,23 @@ package plugins import ( "fmt" "os" + "path/filepath" ) -// AugmentPlugin manages the Elydora audit hook for Augment Code. -// It merges a PostToolUse hook into ~/.augment/settings.json. -type AugmentPlugin struct{} +// LettaPlugin manages the Elydora audit hook for Letta Code. +// It merges PreToolUse/PostToolUse hooks into ~/.letta/settings.json. +type LettaPlugin struct{} -func (p *AugmentPlugin) Install(config InstallConfig) error { - scriptPath, err := hookScriptPath("augment") +func (p *LettaPlugin) configPath() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("resolve home directory: %w", err) + } + return filepath.Join(home, ".letta", "settings.json"), nil +} + +func (p *LettaPlugin) Install(config InstallConfig) error { + scriptPath, err := hookScriptPath(config.AgentID) if err != nil { return err } @@ -24,17 +33,16 @@ func (p *AugmentPlugin) Install(config InstallConfig) error { guardPath := config.GuardScriptPath if guardPath == "" { - guardPath, err = guardScriptPath("augment") + guardPath, err = guardScriptPath(config.AgentID) if err != nil { return err } } - configDir, err := expandHome("~/.augment") + configPath, err := p.configPath() if err != nil { return err } - configPath := configDir + "/settings.json" settings, err := readJSONFile(configPath) if err != nil { @@ -58,6 +66,7 @@ func (p *AugmentPlugin) Install(config InstallConfig) error { preFiltered = append(preFiltered, entry) } guardEntry := map[string]interface{}{ + "matcher": "*", "hooks": []interface{}{ map[string]interface{}{ "type": "command", @@ -80,6 +89,7 @@ func (p *AugmentPlugin) Install(config InstallConfig) error { postFiltered = append(postFiltered, entry) } hookEntry := map[string]interface{}{ + "matcher": "*", "hooks": []interface{}{ map[string]interface{}{ "type": "command", @@ -95,16 +105,15 @@ func (p *AugmentPlugin) Install(config InstallConfig) error { if err := writeJSONFile(configPath, settings); err != nil { return err } - fmt.Printf("Installed Elydora hook for Augment Code at %s\n", configPath) + fmt.Printf("Installed Elydora hook for Letta Code at %s\n", configPath) return nil } -func (p *AugmentPlugin) Uninstall() error { - configDir, err := expandHome("~/.augment") +func (p *LettaPlugin) Uninstall(agentID string) error { + configPath, err := p.configPath() if err != nil { return err } - configPath := configDir + "/settings.json" settings, err := readJSONFile(configPath) if err != nil { @@ -113,7 +122,7 @@ func (p *AugmentPlugin) Uninstall() error { hooks, _ := settings["hooks"].(map[string]interface{}) if hooks == nil { - fmt.Println("No Augment Code hooks found.") + fmt.Println("No Letta Code hooks found.") return nil } @@ -161,40 +170,32 @@ func (p *AugmentPlugin) Uninstall() error { return err } - scriptPath, _ := hookScriptPath("augment") - if scriptPath != "" { - os.Remove(scriptPath) + if agentID != "" { + scriptPath, _ := hookScriptPath(agentID) + if scriptPath != "" { + os.Remove(scriptPath) + } + gPath, _ := guardScriptPath(agentID) + if gPath != "" { + os.Remove(gPath) + } } - gPath, _ := guardScriptPath("augment") - if gPath != "" { - os.Remove(gPath) - } - fmt.Println("Uninstalled Elydora hook for Augment Code.") + fmt.Println("Uninstalled Elydora hook for Letta Code.") return nil } -func (p *AugmentPlugin) Status() (PluginStatus, error) { - scriptPath, err := hookScriptPath("augment") +func (p *LettaPlugin) Status() (PluginStatus, error) { + configPath, err := p.configPath() if err != nil { return PluginStatus{}, err } - configDir, err := expandHome("~/.augment") - if err != nil { - return PluginStatus{}, err - } - configPath := configDir + "/settings.json" - status := PluginStatus{ - AgentName: "augment", - DisplayName: "Augment Code", + AgentName: "letta", + DisplayName: "Letta Code", ConfigPath: configPath, } - if _, err := os.Stat(scriptPath); err == nil { - status.HookScriptExists = true - } - settings, err := readJSONFile(configPath) if err != nil { return status, nil @@ -205,8 +206,17 @@ func (p *AugmentPlugin) Status() (PluginStatus, error) { preConfigured := hasElydoraEntry(hooks["PreToolUse"]) postConfigured := hasElydoraEntry(hooks["PostToolUse"]) status.HookConfigured = preConfigured && postConfigured + + // Extract hook script path from the configured command + scriptPath := extractElydoraScriptPath(hooks["PostToolUse"]) + if scriptPath != "" { + if _, err := os.Stat(scriptPath); err == nil { + status.HookScriptExists = true + } + } } status.Installed = status.HookConfigured && status.HookScriptExists return status, nil } + diff --git a/cmd/elydora/plugins/opencode.go b/cmd/elydora/plugins/opencode.go index de0cda3..60f73b3 100644 --- a/cmd/elydora/plugins/opencode.go +++ b/cmd/elydora/plugins/opencode.go @@ -4,6 +4,7 @@ import ( "fmt" "os" "path/filepath" + "strings" ) // OpenCodePlugin manages the Elydora audit hook for OpenCode. @@ -19,7 +20,7 @@ func (p *OpenCodePlugin) configPath() (string, error) { } func (p *OpenCodePlugin) Install(config InstallConfig) error { - scriptPath, err := hookScriptPath("opencode") + scriptPath, err := hookScriptPath(config.AgentID) if err != nil { return err } @@ -33,7 +34,7 @@ func (p *OpenCodePlugin) Install(config InstallConfig) error { guardPath := config.GuardScriptPath if guardPath == "" { - guardPath, err = guardScriptPath("opencode") + guardPath, err = guardScriptPath(config.AgentID) if err != nil { return err } @@ -109,7 +110,7 @@ module.exports = { `, guardPath, scriptPath) } -func (p *OpenCodePlugin) Uninstall() error { +func (p *OpenCodePlugin) Uninstall(agentID string) error { pluginFile, err := p.configPath() if err != nil { return err @@ -118,13 +119,15 @@ func (p *OpenCodePlugin) Uninstall() error { return fmt.Errorf("remove %s: %w", pluginFile, err) } - scriptPath, _ := hookScriptPath("opencode") - if scriptPath != "" { - os.Remove(scriptPath) - } - gPath, _ := guardScriptPath("opencode") - if gPath != "" { - os.Remove(gPath) + if agentID != "" { + scriptPath, _ := hookScriptPath(agentID) + if scriptPath != "" { + os.Remove(scriptPath) + } + gPath, _ := guardScriptPath(agentID) + if gPath != "" { + os.Remove(gPath) + } } fmt.Println("Uninstalled Elydora hook for OpenCode.") @@ -132,11 +135,6 @@ func (p *OpenCodePlugin) Uninstall() error { } func (p *OpenCodePlugin) Status() (PluginStatus, error) { - scriptPath, err := hookScriptPath("opencode") - if err != nil { - return PluginStatus{}, err - } - pluginFile, err := p.configPath() if err != nil { return PluginStatus{}, err @@ -148,12 +146,34 @@ func (p *OpenCodePlugin) Status() (PluginStatus, error) { ConfigPath: pluginFile, } - if _, err := os.Stat(scriptPath); err == nil { - status.HookScriptExists = true - } - if _, err := os.Stat(pluginFile); err == nil { status.HookConfigured = true + + // Read the plugin file to extract the hook script path + data, readErr := os.ReadFile(pluginFile) + if readErr == nil { + content := string(data) + // Look for the elydora hook.js path in the plugin file content + if idx := strings.Index(content, ".elydora"); idx >= 0 { + // Extract path from the spawn('node', ['/path/to/hook.js']) pattern + // Pragmatic: scan ~/.elydora/*/hook.js for existence + home, homeErr := os.UserHomeDir() + if homeErr == nil { + entries, dirErr := os.ReadDir(filepath.Join(home, ".elydora")) + if dirErr == nil { + for _, entry := range entries { + if entry.IsDir() { + hookPath := filepath.Join(home, ".elydora", entry.Name(), "hook.js") + if _, statErr := os.Stat(hookPath); statErr == nil { + status.HookScriptExists = true + break + } + } + } + } + } + } + } } status.Installed = status.HookConfigured && status.HookScriptExists diff --git a/cmd/elydora/plugins/registry.go b/cmd/elydora/plugins/registry.go index f2cc49a..39c18a5 100644 --- a/cmd/elydora/plugins/registry.go +++ b/cmd/elydora/plugins/registry.go @@ -10,11 +10,13 @@ type AgentRegistryEntry struct { // SupportedAgents is the canonical registry of all supported agent integrations. var SupportedAgents = map[string]AgentRegistryEntry{ "claudecode": {Name: "Claude Code", ConfigDir: "~/.claude", ConfigFile: "settings.json"}, - "cursor": {Name: "Cursor", ConfigDir: ".cursor", ConfigFile: "hooks.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: "~/.opencode/plugins", ConfigFile: "elydora-audit.js"}, + "copilot": {Name: "Copilot CLI", ConfigDir: ".github/hooks", ConfigFile: "hooks.json"}, + "letta": {Name: "Letta Code", ConfigDir: "~/.letta", ConfigFile: "settings.json"}, } // NewPlugin creates a plugin instance for the given agent name. @@ -27,12 +29,16 @@ func NewPlugin(agentName string) AgentPlugin { return &CursorPlugin{} case "gemini": return &GeminiPlugin{} - case "augment": - return &AugmentPlugin{} - case "kiro": - return &KiroPlugin{} + case "kirocli": + return &KiroCliPlugin{} + case "kiroide": + return &KiroIdePlugin{} case "opencode": return &OpenCodePlugin{} + case "copilot": + return &CopilotPlugin{} + case "letta": + return &LettaPlugin{} default: return nil }