feat: support Kiro CLI v2 and v3 hooks
This commit is contained in:
1 parent
598b0bd2bb
commit
758d129dbf
7 files changed
+1175
-171
No files matched your search
@@ -0,0 +1,51 @@
|
|||||||
|
# Elydora Go SDK Engineering Contract
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
This repository owns the public Go SDK, the `cmd/elydora` CLI, local signing behavior, generated Node.js hook runtimes, and Go-specific agent adapters.
|
||||||
|
|
||||||
|
## Integration Sources
|
||||||
|
|
||||||
|
- Verify every agent hook contract against current official provider documentation before changing an adapter.
|
||||||
|
- Treat `../Elydora-Open-Source/integrations/catalog.json` as the cross-product provider inventory.
|
||||||
|
- Keep adapter delivery claims aligned with executable tests in this repository.
|
||||||
|
- Synchronize completed provider behavior into the Node SDK, Python SDK, Open Source distribution, Console, Docs, and landing page through separate reviewed commits.
|
||||||
|
|
||||||
|
## Hook Adapter Invariants
|
||||||
|
|
||||||
|
- Preserve unrelated user configuration and remove only Elydora-owned entries.
|
||||||
|
- Parse every affected user configuration before the first write.
|
||||||
|
- Surface malformed or unreadable configuration with contextual errors and leave the original file intact.
|
||||||
|
- Write configuration through a same-directory temporary file, flush it, and atomically replace the target.
|
||||||
|
- Resolve and quote the Node.js runtime and generated script paths for the host shell.
|
||||||
|
- Forward official hook JSON from STDIN without reshaping provider fields.
|
||||||
|
- Use the provider's documented blocking mechanism. Command-hook providers that define exit code `2` must receive exit code `2` from the freeze guard.
|
||||||
|
- 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.
|
||||||
|
|
||||||
|
## Code Quality
|
||||||
|
|
||||||
|
- Preserve the minimum Go version declared in `go.mod`.
|
||||||
|
- Keep production source files at or below 500 lines.
|
||||||
|
- Keep functions focused on one ownership boundary.
|
||||||
|
- Return unexpected errors to the CLI boundary with operation and path context.
|
||||||
|
- Use documented defaults only for genuinely optional configuration.
|
||||||
|
- Avoid compatibility shims without a named public or user configuration contract.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
Run the focused adapter test during development, then execute all release gates before commit:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
go test ./cmd/elydora/plugins -run <Provider> -count=1
|
||||||
|
go test ./...
|
||||||
|
go test -race ./...
|
||||||
|
go vet ./...
|
||||||
|
go build ./cmd/elydora
|
||||||
|
govulncheck ./...
|
||||||
|
git diff --check
|
||||||
|
```
|
||||||
|
|
||||||
|
Provider adapter tests must cover installation, idempotency, official event forwarding, blocking behavior, status, missing runtime files, uninstall ownership, and malformed configuration preservation.
|
||||||
|
|
||||||
|
Commit and push one root issue before starting the next one.
|
||||||
@@ -74,6 +74,8 @@ elydora install \
|
|||||||
--kid agent-456-key-v1
|
--kid agent-456-key-v1
|
||||||
```
|
```
|
||||||
|
|
||||||
|
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`.
|
||||||
|
|
||||||
### Commands
|
### Commands
|
||||||
|
|
||||||
| Command | Description |
|
| Command | Description |
|
||||||
|
|||||||
+144
-170
@@ -1,24 +1,46 @@
|
|||||||
package plugins
|
package plugins
|
||||||
|
|
||||||
import (
|
import "fmt"
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
)
|
|
||||||
|
|
||||||
// KiroCliPlugin manages the Elydora audit hook for Kiro CLI.
|
// KiroCliPlugin manages the stable v2 custom-agent and early-access v3 hook contracts.
|
||||||
// It merges PreToolUse/PostToolUse hooks into ~/.kiro/settings.json.
|
|
||||||
type KiroCliPlugin struct{}
|
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 {
|
func (p *KiroCliPlugin) Install(config InstallConfig) error {
|
||||||
|
if config.AgentID == "" {
|
||||||
|
return fmt.Errorf("agent ID is required")
|
||||||
|
}
|
||||||
|
if config.GuardScriptPath == "" {
|
||||||
|
return fmt.Errorf("guard script path is required")
|
||||||
|
}
|
||||||
|
guardExists, err := regularFileExists(config.GuardScriptPath, "Elydora guard runtime")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !guardExists {
|
||||||
|
return fmt.Errorf("Elydora guard runtime is missing: %s", config.GuardScriptPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
v2Path, v3Path, err := kiroConfigPaths()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
v2Settings, _, err := readKiroObject(v2Path, "Kiro CLI v2 agent config")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
v3Settings, _, err := readKiroObject(v3Path, "Kiro CLI v3 hooks config")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
v2Hooks, err := kiroHooksObject(v2Settings, "Kiro CLI v2 agent config")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
currentV3Hooks, err := kiroV3Hooks(v3Settings)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
scriptPath, err := hookScriptPath(config.AgentID)
|
scriptPath, err := hookScriptPath(config.AgentID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -26,199 +48,151 @@ func (p *KiroCliPlugin) Install(config InstallConfig) error {
|
|||||||
if config.HookScript != "" {
|
if config.HookScript != "" {
|
||||||
scriptPath = config.HookScript
|
scriptPath = config.HookScript
|
||||||
}
|
}
|
||||||
|
nodePath, err := resolveNodeRuntime()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
preToolUse, err := kiroHookEntries(v2Hooks, "preToolUse", "Kiro CLI v2 agent config")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
postToolUse, err := kiroHookEntries(v2Hooks, "postToolUse", "Kiro CLI v2 agent config")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
v2Hooks["preToolUse"] = append(
|
||||||
|
withoutKiroV2Hooks(preToolUse, ""),
|
||||||
|
buildKiroV2Hook(nodePath, config.GuardScriptPath),
|
||||||
|
)
|
||||||
|
v2Hooks["postToolUse"] = append(
|
||||||
|
withoutKiroV2Hooks(postToolUse, ""),
|
||||||
|
buildKiroV2Hook(nodePath, scriptPath),
|
||||||
|
)
|
||||||
|
|
||||||
|
nextV2 := map[string]any{
|
||||||
|
"name": kiroV2AgentName,
|
||||||
|
"description": kiroV2Description,
|
||||||
|
"tools": []any{"*"},
|
||||||
|
"includeMcpJson": true,
|
||||||
|
}
|
||||||
|
copyKiroObject(nextV2, v2Settings)
|
||||||
|
nextV2["hooks"] = v2Hooks
|
||||||
|
|
||||||
|
nextV3 := cloneKiroObject(v3Settings)
|
||||||
|
nextV3["version"] = "v1"
|
||||||
|
v3Hooks := make([]any, 0, len(currentV3Hooks)+2)
|
||||||
|
for _, hook := range currentV3Hooks {
|
||||||
|
if !isManagedKiroV3Hook(hook, "") {
|
||||||
|
v3Hooks = append(v3Hooks, hook)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
v3Hooks = append(
|
||||||
|
v3Hooks,
|
||||||
|
buildKiroV3Hook(
|
||||||
|
kiroV3GuardName,
|
||||||
|
"Block tool use when the Elydora agent is frozen",
|
||||||
|
"PreToolUse",
|
||||||
|
nodePath,
|
||||||
|
config.GuardScriptPath,
|
||||||
|
),
|
||||||
|
buildKiroV3Hook(
|
||||||
|
kiroV3AuditName,
|
||||||
|
"Record tool use in the Elydora audit trail",
|
||||||
|
"PostToolUse",
|
||||||
|
nodePath,
|
||||||
|
scriptPath,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
nextV3["hooks"] = v3Hooks
|
||||||
|
|
||||||
if err := GenerateHookScript(scriptPath, config); err != nil {
|
if err := GenerateHookScript(scriptPath, config); err != nil {
|
||||||
return fmt.Errorf("generate hook script: %w", err)
|
return fmt.Errorf("generate hook script: %w", err)
|
||||||
}
|
}
|
||||||
|
if err := writeKiroObjectAtomic(v2Path, nextV2); err != nil {
|
||||||
guardPath := config.GuardScriptPath
|
return err
|
||||||
if guardPath == "" {
|
|
||||||
guardPath, err = guardScriptPath(config.AgentID)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
if err := writeKiroObjectAtomic(v3Path, nextV3); err != nil {
|
||||||
configPath, err := p.configPath()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
settings, err := readJSONFile(configPath)
|
fmt.Println(`Kiro CLI v2: start with "kiro-cli --agent elydora-audit".`)
|
||||||
if err != nil {
|
fmt.Println(`Kiro CLI v3: start with "kiro-cli --v3"; global hooks load automatically.`)
|
||||||
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *KiroCliPlugin) Uninstall(agentID string) error {
|
func (p *KiroCliPlugin) Uninstall(agentID string) error {
|
||||||
configPath, err := p.configPath()
|
v2Path, v3Path, err := kiroConfigPaths()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
v2Settings, v2Exists, err := readKiroObject(v2Path, "Kiro CLI v2 agent config")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
v3Settings, v3Exists, err := readKiroObject(v3Path, "Kiro CLI v3 hooks config")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
settings, err := readJSONFile(configPath)
|
v2Mutation, err := prepareKiroV2Uninstall(v2Path, v2Settings, v2Exists, agentID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
v3Mutation, err := prepareKiroV3Uninstall(v3Path, v3Settings, v3Exists, agentID)
|
||||||
hooks, _ := settings["hooks"].(map[string]interface{})
|
if err != nil {
|
||||||
if hooks == nil {
|
return err
|
||||||
fmt.Println("No Kiro CLI hooks found.")
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
if err := applyKiroMutation(v2Mutation); err != nil {
|
||||||
// Remove PreToolUse Elydora entries
|
return err
|
||||||
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 {
|
if err := applyKiroMutation(v3Mutation); err != nil {
|
||||||
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
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if agentID != "" {
|
fmt.Println("Uninstalled Elydora hooks from Kiro CLI.")
|
||||||
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *KiroCliPlugin) Status() (PluginStatus, error) {
|
func (p *KiroCliPlugin) Status() (PluginStatus, error) {
|
||||||
configPath, err := p.configPath()
|
v2Path, v3Path, err := kiroConfigPaths()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return PluginStatus{}, err
|
return PluginStatus{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
status := PluginStatus{
|
status := PluginStatus{
|
||||||
AgentName: "kirocli",
|
AgentName: kiroAgentKey,
|
||||||
DisplayName: "Kiro CLI",
|
DisplayName: "Kiro CLI",
|
||||||
ConfigPath: configPath,
|
ConfigPath: v3Path,
|
||||||
|
}
|
||||||
|
v2Settings, v2Exists, err := readKiroObject(v2Path, "Kiro CLI v2 agent config")
|
||||||
|
if err != nil {
|
||||||
|
return status, err
|
||||||
|
}
|
||||||
|
v3Settings, v3Exists, err := readKiroObject(v3Path, "Kiro CLI v3 hooks config")
|
||||||
|
if err != nil {
|
||||||
|
return status, err
|
||||||
}
|
}
|
||||||
|
|
||||||
settings, err := readJSONFile(configPath)
|
contracts, err := configuredKiroContracts(
|
||||||
|
v2Path,
|
||||||
|
v2Settings,
|
||||||
|
v2Exists,
|
||||||
|
v3Path,
|
||||||
|
v3Settings,
|
||||||
|
v3Exists,
|
||||||
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
return status, err
|
||||||
|
}
|
||||||
|
if len(contracts) == 0 {
|
||||||
return status, nil
|
return status, nil
|
||||||
}
|
}
|
||||||
|
status.ConfigPath = contracts[len(contracts)-1].configPath
|
||||||
hooks, _ := settings["hooks"].(map[string]interface{})
|
status.HookConfigured = true
|
||||||
if hooks != nil {
|
status.HookScriptExists, err = kiroRuntimeScriptsExist(contracts)
|
||||||
preConfigured := hasElydoraEntry(hooks["PreToolUse"])
|
if err != nil {
|
||||||
postConfigured := hasElydoraEntry(hooks["PostToolUse"])
|
return status, err
|
||||||
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.HookScriptExists
|
||||||
status.Installed = status.HookConfigured && status.HookScriptExists
|
|
||||||
return status, nil
|
return status, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,399 @@
|
|||||||
|
package plugins
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
kiroAgentKey = "kirocli"
|
||||||
|
kiroV2AgentName = "elydora-audit"
|
||||||
|
kiroV2Description = "Kiro CLI with Elydora audit and freeze enforcement"
|
||||||
|
kiroV3GuardName = "elydora-guard"
|
||||||
|
kiroV3AuditName = "elydora-audit"
|
||||||
|
)
|
||||||
|
|
||||||
|
type kiroHookContract struct {
|
||||||
|
guardCommand string
|
||||||
|
auditCommand string
|
||||||
|
configPath string
|
||||||
|
}
|
||||||
|
|
||||||
|
type kiroConfigMutation struct {
|
||||||
|
path string
|
||||||
|
value map[string]any
|
||||||
|
changed bool
|
||||||
|
remove bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneKiroObject(value map[string]any) map[string]any {
|
||||||
|
clone := make(map[string]any, len(value))
|
||||||
|
copyKiroObject(clone, value)
|
||||||
|
return clone
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyKiroObject(destination, source map[string]any) {
|
||||||
|
for key, value := range source {
|
||||||
|
destination[key] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func kiroHooksObject(settings map[string]any, label string) (map[string]any, error) {
|
||||||
|
value, exists := settings["hooks"]
|
||||||
|
if !exists {
|
||||||
|
return map[string]any{}, nil
|
||||||
|
}
|
||||||
|
hooks, ok := value.(map[string]any)
|
||||||
|
if !ok || hooks == nil {
|
||||||
|
return nil, fmt.Errorf(`%s field "hooks" must be an object`, label)
|
||||||
|
}
|
||||||
|
return cloneKiroObject(hooks), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func kiroHookEntries(hooks map[string]any, event, label string) ([]map[string]any, error) {
|
||||||
|
value, exists := hooks[event]
|
||||||
|
if !exists {
|
||||||
|
return []map[string]any{}, nil
|
||||||
|
}
|
||||||
|
entries, ok := value.([]any)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf(`%s field "hooks.%s" must be an array of objects`, label, event)
|
||||||
|
}
|
||||||
|
objects := make([]map[string]any, 0, len(entries))
|
||||||
|
for _, entry := range entries {
|
||||||
|
object, ok := entry.(map[string]any)
|
||||||
|
if !ok || object == nil {
|
||||||
|
return nil, fmt.Errorf(`%s field "hooks.%s" must be an array of objects`, label, event)
|
||||||
|
}
|
||||||
|
objects = append(objects, object)
|
||||||
|
}
|
||||||
|
return objects, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func isManagedKiroCommand(command any, scriptName, agentID string) bool {
|
||||||
|
text, ok := command.(string)
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
normalized := strings.ToLower(text)
|
||||||
|
if !strings.Contains(normalized, ".elydora") || !strings.Contains(normalized, strings.ToLower(scriptName)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return agentID == "" || strings.Contains(text, agentID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func withoutKiroV2Hooks(entries []map[string]any, agentID string) []any {
|
||||||
|
filtered := make([]any, 0, len(entries))
|
||||||
|
for _, hook := range entries {
|
||||||
|
if isManagedKiroCommand(hook["command"], "guard.js", agentID) ||
|
||||||
|
isManagedKiroCommand(hook["command"], "hook.js", agentID) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
filtered = append(filtered, hook)
|
||||||
|
}
|
||||||
|
return filtered
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildKiroV2Hook(runtimePath, scriptPath string) map[string]any {
|
||||||
|
return map[string]any{
|
||||||
|
"matcher": "*",
|
||||||
|
"command": buildKiroCommand(runtimePath, scriptPath),
|
||||||
|
"timeout_ms": 5000,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func kiroV3Hooks(settings map[string]any) ([]map[string]any, error) {
|
||||||
|
if value, exists := settings["version"]; exists {
|
||||||
|
version, ok := value.(string)
|
||||||
|
if !ok || version != "v1" {
|
||||||
|
return nil, fmt.Errorf(`Kiro CLI v3 hooks config field "version" must be "v1"`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
value, exists := settings["hooks"]
|
||||||
|
if !exists {
|
||||||
|
return []map[string]any{}, nil
|
||||||
|
}
|
||||||
|
hooks, ok := value.([]any)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf(`Kiro CLI v3 hooks config field "hooks" must be an array of objects`)
|
||||||
|
}
|
||||||
|
objects := make([]map[string]any, 0, len(hooks))
|
||||||
|
for _, hook := range hooks {
|
||||||
|
object, ok := hook.(map[string]any)
|
||||||
|
if !ok || object == nil {
|
||||||
|
return nil, fmt.Errorf(`Kiro CLI v3 hooks config field "hooks" must be an array of objects`)
|
||||||
|
}
|
||||||
|
objects = append(objects, object)
|
||||||
|
}
|
||||||
|
return objects, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func kiroV3ActionCommand(hook map[string]any) any {
|
||||||
|
action, ok := hook["action"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return action["command"]
|
||||||
|
}
|
||||||
|
|
||||||
|
func isManagedKiroV3Hook(hook map[string]any, agentID string) bool {
|
||||||
|
switch hook["name"] {
|
||||||
|
case kiroV3GuardName:
|
||||||
|
return isManagedKiroCommand(kiroV3ActionCommand(hook), "guard.js", agentID)
|
||||||
|
case kiroV3AuditName:
|
||||||
|
return isManagedKiroCommand(kiroV3ActionCommand(hook), "hook.js", agentID)
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildKiroV3Hook(name, description, trigger, runtimePath, scriptPath string) map[string]any {
|
||||||
|
return map[string]any{
|
||||||
|
"name": name,
|
||||||
|
"description": description,
|
||||||
|
"trigger": trigger,
|
||||||
|
"matcher": ".*",
|
||||||
|
"action": map[string]any{
|
||||||
|
"type": "command",
|
||||||
|
"command": buildKiroCommand(runtimePath, scriptPath),
|
||||||
|
},
|
||||||
|
"timeout": 5,
|
||||||
|
"enabled": true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func prepareKiroV2Uninstall(
|
||||||
|
path string,
|
||||||
|
settings map[string]any,
|
||||||
|
exists bool,
|
||||||
|
agentID string,
|
||||||
|
) (kiroConfigMutation, error) {
|
||||||
|
mutation := kiroConfigMutation{path: path}
|
||||||
|
if !exists {
|
||||||
|
return mutation, nil
|
||||||
|
}
|
||||||
|
hooks, err := kiroHooksObject(settings, "Kiro CLI v2 agent config")
|
||||||
|
if err != nil {
|
||||||
|
return mutation, err
|
||||||
|
}
|
||||||
|
pre, err := kiroHookEntries(hooks, "preToolUse", "Kiro CLI v2 agent config")
|
||||||
|
if err != nil {
|
||||||
|
return mutation, err
|
||||||
|
}
|
||||||
|
post, err := kiroHookEntries(hooks, "postToolUse", "Kiro CLI v2 agent config")
|
||||||
|
if err != nil {
|
||||||
|
return mutation, err
|
||||||
|
}
|
||||||
|
filteredPre := withoutKiroV2Hooks(pre, agentID)
|
||||||
|
filteredPost := withoutKiroV2Hooks(post, agentID)
|
||||||
|
if len(filteredPre) == len(pre) && len(filteredPost) == len(post) {
|
||||||
|
return mutation, nil
|
||||||
|
}
|
||||||
|
hooks["preToolUse"] = filteredPre
|
||||||
|
hooks["postToolUse"] = filteredPost
|
||||||
|
next := cloneKiroObject(settings)
|
||||||
|
next["hooks"] = hooks
|
||||||
|
mutation.value = next
|
||||||
|
mutation.changed = true
|
||||||
|
mutation.remove = isKiroV2Owned(settings, hooks)
|
||||||
|
return mutation, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func isKiroV2Owned(settings, hooks map[string]any) bool {
|
||||||
|
allowedConfigKeys := map[string]bool{
|
||||||
|
"name": true, "description": true, "tools": true, "includeMcpJson": true, "hooks": true,
|
||||||
|
}
|
||||||
|
for key := range settings {
|
||||||
|
if !allowedConfigKeys[key] {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tools, ok := settings["tools"].([]any)
|
||||||
|
if settings["name"] != kiroV2AgentName || settings["description"] != kiroV2Description ||
|
||||||
|
!ok || len(tools) != 1 || tools[0] != "*" || settings["includeMcpJson"] != true {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for event, value := range hooks {
|
||||||
|
if event != "preToolUse" && event != "postToolUse" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
entries, ok := value.([]any)
|
||||||
|
if !ok || len(entries) != 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func prepareKiroV3Uninstall(
|
||||||
|
path string,
|
||||||
|
settings map[string]any,
|
||||||
|
exists bool,
|
||||||
|
agentID string,
|
||||||
|
) (kiroConfigMutation, error) {
|
||||||
|
mutation := kiroConfigMutation{path: path}
|
||||||
|
if !exists {
|
||||||
|
return mutation, nil
|
||||||
|
}
|
||||||
|
hooks, err := kiroV3Hooks(settings)
|
||||||
|
if err != nil {
|
||||||
|
return mutation, err
|
||||||
|
}
|
||||||
|
filtered := make([]any, 0, len(hooks))
|
||||||
|
for _, hook := range hooks {
|
||||||
|
if !isManagedKiroV3Hook(hook, agentID) {
|
||||||
|
filtered = append(filtered, hook)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(filtered) == len(hooks) {
|
||||||
|
return mutation, nil
|
||||||
|
}
|
||||||
|
next := cloneKiroObject(settings)
|
||||||
|
next["hooks"] = filtered
|
||||||
|
owned := true
|
||||||
|
for key := range settings {
|
||||||
|
if key != "version" && key != "hooks" {
|
||||||
|
owned = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mutation.value = next
|
||||||
|
mutation.changed = true
|
||||||
|
mutation.remove = owned && len(filtered) == 0
|
||||||
|
return mutation, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyKiroMutation(mutation kiroConfigMutation) error {
|
||||||
|
if !mutation.changed {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if mutation.remove {
|
||||||
|
return removeKiroFile(mutation.path, "Kiro CLI config")
|
||||||
|
}
|
||||||
|
return writeKiroObjectAtomic(mutation.path, mutation.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func configuredKiroContracts(
|
||||||
|
v2Path string,
|
||||||
|
v2Settings map[string]any,
|
||||||
|
v2Exists bool,
|
||||||
|
v3Path string,
|
||||||
|
v3Settings map[string]any,
|
||||||
|
v3Exists bool,
|
||||||
|
) ([]kiroHookContract, error) {
|
||||||
|
contracts := make([]kiroHookContract, 0, 2)
|
||||||
|
if v2Exists {
|
||||||
|
hooks, err := kiroHooksObject(v2Settings, "Kiro CLI v2 agent config")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
pre, err := kiroHookEntries(hooks, "preToolUse", "Kiro CLI v2 agent config")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
post, err := kiroHookEntries(hooks, "postToolUse", "Kiro CLI v2 agent config")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
guard := findKiroV2Command(pre, "guard.js")
|
||||||
|
audit := findKiroV2Command(post, "hook.js")
|
||||||
|
if guard != "" && audit != "" {
|
||||||
|
contracts = append(contracts, kiroHookContract{guard, audit, v2Path})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if v3Exists {
|
||||||
|
hooks, err := kiroV3Hooks(v3Settings)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
guard := findKiroV3Command(hooks, kiroV3GuardName, "guard.js")
|
||||||
|
audit := findKiroV3Command(hooks, kiroV3AuditName, "hook.js")
|
||||||
|
if guard != "" && audit != "" {
|
||||||
|
contracts = append(contracts, kiroHookContract{guard, audit, v3Path})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return contracts, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func findKiroV2Command(entries []map[string]any, scriptName string) string {
|
||||||
|
for _, hook := range entries {
|
||||||
|
if isManagedKiroCommand(hook["command"], scriptName, "") {
|
||||||
|
return hook["command"].(string)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func findKiroV3Command(entries []map[string]any, name, scriptName string) string {
|
||||||
|
for _, hook := range entries {
|
||||||
|
command := kiroV3ActionCommand(hook)
|
||||||
|
if hook["name"] == name && isManagedKiroCommand(command, scriptName, "") {
|
||||||
|
return command.(string)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func regularFileExists(path, label string) (bool, error) {
|
||||||
|
info, err := os.Stat(path)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return false, fmt.Errorf("read %s at %s: %w", label, path, err)
|
||||||
|
}
|
||||||
|
return info.Mode().IsRegular(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func kiroRuntimeScriptsExist(contracts []kiroHookContract) (bool, error) {
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("resolve home directory: %w", err)
|
||||||
|
}
|
||||||
|
root := filepath.Join(home, ".elydora")
|
||||||
|
entries, err := os.ReadDir(root)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return false, fmt.Errorf("read Elydora runtime directory at %s: %w", root, err)
|
||||||
|
}
|
||||||
|
for _, entry := range entries {
|
||||||
|
if !entry.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
agentDir := filepath.Join(root, entry.Name())
|
||||||
|
guardPath := filepath.Join(agentDir, "guard.js")
|
||||||
|
hookPath := filepath.Join(agentDir, "hook.js")
|
||||||
|
referenced := false
|
||||||
|
for _, contract := range contracts {
|
||||||
|
if strings.Contains(contract.guardCommand, guardPath) &&
|
||||||
|
strings.Contains(contract.auditCommand, hookPath) {
|
||||||
|
referenced = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !referenced {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
configPath := filepath.Join(agentDir, "config.json")
|
||||||
|
config, exists, err := readKiroObject(configPath, "Elydora runtime config")
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if !exists || config["agent_name"] != kiroAgentKey {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
guardExists, err := regularFileExists(guardPath, "Elydora guard runtime")
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
hookExists, err := regularFileExists(hookPath, "Elydora audit runtime")
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return guardExists && hookExists, nil
|
||||||
|
}
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
package plugins
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func kiroConfigPaths() (string, string, error) {
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return "", "", fmt.Errorf("resolve home directory: %w", err)
|
||||||
|
}
|
||||||
|
return filepath.Join(home, ".kiro", "agents", kiroV2AgentName+".json"),
|
||||||
|
filepath.Join(home, ".kiro", "hooks", "elydora-audit.json"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func readKiroObject(path, label string) (map[string]any, bool, error) {
|
||||||
|
raw, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return map[string]any{}, false, nil
|
||||||
|
}
|
||||||
|
return nil, false, fmt.Errorf("read %s at %s: %w", label, path, err)
|
||||||
|
}
|
||||||
|
var value any
|
||||||
|
if err := json.Unmarshal(raw, &value); err != nil {
|
||||||
|
return nil, true, fmt.Errorf("parse %s at %s: %w", label, path, err)
|
||||||
|
}
|
||||||
|
object, ok := value.(map[string]any)
|
||||||
|
if !ok || object == nil {
|
||||||
|
return nil, true, fmt.Errorf("%s at %s must contain a JSON object", label, path)
|
||||||
|
}
|
||||||
|
return object, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeKiroObjectAtomic(path string, value map[string]any) error {
|
||||||
|
encoded, err := json.MarshalIndent(value, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("marshal %s: %w", path, err)
|
||||||
|
}
|
||||||
|
encoded = append(encoded, '\n')
|
||||||
|
directory := filepath.Dir(path)
|
||||||
|
if err := os.MkdirAll(directory, 0700); err != nil {
|
||||||
|
return fmt.Errorf("create directory %s: %w", directory, err)
|
||||||
|
}
|
||||||
|
temporary, err := os.CreateTemp(directory, "."+filepath.Base(path)+".*.tmp")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("create temporary file for %s: %w", path, err)
|
||||||
|
}
|
||||||
|
temporaryPath := temporary.Name()
|
||||||
|
cleanup := func(cause error) error {
|
||||||
|
closeErr := temporary.Close()
|
||||||
|
removeErr := os.Remove(temporaryPath)
|
||||||
|
if os.IsNotExist(removeErr) {
|
||||||
|
removeErr = nil
|
||||||
|
}
|
||||||
|
return errors.Join(cause, closeErr, removeErr)
|
||||||
|
}
|
||||||
|
if err := temporary.Chmod(0600); err != nil {
|
||||||
|
return cleanup(fmt.Errorf("set permissions for %s: %w", temporaryPath, err))
|
||||||
|
}
|
||||||
|
if _, err := temporary.Write(encoded); err != nil {
|
||||||
|
return cleanup(fmt.Errorf("write temporary file for %s: %w", path, err))
|
||||||
|
}
|
||||||
|
if err := temporary.Sync(); err != nil {
|
||||||
|
return cleanup(fmt.Errorf("sync temporary file for %s: %w", path, err))
|
||||||
|
}
|
||||||
|
if err := temporary.Close(); err != nil {
|
||||||
|
return cleanup(fmt.Errorf("close temporary file for %s: %w", path, err))
|
||||||
|
}
|
||||||
|
if err := os.Rename(temporaryPath, path); err != nil {
|
||||||
|
removeErr := os.Remove(temporaryPath)
|
||||||
|
if os.IsNotExist(removeErr) {
|
||||||
|
removeErr = nil
|
||||||
|
}
|
||||||
|
return errors.Join(fmt.Errorf("replace %s: %w", path, err), removeErr)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func removeKiroFile(path, label string) error {
|
||||||
|
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
|
||||||
|
return fmt.Errorf("remove %s at %s: %w", label, path, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveNodeRuntime() (string, error) {
|
||||||
|
nodePath, err := exec.LookPath("node")
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("resolve Node.js runtime: %w", err)
|
||||||
|
}
|
||||||
|
absolute, err := filepath.Abs(nodePath)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("resolve Node.js runtime path: %w", err)
|
||||||
|
}
|
||||||
|
return absolute, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildKiroCommand(runtimePath, scriptPath string) string {
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
return quoteWindowsArgument(runtimePath) + " " + quoteWindowsArgument(scriptPath)
|
||||||
|
}
|
||||||
|
return quotePOSIXArgument(runtimePath) + " " + quotePOSIXArgument(scriptPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
func quotePOSIXArgument(value string) string {
|
||||||
|
return "'" + strings.ReplaceAll(value, "'", `'"'"'`) + "'"
|
||||||
|
}
|
||||||
|
|
||||||
|
func quoteWindowsArgument(value string) string {
|
||||||
|
if value != "" && !strings.ContainsAny(value, " \t\n\v\"") {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
var result strings.Builder
|
||||||
|
result.WriteByte('"')
|
||||||
|
backslashes := 0
|
||||||
|
for _, character := range value {
|
||||||
|
if character == '\\' {
|
||||||
|
backslashes++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if character == '"' {
|
||||||
|
result.WriteString(strings.Repeat("\\", backslashes*2+1))
|
||||||
|
result.WriteRune(character)
|
||||||
|
backslashes = 0
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
result.WriteString(strings.Repeat("\\", backslashes))
|
||||||
|
backslashes = 0
|
||||||
|
result.WriteRune(character)
|
||||||
|
}
|
||||||
|
result.WriteString(strings.Repeat("\\", backslashes*2))
|
||||||
|
result.WriteByte('"')
|
||||||
|
return result.String()
|
||||||
|
}
|
||||||
@@ -0,0 +1,436 @@
|
|||||||
|
package plugins
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
const kiroTestAgentID = "agent-1"
|
||||||
|
|
||||||
|
type kiroFixture struct {
|
||||||
|
plugin *KiroCliPlugin
|
||||||
|
config InstallConfig
|
||||||
|
homeDir string
|
||||||
|
guardPath string
|
||||||
|
hookPath string
|
||||||
|
runtimeConfig string
|
||||||
|
v2Path string
|
||||||
|
v3Path string
|
||||||
|
}
|
||||||
|
|
||||||
|
func prepareKiroFixture(t *testing.T, v2Raw, v3Raw string) *kiroFixture {
|
||||||
|
t.Helper()
|
||||||
|
homeDir := filepath.Join(t.TempDir(), "home with spaces")
|
||||||
|
agentDir := filepath.Join(homeDir, ".elydora", kiroTestAgentID)
|
||||||
|
guardPath := filepath.Join(agentDir, "guard.js")
|
||||||
|
v2Path := filepath.Join(homeDir, ".kiro", "agents", "elydora-audit.json")
|
||||||
|
v3Path := filepath.Join(homeDir, ".kiro", "hooks", "elydora-audit.json")
|
||||||
|
if err := os.MkdirAll(agentDir, 0755); err != nil {
|
||||||
|
t.Fatalf("create agent directory: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(
|
||||||
|
guardPath,
|
||||||
|
[]byte("process.stderr.write('Agent is frozen by Elydora.'); process.exit(2);\n"),
|
||||||
|
0600,
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("write guard: %v", err)
|
||||||
|
}
|
||||||
|
writeOptionalKiroConfig(t, v2Path, v2Raw)
|
||||||
|
writeOptionalKiroConfig(t, v3Path, v3Raw)
|
||||||
|
t.Setenv("HOME", homeDir)
|
||||||
|
t.Setenv("USERPROFILE", homeDir)
|
||||||
|
|
||||||
|
return &kiroFixture{
|
||||||
|
plugin: &KiroCliPlugin{},
|
||||||
|
homeDir: homeDir,
|
||||||
|
config: InstallConfig{
|
||||||
|
AgentName: "kirocli",
|
||||||
|
OrgID: "org-1",
|
||||||
|
AgentID: kiroTestAgentID,
|
||||||
|
PrivateKey: "test-key",
|
||||||
|
KID: "kid-1",
|
||||||
|
BaseURL: "https://api.elydora.test",
|
||||||
|
GuardScriptPath: guardPath,
|
||||||
|
},
|
||||||
|
guardPath: guardPath,
|
||||||
|
hookPath: filepath.Join(agentDir, "hook.js"),
|
||||||
|
runtimeConfig: filepath.Join(agentDir, "config.json"),
|
||||||
|
v2Path: v2Path,
|
||||||
|
v3Path: v3Path,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeOptionalKiroConfig(t *testing.T, path, raw string) {
|
||||||
|
t.Helper()
|
||||||
|
if raw == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
|
||||||
|
t.Fatalf("create config directory: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(path, []byte(raw), 0600); err != nil {
|
||||||
|
t.Fatalf("write config: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func readKiroTestObject(t *testing.T, path string) map[string]any {
|
||||||
|
t.Helper()
|
||||||
|
raw, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read %s: %v", path, err)
|
||||||
|
}
|
||||||
|
var value map[string]any
|
||||||
|
if err := json.Unmarshal(raw, &value); err != nil {
|
||||||
|
t.Fatalf("decode %s: %v", path, err)
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
func requireObject(t *testing.T, value any) map[string]any {
|
||||||
|
t.Helper()
|
||||||
|
object, ok := value.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("value = %#v, want object", value)
|
||||||
|
}
|
||||||
|
return object
|
||||||
|
}
|
||||||
|
|
||||||
|
func requireArray(t *testing.T, value any) []any {
|
||||||
|
t.Helper()
|
||||||
|
array, ok := value.([]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("value = %#v, want array", value)
|
||||||
|
}
|
||||||
|
return array
|
||||||
|
}
|
||||||
|
|
||||||
|
func findKiroV3Hook(t *testing.T, config map[string]any, name string) map[string]any {
|
||||||
|
t.Helper()
|
||||||
|
for _, value := range requireArray(t, config["hooks"]) {
|
||||||
|
hook := requireObject(t, value)
|
||||||
|
if hook["name"] == name {
|
||||||
|
return hook
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatalf("hook %q not found", name)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func kiroHookCommand(t *testing.T, hook map[string]any) string {
|
||||||
|
t.Helper()
|
||||||
|
action := requireObject(t, hook["action"])
|
||||||
|
command, ok := action["command"].(string)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("hook command = %#v, want string", action["command"])
|
||||||
|
}
|
||||||
|
return command
|
||||||
|
}
|
||||||
|
|
||||||
|
func marshalKiroPayload(t *testing.T, payload map[string]any) []byte {
|
||||||
|
t.Helper()
|
||||||
|
encoded, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal Kiro hook payload: %v", err)
|
||||||
|
}
|
||||||
|
return encoded
|
||||||
|
}
|
||||||
|
|
||||||
|
func runKiroCommand(t *testing.T, command, homeDir string, payload []byte) (int, string) {
|
||||||
|
t.Helper()
|
||||||
|
var cmd *exec.Cmd
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
commandFile := filepath.Join(t.TempDir(), "run-kiro-hook.cmd")
|
||||||
|
if err := os.WriteFile(commandFile, []byte("@echo off\r\n"+command+"\r\n"), 0600); err != nil {
|
||||||
|
t.Fatalf("write hook command file: %v", err)
|
||||||
|
}
|
||||||
|
cmd = exec.Command("cmd.exe", "/d", "/c", commandFile)
|
||||||
|
} else {
|
||||||
|
cmd = exec.Command("sh", "-c", command)
|
||||||
|
}
|
||||||
|
cmd.Env = append(os.Environ(), "HOME="+homeDir, "USERPROFILE="+homeDir)
|
||||||
|
cmd.Stdin = bytes.NewReader(payload)
|
||||||
|
var stderr bytes.Buffer
|
||||||
|
cmd.Stderr = &stderr
|
||||||
|
err := cmd.Run()
|
||||||
|
if err == nil {
|
||||||
|
return 0, stderr.String()
|
||||||
|
}
|
||||||
|
var exitError *exec.ExitError
|
||||||
|
if errors.As(err, &exitError) {
|
||||||
|
return exitError.ExitCode(), stderr.String()
|
||||||
|
}
|
||||||
|
t.Fatalf("run hook command: %v", err)
|
||||||
|
return -1, stderr.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKiroCLIRegistryPointsAtV3GlobalHookContract(t *testing.T) {
|
||||||
|
entry := SupportedAgents["kirocli"]
|
||||||
|
if entry.Name != "Kiro CLI" || entry.ConfigDir != "~/.kiro/hooks" || entry.ConfigFile != "elydora-audit.json" {
|
||||||
|
t.Fatalf("Kiro CLI registry entry = %#v", entry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKiroCLIInstallPreservesHooksAndWritesIdempotentV2V3Contracts(t *testing.T) {
|
||||||
|
fixture := prepareKiroFixture(t, `{
|
||||||
|
"description": "User Kiro agent",
|
||||||
|
"tools": ["read"],
|
||||||
|
"hooks": {
|
||||||
|
"agentSpawn": [{"command": "existing-spawn"}],
|
||||||
|
"preToolUse": [{"matcher": "read", "command": "existing-v2"}]
|
||||||
|
}
|
||||||
|
}`, `{
|
||||||
|
"version": "v1",
|
||||||
|
"hooks": [{
|
||||||
|
"name": "existing-v3",
|
||||||
|
"trigger": "SessionStart",
|
||||||
|
"action": {"type": "command", "command": "existing-command"}
|
||||||
|
}]
|
||||||
|
}`)
|
||||||
|
if err := fixture.plugin.Install(fixture.config); err != nil {
|
||||||
|
t.Fatalf("install Kiro CLI hooks: %v", err)
|
||||||
|
}
|
||||||
|
if err := fixture.plugin.Install(fixture.config); err != nil {
|
||||||
|
t.Fatalf("repeat Kiro CLI install: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
v2 := readKiroTestObject(t, fixture.v2Path)
|
||||||
|
if v2["description"] != "User Kiro agent" || !reflect.DeepEqual(v2["tools"], []any{"read"}) {
|
||||||
|
t.Fatalf("v2 user fields changed: %#v", v2)
|
||||||
|
}
|
||||||
|
v2Hooks := requireObject(t, v2["hooks"])
|
||||||
|
v2Pre := requireArray(t, v2Hooks["preToolUse"])
|
||||||
|
if len(v2Pre) != 2 || len(requireArray(t, v2Hooks["postToolUse"])) != 1 {
|
||||||
|
t.Fatalf("unexpected v2 hooks: %#v", v2Hooks)
|
||||||
|
}
|
||||||
|
v2Guard := requireObject(t, v2Pre[1])
|
||||||
|
if v2Guard["matcher"] != "*" || v2Guard["timeout_ms"] != float64(5000) {
|
||||||
|
t.Fatalf("unexpected v2 guard: %#v", v2Guard)
|
||||||
|
}
|
||||||
|
if _, ok := v2Guard["command"].(string); !ok {
|
||||||
|
t.Fatalf("v2 guard command = %#v, want string", v2Guard["command"])
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(v2Hooks["agentSpawn"], []any{map[string]any{"command": "existing-spawn"}}) {
|
||||||
|
t.Fatalf("v2 unrelated hook changed: %#v", v2Hooks["agentSpawn"])
|
||||||
|
}
|
||||||
|
|
||||||
|
v3 := readKiroTestObject(t, fixture.v3Path)
|
||||||
|
if v3["version"] != "v1" || len(requireArray(t, v3["hooks"])) != 3 {
|
||||||
|
t.Fatalf("unexpected v3 config: %#v", v3)
|
||||||
|
}
|
||||||
|
guard := findKiroV3Hook(t, v3, "elydora-guard")
|
||||||
|
if guard["trigger"] != "PreToolUse" || guard["matcher"] != ".*" || guard["timeout"] != float64(5) || guard["enabled"] != true {
|
||||||
|
t.Fatalf("unexpected v3 guard: %#v", guard)
|
||||||
|
}
|
||||||
|
if requireObject(t, guard["action"])["type"] != "command" {
|
||||||
|
t.Fatalf("unexpected v3 guard action: %#v", guard["action"])
|
||||||
|
}
|
||||||
|
if findKiroV3Hook(t, v3, "elydora-audit")["trigger"] != "PostToolUse" {
|
||||||
|
t.Fatalf("unexpected v3 audit hook: %#v", v3)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKiroCLICommandsBlockFrozenAgentAndForwardOfficialPayload(t *testing.T) {
|
||||||
|
fixture := prepareKiroFixture(t, "", "")
|
||||||
|
if err := fixture.plugin.Install(fixture.config); err != nil {
|
||||||
|
t.Fatalf("install Kiro CLI hooks: %v", err)
|
||||||
|
}
|
||||||
|
capturePath := filepath.Join(t.TempDir(), "captured-event.json")
|
||||||
|
captureJSON, err := json.Marshal(capturePath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal capture path: %v", err)
|
||||||
|
}
|
||||||
|
captureScript := "const fs = require('node:fs'); const chunks = []; " +
|
||||||
|
"process.stdin.on('data', chunk => chunks.push(chunk)); " +
|
||||||
|
"process.stdin.on('end', () => fs.writeFileSync(" + string(captureJSON) + ", Buffer.concat(chunks)));\n"
|
||||||
|
if err := os.WriteFile(fixture.hookPath, []byte(captureScript), 0600); err != nil {
|
||||||
|
t.Fatalf("write capture hook: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
v3 := readKiroTestObject(t, fixture.v3Path)
|
||||||
|
payload := map[string]any{
|
||||||
|
"hook_event_name": "PreToolUse",
|
||||||
|
"cwd": fixture.homeDir,
|
||||||
|
"session_id": "session-1",
|
||||||
|
"tool_name": "execute_bash",
|
||||||
|
"tool_input": map[string]any{"command": "echo test"},
|
||||||
|
}
|
||||||
|
encoded := marshalKiroPayload(t, payload)
|
||||||
|
exitCode, stderr := runKiroCommand(t, kiroHookCommand(t, findKiroV3Hook(t, v3, "elydora-guard")), fixture.homeDir, encoded)
|
||||||
|
if exitCode != 2 || !strings.Contains(stderr, "Agent is frozen by Elydora") {
|
||||||
|
t.Fatalf("guard exit = %d, stderr = %q", exitCode, stderr)
|
||||||
|
}
|
||||||
|
|
||||||
|
payload["hook_event_name"] = "PostToolUse"
|
||||||
|
payload["tool_response"] = map[string]any{"success": true, "result": "test"}
|
||||||
|
encoded = marshalKiroPayload(t, payload)
|
||||||
|
exitCode, stderr = runKiroCommand(t, kiroHookCommand(t, findKiroV3Hook(t, v3, "elydora-audit")), fixture.homeDir, encoded)
|
||||||
|
if exitCode != 0 {
|
||||||
|
t.Fatalf("audit exit = %d, stderr = %q", exitCode, stderr)
|
||||||
|
}
|
||||||
|
var captured map[string]any
|
||||||
|
if raw, err := os.ReadFile(capturePath); err != nil {
|
||||||
|
t.Fatalf("read captured event: %v", err)
|
||||||
|
} else if err := json.Unmarshal(raw, &captured); err != nil {
|
||||||
|
t.Fatalf("decode captured event: %v", err)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(captured, payload) {
|
||||||
|
t.Fatalf("captured event = %#v, want %#v", captured, payload)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKiroCLIStatusAcceptsEitherContractAndRequiresBothRuntimes(t *testing.T) {
|
||||||
|
fixture := prepareKiroFixture(t, "", "")
|
||||||
|
if err := fixture.plugin.Install(fixture.config); err != nil {
|
||||||
|
t.Fatalf("install Kiro CLI hooks: %v", err)
|
||||||
|
}
|
||||||
|
assertKiroStatus(t, fixture.plugin, true, fixture.v3Path)
|
||||||
|
|
||||||
|
if err := os.Remove(fixture.v3Path); err != nil {
|
||||||
|
t.Fatalf("remove v3 config: %v", err)
|
||||||
|
}
|
||||||
|
assertKiroStatus(t, fixture.plugin, true, fixture.v2Path)
|
||||||
|
|
||||||
|
if err := fixture.plugin.Install(fixture.config); err != nil {
|
||||||
|
t.Fatalf("reinstall Kiro CLI hooks: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.Remove(fixture.v2Path); err != nil {
|
||||||
|
t.Fatalf("remove v2 config: %v", err)
|
||||||
|
}
|
||||||
|
assertKiroStatus(t, fixture.plugin, true, fixture.v3Path)
|
||||||
|
|
||||||
|
if err := os.Remove(fixture.guardPath); err != nil {
|
||||||
|
t.Fatalf("remove guard: %v", err)
|
||||||
|
}
|
||||||
|
status, err := fixture.plugin.Status()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read degraded status: %v", err)
|
||||||
|
}
|
||||||
|
if status.Installed || !status.HookConfigured || status.HookScriptExists {
|
||||||
|
t.Fatalf("unexpected degraded status: %#v", status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertKiroStatus(t *testing.T, plugin *KiroCliPlugin, installed bool, configPath string) {
|
||||||
|
t.Helper()
|
||||||
|
status, err := plugin.Status()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read Kiro CLI status: %v", err)
|
||||||
|
}
|
||||||
|
if status.Installed != installed || status.ConfigPath != configPath {
|
||||||
|
t.Fatalf("status = %#v, want installed=%v config=%q", status, installed, configPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKiroCLIStatusSurfacesMalformedReferencedRuntimeMetadata(t *testing.T) {
|
||||||
|
fixture := prepareKiroFixture(t, "", "")
|
||||||
|
if err := fixture.plugin.Install(fixture.config); err != nil {
|
||||||
|
t.Fatalf("install Kiro CLI hooks: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(fixture.runtimeConfig, []byte("{ malformed"), 0600); err != nil {
|
||||||
|
t.Fatalf("corrupt runtime config: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := fixture.plugin.Status(); err == nil || !strings.Contains(err.Error(), "parse Elydora runtime config") {
|
||||||
|
t.Fatalf("status error = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKiroCLIUninstallPreservesUnrelatedHooks(t *testing.T) {
|
||||||
|
fixture := prepareKiroFixture(t, `{"hooks":{"preToolUse":[{"matcher":"read","command":"existing-v2"}]}}`, `{
|
||||||
|
"version":"v1",
|
||||||
|
"hooks":[{"name":"existing-v3","trigger":"SessionStart","action":{"type":"command","command":"existing-command"}}]
|
||||||
|
}`)
|
||||||
|
if err := fixture.plugin.Install(fixture.config); err != nil {
|
||||||
|
t.Fatalf("install Kiro CLI hooks: %v", err)
|
||||||
|
}
|
||||||
|
if err := fixture.plugin.Uninstall(kiroTestAgentID); err != nil {
|
||||||
|
t.Fatalf("uninstall Kiro CLI hooks: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
v2Hooks := requireObject(t, readKiroTestObject(t, fixture.v2Path)["hooks"])
|
||||||
|
if !reflect.DeepEqual(v2Hooks["preToolUse"], []any{map[string]any{"matcher": "read", "command": "existing-v2"}}) {
|
||||||
|
t.Fatalf("unexpected v2 hooks after uninstall: %#v", v2Hooks)
|
||||||
|
}
|
||||||
|
if len(requireArray(t, v2Hooks["postToolUse"])) != 0 {
|
||||||
|
t.Fatalf("managed v2 audit hook remains: %#v", v2Hooks)
|
||||||
|
}
|
||||||
|
v3Hooks := requireArray(t, readKiroTestObject(t, fixture.v3Path)["hooks"])
|
||||||
|
if len(v3Hooks) != 1 || requireObject(t, v3Hooks[0])["name"] != "existing-v3" {
|
||||||
|
t.Fatalf("unexpected v3 hooks after uninstall: %#v", v3Hooks)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKiroCLIUninstallRemovesElydoraOwnedConfigs(t *testing.T) {
|
||||||
|
fixture := prepareKiroFixture(t, "", "")
|
||||||
|
if err := fixture.plugin.Install(fixture.config); err != nil {
|
||||||
|
t.Fatalf("install Kiro CLI hooks: %v", err)
|
||||||
|
}
|
||||||
|
if err := fixture.plugin.Uninstall(kiroTestAgentID); err != nil {
|
||||||
|
t.Fatalf("uninstall Kiro CLI hooks: %v", err)
|
||||||
|
}
|
||||||
|
for _, path := range []string{fixture.v2Path, fixture.v3Path} {
|
||||||
|
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("owned config still exists at %s: %v", path, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKiroCLIInstallPreservesMalformedConfigsBeforeRuntimeWrite(t *testing.T) {
|
||||||
|
for _, testCase := range []struct {
|
||||||
|
name string
|
||||||
|
v2Raw string
|
||||||
|
v3Raw string
|
||||||
|
badPath func(*kiroFixture) string
|
||||||
|
}{
|
||||||
|
{name: "v2", v2Raw: "{ malformed", badPath: func(f *kiroFixture) string { return f.v2Path }},
|
||||||
|
{name: "v3", v3Raw: "{ malformed", badPath: func(f *kiroFixture) string { return f.v3Path }},
|
||||||
|
} {
|
||||||
|
t.Run(testCase.name, func(t *testing.T) {
|
||||||
|
fixture := prepareKiroFixture(t, testCase.v2Raw, testCase.v3Raw)
|
||||||
|
if err := fixture.plugin.Install(fixture.config); err == nil || !strings.Contains(err.Error(), "parse Kiro CLI "+testCase.name) {
|
||||||
|
t.Fatalf("install error = %v", err)
|
||||||
|
}
|
||||||
|
raw, err := os.ReadFile(testCase.badPath(fixture))
|
||||||
|
if err != nil || string(raw) != "{ malformed" {
|
||||||
|
t.Fatalf("malformed config changed: %q, %v", raw, err)
|
||||||
|
}
|
||||||
|
for _, path := range []string{fixture.hookPath, fixture.runtimeConfig} {
|
||||||
|
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("runtime write occurred at %s: %v", path, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKiroCLIInstallRejectsInvalidContractShapesBeforeRuntimeWrite(t *testing.T) {
|
||||||
|
for _, testCase := range []struct {
|
||||||
|
name string
|
||||||
|
v2Raw string
|
||||||
|
v3Raw string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{name: "v2-null-hooks", v2Raw: `{"hooks":null}`, want: `field "hooks" must be an object`},
|
||||||
|
{name: "v3-null-version", v3Raw: `{"version":null,"hooks":[]}`, want: `field "version" must be "v1"`},
|
||||||
|
{name: "v3-null-hooks", v3Raw: `{"version":"v1","hooks":null}`, want: `field "hooks" must be an array`},
|
||||||
|
} {
|
||||||
|
t.Run(testCase.name, func(t *testing.T) {
|
||||||
|
fixture := prepareKiroFixture(t, testCase.v2Raw, testCase.v3Raw)
|
||||||
|
if err := fixture.plugin.Install(fixture.config); err == nil || !strings.Contains(err.Error(), testCase.want) {
|
||||||
|
t.Fatalf("install error = %v, want %q", err, testCase.want)
|
||||||
|
}
|
||||||
|
for _, path := range []string{fixture.hookPath, fixture.runtimeConfig} {
|
||||||
|
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("runtime write occurred at %s: %v", path, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,7 +12,7 @@ var SupportedAgents = map[string]AgentRegistryEntry{
|
|||||||
"claudecode": {Name: "Claude Code", ConfigDir: "~/.claude", ConfigFile: "settings.json"},
|
"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"},
|
"gemini": {Name: "Gemini CLI", ConfigDir: "~/.gemini", ConfigFile: "settings.json"},
|
||||||
"kirocli": {Name: "Kiro CLI", ConfigDir: "~/.kiro", ConfigFile: "settings.json"},
|
"kirocli": {Name: "Kiro CLI", ConfigDir: "~/.kiro/hooks", ConfigFile: "elydora-audit.json"},
|
||||||
"kiroide": {Name: "Kiro IDE", ConfigDir: "~/.kiro/hooks", ConfigFile: "elydora-audit.kiro.hook"},
|
"kiroide": {Name: "Kiro IDE", ConfigDir: "~/.kiro/hooks", ConfigFile: "elydora-audit.kiro.hook"},
|
||||||
"opencode": {Name: "OpenCode", ConfigDir: "~/.config/opencode/plugins", ConfigFile: "elydora-audit.mjs"},
|
"opencode": {Name: "OpenCode", ConfigDir: "~/.config/opencode/plugins", ConfigFile: "elydora-audit.mjs"},
|
||||||
"copilot": {Name: "Copilot CLI", ConfigDir: ".github/hooks", ConfigFile: "hooks.json"},
|
"copilot": {Name: "Copilot CLI", ConfigDir: ".github/hooks", ConfigFile: "hooks.json"},
|
||||||
|
|||||||
Reference in new issue
Block a user