feat: sync Go Kimi hook integration
This commit is contained in:
1 parent
54c3236aca
commit
90505ca66e
14 files changed
+1677
-6
No files matched your search
@@ -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` |
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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":
|
||||
|
||||
+3
-1
@@ -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
|
||||
@@ -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=
|
||||
Reference in new issue
Block a user