feat(backend): add 10 ACP agents, set_config_option, auth_required flow (#807)
@@ -29,7 +29,7 @@ Open source, multi-provider, no telemetry, not tied to any cloud.
|
||||
|
||||
## Features
|
||||
|
||||
- **Multi-agent support** - Claude Code, Codex, GitHub Copilot, Gemini CLI, Amp, Auggie, OpenCode
|
||||
- **Multi-agent support** - Claude Code, Codex, GitHub Copilot, Gemini CLI, Amp, Auggie, OpenCode, Cursor, Qwen, Factory Droid, iFlow, Kilocode, Pi, Kimi, AWS Kiro, Qoder, Trae
|
||||
- **Parallel task execution** – start and manage multiple tasks from different sources simultaneously, boosting productivity with AI agents
|
||||
- **Integrated workspace** - Built-in terminal, code editor with LSP, git changes panel, embedded vscode and chat in one IDE-like view
|
||||
- **Kanban task management** - Drag-and-drop boards, columns, and workflow automation
|
||||
@@ -54,15 +54,25 @@ Connect Kandev to the tools your team already uses — pull issues into the kanb
|
||||
|
||||
## Supported ACP Agents
|
||||
|
||||
| Agent | Protocol |
|
||||
| Agent | Launch |
|
||||
|:-------:|:----------:|
|
||||
| **Claude Code** | ACP (`@zed-industries/claude-agent-acp`) |
|
||||
| **Codex** | ACP (`@zed-industries/codex-acp`) |
|
||||
| **GitHub Copilot** | ACP |
|
||||
| **Gemini CLI** | ACP |
|
||||
| **Amp** | ACP (`amp-acp`) |
|
||||
| **Auggie** | ACP |
|
||||
| **OpenCode** | ACP |
|
||||
| **Claude Code** | `npx -y @agentclientprotocol/claude-agent-acp` |
|
||||
| **Codex** | `npx -y @zed-industries/codex-acp` |
|
||||
| **GitHub Copilot** | `npx -y @github/copilot --acp` |
|
||||
| **Gemini CLI** | `npx -y @google/gemini-cli --acp` |
|
||||
| **Amp** | `npx -y amp-acp` |
|
||||
| **Auggie** | `npx -y @augmentcode/auggie --acp` |
|
||||
| **OpenCode** | `opencode acp` |
|
||||
| **Cursor** | `cursor-agent acp` *(requires Cursor Pro)* |
|
||||
| **Qwen** | `npx -y @qwen-code/qwen-code --acp` |
|
||||
| **Factory Droid** | `npx -y droid exec --output-format acp` |
|
||||
| **iFlow (beta)** | `npx -y @iflow-ai/iflow-cli --experimental-acp` |
|
||||
| **Kilocode** | `npx -y @kilocode/cli acp` |
|
||||
| **Pi** | `npx -y pi-acp` |
|
||||
| **Kimi** | `kimi acp` *(install Kimi CLI from Moonshot AI)* |
|
||||
| **Kiro** | `kiro-cli-chat acp` *(install Kiro CLI from AWS)* |
|
||||
| **Qoder** | `qodercli --acp` *(install Qoder CLI)* |
|
||||
| **Trae** | `traecli acp serve` *(install Trae IDE CLI)* |
|
||||
|
||||
> All agents communicate via [ACP](https://agentclientprotocol.com) (Agent Client Protocol). Some agents support ACP natively, while others use ACP adapter packages that bridge their native protocols. **CLI Passthrough mode** is also available for direct terminal interaction with any agent CLI. If your agent isn't supported yet, open an issue or submit a PR with the integration. See [Adding a New Agent CLI](docs/add-agent-cli.md) for a step-by-step guide.
|
||||
|
||||
@@ -186,7 +196,7 @@ There are a few similar tools in this space, and new ones appearing everyday. He
|
||||
|
||||
- **Server-first architecture** - Not a desktop app. Runs as a server you can access from any device, including your phone. Start a task away from your computer and check in on it later.
|
||||
- **Remote runtimes** - Run agents on remote servers and Docker hosts, not just your local machine.
|
||||
- **Multi-provider** - Use Claude Code, Codex, Copilot, Gemini, Amp, Auggie, and OpenCode side by side. Not locked to one vendor.
|
||||
- **Multi-provider** - Use Claude Code, Codex, Copilot, Gemini, Amp, Auggie, OpenCode, Cursor, Qwen, Droid, iFlow, Kilocode, Pi, Kimi, Kiro, Qoder, and Trae side by side. Not locked to one vendor.
|
||||
- **CLI passthrough and chat** - Interact with agents through structured chat messages or drop into raw CLI mode for full agent TUI capabilities.
|
||||
- **Open source and self-hostable** - No vendor lock-in, no telemetry, runs on your infrastructure.
|
||||
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
//nolint:dupl // Native-binary ACP agents (Cursor, Kimi, Kiro, Qoder, Trae) follow the same minimal scaffold; differences are the binary name and subcommand.
|
||||
package agents
|
||||
|
||||
import (
|
||||
"context"
|
||||
_ "embed"
|
||||
"time"
|
||||
|
||||
"github.com/kandev/kandev/pkg/agent"
|
||||
)
|
||||
|
||||
//go:embed logos/cursor_acp_light.svg
|
||||
var cursorACPLogoLight []byte
|
||||
|
||||
//go:embed logos/cursor_acp_dark.svg
|
||||
var cursorACPLogoDark []byte
|
||||
|
||||
const cursorACPBin = "cursor-agent"
|
||||
|
||||
var (
|
||||
_ Agent = (*CursorACP)(nil)
|
||||
_ PassthroughAgent = (*CursorACP)(nil)
|
||||
_ InferenceAgent = (*CursorACP)(nil)
|
||||
)
|
||||
|
||||
// CursorACP implements Agent for Cursor's CLI via its native ACP mode.
|
||||
// Cursor isn't published to npm — users must install the cursor-agent binary
|
||||
// from Cursor (Pro subscription required).
|
||||
type CursorACP struct {
|
||||
StandardPassthrough
|
||||
}
|
||||
|
||||
func NewCursorACP() *CursorACP {
|
||||
return &CursorACP{
|
||||
StandardPassthrough: StandardPassthrough{
|
||||
PermSettings: emptyPermSettings,
|
||||
Cfg: PassthroughConfig{
|
||||
Supported: true,
|
||||
Label: "CLI Passthrough",
|
||||
Description: "Show terminal directly instead of chat interface",
|
||||
PassthroughCmd: NewCommand(cursorACPBin),
|
||||
ModelFlag: NewParam("--model", "{model}"),
|
||||
IdleTimeout: 3 * time.Second,
|
||||
BufferMaxBytes: DefaultBufferMaxBytes,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (a *CursorACP) ID() string { return "cursor-acp" }
|
||||
func (a *CursorACP) Name() string { return "Cursor ACP Agent" }
|
||||
func (a *CursorACP) DisplayName() string { return "Cursor" }
|
||||
func (a *CursorACP) Description() string {
|
||||
return "Cursor CLI coding agent (cursor-agent) using the ACP protocol. Requires a Cursor Pro subscription."
|
||||
}
|
||||
func (a *CursorACP) Enabled() bool { return true }
|
||||
func (a *CursorACP) DisplayOrder() int { return 13 }
|
||||
|
||||
func (a *CursorACP) Logo(v LogoVariant) []byte {
|
||||
if v == LogoDark {
|
||||
return cursorACPLogoDark
|
||||
}
|
||||
return cursorACPLogoLight
|
||||
}
|
||||
|
||||
func (a *CursorACP) IsInstalled(ctx context.Context) (*DiscoveryResult, error) {
|
||||
result, err := Detect(ctx, WithCommand(cursorACPBin))
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
result.SupportsMCP = true
|
||||
result.Capabilities = DiscoveryCapabilities{
|
||||
SupportsSessionResume: true,
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (a *CursorACP) BuildCommand(opts CommandOptions) Command {
|
||||
return Cmd(cursorACPBin, "acp").Build()
|
||||
}
|
||||
|
||||
func (a *CursorACP) Runtime() *RuntimeConfig {
|
||||
canRecover := true
|
||||
return &RuntimeConfig{
|
||||
Cmd: Cmd(cursorACPBin, "acp").Build(),
|
||||
WorkingDir: "{workspace}",
|
||||
Env: map[string]string{},
|
||||
ResourceLimits: ResourceLimits{MemoryMB: 4096, CPUCores: 2.0, Timeout: time.Hour},
|
||||
Protocol: agent.ProtocolACP,
|
||||
SessionConfig: SessionConfig{
|
||||
NativeSessionResume: true,
|
||||
CanRecover: &canRecover,
|
||||
SessionDirTemplate: "{home}/.cursor",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (a *CursorACP) RemoteAuth() *RemoteAuth { return nil }
|
||||
|
||||
func (a *CursorACP) InstallScript() string {
|
||||
return "Install Cursor CLI from https://cursor.com/cli"
|
||||
}
|
||||
|
||||
func (a *CursorACP) PermissionSettings() map[string]PermissionSetting {
|
||||
return emptyPermSettings
|
||||
}
|
||||
|
||||
func (a *CursorACP) InferenceConfig() *InferenceConfig {
|
||||
return &InferenceConfig{
|
||||
Supported: true,
|
||||
Command: NewCommand(cursorACPBin, "acp"),
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,35 @@ func WithCommand(name string) DetectOption {
|
||||
}
|
||||
}
|
||||
|
||||
// WithNpxRunnable reports the agent as runnable via `npx -y <pkg>` whenever
|
||||
// npx is on PATH. MatchedPath is tagged "npx <pkg>" so the settings page
|
||||
// renders "Detected at npx <pkg>", giving users an honest hint that the
|
||||
// package isn't globally installed but will be fetched on launch.
|
||||
//
|
||||
// Only `npx` is checked, not `node`: every npm-distributed agent's launch
|
||||
// command is `npx -y <pkg>`, so a node-only setup would pass detection but
|
||||
// fail at session start with "exec: npx: executable file not found in PATH".
|
||||
//
|
||||
// Use this AFTER a WithCommand check for the global binary — Detect is
|
||||
// first-match-wins, so the global install (if present) is preferred and
|
||||
// reports its real path.
|
||||
//
|
||||
// NOTE: not currently wired into any agent's IsInstalled. The host-utility
|
||||
// manager treats Available=true as a green light to spawn and probe the
|
||||
// agent at boot, so claiming availability via npx alone caused unwanted
|
||||
// downloads and misleading auth_required/failed states for agents the user
|
||||
// never installed. The helper is parked here until the host-utility manager
|
||||
// gains a "skip probe for npx-fallback detections" path; at that point this
|
||||
// can be re-wired into the npm-distributed agents' IsInstalled.
|
||||
func WithNpxRunnable(pkg string) DetectOption {
|
||||
return func(ctx context.Context) (bool, string, error) {
|
||||
if _, err := exec.LookPath("npx"); err == nil {
|
||||
return true, "npx " + pkg, nil
|
||||
}
|
||||
return false, "", nil
|
||||
}
|
||||
}
|
||||
|
||||
// WithCommandOutput runs a command and checks stdout matches regex.
|
||||
func WithCommandOutput(pattern string, name string, args ...string) DetectOption {
|
||||
return func(ctx context.Context) (bool, string, error) {
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
package agents
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestWithNpxRunnable_MatchesWhenNpxOnPath confirms the npx-fallback returns
|
||||
// available with the "npx <pkg>" tag whenever npx is on PATH. The settings
|
||||
// page renders this as "Detected at npx <pkg>", giving users a truthful hint
|
||||
// that the package isn't globally installed but launches via npx -y.
|
||||
func TestWithNpxRunnable_MatchesWhenNpxOnPath(t *testing.T) {
|
||||
if !npxOnPath() {
|
||||
t.Skip("npx not on PATH; skipping fallback test")
|
||||
}
|
||||
|
||||
found, matched, err := WithNpxRunnable("@example/pkg")(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("expected found=true when node/npx is on PATH")
|
||||
}
|
||||
if matched != "npx @example/pkg" {
|
||||
t.Errorf("matched = %q, want %q", matched, "npx @example/pkg")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDetect_PrefersGlobalBinaryOverNpxFallback pins the first-match-wins
|
||||
// contract: when both the global binary and npx are available, the global
|
||||
// install reports its real path. This is what makes the UI's "Detected at
|
||||
// /usr/local/bin/<bin>" hint accurate for users who actually installed.
|
||||
func TestDetect_PrefersGlobalBinaryOverNpxFallback(t *testing.T) {
|
||||
if !npxOnPath() {
|
||||
t.Skip("npx not on PATH; skipping ordering test")
|
||||
}
|
||||
// `ls` is guaranteed on PATH in any POSIX-ish CI/dev env.
|
||||
if _, err := exec.LookPath("ls"); err != nil {
|
||||
t.Skip("ls not on PATH; skipping")
|
||||
}
|
||||
|
||||
result, err := Detect(context.Background(),
|
||||
WithCommand("ls"),
|
||||
WithNpxRunnable("@example/pkg"),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !result.Available {
|
||||
t.Fatalf("expected Available=true")
|
||||
}
|
||||
if strings.HasPrefix(result.MatchedPath, "npx ") {
|
||||
t.Errorf("MatchedPath = %q, want a real path (global ls), not the npx tag", result.MatchedPath)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDetect_FallsBackToNpxWhenGlobalMissing covers the headline case: the
|
||||
// agent's binary isn't installed globally, but node is — Detect should still
|
||||
// return Available=true with the npx tag so the agent shows as "Installed"
|
||||
// on the settings page.
|
||||
func TestDetect_FallsBackToNpxWhenGlobalMissing(t *testing.T) {
|
||||
if !npxOnPath() {
|
||||
t.Skip("npx not on PATH; skipping fallback ordering test")
|
||||
}
|
||||
|
||||
result, err := Detect(context.Background(),
|
||||
WithCommand("definitely-not-a-real-binary-xyz"),
|
||||
WithNpxRunnable("@example/pkg"),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !result.Available {
|
||||
t.Fatalf("expected Available=true via npx fallback")
|
||||
}
|
||||
if result.MatchedPath != "npx @example/pkg" {
|
||||
t.Errorf("MatchedPath = %q, want %q", result.MatchedPath, "npx @example/pkg")
|
||||
}
|
||||
}
|
||||
|
||||
func npxOnPath() bool {
|
||||
_, err := exec.LookPath("npx")
|
||||
return err == nil
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package agents
|
||||
|
||||
import (
|
||||
"context"
|
||||
_ "embed"
|
||||
"time"
|
||||
|
||||
"github.com/kandev/kandev/pkg/agent"
|
||||
)
|
||||
|
||||
//go:embed logos/droid_acp_light.svg
|
||||
var droidACPLogoLight []byte
|
||||
|
||||
//go:embed logos/droid_acp_dark.svg
|
||||
var droidACPLogoDark []byte
|
||||
|
||||
const droidACPPkg = "droid"
|
||||
|
||||
var (
|
||||
_ Agent = (*DroidACP)(nil)
|
||||
_ PassthroughAgent = (*DroidACP)(nil)
|
||||
_ InferenceAgent = (*DroidACP)(nil)
|
||||
)
|
||||
|
||||
// DroidACP implements Agent for Factory's Droid CLI using ACP.
|
||||
//
|
||||
// Factory's public CLI reference documents `droid exec` as "single-shot
|
||||
// execution" mode and does not list `acp` as a valid `--output-format` value.
|
||||
// We rely on the same invocation acpx uses (`droid exec --output-format acp`).
|
||||
// Native session resume is intentionally disabled until we can verify the
|
||||
// process stays alive across multiple session/prompt calls — if it doesn't,
|
||||
// our fork-session fallback handles continuity by re-invoking with -s.
|
||||
type DroidACP struct {
|
||||
StandardPassthrough
|
||||
}
|
||||
|
||||
func NewDroidACP() *DroidACP {
|
||||
return &DroidACP{
|
||||
StandardPassthrough: StandardPassthrough{
|
||||
PermSettings: emptyPermSettings,
|
||||
Cfg: PassthroughConfig{
|
||||
Supported: true,
|
||||
Label: "CLI Passthrough",
|
||||
Description: "Show terminal directly instead of chat interface",
|
||||
PassthroughCmd: NewCommand("npx", "-y", droidACPPkg),
|
||||
ModelFlag: NewParam("--model", "{model}"),
|
||||
IdleTimeout: 3 * time.Second,
|
||||
BufferMaxBytes: DefaultBufferMaxBytes,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (a *DroidACP) ID() string { return "droid-acp" }
|
||||
func (a *DroidACP) Name() string { return "Factory Droid ACP Agent" }
|
||||
func (a *DroidACP) DisplayName() string { return "Droid" }
|
||||
func (a *DroidACP) Description() string {
|
||||
return "Factory.ai Droid coding agent via `droid exec --output-format acp`."
|
||||
}
|
||||
func (a *DroidACP) Enabled() bool { return true }
|
||||
func (a *DroidACP) DisplayOrder() int { return 10 }
|
||||
|
||||
func (a *DroidACP) Logo(v LogoVariant) []byte {
|
||||
if v == LogoDark {
|
||||
return droidACPLogoDark
|
||||
}
|
||||
return droidACPLogoLight
|
||||
}
|
||||
|
||||
func (a *DroidACP) IsInstalled(ctx context.Context) (*DiscoveryResult, error) {
|
||||
result, err := Detect(ctx, WithCommand("droid"))
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
result.SupportsMCP = true
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (a *DroidACP) BuildCommand(opts CommandOptions) Command {
|
||||
return Cmd("npx", "-y", droidACPPkg, "exec", "--output-format", "acp").Build()
|
||||
}
|
||||
|
||||
func (a *DroidACP) Runtime() *RuntimeConfig {
|
||||
canRecover := false
|
||||
return &RuntimeConfig{
|
||||
Cmd: Cmd("npx", "-y", droidACPPkg, "exec", "--output-format", "acp").Build(),
|
||||
WorkingDir: "{workspace}",
|
||||
Env: map[string]string{},
|
||||
ResourceLimits: ResourceLimits{MemoryMB: 4096, CPUCores: 2.0, Timeout: time.Hour},
|
||||
Protocol: agent.ProtocolACP,
|
||||
SessionConfig: SessionConfig{
|
||||
NativeSessionResume: false,
|
||||
CanRecover: &canRecover,
|
||||
SessionDirTemplate: "{home}/.factory",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (a *DroidACP) RemoteAuth() *RemoteAuth { return nil }
|
||||
|
||||
func (a *DroidACP) InstallScript() string {
|
||||
return "npm install -g " + droidACPPkg
|
||||
}
|
||||
|
||||
func (a *DroidACP) PermissionSettings() map[string]PermissionSetting {
|
||||
return emptyPermSettings
|
||||
}
|
||||
|
||||
func (a *DroidACP) InferenceConfig() *InferenceConfig {
|
||||
return &InferenceConfig{
|
||||
Supported: true,
|
||||
Command: NewCommand("npx", "-y", droidACPPkg, "exec", "--output-format", "acp"),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package agents
|
||||
|
||||
import (
|
||||
"context"
|
||||
_ "embed"
|
||||
"time"
|
||||
|
||||
"github.com/kandev/kandev/pkg/agent"
|
||||
)
|
||||
|
||||
//go:embed logos/iflow_acp_light.svg
|
||||
var iflowACPLogoLight []byte
|
||||
|
||||
//go:embed logos/iflow_acp_dark.svg
|
||||
var iflowACPLogoDark []byte
|
||||
|
||||
const iflowACPPkg = "@iflow-ai/iflow-cli"
|
||||
|
||||
var (
|
||||
_ Agent = (*IFlowACP)(nil)
|
||||
_ PassthroughAgent = (*IFlowACP)(nil)
|
||||
_ InferenceAgent = (*IFlowACP)(nil)
|
||||
)
|
||||
|
||||
// IFlowACP implements Agent for the iFlow CLI using its experimental ACP mode.
|
||||
// The upstream flag is `--experimental-acp`; treat this agent as beta until
|
||||
// iFlow stabilises the protocol.
|
||||
type IFlowACP struct {
|
||||
StandardPassthrough
|
||||
}
|
||||
|
||||
func NewIFlowACP() *IFlowACP {
|
||||
return &IFlowACP{
|
||||
StandardPassthrough: StandardPassthrough{
|
||||
PermSettings: emptyPermSettings,
|
||||
Cfg: PassthroughConfig{
|
||||
Supported: true,
|
||||
Label: "CLI Passthrough",
|
||||
Description: "Show terminal directly instead of chat interface",
|
||||
PassthroughCmd: NewCommand("npx", "-y", iflowACPPkg),
|
||||
ModelFlag: NewParam("--model", "{model}"),
|
||||
IdleTimeout: 3 * time.Second,
|
||||
BufferMaxBytes: DefaultBufferMaxBytes,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (a *IFlowACP) ID() string { return "iflow-acp" }
|
||||
func (a *IFlowACP) Name() string { return "iFlow ACP Agent" }
|
||||
func (a *IFlowACP) DisplayName() string { return "iFlow (beta)" }
|
||||
func (a *IFlowACP) Description() string {
|
||||
return "iFlow coding agent using ACP via the experimental --experimental-acp flag."
|
||||
}
|
||||
func (a *IFlowACP) Enabled() bool { return true }
|
||||
func (a *IFlowACP) DisplayOrder() int { return 9 }
|
||||
|
||||
func (a *IFlowACP) Logo(v LogoVariant) []byte {
|
||||
if v == LogoDark {
|
||||
return iflowACPLogoDark
|
||||
}
|
||||
return iflowACPLogoLight
|
||||
}
|
||||
|
||||
func (a *IFlowACP) IsInstalled(ctx context.Context) (*DiscoveryResult, error) {
|
||||
result, err := Detect(ctx, WithCommand("iflow"))
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
result.SupportsMCP = true
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (a *IFlowACP) BuildCommand(opts CommandOptions) Command {
|
||||
return Cmd("npx", "-y", iflowACPPkg, "--experimental-acp").Build()
|
||||
}
|
||||
|
||||
func (a *IFlowACP) Runtime() *RuntimeConfig {
|
||||
canRecover := false
|
||||
return &RuntimeConfig{
|
||||
Cmd: Cmd("npx", "-y", iflowACPPkg, "--experimental-acp").Build(),
|
||||
WorkingDir: "{workspace}",
|
||||
Env: map[string]string{},
|
||||
ResourceLimits: ResourceLimits{MemoryMB: 4096, CPUCores: 2.0, Timeout: time.Hour},
|
||||
Protocol: agent.ProtocolACP,
|
||||
SessionConfig: SessionConfig{
|
||||
CanRecover: &canRecover,
|
||||
SessionDirTemplate: "{home}/.iflow",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (a *IFlowACP) RemoteAuth() *RemoteAuth { return nil }
|
||||
|
||||
func (a *IFlowACP) InstallScript() string {
|
||||
return "npm install -g " + iflowACPPkg
|
||||
}
|
||||
|
||||
func (a *IFlowACP) PermissionSettings() map[string]PermissionSetting {
|
||||
return emptyPermSettings
|
||||
}
|
||||
|
||||
func (a *IFlowACP) InferenceConfig() *InferenceConfig {
|
||||
return &InferenceConfig{
|
||||
Supported: true,
|
||||
Command: NewCommand("npx", "-y", iflowACPPkg, "--experimental-acp"),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package agents
|
||||
|
||||
import (
|
||||
"context"
|
||||
_ "embed"
|
||||
"time"
|
||||
|
||||
"github.com/kandev/kandev/pkg/agent"
|
||||
)
|
||||
|
||||
//go:embed logos/kilocode_acp_light.svg
|
||||
var kilocodeACPLogoLight []byte
|
||||
|
||||
//go:embed logos/kilocode_acp_dark.svg
|
||||
var kilocodeACPLogoDark []byte
|
||||
|
||||
const kilocodeACPPkg = "@kilocode/cli"
|
||||
|
||||
var (
|
||||
_ Agent = (*KilocodeACP)(nil)
|
||||
_ PassthroughAgent = (*KilocodeACP)(nil)
|
||||
_ InferenceAgent = (*KilocodeACP)(nil)
|
||||
)
|
||||
|
||||
// KilocodeACP implements Agent for Kilocode using ACP.
|
||||
type KilocodeACP struct {
|
||||
StandardPassthrough
|
||||
}
|
||||
|
||||
func NewKilocodeACP() *KilocodeACP {
|
||||
return &KilocodeACP{
|
||||
StandardPassthrough: StandardPassthrough{
|
||||
PermSettings: emptyPermSettings,
|
||||
Cfg: PassthroughConfig{
|
||||
Supported: true,
|
||||
Label: "CLI Passthrough",
|
||||
Description: "Show terminal directly instead of chat interface",
|
||||
PassthroughCmd: NewCommand("npx", "-y", kilocodeACPPkg),
|
||||
ModelFlag: NewParam("--model", "{model}"),
|
||||
IdleTimeout: 3 * time.Second,
|
||||
BufferMaxBytes: DefaultBufferMaxBytes,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (a *KilocodeACP) ID() string { return "kilocode-acp" }
|
||||
func (a *KilocodeACP) Name() string { return "Kilocode ACP Agent" }
|
||||
func (a *KilocodeACP) DisplayName() string { return "Kilocode" }
|
||||
func (a *KilocodeACP) Description() string {
|
||||
return "Kilocode coding agent using the ACP protocol over stdin/stdout."
|
||||
}
|
||||
func (a *KilocodeACP) Enabled() bool { return true }
|
||||
func (a *KilocodeACP) DisplayOrder() int { return 11 }
|
||||
|
||||
func (a *KilocodeACP) Logo(v LogoVariant) []byte {
|
||||
if v == LogoDark {
|
||||
return kilocodeACPLogoDark
|
||||
}
|
||||
return kilocodeACPLogoLight
|
||||
}
|
||||
|
||||
func (a *KilocodeACP) IsInstalled(ctx context.Context) (*DiscoveryResult, error) {
|
||||
result, err := Detect(ctx, WithCommand("kilo"), WithCommand("kilocode"))
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
result.SupportsMCP = true
|
||||
result.Capabilities = DiscoveryCapabilities{
|
||||
SupportsSessionResume: true,
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (a *KilocodeACP) BuildCommand(opts CommandOptions) Command {
|
||||
return Cmd("npx", "-y", kilocodeACPPkg, "acp").Build()
|
||||
}
|
||||
|
||||
func (a *KilocodeACP) Runtime() *RuntimeConfig {
|
||||
canRecover := true
|
||||
return &RuntimeConfig{
|
||||
Cmd: Cmd("npx", "-y", kilocodeACPPkg, "acp").Build(),
|
||||
WorkingDir: "{workspace}",
|
||||
Env: map[string]string{},
|
||||
ResourceLimits: ResourceLimits{MemoryMB: 4096, CPUCores: 2.0, Timeout: time.Hour},
|
||||
Protocol: agent.ProtocolACP,
|
||||
SessionConfig: SessionConfig{
|
||||
NativeSessionResume: true,
|
||||
CanRecover: &canRecover,
|
||||
SessionDirTemplate: "{home}/.kilocode",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (a *KilocodeACP) RemoteAuth() *RemoteAuth { return nil }
|
||||
|
||||
func (a *KilocodeACP) InstallScript() string {
|
||||
return "npm install -g " + kilocodeACPPkg
|
||||
}
|
||||
|
||||
func (a *KilocodeACP) PermissionSettings() map[string]PermissionSetting {
|
||||
return emptyPermSettings
|
||||
}
|
||||
|
||||
func (a *KilocodeACP) InferenceConfig() *InferenceConfig {
|
||||
return &InferenceConfig{
|
||||
Supported: true,
|
||||
Command: NewCommand("npx", "-y", kilocodeACPPkg, "acp"),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
//nolint:dupl // Native-binary ACP agents (Cursor, Kimi, Kiro, Qoder, Trae) follow the same minimal scaffold; differences are the binary name and subcommand.
|
||||
package agents
|
||||
|
||||
import (
|
||||
"context"
|
||||
_ "embed"
|
||||
"time"
|
||||
|
||||
"github.com/kandev/kandev/pkg/agent"
|
||||
)
|
||||
|
||||
//go:embed logos/kimi_acp_light.svg
|
||||
var kimiACPLogoLight []byte
|
||||
|
||||
//go:embed logos/kimi_acp_dark.svg
|
||||
var kimiACPLogoDark []byte
|
||||
|
||||
const kimiACPBin = "kimi"
|
||||
|
||||
var (
|
||||
_ Agent = (*KimiACP)(nil)
|
||||
_ PassthroughAgent = (*KimiACP)(nil)
|
||||
_ InferenceAgent = (*KimiACP)(nil)
|
||||
)
|
||||
|
||||
// KimiACP implements Agent for Moonshot's Kimi CLI using ACP.
|
||||
// Not on npm — users must install the kimi binary from Moonshot AI.
|
||||
type KimiACP struct {
|
||||
StandardPassthrough
|
||||
}
|
||||
|
||||
func NewKimiACP() *KimiACP {
|
||||
return &KimiACP{
|
||||
StandardPassthrough: StandardPassthrough{
|
||||
PermSettings: emptyPermSettings,
|
||||
Cfg: PassthroughConfig{
|
||||
Supported: true,
|
||||
Label: "CLI Passthrough",
|
||||
Description: "Show terminal directly instead of chat interface",
|
||||
PassthroughCmd: NewCommand(kimiACPBin),
|
||||
ModelFlag: NewParam("--model", "{model}"),
|
||||
IdleTimeout: 3 * time.Second,
|
||||
BufferMaxBytes: DefaultBufferMaxBytes,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (a *KimiACP) ID() string { return "kimi-acp" }
|
||||
func (a *KimiACP) Name() string { return "Kimi ACP Agent" }
|
||||
func (a *KimiACP) DisplayName() string { return "Kimi" }
|
||||
func (a *KimiACP) Description() string {
|
||||
return "Moonshot AI Kimi coding agent using the ACP protocol over stdin/stdout."
|
||||
}
|
||||
func (a *KimiACP) Enabled() bool { return true }
|
||||
func (a *KimiACP) DisplayOrder() int { return 14 }
|
||||
|
||||
func (a *KimiACP) Logo(v LogoVariant) []byte {
|
||||
if v == LogoDark {
|
||||
return kimiACPLogoDark
|
||||
}
|
||||
return kimiACPLogoLight
|
||||
}
|
||||
|
||||
func (a *KimiACP) IsInstalled(ctx context.Context) (*DiscoveryResult, error) {
|
||||
result, err := Detect(ctx, WithCommand(kimiACPBin))
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
result.SupportsMCP = true
|
||||
result.Capabilities = DiscoveryCapabilities{
|
||||
SupportsSessionResume: true,
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (a *KimiACP) BuildCommand(opts CommandOptions) Command {
|
||||
return Cmd(kimiACPBin, "acp").Build()
|
||||
}
|
||||
|
||||
func (a *KimiACP) Runtime() *RuntimeConfig {
|
||||
canRecover := true
|
||||
return &RuntimeConfig{
|
||||
Cmd: Cmd(kimiACPBin, "acp").Build(),
|
||||
WorkingDir: "{workspace}",
|
||||
Env: map[string]string{},
|
||||
ResourceLimits: ResourceLimits{MemoryMB: 4096, CPUCores: 2.0, Timeout: time.Hour},
|
||||
Protocol: agent.ProtocolACP,
|
||||
SessionConfig: SessionConfig{
|
||||
NativeSessionResume: true,
|
||||
CanRecover: &canRecover,
|
||||
SessionDirTemplate: "{home}/.kimi",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (a *KimiACP) RemoteAuth() *RemoteAuth { return nil }
|
||||
|
||||
func (a *KimiACP) InstallScript() string {
|
||||
return "Install Kimi CLI from Moonshot AI"
|
||||
}
|
||||
|
||||
func (a *KimiACP) PermissionSettings() map[string]PermissionSetting {
|
||||
return emptyPermSettings
|
||||
}
|
||||
|
||||
func (a *KimiACP) InferenceConfig() *InferenceConfig {
|
||||
return &InferenceConfig{
|
||||
Supported: true,
|
||||
Command: NewCommand(kimiACPBin, "acp"),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
//nolint:dupl // Native-binary ACP agents (Cursor, Kimi, Kiro, Qoder, Trae) follow the same minimal scaffold; differences are the binary name and subcommand.
|
||||
package agents
|
||||
|
||||
import (
|
||||
"context"
|
||||
_ "embed"
|
||||
"time"
|
||||
|
||||
"github.com/kandev/kandev/pkg/agent"
|
||||
)
|
||||
|
||||
//go:embed logos/kiro_acp_light.svg
|
||||
var kiroACPLogoLight []byte
|
||||
|
||||
//go:embed logos/kiro_acp_dark.svg
|
||||
var kiroACPLogoDark []byte
|
||||
|
||||
const kiroACPBin = "kiro-cli-chat"
|
||||
|
||||
var (
|
||||
_ Agent = (*KiroACP)(nil)
|
||||
_ PassthroughAgent = (*KiroACP)(nil)
|
||||
_ InferenceAgent = (*KiroACP)(nil)
|
||||
)
|
||||
|
||||
// KiroACP implements Agent for AWS Kiro using ACP. The CLI binary
|
||||
// (kiro-cli-chat) is installed via AWS-provided tooling.
|
||||
type KiroACP struct {
|
||||
StandardPassthrough
|
||||
}
|
||||
|
||||
func NewKiroACP() *KiroACP {
|
||||
return &KiroACP{
|
||||
StandardPassthrough: StandardPassthrough{
|
||||
PermSettings: emptyPermSettings,
|
||||
Cfg: PassthroughConfig{
|
||||
Supported: true,
|
||||
Label: "CLI Passthrough",
|
||||
Description: "Show terminal directly instead of chat interface",
|
||||
PassthroughCmd: NewCommand(kiroACPBin),
|
||||
ModelFlag: NewParam("--model", "{model}"),
|
||||
IdleTimeout: 3 * time.Second,
|
||||
BufferMaxBytes: DefaultBufferMaxBytes,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (a *KiroACP) ID() string { return "kiro-acp" }
|
||||
func (a *KiroACP) Name() string { return "Kiro ACP Agent" }
|
||||
func (a *KiroACP) DisplayName() string { return "Kiro" }
|
||||
func (a *KiroACP) Description() string {
|
||||
return "AWS Kiro coding agent using the ACP protocol via kiro-cli-chat."
|
||||
}
|
||||
func (a *KiroACP) Enabled() bool { return true }
|
||||
func (a *KiroACP) DisplayOrder() int { return 15 }
|
||||
|
||||
func (a *KiroACP) Logo(v LogoVariant) []byte {
|
||||
if v == LogoDark {
|
||||
return kiroACPLogoDark
|
||||
}
|
||||
return kiroACPLogoLight
|
||||
}
|
||||
|
||||
func (a *KiroACP) IsInstalled(ctx context.Context) (*DiscoveryResult, error) {
|
||||
result, err := Detect(ctx, WithCommand(kiroACPBin))
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
result.SupportsMCP = true
|
||||
result.Capabilities = DiscoveryCapabilities{
|
||||
SupportsSessionResume: true,
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (a *KiroACP) BuildCommand(opts CommandOptions) Command {
|
||||
return Cmd(kiroACPBin, "acp").Build()
|
||||
}
|
||||
|
||||
func (a *KiroACP) Runtime() *RuntimeConfig {
|
||||
canRecover := true
|
||||
return &RuntimeConfig{
|
||||
Cmd: Cmd(kiroACPBin, "acp").Build(),
|
||||
WorkingDir: "{workspace}",
|
||||
Env: map[string]string{},
|
||||
ResourceLimits: ResourceLimits{MemoryMB: 4096, CPUCores: 2.0, Timeout: time.Hour},
|
||||
Protocol: agent.ProtocolACP,
|
||||
SessionConfig: SessionConfig{
|
||||
// TODO: set SessionDirTemplate once the Kiro session dir is
|
||||
// confirmed (likely "{home}/.kiro"). Without it, the Docker
|
||||
// runtime skips mounting the session dir, so NativeSessionResume
|
||||
// has no persistence across container restarts. ACP-native
|
||||
// session/load still works in standalone mode where the host
|
||||
// home dir is the working filesystem.
|
||||
NativeSessionResume: true,
|
||||
CanRecover: &canRecover,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (a *KiroACP) RemoteAuth() *RemoteAuth { return nil }
|
||||
|
||||
func (a *KiroACP) InstallScript() string {
|
||||
return "Install Kiro CLI from AWS"
|
||||
}
|
||||
|
||||
func (a *KiroACP) PermissionSettings() map[string]PermissionSetting {
|
||||
return emptyPermSettings
|
||||
}
|
||||
|
||||
func (a *KiroACP) InferenceConfig() *InferenceConfig {
|
||||
return &InferenceConfig{
|
||||
Supported: true,
|
||||
Command: NewCommand(kiroACPBin, "acp"),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<svg fill="#FFFFFF" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Cursor</title><path d="M22.106 5.68L12.5.135a.998.998 0 00-.998 0L1.893 5.68a.84.84 0 00-.419.726v11.186c0 .3.16.577.42.727l9.607 5.547a.999.999 0 00.998 0l9.608-5.547a.84.84 0 00.42-.727V6.407a.84.84 0 00-.42-.726zm-.603 1.176L12.228 22.92c-.063.108-.228.064-.228-.061V12.34a.59.59 0 00-.295-.51l-9.11-5.26c-.107-.062-.063-.228.062-.228h18.55c.264 0 .428.286.296.514z"></path></svg>
|
||||
|
After Width: | Height: | Size: 543 B |
@@ -0,0 +1 @@
|
||||
<svg fill="#000000" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Cursor</title><path d="M22.106 5.68L12.5.135a.998.998 0 00-.998 0L1.893 5.68a.84.84 0 00-.419.726v11.186c0 .3.16.577.42.727l9.607 5.547a.999.999 0 00.998 0l9.608-5.547a.84.84 0 00.42-.727V6.407a.84.84 0 00-.42-.726zm-.603 1.176L12.228 22.92c-.063.108-.228.064-.228-.061V12.34a.59.59 0 00-.295-.51l-9.11-5.26c-.107-.062-.063-.228.062-.228h18.55c.264 0 .428.286.296.514z"></path></svg>
|
||||
|
After Width: | Height: | Size: 543 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10" fill="#ccc"/></svg>
|
||||
|
After Width: | Height: | Size: 133 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10" fill="#ccc"/></svg>
|
||||
|
After Width: | Height: | Size: 133 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10" fill="#ccc"/></svg>
|
||||
|
After Width: | Height: | Size: 133 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10" fill="#ccc"/></svg>
|
||||
|
After Width: | Height: | Size: 133 B |
@@ -0,0 +1 @@
|
||||
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Kilo Code</title><path d="M0 0v24h24V0H0zm22.222 22.222H1.778V1.778h20.444v20.444zm-7.555-4.964h2.222v1.778h-2.794L12.89 17.83v-2.794h1.778v2.222zm4 0h-1.778v-2.222h-2.222v-1.778h2.793l1.207 1.207v2.793zm-7.556-2.591H9.333v-1.778h1.778v1.778zm-5.778-1.778h1.778v4h4v1.778H6.54L5.333 17.46V12.89zm13.334-3.556v1.778h-5.778V9.333h1.987V7.111h-1.987V5.333h2.558l1.206 1.207v2.793h2.014zm-11.556-2h2.222l1.778 1.778v2H9.333v-2H7.111v2H5.333V5.333h1.778v2zm4 0H9.333v-2h1.778v2z"></path></svg>
|
||||
|
After Width: | Height: | Size: 652 B |
@@ -0,0 +1 @@
|
||||
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Kilo Code</title><path d="M0 0v24h24V0H0zm22.222 22.222H1.778V1.778h20.444v20.444zm-7.555-4.964h2.222v1.778h-2.794L12.89 17.83v-2.794h1.778v2.222zm4 0h-1.778v-2.222h-2.222v-1.778h2.793l1.207 1.207v2.793zm-7.556-2.591H9.333v-1.778h1.778v1.778zm-5.778-1.778h1.778v4h4v1.778H6.54L5.333 17.46V12.89zm13.334-3.556v1.778h-5.778V9.333h1.987V7.111h-1.987V5.333h2.558l1.206 1.207v2.793h2.014zm-11.556-2h2.222l1.778 1.778v2H9.333v-2H7.111v2H5.333V5.333h1.778v2zm4 0H9.333v-2h1.778v2z"></path></svg>
|
||||
|
After Width: | Height: | Size: 652 B |
@@ -0,0 +1,19 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:xodm="http://www.corel.com/coreldraw/odm/2003" xml:space="preserve" version="1.0" style="shape-rendering:geometricPrecision; text-rendering:geometricPrecision; image-rendering:optimizeQuality; fill-rule:evenodd; clip-rule:evenodd" viewBox="2589.93 529.3 4225.03 4225.03">
|
||||
<defs>
|
||||
<style type="text/css">
|
||||
<![CDATA[
|
||||
.fil3 {fill:#2B2A29}
|
||||
.fil5 {fill:#0179FF}
|
||||
.fil4 {fill:white}
|
||||
]]>
|
||||
</style>
|
||||
</defs>
|
||||
<g id="Layer_x0020_1">
|
||||
<metadata id="CorelCorpID_0Corel-Layer"/>
|
||||
<rect class="fil3" x="2589.93" y="529.3" width="4225.03" height="4225.03" rx="304.59" ry="304.59"/>
|
||||
<g id="_2448169161120">
|
||||
<path class="fil4" d="M4354.82 2395.15l-641.29 0 0 -1043.67 -518.69 0 0 2700.35 518.69 0 0 -1137.99 930.5 0c99.31,0 187.16,-42.51 257.02,-104.49 29.17,-25.87 43.76,-42.09 67.45,-77.16l27.62 -44.68 0 1364.32 521.84 0 0 -1128.55c0,-275.9 -193.35,-483.68 -441.87,-523.22 -53.14,-8.46 -250.95,-4.91 -318.89,-4.91 25.23,-21.93 133.44,-28.86 263.21,-208.33 8.03,-11.11 13.21,-21.99 19.88,-33.56 23.63,-40.99 81.8,-173.93 101.34,-219.31l114.36 -256.58c27.34,-56.91 56.36,-131.98 81.76,-182.31 6.16,-12.22 11.49,-25.16 16.69,-36.74 8.37,-18.63 46,-98.65 47.21,-113.12l-578.42 0c-10.76,20.34 -18.77,42.96 -29.35,64.97 -5.26,10.95 -10.16,23.06 -14.21,32.93l-390.21 889.24c-8.43,18.76 -23.03,62.81 -34.64,62.81z"/>
|
||||
<path class="fil5" d="M5624.83 1533.81c0,162.36 81.74,224.33 81.74,242.06 0,23.25 -11.97,27.5 -32.81,55.21 -11.61,15.44 -24.68,29.28 -33.21,45.39l81.58 -3.31c11.75,-0.97 10.98,-2.67 25.21,-3.08l81.85 -3.04c13.69,-0.7 11.45,-2.49 25.14,-3.15 93.1,-4.5 197.72,-15.93 261.76,-77.75 118.61,-114.51 125.67,-360.29 17.22,-468.46 -45.13,-45.01 -112.6,-81.03 -178.4,-82.52 -17.05,-0.39 -19.63,-3.34 -34.58,-3.34 -29.74,0 -98.29,11.16 -125.81,22.15 -34.4,13.73 -66.41,32.15 -91.97,58.92 -40.84,42.79 -77.72,137.66 -77.72,220.92z"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
@@ -0,0 +1,19 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:xodm="http://www.corel.com/coreldraw/odm/2003" xml:space="preserve" version="1.0" style="shape-rendering:geometricPrecision; text-rendering:geometricPrecision; image-rendering:optimizeQuality; fill-rule:evenodd; clip-rule:evenodd" viewBox="2589.93 529.3 4225.03 4225.03">
|
||||
<defs>
|
||||
<style type="text/css">
|
||||
<![CDATA[
|
||||
.fil3 {fill:#2B2A29}
|
||||
.fil5 {fill:#0179FF}
|
||||
.fil4 {fill:white}
|
||||
]]>
|
||||
</style>
|
||||
</defs>
|
||||
<g id="Layer_x0020_1">
|
||||
<metadata id="CorelCorpID_0Corel-Layer"/>
|
||||
<rect class="fil3" x="2589.93" y="529.3" width="4225.03" height="4225.03" rx="304.59" ry="304.59"/>
|
||||
<g id="_2448169161120">
|
||||
<path class="fil4" d="M4354.82 2395.15l-641.29 0 0 -1043.67 -518.69 0 0 2700.35 518.69 0 0 -1137.99 930.5 0c99.31,0 187.16,-42.51 257.02,-104.49 29.17,-25.87 43.76,-42.09 67.45,-77.16l27.62 -44.68 0 1364.32 521.84 0 0 -1128.55c0,-275.9 -193.35,-483.68 -441.87,-523.22 -53.14,-8.46 -250.95,-4.91 -318.89,-4.91 25.23,-21.93 133.44,-28.86 263.21,-208.33 8.03,-11.11 13.21,-21.99 19.88,-33.56 23.63,-40.99 81.8,-173.93 101.34,-219.31l114.36 -256.58c27.34,-56.91 56.36,-131.98 81.76,-182.31 6.16,-12.22 11.49,-25.16 16.69,-36.74 8.37,-18.63 46,-98.65 47.21,-113.12l-578.42 0c-10.76,20.34 -18.77,42.96 -29.35,64.97 -5.26,10.95 -10.16,23.06 -14.21,32.93l-390.21 889.24c-8.43,18.76 -23.03,62.81 -34.64,62.81z"/>
|
||||
<path class="fil5" d="M5624.83 1533.81c0,162.36 81.74,224.33 81.74,242.06 0,23.25 -11.97,27.5 -32.81,55.21 -11.61,15.44 -24.68,29.28 -33.21,45.39l81.58 -3.31c11.75,-0.97 10.98,-2.67 25.21,-3.08l81.85 -3.04c13.69,-0.7 11.45,-2.49 25.14,-3.15 93.1,-4.5 197.72,-15.93 261.76,-77.75 118.61,-114.51 125.67,-360.29 17.22,-468.46 -45.13,-45.01 -112.6,-81.03 -178.4,-82.52 -17.05,-0.39 -19.63,-3.34 -34.58,-3.34 -29.74,0 -98.29,11.16 -125.81,22.15 -34.4,13.73 -66.41,32.15 -91.97,58.92 -40.84,42.79 -77.72,137.66 -77.72,220.92z"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10" fill="#ccc"/></svg>
|
||||
|
After Width: | Height: | Size: 133 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10" fill="#ccc"/></svg>
|
||||
|
After Width: | Height: | Size: 133 B |
@@ -0,0 +1,22 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 800">
|
||||
<!-- P shape: outer boundary clockwise, inner hole counter-clockwise -->
|
||||
<path fill="#fff" fill-rule="evenodd" d="
|
||||
M165.29 165.29
|
||||
H517.36
|
||||
V400
|
||||
H400
|
||||
V517.36
|
||||
H282.65
|
||||
V634.72
|
||||
H165.29
|
||||
Z
|
||||
M282.65 282.65
|
||||
V400
|
||||
H400
|
||||
V282.65
|
||||
Z
|
||||
"/>
|
||||
<!-- i dot -->
|
||||
<path fill="#fff" d="M517.36 400 H634.72 V634.72 H517.36 Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 473 B |
@@ -0,0 +1,22 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 800">
|
||||
<!-- P shape: outer boundary clockwise, inner hole counter-clockwise -->
|
||||
<path fill="#000000" fill-rule="evenodd" d="
|
||||
M165.29 165.29
|
||||
H517.36
|
||||
V400
|
||||
H400
|
||||
V517.36
|
||||
H282.65
|
||||
V634.72
|
||||
H165.29
|
||||
Z
|
||||
M282.65 282.65
|
||||
V400
|
||||
H400
|
||||
V282.65
|
||||
Z
|
||||
"/>
|
||||
<!-- i dot -->
|
||||
<path fill="#000000" d="M517.36 400 H634.72 V634.72 H517.36 Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 479 B |
|
After Width: | Height: | Size: 6.1 KiB |
|
After Width: | Height: | Size: 6.1 KiB |
@@ -0,0 +1,15 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="27.55 17.52 147.28 145.51">
|
||||
<path d="M174.82 108.75L155.38 75L165.64 57.75C166.46 56.31 166.46 54.53 165.64 53.09L155.38 35.84C154.86 34.91 153.87 34.33 152.78 34.33H114.88L106.14 19.03C105.62 18.1 104.63 17.52 103.54 17.52H83.3C82.21 17.52 81.22 18.1 80.7 19.03L61.26 52.77H41.02C39.93 52.77 38.94 53.35 38.42 54.28L28.16 71.53C27.34 72.97 27.34 74.75 28.16 76.19L45.52 107.5L36.78 122.8C35.96 124.24 35.96 126.02 36.78 127.46L47.04 144.71C47.56 145.64 48.55 146.22 49.64 146.22H87.54L96.28 161.52C96.8 162.45 97.79 163.03 98.88 163.03H119.12C120.21 163.03 121.2 162.45 121.72 161.52L141.16 127.78H158.52C159.61 127.78 160.6 127.2 161.12 126.27L171.38 109.02C172.2 107.58 172.2 105.8 171.38 104.36L174.82 108.75Z" fill="url(#paint0_radial)"/>
|
||||
<path d="M119.12 163.03H98.88L87.54 144.71H49.64L61.26 126.39H80.7L38.42 55.29H61.26L83.3 19.03L93.56 37.35L83.3 55.29H161.58L151.32 72.54L170.76 106.28H151.32L141.16 88.34L101.18 163.03H119.12Z" fill="white"/>
|
||||
<path d="M127.86 79.83H76.14L101.18 122.11L127.86 79.83Z" fill="url(#paint1_radial)"/>
|
||||
<defs>
|
||||
<radialGradient id="paint0_radial" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(100 100) rotate(90) scale(100)">
|
||||
<stop stop-color="#665CEE"/>
|
||||
<stop offset="1" stop-color="#332E91"/>
|
||||
</radialGradient>
|
||||
<radialGradient id="paint1_radial" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(100 100) rotate(90) scale(100)">
|
||||
<stop stop-color="#665CEE"/>
|
||||
<stop offset="1" stop-color="#332E91"/>
|
||||
</radialGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
@@ -0,0 +1,15 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="27.55 17.52 147.28 145.51">
|
||||
<path d="M174.82 108.75L155.38 75L165.64 57.75C166.46 56.31 166.46 54.53 165.64 53.09L155.38 35.84C154.86 34.91 153.87 34.33 152.78 34.33H114.88L106.14 19.03C105.62 18.1 104.63 17.52 103.54 17.52H83.3C82.21 17.52 81.22 18.1 80.7 19.03L61.26 52.77H41.02C39.93 52.77 38.94 53.35 38.42 54.28L28.16 71.53C27.34 72.97 27.34 74.75 28.16 76.19L45.52 107.5L36.78 122.8C35.96 124.24 35.96 126.02 36.78 127.46L47.04 144.71C47.56 145.64 48.55 146.22 49.64 146.22H87.54L96.28 161.52C96.8 162.45 97.79 163.03 98.88 163.03H119.12C120.21 163.03 121.2 162.45 121.72 161.52L141.16 127.78H158.52C159.61 127.78 160.6 127.2 161.12 126.27L171.38 109.02C172.2 107.58 172.2 105.8 171.38 104.36L174.82 108.75Z" fill="url(#paint0_radial)"/>
|
||||
<path d="M119.12 163.03H98.88L87.54 144.71H49.64L61.26 126.39H80.7L38.42 55.29H61.26L83.3 19.03L93.56 37.35L83.3 55.29H161.58L151.32 72.54L170.76 106.28H151.32L141.16 88.34L101.18 163.03H119.12Z" fill="white"/>
|
||||
<path d="M127.86 79.83H76.14L101.18 122.11L127.86 79.83Z" fill="url(#paint1_radial)"/>
|
||||
<defs>
|
||||
<radialGradient id="paint0_radial" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(100 100) rotate(90) scale(100)">
|
||||
<stop stop-color="#665CEE"/>
|
||||
<stop offset="1" stop-color="#332E91"/>
|
||||
</radialGradient>
|
||||
<radialGradient id="paint1_radial" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(100 100) rotate(90) scale(100)">
|
||||
<stop stop-color="#665CEE"/>
|
||||
<stop offset="1" stop-color="#332E91"/>
|
||||
</radialGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
@@ -0,0 +1 @@
|
||||
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>TRAE</title><path d="M24 20.541H3.428v-3.426H0V3.4h24V20.54zM3.428 17.115h17.144V6.827H3.428v10.288zm8.573-5.196l-2.425 2.424-2.424-2.424 2.424-2.424 2.425 2.424zm6.857-.001l-2.424 2.423-2.425-2.423 2.425-2.425 2.424 2.425z" fill="#32F08C"></path></svg>
|
||||
|
After Width: | Height: | Size: 377 B |
@@ -0,0 +1 @@
|
||||
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>TRAE</title><path d="M24 20.541H3.428v-3.426H0V3.4h24V20.54zM3.428 17.115h17.144V6.827H3.428v10.288zm8.573-5.196l-2.425 2.424-2.424-2.424 2.424-2.424 2.425 2.424zm6.857-.001l-2.424 2.423-2.425-2.423 2.425-2.425 2.424 2.425z" fill="#32F08C"></path></svg>
|
||||
|
After Width: | Height: | Size: 377 B |
@@ -0,0 +1,265 @@
|
||||
package agents
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os/exec"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/kandev/kandev/pkg/agent"
|
||||
)
|
||||
|
||||
// acpAgentSpec captures the full contract each newly-added ACP agent must
|
||||
// honor. Pinning every command surface (BuildCommand, Runtime.Cmd,
|
||||
// InferenceConfig.Command, PassthroughCmd) keeps regressions like one path
|
||||
// drifting to npx while the rest stay native (or vice versa) loud rather
|
||||
// than silent.
|
||||
type acpAgentSpec struct {
|
||||
id string
|
||||
displayName string
|
||||
// detectBinaries lists every binary name IsInstalled accepts via
|
||||
// WithCommand. Used by TestNewACPAgents_DetectionRequiresGlobalBinary
|
||||
// to skip the test whenever ANY of these is on PATH — otherwise an
|
||||
// agent with multiple WithCommand fallbacks (e.g. Pi accepts both
|
||||
// `pi-acp` and `pi`) flakes when the secondary binary is present.
|
||||
detectBinaries []string
|
||||
expectedArgv []string // BuildCommand and Runtime.Cmd
|
||||
inferenceArgv []string // InferenceConfig.Command
|
||||
passthroughArgv []string // PassthroughCmd (zero-args allowed)
|
||||
installViaNpm bool // InstallScript starts with "npm install -g"
|
||||
}
|
||||
|
||||
var newACPAgentSpecs = []struct {
|
||||
new func() Agent
|
||||
spec acpAgentSpec
|
||||
}{
|
||||
{func() Agent { return NewQwenACP() }, acpAgentSpec{
|
||||
id: "qwen-acp", displayName: "Qwen", detectBinaries: []string{"qwen"},
|
||||
expectedArgv: []string{"npx", "-y", "@qwen-code/qwen-code", "--acp"},
|
||||
inferenceArgv: []string{"npx", "-y", "@qwen-code/qwen-code", "--acp"},
|
||||
passthroughArgv: []string{"npx", "-y", "@qwen-code/qwen-code"},
|
||||
installViaNpm: true,
|
||||
}},
|
||||
{func() Agent { return NewIFlowACP() }, acpAgentSpec{
|
||||
id: "iflow-acp", displayName: "iFlow (beta)", detectBinaries: []string{"iflow"},
|
||||
expectedArgv: []string{"npx", "-y", "@iflow-ai/iflow-cli", "--experimental-acp"},
|
||||
inferenceArgv: []string{"npx", "-y", "@iflow-ai/iflow-cli", "--experimental-acp"},
|
||||
passthroughArgv: []string{"npx", "-y", "@iflow-ai/iflow-cli"},
|
||||
installViaNpm: true,
|
||||
}},
|
||||
{func() Agent { return NewDroidACP() }, acpAgentSpec{
|
||||
id: "droid-acp", displayName: "Droid", detectBinaries: []string{"droid"},
|
||||
expectedArgv: []string{"npx", "-y", "droid", "exec", "--output-format", "acp"},
|
||||
inferenceArgv: []string{"npx", "-y", "droid", "exec", "--output-format", "acp"},
|
||||
passthroughArgv: []string{"npx", "-y", "droid"},
|
||||
installViaNpm: true,
|
||||
}},
|
||||
{func() Agent { return NewKilocodeACP() }, acpAgentSpec{
|
||||
id: "kilocode-acp", displayName: "Kilocode", detectBinaries: []string{"kilo", "kilocode"},
|
||||
expectedArgv: []string{"npx", "-y", "@kilocode/cli", "acp"},
|
||||
inferenceArgv: []string{"npx", "-y", "@kilocode/cli", "acp"},
|
||||
passthroughArgv: []string{"npx", "-y", "@kilocode/cli"},
|
||||
installViaNpm: true,
|
||||
}},
|
||||
{func() Agent { return NewPiACP() }, acpAgentSpec{
|
||||
id: "pi-acp", displayName: "Pi", detectBinaries: []string{"pi-acp", "pi"},
|
||||
expectedArgv: []string{"npx", "-y", "pi-acp"},
|
||||
inferenceArgv: []string{"npx", "-y", "pi-acp"},
|
||||
passthroughArgv: []string{"npx", "-y", "pi-acp"},
|
||||
installViaNpm: true,
|
||||
}},
|
||||
{func() Agent { return NewCursorACP() }, acpAgentSpec{
|
||||
id: "cursor-acp", displayName: "Cursor", detectBinaries: []string{"cursor-agent"},
|
||||
expectedArgv: []string{"cursor-agent", "acp"},
|
||||
inferenceArgv: []string{"cursor-agent", "acp"},
|
||||
passthroughArgv: []string{"cursor-agent"},
|
||||
installViaNpm: false,
|
||||
}},
|
||||
{func() Agent { return NewKimiACP() }, acpAgentSpec{
|
||||
id: "kimi-acp", displayName: "Kimi", detectBinaries: []string{"kimi"},
|
||||
expectedArgv: []string{"kimi", "acp"},
|
||||
inferenceArgv: []string{"kimi", "acp"},
|
||||
passthroughArgv: []string{"kimi"},
|
||||
installViaNpm: false,
|
||||
}},
|
||||
{func() Agent { return NewKiroACP() }, acpAgentSpec{
|
||||
id: "kiro-acp", displayName: "Kiro", detectBinaries: []string{"kiro-cli-chat"},
|
||||
expectedArgv: []string{"kiro-cli-chat", "acp"},
|
||||
inferenceArgv: []string{"kiro-cli-chat", "acp"},
|
||||
passthroughArgv: []string{"kiro-cli-chat"},
|
||||
installViaNpm: false,
|
||||
}},
|
||||
{func() Agent { return NewQoderACP() }, acpAgentSpec{
|
||||
id: "qoder-acp", displayName: "Qoder", detectBinaries: []string{"qodercli"},
|
||||
expectedArgv: []string{"qodercli", "--acp"},
|
||||
inferenceArgv: []string{"qodercli", "--acp"},
|
||||
passthroughArgv: []string{"qodercli"},
|
||||
installViaNpm: false,
|
||||
}},
|
||||
{func() Agent { return NewTraeACP() }, acpAgentSpec{
|
||||
id: "trae-acp", displayName: "Trae", detectBinaries: []string{"traecli"},
|
||||
expectedArgv: []string{"traecli", "acp", "serve"},
|
||||
inferenceArgv: []string{"traecli", "acp", "serve"},
|
||||
passthroughArgv: []string{"traecli"},
|
||||
installViaNpm: false,
|
||||
}},
|
||||
}
|
||||
|
||||
func TestNewACPAgents_IDAndDisplay(t *testing.T) {
|
||||
for _, tc := range newACPAgentSpecs {
|
||||
t.Run(tc.spec.id, func(t *testing.T) {
|
||||
ag := tc.new()
|
||||
if got := ag.ID(); got != tc.spec.id {
|
||||
t.Errorf("ID() = %q, want %q", got, tc.spec.id)
|
||||
}
|
||||
if got := ag.DisplayName(); got != tc.spec.displayName {
|
||||
t.Errorf("DisplayName() = %q, want %q", got, tc.spec.displayName)
|
||||
}
|
||||
if !ag.Enabled() {
|
||||
t.Errorf("Enabled() = false, want true")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewACPAgents_AllCommandSurfaces pins every launch path: BuildCommand,
|
||||
// Runtime.Cmd, InferenceConfig.Command, and PassthroughCmd. Without this the
|
||||
// PassthroughCmd field can drift independently of the others (see the
|
||||
// OpenCode npx regression in this PR's review history).
|
||||
func TestNewACPAgents_AllCommandSurfaces(t *testing.T) {
|
||||
for _, tc := range newACPAgentSpecs {
|
||||
t.Run(tc.spec.id, func(t *testing.T) {
|
||||
ag := tc.new()
|
||||
assertArgvEqual(t, "BuildCommand", ag.BuildCommand(CommandOptions{}).Args(), tc.spec.expectedArgv)
|
||||
|
||||
rt := ag.Runtime()
|
||||
if rt == nil {
|
||||
t.Fatalf("Runtime() returned nil")
|
||||
}
|
||||
if rt.Protocol != agent.ProtocolACP {
|
||||
t.Errorf("Runtime.Protocol = %q, want ACP", rt.Protocol)
|
||||
}
|
||||
assertArgvEqual(t, "Runtime.Cmd", rt.Cmd.Args(), tc.spec.expectedArgv)
|
||||
|
||||
ia, ok := ag.(InferenceAgent)
|
||||
if !ok {
|
||||
t.Fatalf("%s does not implement InferenceAgent", tc.spec.id)
|
||||
}
|
||||
ic := ia.InferenceConfig()
|
||||
if ic == nil || !ic.Supported {
|
||||
t.Fatalf("InferenceConfig() = %+v, want Supported=true", ic)
|
||||
}
|
||||
assertArgvEqual(t, "InferenceConfig.Command", ic.Command.Args(), tc.spec.inferenceArgv)
|
||||
|
||||
pa, ok := ag.(PassthroughAgent)
|
||||
if !ok {
|
||||
t.Fatalf("%s does not implement PassthroughAgent", tc.spec.id)
|
||||
}
|
||||
assertArgvEqual(t, "PassthroughCmd", pa.PassthroughConfig().PassthroughCmd.Args(), tc.spec.passthroughArgv)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewACPAgents_InstallScript(t *testing.T) {
|
||||
for _, tc := range newACPAgentSpecs {
|
||||
t.Run(tc.spec.id, func(t *testing.T) {
|
||||
ag := tc.new()
|
||||
got := ag.InstallScript()
|
||||
hasNpm := strings.HasPrefix(got, "npm install -g ")
|
||||
if tc.spec.installViaNpm && !hasNpm {
|
||||
t.Errorf("InstallScript() = %q, want npm install -g …", got)
|
||||
}
|
||||
if !tc.spec.installViaNpm && hasNpm {
|
||||
t.Errorf("InstallScript() should NOT use npm for native-binary agent: %q", got)
|
||||
}
|
||||
// The primary detection binary must be referenced somewhere
|
||||
// actionable — either argv (native binaries) or InstallScript
|
||||
// (npm install -g <pkg> ships the bin), so users with "Available
|
||||
// to Install" status see a hint that resolves to the right
|
||||
// command. Only check the first detect binary; secondaries are
|
||||
// fallbacks that may not appear in either surface.
|
||||
primary := tc.spec.detectBinaries[0]
|
||||
argv := ag.BuildCommand(CommandOptions{}).Args()
|
||||
if !slices.Contains(argv, primary) && !strings.Contains(got, primary) {
|
||||
t.Errorf("detection binary %q not referenced in argv (%v) or InstallScript (%q)",
|
||||
primary, argv, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewACPAgents_DetectionRequiresGlobalBinary pins the contract that
|
||||
// agents are not reported as available solely because npx is on PATH. The
|
||||
// host-utility manager treats Available=true as a green light to spawn and
|
||||
// probe the agent — claiming availability for an agent the user hasn't
|
||||
// actually installed triggers unwanted package downloads and produces
|
||||
// misleading auth_required/failed states for agents the user never asked
|
||||
// to use. detectBinaries comes from the spec table so npx-launched agents
|
||||
// (whose argv[0] is "npx") are still verified against their real detection
|
||||
// targets (qwen, iflow, droid, …), and agents with multiple WithCommand
|
||||
// fallbacks (e.g. Pi accepts both `pi-acp` and `pi`) skip the test
|
||||
// whenever ANY of those is on PATH so the test doesn't flake on CI hosts
|
||||
// that happen to have a generic `pi` (Raspberry Pi tooling) installed.
|
||||
func TestNewACPAgents_DetectionRequiresGlobalBinary(t *testing.T) {
|
||||
for _, tc := range newACPAgentSpecs {
|
||||
t.Run(tc.spec.id, func(t *testing.T) {
|
||||
for _, binary := range tc.spec.detectBinaries {
|
||||
if _, err := exec.LookPath(binary); err == nil {
|
||||
t.Skipf("detection binary %q is on PATH; can't verify availability requirement", binary)
|
||||
}
|
||||
}
|
||||
result, err := tc.new().IsInstalled(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("IsInstalled error: %v", err)
|
||||
}
|
||||
if result.Available {
|
||||
t.Errorf("Available=true without any of %v on PATH; detection should require the global binary so the host-utility manager doesn't spawn unwanted npx probes",
|
||||
tc.spec.detectBinaries)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewACPAgents_LogosNonEmpty guards against agents shipping with empty
|
||||
// embedded SVGs (which renders as a broken <img> in the UI).
|
||||
func TestNewACPAgents_LogosNonEmpty(t *testing.T) {
|
||||
for _, tc := range newACPAgentSpecs {
|
||||
t.Run(tc.spec.id, func(t *testing.T) {
|
||||
ag := tc.new()
|
||||
if len(ag.Logo(LogoLight)) == 0 {
|
||||
t.Errorf("Logo(LogoLight) is empty")
|
||||
}
|
||||
if len(ag.Logo(LogoDark)) == 0 {
|
||||
t.Errorf("Logo(LogoDark) is empty")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewACPAgents_DisplayOrderUnique ensures the new agents don't collide
|
||||
// with each other or with the existing built-ins (Claude=1..Amp=7).
|
||||
func TestNewACPAgents_DisplayOrderUnique(t *testing.T) {
|
||||
all := []Agent{
|
||||
NewClaudeACP(), NewCodexACP(), NewAuggie(), NewOpenCodeACP(),
|
||||
NewGemini(), NewCopilotACP(), NewAmpACP(),
|
||||
}
|
||||
for _, tc := range newACPAgentSpecs {
|
||||
all = append(all, tc.new())
|
||||
}
|
||||
seen := map[int]string{}
|
||||
for _, ag := range all {
|
||||
order := ag.DisplayOrder()
|
||||
if other, exists := seen[order]; exists {
|
||||
t.Errorf("DisplayOrder %d collision: %s and %s", order, other, ag.ID())
|
||||
}
|
||||
seen[order] = ag.ID()
|
||||
}
|
||||
}
|
||||
|
||||
func assertArgvEqual(t *testing.T, label string, got, want []string) {
|
||||
t.Helper()
|
||||
if !slices.Equal(got, want) {
|
||||
t.Errorf("%s argv mismatch\n got: %#v\n want: %#v", label, got, want)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package agents
|
||||
|
||||
import (
|
||||
"context"
|
||||
_ "embed"
|
||||
"time"
|
||||
|
||||
"github.com/kandev/kandev/pkg/agent"
|
||||
)
|
||||
|
||||
//go:embed logos/pi_acp_light.svg
|
||||
var piACPLogoLight []byte
|
||||
|
||||
//go:embed logos/pi_acp_dark.svg
|
||||
var piACPLogoDark []byte
|
||||
|
||||
const piACPPkg = "pi-acp"
|
||||
|
||||
var (
|
||||
_ Agent = (*PiACP)(nil)
|
||||
_ PassthroughAgent = (*PiACP)(nil)
|
||||
_ InferenceAgent = (*PiACP)(nil)
|
||||
)
|
||||
|
||||
// PiACP implements Agent for the Pi Coding Agent via the pi-acp adapter.
|
||||
type PiACP struct {
|
||||
StandardPassthrough
|
||||
}
|
||||
|
||||
func NewPiACP() *PiACP {
|
||||
return &PiACP{
|
||||
StandardPassthrough: StandardPassthrough{
|
||||
PermSettings: emptyPermSettings,
|
||||
Cfg: PassthroughConfig{
|
||||
Supported: true,
|
||||
Label: "CLI Passthrough",
|
||||
Description: "Show terminal directly instead of chat interface",
|
||||
PassthroughCmd: NewCommand("npx", "-y", piACPPkg),
|
||||
ModelFlag: NewParam("--model", "{model}"),
|
||||
IdleTimeout: 3 * time.Second,
|
||||
BufferMaxBytes: DefaultBufferMaxBytes,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (a *PiACP) ID() string { return "pi-acp" }
|
||||
func (a *PiACP) Name() string { return "Pi Coding Agent ACP" }
|
||||
func (a *PiACP) DisplayName() string { return "Pi" }
|
||||
func (a *PiACP) Description() string {
|
||||
return "Pi Coding Agent using the ACP protocol via the pi-acp adapter."
|
||||
}
|
||||
func (a *PiACP) Enabled() bool { return true }
|
||||
func (a *PiACP) DisplayOrder() int { return 12 }
|
||||
|
||||
func (a *PiACP) Logo(v LogoVariant) []byte {
|
||||
if v == LogoDark {
|
||||
return piACPLogoDark
|
||||
}
|
||||
return piACPLogoLight
|
||||
}
|
||||
|
||||
func (a *PiACP) IsInstalled(ctx context.Context) (*DiscoveryResult, error) {
|
||||
// "pi" is short and could in theory collide with unrelated tooling, but
|
||||
// the pi-mono coding agent (https://github.com/badlogic/pi-mono) ships
|
||||
// its CLI as `pi`, and a kandev user with `pi` on PATH is overwhelmingly
|
||||
// likely to be the pi coding-agent installation we're targeting.
|
||||
// Checking `pi-acp` first lets a dedicated wrapper take precedence
|
||||
// when both are present.
|
||||
result, err := Detect(ctx, WithCommand("pi-acp"), WithCommand("pi"))
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
result.SupportsMCP = true
|
||||
result.Capabilities = DiscoveryCapabilities{
|
||||
SupportsSessionResume: true,
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (a *PiACP) BuildCommand(opts CommandOptions) Command {
|
||||
return Cmd("npx", "-y", piACPPkg).Build()
|
||||
}
|
||||
|
||||
func (a *PiACP) Runtime() *RuntimeConfig {
|
||||
canRecover := true
|
||||
return &RuntimeConfig{
|
||||
Cmd: Cmd("npx", "-y", piACPPkg).Build(),
|
||||
WorkingDir: "{workspace}",
|
||||
Env: map[string]string{},
|
||||
ResourceLimits: ResourceLimits{MemoryMB: 4096, CPUCores: 2.0, Timeout: time.Hour},
|
||||
Protocol: agent.ProtocolACP,
|
||||
SessionConfig: SessionConfig{
|
||||
// TODO: set SessionDirTemplate once the Pi session dir is
|
||||
// confirmed. Without it, the Docker runtime skips mounting the
|
||||
// session dir, so NativeSessionResume has no persistence across
|
||||
// container restarts.
|
||||
NativeSessionResume: true,
|
||||
CanRecover: &canRecover,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (a *PiACP) RemoteAuth() *RemoteAuth { return nil }
|
||||
|
||||
func (a *PiACP) InstallScript() string {
|
||||
return "npm install -g " + piACPPkg
|
||||
}
|
||||
|
||||
func (a *PiACP) PermissionSettings() map[string]PermissionSetting {
|
||||
return emptyPermSettings
|
||||
}
|
||||
|
||||
func (a *PiACP) InferenceConfig() *InferenceConfig {
|
||||
return &InferenceConfig{
|
||||
Supported: true,
|
||||
Command: NewCommand("npx", "-y", piACPPkg),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
//nolint:dupl // Native-binary ACP agents (Cursor, Kimi, Kiro, Qoder, Trae) follow the same minimal scaffold; differences are the binary name and subcommand.
|
||||
package agents
|
||||
|
||||
import (
|
||||
"context"
|
||||
_ "embed"
|
||||
"time"
|
||||
|
||||
"github.com/kandev/kandev/pkg/agent"
|
||||
)
|
||||
|
||||
//go:embed logos/qoder_acp_light.svg
|
||||
var qoderACPLogoLight []byte
|
||||
|
||||
//go:embed logos/qoder_acp_dark.svg
|
||||
var qoderACPLogoDark []byte
|
||||
|
||||
const qoderACPBin = "qodercli"
|
||||
|
||||
var (
|
||||
_ Agent = (*QoderACP)(nil)
|
||||
_ PassthroughAgent = (*QoderACP)(nil)
|
||||
_ InferenceAgent = (*QoderACP)(nil)
|
||||
)
|
||||
|
||||
// QoderACP implements Agent for the Qoder CLI using ACP.
|
||||
type QoderACP struct {
|
||||
StandardPassthrough
|
||||
}
|
||||
|
||||
func NewQoderACP() *QoderACP {
|
||||
return &QoderACP{
|
||||
StandardPassthrough: StandardPassthrough{
|
||||
PermSettings: emptyPermSettings,
|
||||
Cfg: PassthroughConfig{
|
||||
Supported: true,
|
||||
Label: "CLI Passthrough",
|
||||
Description: "Show terminal directly instead of chat interface",
|
||||
PassthroughCmd: NewCommand(qoderACPBin),
|
||||
ModelFlag: NewParam("--model", "{model}"),
|
||||
IdleTimeout: 3 * time.Second,
|
||||
BufferMaxBytes: DefaultBufferMaxBytes,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (a *QoderACP) ID() string { return "qoder-acp" }
|
||||
func (a *QoderACP) Name() string { return "Qoder ACP Agent" }
|
||||
func (a *QoderACP) DisplayName() string { return "Qoder" }
|
||||
func (a *QoderACP) Description() string {
|
||||
return "Qoder coding agent using the ACP protocol via qodercli --acp."
|
||||
}
|
||||
func (a *QoderACP) Enabled() bool { return true }
|
||||
func (a *QoderACP) DisplayOrder() int { return 16 }
|
||||
|
||||
func (a *QoderACP) Logo(v LogoVariant) []byte {
|
||||
if v == LogoDark {
|
||||
return qoderACPLogoDark
|
||||
}
|
||||
return qoderACPLogoLight
|
||||
}
|
||||
|
||||
func (a *QoderACP) IsInstalled(ctx context.Context) (*DiscoveryResult, error) {
|
||||
result, err := Detect(ctx, WithCommand(qoderACPBin))
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
result.SupportsMCP = true
|
||||
result.Capabilities = DiscoveryCapabilities{
|
||||
SupportsSessionResume: true,
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (a *QoderACP) BuildCommand(opts CommandOptions) Command {
|
||||
return Cmd(qoderACPBin, "--acp").Build()
|
||||
}
|
||||
|
||||
func (a *QoderACP) Runtime() *RuntimeConfig {
|
||||
canRecover := true
|
||||
return &RuntimeConfig{
|
||||
Cmd: Cmd(qoderACPBin, "--acp").Build(),
|
||||
WorkingDir: "{workspace}",
|
||||
Env: map[string]string{},
|
||||
ResourceLimits: ResourceLimits{MemoryMB: 4096, CPUCores: 2.0, Timeout: time.Hour},
|
||||
Protocol: agent.ProtocolACP,
|
||||
SessionConfig: SessionConfig{
|
||||
// TODO: set SessionDirTemplate once the Qoder session dir is
|
||||
// confirmed. Without it, the Docker runtime skips mounting the
|
||||
// session dir, so NativeSessionResume has no persistence across
|
||||
// container restarts.
|
||||
NativeSessionResume: true,
|
||||
CanRecover: &canRecover,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (a *QoderACP) RemoteAuth() *RemoteAuth { return nil }
|
||||
|
||||
func (a *QoderACP) InstallScript() string {
|
||||
return "Install Qoder CLI from https://qoder.com"
|
||||
}
|
||||
|
||||
func (a *QoderACP) PermissionSettings() map[string]PermissionSetting {
|
||||
return emptyPermSettings
|
||||
}
|
||||
|
||||
func (a *QoderACP) InferenceConfig() *InferenceConfig {
|
||||
return &InferenceConfig{
|
||||
Supported: true,
|
||||
Command: NewCommand(qoderACPBin, "--acp"),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package agents
|
||||
|
||||
import (
|
||||
"context"
|
||||
_ "embed"
|
||||
"time"
|
||||
|
||||
"github.com/kandev/kandev/pkg/agent"
|
||||
)
|
||||
|
||||
//go:embed logos/qwen_acp_light.svg
|
||||
var qwenACPLogoLight []byte
|
||||
|
||||
//go:embed logos/qwen_acp_dark.svg
|
||||
var qwenACPLogoDark []byte
|
||||
|
||||
const qwenACPPkg = "@qwen-code/qwen-code"
|
||||
|
||||
var (
|
||||
_ Agent = (*QwenACP)(nil)
|
||||
_ PassthroughAgent = (*QwenACP)(nil)
|
||||
_ InferenceAgent = (*QwenACP)(nil)
|
||||
)
|
||||
|
||||
// QwenACP implements Agent for Alibaba's Qwen Code CLI using the ACP protocol.
|
||||
type QwenACP struct {
|
||||
StandardPassthrough
|
||||
}
|
||||
|
||||
func NewQwenACP() *QwenACP {
|
||||
return &QwenACP{
|
||||
StandardPassthrough: StandardPassthrough{
|
||||
PermSettings: emptyPermSettings,
|
||||
Cfg: PassthroughConfig{
|
||||
Supported: true,
|
||||
Label: "CLI Passthrough",
|
||||
Description: "Show terminal directly instead of chat interface",
|
||||
PassthroughCmd: NewCommand("npx", "-y", qwenACPPkg),
|
||||
ModelFlag: NewParam("--model", "{model}"),
|
||||
IdleTimeout: 3 * time.Second,
|
||||
BufferMaxBytes: DefaultBufferMaxBytes,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (a *QwenACP) ID() string { return "qwen-acp" }
|
||||
func (a *QwenACP) Name() string { return "Qwen Code ACP Agent" }
|
||||
func (a *QwenACP) DisplayName() string { return "Qwen" }
|
||||
func (a *QwenACP) Description() string {
|
||||
return "Alibaba Qwen Code coding agent using the ACP protocol over stdin/stdout."
|
||||
}
|
||||
func (a *QwenACP) Enabled() bool { return true }
|
||||
func (a *QwenACP) DisplayOrder() int { return 8 }
|
||||
|
||||
func (a *QwenACP) Logo(v LogoVariant) []byte {
|
||||
if v == LogoDark {
|
||||
return qwenACPLogoDark
|
||||
}
|
||||
return qwenACPLogoLight
|
||||
}
|
||||
|
||||
func (a *QwenACP) IsInstalled(ctx context.Context) (*DiscoveryResult, error) {
|
||||
result, err := Detect(ctx, WithCommand("qwen"))
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
result.SupportsMCP = true
|
||||
result.Capabilities = DiscoveryCapabilities{
|
||||
SupportsSessionResume: true,
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (a *QwenACP) BuildCommand(opts CommandOptions) Command {
|
||||
return Cmd("npx", "-y", qwenACPPkg, "--acp").Build()
|
||||
}
|
||||
|
||||
func (a *QwenACP) Runtime() *RuntimeConfig {
|
||||
canRecover := true
|
||||
return &RuntimeConfig{
|
||||
Cmd: Cmd("npx", "-y", qwenACPPkg, "--acp").Build(),
|
||||
WorkingDir: "{workspace}",
|
||||
Env: map[string]string{},
|
||||
ResourceLimits: ResourceLimits{MemoryMB: 4096, CPUCores: 2.0, Timeout: time.Hour},
|
||||
Protocol: agent.ProtocolACP,
|
||||
SessionConfig: SessionConfig{
|
||||
NativeSessionResume: true,
|
||||
CanRecover: &canRecover,
|
||||
SessionDirTemplate: "{home}/.qwen",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (a *QwenACP) RemoteAuth() *RemoteAuth { return nil }
|
||||
|
||||
func (a *QwenACP) InstallScript() string {
|
||||
return "npm install -g " + qwenACPPkg
|
||||
}
|
||||
|
||||
func (a *QwenACP) PermissionSettings() map[string]PermissionSetting {
|
||||
return emptyPermSettings
|
||||
}
|
||||
|
||||
func (a *QwenACP) InferenceConfig() *InferenceConfig {
|
||||
return &InferenceConfig{
|
||||
Supported: true,
|
||||
Command: NewCommand("npx", "-y", qwenACPPkg, "--acp"),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package agents
|
||||
|
||||
import (
|
||||
"context"
|
||||
_ "embed"
|
||||
"time"
|
||||
|
||||
"github.com/kandev/kandev/pkg/agent"
|
||||
)
|
||||
|
||||
//go:embed logos/trae_acp_light.svg
|
||||
var traeACPLogoLight []byte
|
||||
|
||||
//go:embed logos/trae_acp_dark.svg
|
||||
var traeACPLogoDark []byte
|
||||
|
||||
const traeACPBin = "traecli"
|
||||
|
||||
var (
|
||||
_ Agent = (*TraeACP)(nil)
|
||||
_ PassthroughAgent = (*TraeACP)(nil)
|
||||
_ InferenceAgent = (*TraeACP)(nil)
|
||||
)
|
||||
|
||||
// TraeACP implements Agent for ByteDance's Trae IDE CLI using ACP.
|
||||
type TraeACP struct {
|
||||
StandardPassthrough
|
||||
}
|
||||
|
||||
func NewTraeACP() *TraeACP {
|
||||
return &TraeACP{
|
||||
StandardPassthrough: StandardPassthrough{
|
||||
PermSettings: emptyPermSettings,
|
||||
Cfg: PassthroughConfig{
|
||||
Supported: true,
|
||||
Label: "CLI Passthrough",
|
||||
Description: "Show terminal directly instead of chat interface",
|
||||
PassthroughCmd: NewCommand(traeACPBin),
|
||||
ModelFlag: NewParam("--model", "{model}"),
|
||||
IdleTimeout: 3 * time.Second,
|
||||
BufferMaxBytes: DefaultBufferMaxBytes,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (a *TraeACP) ID() string { return "trae-acp" }
|
||||
func (a *TraeACP) Name() string { return "Trae ACP Agent" }
|
||||
func (a *TraeACP) DisplayName() string { return "Trae" }
|
||||
func (a *TraeACP) Description() string {
|
||||
return "ByteDance Trae IDE coding agent using the ACP protocol via traecli acp serve."
|
||||
}
|
||||
func (a *TraeACP) Enabled() bool { return true }
|
||||
func (a *TraeACP) DisplayOrder() int { return 17 }
|
||||
|
||||
func (a *TraeACP) Logo(v LogoVariant) []byte {
|
||||
if v == LogoDark {
|
||||
return traeACPLogoDark
|
||||
}
|
||||
return traeACPLogoLight
|
||||
}
|
||||
|
||||
func (a *TraeACP) IsInstalled(ctx context.Context) (*DiscoveryResult, error) {
|
||||
result, err := Detect(ctx, WithCommand(traeACPBin))
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
result.SupportsMCP = true
|
||||
result.Capabilities = DiscoveryCapabilities{
|
||||
SupportsSessionResume: true,
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (a *TraeACP) BuildCommand(opts CommandOptions) Command {
|
||||
return Cmd(traeACPBin, "acp", "serve").Build()
|
||||
}
|
||||
|
||||
func (a *TraeACP) Runtime() *RuntimeConfig {
|
||||
canRecover := true
|
||||
return &RuntimeConfig{
|
||||
Cmd: Cmd(traeACPBin, "acp", "serve").Build(),
|
||||
WorkingDir: "{workspace}",
|
||||
Env: map[string]string{},
|
||||
ResourceLimits: ResourceLimits{MemoryMB: 4096, CPUCores: 2.0, Timeout: time.Hour},
|
||||
Protocol: agent.ProtocolACP,
|
||||
SessionConfig: SessionConfig{
|
||||
// TODO: set SessionDirTemplate once the Trae session dir is
|
||||
// confirmed. Without it, the Docker runtime skips mounting the
|
||||
// session dir, so NativeSessionResume has no persistence across
|
||||
// container restarts.
|
||||
NativeSessionResume: true,
|
||||
CanRecover: &canRecover,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (a *TraeACP) RemoteAuth() *RemoteAuth { return nil }
|
||||
|
||||
func (a *TraeACP) InstallScript() string {
|
||||
return "Install Trae IDE CLI from https://trae.ai"
|
||||
}
|
||||
|
||||
func (a *TraeACP) PermissionSettings() map[string]PermissionSetting {
|
||||
return emptyPermSettings
|
||||
}
|
||||
|
||||
func (a *TraeACP) InferenceConfig() *InferenceConfig {
|
||||
return &InferenceConfig{
|
||||
Supported: true,
|
||||
Command: NewCommand(traeACPBin, "acp", "serve"),
|
||||
}
|
||||
}
|
||||
@@ -361,9 +361,20 @@ func (m *Manager) getInstance(ctx context.Context, agentType string) (*instance,
|
||||
return v.(*instance), ia, nil
|
||||
}
|
||||
|
||||
// probeTimeout caps each ACP probe so an agent that hangs (e.g. one that
|
||||
// blocks waiting for interactive auth) doesn't keep its UI badge stuck on
|
||||
// "Probing". 60s is generous enough for cold npm fetches and ACP handshake,
|
||||
// short enough that the user sees a definitive Error/AuthRequired status
|
||||
// quickly. Without this bound the only ceiling is the 5-minute HTTP client
|
||||
// timeout on the agentctl call.
|
||||
const probeTimeout = 60 * time.Second
|
||||
|
||||
// probe runs an ACP probe against the given instance and translates the result
|
||||
// into an AgentCapabilities record suitable for the cache.
|
||||
func (m *Manager) probe(ctx context.Context, inst *instance, ia agents.InferenceAgent) AgentCapabilities {
|
||||
probeCtx, cancel := context.WithTimeout(ctx, probeTimeout)
|
||||
defer cancel()
|
||||
|
||||
cfg := ia.InferenceConfig()
|
||||
req := &agentctlutil.ProbeRequest{
|
||||
AgentID: inst.agentType,
|
||||
@@ -373,7 +384,7 @@ func (m *Manager) probe(ctx context.Context, inst *instance, ia agents.Inference
|
||||
WorkDir: inst.workDir,
|
||||
},
|
||||
}
|
||||
resp, err := inst.client.Probe(ctx, req)
|
||||
resp, err := inst.client.Probe(probeCtx, req)
|
||||
now := time.Now()
|
||||
if err != nil {
|
||||
return AgentCapabilities{
|
||||
|
||||
@@ -51,6 +51,16 @@ func (r *Registry) LoadDefaults() {
|
||||
agents.NewGemini(),
|
||||
agents.NewOpenCodeACP(),
|
||||
agents.NewAmpACP(),
|
||||
agents.NewQwenACP(),
|
||||
agents.NewIFlowACP(),
|
||||
agents.NewDroidACP(),
|
||||
agents.NewKilocodeACP(),
|
||||
agents.NewPiACP(),
|
||||
agents.NewCursorACP(),
|
||||
agents.NewKimiACP(),
|
||||
agents.NewKiroACP(),
|
||||
agents.NewQoderACP(),
|
||||
agents.NewTraeACP(),
|
||||
agents.NewMockAgent(),
|
||||
}
|
||||
|
||||
|
||||
@@ -199,6 +199,29 @@ func (c *Client) SetModel(ctx context.Context, modelID string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetConfigOption sets a session config option via the agent WebSocket stream.
|
||||
func (c *Client) SetConfigOption(ctx context.Context, configID, value string) error {
|
||||
payload := struct {
|
||||
ConfigID string `json:"config_id"`
|
||||
Value string `json:"value"`
|
||||
}{ConfigID: configID, Value: value}
|
||||
|
||||
resp, err := c.sendStreamRequest(ctx, "agent.session.set_config_option", payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("set config option request failed: %w", err)
|
||||
}
|
||||
|
||||
if resp.Type == ws.MessageTypeError {
|
||||
var errPayload ws.ErrorPayload
|
||||
if err := resp.ParsePayload(&errPayload); err != nil {
|
||||
return fmt.Errorf("set config option failed: unable to parse error")
|
||||
}
|
||||
return fmt.Errorf("set config option failed: %s", errPayload.Message)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Authenticate triggers authentication for a given auth method via the agent WebSocket stream.
|
||||
func (c *Client) Authenticate(ctx context.Context, methodID string) error {
|
||||
payload := struct {
|
||||
|
||||
@@ -106,6 +106,14 @@ type AuthenticatableAdapter interface {
|
||||
Authenticate(ctx context.Context, methodID string) error
|
||||
}
|
||||
|
||||
// ConfigOptionSettableAdapter is an optional interface implemented by adapters
|
||||
// that support setting an arbitrary session config option (ACP
|
||||
// session/set_config_option). Useful for agent-specific runtime knobs that
|
||||
// aren't covered by mode/model.
|
||||
type ConfigOptionSettableAdapter interface {
|
||||
SetConfigOption(ctx context.Context, key, value string) error
|
||||
}
|
||||
|
||||
// SessionResettableAdapter is an optional interface implemented by adapters that
|
||||
// can reset context by creating a new session on the same connection, without
|
||||
// restarting the agent subprocess. Only ACP adapters support this.
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
@@ -133,6 +134,12 @@ type Adapter struct {
|
||||
// Used by SetModel to validate the requested model exists.
|
||||
availableModels []acp.UnstableModelInfo
|
||||
|
||||
// Available auth methods captured from the ACP initialize response.
|
||||
// Re-emitted via EventTypeAuthRequired when session/new fails with the
|
||||
// AuthenticationRequired error code so the frontend can surface a picker
|
||||
// without re-running initialize.
|
||||
availableAuthMethods []streams.AuthMethodInfo
|
||||
|
||||
// Available modes from the most recent session creation/load.
|
||||
// Used by SetMode to include cached modes in the event so the
|
||||
// frontend mode selector can render available options.
|
||||
@@ -255,13 +262,19 @@ func (a *Adapter) Initialize(ctx context.Context) error {
|
||||
zap.String("agent_version", a.agentInfo.Version),
|
||||
zap.Bool("supports_load_session", a.capabilities.LoadSession))
|
||||
|
||||
// Cache auth methods so we can re-emit them on auth_required without re-running initialize.
|
||||
authMethods := convertAuthMethods(resp.AuthMethods)
|
||||
a.mu.Lock()
|
||||
a.availableAuthMethods = authMethods
|
||||
a.mu.Unlock()
|
||||
|
||||
// Emit agent capabilities event with prompt capabilities and auth methods
|
||||
a.sendUpdate(AgentEvent{
|
||||
Type: streams.EventTypeAgentCapabilities,
|
||||
SupportsImage: a.capabilities.PromptCapabilities.Image,
|
||||
SupportsAudio: a.capabilities.PromptCapabilities.Audio,
|
||||
SupportsEmbeddedContext: a.capabilities.PromptCapabilities.EmbeddedContext,
|
||||
AuthMethods: convertAuthMethods(resp.AuthMethods),
|
||||
AuthMethods: authMethods,
|
||||
})
|
||||
|
||||
return nil
|
||||
@@ -305,6 +318,9 @@ func (a *Adapter) NewSession(ctx context.Context, mcpServers []types.McpServer)
|
||||
})
|
||||
if err != nil {
|
||||
span.RecordError(err)
|
||||
if a.maybeEmitAuthRequired(err) {
|
||||
return "", fmt.Errorf("authentication required: %w", err)
|
||||
}
|
||||
return "", fmt.Errorf("failed to create session: %w", err)
|
||||
}
|
||||
|
||||
@@ -1406,6 +1422,68 @@ func (a *Adapter) SetModel(ctx context.Context, modelID string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// maybeEmitAuthRequired inspects an ACP error and, if it represents an
|
||||
// AuthenticationRequired (-32000) failure, emits an EventTypeAuthRequired
|
||||
// carrying the cached auth methods so the frontend can drive the
|
||||
// authenticate → session/new retry. Returns true when the event was emitted.
|
||||
//
|
||||
// The emitted event has no SessionID by design: the failure occurred while
|
||||
// session/new was attempting to create a session, so no session ID exists
|
||||
// yet. Consumers that correlate events by session must treat
|
||||
// EventTypeAuthRequired as a connection-scoped (not session-scoped) signal.
|
||||
//
|
||||
// Returns false when no auth methods are cached. Without methods to choose
|
||||
// from, the frontend can't drive the picker — letting the error fall through
|
||||
// to the generic "failed to create session" path is more actionable than a
|
||||
// pseudo-auth-required signal with no options.
|
||||
func (a *Adapter) maybeEmitAuthRequired(err error) bool {
|
||||
var reqErr *acp.RequestError
|
||||
if !errors.As(err, &reqErr) || reqErr.Code != -32000 {
|
||||
return false
|
||||
}
|
||||
|
||||
a.mu.RLock()
|
||||
methods := a.availableAuthMethods
|
||||
a.mu.RUnlock()
|
||||
|
||||
if len(methods) == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
a.sendUpdate(AgentEvent{
|
||||
Type: streams.EventTypeAuthRequired,
|
||||
AuthMethods: methods,
|
||||
Error: reqErr.Message,
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
// SetConfigOption sets a session configuration option via ACP session/set_config_option.
|
||||
// configID is the option's ID; value is the option-value ID to apply.
|
||||
func (a *Adapter) SetConfigOption(ctx context.Context, configID, value string) error {
|
||||
a.mu.RLock()
|
||||
conn := a.acpConn
|
||||
sessionID := a.sessionID
|
||||
a.mu.RUnlock()
|
||||
|
||||
if conn == nil {
|
||||
return fmt.Errorf("adapter not initialized")
|
||||
}
|
||||
if sessionID == "" {
|
||||
return fmt.Errorf("no active session: call NewSession before SetConfigOption")
|
||||
}
|
||||
|
||||
_, err := conn.SetSessionConfigOption(ctx, acp.SetSessionConfigOptionRequest{
|
||||
SessionId: acp.SessionId(sessionID),
|
||||
ConfigId: acp.SessionConfigId(configID),
|
||||
Value: acp.SessionConfigValueId(value),
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("set session config option failed: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Authenticate triggers ACP session/authenticate for a given auth method.
|
||||
func (a *Adapter) Authenticate(ctx context.Context, methodID string) error {
|
||||
a.mu.RLock()
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
package acp
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
acp "github.com/coder/acp-go-sdk"
|
||||
"github.com/kandev/kandev/internal/agentctl/types/streams"
|
||||
)
|
||||
|
||||
// TestMaybeEmitAuthRequired_AuthenticationRequiredErrorEmitsEvent verifies
|
||||
// that a session/new failure with code -32000 (AuthenticationRequired) emits
|
||||
// an EventTypeAuthRequired event carrying the cached auth methods so the
|
||||
// frontend can drive the authenticate → session/new retry without re-running
|
||||
// initialize.
|
||||
func TestMaybeEmitAuthRequired_AuthenticationRequiredErrorEmitsEvent(t *testing.T) {
|
||||
a := newTestAdapter()
|
||||
cached := []streams.AuthMethodInfo{
|
||||
{ID: "oauth", Name: "OAuth"},
|
||||
{ID: "api-key", Name: "API key"},
|
||||
}
|
||||
a.mu.Lock()
|
||||
a.availableAuthMethods = cached
|
||||
a.mu.Unlock()
|
||||
|
||||
authErr := acp.NewAuthRequired(nil)
|
||||
emitted := a.maybeEmitAuthRequired(authErr)
|
||||
if !emitted {
|
||||
t.Fatalf("maybeEmitAuthRequired returned false for AuthenticationRequired error")
|
||||
}
|
||||
|
||||
events := drainEvents(a)
|
||||
if len(events) != 1 {
|
||||
t.Fatalf("expected 1 event, got %d", len(events))
|
||||
}
|
||||
ev := events[0]
|
||||
if ev.Type != streams.EventTypeAuthRequired {
|
||||
t.Errorf("event Type = %q, want %q", ev.Type, streams.EventTypeAuthRequired)
|
||||
}
|
||||
if len(ev.AuthMethods) != len(cached) {
|
||||
t.Fatalf("event AuthMethods len = %d, want %d", len(ev.AuthMethods), len(cached))
|
||||
}
|
||||
for i, m := range cached {
|
||||
if ev.AuthMethods[i].ID != m.ID {
|
||||
t.Errorf("event AuthMethods[%d].ID = %q, want %q", i, ev.AuthMethods[i].ID, m.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestMaybeEmitAuthRequired_NonAuthErrorIsIgnored guards against the helper
|
||||
// firing on unrelated session/new failures (network errors, MCP misconfig,
|
||||
// etc.). A real auth_required event must be unambiguous.
|
||||
func TestMaybeEmitAuthRequired_NonAuthErrorIsIgnored(t *testing.T) {
|
||||
a := newTestAdapter()
|
||||
|
||||
otherErr := &acp.RequestError{Code: -32603, Message: "internal error"}
|
||||
if a.maybeEmitAuthRequired(otherErr) {
|
||||
t.Fatalf("maybeEmitAuthRequired returned true for non-auth error code %d", otherErr.Code)
|
||||
}
|
||||
if events := drainEvents(a); len(events) != 0 {
|
||||
t.Errorf("expected 0 events, got %d", len(events))
|
||||
}
|
||||
}
|
||||
|
||||
// TestMaybeEmitAuthRequired_PlainErrorIsIgnored covers the case where the
|
||||
// failure isn't even a JSON-RPC RequestError (e.g. transport-level error
|
||||
// surfaced as a wrapped Go error).
|
||||
func TestMaybeEmitAuthRequired_PlainErrorIsIgnored(t *testing.T) {
|
||||
a := newTestAdapter()
|
||||
|
||||
if a.maybeEmitAuthRequired(errors.New("connection reset")) {
|
||||
t.Fatalf("maybeEmitAuthRequired returned true for plain Go error")
|
||||
}
|
||||
}
|
||||
|
||||
// TestMaybeEmitAuthRequired_EmptyMethodsFallsThrough pins the contract that
|
||||
// an AuthenticationRequired error with no cached methods does NOT emit an
|
||||
// EventTypeAuthRequired event. Without methods to pick, the frontend can't
|
||||
// drive the picker — letting the error fall through to the generic "failed
|
||||
// to create session" path is more actionable than a pseudo-auth-required
|
||||
// signal with no options.
|
||||
func TestMaybeEmitAuthRequired_EmptyMethodsFallsThrough(t *testing.T) {
|
||||
a := newTestAdapter()
|
||||
// Leave a.availableAuthMethods empty (Initialize never populated it,
|
||||
// or the agent didn't advertise any methods).
|
||||
|
||||
authErr := acp.NewAuthRequired(nil)
|
||||
if a.maybeEmitAuthRequired(authErr) {
|
||||
t.Fatalf("maybeEmitAuthRequired returned true with no cached methods; should fall through")
|
||||
}
|
||||
if events := drainEvents(a); len(events) != 0 {
|
||||
t.Errorf("expected 0 events, got %d", len(events))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package acp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestSetConfigOption_WithoutConnectionReturnsError pins the precondition
|
||||
// that SetConfigOption must surface an error rather than panic when invoked
|
||||
// before Initialize() has wired up the ACP connection. The same precondition
|
||||
// is enforced by SetMode/SetModel; this test keeps the new method aligned.
|
||||
func TestSetConfigOption_WithoutConnectionReturnsError(t *testing.T) {
|
||||
a := newTestAdapter()
|
||||
|
||||
err := a.SetConfigOption(context.Background(), "model", "claude-3-7-sonnet")
|
||||
if err == nil {
|
||||
t.Fatalf("expected error when adapter not initialized")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "not initialized") {
|
||||
t.Errorf("error = %q, want one containing %q", err.Error(), "not initialized")
|
||||
}
|
||||
}
|
||||
@@ -262,6 +262,8 @@ func (s *Server) handleAgentStreamRequest(ctx context.Context, msg *ws.Message)
|
||||
return s.handleWSSetMode(ctx, msg)
|
||||
case "agent.session.set_model":
|
||||
return s.handleWSSetModel(ctx, msg)
|
||||
case "agent.session.set_config_option":
|
||||
return s.handleWSSetConfigOption(ctx, msg)
|
||||
case "agent.session.authenticate":
|
||||
return s.handleWSAuthenticate(ctx, msg)
|
||||
case "agent.session.reset":
|
||||
@@ -577,6 +579,20 @@ func (s *Server) handleWSSetModel(ctx context.Context, msg *ws.Message) *ws.Mess
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleWSSetConfigOption(ctx context.Context, msg *ws.Message) *ws.Message {
|
||||
var req struct {
|
||||
ConfigID string `json:"config_id"`
|
||||
Value string `json:"value"`
|
||||
}
|
||||
return s.adapterAction(ctx, msg, &req, func(a adapter.AgentAdapter) error {
|
||||
cs, ok := a.(adapter.ConfigOptionSettableAdapter)
|
||||
if !ok {
|
||||
return fmt.Errorf("agent does not support set_config_option")
|
||||
}
|
||||
return cs.SetConfigOption(ctx, req.ConfigID, req.Value)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleWSAuthenticate(ctx context.Context, msg *ws.Message) *ws.Message {
|
||||
var req struct {
|
||||
MethodID string `json:"method_id"`
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
ws "github.com/kandev/kandev/pkg/websocket"
|
||||
)
|
||||
|
||||
// TestHandleWSSetConfigOption_NoAdapter verifies the WS handler surfaces a
|
||||
// clean "agent not running" error when no adapter is connected — same as the
|
||||
// other adapter-action handlers (set_mode, set_model, authenticate).
|
||||
func TestHandleWSSetConfigOption_NoAdapter(t *testing.T) {
|
||||
s := newTestServer(t)
|
||||
ctx := context.Background()
|
||||
|
||||
msg, _ := ws.NewRequest("req-1", "agent.session.set_config_option", map[string]string{
|
||||
"config_id": "model",
|
||||
"value": "claude-3-7-sonnet",
|
||||
})
|
||||
resp := s.handleWSSetConfigOption(ctx, msg)
|
||||
|
||||
if resp.Type != ws.MessageTypeError {
|
||||
t.Errorf("expected error type, got %q", resp.Type)
|
||||
}
|
||||
var errPayload ws.ErrorPayload
|
||||
if err := resp.ParsePayload(&errPayload); err != nil {
|
||||
t.Fatalf("failed to parse error: %v", err)
|
||||
}
|
||||
if !strings.Contains(errPayload.Message, "agent not running") {
|
||||
t.Errorf("expected 'agent not running', got %q", errPayload.Message)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleWSSetConfigOption_BadPayload verifies the handler rejects
|
||||
// malformed payloads with BAD_REQUEST (the adapterAction helper parses the
|
||||
// payload before checking the adapter, so a malformed payload errors first).
|
||||
func TestHandleWSSetConfigOption_BadPayload(t *testing.T) {
|
||||
s := newTestServer(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// Use a non-JSON-decodable payload to trip the parse path.
|
||||
msg, _ := ws.NewRequest("req-1", "agent.session.set_config_option", "not-an-object")
|
||||
resp := s.handleWSSetConfigOption(ctx, msg)
|
||||
|
||||
if resp.Type != ws.MessageTypeError {
|
||||
t.Errorf("expected error type, got %q", resp.Type)
|
||||
}
|
||||
var errPayload ws.ErrorPayload
|
||||
if err := resp.ParsePayload(&errPayload); err != nil {
|
||||
t.Fatalf("failed to parse error: %v", err)
|
||||
}
|
||||
if errPayload.Code != ws.ErrorCodeBadRequest {
|
||||
t.Errorf("expected BAD_REQUEST code, got %q", errPayload.Code)
|
||||
}
|
||||
}
|
||||
@@ -54,6 +54,12 @@ const (
|
||||
|
||||
// EventTypeSessionModels indicates available models from ACP session/new.
|
||||
EventTypeSessionModels = "session_models"
|
||||
|
||||
// EventTypeAuthRequired indicates the agent rejected session/new with an
|
||||
// authentication-required error. The event carries the available auth
|
||||
// methods (from ACP initialize); the client picks one and replays the
|
||||
// authenticate → session/new round-trip.
|
||||
EventTypeAuthRequired = "auth_required"
|
||||
)
|
||||
|
||||
// Session status constants for EventTypeSessionStatus events.
|
||||
|
||||
@@ -152,18 +152,57 @@ function useOnboardingResources(open: boolean) {
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
Promise.all([listAvailableAgents({ cache: "no-store" }), listAgentsAction()])
|
||||
.then(([availRes, savedRes]) => {
|
||||
setAvailableAgents(availRes.agents ?? []);
|
||||
setTools(availRes.tools ?? []);
|
||||
setAgentSettings(buildAgentSettings(availRes.agents ?? [], savedRes.agents ?? []));
|
||||
let cancelled = false;
|
||||
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
// Poll while any agent is still in the "probing" state — the host-utility
|
||||
// probes async at boot and the dialog can open before they all resolve.
|
||||
// Without re-polling, agents that flip status mid-session stay stuck on
|
||||
// the initial badge in the UI. Re-poll on transient fetch errors too:
|
||||
// a single 500 shouldn't strand the dialog on stale probing status.
|
||||
let lastSawProbing = true;
|
||||
const pollOnce = (firstRun: boolean) => {
|
||||
Promise.all([
|
||||
listAvailableAgents({ cache: "no-store" }),
|
||||
firstRun ? listAgentsAction() : Promise.resolve(null),
|
||||
])
|
||||
.then(([availRes, savedRes]) => {
|
||||
if (cancelled) return;
|
||||
const agents = availRes.agents ?? [];
|
||||
setAvailableAgents(agents);
|
||||
setTools(availRes.tools ?? []);
|
||||
if (savedRes) {
|
||||
setAgentSettings(buildAgentSettings(agents, savedRes.agents ?? []));
|
||||
}
|
||||
lastSawProbing = agents.some((a) => a.model_config.status === "probing");
|
||||
})
|
||||
.catch(() => {
|
||||
// Keep polling on transient errors — backend may be momentarily
|
||||
// unreachable while still resolving probes.
|
||||
})
|
||||
.finally(() => {
|
||||
if (cancelled) return;
|
||||
if (firstRun) setLoadingAgents(false);
|
||||
if (lastSawProbing) {
|
||||
timeoutId = setTimeout(() => pollOnce(false), 2000);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
pollOnce(true);
|
||||
listWorkflowTemplates()
|
||||
.then((res) => {
|
||||
if (!cancelled) setTemplates(res.templates ?? []);
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setLoadingAgents(false));
|
||||
listWorkflowTemplates()
|
||||
.then((res) => setTemplates(res.templates ?? []))
|
||||
.catch(() => {})
|
||||
.finally(() => setLoadingTemplates(false));
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoadingTemplates(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
return {
|
||||
@@ -265,7 +304,7 @@ export function OnboardingDialog({ open, onComplete }: OnboardingDialogProps) {
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={() => {}}>
|
||||
<DialogContent className="sm:max-w-[540px]" showCloseButton={false}>
|
||||
<DialogContent className="sm:max-w-3xl" showCloseButton={false}>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-center text-2xl">{STEP_TITLES[step]}</DialogTitle>
|
||||
<DialogDescription className="text-center">{STEP_DESCRIPTIONS[step]}</DialogDescription>
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
IconLock,
|
||||
} from "@tabler/icons-react";
|
||||
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from "@kandev/ui/collapsible";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
|
||||
import { AgentLogo } from "@/components/agent-logo";
|
||||
import { ProfileFormFields, type ProfileFormData } from "@/components/settings/profile-form-fields";
|
||||
import type { AvailableAgent, ToolStatus } from "@/lib/types/http";
|
||||
@@ -56,26 +57,42 @@ function CopyButton({ text }: { text: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
function StatusPill({ status }: { status: string }) {
|
||||
function StatusPill({ status, error }: { status: string; error?: string }) {
|
||||
switch (status) {
|
||||
case "auth_required":
|
||||
return (
|
||||
case "auth_required": {
|
||||
const pill = (
|
||||
<span className="flex items-center gap-1 text-xs text-amber-600 dark:text-amber-400">
|
||||
<IconLock className="h-3.5 w-3.5" />
|
||||
No auth
|
||||
</span>
|
||||
);
|
||||
if (!error) return pill;
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{pill}</TooltipTrigger>
|
||||
<TooltipContent className="max-w-sm">{error}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
case "not_installed":
|
||||
return (
|
||||
<span className="flex items-center gap-1 text-xs text-muted-foreground">Not installed</span>
|
||||
);
|
||||
case "failed":
|
||||
return (
|
||||
case "failed": {
|
||||
const pill = (
|
||||
<span className="flex items-center gap-1 text-xs text-red-600 dark:text-red-400">
|
||||
<IconAlertTriangle className="h-3.5 w-3.5" />
|
||||
Error
|
||||
</span>
|
||||
);
|
||||
if (!error) return pill;
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{pill}</TooltipTrigger>
|
||||
<TooltipContent className="max-w-sm">{error}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
case "probing":
|
||||
return (
|
||||
<span className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
@@ -130,7 +147,7 @@ function InstalledAgentRow({
|
||||
{modelName}
|
||||
</span>
|
||||
)}
|
||||
<StatusPill status={status} />
|
||||
<StatusPill status={status} error={agent.model_config.error} />
|
||||
<IconChevronDown className="h-4 w-4 text-muted-foreground transition-transform group-data-[state=open]:rotate-180" />
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
|
||||