Files
wehub-resource-sync a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:33:42 +08:00

319 lines
8.8 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package kimi implements the Gortex init/install integration for Kimi Code
// CLI. Kimi keeps MCP server declarations in mcp.json and user-level lifecycle
// hooks in config.toml:
//
// - project MCP: .kimi-code/mcp.json
// - user MCP: $KIMI_CODE_HOME/mcp.json, defaulting to ~/.kimi-code/mcp.json
// - user hooks: $KIMI_CODE_HOME/config.toml, defaulting to ~/.kimi-code/config.toml
//
// Kimi's hooks are user-level today, so project-mode `gortex init` only writes
// the repo-local MCP file. Machine-wide `gortex install` writes user MCP and,
// when hooks are enabled, UserPromptSubmit / PreToolUse / Stop / SubagentStart
// hooks that shell `gortex hook --agent=kimi` for pre-turn context injection,
// graph-aware tool redirects, post-turn self-correction, and subagent briefing.
//
// Docs: https://www.kimi.com/code/docs/en/kimi-code-cli/customization/hooks.html
package kimi
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"github.com/zzet/gortex/internal/agents"
"github.com/zzet/gortex/internal/agents/internalutil"
)
const Name = "kimi"
const DocsURL = "https://www.kimi.com/code/docs/en/kimi-code-cli/customization/hooks.html"
// Per-event hook timeouts (Kimi allows 1600s). UserPromptSubmit and
// PreToolUse sit on the turn's critical path, so they stay snappy; the Stop and
// SubagentStart briefings fan out to several graph tools and run off the hot
// path, so they get more headroom before Kimi's fail-open kill.
const (
kimiHookTimeoutSeconds = 5
kimiStopHookTimeoutSeconds = 30
kimiSubagentHookTimeoutSeconds = 15
)
type Adapter struct{}
func New() *Adapter { return &Adapter{} }
func (a *Adapter) Name() string { return Name }
func (a *Adapter) DocsURL() string { return DocsURL }
// Detect checks for the kimi CLI on PATH or an existing Kimi Code home.
func (a *Adapter) Detect(env agents.Env) (bool, error) {
if p, err := exec.LookPath("kimi"); err == nil && p != "" {
return true, nil
}
if home := kimiConfigRoot(env); home != "" {
if _, err := os.Stat(home); err == nil {
return true, nil
}
}
return false, nil
}
func (a *Adapter) Plan(env agents.Env) (*agents.Plan, error) {
p := &agents.Plan{}
if env.Mode == agents.ModeGlobal {
home := kimiConfigRoot(env)
if home == "" {
return p, nil
}
p.Files = append(p.Files, agents.FileAction{
Path: filepath.Join(home, "mcp.json"),
Action: agents.ActionWouldMerge,
Keys: []string{"mcpServers"},
})
if env.InstallHooks {
p.Files = append(p.Files, agents.FileAction{
Path: filepath.Join(home, "config.toml"),
Action: agents.ActionWouldMerge,
Keys: []string{"hooks"},
})
}
return p, nil
}
p.Files = append(p.Files, agents.FileAction{
Path: projectMCPPath(env),
Action: agents.ActionWouldMerge,
Keys: []string{"mcpServers"},
})
return p, nil
}
func (a *Adapter) Apply(env agents.Env, opts agents.ApplyOpts) (*agents.Result, error) {
res := &agents.Result{Name: Name, DocsURL: DocsURL}
detected, _ := a.Detect(env)
res.Detected = detected
if !detected && !opts.ForceDetect {
internalutil.Logf(env.Stderr, "[gortex init] skip Kimi Code CLI setup (kimi not detected)")
return res, nil
}
if env.Mode == agents.ModeGlobal {
return a.applyGlobal(env, opts, res)
}
return a.applyProject(env, opts, res)
}
func (a *Adapter) applyProject(env agents.Env, opts agents.ApplyOpts, res *agents.Result) (*agents.Result, error) {
if env.Root == "" {
return res, fmt.Errorf("kimi: requires a repository root")
}
internalutil.Logf(env.Stderr, "[gortex init] setting up Kimi Code CLI project MCP integration...")
action, err := agents.MergeJSON(env.Stderr, projectMCPPath(env), func(root map[string]any, _ bool) (bool, error) {
return agents.UpsertMCPServer(root, "gortex", agents.DefaultGortexMCPEntry(), opts), nil
}, opts)
if err != nil {
return res, err
}
res.Files = append(res.Files, action)
res.Configured = true
return res, nil
}
func (a *Adapter) applyGlobal(env agents.Env, opts agents.ApplyOpts, res *agents.Result) (*agents.Result, error) {
home := kimiConfigRoot(env)
if home == "" {
return res, fmt.Errorf("kimi: requires KIMI_CODE_HOME or a resolved home directory")
}
internalutil.Logf(env.Stderr, "[gortex install] setting up Kimi Code CLI user integration...")
mcpAction, err := agents.MergeJSON(env.Stderr, filepath.Join(home, "mcp.json"), func(root map[string]any, _ bool) (bool, error) {
return agents.UpsertMCPServer(root, "gortex", agents.DefaultGortexMCPEntry(), opts), nil
}, opts)
if err != nil {
return res, err
}
res.Files = append(res.Files, mcpAction)
if env.InstallHooks {
hookAction, err := agents.MergeTOML(env.Stderr, filepath.Join(home, "config.toml"), func(root map[string]any, _ bool) (bool, error) {
return upsertKimiHooks(root, env, opts), nil
}, opts)
if err != nil {
return res, err
}
if hookAction.Action != agents.ActionSkip {
hookAction.Keys = []string{"hooks"}
}
res.Files = append(res.Files, hookAction)
}
res.Configured = true
return res, nil
}
func projectMCPPath(env agents.Env) string {
return filepath.Join(env.Root, ".kimi-code", "mcp.json")
}
func kimiConfigRoot(env agents.Env) string {
if home := strings.TrimSpace(os.Getenv("KIMI_CODE_HOME")); home != "" {
return home
}
if env.Home == "" {
return ""
}
return filepath.Join(env.Home, ".kimi-code")
}
func upsertKimiHooks(root map[string]any, env agents.Env, opts agents.ApplyOpts) bool {
existing, ok := kimiHookList(root["hooks"])
if !ok {
return false
}
found := make(map[string]bool)
kept := make([]any, 0, len(existing)+2)
for _, entry := range existing {
if event, ok := kimiHookEntryInvokesKimi(entry); ok {
found[event] = true
if opts.Force {
continue
}
}
kept = append(kept, entry)
}
changed := false
for _, hook := range kimiHooks(env) {
event, _ := hook["event"].(string)
if found[event] && !opts.Force {
continue
}
kept = append(kept, hook)
changed = true
}
if !changed {
return false
}
root["hooks"] = kept
return true
}
func kimiHookList(v any) ([]any, bool) {
if v == nil {
return nil, true
}
switch list := v.(type) {
case []any:
return append([]any(nil), list...), true
case []map[string]any:
out := make([]any, 0, len(list))
for _, entry := range list {
out = append(out, entry)
}
return out, true
default:
return nil, false
}
}
func kimiHooks(env agents.Env) []map[string]any {
return []map[string]any{
kimiUserPromptSubmitHook(env),
kimiPreToolUseHook(env),
kimiStopHook(env),
kimiSubagentStartHook(env),
}
}
func kimiUserPromptSubmitHook(env agents.Env) map[string]any {
return map[string]any{
"event": "UserPromptSubmit",
"command": kimiHookCommand(env),
"timeout": kimiHookTimeoutSeconds,
}
}
func kimiPreToolUseHook(env agents.Env) map[string]any {
// Do not set matcher here: Kimi currently exposes MCP tool names as the
// underlying tool name without a documented server namespace, and the
// native Read/Grep/Glob redirects want to see every tool call. The
// dispatcher does the filtering and silently ignores anything it doesn't
// enrich.
return map[string]any{
"event": "PreToolUse",
"command": kimiHookCommand(env),
"timeout": kimiHookTimeoutSeconds,
}
}
// kimiStopHook runs the post-turn diagnostics (changed symbols → test targets,
// guards, dead code, coverage, contracts) and feeds them back so the agent can
// self-correct before handoff.
func kimiStopHook(env agents.Env) map[string]any {
return map[string]any{
"event": "Stop",
"command": kimiHookCommand(env),
"timeout": kimiStopHookTimeoutSeconds,
}
}
// kimiSubagentStartHook briefs a spawned subagent with task-scoped graph
// context and the tool-swap table so it doesn't default to raw Read/Grep.
func kimiSubagentStartHook(env agents.Env) map[string]any {
return map[string]any{
"event": "SubagentStart",
"command": kimiHookCommand(env),
"timeout": kimiSubagentHookTimeoutSeconds,
}
}
func kimiHookCommand(env agents.Env) string {
base := strings.TrimSpace(env.HookCommand)
if base == "" {
base = "gortex hook"
}
return base + " --agent=kimi"
}
func kimiHookEntryInvokesKimi(entry any) (string, bool) {
m, ok := entry.(map[string]any)
if !ok {
return "", false
}
event, _ := m["event"].(string)
switch event {
case "UserPromptSubmit", "PreToolUse", "Stop", "SubagentStart":
default:
return "", false
}
cmd, _ := m["command"].(string)
return event, kimiCommandInvokesKimiHook(cmd)
}
func kimiCommandInvokesKimiHook(cmd string) bool {
fields := strings.Fields(cmd)
if len(fields) < 3 {
return false
}
if !strings.Contains(strings.ToLower(fields[0]), "gortex") {
return false
}
hasHook := false
hasAgent := false
for i, f := range fields[1:] {
if f == "hook" {
hasHook = true
}
if f == "--agent=kimi" {
hasAgent = true
}
if f == "--agent" && i+2 < len(fields) && fields[i+2] == "kimi" {
hasAgent = true
}
}
return hasHook && hasAgent
}