package handler import ( "encoding/json" "fmt" "net/http" "sort" "sync" "github.com/go-chi/chi/v5" "gopkg.in/yaml.v3" "github.com/anotherhadi/iknowyou/config" "github.com/anotherhadi/iknowyou/internal/respond" "github.com/anotherhadi/iknowyou/internal/tools" ) type ConfigHandler struct { configPath string factories []func() tools.ToolRunner demo bool mu sync.Mutex } func NewConfigHandler(configPath string, factories []func() tools.ToolRunner, demo bool) *ConfigHandler { return &ConfigHandler{configPath: configPath, factories: factories, demo: demo} } // GET /api/config func (h *ConfigHandler) Get(w http.ResponseWriter, r *http.Request) { cfg, err := config.Load(h.configPath) if err != nil { respond.Error(w, http.StatusInternalServerError, err.Error()) return } toolConfigs := make(map[string]any, len(cfg.Tools)) for toolName, node := range cfg.Tools { var m map[string]any if err := node.Decode(&m); err == nil { toolConfigs[toolName] = m } } respond.JSON(w, http.StatusOK, map[string]any{ "tools": toolConfigs, "profiles": cfg.Profiles, "readonly": h.demo || config.IsReadonly(h.configPath), "demo": h.demo, }) } type profileSummary struct { Name string `json:"name"` Notes string `json:"notes,omitempty"` Readonly bool `json:"readonly"` } // GET /api/config/profiles func (h *ConfigHandler) ListProfiles(w http.ResponseWriter, r *http.Request) { cfg, err := config.Load(h.configPath) if err != nil { respond.Error(w, http.StatusInternalServerError, err.Error()) return } builtinNames := make([]string, 0, len(config.BuiltinProfiles)) for name := range config.BuiltinProfiles { builtinNames = append(builtinNames, name) } sort.Strings(builtinNames) summaries := make([]profileSummary, 0, len(builtinNames)+len(cfg.Profiles)) for _, name := range builtinNames { summaries = append(summaries, profileSummary{Name: name, Notes: config.BuiltinProfiles[name].Notes, Readonly: true}) } names := make([]string, 0, len(cfg.Profiles)) for name := range cfg.Profiles { names = append(names, name) } sort.Strings(names) for _, name := range names { p := cfg.Profiles[name] summaries = append(summaries, profileSummary{Name: name, Notes: p.Notes, Readonly: false}) } respond.JSON(w, http.StatusOK, summaries) } type profileDetail struct { Name string `json:"name"` Notes string `json:"notes,omitempty"` Readonly bool `json:"readonly"` Enabled []string `json:"enabled"` Disabled []string `json:"disabled"` Tools map[string]any `json:"tools"` ActiveTools []string `json:"active_tools"` } // GET /api/config/profiles/{name} func (h *ConfigHandler) GetProfile(w http.ResponseWriter, r *http.Request) { name := chi.URLParam(r, "name") allNames := make([]string, 0, len(h.factories)) for _, factory := range h.factories { allNames = append(allNames, factory().Name()) } if builtin, ok := config.BuiltinProfiles[name]; ok { activeTools := config.ActiveToolsForProfile(builtin.Profile, allNames) if activeTools == nil { activeTools = allNames } enabled := builtin.Profile.Enabled if enabled == nil { enabled = []string{} } disabled := builtin.Profile.Disabled if disabled == nil { disabled = []string{} } respond.JSON(w, http.StatusOK, profileDetail{ Name: name, Notes: builtin.Notes, Readonly: true, Enabled: enabled, Disabled: disabled, Tools: map[string]any{}, ActiveTools: activeTools, }) return } cfg, err := config.Load(h.configPath) if err != nil { respond.Error(w, http.StatusInternalServerError, err.Error()) return } p, ok := cfg.Profiles[name] if !ok { respond.Error(w, http.StatusNotFound, "profile not found") return } toolOverrides := make(map[string]any, len(p.Tools)) for toolName, node := range p.Tools { var m map[string]any if err := node.Decode(&m); err == nil { toolOverrides[toolName] = m } } activeTools, _ := cfg.ActiveTools(name, allNames) enabled := p.Enabled if enabled == nil { enabled = []string{} } disabled := p.Disabled if disabled == nil { disabled = []string{} } respond.JSON(w, http.StatusOK, profileDetail{ Name: name, Notes: p.Notes, Readonly: false, Enabled: enabled, Disabled: disabled, Tools: toolOverrides, ActiveTools: activeTools, }) } // POST /api/config/profiles func (h *ConfigHandler) CreateProfile(w http.ResponseWriter, r *http.Request) { if h.demo { respond.Error(w, http.StatusForbidden, "demo mode: modifications are disabled") return } if config.IsReadonly(h.configPath) { respond.Error(w, http.StatusForbidden, "config is read-only") return } var req struct { Name string `json:"name"` Notes string `json:"notes"` Enabled []string `json:"enabled"` Disabled []string `json:"disabled"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { respond.Error(w, http.StatusBadRequest, "invalid JSON: "+err.Error()) return } if req.Name == "" { respond.Error(w, http.StatusBadRequest, "name is required") return } if err := validateProfileName(req.Name); err != nil { respond.Error(w, http.StatusBadRequest, err.Error()) return } if _, isBuiltin := config.BuiltinProfiles[req.Name]; isBuiltin { respond.Error(w, http.StatusForbidden, fmt.Sprintf("profile %q is reserved", req.Name)) return } h.mu.Lock() defer h.mu.Unlock() cfg, err := config.Load(h.configPath) if err != nil { respond.Error(w, http.StatusInternalServerError, err.Error()) return } if _, exists := cfg.Profiles[req.Name]; exists { respond.Error(w, http.StatusConflict, "profile already exists") return } cfg.Profiles[req.Name] = config.Profile{ Notes: req.Notes, Enabled: req.Enabled, Disabled: req.Disabled, } if err := config.Save(h.configPath, cfg); err != nil { respond.Error(w, http.StatusInternalServerError, err.Error()) return } respond.JSON(w, http.StatusCreated, cfg.Profiles[req.Name]) } // PATCH /api/config/profiles/{name} func (h *ConfigHandler) UpdateProfile(w http.ResponseWriter, r *http.Request) { if h.demo { respond.Error(w, http.StatusForbidden, "demo mode: modifications are disabled") return } if config.IsReadonly(h.configPath) { respond.Error(w, http.StatusForbidden, "config is read-only") return } name := chi.URLParam(r, "name") if _, isBuiltin := config.BuiltinProfiles[name]; isBuiltin { respond.Error(w, http.StatusForbidden, fmt.Sprintf("profile %q is read-only", name)) return } var req struct { Notes *string `json:"notes"` Enabled *[]string `json:"enabled"` Disabled *[]string `json:"disabled"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { respond.Error(w, http.StatusBadRequest, "invalid JSON: "+err.Error()) return } h.mu.Lock() defer h.mu.Unlock() cfg, err := config.Load(h.configPath) if err != nil { respond.Error(w, http.StatusInternalServerError, err.Error()) return } p, ok := cfg.Profiles[name] if !ok { respond.Error(w, http.StatusNotFound, "profile not found") return } if req.Notes != nil { p.Notes = *req.Notes } if req.Enabled != nil { p.Enabled = *req.Enabled } if req.Disabled != nil { p.Disabled = *req.Disabled } cfg.Profiles[name] = p if err := config.Save(h.configPath, cfg); err != nil { respond.Error(w, http.StatusInternalServerError, err.Error()) return } respond.JSON(w, http.StatusOK, p) } // DELETE /api/config/profiles/{name} func (h *ConfigHandler) DeleteProfile(w http.ResponseWriter, r *http.Request) { if h.demo { respond.Error(w, http.StatusForbidden, "demo mode: modifications are disabled") return } if config.IsReadonly(h.configPath) { respond.Error(w, http.StatusForbidden, "config is read-only") return } name := chi.URLParam(r, "name") if _, isBuiltin := config.BuiltinProfiles[name]; isBuiltin { respond.Error(w, http.StatusForbidden, fmt.Sprintf("profile %q is read-only", name)) return } h.mu.Lock() defer h.mu.Unlock() cfg, err := config.Load(h.configPath) if err != nil { respond.Error(w, http.StatusInternalServerError, err.Error()) return } if _, ok := cfg.Profiles[name]; !ok { respond.Error(w, http.StatusNotFound, "profile not found") return } delete(cfg.Profiles, name) if err := config.Save(h.configPath, cfg); err != nil { respond.Error(w, http.StatusInternalServerError, err.Error()) return } w.WriteHeader(http.StatusNoContent) } // PATCH /api/config/tools/{toolName} func (h *ConfigHandler) UpdateToolConfig(w http.ResponseWriter, r *http.Request) { if h.demo { respond.Error(w, http.StatusForbidden, "demo mode: modifications are disabled") return } if config.IsReadonly(h.configPath) { respond.Error(w, http.StatusForbidden, "config is read-only") return } toolName := chi.URLParam(r, "toolName") var patch map[string]any if err := json.NewDecoder(r.Body).Decode(&patch); err != nil { respond.Error(w, http.StatusBadRequest, "invalid JSON: "+err.Error()) return } if err := h.validatePatch(toolName, patch); err != nil { respond.Error(w, http.StatusBadRequest, err.Error()) return } h.mu.Lock() defer h.mu.Unlock() cfg, err := config.Load(h.configPath) if err != nil { respond.Error(w, http.StatusInternalServerError, err.Error()) return } merged, err := config.MergeNodePatch(cfg.Tools[toolName], patch) if err != nil { respond.Error(w, http.StatusInternalServerError, err.Error()) return } cfg.Tools[toolName] = merged if err := config.Save(h.configPath, cfg); err != nil { respond.Error(w, http.StatusInternalServerError, err.Error()) return } var result map[string]any _ = merged.Decode(&result) respond.JSON(w, http.StatusOK, result) } // DELETE /api/config/tools/{toolName} func (h *ConfigHandler) DeleteToolConfig(w http.ResponseWriter, r *http.Request) { if h.demo { respond.Error(w, http.StatusForbidden, "demo mode: modifications are disabled") return } if config.IsReadonly(h.configPath) { respond.Error(w, http.StatusForbidden, "config is read-only") return } toolName := chi.URLParam(r, "toolName") h.mu.Lock() defer h.mu.Unlock() cfg, err := config.Load(h.configPath) if err != nil { respond.Error(w, http.StatusInternalServerError, err.Error()) return } if _, ok := cfg.Tools[toolName]; !ok { respond.Error(w, http.StatusNotFound, "no global config for this tool") return } delete(cfg.Tools, toolName) if err := config.Save(h.configPath, cfg); err != nil { respond.Error(w, http.StatusInternalServerError, err.Error()) return } w.WriteHeader(http.StatusNoContent) } // PATCH /api/config/profiles/{name}/tools/{toolName} func (h *ConfigHandler) UpdateProfileToolConfig(w http.ResponseWriter, r *http.Request) { if h.demo { respond.Error(w, http.StatusForbidden, "demo mode: modifications are disabled") return } if config.IsReadonly(h.configPath) { respond.Error(w, http.StatusForbidden, "config is read-only") return } name := chi.URLParam(r, "name") toolName := chi.URLParam(r, "toolName") if _, isBuiltin := config.BuiltinProfiles[name]; isBuiltin { respond.Error(w, http.StatusForbidden, fmt.Sprintf("profile %q is read-only", name)) return } var patch map[string]any if err := json.NewDecoder(r.Body).Decode(&patch); err != nil { respond.Error(w, http.StatusBadRequest, "invalid JSON: "+err.Error()) return } if err := h.validatePatch(toolName, patch); err != nil { respond.Error(w, http.StatusBadRequest, err.Error()) return } h.mu.Lock() defer h.mu.Unlock() cfg, err := config.Load(h.configPath) if err != nil { respond.Error(w, http.StatusInternalServerError, err.Error()) return } p, ok := cfg.Profiles[name] if !ok { respond.Error(w, http.StatusNotFound, "profile not found") return } if p.Tools == nil { p.Tools = make(map[string]yaml.Node) } merged, err := config.MergeNodePatch(p.Tools[toolName], patch) if err != nil { respond.Error(w, http.StatusInternalServerError, err.Error()) return } p.Tools[toolName] = merged cfg.Profiles[name] = p if err := config.Save(h.configPath, cfg); err != nil { respond.Error(w, http.StatusInternalServerError, err.Error()) return } var result map[string]any _ = merged.Decode(&result) respond.JSON(w, http.StatusOK, result) } // DELETE /api/config/profiles/{name}/tools/{toolName} func (h *ConfigHandler) DeleteProfileToolConfig(w http.ResponseWriter, r *http.Request) { if h.demo { respond.Error(w, http.StatusForbidden, "demo mode: modifications are disabled") return } if config.IsReadonly(h.configPath) { respond.Error(w, http.StatusForbidden, "config is read-only") return } name := chi.URLParam(r, "name") toolName := chi.URLParam(r, "toolName") if _, isBuiltin := config.BuiltinProfiles[name]; isBuiltin { respond.Error(w, http.StatusForbidden, fmt.Sprintf("profile %q is read-only", name)) return } h.mu.Lock() defer h.mu.Unlock() cfg, err := config.Load(h.configPath) if err != nil { respond.Error(w, http.StatusInternalServerError, err.Error()) return } p, ok := cfg.Profiles[name] if !ok { respond.Error(w, http.StatusNotFound, "profile not found") return } if _, ok := p.Tools[toolName]; !ok { respond.Error(w, http.StatusNotFound, "no config override for this tool in profile") return } delete(p.Tools, toolName) cfg.Profiles[name] = p if err := config.Save(h.configPath, cfg); err != nil { respond.Error(w, http.StatusInternalServerError, err.Error()) return } w.WriteHeader(http.StatusNoContent) } func validateProfileName(name string) error { for _, c := range name { if !((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-') { return fmt.Errorf("profile name must contain only lowercase letters (a-z), digits (0-9), and hyphens (-)") } } return nil } func (h *ConfigHandler) validatePatch(toolName string, patch map[string]any) error { var fields []tools.ConfigField for _, factory := range h.factories { t := factory() if t.Name() == toolName { if d, ok := t.(tools.ConfigDescriber); ok { fields = d.ConfigFields() } break } } if len(fields) == 0 { return nil } fieldMap := make(map[string]tools.ConfigField, len(fields)) for _, f := range fields { fieldMap[f.Name] = f } for key, val := range patch { f, ok := fieldMap[key] if !ok { continue } if err := validateFieldValue(f, val); err != nil { return fmt.Errorf("field %q: %w", key, err) } } return nil } func validateFieldValue(f tools.ConfigField, val any) error { if val == nil { return nil } switch f.Type { case "string": if _, ok := val.(string); !ok { return fmt.Errorf("expected string, got %T", val) } case "bool": if _, ok := val.(bool); !ok { return fmt.Errorf("expected bool, got %T", val) } case "int": switch v := val.(type) { case float64: if v != float64(int64(v)) { return fmt.Errorf("expected integer, got float") } default: return fmt.Errorf("expected int, got %T", val) } case "float": if _, ok := val.(float64); !ok { return fmt.Errorf("expected number, got %T", val) } case "enum": s, ok := val.(string) if !ok { return fmt.Errorf("expected string, got %T", val) } for _, opt := range f.Options { if s == opt { return nil } } return fmt.Errorf("invalid value %q, must be one of: %v", s, f.Options) } return nil }