fix(agents): expose kandev MCP tools to custom TUI agents (#2603)

Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
João Salavisa
2026-08-13 17:25:00 +01:00
committed by GitHub
parent a13379cfbc
commit 37f4992ed5
45 changed files with 1742 additions and 113 deletions
+24 -1
View File
@@ -117,12 +117,35 @@ type LoginAgent interface {
}
// IsPassthroughOnly returns true if the agent only supports passthrough mode
// and should not have interactive MCP tools (e.g. ask_user_question) registered.
// (a raw CLI under a PTY, no ACP protocol mode). It governs execution mode —
// notably seeding the agent's default profile as CLI passthrough — and is true
// for every TUI agent regardless of MCP configuration.
//
// For "should interactive MCP tools be registered?", use
// SupportsInteractiveMCPTools instead. The two questions used to share this one
// predicate, which is why a custom TUI agent could never get ask_user_question.
func IsPassthroughOnly(a Agent) bool {
_, ok := a.(*TUIAgent)
return ok
}
// SupportsInteractiveMCPTools reports whether interactive MCP tools (notably
// ask_user_question) should be registered for the agent's session.
//
// A TUI agent with no MCP injection strategy is a plain terminal tool that
// never speaks MCP: registering a tool it cannot call just means a question
// that hangs forever. A TUI agent *with* a strategy wraps a real MCP-capable
// CLI that kandev points at the per-session server, so it should get the same
// tools as the built-in claude-acp agent running in passthrough mode. Every
// non-TUI agent supports them.
func SupportsInteractiveMCPTools(a Agent) bool {
tui, ok := a.(*TUIAgent)
if !ok {
return true
}
return tui.MCPStrategy() != nil
}
// LogoVariant selects light or dark logo.
type LogoVariant int
@@ -4,6 +4,7 @@ import (
"context"
"time"
"github.com/kandev/kandev/internal/agent/mcpconfig"
"github.com/kandev/kandev/internal/agent/usage"
"github.com/kandev/kandev/pkg/agent"
)
@@ -29,6 +30,12 @@ type TUIAgentConfig struct {
ModelFlag Param // e.g. NewParam("--model", "{model}") — lets users pick a model in profile settings
CommandArgs []string // extra args after Command
DetectOpts []DetectOption // defaults to WithCommand(Command)
// MCPStrategy materializes kandev's per-session MCP server (and the
// profile's configured servers) into the wrapped CLI's own shape. Nil — the
// default — means no injection, which is correct for a TUI tool that is not
// an MCP client at all. Resolved from the user's stored strategy key; see
// mcpconfig.StrategyByKey for why it is chosen rather than inferred.
MCPStrategy mcpconfig.PassthroughMCPStrategy
}
// TUIAgent implements Agent + PassthroughAgent for CLI passthrough TUI tools.
@@ -75,6 +82,7 @@ func NewTUIAgent(cfg TUIAgentConfig) *TUIAgent {
IdleTimeout: cfg.IdleTimeout,
BufferMaxBytes: cfg.BufferMax,
WaitForTerminal: cfg.WaitForTerm,
MCPStrategy: cfg.MCPStrategy,
// Ink-based TUIs (Claude Code and similar) coalesce multi-byte
// stdin reads into a paste burst, absorbing a trailing "\r" into
// the pasted content instead of dispatching Enter, and enable
@@ -113,7 +121,24 @@ func (a *TUIAgent) Logo(v LogoVariant) []byte {
}
func (a *TUIAgent) IsInstalled(ctx context.Context) (*DiscoveryResult, error) {
return Detect(ctx, a.cfg.DetectOpts...)
result, err := Detect(ctx, a.cfg.DetectOpts...)
if err != nil {
return result, err
}
// Derive MCP support from the configured strategy rather than storing it as
// an independent flag. Discovery writes this value back over agents.supports_mcp
// on every sweep (see controller.upsertAgent), so anything set separately at
// creation time is reverted within a boot. Deriving makes that write the
// mechanism that keeps the flag correct instead of the thing fighting it,
// and makes "supports MCP but has no way to inject it" unrepresentable.
result.SupportsMCP = a.cfg.MCPStrategy != nil
return result, nil
}
// MCPStrategy returns the configured passthrough MCP injection strategy, or nil
// when the agent does no injection.
func (a *TUIAgent) MCPStrategy() mcpconfig.PassthroughMCPStrategy {
return a.cfg.MCPStrategy
}
func (a *TUIAgent) BuildCommand(_ CommandOptions) Command {
@@ -1,8 +1,11 @@
package agents
import (
"context"
"testing"
"time"
"github.com/kandev/kandev/internal/agent/mcpconfig"
)
// Custom TUI agents wrap arbitrary CLIs, commonly Ink-based TUIs such as Claude
@@ -31,3 +34,75 @@ func TestNewTUIAgentDefaultsToInkSafePassthrough(t *testing.T) {
t.Errorf("effective submit sequence = %q, want %q", got, "\r")
}
}
// TestTUIAgentDerivesSupportsMCPFromStrategy pins the mechanism that makes the
// flag survive. Discovery writes IsInstalled's SupportsMCP back over the
// agents.supports_mcp column on every sweep (controller.upsertAgent), so a flag
// set independently at creation is reverted within a boot. Deriving it here
// makes that write keep the flag correct instead of clobbering it.
func TestTUIAgentDerivesSupportsMCPFromStrategy(t *testing.T) {
withStrategy := NewTUIAgent(TUIAgentConfig{
AgentID: "with", AgentName: "with", Command: "echo",
MCPStrategy: mcpconfig.ClaudeStrategy{},
})
result, err := withStrategy.IsInstalled(context.Background())
if err != nil {
t.Fatalf("IsInstalled: %v", err)
}
if !result.SupportsMCP {
t.Error("SupportsMCP = false for an agent with a strategy, want true")
}
without := NewTUIAgent(TUIAgentConfig{AgentID: "without", AgentName: "without", Command: "echo"})
result, err = without.IsInstalled(context.Background())
if err != nil {
t.Fatalf("IsInstalled: %v", err)
}
if result.SupportsMCP {
t.Error("SupportsMCP = true for an agent with no strategy, want false")
}
}
// TestTUIAgentCarriesStrategyIntoPassthroughConfig pins the field the launch
// path actually reads: applyPassthroughMCP returns immediately when
// PassthroughConfig.MCPStrategy is nil, so a strategy that does not land here
// injects nothing no matter what else is configured.
func TestTUIAgentCarriesStrategyIntoPassthroughConfig(t *testing.T) {
a := NewTUIAgent(TUIAgentConfig{
AgentID: "x", AgentName: "x", Command: "echo",
MCPStrategy: mcpconfig.CodexStrategy{},
})
if a.PassthroughConfig().MCPStrategy == nil {
t.Fatal("PassthroughConfig.MCPStrategy = nil, want the configured strategy")
}
}
// TestInteractiveMCPToolsSplitFromPassthroughOnly pins the distinction between
// two questions that used to share one predicate. IsPassthroughOnly governs
// execution mode (it seeds the default profile as CLI passthrough), so it must
// stay true for every TUI agent. Interactive-tool registration is separate: a
// TUI agent wrapping a real MCP-capable CLI should get ask_user_question, which
// it never could while both answers came from the same type assertion.
func TestInteractiveMCPToolsSplitFromPassthroughOnly(t *testing.T) {
withStrategy := NewTUIAgent(TUIAgentConfig{
AgentID: "with", AgentName: "with", Command: "echo",
MCPStrategy: mcpconfig.ClaudeStrategy{},
})
without := NewTUIAgent(TUIAgentConfig{AgentID: "without", AgentName: "without", Command: "echo"})
// Both are passthrough-only: neither has an ACP protocol mode. Weakening
// this would seed them as ACP agents and break them outright.
if !IsPassthroughOnly(withStrategy) {
t.Error("IsPassthroughOnly = false for a TUI agent with a strategy, want true")
}
if !IsPassthroughOnly(without) {
t.Error("IsPassthroughOnly = false for a plain TUI agent, want true")
}
if !SupportsInteractiveMCPTools(withStrategy) {
t.Error("SupportsInteractiveMCPTools = false for a TUI agent with a strategy, want true")
}
if SupportsInteractiveMCPTools(without) {
t.Error("SupportsInteractiveMCPTools = true for a plain TUI agent, want false")
}
}
@@ -0,0 +1,81 @@
package mcpconfig
// Strategy keys are the stable identifiers used to persist a passthrough MCP
// strategy choice (custom TUI agents store one on their tui_config) and to
// carry it over the settings API. Built-in agents declare their strategy in Go
// and never round-trip through a key.
//
// A custom TUI agent wraps an arbitrary command — `zsh -ic "fuelclaude --model
// opus"` is a real example — so kandev cannot infer which CLI is underneath or
// how that CLI loads MCP servers. Guessing wrong is the worst outcome
// available: the config file gets written, the CLI ignores it, and nothing
// anywhere reports an error. The user picks the key instead.
const (
// StrategyKeyNone means the agent does not get MCP injection. It is the
// default for custom TUI agents and the stored value for every agent
// created before this field existed.
StrategyKeyNone = ""
StrategyKeyClaude = "claude"
StrategyKeyCodex = "codex"
StrategyKeyCursor = "cursor"
StrategyKeyOpenCode = "opencode"
StrategyKeyPi = "pi"
)
// StrategyOption describes one selectable strategy for the settings UI.
type StrategyOption struct {
Key string `json:"key"`
// Description is the strategy's own Describe() text, so the UI explains the
// actual mechanism ("an MCP config file passed via the --mcp-config flag")
// rather than restating the key.
Description string `json:"description"`
}
// strategyByKey is the single source of truth for key → implementation.
// StrategyOptions and StrategyByKey both read it, so a new strategy becomes
// selectable in the UI and resolvable at launch from one edit.
var strategyByKey = map[string]PassthroughMCPStrategy{
StrategyKeyClaude: ClaudeStrategy{},
StrategyKeyCodex: CodexStrategy{},
StrategyKeyCursor: CursorStrategy{},
StrategyKeyOpenCode: OpenCodeStrategy{},
StrategyKeyPi: PiStrategy{},
}
// strategyKeyOrder fixes the presentation order of StrategyOptions. Map
// iteration order is random, and a dropdown that reshuffles on every request is
// unusable.
var strategyKeyOrder = []string{
StrategyKeyClaude,
StrategyKeyCodex,
StrategyKeyCursor,
StrategyKeyOpenCode,
StrategyKeyPi,
}
// StrategyByKey resolves a persisted strategy key to its implementation.
//
// The empty key resolves to (nil, true): "this agent does no MCP injection" is
// a valid stored value, not a failure. An unknown key returns ok=false so the
// API layer can reject it up front — resolving it to nil instead would persist
// a typo that silently disables MCP forever.
func StrategyByKey(key string) (PassthroughMCPStrategy, bool) {
if key == StrategyKeyNone {
return nil, true
}
strategy, ok := strategyByKey[key]
return strategy, ok
}
// StrategyOptions returns the selectable strategies in stable order, each with
// the mechanism description the UI shows next to it.
func StrategyOptions() []StrategyOption {
options := make([]StrategyOption, 0, len(strategyKeyOrder))
for _, key := range strategyKeyOrder {
options = append(options, StrategyOption{
Key: key,
Description: strategyByKey[key].Describe(),
})
}
return options
}
@@ -0,0 +1,68 @@
package mcpconfig
import "testing"
func TestStrategyByKeyResolvesEveryOfferedOption(t *testing.T) {
// Every key the UI can offer must resolve, or a user could pick an option
// that is rejected on save.
for _, opt := range StrategyOptions() {
strategy, ok := StrategyByKey(opt.Key)
if !ok {
t.Errorf("StrategyByKey(%q) not ok, but it is an offered option", opt.Key)
continue
}
if strategy == nil {
t.Errorf("StrategyByKey(%q) = nil, want an implementation", opt.Key)
}
if opt.Description == "" {
t.Errorf("option %q has an empty description", opt.Key)
}
}
}
func TestStrategyByKeyEmptyIsValidAndNil(t *testing.T) {
// "No MCP injection" is a legitimate stored value — the default for custom
// TUI agents and for every row written before the field existed.
strategy, ok := StrategyByKey(StrategyKeyNone)
if !ok {
t.Fatal("StrategyByKey(\"\") not ok, want ok (no-injection is valid)")
}
if strategy != nil {
t.Fatalf("StrategyByKey(\"\") = %T, want nil", strategy)
}
}
func TestStrategyByKeyRejectsUnknown(t *testing.T) {
// A typo must fail loudly at the API boundary. Resolving it to nil would
// persist a value that silently disables MCP with no error anywhere.
if _, ok := StrategyByKey("cluade"); ok {
t.Fatal("StrategyByKey(\"cluade\") ok = true, want false")
}
}
func TestStrategyOptionsAreStablyOrdered(t *testing.T) {
// Map iteration is randomised; a dropdown that reshuffles per request is
// unusable. Compare several passes rather than pinning exact contents.
first := StrategyOptions()
for range 20 {
next := StrategyOptions()
if len(next) != len(first) {
t.Fatalf("option count changed: %d then %d", len(first), len(next))
}
for i := range first {
if next[i].Key != first[i].Key {
t.Fatalf("option order changed at %d: %q then %q", i, first[i].Key, next[i].Key)
}
}
}
}
func TestStrategyOptionsExcludeNone(t *testing.T) {
// The empty key is represented by the UI's own "off" choice, not as a
// selectable strategy with a mechanism description.
for _, opt := range StrategyOptions() {
if opt.Key == StrategyKeyNone {
t.Fatal("StrategyOptions includes the empty key")
}
}
}
@@ -5,38 +5,88 @@ import (
"strings"
"github.com/kandev/kandev/internal/agent/agents"
"github.com/kandev/kandev/internal/agent/mcpconfig"
)
// RegisterCustomTUIAgent creates a TUIAgent from user-provided parameters and registers it.
// The command string is split into binary + args. Any {{model}} placeholder in the command
// is replaced with the model value.
func (r *Registry) RegisterCustomTUIAgent(slug, displayName, command, description, model string, commandArgs []string) error {
// CustomTUIAgentSpec is the user-provided definition of a custom TUI agent, as
// stored on the agent row's tui_config and replayed into the registry on boot.
type CustomTUIAgentSpec struct {
Slug string
DisplayName string
Command string
Description string
Model string
CommandArgs []string
// MCPStrategyKey selects how kandev injects MCP servers into the wrapped
// CLI (see mcpconfig.StrategyByKey). Empty means no injection.
MCPStrategyKey string
}
// ErrUnknownMCPStrategy is returned when a spec names a strategy key that no
// longer resolves — a typo from an older client, or a key removed by an
// upgrade. Failing loudly beats registering the agent with MCP silently off.
var ErrUnknownMCPStrategy = fmt.Errorf("unknown MCP strategy")
// buildCustomTUIAgent turns a user-provided spec into a TUIAgent. The command
// string is split into binary + args, and any {{model}} placeholder in the
// command is replaced with the model value.
func buildCustomTUIAgent(spec CustomTUIAgentSpec) (*agents.TUIAgent, error) {
// Replace {{model}} template in the command string
resolvedCommand := command
if model != "" {
resolvedCommand = strings.ReplaceAll(command, "{{model}}", model)
resolvedCommand := spec.Command
if spec.Model != "" {
resolvedCommand = strings.ReplaceAll(spec.Command, "{{model}}", spec.Model)
}
// Split command into binary + args
parts := strings.Fields(resolvedCommand)
if len(parts) == 0 {
return fmt.Errorf("command is empty")
return nil, fmt.Errorf("command is empty")
}
binary := parts[0]
args := parts[1:]
if len(commandArgs) > 0 {
args = append(args, commandArgs...)
if len(spec.CommandArgs) > 0 {
args = append(args, spec.CommandArgs...)
}
cfg := agents.TUIAgentConfig{
AgentID: slug,
AgentName: slug,
strategy, ok := mcpconfig.StrategyByKey(spec.MCPStrategyKey)
if !ok {
return nil, fmt.Errorf("%w: %q", ErrUnknownMCPStrategy, spec.MCPStrategyKey)
}
return agents.NewTUIAgent(agents.TUIAgentConfig{
AgentID: spec.Slug,
AgentName: spec.Slug,
Command: binary,
Desc: description,
Display: displayName,
Desc: spec.Description,
Display: spec.DisplayName,
WaitForTerm: true,
CommandArgs: args,
MCPStrategy: strategy,
}), nil
}
// RegisterCustomTUIAgent creates a TUIAgent from user-provided parameters and
// registers it, failing if the ID is already taken.
func (r *Registry) RegisterCustomTUIAgent(spec CustomTUIAgentSpec) error {
tuiAgent, err := buildCustomTUIAgent(spec)
if err != nil {
return err
}
tuiAgent := agents.NewTUIAgent(cfg)
return r.Register(tuiAgent)
}
// ReplaceCustomTUIAgent rebuilds an already-registered custom TUI agent in
// place. The MCP strategy is baked into the TUIAgent at construction, so
// changing it needs a new instance rather than a mutation.
//
// This uses Replace rather than Unregister + Register because the pair leaves a
// window with no entry for the ID: a session launching in that window fails
// with "agent type not found", and a competing registration can leave the agent
// missing until restart.
func (r *Registry) ReplaceCustomTUIAgent(spec CustomTUIAgentSpec) error {
tuiAgent, err := buildCustomTUIAgent(spec)
if err != nil {
return err
}
return r.Replace(tuiAgent)
}
@@ -0,0 +1,135 @@
package registry
import (
"errors"
"testing"
"github.com/kandev/kandev/internal/agent/agents"
"github.com/kandev/kandev/internal/agent/mcpconfig"
"github.com/kandev/kandev/internal/common/logger"
)
func newTestRegistry(t *testing.T) *Registry {
t.Helper()
log, err := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "json"})
if err != nil {
t.Fatalf("logger: %v", err)
}
return NewRegistry(log)
}
func registeredStrategy(t *testing.T, reg *Registry, slug string) mcpconfig.PassthroughMCPStrategy {
t.Helper()
ag, ok := reg.Get(slug)
if !ok {
t.Fatalf("agent %q not registered", slug)
}
pt, ok := ag.(agents.PassthroughAgent)
if !ok {
t.Fatalf("agent %q does not implement PassthroughAgent", slug)
}
return pt.PassthroughConfig().MCPStrategy
}
// TestRegisterCustomTUIAgent_StrategyKeyReachesAgent covers the contract the
// registration tests previously left untested: every case passed the zero-value
// key, so a regression that dropped the strategy on the way to the agent would
// not have failed anything here.
func TestRegisterCustomTUIAgent_StrategyKeyReachesAgent(t *testing.T) {
reg := newTestRegistry(t)
if err := reg.RegisterCustomTUIAgent(CustomTUIAgentSpec{
Slug: "with-strategy", DisplayName: "With", Command: "cli",
MCPStrategyKey: mcpconfig.StrategyKeyClaude,
}); err != nil {
t.Fatalf("RegisterCustomTUIAgent: %v", err)
}
if registeredStrategy(t, reg, "with-strategy") == nil {
t.Error("MCPStrategy = nil, want the Claude strategy")
}
}
func TestRegisterCustomTUIAgent_NoKeyLeavesStrategyNil(t *testing.T) {
reg := newTestRegistry(t)
if err := reg.RegisterCustomTUIAgent(CustomTUIAgentSpec{
Slug: "no-strategy", DisplayName: "None", Command: "k9s",
}); err != nil {
t.Fatalf("RegisterCustomTUIAgent: %v", err)
}
if registeredStrategy(t, reg, "no-strategy") != nil {
t.Error("MCPStrategy set, want nil")
}
}
// TestRegisterCustomTUIAgent_UnknownKeyRejectedWithoutRegistering pins that a
// bad key fails loudly instead of registering an agent whose MCP is silently off.
func TestRegisterCustomTUIAgent_UnknownKeyRejectedWithoutRegistering(t *testing.T) {
reg := newTestRegistry(t)
err := reg.RegisterCustomTUIAgent(CustomTUIAgentSpec{
Slug: "bad-key", DisplayName: "Bad", Command: "cli", MCPStrategyKey: "cluade",
})
if !errors.Is(err, ErrUnknownMCPStrategy) {
t.Fatalf("err = %v, want ErrUnknownMCPStrategy", err)
}
if _, ok := reg.Get("bad-key"); ok {
t.Error("agent was registered despite the rejected strategy key")
}
}
// TestReplaceCustomTUIAgent_SwapsStrategyWithoutRemovingEntry is the reason
// Replace is used instead of Unregister + Register: the pair leaves a window in
// which the ID resolves to nothing, and a session launching then fails with
// "agent type not found". The entry must be continuously resolvable.
func TestReplaceCustomTUIAgent_SwapsStrategyWithoutRemovingEntry(t *testing.T) {
reg := newTestRegistry(t)
spec := CustomTUIAgentSpec{Slug: "swap-me", DisplayName: "Swap", Command: "cli --flag"}
if err := reg.RegisterCustomTUIAgent(spec); err != nil {
t.Fatalf("RegisterCustomTUIAgent: %v", err)
}
spec.MCPStrategyKey = mcpconfig.StrategyKeyCodex
if err := reg.ReplaceCustomTUIAgent(spec); err != nil {
t.Fatalf("ReplaceCustomTUIAgent: %v", err)
}
if registeredStrategy(t, reg, "swap-me") == nil {
t.Error("MCPStrategy = nil after replace")
}
// Replace must not duplicate or drop the entry, and the rest of the
// definition has to survive the rebuild.
ag, _ := reg.Get("swap-me")
want := []string{"cli", "--flag"}
got := ag.Runtime().Cmd.Args()
if len(got) != len(want) {
t.Fatalf("argv = %#v, want %#v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("argv = %#v, want %#v", got, want)
}
}
}
// TestReplaceCustomTUIAgent_RejectsUnknownKeyLeavingEntryIntact pins that a bad
// key on an edit does not take the existing agent down with it.
func TestReplaceCustomTUIAgent_RejectsUnknownKeyLeavingEntryIntact(t *testing.T) {
reg := newTestRegistry(t)
spec := CustomTUIAgentSpec{
Slug: "keep-me", DisplayName: "Keep", Command: "cli",
MCPStrategyKey: mcpconfig.StrategyKeyClaude,
}
if err := reg.RegisterCustomTUIAgent(spec); err != nil {
t.Fatalf("RegisterCustomTUIAgent: %v", err)
}
bad := spec
bad.MCPStrategyKey = "nope"
if err := reg.ReplaceCustomTUIAgent(bad); !errors.Is(err, ErrUnknownMCPStrategy) {
t.Fatalf("err = %v, want ErrUnknownMCPStrategy", err)
}
if registeredStrategy(t, reg, "keep-me") == nil {
t.Error("existing agent lost its strategy after a rejected replace")
}
}
@@ -8,7 +8,7 @@ func TestRegisterCustomTUIAgent_Success(t *testing.T) {
log := newTestLogger()
reg := NewRegistry(log)
err := reg.RegisterCustomTUIAgent("my-agent", "My Agent", "my-agent --verbose", "A test agent", "", nil)
err := reg.RegisterCustomTUIAgent(CustomTUIAgentSpec{Slug: "my-agent", DisplayName: "My Agent", Command: "my-agent --verbose", Description: "A test agent", Model: ""})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -41,7 +41,7 @@ func TestRegisterCustomTUIAgent_ModelTemplate(t *testing.T) {
log := newTestLogger()
reg := NewRegistry(log)
err := reg.RegisterCustomTUIAgent("tmpl-agent", "Template", "my-cli --model {{model}}", "", "best", nil)
err := reg.RegisterCustomTUIAgent(CustomTUIAgentSpec{Slug: "tmpl-agent", DisplayName: "Template", Command: "my-cli --model {{model}}", Description: "", Model: "best"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -69,7 +69,7 @@ func TestRegisterCustomTUIAgent_ModelTemplateNotReplacedWhenEmpty(t *testing.T)
log := newTestLogger()
reg := NewRegistry(log)
err := reg.RegisterCustomTUIAgent("no-model", "No Model", "cli --model {{model}}", "", "", nil)
err := reg.RegisterCustomTUIAgent(CustomTUIAgentSpec{Slug: "no-model", DisplayName: "No Model", Command: "cli --model {{model}}", Description: "", Model: ""})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -91,7 +91,7 @@ func TestRegisterCustomTUIAgent_CommandArgs(t *testing.T) {
log := newTestLogger()
reg := NewRegistry(log)
err := reg.RegisterCustomTUIAgent("extra-args", "Extra", "my-cli", "", "", []string{"--extra", "--flag"})
err := reg.RegisterCustomTUIAgent(CustomTUIAgentSpec{Slug: "extra-args", DisplayName: "Extra", Command: "my-cli", Description: "", Model: "", CommandArgs: []string{"--extra", "--flag"}})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -107,7 +107,7 @@ func TestRegisterCustomTUIAgent_EmptyCommand(t *testing.T) {
log := newTestLogger()
reg := NewRegistry(log)
err := reg.RegisterCustomTUIAgent("empty-cmd", "Empty", "", "", "", nil)
err := reg.RegisterCustomTUIAgent(CustomTUIAgentSpec{Slug: "empty-cmd", DisplayName: "Empty", Command: "", Description: "", Model: ""})
if err == nil {
t.Error("expected error for empty command")
}
@@ -117,8 +117,8 @@ func TestRegisterCustomTUIAgent_DuplicateID(t *testing.T) {
log := newTestLogger()
reg := NewRegistry(log)
_ = reg.RegisterCustomTUIAgent("dup-agent", "First", "first-cli", "", "", nil)
err := reg.RegisterCustomTUIAgent("dup-agent", "Second", "second-cli", "", "", nil)
_ = reg.RegisterCustomTUIAgent(CustomTUIAgentSpec{Slug: "dup-agent", DisplayName: "First", Command: "first-cli", Description: "", Model: ""})
err := reg.RegisterCustomTUIAgent(CustomTUIAgentSpec{Slug: "dup-agent", DisplayName: "Second", Command: "second-cli", Description: "", Model: ""})
if err == nil {
t.Error("expected error for duplicate registration")
}
@@ -406,7 +406,7 @@ func TestResolveFamilyIDsReportsEveryAmbiguousCandidate(t *testing.T) {
t.Run(tc.name, func(t *testing.T) {
reg := NewRegistry(newTestLogger())
reg.LoadDefaults()
if err := reg.RegisterCustomTUIAgent(tc.slug, tc.displayName, "claude-tui", "", "", nil); err != nil {
if err := reg.RegisterCustomTUIAgent(CustomTUIAgentSpec{Slug: tc.slug, DisplayName: tc.displayName, Command: "claude-tui", Description: "", Model: ""}); err != nil {
t.Fatalf("register custom agent: %v", err)
}
@@ -272,7 +272,7 @@ func (cm *ContainerManager) createInstanceAndClient(
if config.AgentConfig != nil {
agentType = config.AgentConfig.ID()
}
disableAskQuestion := agents.IsPassthroughOnly(config.AgentConfig)
disableAskQuestion := !agents.SupportsInteractiveMCPTools(config.AgentConfig)
assumeMcpSse := false
assumeMcpHttp := false
requiresProcessKill := false
@@ -543,7 +543,7 @@ func buildReconnectCreateInstanceRequest(req *ExecutorCreateRequest, instanceID
var stripEnv []string
if req.AgentConfig != nil {
agentType = req.AgentConfig.ID()
disableAskQuestion = agents.IsPassthroughOnly(req.AgentConfig)
disableAskQuestion = !agents.SupportsInteractiveMCPTools(req.AgentConfig)
if rt := req.AgentConfig.Runtime(); rt != nil {
assumeMcpSse = rt.AssumeMcpSse
assumeMcpHttp = rt.AssumeMcpHttp
@@ -137,7 +137,7 @@ func (r *StandaloneExecutor) CreateInstance(ctx context.Context, req *ExecutorCr
if req.AgentConfig != nil {
agentType = req.AgentConfig.ID()
}
disableAskQuestion := agents.IsPassthroughOnly(req.AgentConfig)
disableAskQuestion := !agents.SupportsInteractiveMCPTools(req.AgentConfig)
assumeMcpSse := false
assumeMcpHttp := false
requiresProcessKill := false
@@ -49,6 +49,8 @@ var (
ErrCommandRequired = errors.New("command is required")
ErrInvalidProfileEnvVars = errors.New("invalid profile env vars")
ErrInvalidCommandPrefix = errors.New("invalid command prefix")
ErrUnknownMCPStrategy = errors.New("unknown MCP strategy")
ErrNotCustomTUIAgent = errors.New("agent is not a custom TUI agent")
)
type Controller struct {
@@ -8,6 +8,8 @@ import (
"regexp"
"strings"
"github.com/kandev/kandev/internal/agent/mcpconfig"
"github.com/kandev/kandev/internal/agent/registry"
"github.com/kandev/kandev/internal/agent/settings/dto"
"github.com/kandev/kandev/internal/agent/settings/models"
)
@@ -29,6 +31,9 @@ type CreateCustomTUIAgentRequest struct {
Command string
Description string
CommandArgs []string
// MCPStrategy selects how kandev injects its per-session MCP server into
// the wrapped CLI. Empty means no MCP tools, which is the default.
MCPStrategy string
}
// CreateCustomTUIAgent registers a new custom TUI agent and persists it to the database.
@@ -40,6 +45,9 @@ func (c *Controller) CreateCustomTUIAgent(ctx context.Context, req CreateCustomT
if req.Command == "" {
return nil, ErrCommandRequired
}
if _, ok := mcpconfig.StrategyByKey(req.MCPStrategy); !ok {
return nil, ErrUnknownMCPStrategy
}
// Check for conflict with existing registry entry
if c.agentRegistry.Exists(slug) {
@@ -56,9 +64,15 @@ func (c *Controller) CreateCustomTUIAgent(ctx context.Context, req CreateCustomT
}
// Register in the in-memory registry
if regErr := c.agentRegistry.RegisterCustomTUIAgent(
slug, req.DisplayName, req.Command, req.Description, req.Model, req.CommandArgs,
); regErr != nil {
if regErr := c.agentRegistry.RegisterCustomTUIAgent(registry.CustomTUIAgentSpec{
Slug: slug,
DisplayName: req.DisplayName,
Command: req.Command,
Description: req.Description,
Model: req.Model,
CommandArgs: req.CommandArgs,
MCPStrategyKey: req.MCPStrategy,
}); regErr != nil {
return nil, fmt.Errorf("failed to register agent: %w", regErr)
}
@@ -70,10 +84,14 @@ func (c *Controller) CreateCustomTUIAgent(ctx context.Context, req CreateCustomT
Description: req.Description,
CommandArgs: req.CommandArgs,
WaitForTerminal: true,
MCPStrategy: req.MCPStrategy,
}
agent := &models.Agent{
Name: slug,
TUIConfig: tuiConfig,
Name: slug,
// Mirrors what TUIAgent.IsInstalled derives, so the profile MCP editor
// works immediately instead of only after the next discovery sweep.
SupportsMCP: req.MCPStrategy != mcpconfig.StrategyKeyNone,
TUIConfig: tuiConfig,
}
if err := c.repo.CreateAgent(ctx, agent); err != nil {
// Rollback registry on DB failure
@@ -101,3 +119,87 @@ func (c *Controller) CreateCustomTUIAgent(ctx context.Context, req CreateCustomT
result := toAgentDTO(agent, profiles)
return &result, nil
}
// SetCustomTUIAgentMCPStrategy changes an existing custom TUI agent's MCP
// injection strategy.
//
// Everything else about a tui_config is immutable — there is no edit route for
// the command — but the strategy has to be mutable or every agent created
// before this feature existed could never get MCP tools without being deleted
// and rebuilt, losing its profiles and any session history pointing at it.
//
// The in-memory registry entry is rebuilt because the strategy is baked into
// the TUIAgent at construction; the DB row is updated to match. Sessions
// already running keep the strategy they launched with until they restart,
// since the passthrough command is built once at launch.
func (c *Controller) SetCustomTUIAgentMCPStrategy(ctx context.Context, agentID, strategyKey string) (*dto.AgentDTO, error) {
if _, ok := mcpconfig.StrategyByKey(strategyKey); !ok {
return nil, ErrUnknownMCPStrategy
}
agent, err := c.repo.GetAgent(ctx, agentID)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrAgentNotFound
}
return nil, err
}
if agent == nil {
return nil, ErrAgentNotFound
}
if agent.TUIConfig == nil {
// Built-in agents declare their strategy in Go; only custom TUI agents
// carry a user-selected one.
return nil, ErrNotCustomTUIAgent
}
if agent.TUIConfig.MCPStrategy == strategyKey {
return c.customTUIAgentDTO(ctx, agent)
}
previous := agent.TUIConfig.MCPStrategy
agent.TUIConfig.MCPStrategy = strategyKey
agent.SupportsMCP = strategyKey != mcpconfig.StrategyKeyNone
if err := c.repo.UpdateAgent(ctx, agent); err != nil {
return nil, err
}
if err := c.reregisterCustomTUIAgent(agent); err != nil {
// Roll the row back so the DB cannot claim a strategy the running
// registry does not have — that mismatch would persist until restart.
agent.TUIConfig.MCPStrategy = previous
agent.SupportsMCP = previous != mcpconfig.StrategyKeyNone
if rollbackErr := c.repo.UpdateAgent(ctx, agent); rollbackErr != nil {
return nil, fmt.Errorf("re-register agent: %w (rollback also failed: %v)", err, rollbackErr)
}
return nil, fmt.Errorf("failed to re-register agent: %w", err)
}
return c.customTUIAgentDTO(ctx, agent)
}
// reregisterCustomTUIAgent rebuilds the registry entry from the agent's stored
// tui_config. The strategy is baked into the TUIAgent at construction, so a
// changed strategy needs a new instance rather than a mutation. The swap is
// atomic: Unregister + Register would leave a window in which a launching
// session sees no entry for this agent ID.
func (c *Controller) reregisterCustomTUIAgent(agent *models.Agent) error {
cfg := agent.TUIConfig
return c.agentRegistry.ReplaceCustomTUIAgent(registry.CustomTUIAgentSpec{
Slug: agent.Name,
DisplayName: cfg.DisplayName,
Command: cfg.Command,
Description: cfg.Description,
Model: cfg.Model,
CommandArgs: cfg.CommandArgs,
MCPStrategyKey: cfg.MCPStrategy,
})
}
func (c *Controller) customTUIAgentDTO(ctx context.Context, agent *models.Agent) (*dto.AgentDTO, error) {
profiles, err := c.repo.ListAgentProfiles(ctx, agent.ID)
if err != nil {
return nil, err
}
result := toAgentDTO(agent, filterGlobalProfiles(profiles))
return &result, nil
}
@@ -2,10 +2,14 @@ package controller
import (
"context"
"errors"
"slices"
"testing"
"github.com/kandev/kandev/internal/agent/agents"
"github.com/kandev/kandev/internal/agent/mcpconfig"
"github.com/kandev/kandev/internal/agent/registry"
"github.com/kandev/kandev/internal/agent/settings/models"
"github.com/kandev/kandev/internal/common/logger"
)
@@ -99,3 +103,200 @@ func TestCreateCustomTUIAgent_NoCommandArgs(t *testing.T) {
t.Errorf("stored CommandArgs = %#v, want empty", got)
}
}
// --- MCP strategy ------------------------------------------------------------
// TestCreateCustomTUIAgent_MCPStrategyEnablesInjection is the test that
// distinguishes the fix from the bug in issue #2474. Before the fix a custom
// TUI agent was registered with no MCP strategy and supports_mcp=0, so
// applyPassthroughMCP was a no-op and the mcpconfig service refused the profile
// — kandev's own session tools were unreachable with no way to turn them on.
func TestCreateCustomTUIAgent_MCPStrategyEnablesInjection(t *testing.T) {
st := newFakeStore()
c := newCustomTUIController(t, st)
if _, err := c.CreateCustomTUIAgent(context.Background(), CreateCustomTUIAgentRequest{
DisplayName: "Fuel Claude",
Command: "fuelclaude",
MCPStrategy: mcpconfig.StrategyKeyClaude,
}); err != nil {
t.Fatalf("CreateCustomTUIAgent: %v", err)
}
ag, ok := c.agentRegistry.Get("fuel-claude")
if !ok {
t.Fatal("agent not registered")
}
pt, ok := ag.(agents.PassthroughAgent)
if !ok {
t.Fatal("agent does not implement PassthroughAgent")
}
// The strategy is what applyPassthroughMCP keys off; nil means the CLI is
// never told the per-session MCP server exists.
if pt.PassthroughConfig().MCPStrategy == nil {
t.Error("PassthroughConfig.MCPStrategy = nil, want the Claude strategy")
}
// supports_mcp gates the profile MCP editor and the mcpconfig service.
if stored := st.byName["fuel-claude"]; !stored.SupportsMCP {
t.Error("stored SupportsMCP = false, want true")
}
if got := st.byName["fuel-claude"].TUIConfig.MCPStrategy; got != mcpconfig.StrategyKeyClaude {
t.Errorf("stored MCPStrategy = %q, want %q", got, mcpconfig.StrategyKeyClaude)
}
}
// TestCreateCustomTUIAgent_NoStrategyLeavesMCPOff pins that the default is
// unchanged: a plain TUI tool that never speaks MCP must not advertise support.
func TestCreateCustomTUIAgent_NoStrategyLeavesMCPOff(t *testing.T) {
st := newFakeStore()
c := newCustomTUIController(t, st)
if _, err := c.CreateCustomTUIAgent(context.Background(), CreateCustomTUIAgentRequest{
DisplayName: "Plain Tool",
Command: "k9s",
}); err != nil {
t.Fatalf("CreateCustomTUIAgent: %v", err)
}
ag, _ := c.agentRegistry.Get("plain-tool")
if pt, ok := ag.(agents.PassthroughAgent); ok && pt.PassthroughConfig().MCPStrategy != nil {
t.Error("MCPStrategy set, want nil for an agent created without one")
}
if st.byName["plain-tool"].SupportsMCP {
t.Error("stored SupportsMCP = true, want false")
}
}
// TestCreateCustomTUIAgent_RejectsUnknownStrategy pins that a bad key fails at
// creation. Accepting it would persist an agent whose MCP is silently off
// forever, with no error surfaced anywhere.
func TestCreateCustomTUIAgent_RejectsUnknownStrategy(t *testing.T) {
st := newFakeStore()
c := newCustomTUIController(t, st)
_, err := c.CreateCustomTUIAgent(context.Background(), CreateCustomTUIAgentRequest{
DisplayName: "Typo Agent",
Command: "some-cli",
MCPStrategy: "cluade",
})
if !errors.Is(err, ErrUnknownMCPStrategy) {
t.Fatalf("err = %v, want ErrUnknownMCPStrategy", err)
}
if _, registered := c.agentRegistry.Get("typo-agent"); registered {
t.Error("agent was registered despite the rejected strategy")
}
if _, stored := st.byName["typo-agent"]; stored {
t.Error("agent was persisted despite the rejected strategy")
}
}
// TestSetCustomTUIAgentMCPStrategy_OptsInExistingAgent covers the upgrade path:
// every custom TUI agent created before this feature has an empty strategy, and
// there is no route to edit a tui_config. Without this they could only get MCP
// by being deleted and rebuilt, losing their profiles.
func TestSetCustomTUIAgentMCPStrategy_OptsInExistingAgent(t *testing.T) {
st := newFakeStore()
c := newCustomTUIController(t, st)
created, err := c.CreateCustomTUIAgent(context.Background(), CreateCustomTUIAgentRequest{
DisplayName: "Legacy Agent",
Command: "legacy-cli --flag",
})
if err != nil {
t.Fatalf("CreateCustomTUIAgent: %v", err)
}
got, err := c.SetCustomTUIAgentMCPStrategy(context.Background(), created.ID, mcpconfig.StrategyKeyCodex)
if err != nil {
t.Fatalf("SetCustomTUIAgentMCPStrategy: %v", err)
}
if !got.SupportsMCP {
t.Error("returned SupportsMCP = false, want true")
}
ag, ok := c.agentRegistry.Get("legacy-agent")
if !ok {
t.Fatal("agent missing from registry after re-register")
}
pt, ok := ag.(agents.PassthroughAgent)
if !ok {
t.Fatal("agent does not implement PassthroughAgent")
}
if pt.PassthroughConfig().MCPStrategy == nil {
t.Error("MCPStrategy = nil after opting in")
}
// The rest of the definition must survive the rebuild — the re-register
// reconstructs the agent from stored config, so a dropped field here would
// silently change the command the user launches.
want := []string{"legacy-cli", "--flag"}
if argv := ag.Runtime().Cmd.Args(); !slices.Equal(argv, want) {
t.Errorf("argv = %#v, want %#v", argv, want)
}
if stored := st.byName["legacy-agent"]; stored.TUIConfig.MCPStrategy != mcpconfig.StrategyKeyCodex {
t.Errorf("stored strategy = %q, want %q", stored.TUIConfig.MCPStrategy, mcpconfig.StrategyKeyCodex)
}
}
// TestSetCustomTUIAgentMCPStrategy_TurnsOff pins the reverse direction: clearing
// the strategy must also clear supports_mcp, or the profile MCP editor would
// stay visible for an agent that can no longer receive any servers.
func TestSetCustomTUIAgentMCPStrategy_TurnsOff(t *testing.T) {
st := newFakeStore()
c := newCustomTUIController(t, st)
created, err := c.CreateCustomTUIAgent(context.Background(), CreateCustomTUIAgentRequest{
DisplayName: "Toggle Agent",
Command: "toggle-cli",
MCPStrategy: mcpconfig.StrategyKeyClaude,
})
if err != nil {
t.Fatalf("CreateCustomTUIAgent: %v", err)
}
got, err := c.SetCustomTUIAgentMCPStrategy(context.Background(), created.ID, mcpconfig.StrategyKeyNone)
if err != nil {
t.Fatalf("SetCustomTUIAgentMCPStrategy: %v", err)
}
if got.SupportsMCP {
t.Error("returned SupportsMCP = true, want false after clearing the strategy")
}
ag, _ := c.agentRegistry.Get("toggle-agent")
if pt, ok := ag.(agents.PassthroughAgent); ok && pt.PassthroughConfig().MCPStrategy != nil {
t.Error("MCPStrategy still set after clearing")
}
}
func TestSetCustomTUIAgentMCPStrategy_RejectsNonTUIAgent(t *testing.T) {
st := newFakeStore()
c := newCustomTUIController(t, st)
builtin := &models.Agent{Name: "claude-acp"}
if err := st.CreateAgent(context.Background(), builtin); err != nil {
t.Fatalf("CreateAgent: %v", err)
}
_, err := c.SetCustomTUIAgentMCPStrategy(context.Background(), builtin.ID, mcpconfig.StrategyKeyClaude)
if !errors.Is(err, ErrNotCustomTUIAgent) {
t.Fatalf("err = %v, want ErrNotCustomTUIAgent", err)
}
}
func TestSetCustomTUIAgentMCPStrategy_RejectsUnknownStrategy(t *testing.T) {
st := newFakeStore()
c := newCustomTUIController(t, st)
created, err := c.CreateCustomTUIAgent(context.Background(), CreateCustomTUIAgentRequest{
DisplayName: "Guard Agent",
Command: "guard-cli",
})
if err != nil {
t.Fatalf("CreateCustomTUIAgent: %v", err)
}
if _, err := c.SetCustomTUIAgentMCPStrategy(context.Background(), created.ID, "nope"); !errors.Is(err, ErrUnknownMCPStrategy) {
t.Fatalf("err = %v, want ErrUnknownMCPStrategy", err)
}
if stored := st.byName["guard-agent"]; stored.TUIConfig.MCPStrategy != mcpconfig.StrategyKeyNone {
t.Errorf("stored strategy = %q, want unchanged", stored.TUIConfig.MCPStrategy)
}
}
@@ -783,6 +783,7 @@ func toAgentDTO(agent *models.Agent, profiles []*models.AgentProfile) dto.AgentD
Description: agent.TUIConfig.Description,
CommandArgs: agent.TUIConfig.CommandArgs,
WaitForTerminal: agent.TUIConfig.WaitForTerminal,
MCPStrategy: agent.TUIConfig.MCPStrategy,
}
}
return result
@@ -3,7 +3,9 @@ package controller
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"strconv"
"testing"
"time"
@@ -62,16 +64,41 @@ func newFakeStore() *fakeStore {
}
}
// copyAgent deep-copies an agent so the fake store behaves like a real one:
// callers get a snapshot, not a handle on stored state.
//
// This matters more than it looks. When Get returned the stored pointer and
// UpdateAgent was a no-op, a controller could mutate a nested field, never
// persist it, and every assertion still passed — the test was reading the same
// struct it had just written through. That masked UpdateAgent silently dropping
// the tui_config column. Round-tripping through JSON forces writes to go through
// UpdateAgent to be observable, the same as SQL.
func copyAgent(a *models.Agent) *models.Agent {
if a == nil {
return nil
}
data, err := json.Marshal(a)
if err != nil {
panic("fakeStore: marshal agent: " + err.Error())
}
var out models.Agent
if err := json.Unmarshal(data, &out); err != nil {
panic("fakeStore: unmarshal agent: " + err.Error())
}
return &out
}
func (f *fakeStore) CreateAgent(_ context.Context, a *models.Agent) error {
f.nextAgentID++
a.ID = "agent-" + strconv.Itoa(f.nextAgentID)
f.agents[a.ID] = a
f.byName[a.Name] = a
stored := copyAgent(a)
f.agents[a.ID] = stored
f.byName[a.Name] = stored
return nil
}
func (f *fakeStore) GetAgent(_ context.Context, id string) (*models.Agent, error) {
return f.agents[id], nil
return copyAgent(f.agents[id]), nil
}
func (f *fakeStore) GetAgentByName(_ context.Context, name string) (*models.Agent, error) {
@@ -79,13 +106,28 @@ func (f *fakeStore) GetAgentByName(_ context.Context, name string) (*models.Agen
return nil, f.getByNameErr
}
if a, ok := f.byName[name]; ok {
return a, nil
return copyAgent(a), nil
}
return nil, sql.ErrNoRows
}
func (f *fakeStore) UpdateAgent(context.Context, *models.Agent) error { return nil }
func (f *fakeStore) DeleteAgent(context.Context, string) error { return nil }
func (f *fakeStore) UpdateAgent(_ context.Context, a *models.Agent) error {
existing, ok := f.agents[a.ID]
if !ok {
return fmt.Errorf("agent not found: %s", a.ID)
}
stored := copyAgent(a)
// Mirror the real store's column list so the fake cannot accept a write the
// database would reject or ignore.
existing.WorkspaceID = stored.WorkspaceID
existing.SupportsMCP = stored.SupportsMCP
existing.MCPConfigPath = stored.MCPConfigPath
existing.TUIConfig = stored.TUIConfig
f.byName[existing.Name] = existing
return nil
}
func (f *fakeStore) DeleteAgent(context.Context, string) error { return nil }
func (f *fakeStore) ListAgents(_ context.Context) ([]*models.Agent, error) {
if f.listAgentsErr != nil {
@@ -79,6 +79,8 @@ type TUIConfigDTO struct {
Description string `json:"description,omitempty"`
CommandArgs []string `json:"command_args,omitempty"`
WaitForTerminal bool `json:"wait_for_terminal"`
// MCPStrategy is the selected MCP injection mechanism ("" = none).
MCPStrategy string `json:"mcp_strategy,omitempty"`
}
type AgentDTO struct {
@@ -6,6 +6,7 @@ import (
"github.com/gin-gonic/gin"
"go.uber.org/zap"
"github.com/kandev/kandev/internal/agent/mcpconfig"
"github.com/kandev/kandev/internal/agent/settings/controller"
ws "github.com/kandev/kandev/pkg/websocket"
)
@@ -19,6 +20,9 @@ type createCustomTUIAgentRequest struct {
// which the registry splits on whitespace — each entry is one argv element,
// so an argument containing a space can only be expressed here.
CommandArgs []string `json:"command_args"`
// MCPStrategy names how the wrapped CLI loads MCP servers, so kandev can
// point it at the per-session server. Empty (the default) = no MCP tools.
MCPStrategy string `json:"mcp_strategy"`
}
func (r createCustomTUIAgentRequest) toControllerRequest() controller.CreateCustomTUIAgentRequest {
@@ -28,6 +32,7 @@ func (r createCustomTUIAgentRequest) toControllerRequest() controller.CreateCust
Command: r.Command,
Description: r.Description,
CommandArgs: r.CommandArgs,
MCPStrategy: r.MCPStrategy,
}
}
@@ -49,7 +54,7 @@ func (h *Handlers) httpCreateCustomTUIAgent(c *gin.Context) {
resp, err := h.controller.CreateCustomTUIAgent(c.Request.Context(), body.toControllerRequest())
if err != nil {
switch err {
case controller.ErrInvalidSlug, controller.ErrCommandRequired:
case controller.ErrInvalidSlug, controller.ErrCommandRequired, controller.ErrUnknownMCPStrategy:
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
case controller.ErrAgentAlreadyExists:
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
@@ -82,3 +87,61 @@ func (h *Handlers) httpCreateCustomTUIAgent(c *gin.Context) {
c.JSON(http.StatusOK, resp)
}
type updateCustomTUIAgentMCPRequest struct {
MCPStrategy string `json:"mcp_strategy"`
}
// httpListMCPStrategies serves the selectable MCP injection strategies. The
// list is served rather than hardcoded in the client so a strategy added in Go
// becomes selectable without a matching frontend edit, and so the UI can show
// each strategy's own description of the mechanism it uses.
func (h *Handlers) httpListMCPStrategies(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"strategies": mcpconfig.StrategyOptions()})
}
// httpUpdateCustomTUIAgentMCP changes an existing custom TUI agent's MCP
// injection strategy. The rest of a tui_config stays immutable; this exists so
// agents created before the field existed can opt in without being deleted and
// rebuilt.
func (h *Handlers) httpUpdateCustomTUIAgentMCP(c *gin.Context) {
var body updateCustomTUIAgentMCPRequest
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"})
return
}
resp, err := h.controller.SetCustomTUIAgentMCPStrategy(c.Request.Context(), c.Param("id"), body.MCPStrategy)
if err != nil {
switch err {
case controller.ErrUnknownMCPStrategy, controller.ErrNotCustomTUIAgent:
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
case controller.ErrAgentNotFound:
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
default:
h.logger.Error("failed to update custom TUI agent MCP strategy", zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
}
return
}
// The agent's supports_mcp and tui_config both changed; refresh the agent
// list so open settings tabs show the MCP editor appearing or disappearing
// without a reload.
//
// ActionAgentSettingsUpdated, not ActionAgentUpdated: the latter is a
// runtime status ping shaped {agentId, status} feeding a different store
// slice, so sending an agent record on it inserts {id: undefined} into the
// runtime list instead of updating settings.
//
//ws:global agent settings are not workspace-scoped — the agents list is a
// global settings surface, so every connected client's copy is stale after
// this change. Matches the sibling agent.profile.* and agent.available.updated
// broadcasts in this package.
if h.hub != nil {
notification, _ := ws.NewNotification(ws.ActionAgentSettingsUpdated, gin.H{"agent": resp})
h.hub.Broadcast(notification)
}
c.JSON(http.StatusOK, resp)
}
@@ -58,6 +58,8 @@ func (h *Handlers) registerHTTP(router *gin.Engine) {
api.GET("/agents", h.httpListAgents)
api.POST("/agents", h.interlock, h.httpCreateAgent)
api.POST("/agents/tui", h.interlock, h.httpCreateCustomTUIAgent)
api.GET("/agents/tui/mcp-strategies", h.httpListMCPStrategies)
api.PATCH("/agents/tui/:id/mcp", h.interlock, h.httpUpdateCustomTUIAgentMCP)
api.GET("/agents/:id", h.httpGetAgent)
api.PATCH("/agents/:id", h.interlock, h.httpUpdateAgent)
api.DELETE("/agents/:id", h.interlock, h.httpDeleteAgent)
@@ -29,6 +29,13 @@ type TUIConfigJSON struct {
Description string `json:"description,omitempty"`
CommandArgs []string `json:"command_args,omitempty"`
WaitForTerminal bool `json:"wait_for_terminal"`
// MCPStrategy selects how kandev injects its per-session MCP server into
// the wrapped CLI (mcpconfig.StrategyKey*). Empty — the value every row
// written before this field existed decodes to — means no injection.
// Stored here rather than as an agent column because it is part of the
// user's CLI definition, and because tui_config is JSON: adding the field
// needs no migration.
MCPStrategy string `json:"mcp_strategy,omitempty"`
}
type AgentProfile struct {
@@ -351,6 +351,22 @@ func (r *sqliteRepository) Close() error {
return r.db.Close()
}
// marshalTUIConfig renders a TUI config for the nullable tui_config column.
// Shared by CreateAgent and UpdateAgent so the two writers cannot drift — the
// column being absent from one of them is exactly how an editable field was
// lost on save.
func marshalTUIConfig(cfg *models.TUIConfigJSON) (*string, error) {
if cfg == nil {
return nil, nil
}
data, err := json.Marshal(cfg)
if err != nil {
return nil, fmt.Errorf("failed to marshal tui_config: %w", err)
}
s := string(data)
return &s, nil
}
func (r *sqliteRepository) CreateAgent(ctx context.Context, agent *models.Agent) error {
if agent.ID == "" {
agent.ID = uuid.New().String()
@@ -358,16 +374,11 @@ func (r *sqliteRepository) CreateAgent(ctx context.Context, agent *models.Agent)
now := time.Now().UTC()
agent.CreatedAt = now
agent.UpdatedAt = now
var tuiConfigJSON *string
if agent.TUIConfig != nil {
data, err := json.Marshal(agent.TUIConfig)
if err != nil {
return fmt.Errorf("failed to marshal tui_config: %w", err)
}
s := string(data)
tuiConfigJSON = &s
tuiConfigJSON, err := marshalTUIConfig(agent.TUIConfig)
if err != nil {
return err
}
_, err := r.db.ExecContext(ctx, r.db.Rebind(`
_, err = r.db.ExecContext(ctx, r.db.Rebind(`
INSERT INTO agents (id, name, workspace_id, supports_mcp, mcp_config_path, tui_config, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`), agent.ID, agent.Name, agent.WorkspaceID, dialect.BoolToInt(agent.SupportsMCP), agent.MCPConfigPath, tuiConfigJSON, agent.CreatedAt, agent.UpdatedAt)
@@ -392,10 +403,18 @@ func (r *sqliteRepository) GetAgentByName(ctx context.Context, name string) (*mo
func (r *sqliteRepository) UpdateAgent(ctx context.Context, agent *models.Agent) error {
agent.UpdatedAt = time.Now().UTC()
// tui_config is written here as well as in CreateAgent: a custom TUI agent's
// MCP strategy is editable after creation, and omitting the column silently
// discarded that edit — the row kept supports_mcp while the strategy it was
// derived from reverted on the next boot.
tuiConfigJSON, err := marshalTUIConfig(agent.TUIConfig)
if err != nil {
return err
}
result, err := r.db.ExecContext(ctx, r.db.Rebind(`
UPDATE agents SET workspace_id = ?, supports_mcp = ?, mcp_config_path = ?, updated_at = ?
UPDATE agents SET workspace_id = ?, supports_mcp = ?, mcp_config_path = ?, tui_config = ?, updated_at = ?
WHERE id = ?
`), agent.WorkspaceID, dialect.BoolToInt(agent.SupportsMCP), agent.MCPConfigPath, agent.UpdatedAt, agent.ID)
`), agent.WorkspaceID, dialect.BoolToInt(agent.SupportsMCP), agent.MCPConfigPath, tuiConfigJSON, agent.UpdatedAt, agent.ID)
if err != nil {
return err
}
@@ -0,0 +1,127 @@
package store
import (
"context"
"testing"
"github.com/kandev/kandev/internal/agent/settings/models"
)
// TestUpdateAgent_PersistsTUIConfig pins the column that UpdateAgent used to
// omit. A custom TUI agent's MCP strategy is editable after creation, and the
// UPDATE statement listed only workspace_id/supports_mcp/mcp_config_path — so
// the edit was accepted, supports_mcp was written, and the strategy it is
// derived from silently reverted on the next read. Round-tripping through the
// real store is the only thing that catches this: a fake repository that
// returns the same pointer it was handed reports success either way.
func TestUpdateAgent_PersistsTUIConfig(t *testing.T) {
repo := newTestRepo(t)
ctx := context.Background()
agent := &models.Agent{
Name: "fuel-claude",
TUIConfig: &models.TUIConfigJSON{
Command: "fuelclaude",
DisplayName: "Fuel Claude",
WaitForTerminal: true,
},
}
if err := repo.CreateAgent(ctx, agent); err != nil {
t.Fatalf("CreateAgent: %v", err)
}
agent.TUIConfig.MCPStrategy = "claude"
agent.SupportsMCP = true
if err := repo.UpdateAgent(ctx, agent); err != nil {
t.Fatalf("UpdateAgent: %v", err)
}
// Re-read from the database rather than inspecting the struct we just
// mutated — that distinction is the entire point of this test.
stored, err := repo.GetAgent(ctx, agent.ID)
if err != nil {
t.Fatalf("GetAgent: %v", err)
}
if stored.TUIConfig == nil {
t.Fatal("TUIConfig = nil after update")
}
if stored.TUIConfig.MCPStrategy != "claude" {
t.Errorf("MCPStrategy = %q, want %q", stored.TUIConfig.MCPStrategy, "claude")
}
if !stored.SupportsMCP {
t.Error("SupportsMCP = false, want true")
}
// The rest of the config must survive the write: UpdateAgent rewrites the
// whole JSON blob, so a dropped field here would quietly change the command
// the user launches.
if stored.TUIConfig.Command != "fuelclaude" {
t.Errorf("Command = %q, want %q", stored.TUIConfig.Command, "fuelclaude")
}
if stored.TUIConfig.DisplayName != "Fuel Claude" {
t.Errorf("DisplayName = %q, want %q", stored.TUIConfig.DisplayName, "Fuel Claude")
}
if !stored.TUIConfig.WaitForTerminal {
t.Error("WaitForTerminal = false, want true")
}
}
// TestUpdateAgent_ClearsTUIStrategy pins the off direction, which shares the
// same write path.
func TestUpdateAgent_ClearsTUIStrategy(t *testing.T) {
repo := newTestRepo(t)
ctx := context.Background()
agent := &models.Agent{
Name: "toggle-agent",
SupportsMCP: true,
TUIConfig: &models.TUIConfigJSON{
Command: "toggle-cli",
DisplayName: "Toggle",
MCPStrategy: "codex",
},
}
if err := repo.CreateAgent(ctx, agent); err != nil {
t.Fatalf("CreateAgent: %v", err)
}
agent.TUIConfig.MCPStrategy = ""
agent.SupportsMCP = false
if err := repo.UpdateAgent(ctx, agent); err != nil {
t.Fatalf("UpdateAgent: %v", err)
}
stored, err := repo.GetAgent(ctx, agent.ID)
if err != nil {
t.Fatalf("GetAgent: %v", err)
}
if stored.TUIConfig.MCPStrategy != "" {
t.Errorf("MCPStrategy = %q, want empty", stored.TUIConfig.MCPStrategy)
}
if stored.SupportsMCP {
t.Error("SupportsMCP = true, want false")
}
}
// TestUpdateAgent_NonTUIAgentKeepsNullConfig guards the nil path: a built-in
// agent has no tui_config and must not gain one.
func TestUpdateAgent_NonTUIAgentKeepsNullConfig(t *testing.T) {
repo := newTestRepo(t)
ctx := context.Background()
agent := &models.Agent{Name: "claude-acp"}
if err := repo.CreateAgent(ctx, agent); err != nil {
t.Fatalf("CreateAgent: %v", err)
}
agent.SupportsMCP = true
if err := repo.UpdateAgent(ctx, agent); err != nil {
t.Fatalf("UpdateAgent: %v", err)
}
stored, err := repo.GetAgent(ctx, agent.ID)
if err != nil {
t.Fatalf("GetAgent: %v", err)
}
if stored.TUIConfig != nil {
t.Errorf("TUIConfig = %+v, want nil", stored.TUIConfig)
}
}
+9 -3
View File
@@ -392,9 +392,15 @@ func loadCustomTUIAgents(ctx context.Context, repos *Repositories, agentRegistry
continue
}
cfg := agent.TUIConfig
if regErr := agentRegistry.RegisterCustomTUIAgent(
agent.Name, cfg.DisplayName, cfg.Command, cfg.Description, cfg.Model, cfg.CommandArgs,
); regErr != nil {
if regErr := agentRegistry.RegisterCustomTUIAgent(registry.CustomTUIAgentSpec{
Slug: agent.Name,
DisplayName: cfg.DisplayName,
Command: cfg.Command,
Description: cfg.Description,
Model: cfg.Model,
CommandArgs: cfg.CommandArgs,
MCPStrategyKey: cfg.MCPStrategy,
}); regErr != nil {
log.Warn("failed to register custom TUI agent",
zap.String("name", agent.Name), zap.Error(regErr))
}
@@ -455,7 +455,7 @@ func TestProcessOnEnterConfigureSessionAmbiguousAgentFamilyWarns(t *testing.T) {
shadowed := registry.NewRegistry(testLogger())
shadowed.LoadDefaults()
if err := shadowed.RegisterCustomTUIAgent(tc.slug, tc.displayName, "claude-tui", "", "", nil); err != nil {
if err := shadowed.RegisterCustomTUIAgent(registry.CustomTUIAgentSpec{Slug: tc.slug, DisplayName: tc.displayName, Command: "claude-tui"}); err != nil {
t.Fatalf("register custom agent: %v", err)
}
svc.agentFamilyResolver = shadowed
@@ -495,7 +495,7 @@ func TestProcessOnEnterConfigureSessionUnrelatedAmbiguousRuleStillApplies(t *tes
shadowed := registry.NewRegistry(testLogger())
shadowed.LoadDefaults()
// "Claude" now names both claude-acp and this agent — and neither is codex-acp.
if err := shadowed.RegisterCustomTUIAgent("aaa-claude", "Claude", "claude-tui", "", "", nil); err != nil {
if err := shadowed.RegisterCustomTUIAgent(registry.CustomTUIAgentSpec{Slug: "aaa-claude", DisplayName: "Claude", Command: "claude-tui"}); err != nil {
t.Fatalf("register custom agent: %v", err)
}
svc.agentFamilyResolver = shadowed
@@ -561,7 +561,7 @@ func TestProcessOnEnterConfigureSessionRelevantAmbiguityRefusesDespiteExactRule(
shadowed := registry.NewRegistry(testLogger())
shadowed.LoadDefaults()
if err := shadowed.RegisterCustomTUIAgent("aaa-claude", "Claude", "claude-tui", "", "", nil); err != nil {
if err := shadowed.RegisterCustomTUIAgent(registry.CustomTUIAgentSpec{Slug: "aaa-claude", DisplayName: "Claude", Command: "claude-tui"}); err != nil {
t.Fatalf("register custom agent: %v", err)
}
svc.agentFamilyResolver = shadowed
@@ -603,7 +603,7 @@ func TestProcessOnEnterConfigureSessionAmbiguousSessionFamilyRefuses(t *testing.
shadowed := registry.NewRegistry(testLogger())
shadowed.LoadDefaults()
// "Claude" now answers for both claude-acp and this agent.
if err := shadowed.RegisterCustomTUIAgent("aaa-claude", "Claude", "claude-tui", "", "", nil); err != nil {
if err := shadowed.RegisterCustomTUIAgent(registry.CustomTUIAgentSpec{Slug: "aaa-claude", DisplayName: "Claude", Command: "claude-tui"}); err != nil {
t.Fatalf("register custom agent: %v", err)
}
svc.agentFamilyResolver = shadowed
+6
View File
@@ -253,6 +253,12 @@ const (
ActionAgentProfileCreated = "agent.profile.created"
ActionAgentProfileUpdated = "agent.profile.updated"
// ActionAgentSettingsUpdated carries a full agent settings record
// (dto.AgentDTO) after a settings-side change such as a custom TUI agent's
// MCP strategy. Distinct from ActionAgentUpdated, which is a runtime status
// ping keyed by {agentId, status} and feeds a different store slice.
ActionAgentSettingsUpdated = "agent.settings.updated"
// Permission request actions (agent -> user -> agent)
ActionPermissionRequested = "permission.requested" // Agent requesting permission
ActionPermissionRespond = "permission.respond" // User responding to permission request
@@ -363,6 +363,20 @@ function useAgentSaveRevision(agent: DraftAgent) {
return { revision, saved, setSaved };
}
/**
* Explains why the shared Save control is blocked, or undefined when it is not.
* Extracted so AgentSetupForm stays within the file's function-length limit.
*/
function resolveSaveInvalidReason(
t: (key: string) => string,
profilesValid: boolean,
hasInvalidMcpConfig: boolean,
): string | undefined {
if (!profilesValid) return t("agents:everyProfileNeedsNameAndModel");
if (hasInvalidMcpConfig) return t("agents:fixInvalidMcpConfig");
return undefined;
}
function AgentSetupForm({
initialAgent,
savedAgent,
@@ -420,9 +434,7 @@ function AgentSetupForm({
if (savedDraft) saveRevision.setSaved(JSON.stringify(savedDraft));
};
const profilesValid = areAgentProfilesValid(draftAgent);
let saveInvalidReason: string | undefined;
if (!profilesValid) saveInvalidReason = t("agents:everyProfileNeedsNameAndModel");
else if (hasInvalidMcpConfig) saveInvalidReason = t("agents:fixInvalidMcpConfig");
const saveInvalidReason = resolveSaveInvalidReason(t, profilesValid, hasInvalidMcpConfig);
useSettingsSaveContributor({
id: `agent:${draftAgent.id}`,
revision: saveRevision.revision,
@@ -0,0 +1,109 @@
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { StateProvider } from "@/components/state-provider";
import { ToastProvider } from "@/components/toast-provider";
import type { Agent } from "@/lib/types/http-agents";
// The strategy list is fetched on mount; the mount assertion below is about
// whether the control is reachable at all, not about the option list.
vi.mock("@/lib/api/domains/settings-api", async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
listMCPStrategies: vi.fn(async () => [{ key: "claude", description: "a config file" }]),
updateCustomTUIAgentMCPStrategy: vi.fn(),
}));
const { CustomTUIMcpCard } = await import("@/components/settings/custom-tui-mcp-card");
const { InstalledAgentCard } = await import("@/components/settings/installed-agent-card");
const TIMESTAMP = "2026-08-13T10:00:00Z";
const STRATEGY_SELECT = "mcp-strategy-select";
function tuiAgent(overrides: Partial<Agent> = {}): Agent {
return {
id: "agent-1",
name: "fuel-claude",
supports_mcp: false,
tui_config: {
command: "fuelclaude",
display_name: "Fuel Claude",
wait_for_terminal: true,
},
profiles: [],
created_at: TIMESTAMP,
updated_at: TIMESTAMP,
...overrides,
} as Agent;
}
function discovery(name: string) {
return {
name,
supports_mcp: false,
mcp_config_path: null,
installation_paths: [],
available: true,
matched_path: "/usr/bin/fuelclaude",
};
}
/**
* Composes the card exactly as the agents index page does: inside
* InstalledAgentCard, keyed off the saved agent record.
*
* The point of asserting through this composition rather than rendering
* CustomTUIMcpCard alone is that the control was previously mounted on the
* agent detail route, which redirects saved agents back to the index before the
* form mounts. Testing the leaf in isolation passed while the control was
* reachable from nowhere.
*/
function renderIndexComposition(saved: Agent | undefined, name = "fuel-claude") {
return render(
<StateProvider>
<ToastProvider>
<InstalledAgentCard
agent={discovery(name) as never}
savedAgent={saved}
displayName="Fuel Claude"
>
<CustomTUIMcpCard agent={saved} />
</InstalledAgentCard>
</ToastProvider>
</StateProvider>,
);
}
describe("custom TUI MCP control on the agents index", () => {
afterEach(cleanup);
it("renders for a saved custom TUI agent", async () => {
renderIndexComposition(tuiAgent());
expect(await screen.findByTestId("agent-mcp-strategy-fuel-claude")).toBeTruthy();
expect(screen.getByTestId(STRATEGY_SELECT)).toBeTruthy();
});
it("shows the agent's stored strategy as the current value", async () => {
renderIndexComposition(
tuiAgent({
supports_mcp: true,
tui_config: {
command: "fuelclaude",
display_name: "Fuel Claude",
wait_for_terminal: true,
mcp_strategy: "claude",
},
}),
);
const trigger = await screen.findByTestId(STRATEGY_SELECT);
expect(trigger.textContent).toContain("claude");
});
it("renders nothing for a built-in (non-TUI) agent", () => {
renderIndexComposition(tuiAgent({ tui_config: null }), "claude-acp");
expect(screen.queryByTestId(STRATEGY_SELECT)).toBeNull();
});
it("renders nothing for an agent that has not been saved yet", () => {
renderIndexComposition(undefined);
expect(screen.queryByTestId(STRATEGY_SELECT)).toBeNull();
});
});
+3
View File
@@ -28,6 +28,7 @@ import { useAvailableAgents } from "@/hooks/domains/settings/use-available-agent
import { AddTUIAgentDialog } from "@/components/settings/add-tui-agent-dialog";
import { AgentProfilesSubList } from "@/components/settings/agents/agent-profiles-section";
import { HostShellDialog } from "@/components/settings/host-shell-dialog";
import { CustomTUIMcpCard } from "@/components/settings/custom-tui-mcp-card";
import { InstalledAgentCard } from "@/components/settings/installed-agent-card";
import { AGENTS_BROWSE_SETTINGS_HREF } from "@/lib/settings-discovery/catalog/agents";
import {
@@ -242,6 +243,7 @@ function InstalledAgentsSection({
savedAgent={savedAgentsByName.get(agent.name)}
agentName={agent.name}
/>
<CustomTUIMcpCard agent={savedAgentsByName.get(agent.name)} />
</InstalledAgentCard>
))}
</div>
@@ -296,6 +298,7 @@ function useAgentPageState() {
display_name: string;
model?: string;
command: string;
mcp_strategy?: string;
}) => {
await createCustomTUIAgent(data);
const [discoveryResp, agentsResp, availableResp] = await Promise.all([
@@ -14,8 +14,14 @@ import {
} from "@kandev/ui/dialog";
import { Input } from "@kandev/ui/input";
import { Label } from "@kandev/ui/label";
import { MCPStrategySelect, useMCPStrategies } from "./mcp-strategy-select";
type TUIAgentFormData = { display_name: string; model?: string; command: string };
type TUIAgentFormData = {
display_name: string;
model?: string;
command: string;
mcp_strategy?: string;
};
// The placeholder kandev substitutes into the TUI command. The user types it
// verbatim, so it is interpolated as a value rather than written into the
@@ -32,6 +38,7 @@ type DialogHandlersParams = {
displayName: string;
model: string;
command: string;
mcpStrategy: string;
setError: React.Dispatch<React.SetStateAction<string | null>>;
setLoading: React.Dispatch<React.SetStateAction<boolean>>;
onSubmit: (data: TUIAgentFormData) => Promise<void>;
@@ -43,6 +50,7 @@ function useDialogHandlers({
displayName,
model,
command,
mcpStrategy,
setError,
setLoading,
onSubmit,
@@ -65,6 +73,7 @@ function useDialogHandlers({
display_name: displayName.trim(),
model: model.trim() || undefined,
command: command.trim(),
mcp_strategy: mcpStrategy || undefined,
});
reset();
onOpenChange(false);
@@ -88,13 +97,18 @@ export function AddTUIAgentDialog({ open, onOpenChange, onSubmit }: AddTUIAgentD
const [displayName, setDisplayName] = useState("");
const [model, setModel] = useState("");
const [command, setCommand] = useState("");
const [mcpStrategy, setMcpStrategy] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// Only fetch the strategy list while the dialog is open.
const strategies = useMCPStrategies(open);
const reset = () => {
setDisplayName("");
setModel("");
setCommand("");
setMcpStrategy("");
setError(null);
setLoading(false);
};
@@ -103,6 +117,7 @@ export function AddTUIAgentDialog({ open, onOpenChange, onSubmit }: AddTUIAgentD
displayName,
model,
command,
mcpStrategy,
setError,
setLoading,
onSubmit,
@@ -153,6 +168,12 @@ export function AddTUIAgentDialog({ open, onOpenChange, onSubmit }: AddTUIAgentD
</Trans>
</p>
</div>
<MCPStrategySelect
id="tui-mcp-strategy"
value={mcpStrategy}
onChange={setMcpStrategy}
strategies={strategies}
/>
{error && <p className="text-sm text-destructive">{error}</p>}
</div>
<DialogFooter>
@@ -0,0 +1,78 @@
"use client";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useAppStore } from "@/components/state-provider";
import { useToast } from "@/components/toast-provider";
import { MCPStrategySelect, useMCPStrategies } from "@/components/settings/mcp-strategy-select";
import { updateCustomTUIAgentMCPStrategy } from "@/lib/api/domains/settings-api";
import type { Agent } from "@/lib/types/http-agents";
type CustomTUIMcpCardProps = {
/** Renders nothing unless this is a saved custom TUI agent. */
agent: Agent | null | undefined;
};
/**
* Lets the user change a custom TUI agent's MCP injection strategy.
*
* Self-contained (own store write, own toast) so it can mount wherever the
* agent is actually shown. It first lived on the agent detail route, which
* redirects saved agents straight back to the index — so it rendered nowhere a
* user would find it.
*
* Saves immediately rather than registering with the shared settings-save
* coordinator, matching the profile-enabled toggle: the strategy is agent-level
* state outside the profile draft, the backend rebuilds the registry entry as
* part of the write, and turning it on is what makes the per-profile MCP editor
* appear — deferring that behind a separate Save would be confusing.
*/
export function CustomTUIMcpCard({ agent }: CustomTUIMcpCardProps) {
const { t } = useTranslation();
const { toast } = useToast();
const savedAgents = useAppStore((state) => state.settingsAgents.items);
const setSettingsAgents = useAppStore((state) => state.setSettingsAgents);
// Gated: this card is rendered once per agent on the index, and hooks run
// before the early return below, so an unconditional fetch would issue one
// identical request per built-in agent as well.
const isCustomTUIAgent = Boolean(agent?.tui_config);
const strategies = useMCPStrategies(isCustomTUIAgent);
const [saving, setSaving] = useState(false);
if (!agent?.tui_config) return null;
const handleChange = async (strategy: string) => {
setSaving(true);
try {
const updated = await updateCustomTUIAgentMCPStrategy(agent.id, strategy);
// Write through the store rather than waiting on the WS echo so the
// control reflects the save immediately. Profiles are preserved: this
// endpoint only changes agent-level settings.
setSettingsAgents(
savedAgents.map((item) =>
item.id === updated.id ? { ...item, ...updated, profiles: item.profiles } : item,
),
);
} catch (error) {
toast({
title: t("agents:failedToSaveAgent"),
description: error instanceof Error ? error.message : t("agents:requestFailed"),
variant: "error",
});
} finally {
setSaving(false);
}
};
return (
<div className="border-t px-3 py-2.5" data-testid={`agent-mcp-strategy-${agent.name}`}>
<MCPStrategySelect
id={`agent-mcp-strategy-${agent.id}`}
value={agent.tui_config.mcp_strategy}
onChange={handleChange}
strategies={strategies}
disabled={saving}
/>
</div>
);
}
@@ -0,0 +1,31 @@
import { describe, expect, it } from "vitest";
import { fromStrategyValue, MCP_STRATEGY_NONE, toStrategyValue } from "./mcp-strategy-select";
// Radix Select cannot hold an empty-string value, so "no MCP injection" needs a
// sentinel in the control and "" on the wire. If the mapping leaks, the backend
// receives "__none__", rejects it as an unknown strategy, and the user cannot
// turn MCP off again.
describe("MCP strategy value mapping", () => {
it("maps an unset strategy to the sentinel", () => {
expect(toStrategyValue(undefined)).toBe(MCP_STRATEGY_NONE);
expect(toStrategyValue("")).toBe(MCP_STRATEGY_NONE);
});
it("passes a real strategy key through unchanged", () => {
expect(toStrategyValue("claude")).toBe("claude");
});
it("maps the sentinel back to an empty key for the API", () => {
expect(fromStrategyValue(MCP_STRATEGY_NONE)).toBe("");
});
it("passes a real strategy key back unchanged", () => {
expect(fromStrategyValue("codex")).toBe("codex");
});
it("round-trips every value the control can hold", () => {
for (const key of ["", "claude", "codex", "cursor", "opencode", "pi"]) {
expect(fromStrategyValue(toStrategyValue(key))).toBe(key);
}
});
});
@@ -0,0 +1,114 @@
"use client";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Label } from "@kandev/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@kandev/ui/select";
import { listMCPStrategies } from "@/lib/api/domains/settings-api";
import type { MCPStrategyOption } from "@/lib/types/http-agents";
/**
* Radix Select cannot hold an empty-string value, so the "off" choice needs a
* sentinel. Callers convert between it and "" at the API boundary via
* {@link toStrategyValue} / {@link fromStrategyValue}. It is a control value,
* never displayed and never translated.
*/
export const MCP_STRATEGY_NONE = "__none__";
/** Maps a stored strategy key ("" = off) to the Select's value. */
export function toStrategyValue(strategy: string | undefined): string {
return strategy ? strategy : MCP_STRATEGY_NONE;
}
/** Maps the Select's value back to a stored strategy key ("" = off). */
export function fromStrategyValue(value: string): string {
return value === MCP_STRATEGY_NONE ? "" : value;
}
/**
* Loads the selectable MCP strategies from the backend.
*
* The list is fetched rather than hardcoded so a strategy added in Go becomes
* selectable without a matching frontend edit — and so an option can never be
* offered that the backend would reject on save.
*/
export function useMCPStrategies(enabled: boolean) {
const [strategies, setStrategies] = useState<MCPStrategyOption[]>([]);
useEffect(() => {
if (!enabled) return;
let active = true;
listMCPStrategies()
.then((options) => {
if (active) setStrategies(options);
})
.catch(() => {
// Non-fatal: the picker falls back to the "off" choice only. Creating
// an agent without MCP still works, which is the pre-existing behavior.
if (active) setStrategies([]);
});
return () => {
active = false;
};
}, [enabled]);
return strategies;
}
type MCPStrategySelectProps = {
id: string;
/** Stored strategy key; "" or undefined means no injection. */
value: string | undefined;
/** Receives the stored strategy key ("" when the user picks "off"). */
onChange: (strategy: string) => void;
strategies: MCPStrategyOption[];
disabled?: boolean;
};
/**
* Picks how kandev injects its per-session MCP server into a custom TUI agent's
* wrapped CLI.
*
* This is an explicit choice rather than something inferred from the command,
* because the command is arbitrary (`zsh -ic "fuelclaude --model opus"` is a
* real example) and a wrong guess fails silently: kandev writes a config file,
* the CLI ignores it, and no error surfaces anywhere.
*/
export function MCPStrategySelect({
id,
value,
onChange,
strategies,
disabled,
}: MCPStrategySelectProps) {
const { t } = useTranslation();
return (
<div className="space-y-2">
<Label htmlFor={id}>{t("agents:mcpStrategy")}</Label>
<Select
value={toStrategyValue(value)}
onValueChange={(next) => onChange(fromStrategyValue(next))}
disabled={disabled}
>
<SelectTrigger id={id} className="cursor-pointer" data-testid="mcp-strategy-select">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={MCP_STRATEGY_NONE} className="cursor-pointer">
{t("agents:mcpStrategyNone")}
</SelectItem>
{strategies.map((option) => (
<SelectItem key={option.key} value={option.key} className="cursor-pointer">
{t("agents:mcpStrategyOption", {
name: option.key,
mechanism: option.description,
})}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">{t("agents:mcpStrategyHelp")}</p>
</div>
);
}
+1
View File
@@ -537,6 +537,7 @@ export const i18nGuardFiles = [
"components/settings/command-prefix-field.tsx",
"components/settings/install-agent-card.tsx",
"components/settings/installed-agent-card.tsx",
"components/settings/mcp-strategy-select.tsx",
"components/settings/mode-combobox.tsx",
"components/settings/profile-capability-helpers.tsx",
"components/settings/profile-form-fields.tsx",
+43 -1
View File
@@ -22,6 +22,7 @@ import type {
AgentModelConfigResponse,
ResolveAgentModelConfigRequest,
} from "@/lib/types/http";
import type { MCPStrategyOption } from "@/lib/types/http-agents";
import type {
MessageQueueSettingsPatch,
MessageQueueSettingsResponse,
@@ -332,7 +333,13 @@ export async function getInstallJob(
}
export async function createCustomTUIAgent(
payload: { display_name: string; model?: string; command: string; description?: string },
payload: {
display_name: string;
model?: string;
command: string;
description?: string;
mcp_strategy?: string;
},
options?: ApiRequestOptions,
): Promise<Agent> {
const res = await fetchJson<Agent>("/api/v1/agents/tui", {
@@ -342,6 +349,41 @@ export async function createCustomTUIAgent(
return normalizeAgentResponse(res);
}
/**
* Lists the MCP injection mechanisms a custom TUI agent can use. Served by the
* backend so the option list cannot drift from the strategies that actually
* resolve at launch.
*/
export async function listMCPStrategies(options?: ApiRequestOptions): Promise<MCPStrategyOption[]> {
const res = await fetchJson<{ strategies?: MCPStrategyOption[] }>(
"/api/v1/agents/tui/mcp-strategies",
options,
);
return res.strategies ?? [];
}
/**
* Changes an existing custom TUI agent's MCP injection strategy. Pass an empty
* string to turn injection off. This is the only mutable part of a tui_config —
* it exists so agents created before the field existed can opt in without being
* deleted and rebuilt.
*/
export async function updateCustomTUIAgentMCPStrategy(
agentId: string,
mcpStrategy: string,
options?: ApiRequestOptions,
): Promise<Agent> {
const res = await fetchJson<Agent>(`/api/v1/agents/tui/${agentId}/mcp`, {
...options,
init: {
method: "PATCH",
body: JSON.stringify({ mcp_strategy: mcpStrategy }),
...(options?.init ?? {}),
},
});
return normalizeAgentResponse(res);
}
export async function fetchDynamicModels(
agentName: string,
options?: ApiRequestOptions & { refresh?: boolean },
+11
View File
@@ -15,6 +15,7 @@ import type { RunEventAppendedPayload } from "./run-events";
export type { RunEventAppendedPayload } from "./run-events";
import type {
Agent,
AvailableAgent,
ForegroundActivity,
TaskPendingAction,
@@ -120,6 +121,15 @@ export type AgentUpdatePayload = {
message?: string;
};
/**
* A full agent settings record after an agent-level settings change (e.g. a
* custom TUI agent's MCP strategy). Distinct from AgentUpdatePayload, which is
* the runtime status ping.
*/
export type AgentSettingsUpdatedPayload = {
agent: Agent;
};
export type AgentAvailableUpdatedPayload = {
agents: AvailableAgent[];
tools?: ToolStatus[];
@@ -345,6 +355,7 @@ export type BackendMessageMap = SessionBackendMessageMap &
>;
"task.plan.reverted": BackendMessage<"task.plan.reverted", TaskPlanRevisionEventPayload>;
"agent.updated": BackendMessage<"agent.updated", AgentUpdatePayload>;
"agent.settings.updated": BackendMessage<"agent.settings.updated", AgentSettingsUpdatedPayload>;
"agent.available.updated": BackendMessage<
"agent.available.updated",
AgentAvailableUpdatedPayload
+14
View File
@@ -26,6 +26,20 @@ export type TUIConfig = {
description?: string;
command_args?: string[];
wait_for_terminal: boolean;
/**
* How kandev injects its per-session MCP server into the wrapped CLI.
* Empty/absent means no injection — the agent gets no kandev task tools.
* Keys come from `GET /api/v1/agents/tui/mcp-strategies`; never hardcode a
* list here, or a strategy added in Go silently stops being selectable.
*/
mcp_strategy?: string;
};
/** One selectable MCP injection mechanism, served by the backend. */
export type MCPStrategyOption = {
key: string;
/** The strategy's own description of the mechanism it uses. */
description: string;
};
export type Agent = {
@@ -0,0 +1,111 @@
import { describe, expect, it } from "vitest";
import { create, type StoreApi } from "zustand";
import { immer } from "zustand/middleware/immer";
import { createSettingsSlice } from "@/lib/state/slices/settings/settings-slice";
import type { SettingsSlice } from "@/lib/state/slices/settings/types";
import type { AppState } from "@/lib/state/store";
import { registerAgentsHandlers } from "./agents";
function makeStore() {
return create<SettingsSlice>()(
immer((set, get, store) => createSettingsSlice(set, get, store)),
) as unknown as StoreApi<AppState>;
}
const TIMESTAMP = "2026-08-13T10:00:00Z";
const SETTINGS_UPDATED = "agent.settings.updated";
function message(action: string, payload: unknown) {
return { id: `message-${action}`, type: "notification", action, timestamp: TIMESTAMP, payload };
}
function savedAgent(overrides: Record<string, unknown> = {}) {
return {
id: "agent-1",
name: "fuel-claude",
supports_mcp: false,
tui_config: {
command: "fuelclaude",
display_name: "Fuel Claude",
wait_for_terminal: true,
},
profiles: [{ id: "p1", name: "Default" }],
created_at: TIMESTAMP,
updated_at: TIMESTAMP,
...overrides,
};
}
/**
* These pin the split between two actions that were briefly conflated. The
* settings record was first broadcast on `agent.updated`, whose handler expects
* `{agentId, status}` — it read `undefined` and pushed `{id: undefined}` into
* the runtime-status list instead of updating settings.
*/
describe("agent.settings.updated handler", () => {
it("replaces the agent's settings record in settingsAgents", () => {
const store = makeStore();
store.getState().setSettingsAgents([savedAgent()] as never);
const handlers = registerAgentsHandlers(store);
handlers[SETTINGS_UPDATED]?.(
message(SETTINGS_UPDATED, {
agent: savedAgent({
supports_mcp: true,
tui_config: {
command: "fuelclaude",
display_name: "Fuel Claude",
wait_for_terminal: true,
mcp_strategy: "claude",
},
}),
}) as never,
);
const [updated] = store.getState().settingsAgents.items;
expect(updated.supports_mcp).toBe(true);
expect(updated.tui_config?.mcp_strategy).toBe("claude");
});
it("preserves locally normalized profiles", () => {
const store = makeStore();
store.getState().setSettingsAgents([savedAgent()] as never);
const handlers = registerAgentsHandlers(store);
// The event carries agent-level settings; profiles have their own events,
// so an agent record arriving without them must not wipe the list.
handlers[SETTINGS_UPDATED]?.(
message(SETTINGS_UPDATED, {
agent: savedAgent({ supports_mcp: true, profiles: [] }),
}) as never,
);
expect(store.getState().settingsAgents.items[0].profiles).toHaveLength(1);
});
it("ignores an agent it does not already track", () => {
const store = makeStore();
store.getState().setSettingsAgents([savedAgent()] as never);
const handlers = registerAgentsHandlers(store);
handlers[SETTINGS_UPDATED]?.(
message(SETTINGS_UPDATED, { agent: savedAgent({ id: "agent-other" }) }) as never,
);
expect(store.getState().settingsAgents.items).toHaveLength(1);
expect(store.getState().settingsAgents.items[0].id).toBe("agent-1");
});
it("ignores a payload with no agent rather than corrupting the list", () => {
const store = makeStore();
store.getState().setSettingsAgents([savedAgent()] as never);
const handlers = registerAgentsHandlers(store);
handlers[SETTINGS_UPDATED]?.(message(SETTINGS_UPDATED, {}) as never);
handlers[SETTINGS_UPDATED]?.(message(SETTINGS_UPDATED, { agent: {} }) as never);
expect(store.getState().settingsAgents.items).toHaveLength(1);
expect(store.getState().settingsAgents.items[0].id).toBe("agent-1");
});
});
+21
View File
@@ -230,6 +230,27 @@ export function registerAgentsHandlers(store: StoreApi<AppState>): WsHandlers {
},
}));
},
// Full settings record for one agent (e.g. a custom TUI agent's MCP
// strategy changed). Distinct from "agent.updated", which is a runtime
// status ping keyed by {agentId, status} and feeds state.agents.
"agent.settings.updated": (message) => {
const incoming = message.payload?.agent;
if (!incoming?.id) return;
store.setState((state) => {
const exists = state.settingsAgents.items.some((a) => a.id === incoming.id);
if (!exists) return state;
return {
...state,
settingsAgents: {
items: state.settingsAgents.items.map((a) =>
// Preserve the locally normalized profiles: this event carries
// agent-level settings, and the profile list has its own events.
a.id === incoming.id ? { ...a, ...incoming, profiles: a.profiles } : a,
),
},
};
});
},
"agent.profile.created": (message) => {
store.setState((state) => ({
...state,
+4
View File
@@ -135,6 +135,10 @@
"mcpSaveProfileHint": "Save this profile to configure MCP servers.",
"mcpServersHelp": "MCP definitions are stored in the database and resolved per executor at runtime. This does not override your local agent config.",
"mcpServersJson": "MCP servers (JSON)",
"mcpStrategy": "Kandev MCP tools",
"mcpStrategyHelp": "Kandev can hand this agent its own tools (set task title, ask the user a question, update the plan, mark a step complete). To do that it must write MCP config in the format your CLI reads, so pick the one that matches. Choosing the wrong format fails silently: the config is written, the CLI ignores it, and no error is shown. Leave this off if the command is not an MCP-capable coding agent.",
"mcpStrategyNone": "Off: no Kandev tools",
"mcpStrategyOption": "{{name}}: {{mechanism}}",
"model": "Model",
"modelHeading": "Model",
"modelRequiredForAllProfiles": "Model is required for all profiles.",
+4
View File
@@ -135,6 +135,10 @@
"mcpSaveProfileHint": "Śàvē ţĥĩś ƥŕōƒĩĺē ţō ćōńƒĩĝũŕē ḾĆƤ śēŕvēŕś.",
"mcpServersHelp": "ḾĆƤ ďēƒĩńĩţĩōńś àŕē śţōŕēď ĩń ţĥē ďàţàƀàśē àńď ŕēśōĺvēď ƥēŕ ēxēćũţōŕ àţ ŕũńţĩḿē. Ţĥĩś ďōēś ńōţ ōvēŕŕĩďē ŷōũŕ ĺōćàĺ àĝēńţ ćōńƒĩĝ.",
"mcpServersJson": "ḾĆƤ śēŕvēŕś (ĴŚŌŃ)",
"mcpStrategy": "Ķàńďēv ḾĆƤ ţōōĺś",
"mcpStrategyHelp": "Ķàńďēv ćàń ĥàńď ţĥĩś àĝēńţ ĩţś ōŵń ţōōĺś (śēţ ţàśķ ţĩţĺē, àśķ ţĥē ũśēŕ à qũēśţĩōń, ũƥďàţē ţĥē ƥĺàń, ḿàŕķ à śţēƥ ćōḿƥĺēţē). Ţō ďō ţĥàţ ĩţ ḿũśţ ŵŕĩţē ḾĆƤ ćōńƒĩĝ ĩń ţĥē ƒōŕḿàţ ŷōũŕ ĆĹĨ ŕēàďś, śō ƥĩćķ ţĥē ōńē ţĥàţ ḿàţćĥēś. Ćĥōōśĩńĝ ţĥē ŵŕōńĝ ƒōŕḿàţ ƒàĩĺś śĩĺēńţĺŷ: ţĥē ćōńƒĩĝ ĩś ŵŕĩţţēń, ţĥē ĆĹĨ ĩĝńōŕēś ĩţ, àńď ńō ēŕŕōŕ ĩś śĥōŵń. Ĺēàvē ţĥĩś ōƒƒ ĩƒ ţĥē ćōḿḿàńď ĩś ńōţ àń ḾĆƤ-ćàƥàƀĺē ćōďĩńĝ àĝēńţ.",
"mcpStrategyNone": "Ōƒƒ: ńō Ķàńďēv ţōōĺś",
"mcpStrategyOption": "{{name}}: {{mechanism}}",
"model": "Ḿōďēĺ",
"modelHeading": "Ḿōďēĺ",
"modelRequiredForAllProfiles": "Ḿōďēĺ ĩś ŕēqũĩŕēď ƒōŕ àĺĺ ƥŕōƒĩĺēś.",
+13 -9
View File
@@ -50,10 +50,8 @@
"conflictTasksTitle": "Tarefas:",
"conflictTierMappingRow": "Escalão <0>{{tier}}</0> em {{workspace}} para {{provider}}",
"conflictTierMappingsTitle": "Mapeamentos de escalão da área de trabalho:",
"conflictWatchersTitle": "Vigilâncias (serão desativadas):",
"conflictUtilityAgentsTitle": "Agentes utilitários (manterão ligações obsoletas):",
"disableProfileUtilityWarning": "Agentes utilitários usam este perfil. Desativá-lo impede novas chamadas até reparar cada ligação.",
"disableProfileAnyway": "Desativar perfil",
"conflictWatchersTitle": "Vigilâncias (serão desativadas):",
"copied": "Copiado",
"copyInstallCommand": "Copiar o comando de instalação",
"copyToClipboard": "Copiar para a área de transferência",
@@ -75,6 +73,8 @@
"details": "detalhes",
"detectedAt": "Detetado em {{path}}",
"disableProfile": "Desativar perfil",
"disableProfileAnyway": "Desativar perfil",
"disableProfileUtilityWarning": "Agentes utilitários usam este perfil. Desativá-lo impede novas chamadas até reparar cada ligação.",
"disableSetting": "Desativar {{name}}",
"displayName": "Nome a apresentar",
"displayNameRequired": "O nome a apresentar é obrigatório",
@@ -84,18 +84,13 @@
"enableProfile": "Ativar perfil",
"enableSetting": "Ativar {{name}}",
"enabled": "Ativado",
"enabledProfileInfo": "Mais informações sobre a disponibilidade do perfil",
"enabledProfileHelper": "Os perfis desativados continuam a servir as sessões existentes, mas não são oferecidos ao criar novas tarefas ou agentes.",
"enabledProfileInfo": "Mais informações sobre a disponibilidade do perfil",
"everyProfileNeedsNameAndModel": "Todos os perfis precisam de um nome e de um modelo.",
"exampleValue": "ex.: {{example}}",
"failedToCreateAgent": "Não foi possível criar o agente",
"failedToDeleteProfile": "Não foi possível eliminar o perfil",
"failedToFetchCapabilities": "Não foi possível obter as capacidades",
"modelOptionsAuthenticationRequired": "Inicie sessão neste agente para carregar as opções do modelo.",
"modelOptionsResolutionFailed": "Não foi possível carregar as opções do modelo.",
"modelSettings": "Definições do modelo",
"resolvingModelOptions": "A carregar as opções do modelo…",
"retryModelOptions": "Tentar novamente",
"failedToLoadCommandPreview": "Não foi possível carregar a pré-visualização do comando",
"failedToLoadMcpConfig": "Não foi possível carregar a configuração MCP",
"failedToRefresh": "Não foi possível atualizar: {{error}}",
@@ -128,9 +123,16 @@
"mcpSaveProfileHint": "Guarde este perfil para configurar servidores MCP.",
"mcpServersHelp": "As definições de MCP são guardadas na base de dados e resolvidas por executor em tempo de execução. Isto não substitui a configuração local do seu agente.",
"mcpServersJson": "Servidores MCP (JSON)",
"mcpStrategy": "Ferramentas MCP do Kandev",
"mcpStrategyHelp": "O Kandev pode disponibilizar a este agente as suas próprias ferramentas (definir o título da tarefa, colocar uma questão ao utilizador, atualizar o plano, marcar um passo como concluído). Para isso, tem de escrever a configuração MCP no formato que a sua CLI lê, por isso escolha o que corresponde. Escolher o formato errado falha silenciosamente: a configuração é escrita, a CLI ignora-a e não é apresentado qualquer erro. Deixe desativado se o comando não for um agente de programação compatível com MCP.",
"mcpStrategyNone": "Desativado: sem ferramentas do Kandev",
"mcpStrategyOption": "{{name}}: {{mechanism}}",
"model": "Modelo",
"modelHeading": "Modelo",
"modelOptionsAuthenticationRequired": "Inicie sessão neste agente para carregar as opções do modelo.",
"modelOptionsResolutionFailed": "Não foi possível carregar as opções do modelo.",
"modelRequiredForAllProfiles": "O modelo é obrigatório em todos os perfis.",
"modelSettings": "Definições do modelo",
"noAuthHintLogin": "Clique em Abrir terminal para iniciar sessão e depois atualize.",
"noAuthHintNotInstalled": "Instale a CLI do agente e depois clique em Atualizar.",
"noAuthHintShell": "Clique em Abrir terminal para iniciar uma shell, execute o comando de início de sessão do agente e depois atualize.",
@@ -162,7 +164,9 @@
"removeThisProfile": "Remover este perfil",
"requestFailed": "O pedido falhou",
"rescan": "Voltar a analisar",
"resolvingModelOptions": "A carregar as opções do modelo…",
"retry": "Tentar novamente",
"retryModelOptions": "Tentar novamente",
"retryUpdate": "Repetir a atualização",
"retryVersionCheck": "Repetir a verificação da versão",
"reviewUpdateBeforeApplying": "Reveja a atualização antes de o Kandev alterar o runtime deste anfitrião.",
+4
View File
@@ -133,6 +133,10 @@
"mcpSaveProfileHint": "保存此配置以配置 MCP 服务器。",
"mcpServersHelp": "MCP 定义存储在数据库中,并在运行时按执行器解析。这不会覆盖你的本地智能体配置。",
"mcpServersJson": "MCP 服务器(JSON)",
"mcpStrategy": "Kandev MCP 工具",
"mcpStrategyHelp": "Kandev 可以为该代理提供自己的工具(设置任务标题、向用户提问、更新计划、标记步骤完成)。为此,它必须以你的 CLI 能读取的格式写入 MCP 配置,因此请选择匹配的格式。选错格式会静默失败:配置已写入,但 CLI 会忽略它,并且不会显示任何错误。如果该命令不是支持 MCP 的编码代理,请保持关闭。",
"mcpStrategyNone": "关闭:不提供 Kandev 工具",
"mcpStrategyOption": "{{name}}:{{mechanism}}",
"model": "模型",
"modelHeading": "模型",
"modelRequiredForAllProfiles": "所有配置都需要模型。",
+24 -20
View File
@@ -31,7 +31,6 @@
"capabilityProbeFailed": "探測失敗。請查看代理程式日誌了解詳情。",
"capabilityProbing": "正在檢查代理程式能力…",
"changeTierMappingsFirst": "刪除此設定前,請先更改這些工作區層級映射。",
"everythingInstalled": "所有可用代理程式和工具均已安裝。",
"checkingAuthentication": "正在檢查身份驗證",
"checkingCapabilitiesAndAuth": "正在檢查代理程式能力與身份驗證…",
"checkingLatestRuntimeVersion": "正在檢查最新執行時版本…",
@@ -52,18 +51,13 @@
"conflictTasksTitle": "任務:",
"conflictTierMappingRow": "{{workspace}} 中 {{provider}} 的 <0>{{tier}}</0> 層級",
"conflictTierMappingsTitle": "工作區層級映射:",
"conflictWatchersTitle": "監控(將被停用):",
"conflictUtilityAgentsTitle": "輔助代理程式(將保留失效綁定):",
"disableProfileUtilityWarning": "輔助代理程式正在使用此設定。停用後,新呼叫會停止,直到修復每個綁定。",
"disableProfileAnyway": "停用設定",
"conflictWatchersTitle": "監控(將被停用):",
"copied": "已複製",
"copyInstallCommand": "複製安裝命令",
"copyToClipboard": "複製到剪貼板",
"create": "建立",
"createAgentProfileTitle": "建立 {{name}} 設定",
"installAgents": "安裝代理程式",
"profileCount_one": "{{count}} 個設定",
"profileCount_other": "{{count}} 個設定",
"createProfileIntro": "為此代理程式建立新設定。",
"creating": "正在建立…",
"defaultProfile": "預設代理程式設定檔",
@@ -80,27 +74,30 @@
"details": "詳情",
"detectedAt": "檢測路徑:{{path}}",
"disableProfile": "停用代理程式設定檔",
"disableProfileAnyway": "停用設定",
"disableProfileUtilityWarning": "輔助代理程式正在使用此設定。停用後,新呼叫會停止,直到修復每個綁定。",
"disableSetting": "停用 {{name}}",
"displayName": "顯示名稱",
"displayNameRequired": "顯示名稱為必填項",
"done": "完成",
"duplicate": "複製",
"duplicateProfileNamed": "複製 {{name}}",
"duplicateProfileSuccess": "代理程式設定檔已複製",
"duplicateSaveBlocked": "設定仍在儲存,請稍後重試。",
"enableMcp": "啓用 MCP",
"enableMcpDescription": "允許此設定在工作階段中使用 MCP 伺服器。",
"enableProfile": "啓用代理程式設定檔",
"enableSetting": "啓用 {{name}}",
"enabled": "已啓用",
"enabledProfileInfo": "關於代理程式設定檔可用性的更多資訊",
"enabledProfileHelper": "已停用的代理程式設定檔仍會繼續服務現有工作階段,但在建立新任務或代理程式時不會出現在選項中。",
"enabledProfileInfo": "關於代理程式設定檔可用性的更多資訊",
"everyProfileNeedsNameAndModel": "每個設定都需要名稱和模型。",
"everythingInstalled": "所有可用代理程式和工具均已安裝。",
"exampleValue": "例如 {{example}}",
"failedToCreateAgent": "建立代理程式失敗",
"failedToDeleteProfile": "刪除設定失敗",
"failedToDuplicateProfile": "複製代理程式設定檔失敗",
"failedToFetchCapabilities": "擷取能力失敗",
"modelOptionsAuthenticationRequired": "請登入此代理程式以載入模型選項。",
"modelOptionsResolutionFailed": "無法載入模型選項。",
"modelSettings": "模型設定",
"resolvingModelOptions": "正在載入模型選項…",
"retryModelOptions": "重試",
"failedToLoadCommandPreview": "載入命令預覽失敗",
"failedToLoadMcpConfig": "載入 MCP 設定失敗",
"failedToRefresh": "刷新失敗:{{error}}",
@@ -115,6 +112,7 @@
"hostTerminal": "主機終端",
"hostTerminalDescription": "在 Kandev 主機上執行 shell。可用於手動安裝或設定代理程式。",
"install": "安裝",
"installAgents": "安裝代理程式",
"installQueued": "已排隊…",
"installationNotDetected": "未檢測到安裝",
"installed": "已安裝",
@@ -133,14 +131,21 @@
"mcpSaveProfileHint": "儲存此設定以設定 MCP 伺服器。",
"mcpServersHelp": "MCP 定義存儲在數據庫中,並在執行時按執行器解析。這不會覆蓋你的本地代理程式設定檔。",
"mcpServersJson": "MCP 伺服器(JSON)",
"mcpStrategy": "Kandev MCP 工具",
"mcpStrategyHelp": "Kandev 可以為這個代理程式提供自己的工具(設定任務標題、向使用者提問、更新計畫、標記步驟完成)。為此,它必須以你的 CLI 能讀取的格式寫入 MCP 設定,因此請選擇相符的格式。選錯格式會靜默失敗:設定已寫入,但 CLI 會忽略它,而且不會顯示任何錯誤。如果該命令不是支援 MCP 的程式設計代理程式,請保持關閉。",
"mcpStrategyNone": "關閉:不提供 Kandev 工具",
"mcpStrategyOption": "{{name}}:{{mechanism}}",
"model": "模型",
"modelHeading": "模型",
"modelOptionsAuthenticationRequired": "請登入此代理程式以載入模型選項。",
"modelOptionsResolutionFailed": "無法載入模型選項。",
"modelRequiredForAllProfiles": "所有設定都需要模型。",
"modelSettings": "模型設定",
"newProfile": "新設定",
"noAuthHintLogin": "點擊「開啟終端」登入,然後刷新。",
"noAuthHintNotInstalled": "安裝代理程式 CLI,然後點擊「刷新」。",
"noAuthHintShell": "點擊「開啟終端」啓動 shell,執行代理程式的登入命令,然後刷新。",
"noAuthLoginRequired": "未驗證;需要登入",
"newProfile": "新設定",
"noCliFlagsConfigured": "未設定 CLI 旗標。請在下方新增。",
"noCommandPreview": "無可用命令預覽。",
"noInstalledAgentsDetected": "未檢測到已安裝的代理程式。請在下方安裝,然後點擊「重新掃描」。",
@@ -154,6 +159,8 @@
"popularServers": "常用伺服器",
"probingCapabilities": "正在探測代理程式能力… 通常需要幾秒鐘。",
"profileActions": "設定操作",
"profileCount_one": "{{count}} 個設定",
"profileCount_other": "{{count}} 個設定",
"profileInUseIntro": "此設定目前正在使用。刪除後將影響以下內容:",
"profileName": "設定名稱",
"profileNameRequired": "設定名稱為必填項。",
@@ -170,7 +177,9 @@
"removeThisProfile": "移除此設定",
"requestFailed": "請求失敗",
"rescan": "重新掃描",
"resolvingModelOptions": "正在載入模型選項…",
"retry": "重試",
"retryModelOptions": "重試",
"retryUpdate": "重試更新",
"retryVersionCheck": "重試版本檢查",
"reviewUpdateBeforeApplying": "在 Kandev 更改此主機執行時之前,請先審閱更新。",
@@ -212,10 +221,5 @@
"watcherKindGithubIssue": "GitHub 議題",
"watcherKindGithubReview": "GitHub PR 審查",
"watcherKindJira": "Jira",
"watcherKindLinear": "Linear",
"duplicate": "複製",
"duplicateProfileNamed": "複製 {{name}}",
"duplicateProfileSuccess": "代理程式設定檔已複製",
"duplicateSaveBlocked": "設定仍在儲存,請稍後重試。",
"failedToDuplicateProfile": "複製代理程式設定檔失敗"
"watcherKindLinear": "Linear"
}
+24 -20
View File
@@ -31,7 +31,6 @@
"capabilityProbeFailed": "探測失敗。請檢視代理程式日誌了解詳情。",
"capabilityProbing": "正在檢查代理程式能力…",
"changeTierMappingsFirst": "刪除此設定前,請先更改這些工作區層級對映。",
"everythingInstalled": "所有可用代理程式和工具均已安裝。",
"checkingAuthentication": "正在檢查身分驗證",
"checkingCapabilitiesAndAuth": "正在檢查代理程式能力與身分驗證…",
"checkingLatestRuntimeVersion": "正在檢查最新執行時版本…",
@@ -52,18 +51,13 @@
"conflictTasksTitle": "任務:",
"conflictTierMappingRow": "{{workspace}} 中 {{provider}} 的 <0>{{tier}}</0> 層級",
"conflictTierMappingsTitle": "工作區層級對映:",
"conflictWatchersTitle": "監控(將被停用):",
"conflictUtilityAgentsTitle": "輔助代理程式(將保留失效繫結):",
"disableProfileUtilityWarning": "輔助代理程式正在使用此設定。停用後,新呼叫會停止,直到修復每個繫結。",
"disableProfileAnyway": "停用設定",
"conflictWatchersTitle": "監控(將被停用):",
"copied": "已複製",
"copyInstallCommand": "複製安裝命令",
"copyToClipboard": "複製到剪貼簿",
"create": "建立",
"createAgentProfileTitle": "建立 {{name}} 設定",
"installAgents": "安裝代理程式",
"profileCount_one": "{{count}} 個設定",
"profileCount_other": "{{count}} 個設定",
"createProfileIntro": "為此代理程式建立新設定。",
"creating": "正在建立…",
"defaultProfile": "預設代理程式設定檔",
@@ -80,27 +74,30 @@
"details": "詳情",
"detectedAt": "檢測路徑:{{path}}",
"disableProfile": "停用代理程式設定檔",
"disableProfileAnyway": "停用設定",
"disableProfileUtilityWarning": "輔助代理程式正在使用此設定。停用後,新呼叫會停止,直到修復每個繫結。",
"disableSetting": "停用 {{name}}",
"displayName": "顯示名稱",
"displayNameRequired": "顯示名稱為必填項",
"done": "完成",
"duplicate": "複製",
"duplicateProfileNamed": "複製 {{name}}",
"duplicateProfileSuccess": "代理程式設定檔已複製",
"duplicateSaveBlocked": "設定仍在儲存,請稍後重試。",
"enableMcp": "啟用 MCP",
"enableMcpDescription": "允許此設定在工作階段中使用 MCP 伺服器。",
"enableProfile": "啟用代理程式設定檔",
"enableSetting": "啟用 {{name}}",
"enabled": "已啟用",
"enabledProfileInfo": "關於代理程式設定檔可用性的更多資訊",
"enabledProfileHelper": "已停用的代理程式設定檔仍會繼續服務現有工作階段,但在建立新任務或代理程式時不會出現在選項中。",
"enabledProfileInfo": "關於代理程式設定檔可用性的更多資訊",
"everyProfileNeedsNameAndModel": "每個設定都需要名稱和模型。",
"everythingInstalled": "所有可用代理程式和工具均已安裝。",
"exampleValue": "例如 {{example}}",
"failedToCreateAgent": "建立代理程式失敗",
"failedToDeleteProfile": "刪除設定失敗",
"failedToDuplicateProfile": "複製代理程式設定檔失敗",
"failedToFetchCapabilities": "擷取能力失敗",
"modelOptionsAuthenticationRequired": "請登入此代理程式以載入模型選項。",
"modelOptionsResolutionFailed": "無法載入模型選項。",
"modelSettings": "模型設定",
"resolvingModelOptions": "正在載入模型選項…",
"retryModelOptions": "重試",
"failedToLoadCommandPreview": "載入命令預覽失敗",
"failedToLoadMcpConfig": "載入 MCP 設定失敗",
"failedToRefresh": "重新整理失敗:{{error}}",
@@ -115,6 +112,7 @@
"hostTerminal": "主機終端",
"hostTerminalDescription": "在 Kandev 主機上執行 shell。可用於手動安裝或設定代理程式。",
"install": "安裝",
"installAgents": "安裝代理程式",
"installQueued": "已排隊…",
"installationNotDetected": "未檢測到安裝",
"installed": "已安裝",
@@ -133,14 +131,21 @@
"mcpSaveProfileHint": "儲存此設定以設定 MCP 伺服器。",
"mcpServersHelp": "MCP 定義儲存在資料庫中,並在執行時按執行器解析。這不會覆蓋你的本地代理程式設定檔。",
"mcpServersJson": "MCP 伺服器(JSON)",
"mcpStrategy": "Kandev MCP 工具",
"mcpStrategyHelp": "Kandev 可以為這個代理程式提供自己的工具(設定任務標題、向使用者提問、更新計畫、標記步驟完成)。為此,它必須以你的 CLI 能讀取的格式寫入 MCP 設定,因此請選擇相符的格式。選錯格式會靜默失敗:設定已寫入,但 CLI 會忽略它,而且不會顯示任何錯誤。如果該命令不是支援 MCP 的程式設計代理程式,請保持關閉。",
"mcpStrategyNone": "關閉:不提供 Kandev 工具",
"mcpStrategyOption": "{{name}}:{{mechanism}}",
"model": "模型",
"modelHeading": "模型",
"modelOptionsAuthenticationRequired": "請登入此代理程式以載入模型選項。",
"modelOptionsResolutionFailed": "無法載入模型選項。",
"modelRequiredForAllProfiles": "所有設定都需要模型。",
"modelSettings": "模型設定",
"newProfile": "新設定",
"noAuthHintLogin": "點選「開啟終端」登入,然後重新整理。",
"noAuthHintNotInstalled": "安裝代理程式 CLI,然後點選「重新整理」。",
"noAuthHintShell": "點選「開啟終端」啟動 shell,執行代理程式的登入命令,然後重新整理。",
"noAuthLoginRequired": "未驗證;需要登入",
"newProfile": "新設定",
"noCliFlagsConfigured": "未設定 CLI 旗標。請在下方新增。",
"noCommandPreview": "無可用命令預覽。",
"noInstalledAgentsDetected": "未檢測到已安裝的代理程式。請在下方安裝,然後點選「重新掃描」。",
@@ -154,6 +159,8 @@
"popularServers": "常用伺服器",
"probingCapabilities": "正在探測代理程式能力… 通常需要幾秒鐘。",
"profileActions": "設定操作",
"profileCount_one": "{{count}} 個設定",
"profileCount_other": "{{count}} 個設定",
"profileInUseIntro": "此設定目前正在使用。刪除後將影響以下內容:",
"profileName": "設定名稱",
"profileNameRequired": "設定名稱為必填項。",
@@ -170,7 +177,9 @@
"removeThisProfile": "移除此設定",
"requestFailed": "請求失敗",
"rescan": "重新掃描",
"resolvingModelOptions": "正在載入模型選項…",
"retry": "重試",
"retryModelOptions": "重試",
"retryUpdate": "重試更新",
"retryVersionCheck": "重試版本檢查",
"reviewUpdateBeforeApplying": "在 Kandev 更改此主機執行時之前,請先審閱更新。",
@@ -212,10 +221,5 @@
"watcherKindGithubIssue": "GitHub 議題",
"watcherKindGithubReview": "GitHub PR 審查",
"watcherKindJira": "Jira",
"watcherKindLinear": "Linear",
"duplicate": "複製",
"duplicateProfileNamed": "複製 {{name}}",
"duplicateProfileSuccess": "代理程式設定檔已複製",
"duplicateSaveBlocked": "設定仍在儲存,請稍後重試。",
"failedToDuplicateProfile": "複製代理程式設定檔失敗"
"watcherKindLinear": "Linear"
}