From 084756c9214d695d5515a9bae9727c99723e3c1a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= Date: Tue, 13 Jan 2026 00:45:10 +0000 Subject: [PATCH] feat: agents discovery --- AGENTS.md | 4 + Makefile | 9 +- apps/backend/cmd/kandev/main.go | 19 + .../internal/agent/discovery/agents.json | 99 ++++ .../internal/agent/discovery/discovery.go | 161 +++++++ .../agent/settings/controller/controller.go | 300 ++++++++++++ .../internal/agent/settings/dto/dto.go | 45 ++ .../agent/settings/handlers/handlers.go | 269 +++++++++++ .../internal/agent/settings/models/models.go | 25 + .../internal/agent/settings/store/sqlite.go | 317 +++++++++++++ .../internal/agent/settings/store/store.go | 24 + apps/backend/pkg/websocket/actions.go | 4 + apps/web/app/actions/agents.ts | 102 +++++ apps/web/app/settings/agent/[id]/page.tsx | 254 +--------- .../app/settings/agents/[agentId]/page.tsx | 432 ++++++++++++++++++ .../[agentId]/profiles/[profileId]/page.tsx | 252 ++++++++++ apps/web/app/settings/agents/page.tsx | 325 +++++-------- apps/web/app/task/[id]/page-client.tsx | 4 +- apps/web/components/settings/agent-card.tsx | 2 +- .../settings/settings-app-sidebar.tsx | 51 ++- .../settings/settings-layout-client.tsx | 10 +- apps/web/components/task-create-dialog.tsx | 171 +++++++ .../components/task/task-changes-panel.tsx | 1 - apps/web/components/task/task-chat-panel.tsx | 7 +- apps/web/lib/http/client.ts | 2 + apps/web/lib/state/store.ts | 34 +- apps/web/lib/types/backend.ts | 17 +- apps/web/lib/types/http.ts | 42 ++ apps/web/lib/ws/router.ts | 9 + 29 files changed, 2522 insertions(+), 469 deletions(-) create mode 100644 apps/backend/internal/agent/discovery/agents.json create mode 100644 apps/backend/internal/agent/discovery/discovery.go create mode 100644 apps/backend/internal/agent/settings/controller/controller.go create mode 100644 apps/backend/internal/agent/settings/dto/dto.go create mode 100644 apps/backend/internal/agent/settings/handlers/handlers.go create mode 100644 apps/backend/internal/agent/settings/models/models.go create mode 100644 apps/backend/internal/agent/settings/store/sqlite.go create mode 100644 apps/backend/internal/agent/settings/store/store.go create mode 100644 apps/web/app/actions/agents.ts create mode 100644 apps/web/app/settings/agents/[agentId]/page.tsx create mode 100644 apps/web/app/settings/agents/[agentId]/profiles/[profileId]/page.tsx diff --git a/AGENTS.md b/AGENTS.md index 853d8f400b..4be424c9d8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -869,6 +869,10 @@ Prior versions used a simpler JSON format (not JSON-RPC). For compatibility refe 6. **Validate messages** - Check for required fields before processing 7. **Set exit codes** - 0 for success, non-zero for errors +## Local Agent Discovery + +Kandev checks a bundled discovery config to detect locally installed agents. Each agent entry lists OS-specific installation paths (Linux, Windows, macOS). At runtime, the backend selects the paths for the current OS, expands environment variables and `~`, and marks the agent as available if any configured file or directory exists. + ### Message Flow ``` diff --git a/Makefile b/Makefile index 85524af6ad..fd0b752dc2 100644 --- a/Makefile +++ b/Makefile @@ -75,7 +75,14 @@ dev: @echo "Backend: http://localhost:8080" @echo "Web app: http://localhost:3000" @echo "" - @$(MAKE) -C $(BACKEND_DIR) run & cd $(APPS_DIR) && $(PNPM) --filter @kandev/web dev + @bash -c 'set -euo pipefail; \ + $(MAKE) -C $(BACKEND_DIR) run & backend_pid=$$!; \ + cd $(APPS_DIR); $(PNPM) --filter @kandev/web dev & web_pid=$$!; \ + cleanup() { kill $$backend_pid $$web_pid 2>/dev/null || true; }; \ + trap cleanup EXIT INT TERM; \ + wait -n $$backend_pid $$web_pid; status=$$?; \ + if [ $$status -eq 0 ]; then status=1; fi; \ + exit $$status' .PHONY: dev-backend dev-backend: diff --git a/apps/backend/cmd/kandev/main.go b/apps/backend/cmd/kandev/main.go index 65b042e710..a618ec881e 100644 --- a/apps/backend/cmd/kandev/main.go +++ b/apps/backend/cmd/kandev/main.go @@ -29,10 +29,14 @@ import ( // Agent Manager packages agentcontroller "github.com/kandev/kandev/internal/agent/controller" "github.com/kandev/kandev/internal/agent/credentials" + "github.com/kandev/kandev/internal/agent/discovery" "github.com/kandev/kandev/internal/agent/docker" agenthandlers "github.com/kandev/kandev/internal/agent/handlers" "github.com/kandev/kandev/internal/agent/lifecycle" "github.com/kandev/kandev/internal/agent/registry" + agentsettingscontroller "github.com/kandev/kandev/internal/agent/settings/controller" + agentsettingshandlers "github.com/kandev/kandev/internal/agent/settings/handlers" + settingsstore "github.com/kandev/kandev/internal/agent/settings/store" "github.com/kandev/kandev/internal/agent/worktree" // Orchestrator packages @@ -130,6 +134,18 @@ func main() { defer taskRepo.Close() log.Info("SQLite database initialized", zap.String("db_path", dbPath)) + agentSettingsRepo, err := settingsstore.NewSQLiteRepository(dbPath) + if err != nil { + log.Fatal("Failed to initialize agent settings store", zap.Error(err), zap.String("db_path", dbPath)) + } + defer agentSettingsRepo.Close() + + discoveryRegistry, err := discovery.LoadRegistry() + if err != nil { + log.Fatal("Failed to load agent discovery config", zap.Error(err)) + } + agentSettingsController := agentsettingscontroller.NewController(agentSettingsRepo, discoveryRegistry, log) + taskSvc := taskservice.NewService( taskRepo, eventBus, @@ -514,6 +530,9 @@ func main() { taskhandlers.RegisterCommentRoutes(router, gateway.Dispatcher, taskSvc, &orchestratorAdapter{svc: orchestratorSvc}, log) log.Info("Registered Task Service handlers (HTTP + WebSocket)") + agentsettingshandlers.RegisterRoutes(router, agentSettingsController, gateway.Hub, log) + log.Info("Registered Agent Settings handlers (HTTP)") + // Health check (simple HTTP for load balancers/monitoring) router.GET("/health", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{ diff --git a/apps/backend/internal/agent/discovery/agents.json b/apps/backend/internal/agent/discovery/agents.json new file mode 100644 index 0000000000..ed3f37525f --- /dev/null +++ b/apps/backend/internal/agent/discovery/agents.json @@ -0,0 +1,99 @@ +{ + "agents": [ + { + "name": "claude", + "supports_mcp": false, + "mcp_config_path": { + "linux": [], + "windows": [], + "macos": [] + }, + "installation_path": { + "linux": ["~/.claude.json"], + "windows": ["~/.claude.json"], + "macos": ["~/.claude.json"] + } + }, + { + "name": "gemini", + "supports_mcp": false, + "mcp_config_path": { + "linux": [], + "windows": [], + "macos": [] + }, + "installation_path": { + "linux": ["~/.gemini/oauth_creds.json"], + "windows": ["~/.gemini/oauth_creds.json"], + "macos": ["~/.gemini/oauth_creds.json"] + } + }, + { + "name": "codex", + "supports_mcp": false, + "mcp_config_path": { + "linux": [], + "windows": [], + "macos": [] + }, + "installation_path": { + "linux": ["~/.codex/auth.json"], + "windows": ["~/.codex/auth.json"], + "macos": ["~/.codex/auth.json"] + } + }, + { + "name": "auggie", + "supports_mcp": false, + "mcp_config_path": { + "linux": [], + "windows": [], + "macos": [] + }, + "installation_path": { + "linux": ["~/.augment/.auggie.json"], + "windows": ["~/.augment/.auggie.json"], + "macos": ["~/.augment/.auggie.json"] + } + }, + { + "name": "opencode", + "supports_mcp": false, + "mcp_config_path": { + "linux": [], + "windows": [], + "macos": [] + }, + "installation_path": { + "linux": [ + "$XDG_CONFIG_HOME/opencode/opencode.json", + "~/.config/opencode/opencode.json", + "$XDG_CONFIG_HOME/opencode", + "~/.config/opencode" + ], + "windows": [ + "~/AppData/Roaming/opencode/opencode.json", + "~/AppData/Roaming/opencode" + ], + "macos": [ + "~/Library/Application Support/opencode/opencode.json", + "~/Library/Application Support/opencode" + ] + } + }, + { + "name": "copilot", + "supports_mcp": true, + "mcp_config_path": { + "linux": ["~/.copilot/mcp-config.json"], + "windows": ["~/.copilot/mcp-config.json"], + "macos": ["~/.copilot/mcp-config.json"] + }, + "installation_path": { + "linux": ["~/.copilot/mcp-config.json", "~/.copilot/config.json"], + "windows": ["~/.copilot/mcp-config.json", "~/.copilot/config.json"], + "macos": ["~/.copilot/mcp-config.json", "~/.copilot/config.json"] + } + } + ] +} diff --git a/apps/backend/internal/agent/discovery/discovery.go b/apps/backend/internal/agent/discovery/discovery.go new file mode 100644 index 0000000000..ed8e4b71d9 --- /dev/null +++ b/apps/backend/internal/agent/discovery/discovery.go @@ -0,0 +1,161 @@ +package discovery + +import ( + "context" + "embed" + "encoding/json" + "fmt" + "os" + "path/filepath" + "runtime" + "strings" +) + +//go:embed agents.json +var agentsFS embed.FS + +type OSPaths struct { + Linux []string `json:"linux"` + Windows []string `json:"windows"` + MacOS []string `json:"macos"` +} + +func (p OSPaths) ForOS(goos string) []string { + switch goos { + case "windows": + return p.Windows + case "darwin": + return p.MacOS + default: + return p.Linux + } +} + +type KnownAgent struct { + Name string `json:"name"` + SupportsMCP bool `json:"supports_mcp"` + MCPConfigPath OSPaths `json:"mcp_config_path"` + InstallationPath OSPaths `json:"installation_path"` +} + +type Config struct { + Agents []KnownAgent `json:"agents"` +} + +type Availability struct { + Name string `json:"name"` + SupportsMCP bool `json:"supports_mcp"` + MCPConfigPath string `json:"mcp_config_path,omitempty"` + InstallationPaths []string `json:"installation_paths,omitempty"` + Available bool `json:"available"` + MatchedPath string `json:"matched_path,omitempty"` +} + +type Adapter interface { + Detect(ctx context.Context) (Availability, error) +} + +type Registry struct { + adapters []Adapter +} + +func LoadRegistry() (*Registry, error) { + data, err := agentsFS.ReadFile("agents.json") + if err != nil { + return nil, fmt.Errorf("read agents config: %w", err) + } + var cfg Config + if err := json.Unmarshal(data, &cfg); err != nil { + return nil, fmt.Errorf("parse agents config: %w", err) + } + adapters := make([]Adapter, 0, len(cfg.Agents)) + for _, agent := range cfg.Agents { + adapters = append(adapters, NewFilePresenceAdapter(agent)) + } + return &Registry{adapters: adapters}, nil +} + +func (r *Registry) Detect(ctx context.Context) ([]Availability, error) { + results := make([]Availability, 0, len(r.adapters)) + for _, adapter := range r.adapters { + availability, err := adapter.Detect(ctx) + if err != nil { + return nil, err + } + results = append(results, availability) + } + return results, nil +} + +type FilePresenceAdapter struct { + definition KnownAgent +} + +func NewFilePresenceAdapter(def KnownAgent) *FilePresenceAdapter { + return &FilePresenceAdapter{definition: def} +} + +func (a *FilePresenceAdapter) Detect(ctx context.Context) (Availability, error) { + _ = ctx + paths := resolvePaths(a.definition.InstallationPath.ForOS(runtime.GOOS)) + mcpPaths := resolvePaths(a.definition.MCPConfigPath.ForOS(runtime.GOOS)) + available, matched := anyPathExists(paths) + mcpPath := "" + if len(mcpPaths) > 0 { + mcpPath = mcpPaths[0] + } + return Availability{ + Name: a.definition.Name, + SupportsMCP: a.definition.SupportsMCP, + MCPConfigPath: mcpPath, + InstallationPaths: paths, + Available: available, + MatchedPath: matched, + }, nil +} + +func resolvePaths(paths []string) []string { + resolved := make([]string, 0, len(paths)) + for _, rawPath := range paths { + expanded := expandPath(rawPath) + if expanded == "" { + continue + } + resolved = append(resolved, expanded) + } + return resolved +} + +func expandPath(path string) string { + if path == "" { + return "" + } + if strings.Contains(path, "$XDG_CONFIG_HOME") || strings.Contains(path, "${XDG_CONFIG_HOME}") { + if _, ok := os.LookupEnv("XDG_CONFIG_HOME"); !ok { + return "" + } + } + expanded := os.ExpandEnv(path) + if strings.HasPrefix(expanded, "~") { + home, err := os.UserHomeDir() + if err == nil { + expanded = filepath.Join(home, strings.TrimPrefix(expanded, "~")) + } + } + if expanded == "" { + return "" + } + return filepath.Clean(filepath.FromSlash(expanded)) +} + +func anyPathExists(paths []string) (bool, string) { + for _, candidate := range paths { + if candidate == "" { + continue + } + if _, err := os.Stat(candidate); err == nil { + return true, candidate + } + } + return false, "" +} diff --git a/apps/backend/internal/agent/settings/controller/controller.go b/apps/backend/internal/agent/settings/controller/controller.go new file mode 100644 index 0000000000..0b14ec0245 --- /dev/null +++ b/apps/backend/internal/agent/settings/controller/controller.go @@ -0,0 +1,300 @@ +package controller + +import ( + "context" + "database/sql" + "errors" + "fmt" + "strings" + + "github.com/kandev/kandev/internal/agent/discovery" + "github.com/kandev/kandev/internal/agent/settings/dto" + "github.com/kandev/kandev/internal/agent/settings/models" + "github.com/kandev/kandev/internal/agent/settings/store" + "github.com/kandev/kandev/internal/common/logger" + "go.uber.org/zap" +) + +var ( + ErrAgentNotFound = errors.New("agent not found") + ErrAgentProfileNotFound = errors.New("agent profile not found") +) + +type Controller struct { + repo store.Repository + discovery *discovery.Registry + logger *logger.Logger +} + +func NewController(repo store.Repository, discoveryRegistry *discovery.Registry, log *logger.Logger) *Controller { + return &Controller{ + repo: repo, + discovery: discoveryRegistry, + logger: log.WithFields(zap.String("component", "agent-settings-controller")), + } +} + +func (c *Controller) ListDiscovery(ctx context.Context) (*dto.ListDiscoveryResponse, error) { + results, err := c.discovery.Detect(ctx) + if err != nil { + return nil, err + } + payload := make([]dto.AgentDiscoveryDTO, 0, len(results)) + for _, result := range results { + payload = append(payload, dto.AgentDiscoveryDTO{ + Name: result.Name, + SupportsMCP: result.SupportsMCP, + MCPConfigPath: result.MCPConfigPath, + InstallationPaths: result.InstallationPaths, + Available: result.Available, + MatchedPath: result.MatchedPath, + }) + } + return &dto.ListDiscoveryResponse{Agents: payload, Total: len(payload)}, nil +} + +func (c *Controller) ListAgents(ctx context.Context) (*dto.ListAgentsResponse, error) { + agents, err := c.repo.ListAgents(ctx) + if err != nil { + return nil, err + } + payload := make([]dto.AgentDTO, 0, len(agents)) + for _, agent := range agents { + profiles, err := c.repo.ListAgentProfiles(ctx, agent.ID) + if err != nil { + return nil, err + } + payload = append(payload, toAgentDTO(agent, profiles)) + } + return &dto.ListAgentsResponse{Agents: payload, Total: len(payload)}, nil +} + +func (c *Controller) GetAgent(ctx context.Context, id string) (*dto.AgentDTO, error) { + agent, err := c.repo.GetAgent(ctx, id) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrAgentNotFound + } + return nil, err + } + profiles, err := c.repo.ListAgentProfiles(ctx, agent.ID) + if err != nil { + return nil, err + } + result := toAgentDTO(agent, profiles) + return &result, nil +} + +type CreateAgentRequest struct { + Name string + WorkspaceID *string + Profiles []CreateAgentProfileRequest +} + +type CreateAgentProfileRequest struct { + Name string + Model string + AutoApprove bool + DangerouslySkipPermissions bool + Plan string +} + +func (c *Controller) CreateAgent(ctx context.Context, req CreateAgentRequest) (*dto.AgentDTO, error) { + if req.Name == "" { + return nil, fmt.Errorf("name is required") + } + existing, err := c.repo.GetAgentByName(ctx, req.Name) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return nil, err + } + if err == nil && existing != nil { + return nil, fmt.Errorf("agent already configured: %s", req.Name) + } + discoveryResults, err := c.discovery.Detect(ctx) + if err != nil { + return nil, err + } + var matched *discovery.Availability + for _, result := range discoveryResults { + if result.Name == req.Name { + matched = &result + break + } + } + if matched == nil { + return nil, fmt.Errorf("unknown agent: %s", req.Name) + } + if !matched.Available { + return nil, fmt.Errorf("agent not installed: %s", req.Name) + } + agent := &models.Agent{ + Name: matched.Name, + WorkspaceID: req.WorkspaceID, + SupportsMCP: matched.SupportsMCP, + MCPConfigPath: matched.MCPConfigPath, + } + if err := c.repo.CreateAgent(ctx, agent); err != nil { + return nil, err + } + profiles := make([]*models.AgentProfile, 0, len(req.Profiles)) + for _, profileReq := range req.Profiles { + profile := &models.AgentProfile{ + AgentID: agent.ID, + Name: profileReq.Name, + Model: profileReq.Model, + AutoApprove: profileReq.AutoApprove, + DangerouslySkipPermissions: profileReq.DangerouslySkipPermissions, + Plan: profileReq.Plan, + } + if err := c.repo.CreateAgentProfile(ctx, profile); err != nil { + return nil, err + } + profiles = append(profiles, profile) + } + result := toAgentDTO(agent, profiles) + return &result, nil +} + +type UpdateAgentRequest struct { + ID string + WorkspaceID *string + SupportsMCP *bool + MCPConfigPath *string +} + +func (c *Controller) UpdateAgent(ctx context.Context, req UpdateAgentRequest) (*dto.AgentDTO, error) { + agent, err := c.repo.GetAgent(ctx, req.ID) + if err != nil { + return nil, ErrAgentNotFound + } + if req.WorkspaceID != nil { + agent.WorkspaceID = req.WorkspaceID + } + if req.SupportsMCP != nil { + agent.SupportsMCP = *req.SupportsMCP + } + if req.MCPConfigPath != nil { + agent.MCPConfigPath = *req.MCPConfigPath + } + if err := c.repo.UpdateAgent(ctx, agent); err != nil { + return nil, err + } + profiles, err := c.repo.ListAgentProfiles(ctx, agent.ID) + if err != nil { + return nil, err + } + result := toAgentDTO(agent, profiles) + return &result, nil +} + +func (c *Controller) DeleteAgent(ctx context.Context, id string) error { + if err := c.repo.DeleteAgent(ctx, id); err != nil { + if strings.Contains(err.Error(), "agent not found") { + return ErrAgentNotFound + } + return err + } + return nil +} + +type CreateProfileRequest struct { + AgentID string + Name string + Model string + AutoApprove bool + DangerouslySkipPermissions bool + Plan string +} + +func (c *Controller) CreateProfile(ctx context.Context, req CreateProfileRequest) (*dto.AgentProfileDTO, error) { + profile := &models.AgentProfile{ + AgentID: req.AgentID, + Name: req.Name, + Model: req.Model, + AutoApprove: req.AutoApprove, + DangerouslySkipPermissions: req.DangerouslySkipPermissions, + Plan: req.Plan, + } + if err := c.repo.CreateAgentProfile(ctx, profile); err != nil { + return nil, err + } + result := toProfileDTO(profile) + return &result, nil +} + +type UpdateProfileRequest struct { + ID string + Name *string + Model *string + AutoApprove *bool + DangerouslySkipPermissions *bool + Plan *string +} + +func (c *Controller) UpdateProfile(ctx context.Context, req UpdateProfileRequest) (*dto.AgentProfileDTO, error) { + profile, err := c.repo.GetAgentProfile(ctx, req.ID) + if err != nil { + return nil, ErrAgentProfileNotFound + } + if req.Name != nil { + profile.Name = *req.Name + } + if req.Model != nil { + profile.Model = *req.Model + } + if req.AutoApprove != nil { + profile.AutoApprove = *req.AutoApprove + } + if req.DangerouslySkipPermissions != nil { + profile.DangerouslySkipPermissions = *req.DangerouslySkipPermissions + } + if req.Plan != nil { + profile.Plan = *req.Plan + } + if err := c.repo.UpdateAgentProfile(ctx, profile); err != nil { + return nil, err + } + result := toProfileDTO(profile) + return &result, nil +} + +func (c *Controller) DeleteProfile(ctx context.Context, id string) error { + if err := c.repo.DeleteAgentProfile(ctx, id); err != nil { + if strings.Contains(err.Error(), "agent profile not found") { + return ErrAgentProfileNotFound + } + return err + } + return nil +} + +func toAgentDTO(agent *models.Agent, profiles []*models.AgentProfile) dto.AgentDTO { + profileDTOs := make([]dto.AgentProfileDTO, 0, len(profiles)) + for _, profile := range profiles { + profileDTOs = append(profileDTOs, toProfileDTO(profile)) + } + return dto.AgentDTO{ + ID: agent.ID, + Name: agent.Name, + WorkspaceID: agent.WorkspaceID, + SupportsMCP: agent.SupportsMCP, + MCPConfigPath: agent.MCPConfigPath, + Profiles: profileDTOs, + CreatedAt: agent.CreatedAt, + UpdatedAt: agent.UpdatedAt, + } +} + +func toProfileDTO(profile *models.AgentProfile) dto.AgentProfileDTO { + return dto.AgentProfileDTO{ + ID: profile.ID, + AgentID: profile.AgentID, + Name: profile.Name, + Model: profile.Model, + AutoApprove: profile.AutoApprove, + DangerouslySkipPermissions: profile.DangerouslySkipPermissions, + Plan: profile.Plan, + CreatedAt: profile.CreatedAt, + UpdatedAt: profile.UpdatedAt, + } +} diff --git a/apps/backend/internal/agent/settings/dto/dto.go b/apps/backend/internal/agent/settings/dto/dto.go new file mode 100644 index 0000000000..cd16b650e0 --- /dev/null +++ b/apps/backend/internal/agent/settings/dto/dto.go @@ -0,0 +1,45 @@ +package dto + +import "time" + +type AgentProfileDTO struct { + ID string `json:"id"` + AgentID string `json:"agent_id"` + Name string `json:"name"` + Model string `json:"model"` + AutoApprove bool `json:"auto_approve"` + DangerouslySkipPermissions bool `json:"dangerously_skip_permissions"` + Plan string `json:"plan"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type AgentDTO struct { + ID string `json:"id"` + Name string `json:"name"` + WorkspaceID *string `json:"workspace_id,omitempty"` + SupportsMCP bool `json:"supports_mcp"` + MCPConfigPath string `json:"mcp_config_path,omitempty"` + Profiles []AgentProfileDTO `json:"profiles"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type ListAgentsResponse struct { + Agents []AgentDTO `json:"agents"` + Total int `json:"total"` +} + +type AgentDiscoveryDTO struct { + Name string `json:"name"` + SupportsMCP bool `json:"supports_mcp"` + MCPConfigPath string `json:"mcp_config_path,omitempty"` + InstallationPaths []string `json:"installation_paths,omitempty"` + Available bool `json:"available"` + MatchedPath string `json:"matched_path,omitempty"` +} + +type ListDiscoveryResponse struct { + Agents []AgentDiscoveryDTO `json:"agents"` + Total int `json:"total"` +} diff --git a/apps/backend/internal/agent/settings/handlers/handlers.go b/apps/backend/internal/agent/settings/handlers/handlers.go new file mode 100644 index 0000000000..46a0b7c373 --- /dev/null +++ b/apps/backend/internal/agent/settings/handlers/handlers.go @@ -0,0 +1,269 @@ +package handlers + +import ( + "net/http" + + "github.com/gin-gonic/gin" + + "github.com/kandev/kandev/internal/agent/settings/controller" + "github.com/kandev/kandev/internal/common/logger" + ws "github.com/kandev/kandev/pkg/websocket" + "go.uber.org/zap" +) + +type Handlers struct { + controller *controller.Controller + hub Broadcaster + logger *logger.Logger +} + +type Broadcaster interface { + Broadcast(msg *ws.Message) +} + +func NewHandlers(ctrl *controller.Controller, hub Broadcaster, log *logger.Logger) *Handlers { + return &Handlers{ + controller: ctrl, + hub: hub, + logger: log.WithFields(zap.String("component", "agent-settings-handlers")), + } +} + +func RegisterRoutes(router *gin.Engine, ctrl *controller.Controller, hub Broadcaster, log *logger.Logger) { + handlers := NewHandlers(ctrl, hub, log) + handlers.registerHTTP(router) +} + +func (h *Handlers) registerHTTP(router *gin.Engine) { + api := router.Group("/api/v1") + api.GET("/agents/discovery", h.httpDiscoverAgents) + api.GET("/agents", h.httpListAgents) + api.POST("/agents", h.httpCreateAgent) + api.GET("/agents/:id", h.httpGetAgent) + api.PATCH("/agents/:id", h.httpUpdateAgent) + api.DELETE("/agents/:id", h.httpDeleteAgent) + api.POST("/agents/:id/profiles", h.httpCreateProfile) + api.PATCH("/agent-profiles/:id", h.httpUpdateProfile) + api.DELETE("/agent-profiles/:id", h.httpDeleteProfile) +} + +func (h *Handlers) httpDiscoverAgents(c *gin.Context) { + resp, err := h.controller.ListDiscovery(c.Request.Context()) + if err != nil { + h.logger.Error("failed to discover agents", zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to discover agents"}) + return + } + c.JSON(http.StatusOK, resp) +} + +func (h *Handlers) httpListAgents(c *gin.Context) { + resp, err := h.controller.ListAgents(c.Request.Context()) + if err != nil { + h.logger.Error("failed to list agents", zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list agents"}) + return + } + c.JSON(http.StatusOK, resp) +} + +type createAgentRequest struct { + Name string `json:"name"` + WorkspaceID *string `json:"workspace_id,omitempty"` + Profiles []createAgentProfileRequest `json:"profiles,omitempty"` +} + +type createAgentProfileRequest struct { + Name string `json:"name"` + Model string `json:"model"` + AutoApprove bool `json:"auto_approve"` + DangerouslySkipPermissions bool `json:"dangerously_skip_permissions"` + Plan string `json:"plan"` +} + +func (h *Handlers) httpCreateAgent(c *gin.Context) { + var body createAgentRequest + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"}) + return + } + if body.Name == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"}) + return + } + profiles := make([]controller.CreateAgentProfileRequest, 0, len(body.Profiles)) + for _, profile := range body.Profiles { + profiles = append(profiles, controller.CreateAgentProfileRequest{ + Name: profile.Name, + Model: profile.Model, + AutoApprove: profile.AutoApprove, + DangerouslySkipPermissions: profile.DangerouslySkipPermissions, + Plan: profile.Plan, + }) + } + resp, err := h.controller.CreateAgent(c.Request.Context(), controller.CreateAgentRequest{ + Name: body.Name, + WorkspaceID: body.WorkspaceID, + Profiles: profiles, + }) + if err != nil { + h.logger.Error("failed to create agent", zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, resp) +} + +func (h *Handlers) httpGetAgent(c *gin.Context) { + resp, err := h.controller.GetAgent(c.Request.Context(), c.Param("id")) + if err != nil { + if err == controller.ErrAgentNotFound { + c.JSON(http.StatusNotFound, gin.H{"error": "agent not found"}) + return + } + h.logger.Error("failed to get agent", zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get agent"}) + return + } + c.JSON(http.StatusOK, resp) +} + +type updateAgentRequest struct { + WorkspaceID *string `json:"workspace_id,omitempty"` + SupportsMCP *bool `json:"supports_mcp,omitempty"` + MCPConfigPath *string `json:"mcp_config_path,omitempty"` +} + +func (h *Handlers) httpUpdateAgent(c *gin.Context) { + var body updateAgentRequest + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"}) + return + } + resp, err := h.controller.UpdateAgent(c.Request.Context(), controller.UpdateAgentRequest{ + ID: c.Param("id"), + WorkspaceID: body.WorkspaceID, + SupportsMCP: body.SupportsMCP, + MCPConfigPath: body.MCPConfigPath, + }) + if err != nil { + if err == controller.ErrAgentNotFound { + c.JSON(http.StatusNotFound, gin.H{"error": "agent not found"}) + return + } + h.logger.Error("failed to update agent", zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update agent"}) + return + } + c.JSON(http.StatusOK, resp) +} + +func (h *Handlers) httpDeleteAgent(c *gin.Context) { + if err := h.controller.DeleteAgent(c.Request.Context(), c.Param("id")); err != nil { + if err == controller.ErrAgentNotFound { + c.JSON(http.StatusNotFound, gin.H{"error": "agent not found"}) + return + } + h.logger.Error("failed to delete agent", zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete agent"}) + return + } + c.JSON(http.StatusOK, gin.H{"success": true}) +} + +type createProfileRequest struct { + Name string `json:"name"` + Model string `json:"model"` + AutoApprove bool `json:"auto_approve"` + DangerouslySkipPermissions bool `json:"dangerously_skip_permissions"` + Plan string `json:"plan"` +} + +func (h *Handlers) httpCreateProfile(c *gin.Context) { + var body createProfileRequest + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"}) + return + } + resp, err := h.controller.CreateProfile(c.Request.Context(), controller.CreateProfileRequest{ + AgentID: c.Param("id"), + Name: body.Name, + Model: body.Model, + AutoApprove: body.AutoApprove, + DangerouslySkipPermissions: body.DangerouslySkipPermissions, + Plan: body.Plan, + }) + if err != nil { + h.logger.Error("failed to create profile", zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create profile"}) + return + } + if h.hub != nil { + notification, _ := ws.NewNotification(ws.ActionAgentProfileCreated, gin.H{ + "profile_id": resp.ID, + "agent_id": resp.AgentID, + }) + h.hub.Broadcast(notification) + } + c.JSON(http.StatusOK, resp) +} + +type updateProfileRequest struct { + Name *string `json:"name,omitempty"` + Model *string `json:"model,omitempty"` + AutoApprove *bool `json:"auto_approve,omitempty"` + DangerouslySkipPermissions *bool `json:"dangerously_skip_permissions,omitempty"` + Plan *string `json:"plan,omitempty"` +} + +func (h *Handlers) httpUpdateProfile(c *gin.Context) { + var body updateProfileRequest + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"}) + return + } + resp, err := h.controller.UpdateProfile(c.Request.Context(), controller.UpdateProfileRequest{ + ID: c.Param("id"), + Name: body.Name, + Model: body.Model, + AutoApprove: body.AutoApprove, + DangerouslySkipPermissions: body.DangerouslySkipPermissions, + Plan: body.Plan, + }) + if err != nil { + if err == controller.ErrAgentProfileNotFound { + c.JSON(http.StatusNotFound, gin.H{"error": "agent profile not found"}) + return + } + h.logger.Error("failed to update profile", zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update profile"}) + return + } + if h.hub != nil { + notification, _ := ws.NewNotification(ws.ActionAgentProfileUpdated, gin.H{ + "profile_id": resp.ID, + "agent_id": resp.AgentID, + }) + h.hub.Broadcast(notification) + } + c.JSON(http.StatusOK, resp) +} + +func (h *Handlers) httpDeleteProfile(c *gin.Context) { + if err := h.controller.DeleteProfile(c.Request.Context(), c.Param("id")); err != nil { + if err == controller.ErrAgentProfileNotFound { + c.JSON(http.StatusNotFound, gin.H{"error": "agent profile not found"}) + return + } + h.logger.Error("failed to delete profile", zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete profile"}) + return + } + if h.hub != nil { + notification, _ := ws.NewNotification(ws.ActionAgentProfileDeleted, gin.H{ + "profile_id": c.Param("id"), + }) + h.hub.Broadcast(notification) + } + c.JSON(http.StatusOK, gin.H{"success": true}) +} diff --git a/apps/backend/internal/agent/settings/models/models.go b/apps/backend/internal/agent/settings/models/models.go new file mode 100644 index 0000000000..64308e63b6 --- /dev/null +++ b/apps/backend/internal/agent/settings/models/models.go @@ -0,0 +1,25 @@ +package models + +import "time" + +type Agent struct { + ID string `json:"id"` + Name string `json:"name"` + WorkspaceID *string `json:"workspace_id,omitempty"` + SupportsMCP bool `json:"supports_mcp"` + MCPConfigPath string `json:"mcp_config_path,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type AgentProfile struct { + ID string `json:"id"` + AgentID string `json:"agent_id"` + Name string `json:"name"` + Model string `json:"model"` + AutoApprove bool `json:"auto_approve"` + DangerouslySkipPermissions bool `json:"dangerously_skip_permissions"` + Plan string `json:"plan"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} diff --git a/apps/backend/internal/agent/settings/store/sqlite.go b/apps/backend/internal/agent/settings/store/sqlite.go new file mode 100644 index 0000000000..e2c8b1ff4f --- /dev/null +++ b/apps/backend/internal/agent/settings/store/sqlite.go @@ -0,0 +1,317 @@ +package store + +import ( + "context" + "database/sql" + "fmt" + "os" + "path/filepath" + "time" + + "github.com/google/uuid" + _ "github.com/mattn/go-sqlite3" + + "github.com/kandev/kandev/internal/agent/settings/models" +) + +type SQLiteRepository struct { + db *sql.DB +} + +var _ Repository = (*SQLiteRepository)(nil) + +func NewSQLiteRepository(dbPath string) (*SQLiteRepository, error) { + normalizedPath := normalizeSQLitePath(dbPath) + if err := ensureSQLiteDir(normalizedPath); err != nil { + return nil, fmt.Errorf("failed to prepare database path: %w", err) + } + if err := ensureSQLiteFile(normalizedPath); err != nil { + return nil, fmt.Errorf("failed to create database file: %w", err) + } + dsn := fmt.Sprintf("file:%s?_foreign_keys=on&_mode=rwc", normalizedPath) + db, err := sql.Open("sqlite3", dsn) + if err != nil { + return nil, fmt.Errorf("failed to open database: %w", err) + } + db.SetMaxOpenConns(1) + db.SetMaxIdleConns(1) + + repo := &SQLiteRepository{db: db} + if err := repo.initSchema(); err != nil { + db.Close() + return nil, fmt.Errorf("failed to initialize schema: %w", err) + } + return repo, nil +} + +func ensureSQLiteDir(dbPath string) error { + dir := filepath.Dir(dbPath) + if dir == "." || dir == "" { + return nil + } + return os.MkdirAll(dir, 0o755) +} + +func ensureSQLiteFile(dbPath string) error { + file, err := os.OpenFile(dbPath, os.O_RDWR|os.O_CREATE, 0o644) + if err != nil { + return err + } + return file.Close() +} + +func normalizeSQLitePath(dbPath string) string { + if dbPath == "" { + return dbPath + } + abs, err := filepath.Abs(dbPath) + if err != nil { + return dbPath + } + return abs +} + +func (r *SQLiteRepository) initSchema() error { + schema := ` + CREATE TABLE IF NOT EXISTS agents ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + workspace_id TEXT DEFAULT NULL, + supports_mcp INTEGER NOT NULL DEFAULT 0, + mcp_config_path TEXT DEFAULT '', + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL + ); + + CREATE TABLE IF NOT EXISTS agent_profiles ( + id TEXT PRIMARY KEY, + agent_id TEXT NOT NULL, + name TEXT NOT NULL, + model TEXT NOT NULL, + auto_approve INTEGER NOT NULL DEFAULT 0, + dangerously_skip_permissions INTEGER NOT NULL DEFAULT 0, + plan TEXT DEFAULT '', + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL, + FOREIGN KEY (agent_id) REFERENCES agents(id) ON DELETE CASCADE + ); + + DROP INDEX IF EXISTS idx_agents_name; + CREATE UNIQUE INDEX IF NOT EXISTS idx_agents_name ON agents(name); + CREATE INDEX IF NOT EXISTS idx_agent_profiles_agent_id ON agent_profiles(agent_id); + ` + _, err := r.db.Exec(schema) + return err +} + +func (r *SQLiteRepository) Close() error { + return r.db.Close() +} + +func (r *SQLiteRepository) CreateAgent(ctx context.Context, agent *models.Agent) error { + if agent.ID == "" { + agent.ID = uuid.New().String() + } + now := time.Now().UTC() + agent.CreatedAt = now + agent.UpdatedAt = now + _, err := r.db.ExecContext(ctx, ` + INSERT INTO agents (id, name, workspace_id, supports_mcp, mcp_config_path, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + `, agent.ID, agent.Name, agent.WorkspaceID, boolToInt(agent.SupportsMCP), agent.MCPConfigPath, agent.CreatedAt, agent.UpdatedAt) + return err +} + +func (r *SQLiteRepository) GetAgent(ctx context.Context, id string) (*models.Agent, error) { + row := r.db.QueryRowContext(ctx, ` + SELECT id, name, workspace_id, supports_mcp, mcp_config_path, created_at, updated_at + FROM agents WHERE id = ? + `, id) + return scanAgent(row) +} + +func (r *SQLiteRepository) GetAgentByName(ctx context.Context, name string) (*models.Agent, error) { + row := r.db.QueryRowContext(ctx, ` + SELECT id, name, workspace_id, supports_mcp, mcp_config_path, created_at, updated_at + FROM agents WHERE name = ? + `, name) + return scanAgent(row) +} + +func (r *SQLiteRepository) UpdateAgent(ctx context.Context, agent *models.Agent) error { + agent.UpdatedAt = time.Now().UTC() + result, err := r.db.ExecContext(ctx, ` + UPDATE agents SET workspace_id = ?, supports_mcp = ?, mcp_config_path = ?, updated_at = ? + WHERE id = ? + `, agent.WorkspaceID, boolToInt(agent.SupportsMCP), agent.MCPConfigPath, agent.UpdatedAt, agent.ID) + if err != nil { + return err + } + rows, _ := result.RowsAffected() + if rows == 0 { + return fmt.Errorf("agent not found: %s", agent.ID) + } + return nil +} + +func (r *SQLiteRepository) DeleteAgent(ctx context.Context, id string) error { + result, err := r.db.ExecContext(ctx, `DELETE FROM agents WHERE id = ?`, id) + if err != nil { + return err + } + rows, _ := result.RowsAffected() + if rows == 0 { + return fmt.Errorf("agent not found: %s", id) + } + return nil +} + +func (r *SQLiteRepository) ListAgents(ctx context.Context) ([]*models.Agent, error) { + rows, err := r.db.QueryContext(ctx, ` + SELECT id, name, workspace_id, supports_mcp, mcp_config_path, created_at, updated_at + FROM agents ORDER BY created_at DESC + `) + if err != nil { + return nil, err + } + defer rows.Close() + + var result []*models.Agent + for rows.Next() { + agent, err := scanAgent(rows) + if err != nil { + return nil, err + } + result = append(result, agent) + } + return result, rows.Err() +} + +func (r *SQLiteRepository) CreateAgentProfile(ctx context.Context, profile *models.AgentProfile) error { + if profile.ID == "" { + profile.ID = uuid.New().String() + } + now := time.Now().UTC() + profile.CreatedAt = now + profile.UpdatedAt = now + _, err := r.db.ExecContext(ctx, ` + INSERT INTO agent_profiles (id, agent_id, name, model, auto_approve, dangerously_skip_permissions, plan, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + `, profile.ID, profile.AgentID, profile.Name, profile.Model, boolToInt(profile.AutoApprove), + boolToInt(profile.DangerouslySkipPermissions), profile.Plan, profile.CreatedAt, profile.UpdatedAt) + return err +} + +func (r *SQLiteRepository) UpdateAgentProfile(ctx context.Context, profile *models.AgentProfile) error { + profile.UpdatedAt = time.Now().UTC() + result, err := r.db.ExecContext(ctx, ` + UPDATE agent_profiles + SET name = ?, model = ?, auto_approve = ?, dangerously_skip_permissions = ?, plan = ?, updated_at = ? + WHERE id = ? + `, profile.Name, profile.Model, boolToInt(profile.AutoApprove), + boolToInt(profile.DangerouslySkipPermissions), profile.Plan, profile.UpdatedAt, profile.ID) + if err != nil { + return err + } + rows, _ := result.RowsAffected() + if rows == 0 { + return fmt.Errorf("agent profile not found: %s", profile.ID) + } + return nil +} + +func (r *SQLiteRepository) DeleteAgentProfile(ctx context.Context, id string) error { + result, err := r.db.ExecContext(ctx, `DELETE FROM agent_profiles WHERE id = ?`, id) + if err != nil { + return err + } + rows, _ := result.RowsAffected() + if rows == 0 { + return fmt.Errorf("agent profile not found: %s", id) + } + return nil +} + +func (r *SQLiteRepository) GetAgentProfile(ctx context.Context, id string) (*models.AgentProfile, error) { + row := r.db.QueryRowContext(ctx, ` + SELECT id, agent_id, name, model, auto_approve, dangerously_skip_permissions, plan, created_at, updated_at + FROM agent_profiles WHERE id = ? + `, id) + return scanAgentProfile(row) +} + +func (r *SQLiteRepository) ListAgentProfiles(ctx context.Context, agentID string) ([]*models.AgentProfile, error) { + rows, err := r.db.QueryContext(ctx, ` + SELECT id, agent_id, name, model, auto_approve, dangerously_skip_permissions, plan, created_at, updated_at + FROM agent_profiles WHERE agent_id = ? ORDER BY created_at DESC + `, agentID) + if err != nil { + return nil, err + } + defer rows.Close() + + var result []*models.AgentProfile + for rows.Next() { + profile, err := scanAgentProfile(rows) + if err != nil { + return nil, err + } + result = append(result, profile) + } + return result, rows.Err() +} + +func scanAgent(scanner interface { + Scan(dest ...interface{}) error +}) (*models.Agent, error) { + agent := &models.Agent{} + var supportsMCP int + var workspaceID sql.NullString + if err := scanner.Scan( + &agent.ID, + &agent.Name, + &workspaceID, + &supportsMCP, + &agent.MCPConfigPath, + &agent.CreatedAt, + &agent.UpdatedAt, + ); err != nil { + return nil, err + } + if workspaceID.Valid { + agent.WorkspaceID = &workspaceID.String + } + agent.SupportsMCP = supportsMCP == 1 + return agent, nil +} + +func scanAgentProfile(scanner interface { + Scan(dest ...interface{}) error +}) (*models.AgentProfile, error) { + profile := &models.AgentProfile{} + var autoApprove int + var skipPermissions int + if err := scanner.Scan( + &profile.ID, + &profile.AgentID, + &profile.Name, + &profile.Model, + &autoApprove, + &skipPermissions, + &profile.Plan, + &profile.CreatedAt, + &profile.UpdatedAt, + ); err != nil { + return nil, err + } + profile.AutoApprove = autoApprove == 1 + profile.DangerouslySkipPermissions = skipPermissions == 1 + return profile, nil +} + +func boolToInt(value bool) int { + if value { + return 1 + } + return 0 +} diff --git a/apps/backend/internal/agent/settings/store/store.go b/apps/backend/internal/agent/settings/store/store.go new file mode 100644 index 0000000000..62584e0e00 --- /dev/null +++ b/apps/backend/internal/agent/settings/store/store.go @@ -0,0 +1,24 @@ +package store + +import ( + "context" + + "github.com/kandev/kandev/internal/agent/settings/models" +) + +type Repository interface { + CreateAgent(ctx context.Context, agent *models.Agent) error + GetAgent(ctx context.Context, id string) (*models.Agent, error) + GetAgentByName(ctx context.Context, name string) (*models.Agent, error) + UpdateAgent(ctx context.Context, agent *models.Agent) error + DeleteAgent(ctx context.Context, id string) error + ListAgents(ctx context.Context) ([]*models.Agent, error) + + CreateAgentProfile(ctx context.Context, profile *models.AgentProfile) error + UpdateAgentProfile(ctx context.Context, profile *models.AgentProfile) error + DeleteAgentProfile(ctx context.Context, id string) error + GetAgentProfile(ctx context.Context, id string) (*models.AgentProfile, error) + ListAgentProfiles(ctx context.Context, agentID string) ([]*models.AgentProfile, error) + + Close() error +} diff --git a/apps/backend/pkg/websocket/actions.go b/apps/backend/pkg/websocket/actions.go index 9904eb9aae..e07d3c0f17 100644 --- a/apps/backend/pkg/websocket/actions.go +++ b/apps/backend/pkg/websocket/actions.go @@ -131,6 +131,10 @@ const ( ActionEnvironmentUpdated = "environment.updated" ActionEnvironmentDeleted = "environment.deleted" + ActionAgentProfileDeleted = "agent.profile.deleted" + ActionAgentProfileCreated = "agent.profile.created" + ActionAgentProfileUpdated = "agent.profile.updated" + // Permission request actions (agent -> user -> agent) ActionPermissionRequested = "permission.requested" // Agent requesting permission ActionPermissionRespond = "permission.respond" // User responding to permission request diff --git a/apps/web/app/actions/agents.ts b/apps/web/app/actions/agents.ts new file mode 100644 index 0000000000..1ca4003bae --- /dev/null +++ b/apps/web/app/actions/agents.ts @@ -0,0 +1,102 @@ +'use server'; + +import { getBackendConfig } from '@/lib/config'; +import type { + Agent, + AgentProfile, + ListAgentsResponse, + ListAgentDiscoveryResponse, +} from '@/lib/types/http'; + +const { apiBaseUrl } = getBackendConfig(); + +async function fetchJson(url: string, options?: RequestInit): Promise { + const response = await fetch(url, { + ...options, + cache: 'no-store', + headers: { + 'Content-Type': 'application/json', + ...(options?.headers ?? {}), + }, + }); + if (!response.ok) { + throw new Error(`Request failed: ${response.status} ${response.statusText}`); + } + if (response.status === 204) { + return undefined as T; + } + const text = await response.text(); + if (!text) { + return undefined as T; + } + return JSON.parse(text) as T; +} + +export async function listAgentDiscoveryAction(): Promise { + return fetchJson(`${apiBaseUrl}/api/v1/agents/discovery`); +} + +export async function listAgentsAction(): Promise { + return fetchJson(`${apiBaseUrl}/api/v1/agents`); +} + +export async function createAgentAction(payload: { + name: string; + workspace_id?: string | null; + profiles?: Array<{ + name: string; + model: string; + auto_approve: boolean; + dangerously_skip_permissions: boolean; + plan: string; + }>; +}): Promise { + return fetchJson(`${apiBaseUrl}/api/v1/agents`, { + method: 'POST', + body: JSON.stringify(payload), + }); +} + +export async function updateAgentAction( + id: string, + payload: { workspace_id?: string | null; supports_mcp?: boolean; mcp_config_path?: string | null } +): Promise { + return fetchJson(`${apiBaseUrl}/api/v1/agents/${id}`, { + method: 'PATCH', + body: JSON.stringify(payload), + }); +} + +export async function deleteAgentAction(id: string) { + await fetchJson(`${apiBaseUrl}/api/v1/agents/${id}`, { method: 'DELETE' }); +} + +export async function createAgentProfileAction( + agentId: string, + payload: { + name: string; + model: string; + auto_approve: boolean; + dangerously_skip_permissions: boolean; + plan: string; + } +): Promise { + return fetchJson(`${apiBaseUrl}/api/v1/agents/${agentId}/profiles`, { + method: 'POST', + body: JSON.stringify(payload), + }); +} + +export async function updateAgentProfileAction( + id: string, + payload: Partial> +): Promise { + return fetchJson(`${apiBaseUrl}/api/v1/agent-profiles/${id}`, { + method: 'PATCH', + body: JSON.stringify(payload), + }); +} + +export async function deleteAgentProfileAction(id: string) { + await fetchJson(`${apiBaseUrl}/api/v1/agent-profiles/${id}`, { method: 'DELETE' }); +} diff --git a/apps/web/app/settings/agent/[id]/page.tsx b/apps/web/app/settings/agent/[id]/page.tsx index 1b0c5c3b8b..f85e479e66 100644 --- a/apps/web/app/settings/agent/[id]/page.tsx +++ b/apps/web/app/settings/agent/[id]/page.tsx @@ -1,251 +1,25 @@ 'use client'; -import { use, useState } from 'react'; +import { useEffect } from 'react'; import { useRouter } from 'next/navigation'; -import { IconTrash } from '@tabler/icons-react'; +import { Card, CardContent } from '@kandev/ui/card'; import { Button } from '@kandev/ui/button'; -import { Input } from '@kandev/ui/input'; -import { Label } from '@kandev/ui/label'; -import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@kandev/ui/card'; -import { Separator } from '@kandev/ui/separator'; -import { Switch } from '@kandev/ui/switch'; -import { Slider } from '@kandev/ui/slider'; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from '@kandev/ui/dialog'; -import { SETTINGS_DATA } from '@/lib/settings/dummy-data'; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@kandev/ui/select'; -import type { AgentProfile, AgentType } from '@/lib/settings/types'; -const MODEL_OPTIONS: Record = { - 'claude-code': [ - { value: 'claude-sonnet-4.5', label: 'Claude Sonnet 4.5' }, - { value: 'claude-opus-4', label: 'Claude Opus 4' }, - { value: 'claude-haiku-4', label: 'Claude Haiku 4' }, - ], - 'codex': [ - { value: 'codex-v2', label: 'Codex v2' }, - { value: 'codex-v1', label: 'Codex v1' }, - ], - 'auggie': [ - { value: 'auggie-v1', label: 'Auggie v1' }, - { value: 'auggie-lite', label: 'Auggie Lite' }, - ], -}; - -export default function AgentEditPage({ params }: { params: Promise<{ id: string }> }) { - const { id } = use(params); +export default function AgentEditPage() { const router = useRouter(); - const [agent, setAgent] = useState( - SETTINGS_DATA.agents.find((a) => a.id === id) - ); - const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); - const [deleteConfirmText, setDeleteConfirmText] = useState(''); - if (!agent) { - return ( -
- - -

