diff --git a/AGENTS.md b/AGENTS.md index 1d732d9..0597270 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,7 +22,8 @@ This repository publishes the open-source server, console, integration catalog, - Use each provider's documented blocking mechanism and failure mode. - Report installation as healthy only when a complete contract references all required runtime files. - Keep stable, legacy, and early-access contracts explicit, including their activation commands. -- Select Kimi Code and legacy kimi-cli config targets from existing runtime evidence; a fresh installation targets current Kimi Code and keeps migration markers isolated to active runtimes. +- Select Kimi Code and legacy `kimi-cli` contracts from runtime evidence. An empty `KIMI_CODE_HOME` uses `~/.kimi-code`; create no cross-runtime migration marker. +- Preserve Kimi TOML comments and unrelated formatting through range-based edits, then parse the complete rendered document before writing it. ## Mirror Workflow diff --git a/integrations/catalog.json b/integrations/catalog.json index 5206c84..f401ee1 100644 --- a/integrations/catalog.json +++ b/integrations/catalog.json @@ -291,9 +291,9 @@ "adapters": { "node": true, "python": true, - "go": false + "go": true }, - "delivery_state": "partial", + "delivery_state": "available", "source_url": "https://moonshotai.github.io/kimi-code/en/customization/hooks", "contract_variants": [ { diff --git a/sdks/go/README.md b/sdks/go/README.md index 52c40e7..4383d7d 100644 --- a/sdks/go/README.md +++ b/sdks/go/README.md @@ -75,6 +75,8 @@ Codex performs a one-time trust review for user hooks. Run `/hooks` in Codex aft 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`. +Kimi installation follows detected runtime evidence. Kimi Code uses `$KIMI_CODE_HOME/config.toml` with `~/.kimi-code/config.toml` as the default. Legacy `kimi-cli` uses `~/.kimi/config.toml`; Elydora updates both contracts only when both runtimes are present. + ### Commands | Command | Description | @@ -95,6 +97,7 @@ Kiro CLI installation covers both runtime contracts. Kiro CLI v2 uses the genera | Gemini CLI | `gemini` | | Kiro CLI | `kirocli` | | Kiro IDE | `kiroide` | +| Kimi Code | `kimi` | | Letta Code | `letta` | | OpenCode | `opencode` | diff --git a/sdks/go/cmd/elydora/plugins/hookconfig_io.go b/sdks/go/cmd/elydora/plugins/hookconfig_io.go index 2fa195c..d860dd5 100644 --- a/sdks/go/cmd/elydora/plugins/hookconfig_io.go +++ b/sdks/go/cmd/elydora/plugins/hookconfig_io.go @@ -33,6 +33,10 @@ func writeHookJSONObjectAtomic(path string, value map[string]any) error { return fmt.Errorf("marshal %s: %w", path, err) } encoded = append(encoded, '\n') + return writeHookFileAtomic(path, encoded, 0600) +} + +func writeHookFileAtomic(path string, contents []byte, mode os.FileMode) error { directory := filepath.Dir(path) if err := os.MkdirAll(directory, 0700); err != nil { return fmt.Errorf("create directory %s: %w", directory, err) @@ -50,10 +54,10 @@ func writeHookJSONObjectAtomic(path string, value map[string]any) error { } return errors.Join(cause, closeErr, removeErr) } - if err := temporary.Chmod(0600); err != nil { + if err := temporary.Chmod(mode); err != nil { return cleanup(fmt.Errorf("set permissions for %s: %w", temporaryPath, err)) } - if _, err := temporary.Write(encoded); err != nil { + if _, err := temporary.Write(contents); err != nil { return cleanup(fmt.Errorf("write temporary file for %s: %w", path, err)) } if err := temporary.Sync(); err != nil { diff --git a/sdks/go/cmd/elydora/plugins/kimi.go b/sdks/go/cmd/elydora/plugins/kimi.go new file mode 100644 index 0000000..9e9a49d --- /dev/null +++ b/sdks/go/cmd/elydora/plugins/kimi.go @@ -0,0 +1,143 @@ +package plugins + +import ( + "fmt" + "strings" +) + +type kimiMutation struct { + document kimiDocument + raw []byte + remove bool +} + +// KimiPlugin manages Kimi Code and legacy kimi-cli global lifecycle hooks. +type KimiPlugin struct{} + +func (p *KimiPlugin) Install(config InstallConfig) error { + if config.AgentID == "" { + return fmt.Errorf("agent ID is required") + } + documents, err := readAllKimiConfigs() + if err != nil { + return err + } + 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) + } + hookPath, err := hookScriptPath(config.AgentID) + if err != nil { + return err + } + if config.HookScript != "" { + hookPath = config.HookScript + } + nodePath, err := resolveNodeRuntime() + if err != nil { + return err + } + additions := []kimiHook{ + buildKimiHook("PreToolUse", buildKimiCommand(nodePath, config.GuardScriptPath)), + buildKimiHook("PostToolUse", buildKimiCommand(nodePath, hookPath)), + } + mutations := make([]kimiMutation, 0, len(documents)) + for _, document := range documents { + raw, err := renderKimiHooks(document, keptKimiHookIndices(document.hooks, ""), additions) + if err != nil { + return err + } + if _, err := parseKimiDocument(document.contract, raw, true); err != nil { + return fmt.Errorf("validate rendered %s: %w", document.contract.label, err) + } + mutations = append(mutations, kimiMutation{document: document, raw: raw}) + } + + runtimeConfig := config + runtimeConfig.AgentName = kimiAgentKey + if err := GenerateHookScript(hookPath, runtimeConfig); err != nil { + return fmt.Errorf("generate hook script: %w", err) + } + for _, mutation := range mutations { + if err := writeKimiConfig(mutation.document, mutation.raw); err != nil { + return err + } + } + fmt.Printf("%s: global PreToolUse and PostToolUse hooks installed.\n", kimiRuntimeNames(documents)) + return nil +} + +func (p *KimiPlugin) Uninstall(agentID string) error { + documents, err := readAllKimiConfigs() + if err != nil { + return err + } + mutations := make([]kimiMutation, 0, len(documents)) + for _, document := range documents { + keep := keptKimiHookIndices(document.hooks, agentID) + if len(keep) == len(document.hooks) { + continue + } + raw, err := renderKimiHooks(document, keep, nil) + if err != nil { + return err + } + if _, err := parseKimiDocument(document.contract, raw, true); err != nil { + return fmt.Errorf("validate rendered %s: %w", document.contract.label, err) + } + mutations = append(mutations, kimiMutation{ + document: document, + raw: raw, + remove: strings.TrimSpace(string(raw)) == "", + }) + } + for _, mutation := range mutations { + if mutation.remove { + if err := removeKimiConfig(mutation.document); err != nil { + return err + } + continue + } + if err := writeKimiConfig(mutation.document, mutation.raw); err != nil { + return err + } + } + return nil +} + +func (p *KimiPlugin) Status() (PluginStatus, error) { + documents, err := readAllKimiConfigs() + status := PluginStatus{ + AgentName: kimiAgentKey, + DisplayName: "Kimi Code", + } + if err != nil { + return status, err + } + status.ConfigPath = documents[0].contract.configPath + contracts := make([]kimiRuntimeContract, 0, len(documents)) + for _, document := range documents { + contract := kimiRuntimeForDocument(document) + if contract == nil { + continue + } + contracts = append(contracts, *contract) + status.ConfigPath = contract.configPath + } + if len(contracts) == 0 { + return status, nil + } + status.HookConfigured = true + status.HookScriptExists, err = kimiRuntimeScriptsExist(contracts) + if err != nil { + return status, err + } + status.Installed = status.HookScriptExists + return status, nil +} diff --git a/sdks/go/cmd/elydora/plugins/kimi_contract.go b/sdks/go/cmd/elydora/plugins/kimi_contract.go new file mode 100644 index 0000000..a4fc96e --- /dev/null +++ b/sdks/go/cmd/elydora/plugins/kimi_contract.go @@ -0,0 +1,212 @@ +package plugins + +import ( + "fmt" + "path/filepath" + "runtime" + "sort" + "strings" +) + +const ( + kimiAgentKey = "kimi" + kimiGuardScript = "guard.js" + kimiAuditScript = "hook.js" + kimiHookTimeoutSeconds = int64(10) +) + +var kimiSharedEvents = stringSet( + "PreToolUse", + "PostToolUse", + "PostToolUseFailure", + "UserPromptSubmit", + "Stop", + "StopFailure", + "SessionStart", + "SessionEnd", + "SubagentStart", + "SubagentStop", + "PreCompact", + "PostCompact", + "Notification", +) + +var kimiModernEvents = mergeStringSets(kimiSharedEvents, stringSet( + "PermissionRequest", + "PermissionResult", + "Interrupt", +)) + +type kimiContract struct { + runtimeName string + label string + configPath string + events map[string]struct{} +} + +type kimiHook struct { + event string + matcher *string + command string + timeout *int64 +} + +type kimiRuntimeContract struct { + guard string + audit string + configPath string +} + +func stringSet(values ...string) map[string]struct{} { + result := make(map[string]struct{}, len(values)) + for _, value := range values { + result[value] = struct{}{} + } + return result +} + +func mergeStringSets(sets ...map[string]struct{}) map[string]struct{} { + result := map[string]struct{}{} + for _, set := range sets { + for value := range set { + result[value] = struct{}{} + } + } + return result +} + +func validateKimiHook(value any, contract kimiContract, index int) (kimiHook, error) { + object, ok := value.(map[string]any) + if !ok || object == nil { + return kimiHook{}, fmt.Errorf("%s hook %d must be a table", contract.label, index+1) + } + fields := make([]string, 0, len(object)) + for field := range object { + fields = append(fields, field) + } + sort.Strings(fields) + for _, field := range fields { + switch field { + case "event", "matcher", "command", "timeout": + default: + return kimiHook{}, fmt.Errorf( + `%s hook %d has unsupported field %q`, + contract.label, + index+1, + field, + ) + } + } + + event, ok := object["event"].(string) + if _, supported := contract.events[event]; !ok || !supported { + return kimiHook{}, fmt.Errorf( + `%s hook %d has unsupported event %q`, + contract.label, + index+1, + fmt.Sprint(object["event"]), + ) + } + command, ok := object["command"].(string) + if !ok || command == "" { + return kimiHook{}, fmt.Errorf("%s hook %d requires a non-empty command", contract.label, index+1) + } + + hook := kimiHook{event: event, command: command} + if value, exists := object["matcher"]; exists { + matcher, ok := value.(string) + if !ok { + return kimiHook{}, fmt.Errorf("%s hook %d matcher must be a string", contract.label, index+1) + } + hook.matcher = &matcher + } + if value, exists := object["timeout"]; exists { + timeout, ok := value.(int64) + if !ok || timeout < 1 || timeout > 600 { + return kimiHook{}, fmt.Errorf( + "%s hook %d timeout must be an integer from 1 to 600", + contract.label, + index+1, + ) + } + hook.timeout = &timeout + } + return hook, nil +} + +func kimiHooks(root map[string]any, contract kimiContract) ([]kimiHook, error) { + value, exists := root["hooks"] + if !exists { + return []kimiHook{}, nil + } + array, ok := value.([]any) + if !ok { + return nil, fmt.Errorf(`%s field "hooks" must be an array`, contract.label) + } + hooks := make([]kimiHook, 0, len(array)) + for index, value := range array { + hook, err := validateKimiHook(value, contract, index) + if err != nil { + return nil, err + } + hooks = append(hooks, hook) + } + return hooks, nil +} + +func normalizeKimiPath(value string) string { + normalized := filepath.ToSlash(value) + if runtime.GOOS == "windows" { + return strings.ToLower(normalized) + } + return normalized +} + +func kimiCommandEndsWithPath(command, filePath string) bool { + normalized := strings.TrimSpace(normalizeKimiPath(command)) + expected := normalizeKimiPath(filePath) + return strings.HasSuffix(normalized, expected) || + strings.HasSuffix(normalized, expected+`'`) || + strings.HasSuffix(normalized, expected+`"`) +} + +func isManagedKimiHook(hook kimiHook, event, scriptName, agentID string) bool { + if hook.event != event || hook.matcher != nil || hook.timeout == nil || *hook.timeout != kimiHookTimeoutSeconds { + return false + } + if agentID != "" { + expected := normalizeKimiPath(filepath.Join(".elydora", agentID, scriptName)) + return kimiCommandEndsWithPath(hook.command, "/"+expected) + } + normalized := normalizeKimiPath(hook.command) + return strings.Contains(normalized, "/.elydora/") && + kimiCommandEndsWithPath(hook.command, "/"+normalizeKimiPath(scriptName)) +} + +func keptKimiHookIndices(hooks []kimiHook, agentID string) []int { + indices := make([]int, 0, len(hooks)) + for index, hook := range hooks { + managed := isManagedKimiHook(hook, "PreToolUse", kimiGuardScript, agentID) || + isManagedKimiHook(hook, "PostToolUse", kimiAuditScript, agentID) + if !managed { + indices = append(indices, index) + } + } + return indices +} + +func kimiRuntimeForDocument(document kimiDocument) *kimiRuntimeContract { + var guard, audit string + for _, hook := range document.hooks { + if isManagedKimiHook(hook, "PreToolUse", kimiGuardScript, "") { + guard = hook.command + } + if isManagedKimiHook(hook, "PostToolUse", kimiAuditScript, "") { + audit = hook.command + } + } + if guard == "" || audit == "" { + return nil + } + return &kimiRuntimeContract{guard: guard, audit: audit, configPath: document.contract.configPath} +} diff --git a/sdks/go/cmd/elydora/plugins/kimi_io.go b/sdks/go/cmd/elydora/plugins/kimi_io.go new file mode 100644 index 0000000..1ae381b --- /dev/null +++ b/sdks/go/cmd/elydora/plugins/kimi_io.go @@ -0,0 +1,156 @@ +package plugins + +import ( + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "syscall" +) + +func kimiHomeDirectory() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("resolve home directory: %w", err) + } + return home, nil +} + +func isMissingKimiPath(err error) bool { + return os.IsNotExist(err) || errors.Is(err, syscall.ENOTDIR) +} + +func kimiPathExists(path, label string) (bool, error) { + // #nosec G703 -- KIMI_CODE_HOME is an explicit local-user configuration path. + _, err := os.Stat(path) + if err == nil { + return true, nil + } + if isMissingKimiPath(err) { + return false, nil + } + return false, fmt.Errorf("inspect %s at %s: %w", label, path, err) +} + +func legacyKimiCLIOnPath() (bool, error) { + _, err := exec.LookPath("kimi-cli") + if err == nil { + return true, nil + } + if errors.Is(err, exec.ErrNotFound) { + return false, nil + } + return false, fmt.Errorf("inspect kimi-cli executable on PATH: %w", err) +} + +func resolveKimiContracts() ([]kimiContract, error) { + home, err := kimiHomeDirectory() + if err != nil { + return nil, err + } + explicitHome := os.Getenv("KIMI_CODE_HOME") + kimiHome := explicitHome + if kimiHome == "" { + kimiHome = filepath.Join(home, ".kimi-code") + } + modern := kimiContract{ + runtimeName: "Kimi Code", + label: "Kimi Code hooks config", + configPath: filepath.Join(kimiHome, "config.toml"), + events: kimiModernEvents, + } + legacy := kimiContract{ + runtimeName: "kimi-cli", + label: "kimi-cli legacy hooks config", + configPath: filepath.Join(home, ".kimi", "config.toml"), + events: kimiSharedEvents, + } + modernDetected := explicitHome != "" + if !modernDetected { + modernDetected, err = kimiPathExists(kimiHome, "Kimi Code home") + if err != nil { + return nil, err + } + } + legacyDetected, err := kimiPathExists(legacy.configPath, legacy.label) + if err != nil { + return nil, err + } + if !legacyDetected { + legacyDetected, err = legacyKimiCLIOnPath() + if err != nil { + return nil, err + } + } + if legacyDetected && !modernDetected { + return []kimiContract{legacy}, nil + } + if legacyDetected { + return []kimiContract{modern, legacy}, nil + } + return []kimiContract{modern}, nil +} + +func readKimiConfig(contract kimiContract) (kimiDocument, error) { + raw, err := os.ReadFile(contract.configPath) + if err != nil { + if os.IsNotExist(err) { + return parseKimiDocument(contract, nil, false) + } + return kimiDocument{}, fmt.Errorf("read %s at %s: %w", contract.label, contract.configPath, err) + } + return parseKimiDocument(contract, raw, true) +} + +func readAllKimiConfigs() ([]kimiDocument, error) { + contracts, err := resolveKimiContracts() + if err != nil { + return nil, err + } + documents := make([]kimiDocument, 0, len(contracts)) + for _, contract := range contracts { + document, err := readKimiConfig(contract) + if err != nil { + return nil, err + } + documents = append(documents, document) + } + return documents, nil +} + +func writeKimiConfig(document kimiDocument, raw []byte) error { + if err := writeHookFileAtomic(document.contract.configPath, raw, 0600); err != nil { + return fmt.Errorf("write %s at %s: %w", document.contract.label, document.contract.configPath, err) + } + return nil +} + +func removeKimiConfig(document kimiDocument) error { + if err := removeHookFile(document.contract.configPath, document.contract.label); err != nil { + return err + } + return nil +} + +func buildKimiCommand(runtimePath, scriptPath string) string { + if runtime.GOOS == "windows" { + return quoteWindowsArgument(runtimePath) + " " + quoteWindowsArgument(scriptPath) + } + return quotePOSIXArgument(runtimePath) + " " + quotePOSIXArgument(scriptPath) +} + +func buildKimiHook(event, command string) kimiHook { + timeout := kimiHookTimeoutSeconds + return kimiHook{event: event, command: command, timeout: &timeout} +} + +func kimiRuntimeNames(documents []kimiDocument) string { + names := make([]string, 0, len(documents)) + for _, document := range documents { + names = append(names, document.contract.runtimeName) + } + return strings.Join(names, " and ") +} diff --git a/sdks/go/cmd/elydora/plugins/kimi_status.go b/sdks/go/cmd/elydora/plugins/kimi_status.go new file mode 100644 index 0000000..23f0006 --- /dev/null +++ b/sdks/go/cmd/elydora/plugins/kimi_status.go @@ -0,0 +1,71 @@ +package plugins + +import ( + "fmt" + "os" + "path/filepath" +) + +func kimiRuntimeScriptsExist(contracts []kimiRuntimeContract) (bool, error) { + home, err := kimiHomeDirectory() + if err != nil { + return false, 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, kimiGuardScript) + auditPath := filepath.Join(agentDir, kimiAuditScript) + if !kimiContractsReferenceRuntime(contracts, guardPath, auditPath) { + continue + } + configPath := filepath.Join(agentDir, "config.json") + config, exists, err := readHookJSONObject(configPath, "Elydora runtime config") + if err != nil { + return false, err + } + if !exists { + continue + } + agentName, ok := config["agent_name"].(string) + if !ok { + return false, fmt.Errorf( + `Elydora runtime config at %s field "agent_name" must be a string`, + configPath, + ) + } + if agentName != kimiAgentKey { + continue + } + guardExists, err := regularFileExists(guardPath, "Elydora guard runtime") + if err != nil { + return false, err + } + auditExists, err := regularFileExists(auditPath, "Elydora audit runtime") + if err != nil { + return false, err + } + return guardExists && auditExists, nil + } + return false, nil +} + +func kimiContractsReferenceRuntime(contracts []kimiRuntimeContract, guardPath, auditPath string) bool { + for _, contract := range contracts { + if kimiCommandEndsWithPath(contract.guard, guardPath) && + kimiCommandEndsWithPath(contract.audit, auditPath) { + return true + } + } + return false +} diff --git a/sdks/go/cmd/elydora/plugins/kimi_test.go b/sdks/go/cmd/elydora/plugins/kimi_test.go new file mode 100644 index 0000000..e000878 --- /dev/null +++ b/sdks/go/cmd/elydora/plugins/kimi_test.go @@ -0,0 +1,390 @@ +package plugins + +import ( + "encoding/json" + "os" + "path/filepath" + "reflect" + "strconv" + "strings" + "testing" +) + +func TestKimiRegistryUsesModernGlobalContract(t *testing.T) { + entry := SupportedAgents[kimiAgentKey] + if entry.Name != "Kimi Code" || entry.ConfigDir != "~/.kimi-code" || entry.ConfigFile != "config.toml" { + t.Fatalf("Kimi registry entry = %#v", entry) + } + if _, ok := NewPlugin(kimiAgentKey).(*KimiPlugin); !ok { + t.Fatalf("Kimi registry plugin = %T", NewPlugin(kimiAgentKey)) + } +} + +func TestKimiInstallPreservesBothConfigsAndIsIdempotent(t *testing.T) { + modern := "# modern user config\ndefault_model = \"kimi-code/k3\"\n\n" + + "[[hooks]]\nevent = \"SessionStart\"\ncommand = \"existing-modern\"\n" + + "timeout = 30 # keep modern hook\n" + legacy := "# legacy user config\ntelemetry = false\n\n" + + "[[hooks]]\nevent = \"SessionEnd\"\ncommand = \"existing-legacy\"\n" + fixture := prepareKimiFixture(t, kimiFixtureOptions{ + modernConfig: kimiString(modern), + legacyConfig: kimiString(legacy), + }) + if err := fixture.plugin.Install(fixture.config); err != nil { + t.Fatalf("install Kimi hooks: %v", err) + } + if err := fixture.plugin.Install(fixture.config); err != nil { + t.Fatalf("repeat Kimi install: %v", err) + } + + for _, contract := range []struct{ path, comment, command string }{ + {fixture.modernPath, "# modern user config", "existing-modern"}, + {fixture.legacyPath, "# legacy user config", "existing-legacy"}, + } { + raw, err := os.ReadFile(contract.path) + if err != nil { + t.Fatalf("read Kimi config: %v", err) + } + if !strings.Contains(string(raw), contract.comment) || !strings.Contains(string(raw), contract.command) { + t.Fatalf("user config changed: %s", raw) + } + hooks := readKimiTestHooks(t, contract.path) + if len(hooks) != 3 { + t.Fatalf("hooks = %#v, want three entries", hooks) + } + requireStrictKimiHook(t, findKimiTestHook(t, hooks, "PreToolUse", kimiGuardScript)) + requireStrictKimiHook(t, findKimiTestHook(t, hooks, "PostToolUse", kimiAuditScript)) + } + defaultModern := filepath.Join(fixture.homeDir, ".kimi-code", "config.toml") + if _, err := os.Stat(defaultModern); !os.IsNotExist(err) { + t.Fatalf("false default migration target exists: %v", err) + } +} + +func TestKimiInstallPreservesInlineHookArrayStyle(t *testing.T) { + modern := "# inline user hook\nhooks = [\n # keep leading\n { event = \"SessionStart\", " + + "command = \"existing-inline\" }, # keep gap\n] # keep array\n" + fixture := prepareKimiFixture(t, kimiFixtureOptions{ + modernConfig: kimiString(modern), + withoutLegacyCLI: true, + }) + if err := fixture.plugin.Install(fixture.config); err != nil { + t.Fatalf("install inline Kimi hooks: %v", err) + } + if err := fixture.plugin.Install(fixture.config); err != nil { + t.Fatalf("repeat inline Kimi install: %v", err) + } + raw, err := os.ReadFile(fixture.modernPath) + if err != nil { + t.Fatalf("read inline Kimi config: %v", err) + } + for _, marker := range []string{ + "# inline user hook", + "# keep leading", + "# keep gap", + "# keep array", + "hooks = [", + } { + if !strings.Contains(string(raw), marker) { + t.Fatalf("inline marker %q missing from %s", marker, raw) + } + } + if len(readKimiTestHooks(t, fixture.modernPath)) != 3 { + t.Fatalf("inline Kimi hooks are not idempotent: %s", raw) + } +} + +func TestKimiModernDefaultAvoidsLegacyMigrationMarker(t *testing.T) { + fixture := prepareKimiFixture(t, kimiFixtureOptions{ + useDefaultHome: true, + withoutLegacyCLI: true, + }) + if err := fixture.plugin.Install(fixture.config); err != nil { + t.Fatalf("install modern Kimi hooks: %v", err) + } + if len(readKimiTestHooks(t, fixture.modernPath)) != 2 { + t.Fatalf("modern Kimi config has unexpected hooks") + } + if _, err := os.Stat(fixture.legacyPath); !os.IsNotExist(err) { + t.Fatalf("false legacy migration target exists: %v", err) + } + status, err := fixture.plugin.Status() + if err != nil || !status.Installed { + t.Fatalf("empty KIMI_CODE_HOME status = %#v, %v", status, err) + } +} + +func TestKimiLegacyInstallAvoidsPrematureModernTarget(t *testing.T) { + fixture := prepareKimiFixture(t, kimiFixtureOptions{useDefaultHome: true}) + if err := fixture.plugin.Install(fixture.config); err != nil { + t.Fatalf("install legacy Kimi hooks: %v", err) + } + if len(readKimiTestHooks(t, fixture.legacyPath)) != 2 { + t.Fatalf("legacy Kimi config has unexpected hooks") + } + if _, err := os.Stat(fixture.modernPath); !os.IsNotExist(err) { + t.Fatalf("premature modern migration target exists: %v", err) + } + if err := os.Remove(fixture.legacyCLIPath); err != nil { + t.Fatalf("remove legacy CLI marker: %v", err) + } + status, err := fixture.plugin.Status() + if err != nil || !status.Installed { + t.Fatalf("legacy status after PATH change = %#v, %v", status, err) + } +} + +func TestKimiCommandsBlockAndForwardOfficialPayload(t *testing.T) { + fixture := prepareKimiFixture(t, kimiFixtureOptions{}) + if err := fixture.plugin.Install(fixture.config); err != nil { + t.Fatalf("install Kimi hooks: %v", err) + } + capturePath := filepath.Join(t.TempDir(), "captured-event.json") + encodedPath, err := json.Marshal(capturePath) + if err != nil { + t.Fatalf("marshal capture path: %v", err) + } + source := "const fs = require('node:fs'); const chunks = []; " + + "process.stdin.on('data', chunk => chunks.push(chunk)); " + + "process.stdin.on('end', () => fs.writeFileSync(" + string(encodedPath) + ", Buffer.concat(chunks)));\n" + if err := os.WriteFile(fixture.hookPath, []byte(source), 0700); err != nil { + t.Fatalf("write capture runtime: %v", err) + } + hooks := readKimiTestHooks(t, fixture.modernPath) + payload := map[string]any{ + "hook_event_name": "PreToolUse", + "session_id": "session-1", + "cwd": fixture.homeDir, + "tool_name": "Bash", + "tool_input": map[string]any{"command": "echo test"}, + "tool_call_id": "call-1", + } + guard := findKimiTestHook(t, hooks, "PreToolUse", kimiGuardScript) + exitCode, stderr := runKimiCommand(t, guard["command"].(string), fixture.homeDir, payload) + 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_output"] = map[string]any{"output": "test"} + audit := findKimiTestHook(t, hooks, "PostToolUse", kimiAuditScript) + exitCode, stderr = runKimiCommand(t, audit["command"].(string), fixture.homeDir, payload) + if exitCode != 0 { + t.Fatalf("audit exit = %d, stderr = %q", exitCode, stderr) + } + capturedRaw, err := os.ReadFile(capturePath) + if err != nil { + t.Fatalf("read captured event: %v", err) + } + var captured map[string]any + if err := json.Unmarshal(capturedRaw, &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 TestKimiStatusAcceptsEitherContractAndRequiresBothScripts(t *testing.T) { + fixture := prepareKimiFixture(t, kimiFixtureOptions{}) + if err := fixture.plugin.Install(fixture.config); err != nil { + t.Fatalf("install Kimi hooks: %v", err) + } + if err := os.Remove(fixture.modernPath); err != nil { + t.Fatalf("remove modern config: %v", err) + } + requireKimiInstalledStatus(t, fixture.plugin, fixture.legacyPath) + if err := fixture.plugin.Install(fixture.config); err != nil { + t.Fatalf("reinstall Kimi hooks: %v", err) + } + if err := os.Remove(fixture.legacyPath); err != nil { + t.Fatalf("remove legacy config: %v", err) + } + requireKimiInstalledStatus(t, fixture.plugin, fixture.modernPath) + if err := os.Remove(fixture.guardPath); err != nil { + t.Fatalf("remove Kimi guard: %v", err) + } + status, err := fixture.plugin.Status() + if err != nil || status.Installed || !status.HookConfigured || status.HookScriptExists { + t.Fatalf("degraded Kimi status = %#v, %v", status, err) + } +} + +func requireKimiInstalledStatus(t *testing.T, plugin *KimiPlugin, configPath string) { + t.Helper() + status, err := plugin.Status() + if err != nil || !status.Installed || status.ConfigPath != configPath { + t.Fatalf("Kimi status = %#v, %v; want installed at %s", status, err, configPath) + } +} + +func TestKimiUninstallPreservesUserHooks(t *testing.T) { + userHook := "# user hook\n[[hooks]]\nevent = \"SessionStart\"\n" + + "command = \"existing-command\"\ntimeout = 30 # keep timeout\n" + fixture := prepareKimiFixture(t, kimiFixtureOptions{ + modernConfig: kimiString(userHook), + legacyConfig: kimiString(userHook), + }) + if err := fixture.plugin.Install(fixture.config); err != nil { + t.Fatalf("install Kimi hooks: %v", err) + } + if err := fixture.plugin.Uninstall(kimiTestAgentID); err != nil { + t.Fatalf("uninstall Kimi hooks: %v", err) + } + for _, path := range []string{fixture.modernPath, fixture.legacyPath} { + raw, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read user config after uninstall: %v", err) + } + if !strings.Contains(string(raw), "# user hook") || !strings.Contains(string(raw), "# keep timeout") { + t.Fatalf("user comments changed: %s", raw) + } + hooks := readKimiTestHooks(t, path) + if len(hooks) != 1 || hooks[0]["command"] != "existing-command" { + t.Fatalf("user hook changed: %#v", hooks) + } + } +} + +func TestKimiOwnershipRequiresExactAgentAndScript(t *testing.T) { + fixture := prepareKimiFixture(t, kimiFixtureOptions{}) + backupPath := filepath.Join(fixture.homeDir, ".elydora", kimiTestAgentID, "hook.js.backup") + otherAgentPath := filepath.Join(fixture.homeDir, ".elydora", "agent-10", kimiAuditScript) + userHooks := "[[hooks]]\nevent = \"PostToolUse\"\ncommand = " + + strconv.Quote("node "+backupPath) + "\n\n[[hooks]]\nevent = \"PostToolUse\"\ncommand = " + + strconv.Quote("node "+otherAgentPath) + "\n" + if err := os.MkdirAll(filepath.Dir(fixture.modernPath), 0755); err != nil { + t.Fatalf("create modern Kimi directory: %v", err) + } + if err := os.WriteFile(fixture.modernPath, []byte(userHooks), 0600); err != nil { + t.Fatalf("write ownership fixture: %v", err) + } + if err := fixture.plugin.Install(fixture.config); err != nil { + t.Fatalf("install Kimi hooks: %v", err) + } + if err := fixture.plugin.Uninstall(kimiTestAgentID); err != nil { + t.Fatalf("uninstall Kimi hooks: %v", err) + } + hooks := readKimiTestHooks(t, fixture.modernPath) + if len(hooks) != 2 { + t.Fatalf("ownership filter removed user hooks: %#v", hooks) + } + commands := []any{hooks[0]["command"], hooks[1]["command"]} + if !reflect.DeepEqual(commands, []any{"node " + backupPath, "node " + otherAgentPath}) { + t.Fatalf("remaining commands = %#v", commands) + } +} + +func TestKimiUninstallRemovesOwnedConfigs(t *testing.T) { + fixture := prepareKimiFixture(t, kimiFixtureOptions{}) + if err := fixture.plugin.Install(fixture.config); err != nil { + t.Fatalf("install Kimi hooks: %v", err) + } + if err := fixture.plugin.Uninstall(kimiTestAgentID); err != nil { + t.Fatalf("uninstall Kimi hooks: %v", err) + } + for _, path := range []string{fixture.modernPath, fixture.legacyPath} { + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Fatalf("owned Kimi config remains at %s: %v", path, err) + } + } +} + +func TestKimiParsesEveryConfigBeforeRuntimeWrites(t *testing.T) { + modern := "# untouched modern\ndefault_model = \"kimi-code/k3\"\n" + legacy := "[malformed" + fixture := prepareKimiFixture(t, kimiFixtureOptions{ + modernConfig: kimiString(modern), + legacyConfig: kimiString(legacy), + }) + err := fixture.plugin.Install(fixture.config) + if err == nil || !strings.Contains(err.Error(), "parse kimi-cli legacy hooks config") { + t.Fatalf("install error = %v", err) + } + for path, want := range map[string]string{fixture.modernPath: modern, fixture.legacyPath: legacy} { + raw, readErr := os.ReadFile(path) + if readErr != nil || string(raw) != want { + t.Fatalf("config %s changed: %q, %v", path, raw, readErr) + } + } + requireNoKimiRuntimeWrites(t, fixture) +} + +func TestKimiRejectsInvalidHookContractsBeforeWrites(t *testing.T) { + for _, testCase := range []struct{ name, raw, want string }{ + {"unsupported-field", "[[hooks]]\nevent=\"PreToolUse\"\ncommand=\"x\"\ncwd=\"/tmp\"\n", `unsupported field "cwd"`}, + {"unsupported-event", "[[hooks]]\nevent=\"Interrupt\"\ncommand=\"x\"\n", "unsupported event"}, + {"bad-matcher", "[[hooks]]\nevent=\"PreToolUse\"\ncommand=\"x\"\nmatcher=1\n", "matcher must be a string"}, + {"bad-timeout", "[[hooks]]\nevent=\"PreToolUse\"\ncommand=\"x\"\ntimeout=601\n", "integer from 1 to 600"}, + {"hooks-object", "hooks = { event=\"PreToolUse\", command=\"x\" }\n", `field "hooks" must be an array`}, + } { + t.Run(testCase.name, func(t *testing.T) { + fixture := prepareKimiFixture(t, kimiFixtureOptions{ + useDefaultHome: true, + legacyConfig: kimiString(testCase.raw), + }) + err := fixture.plugin.Install(fixture.config) + if err == nil || !strings.Contains(err.Error(), testCase.want) { + t.Fatalf("install error = %v, want %q", err, testCase.want) + } + raw, readErr := os.ReadFile(fixture.legacyPath) + if readErr != nil || string(raw) != testCase.raw { + t.Fatalf("invalid config changed: %q, %v", raw, readErr) + } + requireNoKimiRuntimeWrites(t, fixture) + }) + } +} + +func TestKimiRejectsMissingGuardBeforeWrites(t *testing.T) { + fixture := prepareKimiFixture(t, kimiFixtureOptions{withoutGuard: true}) + err := fixture.plugin.Install(fixture.config) + if err == nil || !strings.Contains(err.Error(), "guard runtime is missing") { + t.Fatalf("install error = %v", err) + } + for _, path := range []string{fixture.modernPath, fixture.legacyPath} { + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Fatalf("config write occurred at %s: %v", path, err) + } + } + requireNoKimiRuntimeWrites(t, fixture) +} + +func requireNoKimiRuntimeWrites(t *testing.T, fixture *kimiFixture) { + t.Helper() + for _, path := range []string{fixture.hookPath, fixture.runtimeConfig, fixture.privateKey} { + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Fatalf("runtime write occurred at %s: %v", path, err) + } + } +} + +func TestKimiStatusSurfacesMalformedRuntimeMetadata(t *testing.T) { + fixture := prepareKimiFixture(t, kimiFixtureOptions{}) + if err := fixture.plugin.Install(fixture.config); err != nil { + t.Fatalf("install Kimi 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 TestKimiAtomicWritesLeaveNoTemporaryFiles(t *testing.T) { + fixture := prepareKimiFixture(t, kimiFixtureOptions{}) + if err := fixture.plugin.Install(fixture.config); err != nil { + t.Fatalf("install Kimi hooks: %v", err) + } + for _, directory := range []string{filepath.Dir(fixture.modernPath), filepath.Dir(fixture.legacyPath)} { + entries, err := os.ReadDir(directory) + if err != nil { + t.Fatalf("read config directory: %v", err) + } + for _, entry := range entries { + if strings.HasSuffix(entry.Name(), ".tmp") { + t.Fatalf("temporary Kimi config remains at %s", filepath.Join(directory, entry.Name())) + } + } + } +} diff --git a/sdks/go/cmd/elydora/plugins/kimi_test_helpers_test.go b/sdks/go/cmd/elydora/plugins/kimi_test_helpers_test.go new file mode 100644 index 0000000..ea2d377 --- /dev/null +++ b/sdks/go/cmd/elydora/plugins/kimi_test_helpers_test.go @@ -0,0 +1,212 @@ +package plugins + +import ( + "bytes" + "encoding/json" + "errors" + "os" + "os/exec" + "path/filepath" + "reflect" + "runtime" + "strings" + "testing" + + "github.com/pelletier/go-toml/v2" +) + +const kimiTestAgentID = "agent-1" + +type kimiFixtureOptions struct { + modernConfig *string + legacyConfig *string + useDefaultHome bool + withoutLegacyCLI bool + withoutGuard bool +} + +type kimiFixture struct { + plugin *KimiPlugin + config InstallConfig + homeDir string + binDir string + kimiHome string + modernPath string + legacyPath string + agentDir string + guardPath string + hookPath string + runtimeConfig string + privateKey string + legacyCLIPath string +} + +func kimiString(value string) *string { + return &value +} + +func prepareKimiFixture(t *testing.T, options kimiFixtureOptions) *kimiFixture { + t.Helper() + homeDir := filepath.Join(t.TempDir(), "home with spaces") + binDir := filepath.Join(homeDir, "bin") + kimiHome := filepath.Join(homeDir, "custom kimi code") + if options.useDefaultHome { + kimiHome = filepath.Join(homeDir, ".kimi-code") + } + modernPath := filepath.Join(kimiHome, "config.toml") + legacyPath := filepath.Join(homeDir, ".kimi", "config.toml") + agentDir := filepath.Join(homeDir, ".elydora", kimiTestAgentID) + guardPath := filepath.Join(agentDir, kimiGuardScript) + hookPath := filepath.Join(agentDir, kimiAuditScript) + if err := os.MkdirAll(agentDir, 0755); err != nil { + t.Fatalf("create agent directory: %v", err) + } + if err := os.MkdirAll(binDir, 0755); err != nil { + t.Fatalf("create binary directory: %v", err) + } + if !options.withoutGuard { + source := "process.stdin.resume(); process.stderr.write('Agent is frozen by Elydora.'); process.exit(2);\n" + if err := os.WriteFile(guardPath, []byte(source), 0700); err != nil { + t.Fatalf("write guard runtime: %v", err) + } + } + writeOptionalKimiConfig(t, modernPath, options.modernConfig) + writeOptionalKimiConfig(t, legacyPath, options.legacyConfig) + + legacyCLIPath := filepath.Join(binDir, "kimi-cli") + if runtime.GOOS == "windows" { + legacyCLIPath += ".cmd" + } + if !options.withoutLegacyCLI { + if err := os.WriteFile(legacyCLIPath, []byte(""), 0700); err != nil { + t.Fatalf("write legacy CLI marker: %v", err) + } + } + originalPath := os.Getenv("PATH") + t.Setenv("PATH", binDir+string(os.PathListSeparator)+originalPath) + t.Setenv("HOME", homeDir) + t.Setenv("USERPROFILE", homeDir) + if options.useDefaultHome { + t.Setenv("KIMI_CODE_HOME", "") + } else { + t.Setenv("KIMI_CODE_HOME", kimiHome) + } + + return &kimiFixture{ + plugin: &KimiPlugin{}, + homeDir: homeDir, + binDir: binDir, + kimiHome: kimiHome, + config: InstallConfig{ + AgentName: kimiAgentKey, + OrgID: "org-1", + AgentID: kimiTestAgentID, + PrivateKey: "test-key", + KID: "kid-1", + BaseURL: "https://api.elydora.test", + GuardScriptPath: guardPath, + }, + modernPath: modernPath, + legacyPath: legacyPath, + agentDir: agentDir, + guardPath: guardPath, + hookPath: hookPath, + runtimeConfig: filepath.Join(agentDir, "config.json"), + privateKey: filepath.Join(agentDir, "private.key"), + legacyCLIPath: legacyCLIPath, + } +} + +func writeOptionalKimiConfig(t *testing.T, path string, raw *string) { + t.Helper() + if raw == nil { + return + } + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + t.Fatalf("create Kimi config directory: %v", err) + } + if err := os.WriteFile(path, []byte(*raw), 0600); err != nil { + t.Fatalf("write Kimi config: %v", err) + } +} + +func readKimiTestHooks(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) + } + root := map[string]any{} + if err := toml.Unmarshal(raw, &root); err != nil { + t.Fatalf("parse %s: %v", path, err) + } + values, ok := root["hooks"].([]any) + if !ok { + t.Fatalf("hooks = %#v, want array", root["hooks"]) + } + hooks := make([]map[string]any, 0, len(values)) + for _, value := range values { + hook, ok := value.(map[string]any) + if !ok { + t.Fatalf("hook = %#v, want table", value) + } + hooks = append(hooks, hook) + } + return hooks +} + +func findKimiTestHook(t *testing.T, hooks []map[string]any, event, script string) map[string]any { + t.Helper() + for _, hook := range hooks { + command, _ := hook["command"].(string) + if hook["event"] == event && strings.Contains(command, script) { + return hook + } + } + t.Fatalf("%s hook for %s not found", event, script) + return nil +} + +func requireStrictKimiHook(t *testing.T, hook map[string]any) { + t.Helper() + want := map[string]any{ + "event": hook["event"], + "command": hook["command"], + "timeout": int64(10), + } + if !reflect.DeepEqual(hook, want) { + t.Fatalf("hook = %#v, want strict contract %#v", hook, want) + } +} + +func runKimiCommand(t *testing.T, command, homeDir string, payload map[string]any) (int, string) { + t.Helper() + encoded, err := json.Marshal(payload) + if err != nil { + t.Fatalf("marshal Kimi payload: %v", err) + } + var process *exec.Cmd + if runtime.GOOS == "windows" { + commandFile := filepath.Join(t.TempDir(), "run-kimi-hook.cmd") + if err := os.WriteFile(commandFile, []byte("@echo off\r\n"+command+"\r\n"), 0600); err != nil { + t.Fatalf("write Kimi command file: %v", err) + } + process = exec.Command("cmd.exe", "/d", "/c", commandFile) + } else { + process = exec.Command("sh", "-c", command) + } + process.Env = append(os.Environ(), "HOME="+homeDir, "USERPROFILE="+homeDir) + process.Stdin = bytes.NewReader(encoded) + var stderr bytes.Buffer + process.Stderr = &stderr + err = process.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 Kimi hook command: %v", err) + return -1, stderr.String() +} diff --git a/sdks/go/cmd/elydora/plugins/kimi_toml.go b/sdks/go/cmd/elydora/plugins/kimi_toml.go new file mode 100644 index 0000000..ffc6ccd --- /dev/null +++ b/sdks/go/cmd/elydora/plugins/kimi_toml.go @@ -0,0 +1,472 @@ +package plugins + +import ( + "bytes" + "fmt" + "sort" + "strconv" + "strings" + + "github.com/pelletier/go-toml/v2" + "github.com/pelletier/go-toml/v2/unstable" +) + +type kimiByteRange struct { + start int + end int +} + +type kimiTomlLayout struct { + hookTables []kimiByteRange + inlineValue *kimiByteRange + inlineElements []kimiByteRange +} + +type kimiDocument struct { + contract kimiContract + raw []byte + hooks []kimiHook + layout kimiTomlLayout +} + +func parseKimiDocument(contract kimiContract, raw []byte, exists bool) (kimiDocument, error) { + if !exists { + return kimiDocument{contract: contract, raw: []byte{}, hooks: []kimiHook{}}, nil + } + root := map[string]any{} + if err := toml.Unmarshal(raw, &root); err != nil { + return kimiDocument{}, fmt.Errorf( + "parse %s at %s: %w", + contract.label, + contract.configPath, + err, + ) + } + hooks, err := kimiHooks(root, contract) + if err != nil { + return kimiDocument{}, err + } + layout, err := scanKimiTomlLayout(raw) + if err != nil { + return kimiDocument{}, fmt.Errorf( + "inspect %s layout at %s: %w", + contract.label, + contract.configPath, + err, + ) + } + if len(hooks) > 0 && len(layout.hookTables) == 0 && layout.inlineValue == nil { + return kimiDocument{}, fmt.Errorf("%s hooks layout could not be located", contract.label) + } + if len(layout.hookTables) > 0 && len(layout.hookTables) != len(hooks) { + return kimiDocument{}, fmt.Errorf( + "%s hook layout count %d does not match decoded hook count %d", + contract.label, + len(layout.hookTables), + len(hooks), + ) + } + if layout.inlineValue != nil && len(layout.inlineElements) != len(hooks) { + return kimiDocument{}, fmt.Errorf( + "%s inline hook layout count %d does not match decoded hook count %d", + contract.label, + len(layout.inlineElements), + len(hooks), + ) + } + return kimiDocument{ + contract: contract, + raw: append([]byte(nil), raw...), + hooks: hooks, + layout: layout, + }, nil +} + +func scanKimiTomlLayout(raw []byte) (kimiTomlLayout, error) { + parser := unstable.Parser{KeepComments: true} + parser.Reset(raw) + var tableStarts, hookStarts []int + var inlineValue *kimiByteRange + var inlineElements []kimiByteRange + contextIsRoot := true + + for parser.NextExpression() { + expression := parser.Expression() + switch expression.Kind { + case unstable.Table, unstable.ArrayTable: + keys := kimiTomlKeys(expression) + keyRange, err := kimiFirstKeyRange(expression) + if err != nil { + return kimiTomlLayout{}, err + } + start := kimiLineStart(raw, int(keyRange.Offset)) + tableStarts = append(tableStarts, start) + contextIsRoot = false + if expression.Kind == unstable.ArrayTable && len(keys) == 1 && keys[0] == "hooks" { + hookStarts = append(hookStarts, start) + } + case unstable.KeyValue: + keys := kimiTomlKeys(expression) + if !contextIsRoot || len(keys) != 1 || keys[0] != "hooks" { + continue + } + value := expression.Value() + if value.Kind != unstable.Array { + continue + } + rangeValue, err := kimiArrayValueRange(raw, expression) + if err != nil { + return kimiTomlLayout{}, err + } + inlineValue = &rangeValue + inlineElements, err = kimiInlineElementRanges(raw, value) + if err != nil { + return kimiTomlLayout{}, err + } + } + } + if err := parser.Error(); err != nil { + return kimiTomlLayout{}, err + } + if len(hookStarts) > 0 && inlineValue != nil { + return kimiTomlLayout{}, fmt.Errorf("hooks use conflicting TOML layouts") + } + + sort.Ints(tableStarts) + tables := make([]kimiByteRange, 0, len(hookStarts)) + for _, start := range hookStarts { + end := len(raw) + for _, candidate := range tableStarts { + if candidate > start { + end = candidate + break + } + } + tables = append(tables, kimiByteRange{start: start, end: end}) + } + return kimiTomlLayout{ + hookTables: tables, + inlineValue: inlineValue, + inlineElements: inlineElements, + }, nil +} + +func kimiArrayValueRange(raw []byte, expression *unstable.Node) (kimiByteRange, error) { + keyRange, err := kimiFirstKeyRange(expression) + if err != nil { + return kimiByteRange{}, err + } + start := int(keyRange.Offset + keyRange.Length) + end := int(expression.Raw.Offset + expression.Raw.Length) + if start < 0 || start > end || end > len(raw) { + return kimiByteRange{}, fmt.Errorf("hooks array range is invalid") + } + equals := bytes.IndexByte(raw[start:end], '=') + if equals < 0 { + return kimiByteRange{}, fmt.Errorf("hooks array assignment has no equals sign") + } + start += equals + 1 + for start < end && (raw[start] == ' ' || raw[start] == '\t') { + start++ + } + if start >= end || raw[start] != '[' { + return kimiByteRange{}, fmt.Errorf("hooks array value has no opening bracket") + } + return kimiByteRange{start: start, end: end}, nil +} + +func kimiInlineElementRanges(raw []byte, array *unstable.Node) ([]kimiByteRange, error) { + ranges := []kimiByteRange{} + children := array.Children() + for children.Next() { + child := children.Node() + if child.Kind != unstable.InlineTable { + continue + } + start := int(child.Raw.Offset) + end, err := kimiInlineTableEnd(raw, start) + if err != nil { + return nil, err + } + ranges = append(ranges, kimiByteRange{start: start, end: end}) + } + return ranges, nil +} + +func kimiInlineTableEnd(raw []byte, start int) (int, error) { + if start < 0 || start >= len(raw) || raw[start] != '{' { + return 0, fmt.Errorf("inline hook table has no opening brace") + } + depth := 0 + for index := start; index < len(raw); { + switch raw[index] { + case '"', '\'': + end, err := kimiTomlStringEnd(raw, index) + if err != nil { + return 0, err + } + index = end + case '#': + if newline := bytes.IndexByte(raw[index:], '\n'); newline >= 0 { + index += newline + 1 + } else { + return 0, fmt.Errorf("inline hook table comment reaches end of document") + } + case '{': + depth++ + index++ + case '}': + depth-- + index++ + if depth == 0 { + return index, nil + } + default: + index++ + } + } + return 0, fmt.Errorf("inline hook table has no closing brace") +} + +func kimiTomlStringEnd(raw []byte, start int) (int, error) { + delimiter := raw[start] + multiline := start+2 < len(raw) && raw[start+1] == delimiter && raw[start+2] == delimiter + index := start + 1 + if multiline { + index = start + 3 + } + for index < len(raw) { + if delimiter == '"' && raw[index] == '\\' { + index += 2 + continue + } + if raw[index] != delimiter { + index++ + continue + } + if !multiline { + return index + 1, nil + } + runEnd := index + for runEnd < len(raw) && raw[runEnd] == delimiter { + runEnd++ + } + if runEnd-index >= 3 { + return runEnd, nil + } + index = runEnd + } + return 0, fmt.Errorf("inline hook table contains an unterminated string") +} + +func kimiTomlKeys(node *unstable.Node) []string { + keys := []string{} + iterator := node.Key() + for iterator.Next() { + keys = append(keys, string(iterator.Node().Data)) + } + return keys +} + +func kimiFirstKeyRange(node *unstable.Node) (unstable.Range, error) { + iterator := node.Key() + if !iterator.Next() { + return unstable.Range{}, fmt.Errorf("TOML expression has no key") + } + return iterator.Node().Raw, nil +} + +func kimiLineStart(raw []byte, offset int) int { + if offset <= 0 { + return 0 + } + lineBreak := bytes.LastIndexByte(raw[:offset], '\n') + if lineBreak < 0 { + return 0 + } + return lineBreak + 1 +} + +func renderKimiHooks(document kimiDocument, keepIndices []int, additions []kimiHook) ([]byte, error) { + keep := make(map[int]struct{}, len(keepIndices)) + for _, index := range keepIndices { + if index < 0 || index >= len(document.hooks) { + return nil, fmt.Errorf("%s hook index %d is out of range", document.contract.label, index) + } + keep[index] = struct{}{} + } + + if document.layout.inlineValue != nil { + return renderKimiInlineDocument(document, keep, additions) + } + + base := document.raw + if len(document.layout.hookTables) > 0 { + var result bytes.Buffer + cursor := 0 + for index, block := range document.layout.hookTables { + if _, exists := keep[index]; exists { + continue + } + result.Write(base[cursor:block.start]) + cursor = block.end + } + result.Write(base[cursor:]) + base = result.Bytes() + } + if len(additions) == 0 { + return append([]byte(nil), base...), nil + } + return appendKimiHookTables(base, additions, kimiNewline(document.raw)), nil +} + +func renderKimiInlineDocument( + document kimiDocument, + keep map[int]struct{}, + additions []kimiHook, +) ([]byte, error) { + valueRange := *document.layout.inlineValue + value := document.raw[valueRange.start:valueRange.end] + if len(value) < 2 || value[0] != '[' || value[len(value)-1] != ']' { + return nil, fmt.Errorf("%s inline hooks value is malformed", document.contract.label) + } + local := make([]kimiByteRange, len(document.layout.inlineElements)) + for index, element := range document.layout.inlineElements { + local[index] = kimiByteRange{ + start: element.start - valueRange.start, + end: element.end - valueRange.start, + } + } + + var rendered bytes.Buffer + rendered.WriteByte('[') + contentStart := 1 + pending := []byte{} + if len(local) > 0 { + pending = append(pending, value[contentStart:local[0].start]...) + } else { + pending = append(pending, value[contentStart:len(value)-1]...) + } + emitted := 0 + for index, element := range local { + if _, exists := keep[index]; exists { + if emitted > 0 { + rendered.WriteByte(',') + } + rendered.Write(kimiGapWithoutCommas(pending)) + rendered.Write(value[element.start:element.end]) + pending = pending[:0] + emitted++ + } + next := len(value) - 1 + if index+1 < len(local) { + next = local[index+1].start + } + pending = append(pending, value[element.end:next]...) + } + for _, hook := range additions { + if emitted > 0 { + rendered.WriteByte(',') + } + if len(pending) > 0 { + rendered.Write(kimiGapWithoutCommas(pending)) + pending = pending[:0] + } else if emitted > 0 { + rendered.WriteByte(' ') + } + rendered.WriteString(renderKimiInlineTable(hook)) + emitted++ + } + rendered.Write(kimiGapWithoutCommas(pending)) + rendered.WriteByte(']') + + result := make([]byte, 0, len(document.raw)-len(value)+rendered.Len()) + result = append(result, document.raw[:valueRange.start]...) + result = append(result, rendered.Bytes()...) + result = append(result, document.raw[valueRange.end:]...) + return result, nil +} + +func kimiGapWithoutCommas(gap []byte) []byte { + result := make([]byte, 0, len(gap)) + inComment := false + for _, character := range gap { + if inComment { + result = append(result, character) + if character == '\n' { + inComment = false + } + continue + } + if character == '#' { + inComment = true + result = append(result, character) + continue + } + if character != ',' { + result = append(result, character) + } + } + return result +} + +func appendKimiHookTables(base []byte, hooks []kimiHook, newline string) []byte { + result := append([]byte(nil), base...) + if len(result) > 0 { + if !bytes.HasSuffix(result, []byte(newline)) { + result = append(result, newline...) + } + if !bytes.HasSuffix(result, []byte(newline+newline)) { + result = append(result, newline...) + } + } + for index, hook := range hooks { + if index > 0 { + result = append(result, newline...) + } + result = append(result, renderKimiHookTable(hook, newline)...) + } + return result +} + +func renderKimiHookTable(hook kimiHook, newline string) []byte { + var result strings.Builder + result.WriteString("[[hooks]]") + result.WriteString(newline) + result.WriteString("event = ") + result.WriteString(strconv.Quote(hook.event)) + result.WriteString(newline) + if hook.matcher != nil { + result.WriteString("matcher = ") + result.WriteString(strconv.Quote(*hook.matcher)) + result.WriteString(newline) + } + result.WriteString("command = ") + result.WriteString(strconv.Quote(hook.command)) + result.WriteString(newline) + if hook.timeout != nil { + result.WriteString("timeout = ") + result.WriteString(strconv.FormatInt(*hook.timeout, 10)) + result.WriteString(newline) + } + return []byte(result.String()) +} + +func renderKimiInlineTable(hook kimiHook) string { + fields := []string{"event = " + strconv.Quote(hook.event)} + if hook.matcher != nil { + fields = append(fields, "matcher = "+strconv.Quote(*hook.matcher)) + } + fields = append(fields, "command = "+strconv.Quote(hook.command)) + if hook.timeout != nil { + fields = append(fields, "timeout = "+strconv.FormatInt(*hook.timeout, 10)) + } + return "{ " + strings.Join(fields, ", ") + " }" +} + +func kimiNewline(raw []byte) string { + if bytes.Contains(raw, []byte("\r\n")) { + return "\r\n" + } + return "\n" +} diff --git a/sdks/go/cmd/elydora/plugins/registry.go b/sdks/go/cmd/elydora/plugins/registry.go index d215931..fba81fc 100644 --- a/sdks/go/cmd/elydora/plugins/registry.go +++ b/sdks/go/cmd/elydora/plugins/registry.go @@ -15,6 +15,7 @@ var SupportedAgents = map[string]AgentRegistryEntry{ "gemini": {Name: "Gemini CLI", ConfigDir: "~/.gemini", 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"}, + "kimi": {Name: "Kimi Code", ConfigDir: "~/.kimi-code", ConfigFile: "config.toml"}, "opencode": {Name: "OpenCode", ConfigDir: "~/.config/opencode/plugins", ConfigFile: "elydora-audit.mjs"}, "copilot": {Name: "Copilot CLI", ConfigDir: ".github/hooks", ConfigFile: "hooks.json"}, "letta": {Name: "Letta Code", ConfigDir: "~/.letta", ConfigFile: "settings.json"}, @@ -36,6 +37,8 @@ func NewPlugin(agentName string) AgentPlugin { return &KiroCliPlugin{} case "kiroide": return &KiroIdePlugin{} + case "kimi": + return &KimiPlugin{} case "opencode": return &OpenCodePlugin{} case "copilot": diff --git a/sdks/go/go.mod b/sdks/go/go.mod index d136823..77d064f 100644 --- a/sdks/go/go.mod +++ b/sdks/go/go.mod @@ -1,3 +1,5 @@ module github.com/Elydora-Infrastructure/Elydora-Go-SDK -go 1.21 +go 1.21.0 + +require github.com/pelletier/go-toml/v2 v2.4.3 diff --git a/sdks/go/go.sum b/sdks/go/go.sum new file mode 100644 index 0000000..ddaf7d6 --- /dev/null +++ b/sdks/go/go.sum @@ -0,0 +1,2 @@ +github.com/pelletier/go-toml/v2 v2.4.3 h1:GTRvJQutkOSftxIFD5xw9aepkYNuPWmVJpffdDPYVpY= +github.com/pelletier/go-toml/v2 v2.4.3/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=