vikunja/veans/internal/bootstrap/hooks.go

322 lines
10 KiB
Go

// 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 <https://www.gnu.org/licenses/>.
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<unknown> }) =>
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) `<repoRoot>/.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.<event> 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 `<repoRoot>/.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
}