Agent not found

- -
-
-
- ); - } - - const handleDeleteAgent = () => { - if (deleteConfirmText === 'delete') { - router.push('/settings/agents'); - setDeleteDialogOpen(false); - } - }; + useEffect(() => { + router.replace('/settings/agents'); + }, [router]); return ( -
-
-

{agent.name}

-

- Configure agent behavior and capabilities -

-
- - - - - - Agent Name - - - setAgent({ ...agent, name: e.target.value })} - placeholder="Code Assistant" - /> - - - - - - Agent Configuration - - -
-
- - -
-
- - -
-
-
-
- - - - Advanced Settings - - -
-
-
- -

- Controls randomness in responses (0 = deterministic, 1 = creative) -

-
- {agent.temperature.toFixed(1)} -
- setAgent({ ...agent, temperature: value })} - min={0} - max={1} - step={0.1} - className="w-full" - /> -
- - - -
-
- -

- Automatically execute tool calls without confirmation -

-
- setAgent({ ...agent, autoApprove: checked })} - /> -
-
-
- - - - {/* Danger Zone */} - - - Danger Zone - - Irreversible actions that will permanently delete this agent - - - -
-
-

Delete this agent

-

- Once deleted, all configuration will be permanently removed -

-
- -
-
-
- - {/* Delete Confirmation Dialog */} - - - - Delete Agent - - This action cannot be undone. This will permanently delete the agent " - {agent.name}" and all its data. - - -
-

