feat: sync Go Kimi hook integration

This commit is contained in:
ZacharyZhang-NY committed 2026-07-19 07:08:02 -04:00
1 parent 54c3236aca
commit 90505ca66e
14 files changed
+1677 -6

No files matched your search

+2 -1
View File
@@ -22,7 +22,8 @@ This repository publishes the open-source server, console, integration catalog,
- Use each provider's documented blocking mechanism and failure mode. - Use each provider's documented blocking mechanism and failure mode.
- Report installation as healthy only when a complete contract references all required runtime files. - 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. - 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 ## Mirror Workflow
+2 -2
View File
@@ -291,9 +291,9 @@
"adapters": { "adapters": {
"node": true, "node": true,
"python": true, "python": true,
"go": false "go": true
}, },
"delivery_state": "partial", "delivery_state": "available",
"source_url": "https://moonshotai.github.io/kimi-code/en/customization/hooks", "source_url": "https://moonshotai.github.io/kimi-code/en/customization/hooks",
"contract_variants": [ "contract_variants": [
{ {
+3
View File
@@ -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`. 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 ### Commands
| Command | Description | | Command | Description |
@@ -95,6 +97,7 @@ Kiro CLI installation covers both runtime contracts. Kiro CLI v2 uses the genera
| Gemini CLI | `gemini` | | Gemini CLI | `gemini` |
| Kiro CLI | `kirocli` | | Kiro CLI | `kirocli` |
| Kiro IDE | `kiroide` | | Kiro IDE | `kiroide` |
| Kimi Code | `kimi` |
| Letta Code | `letta` | | Letta Code | `letta` |
| OpenCode | `opencode` | | OpenCode | `opencode` |
+6 -2
View File
@@ -33,6 +33,10 @@ func writeHookJSONObjectAtomic(path string, value map[string]any) error {
return fmt.Errorf("marshal %s: %w", path, err) return fmt.Errorf("marshal %s: %w", path, err)
} }
encoded = append(encoded, '\n') encoded = append(encoded, '\n')
return writeHookFileAtomic(path, encoded, 0600)
}
func writeHookFileAtomic(path string, contents []byte, mode os.FileMode) error {
directory := filepath.Dir(path) directory := filepath.Dir(path)
if err := os.MkdirAll(directory, 0700); err != nil { if err := os.MkdirAll(directory, 0700); err != nil {
return fmt.Errorf("create directory %s: %w", directory, err) 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) 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)) 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)) return cleanup(fmt.Errorf("write temporary file for %s: %w", path, err))
} }
if err := temporary.Sync(); err != nil { if err := temporary.Sync(); err != nil {
+143
View File
@@ -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
}
@@ -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}
}
+156
View File
@@ -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 ")
}
@@ -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
}
+390
View File
@@ -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()))
}
}
}
}
@@ -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()
}
+472
View File
@@ -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"
}
+3
View File
@@ -15,6 +15,7 @@ var SupportedAgents = map[string]AgentRegistryEntry{
"gemini": {Name: "Gemini CLI", ConfigDir: "~/.gemini", ConfigFile: "settings.json"}, "gemini": {Name: "Gemini CLI", ConfigDir: "~/.gemini", ConfigFile: "settings.json"},
"kirocli": {Name: "Kiro CLI", ConfigDir: "~/.kiro/hooks", ConfigFile: "elydora-audit.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"},
"kimi": {Name: "Kimi Code", ConfigDir: "~/.kimi-code", ConfigFile: "config.toml"},
"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"},
"letta": {Name: "Letta Code", ConfigDir: "~/.letta", ConfigFile: "settings.json"}, "letta": {Name: "Letta Code", ConfigDir: "~/.letta", ConfigFile: "settings.json"},
@@ -36,6 +37,8 @@ func NewPlugin(agentName string) AgentPlugin {
return &KiroCliPlugin{} return &KiroCliPlugin{}
case "kiroide": case "kiroide":
return &KiroIdePlugin{} return &KiroIdePlugin{}
case "kimi":
return &KimiPlugin{}
case "opencode": case "opencode":
return &OpenCodePlugin{} return &OpenCodePlugin{}
case "copilot": case "copilot":
+3 -1
View File
@@ -1,3 +1,5 @@
module github.com/Elydora-Infrastructure/Elydora-Go-SDK 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
+2
View File
@@ -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=