Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 24 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions internal/cli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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 <home>/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))
Expand Down Expand Up @@ -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{
Expand Down
37 changes: 36 additions & 1 deletion internal/core/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Expand Down
147 changes: 147 additions & 0 deletions internal/core/gemini.go
Original file line number Diff line number Diff line change
@@ -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{

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Please give this rendered file a merge strategy before shipping Gemini apply support. .gemini/settings.json is the same file Gemini CLI uses for unrelated settings like UI, general, security, privacy, and hooksConfig; with the default FileMergeReplace here, an opted-in user who already has that file will lose those settings when Kanon writes only mcpServers and hooks. This also contradicts the README's co-owned-config contract, so Kanon should preserve unrelated top-level fields and non-rendered MCP servers (or fail) instead of replacing the whole file.

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
}
}
Loading
Loading