- Please type delete to confirm: -

- setDeleteConfirmText(e.target.value)} - placeholder="delete" - /> -
- - - - -
-
-
+ + +

Manage agents from the main Agents page.

+ +
+
); } diff --git a/apps/web/app/settings/agents/[agentId]/page.tsx b/apps/web/app/settings/agents/[agentId]/page.tsx new file mode 100644 index 0000000000..e557e1ea04 --- /dev/null +++ b/apps/web/app/settings/agents/[agentId]/page.tsx @@ -0,0 +1,432 @@ +'use client'; + +import { useEffect, useMemo, useState } from 'react'; +import Link from 'next/link'; +import { useParams, useRouter } from 'next/navigation'; +import { IconPlus } from '@tabler/icons-react'; +import { Button } from '@kandev/ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@kandev/ui/card'; +import { Input } from '@kandev/ui/input'; +import { Label } from '@kandev/ui/label'; +import { Separator } from '@kandev/ui/separator'; +import { Switch } from '@kandev/ui/switch'; +import { Textarea } from '@kandev/ui/textarea'; +import { useToast } from '@/components/toast-provider'; +import { UnsavedChangesBadge, UnsavedSaveButton } from '@/components/settings/unsaved-indicator'; +import { + createAgentAction, + createAgentProfileAction, + deleteAgentProfileAction, + listAgentDiscoveryAction, + listAgentsAction, + updateAgentAction, + updateAgentProfileAction, +} from '@/app/actions/agents'; +import type { Agent, AgentDiscovery, AgentProfile } from '@/lib/types/http'; +import { generateUUID } from '@/lib/utils'; +import { useRequest } from '@/lib/http/use-request'; + +type DraftAgent = Agent & { isNew?: boolean }; +type DraftProfile = AgentProfile & { isNew?: boolean }; + +type AgentSetupFormProps = { + initialAgent: DraftAgent; + savedAgent: Agent | null; + discoveryAgent: AgentDiscovery | undefined; + refreshAgents: () => Promise; + onToastError: (error: unknown) => void; +}; + +const AGENT_LABELS: Record = { + claude: 'Claude', + gemini: 'Gemini', + codex: 'Codex', + opencode: 'OpenCode', + copilot: 'Copilot', +}; + +const createDraftProfile = (agentId: string): DraftProfile => ({ + id: `draft-${generateUUID()}`, + agent_id: agentId, + name: '', + model: '', + auto_approve: false, + dangerously_skip_permissions: false, + plan: '', + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + isNew: true, +}); + +const cloneAgent = (agent: Agent): DraftAgent => ({ + ...agent, + workspace_id: agent.workspace_id ?? null, + mcp_config_path: agent.mcp_config_path ?? '', + profiles: agent.profiles.map((profile) => ({ ...profile })), +}); + +const ensureProfiles = (agent: DraftAgent): DraftAgent => { + if (agent.profiles.length > 0) { + return agent; + } + return { + ...agent, + profiles: [createDraftProfile(agent.id)], + }; +}; + +function AgentSetupForm({ + initialAgent, + savedAgent, + discoveryAgent, + refreshAgents, + onToastError, +}: AgentSetupFormProps) { + const router = useRouter(); + const [draftAgent, setDraftAgent] = useState(initialAgent); + const [saveStatus, setSaveStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle'); + + const isProfileDirty = (draft: DraftProfile, saved?: AgentProfile) => { + if (!saved) return true; + return ( + draft.name !== saved.name || + draft.model !== saved.model || + draft.auto_approve !== saved.auto_approve || + draft.dangerously_skip_permissions !== saved.dangerously_skip_permissions || + draft.plan !== saved.plan + ); + }; + + const isAgentDirty = useMemo(() => { + if (!draftAgent) return false; + if (!savedAgent) return true; + if ((draftAgent.workspace_id ?? null) !== (savedAgent.workspace_id ?? null)) return true; + if ((draftAgent.mcp_config_path ?? '') !== (savedAgent.mcp_config_path ?? '')) return true; + if (draftAgent.profiles.length !== savedAgent.profiles.length) return true; + const savedProfiles = new Map(savedAgent.profiles.map((profile) => [profile.id, profile])); + for (const profile of draftAgent.profiles) { + const savedProfile = savedProfiles.get(profile.id); + if (!savedProfile || isProfileDirty(profile, savedProfile)) return true; + } + return false; + }, [draftAgent, savedAgent]); + + const handleAddProfile = () => { + setDraftAgent((current) => ({ + ...current, + profiles: [...current.profiles, createDraftProfile(current.id)], + })); + }; + + const handleRemoveProfile = (profileId: string) => { + setDraftAgent((current) => { + const remaining = current.profiles.filter((profile) => profile.id !== profileId); + return { + ...current, + profiles: remaining.length > 0 ? remaining : [createDraftProfile(current.id)], + }; + }); + }; + + const handleProfileChange = (profileId: string, patch: Partial) => { + setDraftAgent((current) => ({ + ...current, + profiles: current.profiles.map((profile) => + profile.id === profileId ? { ...profile, ...patch } : profile + ), + })); + }; + + const handleAgentFieldChange = (patch: Partial) => { + setDraftAgent((current) => ({ ...current, ...patch })); + }; + + const handleSave = async () => { + setSaveStatus('loading'); + try { + if (!savedAgent) { + const created = await createAgentAction({ + name: draftAgent.name, + workspace_id: draftAgent.workspace_id, + profiles: draftAgent.profiles.map((profile) => ({ + name: profile.name, + model: profile.model, + auto_approve: profile.auto_approve, + dangerously_skip_permissions: profile.dangerously_skip_permissions, + plan: profile.plan, + })), + }); + if ((draftAgent.mcp_config_path ?? '') !== (created.mcp_config_path ?? '')) { + await updateAgentAction(created.id, { mcp_config_path: draftAgent.mcp_config_path ?? '' }); + } + await refreshAgents(); + router.replace(`/settings/agents/${encodeURIComponent(created.name)}`); + } else { + const agentPatch: { workspace_id?: string | null; mcp_config_path?: string | null } = {}; + if ((draftAgent.workspace_id ?? null) !== (savedAgent.workspace_id ?? null)) { + agentPatch.workspace_id = draftAgent.workspace_id ?? null; + } + if ((draftAgent.mcp_config_path ?? '') !== (savedAgent.mcp_config_path ?? '')) { + agentPatch.mcp_config_path = draftAgent.mcp_config_path ?? ''; + } + if (Object.keys(agentPatch).length > 0) { + await updateAgentAction(savedAgent.id, agentPatch); + } + + const savedProfilesById = new Map(savedAgent.profiles.map((profile) => [profile.id, profile])); + for (const profile of draftAgent.profiles) { + const savedProfile = savedProfilesById.get(profile.id); + if (!savedProfile) { + await createAgentProfileAction(savedAgent.id, { + name: profile.name, + model: profile.model, + auto_approve: profile.auto_approve, + dangerously_skip_permissions: profile.dangerously_skip_permissions, + plan: profile.plan, + }); + continue; + } + if (isProfileDirty(profile, savedProfile)) { + await updateAgentProfileAction(profile.id, { + name: profile.name, + model: profile.model, + auto_approve: profile.auto_approve, + dangerously_skip_permissions: profile.dangerously_skip_permissions, + plan: profile.plan, + }); + } + } + for (const savedProfile of savedAgent.profiles) { + const stillExists = draftAgent.profiles.some((profile) => profile.id === savedProfile.id); + if (!stillExists) { + await deleteAgentProfileAction(savedProfile.id); + } + } + + await refreshAgents(); + } + setSaveStatus('success'); + } catch (error) { + setSaveStatus('error'); + onToastError(error); + } + }; + + return ( +
+
+
+
+

+ {AGENT_LABELS[draftAgent.name] ?? draftAgent.name} +

+ + {discoveryAgent?.matched_path ?? 'Installation not detected'} + +
+

+ Configure profiles and defaults for this agent. +

+
+
+ + + + + +
+ {AGENT_LABELS[draftAgent.name] ?? draftAgent.name} Profiles + {isAgentDirty && } +
+ +
+ + {draftAgent.supports_mcp && ( +
+ + handleAgentFieldChange({ mcp_config_path: event.target.value })} + placeholder="~/.copilot/mcp-config.json" + /> +
+ )} + {draftAgent.profiles.map((profile) => ( + + +
+
+ + + handleProfileChange(profile.id, { name: event.target.value }) + } + placeholder="Default profile" + /> +
+ +
+ +
+ + + handleProfileChange(profile.id, { model: event.target.value }) + } + placeholder="model-id" + /> +
+ +
+ +