From d347209d641d6fb9608763e25685471645e3f5dd Mon Sep 17 00:00:00 2001 From: Gunju Kim Date: Tue, 9 Jun 2026 14:01:27 +0000 Subject: [PATCH] Add Gemini CLI render adapter --- README.md | 25 +++- internal/cli/root.go | 6 +- internal/core/config.go | 37 +++++- internal/core/gemini.go | 147 ++++++++++++++++++++++++ internal/core/gemini_test.go | 215 +++++++++++++++++++++++++++++++++++ internal/core/import.go | 2 +- internal/core/render.go | 104 +++++++++++++++-- internal/core/types.go | 2 + 8 files changed, 523 insertions(+), 15 deletions(-) create mode 100644 internal/core/gemini.go create mode 100644 internal/core/gemini_test.go diff --git a/README.md b/README.md index f0f672c..b0bbc71 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,9 @@ Kanon moves your settings between three states, plus a git remote for sharing: - **Source state** — `kanon.yaml` plus `instructions/`, `skills/`, and `hooks/` in the Kanon home. The single source of truth, tracked in git. - **Target state** — the agent-native files **computed** from the source by the - per-agent adapters (`codex`, `claude`). Never stored; recomputed on demand. + per-agent adapters (`codex`, `claude`, `gemini`). Never stored; recomputed on + demand. Adapters beyond the default two are opt-in (see [Choosing which agents + to render](#choosing-which-agents-to-render)). - **Destination state** — the real files on this machine. ### Set up kanon on your current machine @@ -118,6 +120,9 @@ From the source state, Kanon renders: - MCP server definitions - hooks +For Gemini CLI, instructions render into `GEMINI.md` and MCP servers plus hooks +render into `.gemini/settings.json` (`mcpServers` in Gemini's HTTP/SSE shape). + The default flow is preview first (`render` / `diff`), then `apply`. Rendered files are compared directly with the destination and overwritten when they differ. Files that are not rendered by the current source are outside the apply @@ -153,6 +158,24 @@ and server entries. For Claude `settings.json`, Kanon updates the rendered `hooks` section and preserves other settings. If an existing co-owned config cannot be parsed, the merge stops with an error and the file is left untouched. +### Choosing which agents to render + +By default Kanon renders for `codex` and `claude` only. Additional adapters are +opt-in via a top-level `agents:` list in `kanon.yaml`, so adding a new adapter +never makes an existing config start writing new files on the next `apply`: + +```yaml +version: 1 +agents: [codex, claude, gemini] # absent => default (codex, claude) +instructions: + files: [instructions/shared.md] +``` + +Per-setting `targets:` still works (e.g. an MCP server with `targets: [gemini]`), +and `--agent gemini` renders a single agent explicitly regardless of the list. +The top-level `agents:` list accepts concrete adapter names only; `all` is +reserved for CLI flags and per-setting targets. + ## Importing existing settings ```sh diff --git a/internal/cli/root.go b/internal/cli/root.go index d2bbe93..963bd0f 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -43,7 +43,7 @@ It works in three states, mirroring chezmoi: source state kanon.yaml plus instructions/ skills/ hooks/ — the single source of truth, tracked in git target state the agent-native files compiled from the source by the - per-agent adapters (codex, claude) + per-agent adapters (codex, claude, gemini) destination state the real files on this machine (AGENTS.md, CLAUDE.md, ~/.codex, ~/.claude, and project directories) @@ -56,7 +56,7 @@ pull/push/update to sync the source with a remote.`, cmd.PersistentFlags().StringVar(&opts.home, "home", "", "Kanon source repository path (defaults to KANON_HOME or ~/.config/kanon)") cmd.PersistentFlags().StringVar(&opts.configPath, "config", "", "config file path (defaults to /kanon.yaml)") cmd.PersistentFlags().StringVar(&opts.project, "project", "", "render project-scoped agent settings into this repository") - cmd.PersistentFlags().StringVar(&opts.agent, "agent", core.AgentAll, "agent to manage: all, codex, or claude") + cmd.PersistentFlags().StringVar(&opts.agent, "agent", core.AgentAll, "agent to manage: all, codex, claude, or gemini") cmd.AddCommand(initCommand(opts)) cmd.AddCommand(validateCommand(opts)) @@ -397,7 +397,7 @@ func (opts *options) targetOptions() (core.TargetOptions, error) { return core.TargetOptions{}, err } } - if opts.agent != core.AgentAll && opts.agent != core.AgentCodex && opts.agent != core.AgentClaude { + if opts.agent != core.AgentAll && !core.IsAgent(opts.agent) { return core.TargetOptions{}, fmt.Errorf("unsupported agent %q", opts.agent) } return core.TargetOptions{ diff --git a/internal/core/config.go b/internal/core/config.go index 93a4a71..9afab92 100644 --- a/internal/core/config.go +++ b/internal/core/config.go @@ -65,6 +65,15 @@ func ValidateConfig(cfg *Config, home string) []error { if cfg.Version != 1 { errs = append(errs, fmt.Errorf("unsupported version %d", cfg.Version)) } + for _, agent := range cfg.Agents { + if agent == AgentAll { + errs = append(errs, fmt.Errorf("agents cannot include %q; list concrete agents", AgentAll)) + continue + } + if !IsAgent(agent) { + errs = append(errs, fmt.Errorf("agents has unsupported entry %q", agent)) + } + } for _, rel := range cfg.Instructions.Files { if strings.TrimSpace(rel) == "" { errs = append(errs, errors.New("instruction path cannot be empty")) @@ -109,11 +118,17 @@ func ValidateConfig(cfg *Config, home string) []error { errs = append(errs, fmt.Errorf("mcp server %q requires url or command", name)) } } + geminiEnabled := hasAgent(cfg.Agents, AgentGemini) for _, hook := range cfg.Hooks { if strings.TrimSpace(hook.Name) == "" { errs = append(errs, errors.New("hook name cannot be empty")) } validateTargets(fmt.Sprintf("hook %q", hook.Name), hook.Targets, &errs) + if geminiEnabled && HasTarget(hook.Targets, AgentGemini) { + if _, _, err := geminiHookItem(hook); err != nil { + errs = append(errs, err) + } + } } validateEnvRefs("config", cfg, &errs) return errs @@ -168,12 +183,32 @@ func HasTarget(list []string, agent string) bool { func validateTargets(label string, targets []string, errs *[]error) { for _, target := range targets { - if target != AgentAll && target != AgentCodex && target != AgentClaude { + if target != AgentAll && !IsAgent(target) { *errs = append(*errs, fmt.Errorf("%s has unsupported target %q", label, target)) } } } +func hasAgent(list []string, agent string) bool { + for _, item := range list { + if item == agent { + return true + } + } + return false +} + +// IsAgent reports whether name is a render target Kanon supports. It excludes +// the "all" pseudo-target. +func IsAgent(name string) bool { + switch name { + case AgentCodex, AgentClaude, AgentGemini: + return true + default: + return false + } +} + func enabled(value *bool) bool { return value == nil || *value } diff --git a/internal/core/gemini.go b/internal/core/gemini.go new file mode 100644 index 0000000..2b81b1f --- /dev/null +++ b/internal/core/gemini.go @@ -0,0 +1,147 @@ +package core + +import ( + "fmt" + "path/filepath" +) + +type geminiAdapter struct{} + +func (geminiAdapter) Name() string { + return AgentGemini +} + +func (geminiAdapter) Render(cfg *Config, opts TargetOptions) ([]RenderedFile, error) { + var files []RenderedFile + targetRoot := filepath.Join(opts.UserHome, ".gemini") + instructionPath := filepath.Join(targetRoot, "GEMINI.md") + skillRoot := filepath.Join(targetRoot, "skills") + if opts.Project != "" { + targetRoot = filepath.Join(opts.Project, ".gemini") + instructionPath = filepath.Join(opts.Project, "GEMINI.md") + skillRoot = filepath.Join(targetRoot, "skills") + } + + instructions, err := readInstruction(opts.KanonHome, cfg.Instructions.Files) + if err != nil { + return nil, err + } + if len(instructions) > 0 { + files = append(files, RenderedFile{ + Agent: AgentGemini, + Path: instructionPath, + Content: instructions, + Mode: 0o644, + }) + } + + settings := map[string]any{} + if servers := geminiMCPServers(cfg); len(servers) > 0 { + settings["mcpServers"] = servers + } + hooks, err := geminiHooks(cfg) + if err != nil { + return nil, err + } + if len(hooks) > 0 { + settings["hooks"] = hooks + } + if len(settings) > 0 { + data, err := renderJSON(settings) + if err != nil { + return nil, err + } + files = append(files, RenderedFile{ + Agent: AgentGemini, + Path: filepath.Join(targetRoot, "settings.json"), + Content: data, + Mode: 0o644, + }) + } + + skills, err := renderSkills(cfg, opts, AgentGemini, skillRoot) + if err != nil { + return nil, err + } + files = append(files, skills...) + return files, nil +} + +// Import is not yet supported for Gemini CLI. The adapter is render-only for +// now (see issue #7); lifting GEMINI.md + settings.json back into the neutral +// config is tracked as later work. It returns an empty result so that an +// explicit `kanon import --agent gemini` is a harmless no-op rather than an +// error. +func (geminiAdapter) Import(opts ImportOptions) (*ImportResult, error) { + return &ImportResult{ + Config: &Config{Version: 1}, + Files: map[string][]byte{}, + }, nil +} + +func geminiHooks(cfg *Config) (map[string]any, error) { + grouped := map[string]any{} + for _, hook := range cfg.Hooks { + if !HasTarget(hook.Targets, AgentGemini) { + continue + } + event, item, err := geminiHookItem(hook) + if err != nil { + return nil, err + } + grouped[event] = appendAny(grouped[event], item) + } + return grouped, nil +} + +func geminiHookItem(hook Hook) (string, map[string]any, error) { + event := hook.Event + if event == "" { + event = hook.Name + } + geminiEvent, ok := geminiHookEvent(event) + if !ok { + return "", nil, fmt.Errorf("hook %q targets gemini with unsupported event %q", hook.Name, event) + } + if hook.Type != "" && hook.Type != "command" { + return "", nil, fmt.Errorf("hook %q targets gemini with unsupported type %q", hook.Name, hook.Type) + } + if hook.Command == "" { + return "", nil, fmt.Errorf("hook %q targets gemini and requires command", hook.Name) + } + if len(hook.Args) > 0 { + return "", nil, fmt.Errorf("hook %q targets gemini: args are not supported; include arguments in command", hook.Name) + } + if hook.Async { + return "", nil, fmt.Errorf("hook %q targets gemini: async hooks are not supported", hook.Name) + } + handler := map[string]any{ + "name": hook.Name, + "type": "command", + "command": hook.Command, + } + if hook.Timeout > 0 { + handler["timeout"] = hook.Timeout + } + item := map[string]any{"hooks": []any{handler}} + if hook.Matcher != "" { + item["matcher"] = hook.Matcher + } + return geminiEvent, item, nil +} + +func geminiHookEvent(event string) (string, bool) { + switch event { + case "PreToolUse": + return "BeforeTool", true + case "PostToolUse": + return "AfterTool", true + case "BeforeTool", "AfterTool", + "BeforeAgent", "AfterAgent", + "BeforeModel", "BeforeToolSelection", "AfterModel", + "SessionStart", "SessionEnd", "Notification", "PreCompress": + return event, true + default: + return "", false + } +} diff --git a/internal/core/gemini_test.go b/internal/core/gemini_test.go new file mode 100644 index 0000000..82021dd --- /dev/null +++ b/internal/core/gemini_test.go @@ -0,0 +1,215 @@ +package core + +import ( + "encoding/json" + "path/filepath" + "strings" + "testing" +) + +// TestRenderDefaultsExcludeGemini guards the backward-compatibility contract: +// a config without an agents: key must keep rendering exactly codex+claude and +// never emit Gemini files. +func TestRenderDefaultsExcludeGemini(t *testing.T) { + kanonHome := t.TempDir() + userHome := t.TempDir() + writeTestFile(t, filepath.Join(kanonHome, "instructions", "shared.md"), []byte("Shared rules\n")) + + cfg := &Config{ + Version: 1, + Instructions: Instructions{Files: []string{"instructions/shared.md"}}, + } + files, err := RenderAll(cfg, TargetOptions{ + KanonHome: kanonHome, + UserHome: userHome, + Agent: AgentAll, + }) + if err != nil { + t.Fatal(err) + } + for _, file := range files { + if file.Agent == AgentGemini { + t.Fatalf("gemini file rendered without opt-in: %s", file.Path) + } + } + byPath := renderedByPath(files) + if _, ok := byPath[filepath.Join(userHome, ".codex", "AGENTS.md")]; !ok { + t.Fatalf("codex instructions were not rendered by default") + } + if _, ok := byPath[filepath.Join(userHome, ".claude", "CLAUDE.md")]; !ok { + t.Fatalf("claude instructions were not rendered by default") + } +} + +func TestRenderGeminiOptIn(t *testing.T) { + kanonHome := t.TempDir() + userHome := t.TempDir() + writeTestFile(t, filepath.Join(kanonHome, "instructions", "shared.md"), []byte("Shared rules\n")) + writeTestFile(t, filepath.Join(kanonHome, "skills", "review", "SKILL.md"), []byte("---\nname: review\n---\n\nReview code.\n")) + + cfg := &Config{ + Version: 1, + Agents: []string{AgentCodex, AgentClaude, AgentGemini}, + Instructions: Instructions{Files: []string{"instructions/shared.md"}}, + Skills: []Skill{{Name: "review"}}, + MCP: MCPConfig{Servers: map[string]MCPServer{ + "context": { + Command: "context-server", + Args: []string{"--stdio"}, + Env: map[string]string{"TOKEN": "${CONTEXT_TOKEN:-unset}"}, + }, + "remote": { + Type: "http", + URL: "https://example.com/mcp", + }, + }}, + Hooks: []Hook{{ + Name: "fmt", + Event: "PostToolUse", + Matcher: "write_file", + Command: "gofmt -w \"$FILE\"", + }}, + } + + files, err := RenderAll(cfg, TargetOptions{ + KanonHome: kanonHome, + UserHome: userHome, + Agent: AgentAll, + }) + if err != nil { + t.Fatal(err) + } + byPath := renderedByPath(files) + + geminiMd := string(byPath[filepath.Join(userHome, ".gemini", "GEMINI.md")].Content) + if !strings.Contains(geminiMd, "Shared rules") { + t.Fatalf("gemini instructions missing: %q", geminiMd) + } + + settingsData := byPath[filepath.Join(userHome, ".gemini", "settings.json")].Content + settings := string(settingsData) + if !strings.Contains(settings, `"mcpServers"`) || !strings.Contains(settings, `"context-server"`) { + t.Fatalf("gemini settings missing stdio mcp server: %s", settings) + } + if !strings.Contains(settings, `"httpUrl": "https://example.com/mcp"`) { + t.Fatalf("gemini settings missing http mcp server: %s", settings) + } + var parsed map[string]any + if err := json.Unmarshal(settingsData, &parsed); err != nil { + t.Fatal(err) + } + hooks := parsed["hooks"].(map[string]any) + afterTool := hooks["AfterTool"].([]any) + hookGroup := afterTool[0].(map[string]any) + if hookGroup["matcher"] != "write_file" { + t.Fatalf("gemini hook matcher was not rendered: %#v", hookGroup) + } + handler := hookGroup["hooks"].([]any)[0].(map[string]any) + if handler["name"] != "fmt" || handler["type"] != "command" || handler["command"] != `gofmt -w "$FILE"` { + t.Fatalf("gemini hook handler was not rendered correctly: %#v", handler) + } + if _, ok := handler["args"]; ok { + t.Fatalf("gemini hook rendered unsupported args field: %#v", handler) + } + if _, ok := handler["async"]; ok { + t.Fatalf("gemini hook rendered unsupported async field: %#v", handler) + } + if _, ok := byPath[filepath.Join(userHome, ".gemini", "skills", "review", "SKILL.md")]; !ok { + t.Fatalf("gemini skill was not rendered") + } +} + +func TestRenderGeminiExplicitAgent(t *testing.T) { + kanonHome := t.TempDir() + userHome := t.TempDir() + writeTestFile(t, filepath.Join(kanonHome, "instructions", "shared.md"), []byte("Shared rules\n")) + + cfg := &Config{ + Version: 1, + Instructions: Instructions{Files: []string{"instructions/shared.md"}}, + } + // --agent gemini selects the adapter explicitly, even without an opt-in list. + files, err := RenderAll(cfg, TargetOptions{ + KanonHome: kanonHome, + UserHome: userHome, + Agent: AgentGemini, + }) + if err != nil { + t.Fatal(err) + } + for _, file := range files { + if file.Agent != AgentGemini { + t.Fatalf("expected only gemini files, got %s for %s", file.Agent, file.Path) + } + } + byPath := renderedByPath(files) + if _, ok := byPath[filepath.Join(userHome, ".gemini", "GEMINI.md")]; !ok { + t.Fatalf("gemini instructions were not rendered") + } +} + +func TestValidateRejectsUnknownAgent(t *testing.T) { + cfg := &Config{ + Version: 1, + Agents: []string{AgentCodex, "windsurf"}, + } + errs := ValidateConfig(cfg, t.TempDir()) + if len(errs) == 0 { + t.Fatal("expected validation error for unknown agent entry") + } + if !strings.Contains(errs[0].Error(), "windsurf") { + t.Fatalf("unexpected validation error: %v", errs[0]) + } +} + +func TestValidateRejectsTopLevelAgentAll(t *testing.T) { + cfg := &Config{ + Version: 1, + Agents: []string{AgentAll}, + } + errs := ValidateConfig(cfg, t.TempDir()) + if len(errs) == 0 { + t.Fatal("expected validation error for agents all") + } + if !strings.Contains(errs[0].Error(), `agents cannot include "all"`) { + t.Fatalf("unexpected validation error: %v", errs[0]) + } +} + +func TestValidateRejectsInvalidGeminiHookWhenOptedIn(t *testing.T) { + cfg := &Config{ + Version: 1, + Agents: []string{AgentGemini}, + Hooks: []Hook{{ + Name: "fmt", + Event: "PostToolUse", + Command: "gofmt", + Args: []string{"-w", "$FILE"}, + }}, + } + errs := ValidateConfig(cfg, t.TempDir()) + if len(errs) == 0 { + t.Fatal("expected validation error for unsupported gemini hook args") + } + if !strings.Contains(errorsText(errs), "args are not supported") { + t.Fatalf("unexpected validation error: %v", errs) + } +} + +func TestRenderGeminiRejectsUnsupportedHookEvent(t *testing.T) { + _, err := RenderAll(&Config{ + Version: 1, + Hooks: []Hook{{ + Name: "stop", + Targets: []string{AgentGemini}, + Event: "Stop", + Command: "echo stop", + }}, + }, TargetOptions{UserHome: t.TempDir(), Agent: AgentGemini}) + if err == nil { + t.Fatal("expected render error for unsupported gemini hook event") + } + if !strings.Contains(err.Error(), `unsupported event "Stop"`) { + t.Fatalf("unexpected render error: %v", err) + } +} diff --git a/internal/core/import.go b/internal/core/import.go index 63946ab..7581e80 100644 --- a/internal/core/import.go +++ b/internal/core/import.go @@ -39,7 +39,7 @@ func ImportAll(opts ImportOptions) (*ImportResult, error) { if err := importSkills(merged, opts); err != nil { return nil, err } - for _, adapter := range adaptersFor(opts.Agent) { + for _, adapter := range adaptersFor(nil, opts.Agent) { result, err := adapter.Import(opts) if err != nil { return nil, err diff --git a/internal/core/render.go b/internal/core/render.go index cc2d002..87452e1 100644 --- a/internal/core/render.go +++ b/internal/core/render.go @@ -15,7 +15,7 @@ import ( func RenderAll(cfg *Config, opts TargetOptions) ([]RenderedFile, error) { var files []RenderedFile - for _, adapter := range adaptersFor(opts.Agent) { + for _, adapter := range adaptersFor(cfg, opts.Agent) { rendered, err := adapter.Render(cfg, opts) if err != nil { return nil, err @@ -52,15 +52,49 @@ func FormatRender(files []RenderedFile) string { return out.String() } -func adaptersFor(agent string) []Adapter { - switch agent { - case AgentCodex: - return []Adapter{codexAdapter{}} - case AgentClaude: - return []Adapter{claudeAdapter{}} - default: - return []Adapter{codexAdapter{}, claudeAdapter{}} +// allAdapters is the registry of every adapter Kanon knows how to render. +func allAdapters() []Adapter { + return []Adapter{codexAdapter{}, claudeAdapter{}, geminiAdapter{}} +} + +// defaultAgents is the set rendered when a config does not opt in via the +// top-level agents: key. It stays codex+claude so that existing kanon.yaml +// files keep rendering exactly those two and never start writing files for a +// newly added adapter on the next apply. +var defaultAgents = []string{AgentCodex, AgentClaude} + +func adapterByName(name string) Adapter { + for _, adapter := range allAdapters() { + if adapter.Name() == name { + return adapter + } + } + return nil +} + +// adaptersFor resolves which adapters to run. A specific --agent selects just +// that adapter; "all" intersects the registry with the config's enabled agent +// list (cfg.Agents), defaulting to codex+claude when that list is absent. A +// nil config (e.g. during import, before a kanon.yaml exists) uses the default +// set as well, preserving the prior import behavior. +func adaptersFor(cfg *Config, agent string) []Adapter { + if agent != AgentAll { + if adapter := adapterByName(agent); adapter != nil { + return []Adapter{adapter} + } + return nil } + names := defaultAgents + if cfg != nil && len(cfg.Agents) > 0 { + names = cfg.Agents + } + var adapters []Adapter + for _, name := range names { + if adapter := adapterByName(name); adapter != nil { + adapters = append(adapters, adapter) + } + } + return adapters } func readInstruction(home string, paths []string) ([]byte, error) { @@ -336,6 +370,58 @@ func claudeMCPServers(cfg *Config) map[string]any { return out } +func geminiMCPServers(cfg *Config) map[string]any { + out := map[string]any{} + names := sortedMCPNames(cfg) + for _, name := range names { + server := cfg.MCP.Servers[name] + if !enabled(server.Enabled) || !HasTarget(server.Targets, AgentGemini) { + continue + } + item := map[string]any{} + if server.Command != "" { + item["command"] = server.Command + } + if len(server.Args) > 0 { + item["args"] = server.Args + } + if len(server.Env) > 0 { + item["env"] = server.Env + } + if server.URL != "" { + // Gemini CLI distinguishes streamable HTTP (httpUrl) from SSE (url). + if server.Type == "sse" { + item["url"] = server.URL + } else { + item["httpUrl"] = server.URL + } + } + if len(server.Headers) > 0 { + item["headers"] = server.Headers + } + if len(server.EnvHeaders) > 0 { + headers := map[string]string{} + if existing, ok := item["headers"].(map[string]string); ok { + for key, value := range existing { + headers[key] = value + } + } + for key, value := range server.EnvHeaders { + headers[key] = fmt.Sprintf("${%s}", value) + } + item["headers"] = headers + } + if len(server.EnabledTools) > 0 { + item["includeTools"] = server.EnabledTools + } + if len(server.DisabledTools) > 0 { + item["excludeTools"] = server.DisabledTools + } + out[name] = item + } + return out +} + func sortedMCPNames(cfg *Config) []string { names := make([]string, 0, len(cfg.MCP.Servers)) for name := range cfg.MCP.Servers { diff --git a/internal/core/types.go b/internal/core/types.go index 4b040c1..9df7c49 100644 --- a/internal/core/types.go +++ b/internal/core/types.go @@ -5,11 +5,13 @@ import "io/fs" const ( AgentCodex = "codex" AgentClaude = "claude" + AgentGemini = "gemini" AgentAll = "all" ) type Config struct { Version int `yaml:"version"` + Agents []string `yaml:"agents,omitempty"` Instructions Instructions `yaml:"instructions"` Skills []Skill `yaml:"skills"` MCP MCPConfig `yaml:"mcp"`