// Vikunja is a to-do list application to facilitate your life. // Copyright 2018-present Vikunja and contributors. All rights reserved. // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . package bootstrap import ( "encoding/json" "errors" "fmt" "io" "os" "path/filepath" "strings" "code.vikunja.io/veans/internal/auth" "code.vikunja.io/veans/internal/output" ) // veansPrimeCommand is the literal command line every hook ends up invoking. // Centralising it here keeps the install logic and the duplicate-detection // reading the same string. const veansPrimeCommand = "veans prime" // ClaudeCodeHookEvents enumerates every Claude Code lifecycle event that // veans wires `veans prime` into. Both the auto-installer in this file and // the manual-install snippet rendered by the `init` command iterate this // list so the two paths can never drift. var ClaudeCodeHookEvents = []string{"SessionStart", "PreCompact"} // ClaudeCodeSettingsRelPath is the per-repo Claude Code settings file that // installClaudeCodeHook merges into. Exported so the `init` command can name // it in the manual-install blurb without re-typing the literal. const ClaudeCodeSettingsRelPath = ".claude/settings.json" // OpenCodePluginRelPath is the per-repo path the OpenCode plugin is written // to. Same rationale as ClaudeCodeSettingsRelPath. const OpenCodePluginRelPath = ".opencode/plugin/veans-prime.ts" // OpenCodePluginSnippet is the exact TypeScript plugin written to disk by // installOpenCodeHook. Re-exported verbatim for the manual-install path so a // copy-pasted snippet is byte-for-byte what the installer would have // produced. const OpenCodePluginSnippet = `// Auto-generated by 'veans init'. Re-emits the veans agent prompt at the // start of every OpenCode session and before every compaction. See // https://github.com/go-vikunja/vikunja/tree/main/veans for context. export const VeansPrime = { event: ["session.start", "compact.before"], handler: async ({ exec }: { exec: (cmd: string) => Promise }) => exec("veans prime"), } ` // ClaudeCodeHookSnippet renders the JSON fragment a user would paste into // .claude/settings.json to get the same wiring the auto-installer performs. // Generated from ClaudeCodeHookEvents + veansPrimeCommand so a new event // added to the install list automatically shows up in the manual snippet // too. func ClaudeCodeHookSnippet() string { hooks := map[string]any{} for _, event := range ClaudeCodeHookEvents { hooks[event] = []any{ map[string]any{ "hooks": []any{ map[string]any{"type": "command", "command": veansPrimeCommand}, }, }, } } buf, err := json.MarshalIndent(map[string]any{"hooks": hooks}, "", " ") if err != nil { // MarshalIndent on a hand-built map[string]any can't realistically // fail; fall back so callers never see an empty snippet. return fmt.Sprintf(`{"hooks": {"SessionStart": [{"hooks": [{"type": "command", "command": %q}]}]}}`, veansPrimeCommand) } return string(buf) } // AgentHookChoice captures the user's per-agent install decision so the // orchestration in bootstrap.Init can hand the per-repo set of choices // off to the install routines below. type AgentHookChoice struct { ClaudeCode bool OpenCode bool } // offerAgentHooks asks the user — one yes/no per agent — which integrations // they want veans to wire up. Callers pre-populate `choices` from CLI flags // (--install-claude / --install-opencode); only the unset slots get // prompted. When `noHooks` is true we skip everything and return the empty // choice, mirroring the old "just print the snippets" behaviour. func offerAgentHooks(p auth.Prompter, w io.Writer, choices AgentHookChoice, claudeFlagSet, opencodeFlagSet, noHooks bool) (AgentHookChoice, error) { if noHooks { return AgentHookChoice{}, nil } if !claudeFlagSet { yes, err := promptYesNo(p, w, "Wire `veans prime` into Claude Code (.claude/settings.json)?", true) if err != nil { return choices, err } choices.ClaudeCode = yes } if !opencodeFlagSet { yes, err := promptYesNo(p, w, "Wire `veans prime` into OpenCode (.opencode/plugin/veans-prime.ts)?", false) if err != nil { return choices, err } choices.OpenCode = yes } return choices, nil } // installAgentHooks writes the requested integrations to disk relative to // repoRoot. Each install is idempotent: if the hook entry is already there, // it's left alone; if the settings file is missing, it's created with a // fresh skeleton. func installAgentHooks(repoRoot string, choices AgentHookChoice, w io.Writer) error { if choices.ClaudeCode { path, action, err := installClaudeCodeHook(repoRoot) if err != nil { return output.Wrap(output.CodeUnknown, err, "install Claude Code hook: %v", err) } progress(w, "%s Claude Code hook in %s", action, path) } if choices.OpenCode { path, action, err := installOpenCodeHook(repoRoot) if err != nil { return output.Wrap(output.CodeUnknown, err, "install OpenCode hook: %v", err) } progress(w, "%s OpenCode hook in %s", action, path) } return nil } // installClaudeCodeHook merges (or creates) `/.claude/settings.json` // so SessionStart and PreCompact invoke `veans prime`. Returns the path, // a human verb describing what happened ("Wrote", "Updated", "Already // configured"), and any error. func installClaudeCodeHook(repoRoot string) (string, string, error) { path := filepath.Join(repoRoot, filepath.FromSlash(ClaudeCodeSettingsRelPath)) settings, existed, err := readJSONOrEmpty(path) if err != nil { return path, "", err } changed := false for _, event := range ClaudeCodeHookEvents { if ensureClaudeHook(settings, event) { changed = true } } if !changed { return path, "Already configured", nil } if err := writeJSON(path, settings); err != nil { return path, "", err } if existed { return path, "Updated", nil } return path, "Wrote", nil } // ensureClaudeHook walks the settings object and appends a `veans prime` // command entry under hooks. if one isn't already present. Returns // true iff the structure was modified. // // Claude Code's settings shape: // // { // "hooks": { // "SessionStart": [ // { "hooks": [ { "type": "command", "command": "veans prime" } ] } // ] // } // } func ensureClaudeHook(settings map[string]any, event string) bool { hooks := mapAt(settings, "hooks") entries, _ := hooks[event].([]any) for _, entry := range entries { entryMap, ok := entry.(map[string]any) if !ok { continue } inner, _ := entryMap["hooks"].([]any) for _, h := range inner { hmap, ok := h.(map[string]any) if !ok { continue } if str(hmap, "type") == "command" && str(hmap, "command") == veansPrimeCommand { return false } } } entries = append(entries, map[string]any{ "hooks": []any{ map[string]any{"type": "command", "command": veansPrimeCommand}, }, }) hooks[event] = entries settings["hooks"] = hooks return true } // installOpenCodeHook writes `/.opencode/plugin/veans-prime.ts` // if missing. Existing files are left alone (TypeScript merging is out of // scope; the user can edit by hand). func installOpenCodeHook(repoRoot string) (string, string, error) { path := filepath.Join(repoRoot, filepath.FromSlash(OpenCodePluginRelPath)) if _, err := os.Stat(path); err == nil { return path, "Already configured", nil } else if !errors.Is(err, os.ErrNotExist) { return path, "", err } if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { return path, "", err } if err := os.WriteFile(path, []byte(OpenCodePluginSnippet), 0o644); err != nil { return path, "", err } return path, "Wrote", nil } // readJSONOrEmpty reads `path` as JSON or returns an empty object if the // file doesn't exist. The `existed` flag tells the caller whether the // resulting object was loaded from disk (so it can decide between // "Wrote" and "Updated"). func readJSONOrEmpty(path string) (out map[string]any, existed bool, err error) { buf, err := os.ReadFile(path) if err != nil { if errors.Is(err, os.ErrNotExist) { return map[string]any{}, false, nil } return nil, false, err } out = map[string]any{} if len(buf) == 0 { return out, true, nil } if err := json.Unmarshal(buf, &out); err != nil { return nil, true, fmt.Errorf("parse %s: %w", path, err) } return out, true, nil } // writeJSON encodes `data` with two-space indent (Claude Code's house // style) and a trailing newline, creating parent directories as needed. // Settings files written here may end up holding provider API keys, so we // default new files to 0o600 and preserve the existing mode on update so a // user who has tightened the file (e.g. to 0o600 explicitly, or chmod'd it // further) doesn't see their permissions widened on the next write. func writeJSON(path string, data map[string]any) error { if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { return err } buf, err := json.MarshalIndent(data, "", " ") if err != nil { return err } buf = append(buf, '\n') mode := os.FileMode(0o600) if info, err := os.Stat(path); err == nil { mode = info.Mode().Perm() } return os.WriteFile(path, buf, mode) } // mapAt returns the map at key `k` on `m`, creating it if missing or if // the existing value is the wrong type. Lets ensureClaudeHook treat the // JSON object tree as if it were always well-shaped. func mapAt(m map[string]any, k string) map[string]any { if v, ok := m[k].(map[string]any); ok { return v } v := map[string]any{} m[k] = v return v } func str(m map[string]any, k string) string { s, _ := m[k].(string) return s } // promptYesNo reads a Y/n (or y/N) answer with the given default. func promptYesNo(p auth.Prompter, w io.Writer, question string, defaultYes bool) (bool, error) { tag := "[Y/n]" if !defaultYes { tag = "[y/N]" } fmt.Fprintln(w, question) ans, err := p.ReadLine(tag + " ") if err != nil { return defaultYes, err } switch strings.ToLower(strings.TrimSpace(ans)) { case "": return defaultYes, nil case "y", "yes": return true, nil case "n", "no": return false, nil } return defaultYes, nil }