mirror of
https://github.com/cline/cline.git
synced 2026-09-01 23:19:18 +08:00
Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 56dd36c89e | |||
| 9f7657fef6 | |||
| 01a97bd7a3 | |||
| 24230abe33 | |||
| 218c651ba3 | |||
| 45b3426d00 | |||
| e0736a25e4 | |||
| d00ee8cff8 | |||
| 2530a81e8e | |||
| be640a712d | |||
| 034f11dce9 | |||
| 2443578b18 | |||
| f8f56349de | |||
| 5570bf4d59 | |||
| d444b2774c | |||
| b5aedef45b | |||
| 8ea76b9c21 |
@@ -34,6 +34,3 @@ webview-ui/src/services/grpc-client.ts
|
||||
|
||||
# E2E Tests
|
||||
test-results
|
||||
|
||||
## CLI pre-release ##
|
||||
/cli
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
cline-core-debug.log
|
||||
bin/*
|
||||
@@ -0,0 +1,6 @@
|
||||
/_____/\ /_/\ /_______/\/__/\ /__/\ /_____/\
|
||||
\:::__\/ \:\ \ \__.::._\/\::\_\\ \ \\::::_\/_
|
||||
\:\ \ __\:\ \ \::\ \ \:. `-\ \ \\:\/___/\
|
||||
\:\ \/_/\\:\ \____ _\::\ \__\:. _ \ \\::___\/_
|
||||
\:\_\ \ \\:\/___/\/__\::\__/\\. \`-\ \ \\:\____/\
|
||||
\_____\/ \_____\/\________\/ \__\/ \__\/ \_____\/
|
||||
@@ -0,0 +1,71 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/cline/cli/pkg/hostbridge"
|
||||
)
|
||||
|
||||
var (
|
||||
port int
|
||||
verbose bool
|
||||
)
|
||||
|
||||
func main() {
|
||||
rootCmd := &cobra.Command{
|
||||
Use: "cline-host",
|
||||
Short: "Cline Host Bridge Service",
|
||||
Long: `A simple host bridge service that provides host operations for Cline Core.`,
|
||||
RunE: runServer,
|
||||
}
|
||||
|
||||
rootCmd.Flags().IntVarP(&port, "port", "p", 51052, "port to listen on")
|
||||
rootCmd.Flags().BoolVarP(&verbose, "verbose", "v", false, "verbose logging")
|
||||
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func runServer(cmd *cobra.Command, args []string) error {
|
||||
ctx := cmd.Context()
|
||||
|
||||
// Create gRPC hostbridge service
|
||||
service := hostbridge.NewGrpcServer(port, verbose)
|
||||
|
||||
// Handle graceful shutdown
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
|
||||
go func() {
|
||||
sigChan := make(chan os.Signal, 1)
|
||||
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
|
||||
<-sigChan
|
||||
|
||||
if verbose {
|
||||
log.Println("Shutting down hostbridge server...")
|
||||
}
|
||||
|
||||
cancel()
|
||||
}()
|
||||
|
||||
// Start server
|
||||
if verbose {
|
||||
log.Printf("Starting Cline Host Bridge on port %d", port)
|
||||
}
|
||||
|
||||
// Run the service
|
||||
if err := service.Start(ctx); err != nil {
|
||||
return fmt.Errorf("failed to run service: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/cline/cli/pkg/cli"
|
||||
"github.com/cline/cli/pkg/cli/global"
|
||||
"github.com/cline/cli/pkg/common"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var (
|
||||
coreAddress string
|
||||
cfgFile string
|
||||
verbose bool
|
||||
outputFormat string
|
||||
)
|
||||
|
||||
func main() {
|
||||
rootCmd := &cobra.Command{
|
||||
Use: "cline",
|
||||
Short: "Cline CLI - AI-powered coding assistant",
|
||||
Long: `A command-line interface for interacting with Cline AI coding assistant.
|
||||
|
||||
This CLI provides access to Cline's task management, configuration, and
|
||||
monitoring capabilities from the terminal.`,
|
||||
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
|
||||
if outputFormat != "rich" && outputFormat != "json" && outputFormat != "plain" {
|
||||
return fmt.Errorf("invalid output format '%s': must be one of 'rich', 'json', or 'plain'", outputFormat)
|
||||
}
|
||||
|
||||
return global.InitializeGlobalConfig(&global.GlobalConfig{
|
||||
ConfigPath: cfgFile,
|
||||
Verbose: verbose,
|
||||
OutputFormat: outputFormat,
|
||||
CoreAddress: coreAddress,
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
rootCmd.PersistentFlags().StringVar(&coreAddress, "address", fmt.Sprintf("localhost:%d", common.DEFAULT_CLINE_CORE_PORT), "Cline Core gRPC address")
|
||||
rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/.cline/config.yaml)")
|
||||
rootCmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "verbose output")
|
||||
rootCmd.PersistentFlags().StringVarP(&outputFormat, "output-format", "o", "rich", "output format (rich|json|plain)")
|
||||
|
||||
rootCmd.AddCommand(cli.NewTaskCommand())
|
||||
rootCmd.AddCommand(cli.NewInstanceCommand())
|
||||
rootCmd.AddCommand(cli.NewVersionCommand())
|
||||
rootCmd.AddCommand(cli.NewAuthCommand())
|
||||
|
||||
if err := rootCmd.ExecuteContext(context.Background()); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
package e2e
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
"testing"
|
||||
|
||||
"github.com/cline/cli/pkg/common"
|
||||
)
|
||||
|
||||
// 2. Multi-instance start: default_instance remains the first started.
|
||||
func TestMultiInstanceDefaultUnchanged(t *testing.T) {
|
||||
_ = setTempClineDir(t)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), longTimeout)
|
||||
defer cancel()
|
||||
|
||||
// Start first instance and wait healthy
|
||||
_ = mustRunCLI(ctx, t, "instance", "new")
|
||||
out1 := listInstancesJSON(ctx, t)
|
||||
if len(out1.CoreInstances) != 1 {
|
||||
t.Fatalf("expected 1 instance, got %d", len(out1.CoreInstances))
|
||||
}
|
||||
firstAddr := out1.CoreInstances[0].Address
|
||||
waitForAddressHealthy(t, firstAddr, defaultTimeout)
|
||||
|
||||
// Start second instance
|
||||
_ = mustRunCLI(ctx, t, "instance", "new")
|
||||
out2 := listInstancesJSON(ctx, t)
|
||||
if len(out2.CoreInstances) < 2 {
|
||||
t.Fatalf("expected at least 2 instances, got %d", len(out2.CoreInstances))
|
||||
}
|
||||
|
||||
// Default should remain the first started address
|
||||
if out2.DefaultInstance != firstAddr {
|
||||
t.Fatalf("default changed; expected %s, got %s", firstAddr, out2.DefaultInstance)
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Default.json update after removal of current default
|
||||
func TestDefaultJsonUpdateAfterRemoval(t *testing.T) {
|
||||
_ = setTempClineDir(t)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), longTimeout)
|
||||
defer cancel()
|
||||
|
||||
// Start two instances
|
||||
_ = mustRunCLI(ctx, t, "instance", "new")
|
||||
_ = mustRunCLI(ctx, t, "instance", "new")
|
||||
|
||||
out := listInstancesJSON(ctx, t)
|
||||
if len(out.CoreInstances) < 2 {
|
||||
t.Fatalf("expected at least 2 instances, got %d", len(out.CoreInstances))
|
||||
}
|
||||
|
||||
// Choose second as new default
|
||||
target := out.CoreInstances[1]
|
||||
waitForAddressHealthy(t, target.Address, defaultTimeout)
|
||||
|
||||
// Set as default
|
||||
_ = mustRunCLI(ctx, t, "instance", "use", target.Address)
|
||||
|
||||
// Verify default switched
|
||||
out = listInstancesJSON(ctx, t)
|
||||
if out.DefaultInstance != target.Address {
|
||||
t.Fatalf("default_instance not updated to %s (got %s)", target.Address, out.DefaultInstance)
|
||||
}
|
||||
|
||||
// Kill the default instance using runtime PID discovery
|
||||
corePID := getCorePID(t, target.Address)
|
||||
if corePID <= 0 {
|
||||
t.Fatalf("could not find PID for core process at %s", target.Address)
|
||||
}
|
||||
t.Logf("Killing cline-core process PID %d for instance %s", corePID, target.Address)
|
||||
if err := syscall.Kill(corePID, syscall.SIGKILL); err != nil {
|
||||
t.Fatalf("kill pid %d: %v", corePID, err)
|
||||
}
|
||||
|
||||
// Wait for removal
|
||||
waitForAddressRemoved(t, target.Address, longTimeout)
|
||||
|
||||
// Clean up dangling host process (SIGKILL leaves these behind by design)
|
||||
t.Logf("Cleaning up dangling host process on port %d", target.HostPort())
|
||||
findAndKillHostProcess(t, target.HostPort())
|
||||
|
||||
// Ensure default_instance updated to another available instance (or removed if none remain)
|
||||
out = listInstancesJSON(ctx, t)
|
||||
|
||||
// If there are instances left, default_instance must be one of them
|
||||
if len(out.CoreInstances) > 0 {
|
||||
found := false
|
||||
for _, it := range out.CoreInstances {
|
||||
if out.DefaultInstance == it.Address {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("default_instance %s not set to an existing instance after removal", out.DefaultInstance)
|
||||
}
|
||||
} else {
|
||||
// No instances remain; cli-default-instance.json should be removed
|
||||
clineDir := getClineDir(t)
|
||||
defPath := filepath.Join(clineDir, common.SETTINGS_SUBFOLDER, "settings", "cli-default-instance.json")
|
||||
if _, err := os.Stat(defPath); err == nil {
|
||||
t.Fatalf("expected cli-default-instance.json removed when no instances remain")
|
||||
}
|
||||
}
|
||||
|
||||
// Also verify cli-default-instance.json on disk reflects the in-memory default (if any)
|
||||
clineDir := getClineDir(t)
|
||||
defPath := filepath.Join(clineDir, common.SETTINGS_SUBFOLDER, "settings", "cli-default-instance.json")
|
||||
if len(out.CoreInstances) > 0 {
|
||||
raw, err := os.ReadFile(defPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read cli-default-instance.json: %v", err)
|
||||
}
|
||||
var tmp struct {
|
||||
DefaultInstance string `json:"default_instance"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &tmp); err != nil {
|
||||
t.Fatalf("unmarshal cli-default-instance.json: %v", err)
|
||||
}
|
||||
if tmp.DefaultInstance != out.DefaultInstance {
|
||||
t.Fatalf("cli-default-instance.json mismatch: file=%s list=%s", tmp.DefaultInstance, out.DefaultInstance)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 11. SQLite database missing (edge): list succeeds and returns empty set
|
||||
func TestRegistryDirMissingEdge(t *testing.T) {
|
||||
clineDir := setTempClineDir(t)
|
||||
|
||||
// Remove the settings directory entirely (which contains locks.db)
|
||||
settingsDir := filepath.Join(clineDir, common.SETTINGS_SUBFOLDER)
|
||||
if err := os.RemoveAll(settingsDir); err != nil {
|
||||
t.Fatalf("RemoveAll(%s): %v", common.SETTINGS_SUBFOLDER, err)
|
||||
}
|
||||
|
||||
// Listing should succeed and return empty results
|
||||
ctx, cancel := context.WithTimeout(context.Background(), defaultTimeout)
|
||||
defer cancel()
|
||||
out := listInstancesJSON(ctx, t)
|
||||
if len(out.CoreInstances) != 0 {
|
||||
t.Fatalf("expected 0 instances after removing %s dir, got %d", common.SETTINGS_SUBFOLDER, len(out.CoreInstances))
|
||||
}
|
||||
|
||||
// Ensure cli-default-instance.json not present
|
||||
defPath := filepath.Join(clineDir, common.SETTINGS_SUBFOLDER, "settings", "cli-default-instance.json")
|
||||
if _, err := os.Stat(defPath); err == nil {
|
||||
t.Fatalf("expected no cli-default-instance.json after removing %s dir", common.SETTINGS_SUBFOLDER)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,378 @@
|
||||
package e2e
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/cline/cli/pkg/cli/global"
|
||||
"github.com/cline/cli/pkg/common"
|
||||
"github.com/cline/grpc-go/cline"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultTimeout = 30 * time.Second
|
||||
longTimeout = 60 * time.Second
|
||||
pollInterval = 250 * time.Millisecond
|
||||
instancesBinRel = "../bin/cline"
|
||||
)
|
||||
|
||||
func repoAwareBinPath(t *testing.T) string {
|
||||
// Tests live in repoRoot/cli/e2e. Binary is at repoRoot/cli/bin/cline
|
||||
t.Helper()
|
||||
wd, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatalf("Getwd error: %v", err)
|
||||
}
|
||||
// cli/e2e -> cli/bin/cline
|
||||
p := filepath.Clean(filepath.Join(wd, instancesBinRel))
|
||||
if _, err := os.Stat(p); err != nil {
|
||||
t.Fatalf("CLI binary not found at %s; run `npm run compile-cli` first: %v", p, err)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func setTempClineDir(t *testing.T) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
clineDir := filepath.Join(dir, ".cline")
|
||||
if err := os.MkdirAll(clineDir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir clineDir: %v", err)
|
||||
}
|
||||
t.Setenv("CLINE_DIR", clineDir)
|
||||
return clineDir
|
||||
}
|
||||
|
||||
func runCLI(ctx context.Context, t *testing.T, args ...string) (string, string, int) {
|
||||
t.Helper()
|
||||
bin := repoAwareBinPath(t)
|
||||
|
||||
// Ensure CLI uses the same CLINE_DIR as the tests by passing --config=<CLINE_DIR>
|
||||
// (InitializeGlobalConfig uses ConfigPath as the base directory for registry.)
|
||||
if clineDir := os.Getenv("CLINE_DIR"); clineDir != "" && !contains(args, "--config") {
|
||||
// Prepend persistent flag so Cobra sees it regardless of subcommand position
|
||||
args = append([]string{"--config", clineDir}, args...)
|
||||
}
|
||||
|
||||
cmd := exec.CommandContext(ctx, bin, args...)
|
||||
// Run CLI from repo root so relative paths inside CLI (./cli/bin/...) resolve
|
||||
if wd, err := os.Getwd(); err == nil {
|
||||
repoRoot := filepath.Clean(filepath.Join(wd, "..", ".."))
|
||||
cmd.Dir = repoRoot
|
||||
}
|
||||
// propagate env including CLINE_DIR
|
||||
cmd.Env = os.Environ()
|
||||
outB, errB := &strings.Builder{}, &strings.Builder{}
|
||||
cmd.Stdout = outB
|
||||
cmd.Stderr = errB
|
||||
err := cmd.Run()
|
||||
exit := 0
|
||||
if err != nil {
|
||||
// Extract exit code if possible
|
||||
if ee, ok := err.(*exec.ExitError); ok {
|
||||
exit = ee.ExitCode()
|
||||
} else {
|
||||
exit = -1
|
||||
}
|
||||
}
|
||||
return outB.String(), errB.String(), exit
|
||||
}
|
||||
|
||||
func mustRunCLI(ctx context.Context, t *testing.T, args ...string) string {
|
||||
t.Helper()
|
||||
out, errOut, exit := runCLI(ctx, t, args...)
|
||||
if exit != 0 {
|
||||
t.Fatalf("cline %v failed (exit=%d)\nstdout:\n%s\nstderr:\n%s", args, exit, out, errOut)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func listInstancesJSON(ctx context.Context, t *testing.T) common.InstancesOutput {
|
||||
t.Helper()
|
||||
// Trigger CLI to perform cleanup/health by invoking list (table output is ignored)
|
||||
_ = mustRunCLI(ctx, t, "instance", "list")
|
||||
|
||||
// Read from SQLite locks database to build structured output
|
||||
clineDir := getClineDir(t)
|
||||
|
||||
// Load default instance from settings file
|
||||
defaultInstance := readDefaultInstanceFromSettings(t, clineDir)
|
||||
|
||||
// Load instances from SQLite
|
||||
instances := readInstancesFromSQLite(t, clineDir)
|
||||
|
||||
return common.InstancesOutput{
|
||||
DefaultInstance: defaultInstance,
|
||||
CoreInstances: instances,
|
||||
}
|
||||
}
|
||||
|
||||
func hasAddress(in common.InstancesOutput, addr string) bool {
|
||||
for _, it := range in.CoreInstances {
|
||||
if it.Address == addr {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func getByAddress(in common.InstancesOutput, addr string) (common.CoreInstanceInfo, bool) {
|
||||
for _, it := range in.CoreInstances {
|
||||
if it.Address == addr {
|
||||
return it, true
|
||||
}
|
||||
}
|
||||
return common.CoreInstanceInfo{}, false
|
||||
}
|
||||
|
||||
func waitFor(t *testing.T, timeout time.Duration, cond func() (bool, string)) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(timeout)
|
||||
for {
|
||||
ok, msg := cond()
|
||||
if ok {
|
||||
return
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("waitFor timeout: %s", msg)
|
||||
}
|
||||
time.Sleep(pollInterval)
|
||||
}
|
||||
}
|
||||
|
||||
func waitForAddressHealthy(t *testing.T, addr string, timeout time.Duration) {
|
||||
t.Helper()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
|
||||
t.Logf("Waiting for gRPC health check on %s...", addr)
|
||||
|
||||
waitFor(t, timeout, func() (bool, string) {
|
||||
if common.IsInstanceHealthy(ctx, addr) {
|
||||
return true, ""
|
||||
}
|
||||
return false, fmt.Sprintf("gRPC health check failed for %s", addr)
|
||||
})
|
||||
|
||||
t.Logf("gRPC health check passed for %s", addr)
|
||||
}
|
||||
|
||||
func waitForAddressRemoved(t *testing.T, addr string, timeout time.Duration) {
|
||||
t.Helper()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
waitFor(t, timeout, func() (bool, string) {
|
||||
out := listInstancesJSON(ctx, t)
|
||||
if hasAddress(out, addr) {
|
||||
return false, fmt.Sprintf("address %s still present", addr)
|
||||
}
|
||||
return true, ""
|
||||
})
|
||||
}
|
||||
|
||||
func findFreePort(t *testing.T) int {
|
||||
t.Helper()
|
||||
l, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("listen 127.0.0.1:0: %v", err)
|
||||
}
|
||||
defer l.Close()
|
||||
_, portStr, _ := net.SplitHostPort(l.Addr().String())
|
||||
var port int
|
||||
fmt.Sscanf(portStr, "%d", &port)
|
||||
return port
|
||||
}
|
||||
|
||||
func getClineDir(t *testing.T) string {
|
||||
t.Helper()
|
||||
clineDir := os.Getenv("CLINE_DIR")
|
||||
if clineDir == "" {
|
||||
t.Fatalf("CLINE_DIR not set")
|
||||
}
|
||||
return clineDir
|
||||
}
|
||||
|
||||
// isPortInUse checks if a port is currently in use by any process
|
||||
func isPortInUse(port int) bool {
|
||||
conn, err := net.Listen("tcp", fmt.Sprintf(":%d", port))
|
||||
if err != nil {
|
||||
return true // Port is in use
|
||||
}
|
||||
conn.Close()
|
||||
return false // Port is free
|
||||
}
|
||||
|
||||
// waitForPortClosed waits for a port to become free (no process listening)
|
||||
func waitForPortClosed(t *testing.T, port int, timeout time.Duration) {
|
||||
t.Helper()
|
||||
waitFor(t, timeout, func() (bool, string) {
|
||||
if isPortInUse(port) {
|
||||
return false, fmt.Sprintf("port %d still in use", port)
|
||||
}
|
||||
return true, ""
|
||||
})
|
||||
}
|
||||
|
||||
// waitForPortsClosed waits for both core and host ports to become free
|
||||
func waitForPortsClosed(t *testing.T, corePort, hostPort int, timeout time.Duration) {
|
||||
t.Helper()
|
||||
waitFor(t, timeout, func() (bool, string) {
|
||||
if isPortInUse(corePort) {
|
||||
return false, fmt.Sprintf("core port %d still in use", corePort)
|
||||
}
|
||||
if isPortInUse(hostPort) {
|
||||
return false, fmt.Sprintf("host port %d still in use", hostPort)
|
||||
}
|
||||
return true, ""
|
||||
})
|
||||
}
|
||||
|
||||
// findAndKillHostProcess finds and kills any process listening on the host port
|
||||
// This is used to clean up dangling host processes after SIGKILL tests
|
||||
func findAndKillHostProcess(t *testing.T, hostPort int) {
|
||||
t.Helper()
|
||||
// Use lsof to find process listening on the host port
|
||||
cmd := exec.Command("lsof", "-ti", fmt.Sprintf(":%d", hostPort))
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
// No process found on port - that's fine
|
||||
return
|
||||
}
|
||||
|
||||
pidStr := strings.TrimSpace(string(output))
|
||||
if pidStr == "" {
|
||||
return
|
||||
}
|
||||
|
||||
var pid int
|
||||
if _, err := fmt.Sscanf(pidStr, "%d", &pid); err != nil {
|
||||
t.Logf("Warning: could not parse PID from lsof output: %s", pidStr)
|
||||
return
|
||||
}
|
||||
|
||||
if pid > 0 {
|
||||
t.Logf("Cleaning up dangling host process PID %d on port %d", pid, hostPort)
|
||||
if err := syscall.Kill(pid, syscall.SIGKILL); err != nil {
|
||||
t.Logf("Warning: failed to kill dangling host process %d: %v", pid, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// getPIDByPort returns the PID of the process listening on the specified port (fallback method)
|
||||
func getPIDByPort(t *testing.T, port int) int {
|
||||
t.Helper()
|
||||
cmd := exec.Command("lsof", "-ti", fmt.Sprintf(":%d", port))
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return 0 // Process not found
|
||||
}
|
||||
|
||||
pidStr := strings.TrimSpace(string(output))
|
||||
if pidStr == "" {
|
||||
return 0
|
||||
}
|
||||
|
||||
pid, err := strconv.Atoi(pidStr)
|
||||
if err != nil {
|
||||
t.Logf("Warning: could not parse PID from lsof output: %s", pidStr)
|
||||
return 0
|
||||
}
|
||||
|
||||
return pid
|
||||
}
|
||||
|
||||
// getCorePIDViaRPC returns the PID of the cline-core process using RPC (preferred method)
|
||||
func getCorePIDViaRPC(t *testing.T, address string) int {
|
||||
t.Helper()
|
||||
|
||||
// Initialize global config to access registry
|
||||
clineDir := os.Getenv("CLINE_DIR")
|
||||
if clineDir == "" {
|
||||
t.Logf("Warning: CLINE_DIR not set, falling back to lsof")
|
||||
return getCorePIDViaLsof(t, address)
|
||||
}
|
||||
|
||||
cfg := &global.GlobalConfig{
|
||||
ConfigPath: clineDir,
|
||||
}
|
||||
|
||||
if err := global.InitializeGlobalConfig(cfg); err != nil {
|
||||
t.Logf("Warning: failed to initialize global config, falling back to lsof: %v", err)
|
||||
return getCorePIDViaLsof(t, address)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Get client for the address
|
||||
client, err := global.Clients.GetRegistry().GetClient(ctx, address)
|
||||
if err != nil {
|
||||
t.Logf("Warning: failed to get client for %s, falling back to lsof: %v", address, err)
|
||||
return getCorePIDViaLsof(t, address)
|
||||
}
|
||||
|
||||
// Call GetProcessInfo RPC
|
||||
processInfo, err := client.State.GetProcessInfo(ctx, &cline.EmptyRequest{})
|
||||
if err != nil {
|
||||
t.Logf("Warning: GetProcessInfo RPC failed for %s, falling back to lsof: %v", address, err)
|
||||
return getCorePIDViaLsof(t, address)
|
||||
}
|
||||
|
||||
return int(processInfo.ProcessId)
|
||||
}
|
||||
|
||||
// getCorePIDViaLsof returns the PID using lsof (fallback method)
|
||||
func getCorePIDViaLsof(t *testing.T, address string) int {
|
||||
t.Helper()
|
||||
_, portStr, err := net.SplitHostPort(address)
|
||||
if err != nil {
|
||||
t.Logf("Warning: invalid address format %s", address)
|
||||
return 0
|
||||
}
|
||||
|
||||
port, err := strconv.Atoi(portStr)
|
||||
if err != nil {
|
||||
t.Logf("Warning: invalid port in address %s", address)
|
||||
return 0
|
||||
}
|
||||
|
||||
return getPIDByPort(t, port)
|
||||
}
|
||||
|
||||
// getCorePID returns the PID of the cline-core process for the given address
|
||||
// Uses RPC first, falls back to lsof if RPC fails
|
||||
func getCorePID(t *testing.T, address string) int {
|
||||
t.Helper()
|
||||
|
||||
// Try RPC first (preferred method)
|
||||
if pid := getCorePIDViaRPC(t, address); pid > 0 {
|
||||
return pid
|
||||
}
|
||||
|
||||
// Fall back to lsof if RPC fails
|
||||
return getCorePIDViaLsof(t, address)
|
||||
}
|
||||
|
||||
// getHostPID returns the PID of the cline-host process for the given host port
|
||||
func getHostPID(t *testing.T, hostPort int) int {
|
||||
t.Helper()
|
||||
return getPIDByPort(t, hostPort)
|
||||
}
|
||||
|
||||
// contains reports whether slice has the target string.
|
||||
func contains(slice []string, target string) bool {
|
||||
for _, s := range slice {
|
||||
if s == target {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package e2e
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestMain validates required artifacts exist before running E2E tests.
|
||||
// It does NOT build artifacts. Build manually via:
|
||||
//
|
||||
// npm run compile-standalone
|
||||
// npm run compile-cli
|
||||
func TestMain(m *testing.M) {
|
||||
// Determine repo root from cli/e2e
|
||||
wd, err := os.Getwd()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "getwd: %v\n", err)
|
||||
os.Exit(2)
|
||||
}
|
||||
repoRoot := filepath.Clean(filepath.Join(wd, "..", ".."))
|
||||
|
||||
cliBin := filepath.Join(repoRoot, "cli", "bin", "cline")
|
||||
coreJS := filepath.Join(repoRoot, "dist-standalone", "cline-core.js")
|
||||
|
||||
missing := []string{}
|
||||
if _, err := os.Stat(cliBin); err != nil {
|
||||
missing = append(missing, cliBin)
|
||||
}
|
||||
if _, err := os.Stat(coreJS); err != nil {
|
||||
missing = append(missing, coreJS)
|
||||
}
|
||||
|
||||
if len(missing) > 0 {
|
||||
if testing.Short() {
|
||||
// Optional quality-of-life: allow skipping with -short when artifacts are absent
|
||||
fmt.Fprintf(os.Stderr, "[e2e] skipping (-short) due to missing artifacts:\n %s\n", strings.Join(missing, "\n "))
|
||||
os.Exit(0)
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "Missing required build artifacts for E2E tests:\n %s\n\nPlease build them first:\n npm run compile-standalone\n npm run compile-cli\n", strings.Join(missing, "\n "))
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package e2e
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
"testing"
|
||||
|
||||
"github.com/cline/cli/pkg/common"
|
||||
)
|
||||
|
||||
// 9. Mixed localhost vs 127.0.0.1 addresses coexist and are both healthy
|
||||
func TestMixedLocalhostVs127Coexist(t *testing.T) {
|
||||
clineDir := setTempClineDir(t)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), longTimeout)
|
||||
defer cancel()
|
||||
|
||||
// Start one instance
|
||||
_ = mustRunCLI(ctx, t, "instance", "new")
|
||||
|
||||
// Get the running instance and its port/PID
|
||||
out := listInstancesJSON(ctx, t)
|
||||
if len(out.CoreInstances) == 0 {
|
||||
t.Fatalf("expected at least 1 instance")
|
||||
}
|
||||
inst := out.CoreInstances[0]
|
||||
waitForAddressHealthy(t, inst.Address, defaultTimeout)
|
||||
|
||||
// Manually add a SQLite entry for the same port but 127.0.0.1 host
|
||||
addr127 := fmt.Sprintf("127.0.0.1:%d", inst.CorePort())
|
||||
dbPath := filepath.Join(clineDir, common.SETTINGS_SUBFOLDER, "locks.db")
|
||||
|
||||
if err := insertRemoteInstanceIntoSQLite(t, dbPath, addr127, inst.CorePort(), inst.HostPort()); err != nil {
|
||||
t.Fatalf("insert 127 alias entry: %v", err)
|
||||
}
|
||||
|
||||
// Verify both addresses appear and are healthy
|
||||
waitForAddressHealthy(t, inst.Address, defaultTimeout)
|
||||
waitForAddressHealthy(t, addr127, defaultTimeout)
|
||||
|
||||
out = listInstancesJSON(ctx, t)
|
||||
if !hasAddress(out, inst.Address) || !hasAddress(out, addr127) {
|
||||
t.Fatalf("expected both %s and %s present", inst.Address, addr127)
|
||||
}
|
||||
}
|
||||
|
||||
// 10. Start-stop stress: loop starting then killing instances; ensure no leftovers
|
||||
func TestStartStopStress(t *testing.T) {
|
||||
_ = setTempClineDir(t)
|
||||
|
||||
for i := 0; i < 3; i++ { // keep small for CI time
|
||||
ctx, cancel := context.WithTimeout(context.Background(), longTimeout)
|
||||
defer cancel()
|
||||
|
||||
// Snapshot current addresses
|
||||
before := listInstancesJSON(ctx, t)
|
||||
beforeSet := map[string]struct{}{}
|
||||
for _, it := range before.CoreInstances {
|
||||
beforeSet[it.Address] = struct{}{}
|
||||
}
|
||||
|
||||
// Start a new instance
|
||||
_ = mustRunCLI(ctx, t, "instance", "new")
|
||||
|
||||
// Find the new instance address
|
||||
var newAddr string
|
||||
waitFor(t, defaultTimeout, func() (bool, string) {
|
||||
after := listInstancesJSON(ctx, t)
|
||||
for _, it := range after.CoreInstances {
|
||||
if _, ok := beforeSet[it.Address]; !ok {
|
||||
newAddr = it.Address
|
||||
return true, ""
|
||||
}
|
||||
}
|
||||
return false, "new instance address not detected yet"
|
||||
})
|
||||
|
||||
// Wait healthy
|
||||
waitForAddressHealthy(t, newAddr, defaultTimeout)
|
||||
|
||||
// Get PID using runtime discovery and kill it
|
||||
after := listInstancesJSON(ctx, t)
|
||||
info, ok := getByAddress(after, newAddr)
|
||||
if !ok {
|
||||
t.Fatalf("new instance %s missing", newAddr)
|
||||
}
|
||||
|
||||
// Get PID using runtime discovery
|
||||
corePID := getCorePID(t, info.Address)
|
||||
if corePID <= 0 {
|
||||
t.Fatalf("could not find PID for new instance at %s", info.Address)
|
||||
}
|
||||
|
||||
t.Logf("Killing new instance %s (PID %d) for iteration %d", info.Address, corePID, i)
|
||||
if err := syscall.Kill(corePID, syscall.SIGKILL); err != nil {
|
||||
t.Fatalf("kill pid %d: %v", corePID, err)
|
||||
}
|
||||
|
||||
// Wait removed from SQLite database
|
||||
waitForAddressRemoved(t, newAddr, longTimeout)
|
||||
|
||||
// Verify instance is removed from SQLite database
|
||||
clineDir := os.Getenv("CLINE_DIR")
|
||||
if clineDir != "" {
|
||||
dbPath := filepath.Join(clineDir, common.SETTINGS_SUBFOLDER, "locks.db")
|
||||
if verifyInstanceExistsInSQLite(t, dbPath, newAddr) {
|
||||
t.Fatalf("expected instance removed from SQLite database: %s", newAddr)
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up dangling host process (SIGKILL leaves these behind by design)
|
||||
t.Logf("Cleaning up dangling host process on port %d for iteration %d", info.HostPort(), i)
|
||||
findAndKillHostProcess(t, info.HostPort())
|
||||
|
||||
// Verify both ports are now free
|
||||
waitForPortsClosed(t, info.CorePort(), info.HostPort(), defaultTimeout)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
package e2e
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/cline/cli/pkg/common"
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
"google.golang.org/grpc/health/grpc_health_v1"
|
||||
)
|
||||
|
||||
// readInstancesFromSQLite reads instances directly from the SQLite database for testing
|
||||
func readInstancesFromSQLite(t *testing.T, clineDir string) []common.CoreInstanceInfo {
|
||||
t.Helper()
|
||||
|
||||
dbPath := filepath.Join(clineDir, common.SETTINGS_SUBFOLDER, "locks.db")
|
||||
|
||||
// Check if database exists
|
||||
if _, err := os.Stat(dbPath); os.IsNotExist(err) {
|
||||
return []common.CoreInstanceInfo{}
|
||||
}
|
||||
|
||||
db, err := sql.Open("sqlite3", dbPath)
|
||||
if err != nil {
|
||||
t.Logf("Warning: Failed to open SQLite database: %v", err)
|
||||
return []common.CoreInstanceInfo{}
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
// Query instance locks
|
||||
query := common.SelectInstanceLockHoldersAscSQL
|
||||
|
||||
rows, err := db.Query(query)
|
||||
if err != nil {
|
||||
t.Logf("Warning: Failed to query instance locks: %v", err)
|
||||
return []common.CoreInstanceInfo{}
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var instances []common.CoreInstanceInfo
|
||||
for rows.Next() {
|
||||
var heldBy, lockTarget string
|
||||
var lockedAt int64
|
||||
|
||||
err := rows.Scan(&heldBy, &lockTarget, &lockedAt)
|
||||
if err != nil {
|
||||
t.Logf("Warning: Failed to scan lock row: %v", err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Create InstanceInfo
|
||||
info := common.CoreInstanceInfo{
|
||||
Address: heldBy,
|
||||
HostServiceAddress: lockTarget,
|
||||
Status: grpc_health_v1.HealthCheckResponse_UNKNOWN, // Will be updated by health check
|
||||
LastSeen: time.Unix(lockedAt/1000, 0), // Convert from milliseconds
|
||||
}
|
||||
|
||||
instances = append(instances, info)
|
||||
}
|
||||
|
||||
return instances
|
||||
}
|
||||
|
||||
// readDefaultInstanceFromSettings reads the default instance from the settings file
|
||||
func readDefaultInstanceFromSettings(t *testing.T, clineDir string) string {
|
||||
t.Helper()
|
||||
|
||||
settingsPath := filepath.Join(clineDir, common.SETTINGS_SUBFOLDER, "settings", "cli-default-instance.json")
|
||||
|
||||
data, err := os.ReadFile(settingsPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return ""
|
||||
}
|
||||
t.Logf("Warning: Failed to read default instance file: %v", err)
|
||||
return ""
|
||||
}
|
||||
|
||||
var tmp struct {
|
||||
DefaultInstance string `json:"default_instance"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &tmp); err != nil {
|
||||
t.Logf("Warning: Failed to parse default instance file: %v", err)
|
||||
return ""
|
||||
}
|
||||
|
||||
return tmp.DefaultInstance
|
||||
}
|
||||
|
||||
// insertRemoteInstanceIntoSQLite inserts a remote instance entry directly into SQLite for testing
|
||||
func insertRemoteInstanceIntoSQLite(t *testing.T, dbPath, address string, corePort, hostPort int) error {
|
||||
t.Helper()
|
||||
|
||||
db, err := sql.Open("sqlite3", dbPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
// Initialize database schema for testing
|
||||
createTableSQL := `
|
||||
CREATE TABLE IF NOT EXISTS locks (
|
||||
id INTEGER PRIMARY KEY,
|
||||
held_by TEXT NOT NULL,
|
||||
lock_type TEXT NOT NULL CHECK (lock_type IN ('file', 'instance', 'folder')),
|
||||
lock_target TEXT NOT NULL,
|
||||
locked_at INTEGER NOT NULL,
|
||||
UNIQUE(lock_type, lock_target)
|
||||
);
|
||||
`
|
||||
createIndexesSQL := `
|
||||
CREATE INDEX IF NOT EXISTS idx_locks_held_by ON locks(held_by);
|
||||
CREATE INDEX IF NOT EXISTS idx_locks_type ON locks(lock_type);
|
||||
CREATE INDEX IF NOT EXISTS idx_locks_target ON locks(lock_target);
|
||||
`
|
||||
|
||||
if _, err := db.Exec(createTableSQL); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := db.Exec(createIndexesSQL); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Insert the remote instance
|
||||
hostAddress := "remote.example.com:0"
|
||||
if hostPort != 0 {
|
||||
hostAddress = "remote.example.com:" + strconv.Itoa(hostPort)
|
||||
}
|
||||
|
||||
insertSQL := `INSERT INTO locks (held_by, lock_type, lock_target, locked_at) VALUES (?, 'instance', ?, ?)`
|
||||
_, err = db.Exec(insertSQL, address, hostAddress, time.Now().Unix()*1000)
|
||||
return err
|
||||
}
|
||||
|
||||
// verifyInstanceExistsInSQLite checks if an instance exists in the SQLite database
|
||||
func verifyInstanceExistsInSQLite(t *testing.T, dbPath, address string) bool {
|
||||
t.Helper()
|
||||
|
||||
db, err := sql.Open("sqlite3", dbPath)
|
||||
if err != nil {
|
||||
t.Logf("Failed to open database: %v", err)
|
||||
return false
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
query := `SELECT COUNT(*) FROM locks WHERE held_by = ? AND lock_type = 'instance'`
|
||||
var count int
|
||||
err = db.QueryRow(query, address).Scan(&count)
|
||||
if err != nil {
|
||||
t.Logf("Failed to query database: %v", err)
|
||||
return false
|
||||
}
|
||||
|
||||
return count > 0
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
package e2e
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"syscall"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestStartAndList verifies self-registration and default.json semantics in a fresh CLINE_DIR.
|
||||
func TestStartAndList(t *testing.T) {
|
||||
clineDir := setTempClineDir(t)
|
||||
t.Logf("Using temp CLINE_DIR: %s", clineDir)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), longTimeout)
|
||||
defer cancel()
|
||||
|
||||
t.Logf("Starting new instance...")
|
||||
// Start a new instance
|
||||
startOutput := mustRunCLI(ctx, t, "instance", "new")
|
||||
t.Logf("Instance start output: %s", startOutput)
|
||||
|
||||
t.Logf("Listing instances to check registration...")
|
||||
// It should appear healthy in list JSON and be the default.
|
||||
out := listInstancesJSON(ctx, t)
|
||||
t.Logf("Found %d instances after start", len(out.CoreInstances))
|
||||
|
||||
if len(out.CoreInstances) != 1 {
|
||||
t.Fatalf("expected 1 instance, got %d", len(out.CoreInstances))
|
||||
}
|
||||
|
||||
addr := out.CoreInstances[0].Address
|
||||
t.Logf("Instance address: %s, status: %s", addr, out.CoreInstances[0].Status)
|
||||
|
||||
t.Logf("Waiting for address %s to become healthy...", addr)
|
||||
waitForAddressHealthy(t, addr, defaultTimeout)
|
||||
t.Logf("Address %s is now healthy", addr)
|
||||
|
||||
t.Logf("Checking default instance configuration...")
|
||||
// Default should be set to the new instance.
|
||||
out = listInstancesJSON(ctx, t)
|
||||
t.Logf("Default instance: %s", out.DefaultInstance)
|
||||
|
||||
if out.DefaultInstance == "" {
|
||||
t.Fatalf("default_instance not set")
|
||||
}
|
||||
if out.DefaultInstance != out.CoreInstances[0].Address {
|
||||
t.Fatalf("expected default_instance=%s, got %s", out.CoreInstances[0].Address, out.DefaultInstance)
|
||||
}
|
||||
|
||||
t.Logf("TestStartAndList completed successfully")
|
||||
}
|
||||
|
||||
// TestTaskNewDefault ensures tasks route to default instance.
|
||||
func TestTaskNewDefault(t *testing.T) {
|
||||
_ = setTempClineDir(t)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), longTimeout)
|
||||
defer cancel()
|
||||
|
||||
// Start one instance and wait for healthy
|
||||
_ = mustRunCLI(ctx, t, "instance", "new")
|
||||
out := listInstancesJSON(ctx, t)
|
||||
if len(out.CoreInstances) != 1 {
|
||||
t.Fatalf("expected 1 instance, got %d", len(out.CoreInstances))
|
||||
}
|
||||
addr := out.CoreInstances[0].Address
|
||||
waitForAddressHealthy(t, addr, defaultTimeout)
|
||||
|
||||
// Create a new task at default (success is sufficient)
|
||||
_ = mustRunCLI(ctx, t, "task", "new", "hello world")
|
||||
}
|
||||
|
||||
// TestExplicitAddressAutoStart verifies that giving an explicit address auto-starts an instance and routes the task.
|
||||
func TestExplicitAddressAutoStart(t *testing.T) {
|
||||
_ = setTempClineDir(t)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), longTimeout)
|
||||
defer cancel()
|
||||
|
||||
// Find a free port and use explicit address. This should auto-start an instance.
|
||||
port := findFreePort(t)
|
||||
addr := "localhost:" + itoa(port)
|
||||
|
||||
// Run a task at explicit address (auto-start path)
|
||||
_ = mustRunCLI(ctx, t, "task", "new", "--address", "localhost:"+itoa(port), "explicit address task")
|
||||
|
||||
// Verify the instance is present and healthy
|
||||
waitForAddressHealthy(t, addr, defaultTimeout)
|
||||
}
|
||||
|
||||
// TestCrashCleanup verifies that after SIGKILL of a local core, the cleanup removes the registry entry.
|
||||
// Also tests graceful shutdown (SIGTERM) vs crash cleanup and ensures no dangling host processes.
|
||||
func TestCrashCleanup(t *testing.T) {
|
||||
_ = setTempClineDir(t)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), longTimeout)
|
||||
defer cancel()
|
||||
|
||||
// Start two instances for testing both graceful and crash scenarios
|
||||
_ = mustRunCLI(ctx, t, "instance", "new")
|
||||
_ = mustRunCLI(ctx, t, "instance", "new")
|
||||
|
||||
out := listInstancesJSON(ctx, t)
|
||||
if len(out.CoreInstances) < 2 {
|
||||
t.Fatalf("expected at least 2 instances, got %d", len(out.CoreInstances))
|
||||
}
|
||||
|
||||
// Test 1: Graceful shutdown (SIGTERM) - should clean up both processes
|
||||
gracefulTarget := out.CoreInstances[0]
|
||||
waitForAddressHealthy(t, gracefulTarget.Address, defaultTimeout)
|
||||
|
||||
// Get PID using runtime discovery
|
||||
gracefulPID := getCorePID(t, gracefulTarget.Address)
|
||||
if gracefulPID <= 0 {
|
||||
t.Fatalf("could not find PID for graceful target at %s", gracefulTarget.Address)
|
||||
}
|
||||
|
||||
t.Logf("Testing graceful shutdown (SIGTERM) for instance %s (PID %d)", gracefulTarget.Address, gracefulPID)
|
||||
if err := syscall.Kill(gracefulPID, syscall.SIGTERM); err != nil {
|
||||
t.Fatalf("kill SIGTERM pid %d: %v", gracefulPID, err)
|
||||
}
|
||||
|
||||
// Wait for registry cleanup
|
||||
waitForAddressRemoved(t, gracefulTarget.Address, longTimeout)
|
||||
|
||||
// Verify both core and host ports are freed (no dangling processes)
|
||||
waitForPortsClosed(t, gracefulTarget.CorePort(), gracefulTarget.HostPort(), defaultTimeout)
|
||||
|
||||
// Verify the instance is removed from SQLite (no file to check anymore)
|
||||
// The waitForAddressRemoved already confirms the instance is gone from the registry
|
||||
|
||||
// Test 2: Crash cleanup (SIGKILL) - creates dangling host process that we must clean up
|
||||
crashTarget := out.CoreInstances[1]
|
||||
waitForAddressHealthy(t, crashTarget.Address, defaultTimeout)
|
||||
|
||||
// Get PID using runtime discovery
|
||||
crashPID := getCorePID(t, crashTarget.Address)
|
||||
if crashPID <= 0 {
|
||||
t.Fatalf("could not find PID for crash target at %s", crashTarget.Address)
|
||||
}
|
||||
|
||||
t.Logf("Testing crash cleanup (SIGKILL) for instance %s (PID %d)", crashTarget.Address, crashPID)
|
||||
if err := syscall.Kill(crashPID, syscall.SIGKILL); err != nil {
|
||||
t.Fatalf("kill SIGKILL pid %d: %v", crashPID, err)
|
||||
}
|
||||
|
||||
// Wait for registry cleanup
|
||||
waitForAddressRemoved(t, crashTarget.Address, longTimeout)
|
||||
|
||||
// Verify the instance is removed from SQLite (no file to check anymore)
|
||||
// The waitForAddressRemoved already confirms the instance is gone from the registry
|
||||
|
||||
// Clean up dangling host process (SIGKILL leaves these behind by design)
|
||||
t.Logf("Cleaning up dangling host process %s", crashTarget.HostServiceAddress)
|
||||
findAndKillHostProcess(t, crashTarget.HostPort())
|
||||
|
||||
// Verify both ports are now free
|
||||
waitForPortsClosed(t, crashTarget.CorePort(), crashTarget.HostPort(), defaultTimeout)
|
||||
}
|
||||
|
||||
// itoa is a small helper for readability
|
||||
func itoa(i int) string {
|
||||
return strconvItoa(i)
|
||||
}
|
||||
|
||||
// minimal inline int->string to avoid extra imports in helpers
|
||||
func strconvItoa(i int) string {
|
||||
// simple fast path
|
||||
return fmtInt(i)
|
||||
}
|
||||
|
||||
func fmtInt(i int) string {
|
||||
// allocate small buffer; ints here are short
|
||||
return (func(n int) string {
|
||||
return fmt.Sprintf("%d", n)
|
||||
})(i)
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
module github.com/cline/cli
|
||||
|
||||
go 1.23.0
|
||||
|
||||
require (
|
||||
github.com/cline/grpc-go v0.0.0
|
||||
github.com/mattn/go-sqlite3 v1.14.24
|
||||
github.com/spf13/cobra v1.8.0
|
||||
google.golang.org/grpc v1.75.0
|
||||
)
|
||||
|
||||
replace github.com/cline/grpc-go => ../src/generated/grpc-go
|
||||
|
||||
require (
|
||||
github.com/atotto/clipboard v0.1.4 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/spf13/pflag v1.0.5 // indirect
|
||||
golang.org/x/net v0.41.0 // indirect
|
||||
golang.org/x/sys v0.33.0 // indirect
|
||||
golang.org/x/text v0.26.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250707201910-8d1bb00bc6a7 // indirect
|
||||
google.golang.org/protobuf v1.36.6 // indirect
|
||||
)
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
|
||||
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM=
|
||||
github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0=
|
||||
github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho=
|
||||
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
|
||||
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
|
||||
go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ=
|
||||
go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I=
|
||||
go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE=
|
||||
go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E=
|
||||
go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI=
|
||||
go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.37.0 h1:90lI228XrB9jCMuSdA0673aubgRobVZFhbjxHHspCPc=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.37.0/go.mod h1:cNen4ZWfiD37l5NhS+Keb5RXVWZWpRE+9WyVCpbo5ps=
|
||||
go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
|
||||
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
|
||||
golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw=
|
||||
golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA=
|
||||
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
|
||||
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M=
|
||||
golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA=
|
||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250707201910-8d1bb00bc6a7 h1:pFyd6EwwL2TqFf8emdthzeX+gZE1ElRq3iM8pui4KBY=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250707201910-8d1bb00bc6a7/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
|
||||
google.golang.org/grpc v1.75.0 h1:+TW+dqTd2Biwe6KKfhE5JpiYIBWq865PhKGSXiivqt4=
|
||||
google.golang.org/grpc v1.75.0/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ=
|
||||
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
|
||||
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
@@ -0,0 +1,125 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/cline/cli/pkg/cli/global"
|
||||
"github.com/cline/grpc-go/cline"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var isSessionAuthenticated bool
|
||||
|
||||
func NewAuthCommand() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "auth",
|
||||
Short: "Sign in to Cline",
|
||||
Long: `Complete the authentication flow in browser to sign in to Cline.`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return handleAuthCommand(cmd.Context())
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func handleAuthCommand(ctx context.Context) error {
|
||||
fmt.Print("Authenticating with Cline...\n")
|
||||
if IsAuthenticated(ctx) {
|
||||
return signOutDialog(ctx)
|
||||
}
|
||||
|
||||
if err := signIn(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Println("You are signed in!")
|
||||
return nil
|
||||
}
|
||||
|
||||
func signOut(ctx context.Context) error {
|
||||
client, err := global.GetDefaultClient(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err = client.Account.AccountLogoutClicked(ctx, &cline.EmptyRequest{}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
isSessionAuthenticated = false
|
||||
fmt.Println("You have been signed out of Cline.")
|
||||
return nil
|
||||
}
|
||||
|
||||
func signOutDialog(ctx context.Context) error {
|
||||
fmt.Print("You are already signed in to Cline.\nWould you like to sign out? (y/N): ")
|
||||
|
||||
scanner := bufio.NewScanner(os.Stdin)
|
||||
if !scanner.Scan() {
|
||||
return nil
|
||||
}
|
||||
|
||||
response := strings.ToLower(strings.TrimSpace(scanner.Text()))
|
||||
if response == "y" || response == "yes" {
|
||||
if err := signOut(ctx); err != nil {
|
||||
fmt.Printf("Failed to sign out: %v\n", err)
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func signIn(ctx context.Context) error {
|
||||
if IsAuthenticated(ctx) {
|
||||
return nil
|
||||
}
|
||||
|
||||
verboseLog("Ensuring default instance exists...")
|
||||
if err := ensureDefaultInstance(ctx); err != nil {
|
||||
verboseLog("Failed to ensure default instance: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
verboseLog("Default instance ensured successfully.")
|
||||
time.Sleep(2 * time.Second) // Allow services to start
|
||||
|
||||
client, err := global.GetDefaultClient(ctx)
|
||||
if err != nil {
|
||||
verboseLog("Failed to obtain client: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = client.Account.AccountLoginClicked(ctx, &cline.EmptyRequest{})
|
||||
if err != nil {
|
||||
verboseLog("Failed to login: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
isSessionAuthenticated = true
|
||||
verboseLog("Login successful")
|
||||
return nil
|
||||
}
|
||||
|
||||
func IsAuthenticated(ctx context.Context) bool {
|
||||
if isSessionAuthenticated {
|
||||
return true
|
||||
}
|
||||
|
||||
client, err := global.GetDefaultClient(ctx)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
_, err = client.Account.GetUserCredits(ctx, &cline.EmptyRequest{})
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func verboseLog(format string, args ...interface{}) {
|
||||
if global.Config != nil && global.Config.Verbose {
|
||||
fmt.Printf("[VERBOSE] "+format+"\n", args...)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package display
|
||||
|
||||
import (
|
||||
"crypto/md5"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/cline/cli/pkg/cli/types"
|
||||
)
|
||||
|
||||
// MessageDeduplicator handles message deduplication to prevent duplicate displays
|
||||
type MessageDeduplicator struct {
|
||||
mu sync.RWMutex
|
||||
seenMessages map[string]time.Time
|
||||
maxAge time.Duration
|
||||
cleanupTicker *time.Ticker
|
||||
}
|
||||
|
||||
// NewMessageDeduplicator creates a new message deduplicator
|
||||
func NewMessageDeduplicator() *MessageDeduplicator {
|
||||
d := &MessageDeduplicator{
|
||||
seenMessages: make(map[string]time.Time),
|
||||
maxAge: 5 * time.Minute, // Keep messages for 5 minutes
|
||||
cleanupTicker: time.NewTicker(1 * time.Minute), // Cleanup every minute
|
||||
}
|
||||
|
||||
// Start cleanup goroutine
|
||||
go d.cleanup()
|
||||
|
||||
return d
|
||||
}
|
||||
|
||||
// IsDuplicate checks if a message is a duplicate
|
||||
func (d *MessageDeduplicator) IsDuplicate(msg *types.ClineMessage) bool {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
|
||||
// Create a hash of the message content
|
||||
hash := d.hashMessage(msg)
|
||||
|
||||
// Check if we've seen this message recently
|
||||
if lastSeen, exists := d.seenMessages[hash]; exists {
|
||||
// If we've seen it within the last few seconds, it's a duplicate
|
||||
if time.Since(lastSeen) < 2*time.Second {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Mark this message as seen
|
||||
d.seenMessages[hash] = time.Now()
|
||||
return false
|
||||
}
|
||||
|
||||
// hashMessage creates a hash of the message for deduplication
|
||||
func (d *MessageDeduplicator) hashMessage(msg *types.ClineMessage) string {
|
||||
// Create a hash based on message content, type, and timestamp
|
||||
content := fmt.Sprintf("%s|%s|%s|%d",
|
||||
string(msg.Type),
|
||||
msg.Say,
|
||||
msg.Ask,
|
||||
msg.Timestamp)
|
||||
|
||||
// For partial messages, include the text content in the hash
|
||||
if msg.Partial {
|
||||
content += "|" + msg.Text
|
||||
}
|
||||
|
||||
hash := md5.Sum([]byte(content))
|
||||
return fmt.Sprintf("%x", hash)
|
||||
}
|
||||
|
||||
// cleanup removes old entries from the seen messages map
|
||||
func (d *MessageDeduplicator) cleanup() {
|
||||
for range d.cleanupTicker.C {
|
||||
d.mu.Lock()
|
||||
now := time.Now()
|
||||
|
||||
// Remove entries older than maxAge
|
||||
for hash, timestamp := range d.seenMessages {
|
||||
if now.Sub(timestamp) > d.maxAge {
|
||||
delete(d.seenMessages, hash)
|
||||
}
|
||||
}
|
||||
|
||||
d.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// Stop stops the cleanup goroutine
|
||||
func (d *MessageDeduplicator) Stop() {
|
||||
if d.cleanupTicker != nil {
|
||||
d.cleanupTicker.Stop()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
package display
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/cline/cli/pkg/cli/global"
|
||||
"github.com/cline/cli/pkg/cli/types"
|
||||
"github.com/cline/grpc-go/cline"
|
||||
)
|
||||
|
||||
type Renderer struct {
|
||||
typewriter *TypewriterPrinter
|
||||
}
|
||||
|
||||
func NewRenderer() *Renderer {
|
||||
return &Renderer{
|
||||
typewriter: NewTypewriterPrinter(DefaultTypewriterConfig()),
|
||||
}
|
||||
}
|
||||
|
||||
// RenderMessage renders a message with timestamp and prefix
|
||||
func (r *Renderer) RenderMessage(timestamp, prefix, text string) error {
|
||||
if text == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
cleanText := r.sanitizeText(text)
|
||||
if cleanText == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
r.typewriter.PrintMessageLine(timestamp, prefix, cleanText)
|
||||
return nil
|
||||
}
|
||||
|
||||
// RenderCommand renders a command execution
|
||||
func (r *Renderer) RenderCommand(timestamp, command string, isExecuting bool) error {
|
||||
if isExecuting {
|
||||
r.typewriter.PrintMessageLine(timestamp, "EXEC", command)
|
||||
} else {
|
||||
r.typewriter.PrintMessageLine(timestamp, "CMD", command)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// formatNumber formats numbers with k/m abbreviations
|
||||
func formatNumber(n int) string {
|
||||
if n >= 1000000 {
|
||||
return fmt.Sprintf("%.1fm", float64(n)/1000000.0)
|
||||
} else if n >= 1000 {
|
||||
return fmt.Sprintf("%.1fk", float64(n)/1000.0)
|
||||
}
|
||||
return fmt.Sprintf("%d", n)
|
||||
}
|
||||
|
||||
// formatUsageInfo formats token usage information (extracted from RenderAPI)
|
||||
func (r *Renderer) formatUsageInfo(tokensIn, tokensOut, cacheReads, cacheWrites int, cost float64) string {
|
||||
tokenDetails := fmt.Sprintf("[tokens in: %s, out: %s; cache read: %s, write: %s]",
|
||||
formatNumber(tokensIn),
|
||||
formatNumber(tokensOut),
|
||||
formatNumber(cacheReads),
|
||||
formatNumber(cacheWrites))
|
||||
|
||||
return fmt.Sprintf("%s ($%.4f)", tokenDetails, cost)
|
||||
}
|
||||
|
||||
// RenderAPI renders API request information
|
||||
func (r *Renderer) RenderAPI(timestamp, status string, apiInfo *types.APIRequestInfo) error {
|
||||
if apiInfo.Cost >= 0 {
|
||||
message := fmt.Sprintf("%s %s", status, r.formatUsageInfo(apiInfo.TokensIn, apiInfo.TokensOut, apiInfo.CacheReads, apiInfo.CacheWrites, apiInfo.Cost))
|
||||
r.typewriter.PrintMessageLine(timestamp, "API INFO", message)
|
||||
} else {
|
||||
r.typewriter.PrintMessageLine(timestamp, "API INFO", status)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RenderRetry renders retry information
|
||||
func (r *Renderer) RenderRetry(timestamp string, attempt, maxAttempts, delaySec int) error {
|
||||
message := fmt.Sprintf("Retrying failed attempt %d/%d", attempt, maxAttempts)
|
||||
if delaySec > 0 {
|
||||
message += fmt.Sprintf(" in %d seconds", delaySec)
|
||||
}
|
||||
message += "..."
|
||||
r.typewriter.PrintMessageLine(timestamp, "API INFO", message)
|
||||
return nil
|
||||
}
|
||||
|
||||
// RenderTaskList displays task history with improved formatting
|
||||
func (r *Renderer) RenderTaskList(tasks []*cline.TaskItem) error {
|
||||
const maxTasks = 20
|
||||
|
||||
startIndex := 0
|
||||
if len(tasks) > maxTasks {
|
||||
startIndex = len(tasks) - maxTasks
|
||||
}
|
||||
|
||||
recentTasks := tasks[startIndex:]
|
||||
|
||||
r.typewriter.PrintfLn("=== Task History (showing last %d of %d total tasks) ===\n", len(recentTasks), len(tasks))
|
||||
|
||||
for i, task := range recentTasks {
|
||||
r.typewriter.PrintfLn("Task ID: %s", task.Id)
|
||||
|
||||
description := task.Task
|
||||
if len(description) > 1000 {
|
||||
description = description[:1000] + "..."
|
||||
}
|
||||
r.typewriter.PrintfLn("Message: %s", description)
|
||||
|
||||
usageInfo := r.formatUsageInfo(int(task.TokensIn), int(task.TokensOut), int(task.CacheReads), int(task.CacheWrites), task.TotalCost)
|
||||
r.typewriter.PrintfLn("Usage : %s", usageInfo)
|
||||
|
||||
// Single space between tasks (except last)
|
||||
if i < len(recentTasks)-1 {
|
||||
r.typewriter.PrintfLn("")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Renderer) RenderDebug(format string, args ...interface{}) error {
|
||||
if global.Config.Verbose {
|
||||
timestamp := time.Now().Format("15:04:05")
|
||||
message := fmt.Sprintf(format, args...)
|
||||
r.typewriter.PrintMessageLine(timestamp, "[DEBUG]", message)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Renderer) ClearLine() {
|
||||
fmt.Print("\r\033[K")
|
||||
}
|
||||
|
||||
func (r *Renderer) MoveCursorUp(n int) {
|
||||
fmt.Printf("\033[%dA", n)
|
||||
}
|
||||
|
||||
func (r *Renderer) sanitizeText(text string) string {
|
||||
text = strings.TrimSpace(text)
|
||||
|
||||
if text == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Remove control characters and escape sequences
|
||||
var result strings.Builder
|
||||
for _, r := range text {
|
||||
// Keep printable characters, spaces, tabs, and newlines
|
||||
if r >= 32 || r == '\t' || r == '\n' || r == '\r' {
|
||||
result.WriteRune(r)
|
||||
}
|
||||
// Skip control characters (0-31 except tab, newline, carriage return)
|
||||
}
|
||||
|
||||
return result.String()
|
||||
}
|
||||
|
||||
func (r *Renderer) SetTypewriterEnabled(enabled bool) {
|
||||
r.typewriter.SetEnabled(enabled)
|
||||
}
|
||||
|
||||
func (r *Renderer) IsTypewriterEnabled() bool {
|
||||
return r.typewriter.IsEnabled()
|
||||
}
|
||||
|
||||
func (r *Renderer) SetTypewriterSpeed(multiplier float64) {
|
||||
r.typewriter.SetSpeed(multiplier)
|
||||
}
|
||||
|
||||
func (r *Renderer) GetTypewriter() *TypewriterPrinter {
|
||||
return r.typewriter
|
||||
}
|
||||
@@ -0,0 +1,449 @@
|
||||
package display
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/cline/cli/pkg/cli/types"
|
||||
)
|
||||
|
||||
// StreamingDisplay manages streaming message display with deduplication
|
||||
type StreamingDisplay struct {
|
||||
mu sync.RWMutex
|
||||
state *types.ConversationState
|
||||
renderer *Renderer
|
||||
dedupe *MessageDeduplicator
|
||||
}
|
||||
|
||||
// NewStreamingDisplay creates a new streaming display manager
|
||||
func NewStreamingDisplay(state *types.ConversationState, renderer *Renderer) *StreamingDisplay {
|
||||
return &StreamingDisplay{
|
||||
state: state,
|
||||
renderer: renderer,
|
||||
dedupe: NewMessageDeduplicator(),
|
||||
}
|
||||
}
|
||||
|
||||
// HandlePartialMessage processes partial messages with streaming support
|
||||
func (s *StreamingDisplay) HandlePartialMessage(msg *types.ClineMessage) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
messageKey := fmt.Sprintf("%d", msg.Timestamp)
|
||||
timestamp := msg.GetTimestamp()
|
||||
|
||||
// Check for deduplication
|
||||
if s.dedupe.IsDuplicate(msg) {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get current streaming state
|
||||
streamingMsg := s.state.GetStreamingMessage()
|
||||
|
||||
switch msg.Type {
|
||||
case types.MessageTypeAsk:
|
||||
return s.handleStreamingAsk(msg, messageKey, timestamp, streamingMsg)
|
||||
case types.MessageTypeSay:
|
||||
return s.handleStreamingSay(msg, messageKey, timestamp, streamingMsg)
|
||||
default:
|
||||
return s.renderer.RenderMessage(timestamp, "🤖", msg.Text)
|
||||
}
|
||||
}
|
||||
|
||||
// handleStreamingAsk handles streaming ASK messages
|
||||
func (s *StreamingDisplay) handleStreamingAsk(msg *types.ClineMessage, messageKey, timestamp string, streamingMsg *types.StreamingMessage) error {
|
||||
if msg.Text == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
cleanText := s.renderer.sanitizeText(msg.Text)
|
||||
if cleanText == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check if this is an update to the same ASK message
|
||||
if streamingMsg.CurrentKey == messageKey {
|
||||
// This is an update to the same ASK message - stream the changes
|
||||
if cleanText != streamingMsg.LastText {
|
||||
s.streamAskMessageUpdate(cleanText, streamingMsg.LastText, timestamp)
|
||||
s.state.SetStreamingMessage(messageKey, cleanText)
|
||||
}
|
||||
} else {
|
||||
// This is a new ASK message
|
||||
s.finishCurrentStream()
|
||||
s.streamAskMessage(cleanText, timestamp, true)
|
||||
s.state.SetStreamingMessage(messageKey, cleanText)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleStreamingSay handles streaming SAY messages
|
||||
func (s *StreamingDisplay) handleStreamingSay(msg *types.ClineMessage, messageKey, timestamp string, streamingMsg *types.StreamingMessage) error {
|
||||
switch msg.Say {
|
||||
case string(types.SayTypeText), string(types.SayTypeCompletionResult):
|
||||
return s.handleStreamingText(msg, messageKey, timestamp, streamingMsg)
|
||||
case string(types.SayTypeCommand):
|
||||
return s.handleStreamingCommand(msg, messageKey, timestamp, streamingMsg)
|
||||
case string(types.SayTypeCommandOutput):
|
||||
return s.handleStreamingCommandOutput(msg, messageKey, timestamp, streamingMsg)
|
||||
case string(types.SayTypeTool):
|
||||
return s.handleStreamingTool(msg, messageKey, timestamp, streamingMsg)
|
||||
case string(types.SayTypeShellIntegrationWarning):
|
||||
return s.handleShellIntegrationWarning(msg, messageKey, timestamp, streamingMsg)
|
||||
default:
|
||||
// For non-streaming message types, use regular display
|
||||
return s.renderer.RenderMessage(timestamp, s.getMessagePrefix(msg.Say), msg.Text)
|
||||
}
|
||||
}
|
||||
|
||||
// handleStreamingText handles streaming text messages
|
||||
func (s *StreamingDisplay) handleStreamingText(msg *types.ClineMessage, messageKey, timestamp string, streamingMsg *types.StreamingMessage) error {
|
||||
cleanText := s.renderer.sanitizeText(msg.Text)
|
||||
if cleanText == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check if we've already displayed this exact message
|
||||
if streamingMsg.CurrentKey == messageKey && streamingMsg.LastText == cleanText {
|
||||
return nil // Duplicate - ignore it
|
||||
}
|
||||
|
||||
// Check if this is an update to the same message
|
||||
if streamingMsg.CurrentKey == messageKey {
|
||||
// Show incremental changes
|
||||
if len(cleanText) > len(streamingMsg.LastText) && strings.HasPrefix(cleanText, streamingMsg.LastText) {
|
||||
// Show only the new characters with typewriter effect
|
||||
newChars := cleanText[len(streamingMsg.LastText):]
|
||||
s.typewriterPrint(newChars)
|
||||
s.state.SetStreamingMessage(messageKey, cleanText)
|
||||
} else {
|
||||
// Text changed in a non-incremental way - replace the line
|
||||
s.renderer.ClearLine()
|
||||
prefix := s.getMessagePrefix(msg.Say)
|
||||
s.renderer.typewriter.PrintfInstant("[%s] %s: ", timestamp, prefix)
|
||||
s.typewriterPrint(cleanText)
|
||||
s.state.SetStreamingMessage(messageKey, cleanText)
|
||||
}
|
||||
} else {
|
||||
// This is a new message
|
||||
s.finishCurrentStream()
|
||||
prefix := s.getMessagePrefix(msg.Say)
|
||||
s.renderer.typewriter.PrintfInstant("[%s] %s: ", timestamp, prefix)
|
||||
|
||||
// Add typewriter animation for new messages
|
||||
s.typewriterPrint(cleanText)
|
||||
|
||||
s.state.SetStreamingMessage(messageKey, cleanText)
|
||||
}
|
||||
|
||||
// If message is complete, add newline
|
||||
if !msg.Partial {
|
||||
fmt.Println()
|
||||
s.state.SetStreamingMessage("", "")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleStreamingCommand handles command execution messages
|
||||
func (s *StreamingDisplay) handleStreamingCommand(msg *types.ClineMessage, messageKey, timestamp string, streamingMsg *types.StreamingMessage) error {
|
||||
cleanText := s.renderer.sanitizeText(msg.Text)
|
||||
if cleanText == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Show command being executed with typewriter effect
|
||||
s.finishCurrentStream()
|
||||
s.renderer.typewriter.PrintfInstant("[%s] 🖥️ CMD: ", timestamp)
|
||||
s.typewriterPrint(cleanText)
|
||||
fmt.Println()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleStreamingCommandOutput handles streaming command output
|
||||
func (s *StreamingDisplay) handleStreamingCommandOutput(msg *types.ClineMessage, messageKey, timestamp string, streamingMsg *types.StreamingMessage) error {
|
||||
cleanText := s.renderer.sanitizeText(msg.Text)
|
||||
if cleanText == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check if we've already displayed this exact message
|
||||
if streamingMsg.CurrentKey == messageKey && streamingMsg.LastText == cleanText {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check if this is an update to the same message
|
||||
if streamingMsg.CurrentKey == messageKey {
|
||||
// Show incremental changes with typewriter effect
|
||||
if len(cleanText) > len(streamingMsg.LastText) && strings.HasPrefix(cleanText, streamingMsg.LastText) {
|
||||
newChars := cleanText[len(streamingMsg.LastText):]
|
||||
s.typewriterPrint(newChars)
|
||||
s.state.SetStreamingMessage(messageKey, cleanText)
|
||||
} else {
|
||||
// Non-incremental change - replace the line
|
||||
s.renderer.ClearLine()
|
||||
s.renderer.typewriter.PrintfInstant("[%s] 🖥️ OUT: ", timestamp)
|
||||
s.typewriterPrint(cleanText)
|
||||
s.state.SetStreamingMessage(messageKey, cleanText)
|
||||
}
|
||||
} else {
|
||||
// New command output message
|
||||
s.finishCurrentStream()
|
||||
s.renderer.typewriter.PrintfInstant("[%s] 🖥️ OUT: ", timestamp)
|
||||
s.typewriterPrint(cleanText)
|
||||
s.state.SetStreamingMessage(messageKey, cleanText)
|
||||
}
|
||||
|
||||
// If message is complete, add newline
|
||||
if !msg.Partial {
|
||||
fmt.Println()
|
||||
s.state.SetStreamingMessage("", "")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleShellIntegrationWarning handles shell integration warning messages
|
||||
func (s *StreamingDisplay) handleShellIntegrationWarning(msg *types.ClineMessage, messageKey, timestamp string, streamingMsg *types.StreamingMessage) error {
|
||||
cleanText := s.renderer.sanitizeText(msg.Text)
|
||||
if cleanText == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Show a more concise shell integration warning
|
||||
s.finishCurrentStream()
|
||||
s.renderer.typewriter.PrintfInstant("[%s] ℹ️ NOTE: ", timestamp)
|
||||
s.typewriterPrint("Command executed (output not streamed due to shell integration)")
|
||||
fmt.Println()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleStreamingTool handles streaming tool messages with deduplication
|
||||
func (s *StreamingDisplay) handleStreamingTool(msg *types.ClineMessage, messageKey, timestamp string, streamingMsg *types.StreamingMessage) error {
|
||||
cleanText := s.renderer.sanitizeText(msg.Text)
|
||||
if cleanText == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
formattedTool := s.formatToolMessage(cleanText)
|
||||
|
||||
// Check if this is the exact same tool message we just displayed
|
||||
if streamingMsg.LastToolMessage == formattedTool {
|
||||
return nil // Exact duplicate - ignore it
|
||||
}
|
||||
|
||||
// Check if this is a very similar tool message
|
||||
if streamingMsg.LastToolMessage != "" && s.isSimilarToolMessage(streamingMsg.LastToolMessage, formattedTool) {
|
||||
return nil // Similar duplicate - ignore it
|
||||
}
|
||||
|
||||
// This is a genuinely new/different tool message
|
||||
s.finishCurrentStream()
|
||||
fmt.Printf("[%s] 🔧 TOOL: %s\n", timestamp, formattedTool)
|
||||
|
||||
// Store the formatted tool message for deduplication
|
||||
s.state.StreamingMessage.LastToolMessage = formattedTool
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// streamAskMessage streams an ASK message in a natural format
|
||||
func (s *StreamingDisplay) streamAskMessage(text, timestamp string, isNew bool) {
|
||||
// Try to parse as JSON
|
||||
var askData types.AskData
|
||||
if err := s.parseJSON(text, &askData); err != nil {
|
||||
// Display as text but sanitized
|
||||
fmt.Printf("[%s] 🤖 ASK: %s", timestamp, text)
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf("[%s] 🤖 ASK: %s", timestamp, askData.Response)
|
||||
|
||||
// Display options if available
|
||||
if len(askData.Options) > 0 {
|
||||
fmt.Print("\n\nOptions:")
|
||||
for i, option := range askData.Options {
|
||||
fmt.Printf("\n%d. %s", i+1, option)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// streamAskMessageUpdate handles updates to an existing ASK message
|
||||
func (s *StreamingDisplay) streamAskMessageUpdate(newText, oldText, timestamp string) {
|
||||
var oldAskData, newAskData types.AskData
|
||||
|
||||
oldErr := s.parseJSON(oldText, &oldAskData)
|
||||
newErr := s.parseJSON(newText, &newAskData)
|
||||
|
||||
if oldErr != nil || newErr != nil {
|
||||
// Handle plain text incremental updates
|
||||
if len(newText) > len(oldText) && strings.HasPrefix(newText, oldText) {
|
||||
newChars := newText[len(oldText):]
|
||||
fmt.Print(newChars)
|
||||
} else {
|
||||
// Non-incremental change - clear line and reprint everything
|
||||
s.renderer.ClearLine()
|
||||
fmt.Printf("[%s] 🤖 ASK: %s", timestamp, newText)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Handle structured updates
|
||||
if len(newAskData.Response) > len(oldAskData.Response) && strings.HasPrefix(newAskData.Response, oldAskData.Response) {
|
||||
newChars := newAskData.Response[len(oldAskData.Response):]
|
||||
fmt.Print(newChars)
|
||||
} else if oldAskData.Response != newAskData.Response {
|
||||
s.renderer.ClearLine()
|
||||
fmt.Printf("[%s] 🤖 ASK: %s", timestamp, newAskData.Response)
|
||||
}
|
||||
|
||||
// Handle options changes
|
||||
if len(newAskData.Options) > len(oldAskData.Options) {
|
||||
if len(oldAskData.Options) == 0 {
|
||||
fmt.Print("\n\nOptions:")
|
||||
}
|
||||
|
||||
for i := len(oldAskData.Options); i < len(newAskData.Options); i++ {
|
||||
fmt.Printf("\n%d. %s", i+1, newAskData.Options[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// typewriterPrint displays text with a typewriter animation effect
|
||||
func (s *StreamingDisplay) typewriterPrint(text string) {
|
||||
// Use the renderer's typewriter for consistent animation
|
||||
s.renderer.typewriter.Print(text)
|
||||
}
|
||||
|
||||
// finishCurrentStream completes any ongoing streaming message
|
||||
func (s *StreamingDisplay) finishCurrentStream() {
|
||||
streamingMsg := s.state.GetStreamingMessage()
|
||||
if streamingMsg.CurrentKey != "" {
|
||||
//fmt.Println() // Add newline to finish the current streaming message
|
||||
s.state.SetStreamingMessage("", "")
|
||||
}
|
||||
}
|
||||
|
||||
// getMessagePrefix returns the appropriate prefix for a message type
|
||||
func (s *StreamingDisplay) getMessagePrefix(say string) string {
|
||||
switch say {
|
||||
case string(types.SayTypeCompletionResult):
|
||||
return "✅ RESULT"
|
||||
case string(types.SayTypeText):
|
||||
return "🤖"
|
||||
default:
|
||||
return "🤖"
|
||||
}
|
||||
}
|
||||
|
||||
// formatToolMessage formats tool call messages for better readability
|
||||
func (s *StreamingDisplay) formatToolMessage(text string) string {
|
||||
var toolCall map[string]interface{}
|
||||
if err := s.parseJSON(text, &toolCall); err == nil {
|
||||
if tool, ok := toolCall["tool"].(string); ok {
|
||||
parts := []string{tool}
|
||||
|
||||
if path, ok := toolCall["path"].(string); ok && path != "" {
|
||||
parts = append(parts, fmt.Sprintf("path=%s", path))
|
||||
}
|
||||
|
||||
if content, ok := toolCall["content"].(string); ok && content != "" {
|
||||
if len(content) > 50 {
|
||||
parts = append(parts, fmt.Sprintf("content=%s...", content[:50]))
|
||||
} else {
|
||||
parts = append(parts, fmt.Sprintf("content=%s", content))
|
||||
}
|
||||
}
|
||||
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
}
|
||||
|
||||
// If not JSON or doesn't have expected structure, return truncated
|
||||
if len(text) > 100 {
|
||||
return text[:100] + "..."
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
// isSimilarToolMessage checks if two tool messages are similar enough to be considered duplicates
|
||||
func (s *StreamingDisplay) isSimilarToolMessage(msg1, msg2 string) bool {
|
||||
parts1 := strings.Fields(msg1)
|
||||
parts2 := strings.Fields(msg2)
|
||||
|
||||
if len(parts1) == 0 || len(parts2) == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
// If the first word (tool name) is the same, check for similarity
|
||||
if parts1[0] == parts2[0] {
|
||||
// For file operations, check if the path is the same
|
||||
if strings.Contains(msg1, "path=") && strings.Contains(msg2, "path=") {
|
||||
path1 := s.extractPathFromToolMessage(msg1)
|
||||
path2 := s.extractPathFromToolMessage(msg2)
|
||||
|
||||
if path1 != "" && path1 == path2 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// For very similar content (>80% similarity), consider them duplicates
|
||||
similarity := s.calculateStringSimilarity(msg1, msg2)
|
||||
return similarity > 0.8
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// extractPathFromToolMessage extracts the path parameter from a tool message
|
||||
func (s *StreamingDisplay) extractPathFromToolMessage(msg string) string {
|
||||
parts := strings.Fields(msg)
|
||||
for _, part := range parts {
|
||||
if strings.HasPrefix(part, "path=") {
|
||||
return strings.TrimPrefix(part, "path=")
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// calculateStringSimilarity calculates a simple similarity ratio between two strings
|
||||
func (s *StreamingDisplay) calculateStringSimilarity(s1, s2 string) float64 {
|
||||
if s1 == s2 {
|
||||
return 1.0
|
||||
}
|
||||
|
||||
if len(s1) == 0 || len(s2) == 0 {
|
||||
return 0.0
|
||||
}
|
||||
|
||||
shorter, longer := s1, s2
|
||||
if len(s1) > len(s2) {
|
||||
shorter, longer = s2, s1
|
||||
}
|
||||
|
||||
matches := 0
|
||||
for i, r := range shorter {
|
||||
if i < len(longer) && rune(longer[i]) == r {
|
||||
matches++
|
||||
}
|
||||
}
|
||||
|
||||
return float64(matches) / float64(len(longer))
|
||||
}
|
||||
|
||||
// parseJSON is a helper function to parse JSON with error handling
|
||||
func (s *StreamingDisplay) parseJSON(text string, v interface{}) error {
|
||||
return json.Unmarshal([]byte(text), v)
|
||||
}
|
||||
|
||||
// Cleanup cleans up streaming display resources
|
||||
func (s *StreamingDisplay) Cleanup() {
|
||||
if s.dedupe != nil {
|
||||
s.dedupe.Stop()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
package display
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TypewriterConfig holds configuration for the typewriter effect
|
||||
type TypewriterConfig struct {
|
||||
BaseDelay time.Duration // Base delay between characters
|
||||
FastDelay time.Duration // Faster delay for common characters
|
||||
SlowDelay time.Duration // Slower delay for punctuation
|
||||
PauseDelay time.Duration // Pause after sentences
|
||||
Enabled bool // Whether typewriter effect is enabled
|
||||
RandomFactor float64 // Randomness factor (0.0 to 1.0)
|
||||
}
|
||||
|
||||
// DefaultTypewriterConfig returns the default typewriter configuration
|
||||
func DefaultTypewriterConfig() *TypewriterConfig {
|
||||
return &TypewriterConfig{
|
||||
BaseDelay: 15 * time.Millisecond,
|
||||
FastDelay: 8 * time.Millisecond,
|
||||
SlowDelay: 25 * time.Millisecond,
|
||||
PauseDelay: 150 * time.Millisecond,
|
||||
Enabled: false,
|
||||
RandomFactor: 0.3,
|
||||
}
|
||||
}
|
||||
|
||||
// TypewriterPrinter handles typewriter-style output
|
||||
type TypewriterPrinter struct {
|
||||
config *TypewriterConfig
|
||||
}
|
||||
|
||||
// NewTypewriterPrinter creates a new typewriter printer
|
||||
func NewTypewriterPrinter(config *TypewriterConfig) *TypewriterPrinter {
|
||||
if config == nil {
|
||||
config = DefaultTypewriterConfig()
|
||||
}
|
||||
return &TypewriterPrinter{
|
||||
config: config,
|
||||
}
|
||||
}
|
||||
|
||||
// Print prints text with typewriter effect
|
||||
func (tp *TypewriterPrinter) Print(text string) {
|
||||
if !tp.config.Enabled {
|
||||
fmt.Print(text)
|
||||
return
|
||||
}
|
||||
|
||||
tp.typewriterPrint(text)
|
||||
}
|
||||
|
||||
// Printf prints formatted text with typewriter effect
|
||||
func (tp *TypewriterPrinter) Printf(format string, args ...interface{}) {
|
||||
text := fmt.Sprintf(format, args...)
|
||||
tp.Print(text)
|
||||
}
|
||||
|
||||
// Println prints text with typewriter effect and adds a newline
|
||||
func (tp *TypewriterPrinter) Println(text string) {
|
||||
tp.Print(text + "\n")
|
||||
}
|
||||
|
||||
// PrintfLn prints formatted text with typewriter effect and adds a newline
|
||||
func (tp *TypewriterPrinter) PrintfLn(format string, args ...interface{}) {
|
||||
text := fmt.Sprintf(format, args...)
|
||||
tp.Println(text)
|
||||
}
|
||||
|
||||
// PrintInstant prints text immediately without typewriter effect
|
||||
func (tp *TypewriterPrinter) PrintInstant(text string) {
|
||||
fmt.Print(text)
|
||||
}
|
||||
|
||||
// PrintfInstant prints formatted text immediately without typewriter effect
|
||||
func (tp *TypewriterPrinter) PrintfInstant(format string, args ...interface{}) {
|
||||
fmt.Printf(format, args...)
|
||||
}
|
||||
|
||||
// typewriterPrint displays text with a typewriter animation effect
|
||||
func (tp *TypewriterPrinter) typewriterPrint(text string) {
|
||||
// Convert string to runes to handle Unicode properly
|
||||
runes := []rune(text)
|
||||
|
||||
for i, r := range runes {
|
||||
// Print the character
|
||||
fmt.Print(string(r))
|
||||
os.Stdout.Sync() // Force immediate output
|
||||
|
||||
// Don't add delay after the last character
|
||||
if i == len(runes)-1 {
|
||||
break
|
||||
}
|
||||
|
||||
// Determine delay based on character type
|
||||
delay := tp.getDelayForCharacter(r, i)
|
||||
|
||||
// Sleep for the calculated delay
|
||||
time.Sleep(delay)
|
||||
}
|
||||
}
|
||||
|
||||
// getDelayForCharacter returns the appropriate delay for a character
|
||||
func (tp *TypewriterPrinter) getDelayForCharacter(r rune, position int) time.Duration {
|
||||
var baseDelay time.Duration
|
||||
|
||||
switch {
|
||||
case r == '.' || r == '!' || r == '?':
|
||||
// Longer pause after sentence endings
|
||||
baseDelay = tp.config.PauseDelay
|
||||
case r == ',' || r == ';' || r == ':':
|
||||
// Medium pause after punctuation
|
||||
baseDelay = tp.config.SlowDelay
|
||||
case r == ' ':
|
||||
// Slightly faster for spaces
|
||||
baseDelay = tp.config.FastDelay
|
||||
case r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z':
|
||||
// Fast for common letters
|
||||
baseDelay = tp.config.FastDelay
|
||||
case r == '\n':
|
||||
// No delay for newlines
|
||||
return 0
|
||||
default:
|
||||
// Base delay for other characters
|
||||
baseDelay = tp.config.BaseDelay
|
||||
}
|
||||
|
||||
// Add randomness to make it feel more natural
|
||||
if tp.config.RandomFactor > 0 {
|
||||
// Simple pseudo-random based on position to ensure consistency
|
||||
randomFactor := 0.7 + (tp.config.RandomFactor * float64(position%7) / 6.0)
|
||||
baseDelay = time.Duration(float64(baseDelay) * randomFactor)
|
||||
}
|
||||
|
||||
return baseDelay
|
||||
}
|
||||
|
||||
// SetEnabled enables or disables the typewriter effect
|
||||
func (tp *TypewriterPrinter) SetEnabled(enabled bool) {
|
||||
tp.config.Enabled = enabled
|
||||
}
|
||||
|
||||
// IsEnabled returns whether the typewriter effect is enabled
|
||||
func (tp *TypewriterPrinter) IsEnabled() bool {
|
||||
return tp.config.Enabled
|
||||
}
|
||||
|
||||
// SetSpeed adjusts the typewriter speed (multiplier: 0.1 = very slow, 1.0 = normal, 2.0 = fast)
|
||||
func (tp *TypewriterPrinter) SetSpeed(multiplier float64) {
|
||||
if multiplier <= 0 {
|
||||
multiplier = 1.0
|
||||
}
|
||||
|
||||
tp.config.BaseDelay = time.Duration(float64(15*time.Millisecond) / multiplier)
|
||||
tp.config.FastDelay = time.Duration(float64(8*time.Millisecond) / multiplier)
|
||||
tp.config.SlowDelay = time.Duration(float64(25*time.Millisecond) / multiplier)
|
||||
tp.config.PauseDelay = time.Duration(float64(150*time.Millisecond) / multiplier)
|
||||
}
|
||||
|
||||
// PrintMessageLine prints a complete message line with typewriter effect
|
||||
func (tp *TypewriterPrinter) PrintMessageLine(timestamp, prefix, text string) {
|
||||
// Print the timestamp and prefix with 10-char padding
|
||||
tp.PrintfInstant("[%s] %-10s: ", timestamp, prefix)
|
||||
// Print the message text with typewriter effect
|
||||
tp.Println(text)
|
||||
}
|
||||
|
||||
// Global typewriter printer instance
|
||||
var globalTypewriter = NewTypewriterPrinter(DefaultTypewriterConfig())
|
||||
|
||||
// Global convenience functions that use the global typewriter instance
|
||||
|
||||
// TypewriterPrint prints text with typewriter effect using the global instance
|
||||
func TypewriterPrint(text string) {
|
||||
globalTypewriter.Print(text)
|
||||
}
|
||||
|
||||
// TypewriterPrintf prints formatted text with typewriter effect using the global instance
|
||||
func TypewriterPrintf(format string, args ...interface{}) {
|
||||
globalTypewriter.Printf(format, args...)
|
||||
}
|
||||
|
||||
// TypewriterPrintln prints text with typewriter effect and newline using the global instance
|
||||
func TypewriterPrintln(text string) {
|
||||
globalTypewriter.Println(text)
|
||||
}
|
||||
|
||||
// TypewriterPrintfLn prints formatted text with typewriter effect and newline using the global instance
|
||||
func TypewriterPrintfLn(format string, args ...interface{}) {
|
||||
globalTypewriter.PrintfLn(format, args...)
|
||||
}
|
||||
|
||||
// TypewriterPrintMessageLine prints a message line with typewriter effect using the global instance
|
||||
func TypewriterPrintMessageLine(timestamp, prefix, text string) {
|
||||
globalTypewriter.PrintMessageLine(timestamp, prefix, text)
|
||||
}
|
||||
|
||||
// SetGlobalTypewriterEnabled enables or disables the global typewriter effect
|
||||
func SetGlobalTypewriterEnabled(enabled bool) {
|
||||
globalTypewriter.SetEnabled(enabled)
|
||||
}
|
||||
|
||||
// SetGlobalTypewriterSpeed sets the speed of the global typewriter effect
|
||||
func SetGlobalTypewriterSpeed(multiplier float64) {
|
||||
globalTypewriter.SetSpeed(multiplier)
|
||||
}
|
||||
|
||||
// GetGlobalTypewriter returns the global typewriter instance
|
||||
func GetGlobalTypewriter() *TypewriterPrinter {
|
||||
return globalTypewriter
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
package global
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"time"
|
||||
|
||||
"github.com/cline/cli/pkg/common"
|
||||
)
|
||||
|
||||
// ClineClients manages Cline instances using the new registry system
|
||||
type ClineClients struct {
|
||||
registry *ClientRegistry
|
||||
}
|
||||
|
||||
// NewClineClients creates a new ClineClients instance
|
||||
func NewClineClients(configPath string) *ClineClients {
|
||||
registry := NewClientRegistry(configPath)
|
||||
return &ClineClients{
|
||||
registry: registry,
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize performs cleanup of stale instances
|
||||
func (c *ClineClients) Initialize(ctx context.Context) error {
|
||||
// Clean up stale entries (direct SQLite operations)
|
||||
_ = c.registry.CleanupStaleInstances(ctx)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// StartNewInstance starts a new Cline instance and waits for cline-core to self-register
|
||||
func (c *ClineClients) StartNewInstance(ctx context.Context) (*common.CoreInstanceInfo, error) {
|
||||
// Find available ports
|
||||
corePort, hostPort, err := common.FindAvailablePortPair()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to find available ports: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Starting new Cline instance on ports %d (core) and %d (host bridge)\n", corePort, hostPort)
|
||||
|
||||
// Start cline-host first
|
||||
hostCmd, err := startClineHost(hostPort, corePort)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to start cline-host: %w", err)
|
||||
}
|
||||
|
||||
// Start cline-core (it will register itself in SQLite locks database)
|
||||
coreCmd, err := startClineCore(corePort, hostPort)
|
||||
if err != nil {
|
||||
// Clean up host process if core fails to start
|
||||
if hostCmd != nil && hostCmd.Process != nil {
|
||||
hostCmd.Process.Kill()
|
||||
}
|
||||
return nil, fmt.Errorf("failed to start cline-core: %w", err)
|
||||
}
|
||||
|
||||
fullAddress := fmt.Sprintf("localhost:%d", corePort)
|
||||
fmt.Println("Waiting for services to start and self-register in SQLite...")
|
||||
|
||||
// Use RetryOperation to wait for instance to be ready
|
||||
var instance *common.CoreInstanceInfo
|
||||
err = common.RetryOperation(12, 5*time.Second, func() error {
|
||||
// Check if instance registered itself in SQLite
|
||||
foundInstance, err := c.registry.GetInstance(fullAddress)
|
||||
if err != nil || foundInstance == nil {
|
||||
return fmt.Errorf("instance not found in registry: %v", err)
|
||||
}
|
||||
|
||||
// Verify instance is healthy
|
||||
if !common.IsInstanceHealthy(ctx, fullAddress) {
|
||||
return fmt.Errorf("instance is registered but not healthy")
|
||||
}
|
||||
|
||||
// Success - store the instance for return
|
||||
instance = foundInstance
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
// Clean up both processes on failure
|
||||
if coreCmd != nil && coreCmd.Process != nil {
|
||||
fmt.Printf("Cleaning up core process (PID: %d)\n", coreCmd.Process.Pid)
|
||||
coreCmd.Process.Kill()
|
||||
}
|
||||
if hostCmd != nil && hostCmd.Process != nil {
|
||||
fmt.Printf("Cleaning up host process (PID: %d)\n", hostCmd.Process.Pid)
|
||||
hostCmd.Process.Kill()
|
||||
}
|
||||
return nil, fmt.Errorf("failed to start instance: %w", err)
|
||||
}
|
||||
|
||||
fmt.Println("✅ Services started and registered successfully!")
|
||||
fmt.Printf(" Address: %s\n", instance.Address)
|
||||
fmt.Printf(" Core Port: %d\n", instance.CorePort())
|
||||
fmt.Printf(" Host Bridge Port: %d\n", instance.HostPort())
|
||||
fmt.Printf(" Process PID: %d\n", coreCmd.Process.Pid)
|
||||
return instance, nil
|
||||
}
|
||||
|
||||
// StartNewInstanceAtPort starts a new Cline instance at the specified port and waits for self-registration
|
||||
func (c *ClineClients) StartNewInstanceAtPort(ctx context.Context, corePort int) (*common.CoreInstanceInfo, error) {
|
||||
// Find available host port (core port + 1000)
|
||||
hostPort := corePort + 1000
|
||||
coreAddress := fmt.Sprintf("localhost:%d", corePort)
|
||||
|
||||
// Check if the specified core port is available
|
||||
if common.IsInstanceHealthy(ctx, coreAddress) {
|
||||
return nil, fmt.Errorf("port %d is already in use by another Cline instance", corePort)
|
||||
}
|
||||
|
||||
fmt.Printf("Starting new Cline instance on ports %d (core) and %d (host bridge)\n", corePort, hostPort)
|
||||
|
||||
// Start cline-host first
|
||||
hostCmd, err := startClineHost(hostPort, corePort)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to start cline-host: %w", err)
|
||||
}
|
||||
|
||||
// Start cline-core (it will register itself in SQLite locks database)
|
||||
coreCmd, err := startClineCore(corePort, hostPort)
|
||||
if err != nil {
|
||||
// Clean up host process if core fails to start
|
||||
if hostCmd != nil && hostCmd.Process != nil {
|
||||
hostCmd.Process.Kill()
|
||||
}
|
||||
return nil, fmt.Errorf("failed to start cline-core: %w", err)
|
||||
}
|
||||
|
||||
fullAddress := fmt.Sprintf("localhost:%d", corePort)
|
||||
fmt.Println("Waiting for services to start and self-register in SQLite...")
|
||||
|
||||
// Use RetryOperation to wait for instance to be ready
|
||||
var instance *common.CoreInstanceInfo
|
||||
err = common.RetryOperation(12, 5*time.Second, func() error {
|
||||
// Check if instance registered itself in SQLite
|
||||
foundInstance, err := c.registry.GetInstance(fullAddress)
|
||||
if err != nil || foundInstance == nil {
|
||||
return fmt.Errorf("instance not found in registry: %v", err)
|
||||
}
|
||||
|
||||
// Verify instance is healthy
|
||||
if !common.IsInstanceHealthy(ctx, fullAddress) {
|
||||
return fmt.Errorf("instance is registered but not healthy")
|
||||
}
|
||||
|
||||
// Success - store the instance for return
|
||||
instance = foundInstance
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
// Clean up both processes on failure
|
||||
if coreCmd != nil && coreCmd.Process != nil {
|
||||
fmt.Printf("Cleaning up core process (PID: %d)\n", coreCmd.Process.Pid)
|
||||
coreCmd.Process.Kill()
|
||||
}
|
||||
if hostCmd != nil && hostCmd.Process != nil {
|
||||
fmt.Printf("Cleaning up host process (PID: %d)\n", hostCmd.Process.Pid)
|
||||
hostCmd.Process.Kill()
|
||||
}
|
||||
return nil, fmt.Errorf("failed to start instance at port %d: %w", corePort, err)
|
||||
}
|
||||
|
||||
fmt.Println("✅ Services started and registered successfully!")
|
||||
fmt.Printf(" Address: %s\n", instance.Address)
|
||||
fmt.Printf(" Core Port: %d\n", instance.CorePort())
|
||||
fmt.Printf(" Host Bridge Port: %d\n", instance.HostPort())
|
||||
fmt.Printf(" Process PID: %d\n", coreCmd.Process.Pid)
|
||||
return instance, nil
|
||||
}
|
||||
|
||||
// GetRegistry returns the client registry
|
||||
func (c *ClineClients) GetRegistry() *ClientRegistry {
|
||||
return c.registry
|
||||
}
|
||||
|
||||
// EnsureInstanceAtAddress ensures an instance exists at the given address, starting one if needed
|
||||
func (c *ClineClients) EnsureInstanceAtAddress(ctx context.Context, address string) error {
|
||||
// Expect host:port everywhere
|
||||
normalized := address
|
||||
if normalized == "" {
|
||||
normalized = fmt.Sprintf("localhost:%d", common.DEFAULT_CLINE_CORE_PORT)
|
||||
}
|
||||
|
||||
// Check if instance already exists at this address
|
||||
if c.registry.HasInstanceAtAddress(normalized) {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Parse host:port
|
||||
host, port, err := common.ParseHostPort(normalized)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid address format %s", address)
|
||||
}
|
||||
|
||||
// Use IPv6-compatible localhost detection
|
||||
if common.IsLocalAddress(host) {
|
||||
_, err := c.StartNewInstanceAtPort(ctx, port)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to start new instance at %s: %w", normalized, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
return fmt.Errorf("cannot start remote instance at %s", normalized)
|
||||
}
|
||||
|
||||
func startClineHost(hostPort, corePort int) (*exec.Cmd, error) {
|
||||
fmt.Printf("Starting cline-host on port %d\n", hostPort)
|
||||
|
||||
// Start the cline-host process
|
||||
cmd := exec.Command("./cli/bin/cline-host",
|
||||
"--verbose",
|
||||
"--port", fmt.Sprintf("%d", hostPort))
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
return nil, fmt.Errorf("failed to start cline-host: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Started cline-host (PID: %d)\n", cmd.Process.Pid)
|
||||
return cmd, nil
|
||||
}
|
||||
|
||||
func startClineCore(corePort, hostPort int) (*exec.Cmd, error) {
|
||||
fmt.Printf("Starting cline-core on port %d (with hostbridge on %d)\n", corePort, hostPort)
|
||||
|
||||
// Create port-tagged log file in OS temp directory with full address
|
||||
logFileName := fmt.Sprintf("cline-core-debug-localhost-%d.log", corePort)
|
||||
logFilePath := fmt.Sprintf("%s/%s", os.TempDir(), logFileName)
|
||||
logFile, err := os.Create(logFilePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create log file: %w", err)
|
||||
}
|
||||
|
||||
// Start the cline-core process with --config flag instead of CLINE_DIR env var
|
||||
args := []string{"cline-core.js",
|
||||
"--port", fmt.Sprintf("%d", corePort),
|
||||
"--host-bridge-port", fmt.Sprintf("%d", hostPort),
|
||||
"--config", Config.ConfigPath}
|
||||
|
||||
fmt.Printf("DEBUG: Starting cline-core with command: node %v\n", args)
|
||||
fmt.Printf("DEBUG: Working directory: ./dist-standalone\n")
|
||||
fmt.Printf("DEBUG: Config path: %s\n", Config.ConfigPath)
|
||||
|
||||
cmd := exec.Command("node", args...)
|
||||
|
||||
// Set working directory to dist-standalone (relative to project root)
|
||||
cmd.Dir = "./dist-standalone"
|
||||
|
||||
// Redirect stdout and stderr to log file
|
||||
cmd.Stdout = logFile
|
||||
cmd.Stderr = logFile
|
||||
|
||||
// Set environment variables (removed CLINE_DIR)
|
||||
env := os.Environ()
|
||||
env = append(env,
|
||||
"GRPC_TRACE=all",
|
||||
"GRPC_VERBOSITY=DEBUG",
|
||||
"NODE_ENV=development",
|
||||
)
|
||||
cmd.Env = env
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
logFile.Close()
|
||||
return nil, fmt.Errorf("failed to start cline-core: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Started cline-core (PID: %d)\n", cmd.Process.Pid)
|
||||
fmt.Printf("Logging cline-core output to: %s\n", logFilePath)
|
||||
return cmd, nil
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package global
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/cline/cli/pkg/common"
|
||||
"github.com/cline/grpc-go/client"
|
||||
)
|
||||
|
||||
type Port uint16
|
||||
|
||||
type GlobalConfig struct {
|
||||
ConfigPath string
|
||||
Verbose bool
|
||||
OutputFormat string
|
||||
CoreAddress string
|
||||
}
|
||||
|
||||
var (
|
||||
Config *GlobalConfig
|
||||
Clients *ClineClients
|
||||
)
|
||||
|
||||
func InitializeGlobalConfig(cfg *GlobalConfig) error {
|
||||
if cfg.ConfigPath == "" {
|
||||
homeDir, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get home directory: %w", err)
|
||||
}
|
||||
cfg.ConfigPath = filepath.Join(homeDir, ".cline")
|
||||
}
|
||||
|
||||
// Ensure .cline directory exists
|
||||
if err := os.MkdirAll(cfg.ConfigPath, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create config directory: %w", err)
|
||||
}
|
||||
|
||||
Config = cfg
|
||||
Clients = NewClineClients(cfg.ConfigPath)
|
||||
|
||||
// Initialize the clients registry
|
||||
ctx := context.Background()
|
||||
if err := Clients.Initialize(ctx); err != nil {
|
||||
return fmt.Errorf("failed to initialize clients: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetDefaultClient returns a client for the default instance or the address override
|
||||
func GetDefaultClient(ctx context.Context) (*client.ClineClient, error) {
|
||||
if Config.CoreAddress != "" && Config.CoreAddress != fmt.Sprintf("localhost:%d", common.DEFAULT_CLINE_CORE_PORT) {
|
||||
// User specified a specific address, use that
|
||||
return Clients.GetRegistry().GetClient(ctx, Config.CoreAddress)
|
||||
}
|
||||
|
||||
// Use the default instance from registry
|
||||
return Clients.GetRegistry().GetDefaultClient(ctx)
|
||||
}
|
||||
|
||||
// GetClientForAddress returns a client for a specific address
|
||||
func GetClientForAddress(ctx context.Context, address string) (*client.ClineClient, error) {
|
||||
return Clients.GetRegistry().GetClient(ctx, address)
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
package global
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/cline/cli/pkg/cli/sqlite"
|
||||
"github.com/cline/cli/pkg/common"
|
||||
"github.com/cline/grpc-go/client"
|
||||
"github.com/cline/grpc-go/cline"
|
||||
"github.com/cline/grpc-go/host"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
"google.golang.org/grpc/health/grpc_health_v1"
|
||||
)
|
||||
|
||||
// ClientRegistry manages Cline client connections using direct SQLite operations
|
||||
type ClientRegistry struct {
|
||||
lockManager *sqlite.LockManager
|
||||
configPath string
|
||||
}
|
||||
|
||||
// NewClientRegistry creates a new client registry
|
||||
func NewClientRegistry(configPath string) *ClientRegistry {
|
||||
lockManager, err := sqlite.NewLockManager(configPath)
|
||||
if err != nil {
|
||||
// Log error but continue - we can still function without SQLite
|
||||
log.Fatalf("Warning: Failed to initialize SQLite lock manager: %v\n", err)
|
||||
}
|
||||
|
||||
return &ClientRegistry{
|
||||
lockManager: lockManager,
|
||||
configPath: configPath,
|
||||
}
|
||||
}
|
||||
|
||||
// GetDefaultInstance returns the default instance address from settings file
|
||||
func (r *ClientRegistry) GetDefaultInstance() string {
|
||||
defaultAddr, err := sqlite.GetDefaultInstance(r.configPath)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return defaultAddr
|
||||
}
|
||||
|
||||
// SetDefaultInstance sets the default instance (writes default.json)
|
||||
func (r *ClientRegistry) SetDefaultInstance(address string) error {
|
||||
// Verify the instance exists in SQLite
|
||||
if r.lockManager != nil {
|
||||
exists, err := r.lockManager.HasInstanceAtAddress(address)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check instance existence: %w", err)
|
||||
}
|
||||
if !exists {
|
||||
return fmt.Errorf("instance %s not found in registry", address)
|
||||
}
|
||||
}
|
||||
|
||||
return sqlite.SetDefaultInstance(r.configPath, address)
|
||||
}
|
||||
|
||||
// GetInstance returns instance information directly from SQLite
|
||||
func (r *ClientRegistry) GetInstance(address string) (*common.CoreInstanceInfo, error) {
|
||||
if r.lockManager == nil {
|
||||
return nil, fmt.Errorf("lock manager not available")
|
||||
}
|
||||
|
||||
return r.lockManager.GetInstanceInfo(address)
|
||||
}
|
||||
|
||||
// GetClient returns a connected client for the given address (created on-demand)
|
||||
func (r *ClientRegistry) GetClient(ctx context.Context, address string) (*client.ClineClient, error) {
|
||||
// Verify instance exists in SQLite
|
||||
if r.lockManager != nil {
|
||||
exists, err := r.lockManager.HasInstanceAtAddress(address)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to check instance existence: %w", err)
|
||||
}
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("instance %s not found", address)
|
||||
}
|
||||
}
|
||||
|
||||
// Create client on-demand (no caching)
|
||||
target, err := common.NormalizeAddressForGRPC(address)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid address %s: %w", address, err)
|
||||
}
|
||||
|
||||
cl, err := client.NewClineClient(target)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create client for %s: %w", target, err)
|
||||
}
|
||||
|
||||
if err := cl.Connect(ctx); err != nil {
|
||||
return nil, fmt.Errorf("failed to connect to %s: %w", target, err)
|
||||
}
|
||||
|
||||
return cl, nil
|
||||
}
|
||||
|
||||
// GetDefaultClient returns a client for the default instance
|
||||
func (r *ClientRegistry) GetDefaultClient(ctx context.Context) (*client.ClineClient, error) {
|
||||
defaultAddr := r.GetDefaultInstance()
|
||||
if defaultAddr == "" {
|
||||
return nil, fmt.Errorf("no default instance configured")
|
||||
}
|
||||
|
||||
return r.GetClient(ctx, defaultAddr)
|
||||
}
|
||||
|
||||
// ListInstances returns all registered instances directly from SQLite
|
||||
func (r *ClientRegistry) ListInstances() []*common.CoreInstanceInfo {
|
||||
if r.lockManager == nil {
|
||||
return []*common.CoreInstanceInfo{}
|
||||
}
|
||||
|
||||
// Use context with timeout for health checks
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
instances, err := r.lockManager.ListInstancesWithHealthCheck(ctx)
|
||||
if err != nil {
|
||||
fmt.Printf("Warning: Failed to list instances: %v\n", err)
|
||||
return []*common.CoreInstanceInfo{}
|
||||
}
|
||||
|
||||
return instances
|
||||
}
|
||||
|
||||
// HasInstanceAtAddress checks if an instance exists at the given address (delegates to SQLite)
|
||||
func (r *ClientRegistry) HasInstanceAtAddress(address string) bool {
|
||||
if r.lockManager == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
exists, err := r.lockManager.HasInstanceAtAddress(address)
|
||||
if err != nil {
|
||||
fmt.Printf("Warning: Failed to check instance existence: %v\n", err)
|
||||
return false
|
||||
}
|
||||
|
||||
return exists
|
||||
}
|
||||
|
||||
// CleanupStaleInstances removes stale instances using direct SQLite operations
|
||||
func (r *ClientRegistry) CleanupStaleInstances(ctx context.Context) error {
|
||||
if r.lockManager == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get all instances with health checks
|
||||
instances, err := r.lockManager.ListInstancesWithHealthCheck(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to list instances for cleanup: %w", err)
|
||||
}
|
||||
|
||||
// Clean up all stale instances
|
||||
for _, instance := range instances {
|
||||
if instance.Status != grpc_health_v1.HealthCheckResponse_SERVING {
|
||||
// Try to gracefully shutdown the paired host process before cleanup
|
||||
|
||||
fmt.Printf("Attempting to shutdown dangling host service %s for stale cline core instance %s\n",
|
||||
instance.HostServiceAddress, instance.Address)
|
||||
r.tryShutdownHostProcess(instance.HostServiceAddress)
|
||||
|
||||
// Remove from SQLite database
|
||||
if err := r.lockManager.RemoveInstanceLock(instance.Address); err != nil {
|
||||
return fmt.Errorf("failed to remove stale instance %s: %w", instance.Address, err)
|
||||
}
|
||||
|
||||
fmt.Printf("Removed stale instance: %s\n", instance.Address)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// tryShutdownHostProcess attempts to gracefully shutdown a host process via RPC
|
||||
// Best effort, don't throw errors i guess
|
||||
func (r *ClientRegistry) tryShutdownHostProcess(hostServiceAddress string) {
|
||||
err := common.RetryOperation(3, 2*time.Second, func() error {
|
||||
// Create context with timeout
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Create gRPC connection to host bridge
|
||||
conn, err := grpc.DialContext(ctx, hostServiceAddress,
|
||||
grpc.WithTransportCredentials(insecure.NewCredentials()),
|
||||
grpc.WithBlock())
|
||||
if err != nil {
|
||||
return fmt.Errorf("connection failed: %w", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
// Create env service client and call shutdown
|
||||
envClient := host.NewEnvServiceClient(conn)
|
||||
_, err = envClient.Shutdown(ctx, &cline.EmptyRequest{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("RPC failed: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
fmt.Printf("Warning: Failed to request host bridge shutdown on port %s: %v\n", hostServiceAddress, err)
|
||||
} else {
|
||||
fmt.Printf("Host bridge shutdown requested successfully on port %s\n", hostServiceAddress)
|
||||
}
|
||||
}
|
||||
|
||||
// ListInstancesCleaned performs cleanup and returns instances with health checks
|
||||
func (r *ClientRegistry) ListInstancesCleaned(ctx context.Context) ([]*common.CoreInstanceInfo, error) {
|
||||
// 1. Clean up stale entries (best-effort)
|
||||
_ = r.CleanupStaleInstances(ctx)
|
||||
|
||||
// 2. Get all instances with real-time health checks
|
||||
instances := r.ListInstances()
|
||||
|
||||
// 3. Ensure default is set if instances exist
|
||||
if err := r.ensureDefaultInstance(instances); err != nil {
|
||||
fmt.Printf("Warning: Failed to ensure default instance: %v\n", err)
|
||||
}
|
||||
|
||||
return instances, nil
|
||||
}
|
||||
|
||||
// ensureDefaultInstance ensures a default instance is set if instances exist but no default is configured
|
||||
func (r *ClientRegistry) ensureDefaultInstance(instances []*common.CoreInstanceInfo) error {
|
||||
currentDefault := r.GetDefaultInstance()
|
||||
|
||||
// If we have no instances, clear any stale default and remove settings file
|
||||
if len(instances) == 0 {
|
||||
if currentDefault != "" {
|
||||
// Remove the settings file since no instances exist
|
||||
settingsPath := filepath.Join(r.configPath, common.SETTINGS_SUBFOLDER, "settings", "cli-default-instance.json")
|
||||
_ = os.Remove(settingsPath)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// If we have instances but no default, pick the first one
|
||||
if currentDefault == "" {
|
||||
return sqlite.SetDefaultInstance(r.configPath, instances[0].Address)
|
||||
}
|
||||
|
||||
// Validate current default still exists in the instances
|
||||
defaultExists := false
|
||||
for _, instance := range instances {
|
||||
if instance.Address == currentDefault {
|
||||
defaultExists = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !defaultExists {
|
||||
// Current default doesn't exist, pick a new one from available instances
|
||||
return sqlite.SetDefaultInstance(r.configPath, instances[0].Address)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,354 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/cline/cli/pkg/cli/types"
|
||||
)
|
||||
|
||||
// AskHandler handles ASK type messages
|
||||
type AskHandler struct {
|
||||
*BaseHandler
|
||||
}
|
||||
|
||||
// NewAskHandler creates a new ASK handler
|
||||
func NewAskHandler() *AskHandler {
|
||||
return &AskHandler{
|
||||
BaseHandler: NewBaseHandler("ask", PriorityHigh),
|
||||
}
|
||||
}
|
||||
|
||||
// CanHandle returns true if this is an ASK message
|
||||
func (h *AskHandler) CanHandle(msg *types.ClineMessage) bool {
|
||||
return msg.IsAsk()
|
||||
}
|
||||
|
||||
// Handle processes ASK messages
|
||||
func (h *AskHandler) Handle(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
timestamp := msg.GetTimestamp()
|
||||
|
||||
switch msg.Ask {
|
||||
case string(types.AskTypeFollowup):
|
||||
return h.handleFollowup(msg, dc, timestamp)
|
||||
case string(types.AskTypePlanModeRespond):
|
||||
return h.handlePlanModeRespond(msg, dc, timestamp)
|
||||
case string(types.AskTypeCommand):
|
||||
return h.handleCommand(msg, dc, timestamp)
|
||||
case string(types.AskTypeCommandOutput):
|
||||
return h.handleCommandOutput(msg, dc, timestamp)
|
||||
case string(types.AskTypeCompletionResult):
|
||||
return h.handleCompletionResult(msg, dc, timestamp)
|
||||
case string(types.AskTypeTool):
|
||||
return h.handleTool(msg, dc, timestamp)
|
||||
case string(types.AskTypeAPIReqFailed):
|
||||
return h.handleAPIReqFailed(msg, dc, timestamp)
|
||||
case string(types.AskTypeResumeTask):
|
||||
return h.handleResumeTask(msg, dc, timestamp)
|
||||
case string(types.AskTypeResumeCompletedTask):
|
||||
return h.handleResumeCompletedTask(msg, dc, timestamp)
|
||||
case string(types.AskTypeMistakeLimitReached):
|
||||
return h.handleMistakeLimitReached(msg, dc, timestamp)
|
||||
case string(types.AskTypeAutoApprovalMaxReached):
|
||||
return h.handleAutoApprovalMaxReached(msg, dc, timestamp)
|
||||
case string(types.AskTypeBrowserActionLaunch):
|
||||
return h.handleBrowserActionLaunch(msg, dc, timestamp)
|
||||
case string(types.AskTypeUseMcpServer):
|
||||
return h.handleUseMcpServer(msg, dc, timestamp)
|
||||
case string(types.AskTypeNewTask):
|
||||
return h.handleNewTask(msg, dc, timestamp)
|
||||
case string(types.AskTypeCondense):
|
||||
return h.handleCondense(msg, dc, timestamp)
|
||||
case string(types.AskTypeReportBug):
|
||||
return h.handleReportBug(msg, dc, timestamp)
|
||||
default:
|
||||
return h.handleDefault(msg, dc, timestamp)
|
||||
}
|
||||
}
|
||||
|
||||
// handleFollowup handles followup questions
|
||||
func (h *AskHandler) handleFollowup(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
var question string
|
||||
var options []string
|
||||
|
||||
var askData types.AskData
|
||||
if err := json.Unmarshal([]byte(msg.Text), &askData); err == nil {
|
||||
question = askData.Question
|
||||
options = askData.Options
|
||||
} else {
|
||||
question = msg.Text
|
||||
}
|
||||
|
||||
if question == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
err := dc.Renderer.RenderMessage(timestamp, "QUESTION", question)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Display options if available
|
||||
if len(options) > 0 {
|
||||
fmt.Println("\nOptions:")
|
||||
for i, option := range options {
|
||||
fmt.Printf("%d. %s\n", i+1, option)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// handlePlanModeRespond handles plan mode responses
|
||||
func (h *AskHandler) handlePlanModeRespond(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
var response string
|
||||
var options []string
|
||||
|
||||
// Try to parse as JSON
|
||||
type PlanModeResponse struct {
|
||||
Response string `json:"response"`
|
||||
Options []string `json:"options,omitempty"`
|
||||
}
|
||||
|
||||
var planData PlanModeResponse
|
||||
if err := json.Unmarshal([]byte(msg.Text), &planData); err == nil {
|
||||
response = planData.Response
|
||||
options = planData.Options
|
||||
} else {
|
||||
response = msg.Text
|
||||
}
|
||||
|
||||
if response == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
err := dc.Renderer.RenderMessage(timestamp, "ASST PLAN", response)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Display options if available
|
||||
if len(options) > 0 {
|
||||
fmt.Println("\nOptions:")
|
||||
for i, option := range options {
|
||||
fmt.Printf("%d. %s\n", i+1, option)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleCommand handles command execution requests
|
||||
func (h *AskHandler) handleCommand(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
if msg.Text == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
command := msg.Text
|
||||
|
||||
// Check if this command was flagged despite auto-approval settings turned on for safe commands
|
||||
hasAutoApprovalConflict := strings.HasSuffix(command, "REQ_APP")
|
||||
if hasAutoApprovalConflict {
|
||||
command = strings.TrimSuffix(command, "REQ_APP")
|
||||
}
|
||||
|
||||
err := dc.Renderer.RenderMessage(timestamp, "TERMINAL", "Cline wants to execute this command:")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to render handleCommand: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("\n```shell\n%s\n```\n", strings.TrimSpace(command))
|
||||
|
||||
if hasAutoApprovalConflict {
|
||||
fmt.Printf("\nThe model has determined this command requires explicit approval.\n")
|
||||
} else {
|
||||
fmt.Printf("\nApproval required for this command.\n")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleCommandOutput handles command output requests
|
||||
func (h *AskHandler) handleCommandOutput(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
if msg.Text == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
commandOutput := msg.Text
|
||||
|
||||
err := dc.Renderer.RenderMessage(timestamp, "TERMINAL", fmt.Sprintf("Current terminal output: %s", commandOutput))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to render handleCommandOutput: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("\nApprove to proceed while this command runs in the background.\n")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleCompletionResult handles completion result requests
|
||||
func (h *AskHandler) handleCompletionResult(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleTool handles tool execution requests
|
||||
func (h *AskHandler) handleTool(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
// Parse tool message
|
||||
var tool types.ToolMessage
|
||||
if err := json.Unmarshal([]byte(msg.Text), &tool); err != nil {
|
||||
// Fallback to simple display
|
||||
return dc.Renderer.RenderMessage(timestamp, "TOOL", msg.Text)
|
||||
}
|
||||
|
||||
return h.renderToolMessage(&tool, dc, timestamp)
|
||||
}
|
||||
|
||||
// renderToolMessage renders a tool message with appropriate formatting
|
||||
func (h *AskHandler) renderToolMessage(tool *types.ToolMessage, dc *DisplayContext, timestamp string) error {
|
||||
switch tool.Tool {
|
||||
case string(types.ToolTypeEditedExistingFile):
|
||||
dc.Renderer.RenderMessage(timestamp, "TOOL", fmt.Sprintf("Cline wants to edit file: %s", tool.Path))
|
||||
case string(types.ToolTypeNewFileCreated):
|
||||
dc.Renderer.RenderMessage(timestamp, "TOOL", fmt.Sprintf("Cline wants to create file: %s", tool.Path))
|
||||
case string(types.ToolTypeReadFile):
|
||||
dc.Renderer.RenderMessage(timestamp, "TOOL", fmt.Sprintf("Cline wants to read file: %s", tool.Path))
|
||||
case string(types.ToolTypeListFilesTopLevel):
|
||||
dc.Renderer.RenderMessage(timestamp, "TOOL", fmt.Sprintf("Cline wants to list files in: %s", tool.Path))
|
||||
case string(types.ToolTypeListFilesRecursive):
|
||||
dc.Renderer.RenderMessage(timestamp, "TOOL", fmt.Sprintf("Cline wants to recursively list files in: %s", tool.Path))
|
||||
case string(types.ToolTypeSearchFiles):
|
||||
dc.Renderer.RenderMessage(timestamp, "TOOL", fmt.Sprintf("Cline wants to search for '%s' in: %s", tool.Regex, tool.Path))
|
||||
case string(types.ToolTypeWebFetch):
|
||||
dc.Renderer.RenderMessage(timestamp, "TOOL", fmt.Sprintf("Cline wants to fetch URL: %s", tool.Path))
|
||||
case string(types.ToolTypeListCodeDefinitionNames):
|
||||
dc.Renderer.RenderMessage(timestamp, "TOOL", fmt.Sprintf("Cline wants to list code definitions for: %s", tool.Path))
|
||||
default:
|
||||
dc.Renderer.RenderMessage(timestamp, "TOOL", fmt.Sprintf("Cline wants to use tool: %s", tool.Tool))
|
||||
}
|
||||
|
||||
// Skip content preview for readFile and webFetch tools
|
||||
if tool.Tool == string(types.ToolTypeReadFile) || tool.Tool == string(types.ToolTypeWebFetch) {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Show content preview, truncating if necessary
|
||||
preview := tool.Content
|
||||
if preview != "" {
|
||||
preview = strings.TrimSpace(tool.Content)
|
||||
if len(preview) > 1000 {
|
||||
preview = preview[:1000] + "..."
|
||||
}
|
||||
|
||||
fmt.Printf("Preview: %s\n", preview)
|
||||
}
|
||||
|
||||
fmt.Printf("\nApproval required.\n")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleAPIReqFailed handles API request failures
|
||||
func (h *AskHandler) handleAPIReqFailed(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
return dc.Renderer.RenderMessage(timestamp, "ERROR", fmt.Sprintf("API Request Failed: %s. Approve to retry request.", msg.Text))
|
||||
}
|
||||
|
||||
// handleResumeTask handles resume task requests
|
||||
func (h *AskHandler) handleResumeTask(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
return dc.Renderer.RenderMessage(timestamp, "GEN INFO", "Resuming interrupted task.")
|
||||
}
|
||||
|
||||
// handleResumeCompletedTask handles resume completed task requests
|
||||
func (h *AskHandler) handleResumeCompletedTask(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
return dc.Renderer.RenderMessage(timestamp, "GEN INFO", "Resuming completed task.")
|
||||
}
|
||||
|
||||
// handleMistakeLimitReached handles mistake limit reached
|
||||
func (h *AskHandler) handleMistakeLimitReached(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
return dc.Renderer.RenderMessage(timestamp, "ERROR", fmt.Sprintf("Mistake Limit Reached: %s. Approval required.", msg.Text))
|
||||
}
|
||||
|
||||
// handleAutoApprovalMaxReached handles auto-approval max reached
|
||||
func (h *AskHandler) handleAutoApprovalMaxReached(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
return dc.Renderer.RenderMessage(timestamp, "WARNING", fmt.Sprintf("Auto-approval limit reached: %s. Approval required.", msg.Text))
|
||||
}
|
||||
|
||||
// handleBrowserActionLaunch handles browser action launch requests
|
||||
func (h *AskHandler) handleBrowserActionLaunch(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
url := strings.TrimSpace(msg.Text)
|
||||
return dc.Renderer.RenderMessage(timestamp, "BROWSER", fmt.Sprintf("Cline wants to launch browser and navigate to: %s. Approval required.", url))
|
||||
}
|
||||
|
||||
// handleUseMcpServer handles MCP server usage requests
|
||||
func (h *AskHandler) handleUseMcpServer(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
// Parse MCP server usage request
|
||||
type McpServerRequest struct {
|
||||
ServerName string `json:"serverName"`
|
||||
Type string `json:"type"`
|
||||
ToolName string `json:"toolName,omitempty"`
|
||||
Arguments string `json:"arguments,omitempty"`
|
||||
URI string `json:"uri,omitempty"`
|
||||
}
|
||||
|
||||
var mcpReq McpServerRequest
|
||||
if err := json.Unmarshal([]byte(msg.Text), &mcpReq); err != nil {
|
||||
return dc.Renderer.RenderMessage(timestamp, "MCP", msg.Text)
|
||||
}
|
||||
|
||||
var operation string
|
||||
if mcpReq.Type == "access_mcp_resource" {
|
||||
operation = "access a resource"
|
||||
} else {
|
||||
operation = fmt.Sprintf("use a tool (%s)", mcpReq.ToolName)
|
||||
if mcpReq.Arguments != "" {
|
||||
operation = fmt.Sprintf("%s with args (%s)", operation, mcpReq.Arguments)
|
||||
}
|
||||
}
|
||||
|
||||
return dc.Renderer.RenderMessage(timestamp, "MCP",
|
||||
fmt.Sprintf("Cline wants to %s on the %s MCP server", operation, mcpReq.ServerName))
|
||||
}
|
||||
|
||||
// handleNewTask handles new task creation requests
|
||||
func (h *AskHandler) handleNewTask(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
return dc.Renderer.RenderMessage(timestamp, "NEW TASK", fmt.Sprintf("Cline wants to start a new task: %s. Approval required.", msg.Text))
|
||||
}
|
||||
|
||||
// handleCondense handles conversation condensing requests
|
||||
func (h *AskHandler) handleCondense(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
return dc.Renderer.RenderMessage(timestamp, "CONDENSE", fmt.Sprintf("Cline wants to condense the conversation: %s. Approval required.", msg.Text))
|
||||
}
|
||||
|
||||
// handleReportBug handles bug report requests
|
||||
func (h *AskHandler) handleReportBug(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
var bugData struct {
|
||||
Title string `json:"title"`
|
||||
WhatHappened string `json:"what_happened"`
|
||||
StepsToReproduce string `json:"steps_to_reproduce"`
|
||||
APIRequestOutput string `json:"api_request_output"`
|
||||
AdditionalContext string `json:"additional_context"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal([]byte(msg.Text), &bugData); err != nil {
|
||||
return dc.Renderer.RenderMessage(timestamp, "BUG REPORT", fmt.Sprintf("Cline wants to create a GitHub issue: %s. Approval required.", msg.Text))
|
||||
}
|
||||
|
||||
err := dc.Renderer.RenderMessage(timestamp, "BUG REPORT", "Cline wants to create a GitHub issue:")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to render handleReportBug: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("\n**Title**: %s\n", bugData.Title)
|
||||
fmt.Printf("**What Happened**: %s\n", bugData.WhatHappened)
|
||||
fmt.Printf("**Steps to Reproduce**: %s\n", bugData.StepsToReproduce)
|
||||
fmt.Printf("**API Request Output**: %s\n", bugData.APIRequestOutput)
|
||||
fmt.Printf("**Additional Context**: %s\n", bugData.AdditionalContext)
|
||||
fmt.Printf("\nApprove to create a GitHub issue.\n")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleDefault handles unknown ASK message types
|
||||
func (h *AskHandler) handleDefault(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
return dc.Renderer.RenderMessage(timestamp, "ASK", msg.Text)
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"github.com/cline/cli/pkg/cli/display"
|
||||
"github.com/cline/cli/pkg/cli/types"
|
||||
)
|
||||
|
||||
// MessageHandler defines the interface for handling different message types
|
||||
type MessageHandler interface {
|
||||
// CanHandle returns true if this handler can process the given message
|
||||
CanHandle(msg *types.ClineMessage) bool
|
||||
|
||||
// Handle processes the message and renders it using the display context
|
||||
Handle(msg *types.ClineMessage, dc *DisplayContext) error
|
||||
|
||||
// GetPriority returns the priority of this handler (higher = more priority)
|
||||
GetPriority() int
|
||||
|
||||
// GetName returns a human-readable name for this handler
|
||||
GetName() string
|
||||
}
|
||||
|
||||
// DisplayContext provides context and utilities for message handlers
|
||||
type DisplayContext struct {
|
||||
State *types.ConversationState
|
||||
Renderer *display.Renderer
|
||||
IsLast bool
|
||||
IsPartial bool
|
||||
Verbose bool
|
||||
MessageIndex int
|
||||
}
|
||||
|
||||
// BaseHandler provides common functionality for message handlers
|
||||
type BaseHandler struct {
|
||||
name string
|
||||
priority int
|
||||
}
|
||||
|
||||
// NewBaseHandler creates a new base handler
|
||||
func NewBaseHandler(name string, priority int) *BaseHandler {
|
||||
return &BaseHandler{
|
||||
name: name,
|
||||
priority: priority,
|
||||
}
|
||||
}
|
||||
|
||||
// GetName returns the handler name
|
||||
func (h *BaseHandler) GetName() string {
|
||||
return h.name
|
||||
}
|
||||
|
||||
// GetPriority returns the handler priority
|
||||
func (h *BaseHandler) GetPriority() int {
|
||||
return h.priority
|
||||
}
|
||||
|
||||
// HandlerRegistry manages a collection of message handlers
|
||||
type HandlerRegistry struct {
|
||||
handlers []MessageHandler
|
||||
}
|
||||
|
||||
// NewHandlerRegistry creates a new handler registry
|
||||
func NewHandlerRegistry() *HandlerRegistry {
|
||||
return &HandlerRegistry{
|
||||
handlers: make([]MessageHandler, 0),
|
||||
}
|
||||
}
|
||||
|
||||
// Register adds a handler to the registry
|
||||
func (r *HandlerRegistry) Register(handler MessageHandler) {
|
||||
r.handlers = append(r.handlers, handler)
|
||||
|
||||
// Sort handlers by priority (highest first)
|
||||
for i := len(r.handlers) - 1; i > 0; i-- {
|
||||
if r.handlers[i].GetPriority() > r.handlers[i-1].GetPriority() {
|
||||
r.handlers[i], r.handlers[i-1] = r.handlers[i-1], r.handlers[i]
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle finds the appropriate handler and processes the message
|
||||
func (r *HandlerRegistry) Handle(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
for _, handler := range r.handlers {
|
||||
if handler.CanHandle(msg) {
|
||||
return handler.Handle(msg, dc)
|
||||
}
|
||||
}
|
||||
|
||||
// If no specific handler found, use default text handler
|
||||
return r.handleDefault(msg, dc)
|
||||
}
|
||||
|
||||
// handleDefault provides default handling for unrecognized messages
|
||||
func (r *HandlerRegistry) handleDefault(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
timestamp := msg.GetTimestamp()
|
||||
|
||||
if msg.Text == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
prefix := "RESPONSE:"
|
||||
|
||||
return dc.Renderer.RenderMessage(timestamp, prefix, msg.Text)
|
||||
}
|
||||
|
||||
// GetHandlers returns all registered handlers
|
||||
func (r *HandlerRegistry) GetHandlers() []MessageHandler {
|
||||
return r.handlers
|
||||
}
|
||||
|
||||
// GetHandlerByName finds a handler by name
|
||||
func (r *HandlerRegistry) GetHandlerByName(name string) MessageHandler {
|
||||
for _, handler := range r.handlers {
|
||||
if handler.GetName() == name {
|
||||
return handler
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// HandlerPriorities defines standard priority levels for handlers
|
||||
const (
|
||||
PriorityHigh = 100
|
||||
PriorityNormal = 50
|
||||
PriorityLow = 10
|
||||
)
|
||||
@@ -0,0 +1,418 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/cline/cli/pkg/cli/types"
|
||||
)
|
||||
|
||||
// SayHandler handles SAY type messages
|
||||
type SayHandler struct {
|
||||
*BaseHandler
|
||||
}
|
||||
|
||||
// NewSayHandler creates a new SAY handler
|
||||
func NewSayHandler() *SayHandler {
|
||||
return &SayHandler{
|
||||
BaseHandler: NewBaseHandler("say", PriorityNormal),
|
||||
}
|
||||
}
|
||||
|
||||
// CanHandle returns true if this is a SAY message
|
||||
func (h *SayHandler) CanHandle(msg *types.ClineMessage) bool {
|
||||
return msg.IsSay()
|
||||
}
|
||||
|
||||
// Handle processes SAY messages
|
||||
func (h *SayHandler) Handle(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
timestamp := msg.GetTimestamp()
|
||||
|
||||
switch msg.Say {
|
||||
case string(types.SayTypeTask):
|
||||
return h.handleTask(msg, dc, timestamp)
|
||||
case string(types.SayTypeError):
|
||||
return h.handleError(msg, dc, timestamp)
|
||||
case string(types.SayTypeAPIReqStarted):
|
||||
return h.handleAPIReqStarted(msg, dc, timestamp)
|
||||
case string(types.SayTypeAPIReqFinished):
|
||||
return h.handleAPIReqFinished(msg, dc, timestamp)
|
||||
case string(types.SayTypeText):
|
||||
return h.handleText(msg, dc, timestamp)
|
||||
case string(types.SayTypeReasoning):
|
||||
return h.handleReasoning(msg, dc, timestamp)
|
||||
case string(types.SayTypeCompletionResult):
|
||||
return h.handleCompletionResult(msg, dc, timestamp)
|
||||
case string(types.SayTypeUserFeedback):
|
||||
return h.handleUserFeedback(msg, dc, timestamp)
|
||||
case string(types.SayTypeUserFeedbackDiff):
|
||||
return h.handleUserFeedbackDiff(msg, dc, timestamp)
|
||||
case string(types.SayTypeAPIReqRetried):
|
||||
return h.handleAPIReqRetried(msg, dc, timestamp)
|
||||
case string(types.SayTypeCommand):
|
||||
return h.handleCommand(msg, dc, timestamp)
|
||||
case string(types.SayTypeCommandOutput):
|
||||
return h.handleCommandOutput(msg, dc, timestamp)
|
||||
case string(types.SayTypeTool):
|
||||
return h.handleTool(msg, dc, timestamp)
|
||||
case string(types.SayTypeShellIntegrationWarning):
|
||||
return h.handleShellIntegrationWarning(msg, dc, timestamp)
|
||||
case string(types.SayTypeBrowserActionLaunch):
|
||||
return h.handleBrowserActionLaunch(msg, dc, timestamp)
|
||||
case string(types.SayTypeBrowserAction):
|
||||
return h.handleBrowserAction(msg, dc, timestamp)
|
||||
case string(types.SayTypeBrowserActionResult):
|
||||
return h.handleBrowserActionResult(msg, dc, timestamp)
|
||||
case string(types.SayTypeMcpServerRequestStarted):
|
||||
return h.handleMcpServerRequestStarted(msg, dc, timestamp)
|
||||
case string(types.SayTypeMcpServerResponse):
|
||||
return h.handleMcpServerResponse(msg, dc, timestamp)
|
||||
case string(types.SayTypeMcpNotification):
|
||||
return h.handleMcpNotification(msg, dc, timestamp)
|
||||
case string(types.SayTypeUseMcpServer):
|
||||
return h.handleUseMcpServer(msg, dc, timestamp)
|
||||
case string(types.SayTypeDiffError):
|
||||
return h.handleDiffError(msg, dc, timestamp)
|
||||
case string(types.SayTypeDeletedAPIReqs):
|
||||
return h.handleDeletedAPIReqs(msg, dc, timestamp)
|
||||
case string(types.SayTypeClineignoreError):
|
||||
return h.handleClineignoreError(msg, dc, timestamp)
|
||||
case string(types.SayTypeCheckpointCreated):
|
||||
return h.handleCheckpointCreated(msg, dc, timestamp)
|
||||
case string(types.SayTypeLoadMcpDocumentation):
|
||||
return h.handleLoadMcpDocumentation(msg, dc, timestamp)
|
||||
case string(types.SayTypeInfo):
|
||||
return h.handleInfo(msg, dc, timestamp)
|
||||
case string(types.SayTypeTaskProgress):
|
||||
return h.handleTaskProgress(msg, dc, timestamp)
|
||||
default:
|
||||
return h.handleDefault(msg, dc, timestamp)
|
||||
}
|
||||
}
|
||||
|
||||
// handleTask handles task messages
|
||||
func (h *SayHandler) handleTask(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleError handles error messages
|
||||
func (h *SayHandler) handleError(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
return dc.Renderer.RenderMessage(timestamp, "ERROR", msg.Text)
|
||||
}
|
||||
|
||||
// handleAPIReqStarted handles API request started messages
|
||||
func (h *SayHandler) handleAPIReqStarted(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
// Parse API request info
|
||||
apiInfo := types.APIRequestInfo{Cost: -1}
|
||||
if err := json.Unmarshal([]byte(msg.Text), &apiInfo); err != nil {
|
||||
return dc.Renderer.RenderMessage(timestamp, "API INFO", msg.Text)
|
||||
}
|
||||
|
||||
// Handle different API request states
|
||||
if apiInfo.CancelReason != "" {
|
||||
if apiInfo.CancelReason == "user_cancelled" {
|
||||
return dc.Renderer.RenderMessage(timestamp, "API INFO", "Request Cancelled")
|
||||
} else if apiInfo.CancelReason == "retries_exhausted" {
|
||||
return dc.Renderer.RenderMessage(timestamp, "API INFO", "Request Failed (Retries Exhausted)")
|
||||
}
|
||||
return dc.Renderer.RenderMessage(timestamp, "API INFO", "Streaming Failed")
|
||||
}
|
||||
|
||||
if apiInfo.Cost >= 0 {
|
||||
return dc.Renderer.RenderAPI(timestamp, "Request completed", &apiInfo)
|
||||
}
|
||||
|
||||
// Check for retry status
|
||||
if apiInfo.RetryStatus != nil {
|
||||
return dc.Renderer.RenderRetry(timestamp,
|
||||
apiInfo.RetryStatus.Attempt,
|
||||
apiInfo.RetryStatus.MaxAttempts,
|
||||
apiInfo.RetryStatus.DelaySec)
|
||||
}
|
||||
|
||||
return dc.Renderer.RenderAPI(timestamp, "Processing request", &apiInfo)
|
||||
}
|
||||
|
||||
// handleAPIReqFinished handles API request finished messages
|
||||
func (h *SayHandler) handleAPIReqFinished(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
// This message type is typically not displayed as it's handled by the started message
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleText handles regular text messages
|
||||
func (h *SayHandler) handleText(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
if msg.Text == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Special case for the user's task input
|
||||
prefix := "ASST TEXT"
|
||||
if dc.MessageIndex == 0 {
|
||||
prefix = "USER"
|
||||
}
|
||||
|
||||
return dc.Renderer.RenderMessage(timestamp, prefix, msg.Text)
|
||||
}
|
||||
|
||||
// handleReasoning handles reasoning messages
|
||||
func (h *SayHandler) handleReasoning(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
if msg.Text == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
return dc.Renderer.RenderMessage(timestamp, "THINKING", msg.Text)
|
||||
}
|
||||
|
||||
func (h *SayHandler) handleCompletionResult(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
text := msg.Text
|
||||
|
||||
if strings.HasSuffix(text, "HAS_CHANGES") {
|
||||
text = strings.TrimSuffix(text, "HAS_CHANGES")
|
||||
}
|
||||
|
||||
return dc.Renderer.RenderMessage(timestamp, "RESULT", text)
|
||||
}
|
||||
|
||||
// handleUserFeedback handles user feedback messages
|
||||
func (h *SayHandler) handleUserFeedback(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
if msg.Text != "" {
|
||||
return dc.Renderer.RenderMessage(timestamp, "USER", msg.Text)
|
||||
} else {
|
||||
return dc.Renderer.RenderMessage(timestamp, "USER", "[Provided feedback without text]")
|
||||
}
|
||||
}
|
||||
|
||||
// handleUserFeedbackDiff handles user feedback diff messages
|
||||
func (h *SayHandler) handleUserFeedbackDiff(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
var toolMsg types.ToolMessage
|
||||
if err := json.Unmarshal([]byte(msg.Text), &toolMsg); err != nil {
|
||||
return dc.Renderer.RenderMessage(timestamp, "USER DIFF", msg.Text)
|
||||
}
|
||||
|
||||
message := fmt.Sprintf("User manually edited: %s\n\nDiff:\n%s",
|
||||
toolMsg.Path,
|
||||
toolMsg.Diff)
|
||||
|
||||
return dc.Renderer.RenderMessage(timestamp, "USER DIFF", message)
|
||||
}
|
||||
|
||||
// handleAPIReqRetried handles API request retry messages
|
||||
func (h *SayHandler) handleAPIReqRetried(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
return dc.Renderer.RenderMessage(timestamp, "API INFO", "Retrying request")
|
||||
}
|
||||
|
||||
// handleCommand handles command execution announcements
|
||||
func (h *SayHandler) handleCommand(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
if msg.Text == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
command := strings.TrimSpace(msg.Text)
|
||||
|
||||
err := dc.Renderer.RenderMessage(timestamp, "TERMINAL", "Running command:")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to render handleCommand: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("\n```shell\n%s\n```\n", command)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleCommandOutput handles command output messages
|
||||
func (h *SayHandler) handleCommandOutput(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
commandOutput := msg.Text
|
||||
return dc.Renderer.RenderMessage(timestamp, "TERMINAL", fmt.Sprintf("Current terminal output: %s", commandOutput))
|
||||
}
|
||||
|
||||
func (h *SayHandler) handleTool(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
var tool types.ToolMessage
|
||||
if err := json.Unmarshal([]byte(msg.Text), &tool); err != nil {
|
||||
return dc.Renderer.RenderMessage(timestamp, "TOOL", msg.Text)
|
||||
}
|
||||
|
||||
return h.renderToolMessage(&tool, dc, timestamp)
|
||||
}
|
||||
|
||||
func (h *SayHandler) renderToolMessage(tool *types.ToolMessage, dc *DisplayContext, timestamp string) error {
|
||||
switch tool.Tool {
|
||||
case string(types.ToolTypeEditedExistingFile):
|
||||
dc.Renderer.RenderMessage(timestamp, "TOOL", fmt.Sprintf("Cline edited file: %s", tool.Path))
|
||||
case string(types.ToolTypeNewFileCreated):
|
||||
dc.Renderer.RenderMessage(timestamp, "TOOL", fmt.Sprintf("Cline created file: %s", tool.Path))
|
||||
case string(types.ToolTypeReadFile):
|
||||
dc.Renderer.RenderMessage(timestamp, "TOOL", fmt.Sprintf("Cline read file: %s", tool.Path))
|
||||
case string(types.ToolTypeListFilesTopLevel):
|
||||
dc.Renderer.RenderMessage(timestamp, "TOOL", fmt.Sprintf("Cline listed files in: %s", tool.Path))
|
||||
case string(types.ToolTypeListFilesRecursive):
|
||||
dc.Renderer.RenderMessage(timestamp, "TOOL", fmt.Sprintf("Cline recursively listed files in: %s", tool.Path))
|
||||
case string(types.ToolTypeSearchFiles):
|
||||
dc.Renderer.RenderMessage(timestamp, "TOOL", fmt.Sprintf("Cline searched for '%s' in: %s", tool.Regex, tool.Path))
|
||||
case string(types.ToolTypeWebFetch):
|
||||
dc.Renderer.RenderMessage(timestamp, "TOOL", fmt.Sprintf("Cline fetched URL: %s", tool.Path))
|
||||
case string(types.ToolTypeListCodeDefinitionNames):
|
||||
dc.Renderer.RenderMessage(timestamp, "TOOL", fmt.Sprintf("Cline listed code definitions for: %s", tool.Path))
|
||||
case string(types.ToolTypeSummarizeTask):
|
||||
dc.Renderer.RenderMessage(timestamp, "TOOL", "Cline condensed the conversation")
|
||||
default:
|
||||
dc.Renderer.RenderMessage(timestamp, "TOOL", fmt.Sprintf("Cline executed tool: %s", tool.Tool))
|
||||
}
|
||||
|
||||
// Skip content preview for readFile and webFetch tools
|
||||
if tool.Tool == string(types.ToolTypeReadFile) || tool.Tool == string(types.ToolTypeWebFetch) {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Show content preview, truncating if necessary
|
||||
preview := tool.Content
|
||||
if preview != "" {
|
||||
preview = strings.TrimSpace(tool.Content)
|
||||
if len(preview) > 1000 {
|
||||
preview = preview[:1000] + "..."
|
||||
}
|
||||
fmt.Printf("Content: %s\n", preview)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleShellIntegrationWarning handles shell integration warning messages
|
||||
func (h *SayHandler) handleShellIntegrationWarning(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
return dc.Renderer.RenderMessage(timestamp, "WARNING", "Shell Integration Unavailable - Cline won't be able to view the command's output.")
|
||||
}
|
||||
|
||||
// handleBrowserActionLaunch handles browser action launch messages
|
||||
func (h *SayHandler) handleBrowserActionLaunch(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
url := msg.Text
|
||||
if url == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
return dc.Renderer.RenderMessage(timestamp, "BROWSER", fmt.Sprintf("Launching browser at: %s", url))
|
||||
}
|
||||
|
||||
// handleBrowserAction handles browser action messages
|
||||
func (h *SayHandler) handleBrowserAction(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
if msg.Text == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
type BrowserActionData struct {
|
||||
Action string `json:"action"`
|
||||
Coordinate string `json:"coordinate,omitempty"`
|
||||
Text string `json:"text,omitempty"`
|
||||
}
|
||||
|
||||
var actionData BrowserActionData
|
||||
if err := json.Unmarshal([]byte(msg.Text), &actionData); err != nil {
|
||||
return dc.Renderer.RenderMessage(timestamp, "BROWSER", msg.Text)
|
||||
}
|
||||
|
||||
// Special handling for type action
|
||||
if actionData.Action == "type" && actionData.Text != "" {
|
||||
actionText := fmt.Sprintf("type '%s'", actionData.Text)
|
||||
return dc.Renderer.RenderMessage(timestamp, "BROWSER", fmt.Sprintf("Next action: %s", actionText))
|
||||
}
|
||||
|
||||
// Special handling for click action
|
||||
if actionData.Action == "click" && actionData.Coordinate != "" {
|
||||
actionText := fmt.Sprintf("click (%s)", actionData.Coordinate)
|
||||
return dc.Renderer.RenderMessage(timestamp, "BROWSER", fmt.Sprintf("Next action: %s", actionText))
|
||||
}
|
||||
|
||||
// Generic handling for all other actions
|
||||
return dc.Renderer.RenderMessage(timestamp, "BROWSER", fmt.Sprintf("Next action: %s", actionData.Action))
|
||||
}
|
||||
|
||||
// handleBrowserActionResult handles browser action result messages
|
||||
func (h *SayHandler) handleBrowserActionResult(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
if msg.Text == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
type BrowserActionResult struct {
|
||||
Screenshot string `json:"screenshot,omitempty"`
|
||||
Logs string `json:"logs,omitempty"`
|
||||
CurrentUrl string `json:"currentUrl,omitempty"`
|
||||
CurrentMousePosition string `json:"currentMousePosition,omitempty"`
|
||||
}
|
||||
|
||||
var result BrowserActionResult
|
||||
if err := json.Unmarshal([]byte(msg.Text), &result); err != nil {
|
||||
return dc.Renderer.RenderMessage(timestamp, "BROWSER", "Action completed")
|
||||
}
|
||||
|
||||
// If we have logs, include them in the message
|
||||
if result.Logs != "" {
|
||||
return dc.Renderer.RenderMessage(timestamp, "BROWSER", fmt.Sprintf("Action completed with logs: '%s'", result.Logs))
|
||||
}
|
||||
|
||||
// Default case
|
||||
return dc.Renderer.RenderMessage(timestamp, "BROWSER", "Action completed")
|
||||
}
|
||||
|
||||
// handleMcpServerRequestStarted handles MCP server request started messages
|
||||
func (h *SayHandler) handleMcpServerRequestStarted(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
return dc.Renderer.RenderMessage(timestamp, "MCP", "Sending request to server")
|
||||
}
|
||||
|
||||
// handleMcpServerResponse handles MCP server response messages
|
||||
func (h *SayHandler) handleMcpServerResponse(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
return dc.Renderer.RenderMessage(timestamp, "MCP", fmt.Sprintf("Server response: %s", msg.Text))
|
||||
}
|
||||
|
||||
// handleMcpNotification handles MCP notification messages
|
||||
func (h *SayHandler) handleMcpNotification(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
return dc.Renderer.RenderMessage(timestamp, "MCP", fmt.Sprintf("Server notification: %s", msg.Text))
|
||||
}
|
||||
|
||||
// handleUseMcpServer handles MCP server usage messages
|
||||
func (h *SayHandler) handleUseMcpServer(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
return dc.Renderer.RenderMessage(timestamp, "MCP", "Server operation approved")
|
||||
}
|
||||
|
||||
// handleDiffError handles diff error messages
|
||||
func (h *SayHandler) handleDiffError(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
return dc.Renderer.RenderMessage(timestamp, "WARNING", "Diff Edit Failure - The model used an invalid diff edit format or used search patterns that don't match anything in the file.")
|
||||
}
|
||||
|
||||
// handleDeletedAPIReqs handles deleted API requests messages
|
||||
func (h *SayHandler) handleDeletedAPIReqs(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
// This message includes api metrics of deleted messages, which we do not log
|
||||
return dc.Renderer.RenderMessage(timestamp, "GEN INFO", "Checkpoint restored")
|
||||
}
|
||||
|
||||
// handleClineignoreError handles .clineignore error messages
|
||||
func (h *SayHandler) handleClineignoreError(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
return dc.Renderer.RenderMessage(timestamp, "WARNING", fmt.Sprintf("Access Denied - Cline tried to access %s which is blocked by the .clineignore file", msg.Text))
|
||||
}
|
||||
|
||||
// handleCheckpointCreated handles checkpoint created messages
|
||||
func (h *SayHandler) handleCheckpointCreated(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
return dc.Renderer.RenderMessage(timestamp, "GEN INFO", "Checkpoint created")
|
||||
}
|
||||
|
||||
// handleLoadMcpDocumentation handles load MCP documentation messages
|
||||
func (h *SayHandler) handleLoadMcpDocumentation(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
return dc.Renderer.RenderMessage(timestamp, "GEN INFO", "Loading MCP documentation")
|
||||
}
|
||||
|
||||
// handleInfo handles info messages
|
||||
func (h *SayHandler) handleInfo(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleTaskProgress handles task progress messages
|
||||
func (h *SayHandler) handleTaskProgress(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
if msg.Text == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
return dc.Renderer.RenderMessage(timestamp, "PROGRESS", fmt.Sprintf("Task Checklist: %s", msg.Text))
|
||||
}
|
||||
|
||||
// handleDefault handles unknown SAY message types
|
||||
func (h *SayHandler) handleDefault(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
return dc.Renderer.RenderMessage(timestamp, "SAY", msg.Text)
|
||||
}
|
||||
@@ -0,0 +1,388 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"syscall"
|
||||
"text/tabwriter"
|
||||
"time"
|
||||
|
||||
"github.com/cline/cli/pkg/cli/global"
|
||||
"github.com/cline/grpc-go/cline"
|
||||
"github.com/spf13/cobra"
|
||||
"google.golang.org/grpc/health/grpc_health_v1"
|
||||
)
|
||||
|
||||
func NewInstanceCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "instance",
|
||||
Aliases: []string{"i"},
|
||||
Short: "Manage Cline instances",
|
||||
Long: `List and manage multiple Cline instances similar to kubectl contexts.`,
|
||||
}
|
||||
|
||||
cmd.AddCommand(newInstanceListCommand())
|
||||
cmd.AddCommand(newInstanceUseCommand())
|
||||
cmd.AddCommand(newInstanceNewCommand())
|
||||
cmd.AddCommand(newInstanceKillCommand())
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newInstanceKillCommand() *cobra.Command {
|
||||
var killAll bool
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "kill <address>",
|
||||
Aliases: []string{"k"},
|
||||
Short: "Kill a Cline instance by address",
|
||||
Long: `Kill a running Cline instance and clean up its registry entry.`,
|
||||
Args: func(cmd *cobra.Command, args []string) error {
|
||||
if killAll && len(args) > 0 {
|
||||
return fmt.Errorf("cannot specify both --all flag and address argument")
|
||||
}
|
||||
if !killAll && len(args) != 1 {
|
||||
return fmt.Errorf("requires exactly one address argument when --all is not specified")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if global.Clients == nil {
|
||||
return fmt.Errorf("clients not initialized")
|
||||
}
|
||||
|
||||
ctx := cmd.Context()
|
||||
registry := global.Clients.GetRegistry()
|
||||
|
||||
if killAll {
|
||||
return killAllInstances(ctx, registry)
|
||||
} else {
|
||||
return killSingleInstance(ctx, registry, args[0])
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().BoolVar(&killAll, "all", false, "kill all running instances")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func killSingleInstance(ctx context.Context, registry *global.ClientRegistry, address string) error {
|
||||
// Check if the instance exists in the registry
|
||||
_, err := registry.GetInstance(address)
|
||||
if err != nil {
|
||||
return fmt.Errorf("instance %s not found in registry", address)
|
||||
}
|
||||
|
||||
fmt.Printf("Killing instance: %s\n", address)
|
||||
|
||||
// Get gRPC client and process info
|
||||
client, err := registry.GetClient(ctx, address)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to connect to instance %s: %w", address, err)
|
||||
}
|
||||
|
||||
processInfo, err := client.State.GetProcessInfo(ctx, &cline.EmptyRequest{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get process info for instance %s: %w", address, err)
|
||||
}
|
||||
|
||||
pid := int(processInfo.ProcessId)
|
||||
fmt.Printf("Terminating process PID %d...\n", pid)
|
||||
|
||||
// Kill the process
|
||||
if err := syscall.Kill(pid, syscall.SIGTERM); err != nil {
|
||||
return fmt.Errorf("failed to kill process %d: %w", pid, err)
|
||||
}
|
||||
|
||||
// Wait for the instance to remove itself from registry
|
||||
fmt.Printf("Waiting for instance to clean up registry entry...\n")
|
||||
for i := 0; i < 5; i++ {
|
||||
time.Sleep(1 * time.Second)
|
||||
if !registry.HasInstanceAtAddress(address) {
|
||||
fmt.Printf("Instance %s successfully killed and removed from registry.\n", address)
|
||||
|
||||
// Update default instance if needed
|
||||
instances, err := registry.ListInstancesCleaned(ctx)
|
||||
if err == nil && len(instances) > 0 {
|
||||
// ensureDefaultInstance logic will handle setting a new default
|
||||
defaultInstance := registry.GetDefaultInstance()
|
||||
if defaultInstance == address || defaultInstance == "" {
|
||||
if len(instances) > 0 {
|
||||
if err := registry.SetDefaultInstance(instances[0].Address); err == nil {
|
||||
fmt.Printf("Updated default instance to: %s\n", instances[0].Address)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
return fmt.Errorf("instance killed but failed to remove itself from registry within 5 seconds")
|
||||
}
|
||||
|
||||
func killAllInstances(ctx context.Context, registry *global.ClientRegistry) error {
|
||||
// Get all instances from registry
|
||||
instances, err := registry.ListInstancesCleaned(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to list instances: %w", err)
|
||||
}
|
||||
|
||||
if len(instances) == 0 {
|
||||
fmt.Println("No Cline instances found to kill.")
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Printf("Killing %d instances...\n", len(instances))
|
||||
|
||||
var killResults []killResult
|
||||
|
||||
// Kill all instances
|
||||
for _, instance := range instances {
|
||||
result := killInstanceProcess(ctx, registry, instance.Address)
|
||||
killResults = append(killResults, result)
|
||||
|
||||
if result.err != nil {
|
||||
fmt.Printf("✗ Failed to kill %s: %v\n", instance.Address, result.err)
|
||||
} else if result.alreadyDead {
|
||||
fmt.Printf("⚠ Instance %s appears to be already dead\n", instance.Address)
|
||||
} else {
|
||||
fmt.Printf("✓ Killed %s (PID %d)\n", instance.Address, result.pid)
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for all instances to clean up their registry entries
|
||||
fmt.Printf("Waiting for instances to clean up registry entries...\n")
|
||||
|
||||
maxWaitTime := 10 // seconds
|
||||
for i := 0; i < maxWaitTime; i++ {
|
||||
time.Sleep(1 * time.Second)
|
||||
|
||||
remainingInstances, err := registry.ListInstancesCleaned(ctx)
|
||||
if err != nil {
|
||||
fmt.Printf("Warning: failed to check registry status: %v\n", err)
|
||||
continue
|
||||
}
|
||||
|
||||
if len(remainingInstances) == 0 {
|
||||
fmt.Printf("✓ All instances successfully removed from registry.\n")
|
||||
break
|
||||
}
|
||||
|
||||
if i == maxWaitTime-1 {
|
||||
fmt.Printf("⚠ %d instances still in registry after %d seconds\n", len(remainingInstances), maxWaitTime)
|
||||
for _, remaining := range remainingInstances {
|
||||
fmt.Printf(" - %s\n", remaining.Address)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Print summary
|
||||
successful := 0
|
||||
failed := 0
|
||||
alreadyDead := 0
|
||||
|
||||
for _, result := range killResults {
|
||||
if result.err != nil {
|
||||
failed++
|
||||
} else if result.alreadyDead {
|
||||
alreadyDead++
|
||||
} else {
|
||||
successful++
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf("\nSummary: ")
|
||||
if successful > 0 {
|
||||
fmt.Printf("Successfully killed %d instances. ", successful)
|
||||
}
|
||||
if alreadyDead > 0 {
|
||||
fmt.Printf("%d were already dead. ", alreadyDead)
|
||||
}
|
||||
if failed > 0 {
|
||||
fmt.Printf("%d failures.", failed)
|
||||
return fmt.Errorf("failed to kill %d out of %d instances", failed, len(instances))
|
||||
}
|
||||
fmt.Println()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type killResult struct {
|
||||
address string
|
||||
pid int
|
||||
alreadyDead bool
|
||||
err error
|
||||
}
|
||||
|
||||
func killInstanceProcess(ctx context.Context, registry *global.ClientRegistry, address string) killResult {
|
||||
// Get gRPC client and process info
|
||||
client, err := registry.GetClient(ctx, address)
|
||||
if err != nil {
|
||||
return killResult{address: address, alreadyDead: true, err: nil}
|
||||
}
|
||||
|
||||
processInfo, err := client.State.GetProcessInfo(ctx, &cline.EmptyRequest{})
|
||||
if err != nil {
|
||||
return killResult{address: address, alreadyDead: true, err: nil}
|
||||
}
|
||||
|
||||
pid := int(processInfo.ProcessId)
|
||||
|
||||
// Kill the process
|
||||
if err := syscall.Kill(pid, syscall.SIGTERM); err != nil {
|
||||
return killResult{address: address, pid: pid, err: err}
|
||||
}
|
||||
|
||||
return killResult{address: address, pid: pid, err: nil}
|
||||
}
|
||||
|
||||
func newInstanceListCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "list",
|
||||
Aliases: []string{"l"},
|
||||
Short: "List all registered Cline instances",
|
||||
Long: `List all registered Cline instances with their status and connection details.`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if global.Clients == nil {
|
||||
return fmt.Errorf("clients not initialized")
|
||||
}
|
||||
|
||||
ctx := cmd.Context()
|
||||
registry := global.Clients.GetRegistry()
|
||||
|
||||
// Load, cleanup stale local entries, and update health
|
||||
instances, err := registry.ListInstancesCleaned(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to list instances: %w", err)
|
||||
}
|
||||
defaultInstance := registry.GetDefaultInstance()
|
||||
|
||||
if len(instances) == 0 {
|
||||
fmt.Println("No Cline instances found.")
|
||||
fmt.Println("Run 'cline instance new' to start a new instance, or 'cline task new \"...\"' to auto-start one.")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Always output a table
|
||||
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
|
||||
fmt.Fprintln(w, "ADDRESS\tSTATUS\tVERSION\tLAST SEEN\tPID\tDEFAULT")
|
||||
|
||||
for _, instance := range instances {
|
||||
isDefault := ""
|
||||
if instance.Address == defaultInstance {
|
||||
isDefault = "*"
|
||||
}
|
||||
|
||||
lastSeen := instance.LastSeen.Format("15:04:05")
|
||||
if time.Since(instance.LastSeen) > 24*time.Hour {
|
||||
lastSeen = instance.LastSeen.Format("2006-01-02")
|
||||
}
|
||||
|
||||
// Get PID via RPC if instance is healthy
|
||||
pid := "N/A"
|
||||
if instance.Status == grpc_health_v1.HealthCheckResponse_SERVING {
|
||||
if client, err := registry.GetClient(ctx, instance.Address); err == nil {
|
||||
if processInfo, err := client.State.GetProcessInfo(ctx, &cline.EmptyRequest{}); err == nil {
|
||||
pid = fmt.Sprintf("%d", processInfo.ProcessId)
|
||||
// Update version from RPC if available
|
||||
if processInfo.Version != nil && *processInfo.Version != "" && *processInfo.Version != "unknown" {
|
||||
instance.Version = *processInfo.Version
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\n",
|
||||
instance.Address,
|
||||
instance.Status,
|
||||
instance.Version,
|
||||
lastSeen,
|
||||
pid,
|
||||
isDefault,
|
||||
)
|
||||
}
|
||||
|
||||
w.Flush()
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newInstanceUseCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "use <address>",
|
||||
Aliases: []string{"u"},
|
||||
Short: "Set the default Cline instance",
|
||||
Long: `Set the default Cline instance to use for subsequent commands.`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
address := args[0]
|
||||
|
||||
if global.Clients == nil {
|
||||
return fmt.Errorf("clients not initialized")
|
||||
}
|
||||
|
||||
registry := global.Clients.GetRegistry()
|
||||
|
||||
// Verify the instance exists
|
||||
_, err := registry.GetInstance(address)
|
||||
if err != nil {
|
||||
return fmt.Errorf("instance %s not found. Run 'cline instance list' to see available instances", address)
|
||||
}
|
||||
|
||||
// Set as default
|
||||
if err := registry.SetDefaultInstance(address); err != nil {
|
||||
return fmt.Errorf("failed to set default instance: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Switched to instance: %s\n", address)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newInstanceNewCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "new",
|
||||
Aliases: []string{"n"},
|
||||
Short: "Create a new Cline instance",
|
||||
Long: `Create a new Cline instance with automatically assigned ports.`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := cmd.Context()
|
||||
|
||||
if global.Clients == nil {
|
||||
return fmt.Errorf("clients not initialized")
|
||||
}
|
||||
|
||||
fmt.Println("Starting new Cline instance...")
|
||||
|
||||
instance, err := global.Clients.StartNewInstance(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to start instance: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Successfully started new instance:\n")
|
||||
fmt.Printf(" Address: %s\n", instance.Address)
|
||||
fmt.Printf(" Core Port: %d\n", instance.CorePort())
|
||||
fmt.Printf(" Host Bridge Port: %d\n", instance.HostPort())
|
||||
|
||||
// Check if this is now the default instance
|
||||
registry := global.Clients.GetRegistry()
|
||||
if registry.GetDefaultInstance() == instance.Address {
|
||||
fmt.Printf(" Status: Default instance\n")
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -0,0 +1,366 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/cline/cli/pkg/common"
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
"google.golang.org/grpc/health/grpc_health_v1"
|
||||
)
|
||||
|
||||
// normalizeAddressVariants returns address variants to try when querying SQLite.
|
||||
// Handles localhost/127.0.0.1 equivalence by returning both forms.
|
||||
func normalizeAddressVariants(address string) []string {
|
||||
variants := []string{address}
|
||||
|
||||
// Extract host and port
|
||||
host, port, err := net.SplitHostPort(address)
|
||||
if err != nil {
|
||||
return variants
|
||||
}
|
||||
|
||||
// Add the alternate form for localhost/127.0.0.1
|
||||
if host == "localhost" {
|
||||
variants = append(variants, net.JoinHostPort("127.0.0.1", port))
|
||||
} else if host == "127.0.0.1" {
|
||||
variants = append(variants, net.JoinHostPort("localhost", port))
|
||||
}
|
||||
|
||||
return variants
|
||||
}
|
||||
|
||||
// LockManager provides access to the SQLite locks database
|
||||
type LockManager struct {
|
||||
dbPath string
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// NewLockManager creates a new lock manager
|
||||
func NewLockManager(clineDir string) (*LockManager, error) {
|
||||
dbPath := filepath.Join(clineDir, common.SETTINGS_SUBFOLDER, "locks.db")
|
||||
|
||||
// Ensure the directory exists (for future DB creation by cline-core)
|
||||
dbDir := filepath.Dir(dbPath)
|
||||
if err := os.MkdirAll(dbDir, 0755); err != nil {
|
||||
return nil, fmt.Errorf("failed to create database directory: %w", err)
|
||||
}
|
||||
|
||||
// Check if database exists
|
||||
if _, err := os.Stat(dbPath); os.IsNotExist(err) {
|
||||
// Database doesn't exist - return manager with nil db
|
||||
// All methods already handle this gracefully!
|
||||
return &LockManager{dbPath: dbPath, db: nil}, nil
|
||||
}
|
||||
|
||||
// Database exists - open it normally (no schema creation)
|
||||
db, err := sql.Open("sqlite3", dbPath)
|
||||
if err != nil {
|
||||
// If we can't open existing database, return nil db manager
|
||||
return &LockManager{dbPath: dbPath, db: nil}, nil
|
||||
}
|
||||
|
||||
// Test the connection
|
||||
if err := db.Ping(); err != nil {
|
||||
db.Close()
|
||||
// If connection fails, return nil db manager
|
||||
return &LockManager{dbPath: dbPath, db: nil}, nil
|
||||
}
|
||||
|
||||
return &LockManager{
|
||||
dbPath: dbPath,
|
||||
db: db,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ensureConnection attempts to establish a database connection if one doesn't exist
|
||||
func (lm *LockManager) ensureConnection() error {
|
||||
// If we already have a connection, we're done
|
||||
if lm.db != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check if database exists now (created by cline-core)
|
||||
if _, err := os.Stat(lm.dbPath); os.IsNotExist(err) {
|
||||
return fmt.Errorf("database not available")
|
||||
}
|
||||
|
||||
// Database exists, try to connect
|
||||
db, err := sql.Open("sqlite3", lm.dbPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to connect to database: %w", err)
|
||||
}
|
||||
|
||||
if err := db.Ping(); err != nil {
|
||||
db.Close()
|
||||
return fmt.Errorf("database connection failed: %w", err)
|
||||
}
|
||||
|
||||
// Success! Update our connection permanently
|
||||
lm.db = db
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close closes the database connection
|
||||
func (lm *LockManager) Close() error {
|
||||
if lm.db != nil {
|
||||
return lm.db.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetInstanceLocks returns all instance locks
|
||||
func (lm *LockManager) GetInstanceLocks() ([]common.LockRow, error) {
|
||||
if err := lm.ensureConnection(); err != nil {
|
||||
return []common.LockRow{}, nil
|
||||
}
|
||||
|
||||
query := common.SelectInstanceLocksSQL
|
||||
|
||||
rows, err := lm.db.Query(query)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to query instance locks: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var locks []common.LockRow
|
||||
for rows.Next() {
|
||||
var lock common.LockRow
|
||||
err := rows.Scan(&lock.ID, &lock.HeldBy, &lock.LockType, &lock.LockTarget, &lock.LockedAt)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to scan lock row: %w", err)
|
||||
}
|
||||
locks = append(locks, lock)
|
||||
}
|
||||
|
||||
return locks, nil
|
||||
}
|
||||
|
||||
// RemoveInstanceLock removes an instance lock by address
|
||||
func (lm *LockManager) RemoveInstanceLock(address string) error {
|
||||
if err := lm.ensureConnection(); err != nil {
|
||||
return nil // Gracefully handle missing database for cleanup operations
|
||||
}
|
||||
|
||||
query := common.DeleteInstanceLockSQL
|
||||
_, err := lm.db.Exec(query, address)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to remove instance lock: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// HasInstanceAtAddress checks if an instance exists at the given address
|
||||
func (lm *LockManager) HasInstanceAtAddress(address string) (bool, error) {
|
||||
if err := lm.ensureConnection(); err != nil {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
query := common.CountInstanceLockSQL
|
||||
var count int
|
||||
err := lm.db.QueryRow(query, address).Scan(&count)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed to check instance existence: %w", err)
|
||||
}
|
||||
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
// GetInstanceInfo returns instance information directly from SQLite.
|
||||
// Handles localhost/127.0.0.1 equivalence by trying both variants.
|
||||
func (lm *LockManager) GetInstanceInfo(address string) (*common.CoreInstanceInfo, error) {
|
||||
if err := lm.ensureConnection(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
query := common.SelectInstanceLockByHolderSQL
|
||||
variants := normalizeAddressVariants(address)
|
||||
|
||||
var heldBy, lockTarget string
|
||||
var lockedAt int64
|
||||
var lastErr error
|
||||
|
||||
// Try each address variant (e.g., localhost:50607 and 127.0.0.1:50607)
|
||||
for _, variant := range variants {
|
||||
err := lm.db.QueryRow(query, variant).Scan(&heldBy, &lockTarget, &lockedAt)
|
||||
if err == nil {
|
||||
// Found it!
|
||||
return &common.CoreInstanceInfo{
|
||||
Address: heldBy,
|
||||
HostServiceAddress: lockTarget,
|
||||
Status: grpc_health_v1.HealthCheckResponse_UNKNOWN,
|
||||
LastSeen: time.Unix(lockedAt/1000, 0),
|
||||
}, nil
|
||||
}
|
||||
if err != sql.ErrNoRows {
|
||||
// Real error (not just "not found"), save it
|
||||
lastErr = err
|
||||
}
|
||||
}
|
||||
|
||||
// None of the variants were found
|
||||
if lastErr != nil {
|
||||
return nil, fmt.Errorf("failed to query instance: %w", lastErr)
|
||||
}
|
||||
return nil, fmt.Errorf("instance %s not found", address)
|
||||
}
|
||||
|
||||
// ListInstancesWithHealthCheck returns all instances with real-time health checks
|
||||
func (lm *LockManager) ListInstancesWithHealthCheck(ctx context.Context) ([]*common.CoreInstanceInfo, error) {
|
||||
if err := lm.ensureConnection(); err != nil {
|
||||
return []*common.CoreInstanceInfo{}, nil
|
||||
}
|
||||
|
||||
// Get all instance locks
|
||||
locks, err := lm.GetInstanceLocks()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get instance locks: %w", err)
|
||||
}
|
||||
|
||||
var instances []*common.CoreInstanceInfo
|
||||
|
||||
for _, lock := range locks {
|
||||
// Create instance info using actual SQLite data
|
||||
status, err := common.PerformHealthCheck(ctx, lock.HeldBy)
|
||||
if status != grpc_health_v1.HealthCheckResponse_SERVING || err != nil {
|
||||
time.Sleep(1 * time.Second)
|
||||
status, err = common.PerformHealthCheck(ctx, lock.HeldBy)
|
||||
}
|
||||
|
||||
info := &common.CoreInstanceInfo{
|
||||
Address: lock.HeldBy,
|
||||
HostServiceAddress: lock.LockTarget,
|
||||
Status: status,
|
||||
LastSeen: time.Unix(lock.LockedAt/1000, 0),
|
||||
}
|
||||
|
||||
instances = append(instances, info)
|
||||
}
|
||||
|
||||
return instances, nil
|
||||
}
|
||||
|
||||
// GetDefaultInstance reads the default instance from the settings file
|
||||
func GetDefaultInstance(clineDir string) (string, error) {
|
||||
settingsPath := filepath.Join(clineDir, common.SETTINGS_SUBFOLDER, "settings", "cli-default-instance.json")
|
||||
|
||||
data, err := os.ReadFile(settingsPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return "", nil
|
||||
}
|
||||
return "", fmt.Errorf("failed to read default instance file: %w", err)
|
||||
}
|
||||
|
||||
var defaultInstance common.DefaultCoreInstance
|
||||
if err := json.Unmarshal(data, &defaultInstance); err != nil {
|
||||
return "", fmt.Errorf("failed to parse default instance JSON: %w", err)
|
||||
}
|
||||
|
||||
if defaultInstance.Address == "" {
|
||||
return "", fmt.Errorf("default instance not set in settings file")
|
||||
}
|
||||
|
||||
return defaultInstance.Address, nil
|
||||
}
|
||||
|
||||
// SetDefaultInstance writes the default instance to the settings file with proper locking
|
||||
func SetDefaultInstance(clineDir, address string) error {
|
||||
// Create lock manager for this operation
|
||||
lockManager, err := NewLockManager(clineDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Warning: SQLite unavailable, writing without lock: %v\n", err)
|
||||
}
|
||||
defer lockManager.Close()
|
||||
|
||||
settingsPath := filepath.Join(clineDir, common.SETTINGS_SUBFOLDER, "settings", "cli-default-instance.json")
|
||||
|
||||
// Generate a unique identifier for this CLI process
|
||||
heldBy := fmt.Sprintf("cli-process-%d", os.Getpid())
|
||||
|
||||
// Use file lock for the write operation
|
||||
return lockManager.WithFileLock(settingsPath, heldBy, func() error {
|
||||
return writeDefaultInstanceJSONToDisk(clineDir, address)
|
||||
})
|
||||
}
|
||||
|
||||
func writeDefaultInstanceJSONToDisk(clineDir, address string) error {
|
||||
settingsDir := filepath.Join(clineDir, common.SETTINGS_SUBFOLDER, "settings")
|
||||
if err := os.MkdirAll(settingsDir, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create settings directory: %w", err)
|
||||
}
|
||||
|
||||
settingsPath := filepath.Join(settingsDir, "cli-default-instance.json")
|
||||
|
||||
payload := common.DefaultCoreInstance{
|
||||
Address: address,
|
||||
LastUpdated: time.Now().Format(time.RFC3339),
|
||||
}
|
||||
|
||||
data, err := json.MarshalIndent(payload, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal default instance JSON: %w", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(settingsPath, data, 0644); err != nil {
|
||||
return fmt.Errorf("failed to write default instance file: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// AcquireFileLock attempts to acquire a file lock
|
||||
func (lm *LockManager) AcquireFileLock(filePath, heldBy string) error {
|
||||
if err := lm.ensureConnection(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
now := time.Now().Unix() * 1000 // Convert to milliseconds
|
||||
|
||||
query := common.InsertFileLockSQL
|
||||
|
||||
_, err := lm.db.Exec(query, heldBy, filePath, now)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to acquire file lock for %s: %w", filePath, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ReleaseFileLock releases a file lock
|
||||
func (lm *LockManager) ReleaseFileLock(filePath, heldBy string) error {
|
||||
if lm.db == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
query := common.DeleteFileLockSQL
|
||||
|
||||
_, err := lm.db.Exec(query, heldBy, filePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to release file lock for %s: %w", filePath, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// WithFileLock executes a function while holding a file lock
|
||||
func (lm *LockManager) WithFileLock(filePath, heldBy string, fn func() error) error {
|
||||
if err := lm.AcquireFileLock(filePath, heldBy); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if releaseErr := lm.ReleaseFileLock(filePath, heldBy); releaseErr != nil {
|
||||
fmt.Printf("Warning: Failed to release file lock for %s: %v\n", filePath, releaseErr)
|
||||
}
|
||||
}()
|
||||
|
||||
return fn()
|
||||
}
|
||||
@@ -0,0 +1,444 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/cline/cli/pkg/cli/global"
|
||||
"github.com/cline/cli/pkg/cli/task"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func NewTaskCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "task",
|
||||
Aliases: []string{"t"},
|
||||
Short: "Manage Cline tasks",
|
||||
Long: `Create, monitor, and manage Cline AI tasks.`,
|
||||
}
|
||||
|
||||
cmd.AddCommand(newTaskNewCommand())
|
||||
cmd.AddCommand(newTaskCancelCommand())
|
||||
cmd.AddCommand(newTaskFollowCommand())
|
||||
cmd.AddCommand(newTaskSendCommand())
|
||||
cmd.AddCommand(newTaskViewCommand())
|
||||
cmd.AddCommand(newTaskListCommand())
|
||||
cmd.AddCommand(newTaskResumeCommand())
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
var taskManager *task.Manager
|
||||
|
||||
func ensureTaskManager(ctx context.Context, address string) error {
|
||||
if taskManager == nil || (address != "" && taskManager.GetCurrentInstance() != address) {
|
||||
var err error
|
||||
var instanceAddress string
|
||||
|
||||
if address != "" {
|
||||
// Ensure instance exists at the specified address
|
||||
if err := ensureInstanceAtAddress(ctx, address); err != nil {
|
||||
return fmt.Errorf("failed to ensure instance at address %s: %w", address, err)
|
||||
}
|
||||
taskManager, err = task.NewManagerForAddress(ctx, address)
|
||||
instanceAddress = address
|
||||
} else {
|
||||
// Ensure default instance exists
|
||||
if err := ensureDefaultInstance(ctx); err != nil {
|
||||
return fmt.Errorf("failed to ensure default instance: %w", err)
|
||||
}
|
||||
taskManager, err = task.NewManagerForDefault(ctx)
|
||||
if err == nil {
|
||||
instanceAddress = taskManager.GetCurrentInstance()
|
||||
}
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create task manager: %w", err)
|
||||
}
|
||||
|
||||
// Always set the instance we're using as the default
|
||||
registry := global.Clients.GetRegistry()
|
||||
if err := registry.SetDefaultInstance(instanceAddress); err != nil {
|
||||
// Log warning but don't fail - this is not critical
|
||||
fmt.Printf("Warning: failed to set default instance: %v\n", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ensureInstanceAtAddress ensures an instance exists at the given address
|
||||
func ensureInstanceAtAddress(ctx context.Context, address string) error {
|
||||
if global.Clients == nil {
|
||||
return fmt.Errorf("global clients not initialized")
|
||||
}
|
||||
return global.Clients.EnsureInstanceAtAddress(ctx, address)
|
||||
}
|
||||
|
||||
// ensureDefaultInstance ensures a default instance exists
|
||||
func ensureDefaultInstance(ctx context.Context) error {
|
||||
if global.Clients == nil {
|
||||
return fmt.Errorf("global clients not initialized")
|
||||
}
|
||||
|
||||
// Check if we have any instances in the registry
|
||||
registry := global.Clients.GetRegistry()
|
||||
if registry.GetDefaultInstance() == "" {
|
||||
// No default instance, start a new one
|
||||
instance, err := global.Clients.StartNewInstance(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to start new default instance: %w", err)
|
||||
}
|
||||
|
||||
// Set the new instance as default
|
||||
if err := registry.SetDefaultInstance(instance.Address); err != nil {
|
||||
return fmt.Errorf("failed to set default instance: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func newTaskNewCommand() *cobra.Command {
|
||||
var (
|
||||
images []string
|
||||
files []string
|
||||
wait bool
|
||||
workspaces []string
|
||||
address string
|
||||
mode string
|
||||
)
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "new <prompt>",
|
||||
Aliases: []string{"n"},
|
||||
Short: "Create a new task",
|
||||
Long: `Create a new Cline task with the specified prompt. If no Cline instance exists at the specified address, a new one will be started automatically.`,
|
||||
Args: cobra.MinimumNArgs(0),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := cmd.Context()
|
||||
|
||||
// Get content from both args and stdin
|
||||
prompt, err := getContentFromStdinAndArgs(args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read prompt: %w", err)
|
||||
}
|
||||
|
||||
// Validate that prompt is passed in call
|
||||
if prompt == "" {
|
||||
return fmt.Errorf("prompt required: provide as argument or pipe via stdin")
|
||||
}
|
||||
|
||||
// Ensure task manager is initialized
|
||||
if err := ensureTaskManager(ctx, address); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Set mode if provided
|
||||
if mode != "" {
|
||||
if err := taskManager.SetMode(ctx, mode, nil, nil, nil); err != nil {
|
||||
return fmt.Errorf("failed to set mode: %w", err)
|
||||
}
|
||||
fmt.Printf("Mode set to: %s\n", mode)
|
||||
}
|
||||
|
||||
// Create the task
|
||||
taskID, err := taskManager.CreateTask(ctx, prompt, images, files, workspaces)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create task: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Task created successfully with ID: %s\n", taskID)
|
||||
fmt.Printf("Using instance: %s\n", taskManager.GetCurrentInstance())
|
||||
|
||||
// Wait for completion if requested
|
||||
if wait {
|
||||
fmt.Println("Following task conversation...")
|
||||
return taskManager.FollowConversation(ctx)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringSliceVarP(&images, "image", "i", nil, "attach image files")
|
||||
cmd.Flags().StringSliceVarP(&files, "file", "f", nil, "attach files")
|
||||
cmd.Flags().BoolVar(&wait, "wait", false, "wait for task completion")
|
||||
cmd.Flags().StringSliceVarP(&workspaces, "workdir", "w", nil, "workdir directory paths")
|
||||
cmd.Flags().StringVar(&address, "address", "", "specific Cline instance address to use")
|
||||
cmd.Flags().StringVarP(&mode, "mode", "m", "", "mode (act|plan)")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newTaskCancelCommand() *cobra.Command {
|
||||
var address string
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "cancel",
|
||||
Aliases: []string{"c"},
|
||||
Short: "Cancel the current task",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := cmd.Context()
|
||||
|
||||
if err := ensureTaskManager(ctx, address); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := taskManager.CancelTask(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Println("Task cancelled successfully")
|
||||
fmt.Printf("Instance: %s\n", taskManager.GetCurrentInstance())
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&address, "address", "", "specific Cline instance address to use")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newTaskSendCommand() *cobra.Command {
|
||||
var (
|
||||
images []string
|
||||
files []string
|
||||
address string
|
||||
mode string
|
||||
approve string
|
||||
)
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "send [message]",
|
||||
Aliases: []string{"s"},
|
||||
Short: "Send a followup message to the current task and/or update mode/approve",
|
||||
Long: `Send a followup message to continue the conversation with the current task and/or update mode/approve.`,
|
||||
Args: cobra.MinimumNArgs(0),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := cmd.Context()
|
||||
|
||||
// Get content from both args and stdin
|
||||
message, err := getContentFromStdinAndArgs(args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read message: %w", err)
|
||||
}
|
||||
|
||||
if message == "" && len(images) == 0 && len(files) == 0 && mode == "" && approve == "" {
|
||||
return fmt.Errorf("content (message, files, images) required unless using --mode or --approve flags")
|
||||
}
|
||||
|
||||
if approve != "" && approve != "true" && approve != "false" {
|
||||
return fmt.Errorf("--approve must be 'true' or 'false'")
|
||||
}
|
||||
|
||||
if approve != "" && mode != "" {
|
||||
return fmt.Errorf("cannot use --approve and --mode together")
|
||||
}
|
||||
|
||||
// Ensure task manager is initialized
|
||||
if err := ensureTaskManager(ctx, address); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
sendDisabled, err := taskManager.CheckSendDisabled(ctx)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check if message can be sent: %w", err)
|
||||
}
|
||||
|
||||
if sendDisabled {
|
||||
fmt.Println("Cannot send message: task is currently busy")
|
||||
return nil
|
||||
}
|
||||
|
||||
if mode != "" {
|
||||
if err := taskManager.SetModeAndSendMessage(ctx, mode, message, images, files); err != nil {
|
||||
return fmt.Errorf("failed to set mode and send message: %w", err)
|
||||
}
|
||||
fmt.Printf("Mode set to %s and message sent successfully.\n", mode)
|
||||
|
||||
} else {
|
||||
if err := taskManager.SendMessage(ctx, message, images, files, approve); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("Message sent successfully.\n")
|
||||
}
|
||||
|
||||
fmt.Printf("Instance: %s\n", taskManager.GetCurrentInstance())
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringSliceVarP(&images, "image", "i", nil, "attach image files")
|
||||
cmd.Flags().StringSliceVarP(&files, "file", "f", nil, "attach files")
|
||||
cmd.Flags().StringVar(&address, "address", "", "specific Cline instance address to use")
|
||||
cmd.Flags().StringVarP(&mode, "mode", "m", "", "mode (act|plan)")
|
||||
cmd.Flags().StringVarP(&approve, "approve", "a", "", "approve (true) or deny (false) pending request")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newTaskFollowCommand() *cobra.Command {
|
||||
var address string
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "follow",
|
||||
Aliases: []string{"f"},
|
||||
Short: "Follow current task conversation in real-time",
|
||||
Long: `Follow the current task conversation, displaying new messages as they arrive in real-time.`,
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := cmd.Context()
|
||||
|
||||
if err := ensureTaskManager(ctx, address); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Printf("Using instance: %s\n", taskManager.GetCurrentInstance())
|
||||
|
||||
return taskManager.FollowConversation(ctx)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&address, "address", "", "specific Cline instance address to use")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newTaskViewCommand() *cobra.Command {
|
||||
var (
|
||||
current bool
|
||||
summary bool
|
||||
address string
|
||||
)
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "view",
|
||||
Aliases: []string{"v"},
|
||||
Short: "View task conversation",
|
||||
Long: `Output conversation until next completion, with options for current state or summary only.`,
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := cmd.Context()
|
||||
|
||||
if err := ensureTaskManager(ctx, address); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Printf("Using instance: %s\n", taskManager.GetCurrentInstance())
|
||||
|
||||
if current {
|
||||
return taskManager.ShowConversation(ctx)
|
||||
} else if summary {
|
||||
return taskManager.GatherFinalSummary(ctx)
|
||||
} else {
|
||||
return taskManager.FollowConversationUntilCompletion(ctx)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().BoolVarP(¤t, "current", "c", false, "output current conversation without following")
|
||||
cmd.Flags().BoolVarP(&summary, "summary", "s", false, "outputs only the completion summary")
|
||||
cmd.Flags().StringVar(&address, "address", "", "specific Cline instance address to use")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newTaskListCommand() *cobra.Command {
|
||||
var address string
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "list",
|
||||
Aliases: []string{"l"},
|
||||
Short: "List recent task history",
|
||||
Long: `Display recent tasks from task history.`,
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := cmd.Context()
|
||||
|
||||
// Ensure task manager is initialized
|
||||
if err := ensureTaskManager(ctx, address); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Printf("Using instance: %s\n", taskManager.GetCurrentInstance())
|
||||
|
||||
return taskManager.ListTasks(ctx)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&address, "address", "", "specific Cline instance address to use")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newTaskResumeCommand() *cobra.Command {
|
||||
var address string
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "resume <task-id>",
|
||||
Aliases: []string{"r"},
|
||||
Short: "Resume a task by ID",
|
||||
Long: `Resume an existing task by ID.`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := cmd.Context()
|
||||
taskID := args[0]
|
||||
|
||||
// Ensure task manager is initialized
|
||||
if err := ensureTaskManager(ctx, address); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Printf("Using instance: %s\n", taskManager.GetCurrentInstance())
|
||||
|
||||
return taskManager.ResumeTask(ctx, taskID)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&address, "address", "", "specific Cline instance address to use")
|
||||
return cmd
|
||||
}
|
||||
|
||||
// getContentFromStdinAndArgs reads content from both command line args and stdin, and combines them
|
||||
func getContentFromStdinAndArgs(args []string) (string, error) {
|
||||
var content strings.Builder
|
||||
|
||||
// Add command line args first (if any)
|
||||
if len(args) > 0 {
|
||||
content.WriteString(strings.Join(args, " "))
|
||||
}
|
||||
|
||||
// Check if stdin has data
|
||||
stat, err := os.Stdin.Stat()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to stat stdin: %w", err)
|
||||
}
|
||||
|
||||
// Check if data is being piped to stdin
|
||||
if (stat.Mode() & os.ModeCharDevice) == 0 {
|
||||
stdinBytes, err := io.ReadAll(os.Stdin)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read from stdin: %w", err)
|
||||
}
|
||||
|
||||
stdinContent := strings.TrimSpace(string(stdinBytes))
|
||||
if stdinContent != "" {
|
||||
if content.Len() > 0 {
|
||||
content.WriteString(" ")
|
||||
}
|
||||
content.WriteString(stdinContent)
|
||||
}
|
||||
}
|
||||
|
||||
return content.String(), nil
|
||||
}
|
||||
|
||||
// CleanupTaskManager cleans up the task manager resources
|
||||
func CleanupTaskManager() {
|
||||
if taskManager != nil {
|
||||
taskManager.Cleanup()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,946 @@
|
||||
package task
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/cline/cli/pkg/cli/display"
|
||||
"github.com/cline/cli/pkg/cli/global"
|
||||
"github.com/cline/cli/pkg/cli/handlers"
|
||||
"github.com/cline/cli/pkg/cli/types"
|
||||
"github.com/cline/grpc-go/client"
|
||||
"github.com/cline/grpc-go/cline"
|
||||
)
|
||||
|
||||
// Manager handles task execution and message display
|
||||
type Manager struct {
|
||||
mu sync.RWMutex
|
||||
client *client.ClineClient
|
||||
clientAddress string
|
||||
state *types.ConversationState
|
||||
renderer *display.Renderer
|
||||
streamingDisplay *display.StreamingDisplay
|
||||
handlerRegistry *handlers.HandlerRegistry
|
||||
}
|
||||
|
||||
// NewManager creates a new task manager
|
||||
func NewManager(client *client.ClineClient) *Manager {
|
||||
state := types.NewConversationState()
|
||||
renderer := display.NewRenderer()
|
||||
streamingDisplay := display.NewStreamingDisplay(state, renderer)
|
||||
|
||||
// Create handler registry and register handlers
|
||||
registry := handlers.NewHandlerRegistry()
|
||||
registry.Register(handlers.NewAskHandler())
|
||||
registry.Register(handlers.NewSayHandler())
|
||||
|
||||
return &Manager{
|
||||
client: client,
|
||||
clientAddress: "", // Will be set when client is provided
|
||||
state: state,
|
||||
renderer: renderer,
|
||||
streamingDisplay: streamingDisplay,
|
||||
handlerRegistry: registry,
|
||||
}
|
||||
}
|
||||
|
||||
// NewManagerForAddress creates a new task manager for a specific instance address
|
||||
func NewManagerForAddress(ctx context.Context, address string) (*Manager, error) {
|
||||
client, err := global.GetClientForAddress(ctx, address)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get client for address %s: %w", address, err)
|
||||
}
|
||||
|
||||
manager := NewManager(client)
|
||||
manager.clientAddress = address
|
||||
return manager, nil
|
||||
}
|
||||
|
||||
// NewManagerForDefault creates a new task manager using the default instance
|
||||
func NewManagerForDefault(ctx context.Context) (*Manager, error) {
|
||||
client, err := global.GetDefaultClient(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get default client: %w", err)
|
||||
}
|
||||
|
||||
manager := NewManager(client)
|
||||
|
||||
// Get the default instance address
|
||||
if global.Clients != nil {
|
||||
manager.clientAddress = global.Clients.GetRegistry().GetDefaultInstance()
|
||||
}
|
||||
|
||||
return manager, nil
|
||||
}
|
||||
|
||||
// SwitchToInstance switches the manager to use a different Cline instance
|
||||
func (m *Manager) SwitchToInstance(ctx context.Context, address string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
// Get client for the new address
|
||||
newClient, err := global.GetClientForAddress(ctx, address)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get client for address %s: %w", address, err)
|
||||
}
|
||||
|
||||
// Update the client and address
|
||||
m.client = newClient
|
||||
m.clientAddress = address
|
||||
|
||||
if global.Config.Verbose {
|
||||
m.renderer.RenderDebug("Switched to instance: %s", address)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetCurrentInstance returns the address of the current instance
|
||||
func (m *Manager) GetCurrentInstance() string {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
return m.clientAddress
|
||||
}
|
||||
|
||||
// CreateTask creates a new task
|
||||
func (m *Manager) CreateTask(ctx context.Context, prompt string, images, files []string, workspacePaths []string) (string, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
if global.Config.Verbose {
|
||||
m.renderer.RenderDebug("Creating task: %s", prompt)
|
||||
if len(files) > 0 {
|
||||
m.renderer.RenderDebug("Files: %v", files)
|
||||
}
|
||||
if len(images) > 0 {
|
||||
m.renderer.RenderDebug("Images: %v", images)
|
||||
}
|
||||
if len(workspacePaths) > 0 {
|
||||
m.renderer.RenderDebug("Workspaces: %v", workspacePaths)
|
||||
}
|
||||
}
|
||||
|
||||
// Check if there's an active task and cancel it first
|
||||
if err := m.cancelExistingTaskIfNeeded(ctx); err != nil {
|
||||
return "", fmt.Errorf("failed to cancel existing task: %w", err)
|
||||
}
|
||||
|
||||
// Create task request
|
||||
req := &cline.NewTaskRequest{
|
||||
Text: prompt,
|
||||
Images: images,
|
||||
Files: files,
|
||||
}
|
||||
|
||||
resp, err := m.client.Task.NewTask(ctx, req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create task: %w", err)
|
||||
}
|
||||
|
||||
taskID := resp.Value
|
||||
|
||||
return taskID, nil
|
||||
}
|
||||
|
||||
// cancelExistingTaskIfNeeded checks if there's an active task and cancels it
|
||||
func (m *Manager) cancelExistingTaskIfNeeded(ctx context.Context) error {
|
||||
// Try to get the current state to check if there's an active task
|
||||
state, err := m.client.State.GetLatestState(ctx, &cline.EmptyRequest{})
|
||||
if err != nil {
|
||||
// If we can't get state, assume no active task and continue
|
||||
if global.Config.Verbose {
|
||||
m.renderer.RenderDebug("Could not get state to check for active task: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Properly parse the state to check if there's actually an active task
|
||||
if state.StateJson != "" {
|
||||
var stateData types.ExtensionState
|
||||
if err := json.Unmarshal([]byte(state.StateJson), &stateData); err != nil {
|
||||
// If we can't parse state, assume no active task
|
||||
if global.Config.Verbose {
|
||||
m.renderer.RenderDebug("Could not parse state JSON: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check if there's actually an active task
|
||||
if stateData.CurrentTaskItem != nil && stateData.CurrentTaskItem.Id != "" {
|
||||
if global.Config.Verbose {
|
||||
m.renderer.RenderDebug("Found active task %s, cancelling...", stateData.CurrentTaskItem.Id)
|
||||
}
|
||||
|
||||
// Cancel the existing task
|
||||
_, err := m.client.Task.CancelTask(ctx, &cline.EmptyRequest{})
|
||||
if err != nil {
|
||||
if global.Config.Verbose {
|
||||
m.renderer.RenderDebug("Cancel task returned error: %v", err)
|
||||
}
|
||||
} else {
|
||||
fmt.Println("Cancelled existing task to start new one")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// CheckSendDisabled determines if we can send a message to the current task
|
||||
// We duplicate the logic from buttonConfig::getButtonConfig
|
||||
func (m *Manager) CheckSendDisabled(ctx context.Context) (bool, error) {
|
||||
state, err := m.client.State.GetLatestState(ctx, &cline.EmptyRequest{})
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed to get latest state: %w", err)
|
||||
}
|
||||
|
||||
messages, err := m.extractMessagesFromState(state.StateJson)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed to extract messages: %w", err)
|
||||
}
|
||||
|
||||
if len(messages) == 0 {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// Use final message to perform validation
|
||||
lastMessage := messages[len(messages)-1]
|
||||
|
||||
// Error types which we allow sending on
|
||||
errorTypes := []string{
|
||||
string(types.AskTypeAPIReqFailed), // "api_req_failed"
|
||||
string(types.AskTypeMistakeLimitReached), // "mistake_limit_reached"
|
||||
string(types.AskTypeAutoApprovalMaxReached), // "auto_approval_max_req_reached"
|
||||
}
|
||||
|
||||
isError := false
|
||||
|
||||
// Check if message is an error type
|
||||
if lastMessage.Type == types.MessageTypeAsk {
|
||||
for _, errType := range errorTypes {
|
||||
if lastMessage.Ask == errType {
|
||||
isError = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Streaming and error check
|
||||
if lastMessage.Partial && !isError {
|
||||
if global.Config.Verbose {
|
||||
m.renderer.RenderDebug("Send disabled: task is streaming and non-error")
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// All ask messages allow sending
|
||||
if lastMessage.Type == types.MessageTypeAsk {
|
||||
if global.Config.Verbose {
|
||||
m.renderer.RenderDebug("Send enabled: ask message")
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// Technically unnecessary but implements getButtonConfig 1-1
|
||||
if lastMessage.Type == types.MessageTypeSay && lastMessage.Say == string(types.SayTypeAPIReqStarted) {
|
||||
if global.Config.Verbose {
|
||||
m.renderer.RenderDebug("Send disabled: API request is active")
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
if global.Config.Verbose {
|
||||
m.renderer.RenderDebug("Send disabled: default fallback")
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// SendMessage sends a followup message to the current task
|
||||
func (m *Manager) SendMessage(ctx context.Context, message string, images, files []string, approve string) error {
|
||||
responseType := "messageResponse"
|
||||
|
||||
if approve == "true" {
|
||||
responseType = "yesButtonClicked"
|
||||
}
|
||||
|
||||
if approve == "false" {
|
||||
responseType = "noButtonClicked"
|
||||
}
|
||||
|
||||
if global.Config.Verbose {
|
||||
m.renderer.RenderDebug("Sending message: %s", message)
|
||||
if len(files) > 0 {
|
||||
m.renderer.RenderDebug("Files: %v", files)
|
||||
}
|
||||
if len(images) > 0 {
|
||||
m.renderer.RenderDebug("Images: %v", images)
|
||||
}
|
||||
}
|
||||
|
||||
// Send the followup message using AskResponse
|
||||
req := &cline.AskResponseRequest{
|
||||
ResponseType: responseType,
|
||||
Text: message,
|
||||
Images: images,
|
||||
Files: files,
|
||||
}
|
||||
|
||||
_, err := m.client.Task.AskResponse(ctx, req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to send message: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetMode sets the Plan/Act mode for the current Cline instance and optionally sends message
|
||||
func (m *Manager) SetMode(ctx context.Context, mode string, message *string, images, files []string) error {
|
||||
if mode != "act" && mode != "plan" {
|
||||
return fmt.Errorf("invalid mode '%s': must be 'act' or 'plan'", mode)
|
||||
}
|
||||
|
||||
var protoMode cline.PlanActMode
|
||||
if mode == "plan" {
|
||||
protoMode = cline.PlanActMode_PLAN
|
||||
} else {
|
||||
protoMode = cline.PlanActMode_ACT
|
||||
}
|
||||
|
||||
req := &cline.TogglePlanActModeRequest{
|
||||
Metadata: &cline.Metadata{},
|
||||
Mode: protoMode,
|
||||
}
|
||||
|
||||
if message != nil {
|
||||
req.ChatContent = &cline.ChatContent{
|
||||
Message: message,
|
||||
Images: images,
|
||||
Files: files,
|
||||
}
|
||||
}
|
||||
|
||||
_, err := m.client.State.TogglePlanActModeProto(ctx, req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to set mode to '%s': %w", mode, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetModeAndSendMessage sets the mode and sends a message in one operation
|
||||
// Handles task restoration internally if the mode switch cancels the current task
|
||||
func (m *Manager) SetModeAndSendMessage(ctx context.Context, mode, message string, images, files []string) error {
|
||||
if mode != "act" && mode != "plan" {
|
||||
return fmt.Errorf("invalid mode '%s': must be 'act' or 'plan'", mode)
|
||||
}
|
||||
|
||||
taskId, err := m.getCurrentTaskId(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get current task ID: %w", err)
|
||||
}
|
||||
fmt.Printf("Current task ID: %s\n", taskId)
|
||||
|
||||
var protoMode cline.PlanActMode
|
||||
if mode == "plan" {
|
||||
protoMode = cline.PlanActMode_PLAN
|
||||
} else {
|
||||
protoMode = cline.PlanActMode_ACT
|
||||
}
|
||||
|
||||
req := &cline.TogglePlanActModeRequest{
|
||||
Metadata: &cline.Metadata{},
|
||||
Mode: protoMode,
|
||||
ChatContent: &cline.ChatContent{
|
||||
Message: &message,
|
||||
Images: images,
|
||||
Files: files,
|
||||
},
|
||||
}
|
||||
|
||||
result, err := m.client.State.TogglePlanActModeProto(ctx, req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to set mode to '%s': %w", mode, err)
|
||||
}
|
||||
|
||||
taskPreserved := result.Value
|
||||
|
||||
if taskPreserved {
|
||||
fmt.Printf("Message sent as part of mode change\n")
|
||||
return nil
|
||||
} else {
|
||||
if message != "" || len(images) > 0 || len(files) > 0 {
|
||||
fmt.Printf("Task was cancelled, restoring task ID: %s\n", taskId)
|
||||
|
||||
err = m.ReinitExistingTaskFromId(ctx, taskId)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Failed to restore task: %w", err)
|
||||
}
|
||||
fmt.Printf("Task restored successfully\n")
|
||||
|
||||
// Hardcoded sleep should be replaced with a way to fetch whether task is ready algorithmically
|
||||
time.Sleep(1 * time.Second)
|
||||
|
||||
err = m.SendMessage(ctx, message, images, files, "")
|
||||
if err != nil {
|
||||
return fmt.Errorf("Failed to send message: %w", err)
|
||||
}
|
||||
fmt.Printf("Message sent to restored task\n")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// getCurrentTaskId extracts the current task ID from the server state
|
||||
func (m *Manager) getCurrentTaskId(ctx context.Context) (string, error) {
|
||||
// Get the latest state
|
||||
state, err := m.client.State.GetLatestState(ctx, &cline.EmptyRequest{})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get state: %w", err)
|
||||
}
|
||||
|
||||
// Parse the server state JSON
|
||||
var stateData types.ExtensionState
|
||||
if err := json.Unmarshal([]byte(state.StateJson), &stateData); err != nil {
|
||||
return "", fmt.Errorf("failed to parse state JSON: %w", err)
|
||||
}
|
||||
|
||||
// Extract current task ID
|
||||
if stateData.CurrentTaskItem != nil && stateData.CurrentTaskItem.Id != "" {
|
||||
return stateData.CurrentTaskItem.Id, nil
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("no current task found in state")
|
||||
}
|
||||
|
||||
// ReinitExistingTaskFromId reinitializes an existing task from the given task ID
|
||||
func (m *Manager) ReinitExistingTaskFromId(ctx context.Context, taskId string) error {
|
||||
req := &cline.StringRequest{Value: taskId}
|
||||
resp, err := m.client.Task.ShowTaskWithId(ctx, req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Failed to reinitialize task %s: %w", taskId, err)
|
||||
}
|
||||
|
||||
fmt.Printf("Successfully reinitialized task: %s (ID: %s)\n", taskId, resp.Id)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ResumeTask resumes an existing task by ID
|
||||
func (m *Manager) ResumeTask(ctx context.Context, taskID string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
if global.Config.Verbose {
|
||||
m.renderer.RenderDebug("Resuming task: %s", taskID)
|
||||
}
|
||||
|
||||
// This call handles cancellation of any active task
|
||||
if err := m.ReinitExistingTaskFromId(ctx, taskID); err != nil {
|
||||
return fmt.Errorf("failed to resume task %s: %w", taskID, err)
|
||||
}
|
||||
|
||||
fmt.Printf("Task %s resumed successfully\n", taskID)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// CancelTask cancels the current task
|
||||
func (m *Manager) CancelTask(ctx context.Context) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
_, err := m.client.Task.CancelTask(ctx, &cline.EmptyRequest{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to cancel task: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListTasks retrieves and displays task history
|
||||
func (m *Manager) ListTasks(ctx context.Context) error {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
req := &cline.GetTaskHistoryRequest{
|
||||
FavoritesOnly: false,
|
||||
SearchQuery: "",
|
||||
SortBy: "oldest",
|
||||
CurrentWorkspaceOnly: false,
|
||||
}
|
||||
|
||||
resp, err := m.client.Task.GetTaskHistory(ctx, req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get task history: %w", err)
|
||||
}
|
||||
|
||||
if len(resp.Tasks) == 0 {
|
||||
fmt.Println("No task history found.")
|
||||
return nil
|
||||
}
|
||||
|
||||
return m.renderer.RenderTaskList(resp.Tasks)
|
||||
}
|
||||
|
||||
// GatherFinalSummary attempts to gather the latest completion_result output and display it
|
||||
func (m *Manager) GatherFinalSummary(ctx context.Context) error {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
state, err := m.client.State.GetLatestState(ctx, &cline.EmptyRequest{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get state: %w", err)
|
||||
}
|
||||
|
||||
messages, err := m.extractMessagesFromState(state.StateJson)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to extract messages: %w", err)
|
||||
}
|
||||
|
||||
for i := len(messages) - 1; i >= 0; i-- {
|
||||
msg := messages[i]
|
||||
|
||||
// Check if this is a completion result SAY message
|
||||
if msg.IsSay() && msg.Say == string(types.SayTypeCompletionResult) {
|
||||
return m.displayMessage(msg, false, false, i)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ShowConversation displays the current conversation
|
||||
func (m *Manager) ShowConversation(ctx context.Context) error {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
// Get the latest state which contains messages
|
||||
state, err := m.client.State.GetLatestState(ctx, &cline.EmptyRequest{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get state: %w", err)
|
||||
}
|
||||
|
||||
// Parse the state JSON to extract messages
|
||||
messages, err := m.extractMessagesFromState(state.StateJson)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to extract messages: %w", err)
|
||||
}
|
||||
|
||||
if len(messages) == 0 {
|
||||
fmt.Println("No conversation history found.")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Display messages
|
||||
for i, msg := range messages {
|
||||
m.displayMessage(msg, false, false, i)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) FollowConversation(ctx context.Context) error {
|
||||
fmt.Println("Following task conversation... (Press Ctrl+C to exit)")
|
||||
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
|
||||
// Create stream coordinator
|
||||
coordinator := NewStreamCoordinator()
|
||||
|
||||
// Load history first
|
||||
totalMessageCount, err := m.loadAndDisplayRecentHistory(ctx)
|
||||
if err != nil {
|
||||
m.renderer.RenderDebug("Warning: Failed to load conversation history: %v", err)
|
||||
totalMessageCount = 0
|
||||
}
|
||||
coordinator.SetConversationTurnStartIndex(totalMessageCount)
|
||||
|
||||
fmt.Println("\n--- Live updates ---")
|
||||
|
||||
// Start both streams concurrently
|
||||
errChan := make(chan error, 2)
|
||||
|
||||
if global.Config.OutputFormat == "json" {
|
||||
go m.handleStateStream(ctx, coordinator, errChan, nil)
|
||||
} else {
|
||||
go m.handleStateStream(ctx, coordinator, errChan, nil)
|
||||
go m.handlePartialMessageStream(ctx, coordinator, errChan)
|
||||
}
|
||||
|
||||
// Wait for either stream to error or context cancellation
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case err := <-errChan:
|
||||
cancel()
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// FollowConversationUntilCompletion streams conversation updates until task completion
|
||||
func (m *Manager) FollowConversationUntilCompletion(ctx context.Context) error {
|
||||
fmt.Println("Streaming conversation until completion... (Press Ctrl+C to exit)")
|
||||
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
|
||||
// Create stream coordinator
|
||||
coordinator := NewStreamCoordinator()
|
||||
|
||||
// Get current message count without displaying history
|
||||
totalMessageCount, err := m.getCurrentMessageCount(ctx)
|
||||
if err != nil {
|
||||
m.renderer.RenderDebug("Warning: Failed to get current message count: %v", err)
|
||||
totalMessageCount = 0
|
||||
}
|
||||
coordinator.SetConversationTurnStartIndex(totalMessageCount)
|
||||
|
||||
// Start both streams concurrently
|
||||
errChan := make(chan error, 2)
|
||||
completionChan := make(chan bool, 1)
|
||||
|
||||
if global.Config.OutputFormat == "json" {
|
||||
go m.handleStateStream(ctx, coordinator, errChan, completionChan)
|
||||
} else {
|
||||
go m.handleStateStream(ctx, coordinator, errChan, completionChan)
|
||||
go m.handlePartialMessageStream(ctx, coordinator, errChan)
|
||||
}
|
||||
|
||||
// Wait for completion, error, or context cancellation
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-completionChan:
|
||||
cancel()
|
||||
return nil
|
||||
case err := <-errChan:
|
||||
cancel()
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// handleStateStream handles the SubscribeToState stream
|
||||
func (m *Manager) handleStateStream(ctx context.Context, coordinator *StreamCoordinator, errChan chan error, completionChan chan bool) {
|
||||
stateStream, err := m.client.State.SubscribeToState(ctx, &cline.EmptyRequest{})
|
||||
if err != nil {
|
||||
errChan <- fmt.Errorf("failed to subscribe to state: %w", err)
|
||||
return
|
||||
}
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
default:
|
||||
stateUpdate, err := stateStream.Recv()
|
||||
if err != nil {
|
||||
m.renderer.RenderDebug("State stream receive error: %v", err)
|
||||
errChan <- fmt.Errorf("failed to receive state update: %w", err)
|
||||
return
|
||||
}
|
||||
|
||||
var pErr error
|
||||
|
||||
if global.Config.OutputFormat == "json" {
|
||||
pErr = m.processStateUpdateJsonMode(stateUpdate, coordinator, completionChan)
|
||||
} else {
|
||||
pErr = m.processStateUpdate(stateUpdate, coordinator, completionChan)
|
||||
}
|
||||
|
||||
if pErr != nil {
|
||||
m.renderer.RenderDebug("State processing error: %v", pErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) processStateUpdateJsonMode(stateUpdate *cline.State, coordinator *StreamCoordinator, completionChan chan bool) error {
|
||||
messages, err := m.extractMessagesFromState(stateUpdate.StateJson)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Process messages from current conversation turn onwards
|
||||
startIndex := coordinator.GetConversationTurnStartIndex()
|
||||
|
||||
var foundCompletion bool
|
||||
var displayedUsage bool
|
||||
|
||||
for i := startIndex; i < len(messages); i++ {
|
||||
msg := messages[i]
|
||||
|
||||
if global.Config.Verbose {
|
||||
m.renderer.RenderDebug("State message %d: type=%s, say=%s", i, msg.Type, msg.Say)
|
||||
}
|
||||
|
||||
// Exit after we've seen a task completion & printed out the usage info
|
||||
if msg.Say == string(types.SayTypeCompletionResult) {
|
||||
foundCompletion = true
|
||||
}
|
||||
|
||||
// Determine if message is ready to be displayed now
|
||||
shouldDisplay := true
|
||||
|
||||
switch {
|
||||
case msg.Say == string(types.SayTypeAPIReqStarted):
|
||||
shouldDisplay = false
|
||||
apiInfo := types.APIRequestInfo{Cost: -1}
|
||||
if err := json.Unmarshal([]byte(msg.Text), &apiInfo); err == nil && apiInfo.Cost >= 0 {
|
||||
shouldDisplay = true
|
||||
displayedUsage = true
|
||||
}
|
||||
}
|
||||
|
||||
// Skip if message is partial, except for a specific edge case
|
||||
if msg.Partial {
|
||||
// Exception: display if type=say, text="", say="text"
|
||||
if msg.IsSay() && msg.Text == "" && msg.Say == string(types.SayTypeText) {
|
||||
shouldDisplay = true
|
||||
} else {
|
||||
shouldDisplay = false
|
||||
}
|
||||
}
|
||||
|
||||
// Display valid messages, exit as soon as we hit a non-valid message
|
||||
if shouldDisplay {
|
||||
coordinator.CompleteTurn(i + 1) // Mark the message as complete as soon as we print it
|
||||
m.displayMessage(msg, false, false, i)
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// We only want to exit after we've displayed the usage, for the case of seeing completion result
|
||||
if completionChan != nil && foundCompletion && displayedUsage {
|
||||
completionChan <- true
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// processStateUpdate processes state updates and supports logic for handling task competion markers
|
||||
func (m *Manager) processStateUpdate(stateUpdate *cline.State, coordinator *StreamCoordinator, completionChan chan bool) error {
|
||||
messages, err := m.extractMessagesFromState(stateUpdate.StateJson)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Process messages from current conversation turn onwards
|
||||
startIndex := coordinator.GetConversationTurnStartIndex()
|
||||
|
||||
var foundCompletion bool
|
||||
var displayedUsage bool
|
||||
|
||||
for i := startIndex; i < len(messages); i++ {
|
||||
msg := messages[i]
|
||||
|
||||
if global.Config.Verbose {
|
||||
m.renderer.RenderDebug("State message %d: type=%s, say=%s", i, msg.Type, msg.Say)
|
||||
}
|
||||
|
||||
// Exit after we've seen a task completion & printed out the usage info
|
||||
if msg.Say == string(types.SayTypeCompletionResult) {
|
||||
foundCompletion = true
|
||||
}
|
||||
|
||||
// Currently handling a subset of message types for displaying
|
||||
switch {
|
||||
case msg.Say == string(types.SayTypeUserFeedback):
|
||||
if !coordinator.IsProcessedInCurrentTurn("user_msg") {
|
||||
m.displayMessage(msg, false, false, i)
|
||||
coordinator.MarkProcessedInCurrentTurn("user_msg")
|
||||
}
|
||||
|
||||
case msg.Say == string(types.SayTypeCheckpointCreated):
|
||||
if !coordinator.IsProcessedInCurrentTurn("checkpoint") {
|
||||
m.displayMessage(msg, false, false, i)
|
||||
coordinator.MarkProcessedInCurrentTurn("checkpoint")
|
||||
}
|
||||
|
||||
case msg.Say == string(types.SayTypeAPIReqStarted):
|
||||
apiInfo := types.APIRequestInfo{Cost: -1}
|
||||
if err := json.Unmarshal([]byte(msg.Text), &apiInfo); err == nil && apiInfo.Cost >= 0 {
|
||||
fmt.Println() // adds a separator between cline message and usage message
|
||||
m.displayMessage(msg, false, false, i)
|
||||
coordinator.CompleteTurn(len(messages))
|
||||
displayedUsage = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// We only want to exit after we've displayed the usage, for the case of seeing completion result
|
||||
if completionChan != nil && foundCompletion && displayedUsage {
|
||||
completionChan <- true
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// handlePartialMessageStream handles the SubscribeToPartialMessage stream for streaming assistant text
|
||||
func (m *Manager) handlePartialMessageStream(ctx context.Context, coordinator *StreamCoordinator, errChan chan error) {
|
||||
partialStream, err := m.client.Ui.SubscribeToPartialMessage(ctx, &cline.EmptyRequest{})
|
||||
if err != nil {
|
||||
errChan <- fmt.Errorf("failed to subscribe to partial messages: %w", err)
|
||||
return
|
||||
}
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
default:
|
||||
protoMsg, err := partialStream.Recv()
|
||||
if err != nil {
|
||||
m.renderer.RenderDebug("Partial stream receive error: %v", err)
|
||||
errChan <- fmt.Errorf("failed to receive partial message: %w", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Convert proto message to our Message struct
|
||||
msg := types.ConvertProtoToMessage(protoMsg)
|
||||
|
||||
// Debug: Log received message (always show for debugging)
|
||||
m.renderer.RenderDebug("Received streaming message: type=%s, partial=%v, text_len=%d",
|
||||
msg.Type, msg.Partial, len(msg.Text))
|
||||
|
||||
// Handle the message with streaming support for de-dupping
|
||||
if err := m.handleStreamingMessage(msg); err != nil {
|
||||
m.renderer.RenderDebug("Error handling streaming message: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// handleStreamingMessage handles a streaming message
|
||||
func (m *Manager) handleStreamingMessage(msg *types.ClineMessage) error {
|
||||
// Debug: Always log what we're processing
|
||||
m.renderer.RenderDebug("Processing message: timestamp=%d, partial=%v, type=%s, text_preview=%s",
|
||||
msg.Timestamp, msg.Partial, msg.Type, m.truncateText(msg.Text, 50))
|
||||
|
||||
// Use streaming display which handles deduplication internally
|
||||
if err := m.streamingDisplay.HandlePartialMessage(msg); err != nil {
|
||||
m.renderer.RenderDebug("Streaming display failed, using fallback: %v", err)
|
||||
// Fallback to regular display
|
||||
return m.displayMessage(msg, true, false, -1)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// truncateText truncates text for debug display
|
||||
func (m *Manager) truncateText(text string, maxLen int) string {
|
||||
if len(text) <= maxLen {
|
||||
return text
|
||||
}
|
||||
return text[:maxLen] + "..."
|
||||
}
|
||||
|
||||
// displayMessage displays a single message using the handler system
|
||||
func (m *Manager) displayMessage(msg *types.ClineMessage, isLast, isPartial bool, messageIndex int) error {
|
||||
if global.Config.OutputFormat == "json" {
|
||||
return m.outputMessageAsJSON(msg)
|
||||
} else {
|
||||
dc := &handlers.DisplayContext{
|
||||
State: m.state,
|
||||
Renderer: m.renderer,
|
||||
IsLast: isLast,
|
||||
IsPartial: isPartial,
|
||||
MessageIndex: messageIndex,
|
||||
}
|
||||
|
||||
return m.handlerRegistry.Handle(msg, dc)
|
||||
}
|
||||
}
|
||||
|
||||
// outputMessageAsJSON prints a single cline message as json
|
||||
func (m *Manager) outputMessageAsJSON(msg *types.ClineMessage) error {
|
||||
jsonBytes, err := json.MarshalIndent(msg, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal message as JSON: %w", err)
|
||||
}
|
||||
|
||||
fmt.Println(string(jsonBytes))
|
||||
return nil
|
||||
}
|
||||
|
||||
// getCurrentMessageCount gets the current message count without displaying messages
|
||||
func (m *Manager) getCurrentMessageCount(ctx context.Context) (int, error) {
|
||||
state, err := m.client.State.GetLatestState(ctx, &cline.EmptyRequest{})
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to get state: %w", err)
|
||||
}
|
||||
|
||||
messages, err := m.extractMessagesFromState(state.StateJson)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to extract messages: %w", err)
|
||||
}
|
||||
|
||||
return len(messages), nil
|
||||
}
|
||||
|
||||
// loadAndDisplayRecentHistory loads and displays recent conversation history and returns the total number of existing messages
|
||||
func (m *Manager) loadAndDisplayRecentHistory(ctx context.Context) (int, error) {
|
||||
// Get the latest state which contains messages
|
||||
state, err := m.client.State.GetLatestState(ctx, &cline.EmptyRequest{})
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to get state: %w", err)
|
||||
}
|
||||
|
||||
// Parse the state JSON to extract messages
|
||||
messages, err := m.extractMessagesFromState(state.StateJson)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to extract messages: %w", err)
|
||||
}
|
||||
|
||||
if len(messages) == 0 {
|
||||
fmt.Println("No conversation history found.")
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
// Show only the last 100 messages by default
|
||||
const maxHistoryMessages = 100
|
||||
totalMessages := len(messages)
|
||||
startIndex := 0
|
||||
|
||||
if totalMessages > maxHistoryMessages {
|
||||
startIndex = totalMessages - maxHistoryMessages
|
||||
fmt.Printf("--- Conversation history (%d of %d messages) ---\n", maxHistoryMessages, totalMessages)
|
||||
} else {
|
||||
fmt.Printf("--- Conversation history (%d messages) ---\n", totalMessages)
|
||||
}
|
||||
|
||||
// Display recent messages
|
||||
for i := startIndex; i < len(messages); i++ {
|
||||
msg := messages[i]
|
||||
|
||||
// Display the message
|
||||
m.displayMessage(msg, false, false, i)
|
||||
}
|
||||
|
||||
// Return the total number of messages in the conversation
|
||||
return totalMessages, nil
|
||||
}
|
||||
|
||||
// extractMessagesFromState parses the state JSON and extracts messages
|
||||
func (m *Manager) extractMessagesFromState(stateJson string) ([]*types.ClineMessage, error) {
|
||||
return types.ExtractMessagesFromStateJSON(stateJson)
|
||||
}
|
||||
|
||||
// GetState returns the current conversation state
|
||||
func (m *Manager) GetState() *types.ConversationState {
|
||||
return m.state
|
||||
}
|
||||
|
||||
// Cleanup cleans up resources
|
||||
func (m *Manager) Cleanup() {
|
||||
// Clean up streaming display resources if needed
|
||||
if m.streamingDisplay != nil {
|
||||
m.streamingDisplay.Cleanup()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package task
|
||||
|
||||
// StreamCoordinator manages coordination between SubscribeToState and SubscribeToPartialMessage streams
|
||||
type StreamCoordinator struct {
|
||||
conversationTurnStartIndex int // First message index of current turn
|
||||
processedInCurrentTurn map[string]bool // What we've handled in THIS turn
|
||||
}
|
||||
|
||||
// NewStreamCoordinator creates a new stream coordinator
|
||||
func NewStreamCoordinator() *StreamCoordinator {
|
||||
return &StreamCoordinator{
|
||||
conversationTurnStartIndex: 0,
|
||||
processedInCurrentTurn: make(map[string]bool),
|
||||
}
|
||||
}
|
||||
|
||||
// SetConversationTurnStartIndex sets the starting index for the current conversation turn
|
||||
func (sc *StreamCoordinator) SetConversationTurnStartIndex(index int) {
|
||||
sc.conversationTurnStartIndex = index
|
||||
}
|
||||
|
||||
// GetConversationTurnStartIndex returns the starting index for the current conversation turn
|
||||
func (sc *StreamCoordinator) GetConversationTurnStartIndex() int {
|
||||
return sc.conversationTurnStartIndex
|
||||
}
|
||||
|
||||
// MarkProcessedInCurrentTurn marks an item as processed in the current turn
|
||||
func (sc *StreamCoordinator) MarkProcessedInCurrentTurn(key string) {
|
||||
sc.processedInCurrentTurn[key] = true
|
||||
}
|
||||
|
||||
// IsProcessedInCurrentTurn checks if an item has been processed in the current turn
|
||||
func (sc *StreamCoordinator) IsProcessedInCurrentTurn(key string) bool {
|
||||
return sc.processedInCurrentTurn[key]
|
||||
}
|
||||
|
||||
// CompleteTurn resets the coordinator for the next conversation turn
|
||||
func (sc *StreamCoordinator) CompleteTurn(totalMessages int) {
|
||||
sc.conversationTurnStartIndex = totalMessages
|
||||
sc.processedInCurrentTurn = make(map[string]bool)
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
package types
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
"strconv"
|
||||
"github.com/cline/grpc-go/cline"
|
||||
)
|
||||
|
||||
// ClineMessage represents a conversation message in the CLI
|
||||
type ClineMessage struct {
|
||||
Type MessageType `json:"type"`
|
||||
Text string `json:"text"`
|
||||
Timestamp int64 `json:"ts"`
|
||||
Reasoning string `json:"reasoning,omitempty"`
|
||||
Say string `json:"say,omitempty"`
|
||||
Ask string `json:"ask,omitempty"`
|
||||
Partial bool `json:"partial,omitempty"`
|
||||
Images []string `json:"images,omitempty"`
|
||||
Files []string `json:"files,omitempty"`
|
||||
}
|
||||
|
||||
// MessageType represents the type of message
|
||||
type MessageType string
|
||||
|
||||
const (
|
||||
MessageTypeAsk MessageType = "ask"
|
||||
MessageTypeSay MessageType = "say"
|
||||
)
|
||||
|
||||
// AskType represents different types of ASK messages
|
||||
type AskType string
|
||||
|
||||
const (
|
||||
AskTypeFollowup AskType = "followup"
|
||||
AskTypePlanModeRespond AskType = "plan_mode_respond"
|
||||
AskTypeCommand AskType = "command"
|
||||
AskTypeCommandOutput AskType = "command_output"
|
||||
AskTypeCompletionResult AskType = "completion_result"
|
||||
AskTypeTool AskType = "tool"
|
||||
AskTypeAPIReqFailed AskType = "api_req_failed"
|
||||
AskTypeResumeTask AskType = "resume_task"
|
||||
AskTypeResumeCompletedTask AskType = "resume_completed_task"
|
||||
AskTypeMistakeLimitReached AskType = "mistake_limit_reached"
|
||||
AskTypeAutoApprovalMaxReached AskType = "auto_approval_max_req_reached"
|
||||
AskTypeBrowserActionLaunch AskType = "browser_action_launch"
|
||||
AskTypeUseMcpServer AskType = "use_mcp_server"
|
||||
AskTypeNewTask AskType = "new_task"
|
||||
AskTypeCondense AskType = "condense"
|
||||
AskTypeReportBug AskType = "report_bug"
|
||||
)
|
||||
|
||||
// SayType represents different types of SAY messages
|
||||
type SayType string
|
||||
|
||||
const (
|
||||
SayTypeTask SayType = "task"
|
||||
SayTypeError SayType = "error"
|
||||
SayTypeAPIReqStarted SayType = "api_req_started"
|
||||
SayTypeAPIReqFinished SayType = "api_req_finished"
|
||||
SayTypeText SayType = "text"
|
||||
SayTypeReasoning SayType = "reasoning"
|
||||
SayTypeCompletionResult SayType = "completion_result"
|
||||
SayTypeUserFeedback SayType = "user_feedback"
|
||||
SayTypeUserFeedbackDiff SayType = "user_feedback_diff"
|
||||
SayTypeAPIReqRetried SayType = "api_req_retried"
|
||||
SayTypeCommand SayType = "command"
|
||||
SayTypeCommandOutput SayType = "command_output"
|
||||
SayTypeTool SayType = "tool"
|
||||
SayTypeShellIntegrationWarning SayType = "shell_integration_warning"
|
||||
SayTypeBrowserActionLaunch SayType = "browser_action_launch"
|
||||
SayTypeBrowserAction SayType = "browser_action"
|
||||
SayTypeBrowserActionResult SayType = "browser_action_result"
|
||||
SayTypeMcpServerRequestStarted SayType = "mcp_server_request_started"
|
||||
SayTypeMcpServerResponse SayType = "mcp_server_response"
|
||||
SayTypeMcpNotification SayType = "mcp_notification"
|
||||
SayTypeUseMcpServer SayType = "use_mcp_server"
|
||||
SayTypeDiffError SayType = "diff_error"
|
||||
SayTypeDeletedAPIReqs SayType = "deleted_api_reqs"
|
||||
SayTypeClineignoreError SayType = "clineignore_error"
|
||||
SayTypeCheckpointCreated SayType = "checkpoint_created"
|
||||
SayTypeLoadMcpDocumentation SayType = "load_mcp_documentation"
|
||||
SayTypeInfo SayType = "info"
|
||||
SayTypeTaskProgress SayType = "task_progress"
|
||||
)
|
||||
|
||||
// ToolMessage represents a tool-related message
|
||||
type ToolMessage struct {
|
||||
Tool string `json:"tool"`
|
||||
Path string `json:"path,omitempty"`
|
||||
Content string `json:"content,omitempty"`
|
||||
Diff string `json:"diff,omitempty"`
|
||||
Regex string `json:"regex,omitempty"`
|
||||
FilePattern string `json:"filePattern,omitempty"`
|
||||
OperationIsLocatedInWorkspace *bool `json:"operationIsLocatedInWorkspace,omitempty"`
|
||||
}
|
||||
|
||||
// ToolType represents different types of tools
|
||||
type ToolType string
|
||||
|
||||
const (
|
||||
ToolTypeEditedExistingFile ToolType = "editedExistingFile"
|
||||
ToolTypeNewFileCreated ToolType = "newFileCreated"
|
||||
ToolTypeReadFile ToolType = "readFile"
|
||||
ToolTypeListFilesTopLevel ToolType = "listFilesTopLevel"
|
||||
ToolTypeListFilesRecursive ToolType = "listFilesRecursive"
|
||||
ToolTypeListCodeDefinitionNames ToolType = "listCodeDefinitionNames"
|
||||
ToolTypeSearchFiles ToolType = "searchFiles"
|
||||
ToolTypeWebFetch ToolType = "webFetch"
|
||||
ToolTypeSummarizeTask ToolType = "summarizeTask"
|
||||
)
|
||||
|
||||
// AskData represents the parsed structure of an ASK message
|
||||
type AskData struct {
|
||||
Question string `json:"question"`
|
||||
Response string `json:"response"`
|
||||
Options []string `json:"options,omitempty"`
|
||||
}
|
||||
|
||||
// APIRequestInfo represents API request information
|
||||
type APIRequestInfo struct {
|
||||
Request string `json:"request,omitempty"`
|
||||
TokensIn int `json:"tokensIn,omitempty"`
|
||||
TokensOut int `json:"tokensOut,omitempty"`
|
||||
CacheWrites int `json:"cacheWrites,omitempty"`
|
||||
CacheReads int `json:"cacheReads,omitempty"`
|
||||
Cost float64 `json:"cost,omitempty"`
|
||||
CancelReason string `json:"cancelReason,omitempty"`
|
||||
StreamingFailedMessage string `json:"streamingFailedMessage,omitempty"`
|
||||
RetryStatus *APIRequestRetryStatus `json:"retryStatus,omitempty"`
|
||||
}
|
||||
|
||||
// APIRequestRetryStatus represents retry status information
|
||||
type APIRequestRetryStatus struct {
|
||||
Attempt int `json:"attempt"`
|
||||
MaxAttempts int `json:"maxAttempts"`
|
||||
DelaySec int `json:"delaySec"`
|
||||
ErrorSnippet string `json:"errorSnippet,omitempty"`
|
||||
}
|
||||
|
||||
// GetTimestamp returns a formatted timestamp string
|
||||
func (m *ClineMessage) GetTimestamp() string {
|
||||
return time.Unix(m.Timestamp/1000, 0).Format("15:04:05")
|
||||
}
|
||||
|
||||
// IsAsk returns true if this is an ASK message
|
||||
func (m *ClineMessage) IsAsk() bool {
|
||||
return m.Type == MessageTypeAsk
|
||||
}
|
||||
|
||||
// IsSay returns true if this is a SAY message
|
||||
func (m *ClineMessage) IsSay() bool {
|
||||
return m.Type == MessageTypeSay
|
||||
}
|
||||
|
||||
// GetMessageKey returns a unique key for this message based on timestamp
|
||||
func (m *ClineMessage) GetMessageKey() string {
|
||||
return strconv.FormatInt(m.Timestamp, 10)
|
||||
}
|
||||
|
||||
// ExtractMessagesFromStateJSON parses the state JSON and extracts messages
|
||||
func ExtractMessagesFromStateJSON(stateJson string) ([]*ClineMessage, error) {
|
||||
// Parse the state JSON to extract clineMessages
|
||||
var rawState map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(stateJson), &rawState); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse state JSON: %w", err)
|
||||
}
|
||||
|
||||
// Try to extract clineMessages
|
||||
clineMessagesRaw, exists := rawState["clineMessages"]
|
||||
if !exists {
|
||||
return []*ClineMessage{}, nil
|
||||
}
|
||||
|
||||
// Convert to JSON and back to get proper Message structs
|
||||
clineMessagesJson, err := json.Marshal(clineMessagesRaw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal clineMessages: %w", err)
|
||||
}
|
||||
|
||||
var messages []*ClineMessage
|
||||
if err := json.Unmarshal(clineMessagesJson, &messages); err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal clineMessages: %w", err)
|
||||
}
|
||||
|
||||
return messages, nil
|
||||
}
|
||||
|
||||
// ConvertProtoToMessage converts a protobuf ClineMessage to our local Message struct
|
||||
func ConvertProtoToMessage(protoMsg *cline.ClineMessage) *ClineMessage {
|
||||
var msgType MessageType
|
||||
var say, ask string
|
||||
|
||||
// Convert message type
|
||||
switch protoMsg.Type {
|
||||
case cline.ClineMessageType_ASK:
|
||||
msgType = MessageTypeAsk
|
||||
ask = convertProtoAskType(protoMsg.Ask)
|
||||
case cline.ClineMessageType_SAY:
|
||||
msgType = MessageTypeSay
|
||||
say = convertProtoSayType(protoMsg.Say)
|
||||
default:
|
||||
msgType = MessageTypeSay
|
||||
say = "unknown"
|
||||
}
|
||||
|
||||
return &ClineMessage{
|
||||
Type: msgType,
|
||||
Text: protoMsg.Text,
|
||||
Timestamp: protoMsg.Ts,
|
||||
Reasoning: protoMsg.Reasoning,
|
||||
Say: say,
|
||||
Ask: ask,
|
||||
Partial: protoMsg.Partial,
|
||||
}
|
||||
}
|
||||
|
||||
// convertProtoAskType converts protobuf ask type to string
|
||||
func convertProtoAskType(askType cline.ClineAsk) string {
|
||||
switch askType {
|
||||
case cline.ClineAsk_FOLLOWUP:
|
||||
return string(AskTypeFollowup)
|
||||
case cline.ClineAsk_PLAN_MODE_RESPOND:
|
||||
return string(AskTypePlanModeRespond)
|
||||
case cline.ClineAsk_COMMAND:
|
||||
return string(AskTypeCommand)
|
||||
case cline.ClineAsk_COMMAND_OUTPUT:
|
||||
return string(AskTypeCommandOutput)
|
||||
case cline.ClineAsk_COMPLETION_RESULT:
|
||||
return string(AskTypeCompletionResult)
|
||||
case cline.ClineAsk_TOOL:
|
||||
return string(AskTypeTool)
|
||||
case cline.ClineAsk_API_REQ_FAILED:
|
||||
return string(AskTypeAPIReqFailed)
|
||||
case cline.ClineAsk_RESUME_TASK:
|
||||
return string(AskTypeResumeTask)
|
||||
case cline.ClineAsk_RESUME_COMPLETED_TASK:
|
||||
return string(AskTypeResumeCompletedTask)
|
||||
case cline.ClineAsk_MISTAKE_LIMIT_REACHED:
|
||||
return string(AskTypeMistakeLimitReached)
|
||||
case cline.ClineAsk_AUTO_APPROVAL_MAX_REQ_REACHED:
|
||||
return string(AskTypeAutoApprovalMaxReached)
|
||||
case cline.ClineAsk_BROWSER_ACTION_LAUNCH:
|
||||
return string(AskTypeBrowserActionLaunch)
|
||||
case cline.ClineAsk_USE_MCP_SERVER:
|
||||
return string(AskTypeUseMcpServer)
|
||||
case cline.ClineAsk_NEW_TASK:
|
||||
return string(AskTypeNewTask)
|
||||
case cline.ClineAsk_CONDENSE:
|
||||
return string(AskTypeCondense)
|
||||
case cline.ClineAsk_REPORT_BUG:
|
||||
return string(AskTypeReportBug)
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
// convertProtoSayType converts protobuf say type to string
|
||||
func convertProtoSayType(sayType cline.ClineSay) string {
|
||||
switch sayType {
|
||||
case cline.ClineSay_TASK:
|
||||
return string(SayTypeTask)
|
||||
case cline.ClineSay_ERROR:
|
||||
return string(SayTypeError)
|
||||
case cline.ClineSay_API_REQ_STARTED:
|
||||
return string(SayTypeAPIReqStarted)
|
||||
case cline.ClineSay_API_REQ_FINISHED:
|
||||
return string(SayTypeAPIReqFinished)
|
||||
case cline.ClineSay_TEXT:
|
||||
return string(SayTypeText)
|
||||
case cline.ClineSay_REASONING:
|
||||
return string(SayTypeReasoning)
|
||||
case cline.ClineSay_COMPLETION_RESULT_SAY:
|
||||
return string(SayTypeCompletionResult)
|
||||
case cline.ClineSay_USER_FEEDBACK:
|
||||
return string(SayTypeUserFeedback)
|
||||
case cline.ClineSay_USER_FEEDBACK_DIFF:
|
||||
return string(SayTypeUserFeedbackDiff)
|
||||
case cline.ClineSay_API_REQ_RETRIED:
|
||||
return string(SayTypeAPIReqRetried)
|
||||
case cline.ClineSay_COMMAND_SAY:
|
||||
return string(SayTypeCommand)
|
||||
case cline.ClineSay_COMMAND_OUTPUT_SAY:
|
||||
return string(SayTypeCommandOutput)
|
||||
case cline.ClineSay_TOOL_SAY:
|
||||
return string(SayTypeTool)
|
||||
case cline.ClineSay_SHELL_INTEGRATION_WARNING:
|
||||
return string(SayTypeShellIntegrationWarning)
|
||||
case cline.ClineSay_BROWSER_ACTION_LAUNCH_SAY:
|
||||
return string(SayTypeBrowserActionLaunch)
|
||||
case cline.ClineSay_BROWSER_ACTION:
|
||||
return string(SayTypeBrowserAction)
|
||||
case cline.ClineSay_BROWSER_ACTION_RESULT:
|
||||
return string(SayTypeBrowserActionResult)
|
||||
case cline.ClineSay_MCP_SERVER_REQUEST_STARTED:
|
||||
return string(SayTypeMcpServerRequestStarted)
|
||||
case cline.ClineSay_MCP_SERVER_RESPONSE:
|
||||
return string(SayTypeMcpServerResponse)
|
||||
case cline.ClineSay_MCP_NOTIFICATION:
|
||||
return string(SayTypeMcpNotification)
|
||||
case cline.ClineSay_USE_MCP_SERVER_SAY:
|
||||
return string(SayTypeUseMcpServer)
|
||||
case cline.ClineSay_DIFF_ERROR:
|
||||
return string(SayTypeDiffError)
|
||||
case cline.ClineSay_DELETED_API_REQS:
|
||||
return string(SayTypeDeletedAPIReqs)
|
||||
case cline.ClineSay_CLINEIGNORE_ERROR:
|
||||
return string(SayTypeClineignoreError)
|
||||
case cline.ClineSay_CHECKPOINT_CREATED:
|
||||
return string(SayTypeCheckpointCreated)
|
||||
case cline.ClineSay_LOAD_MCP_DOCUMENTATION:
|
||||
return string(SayTypeLoadMcpDocumentation)
|
||||
case cline.ClineSay_INFO:
|
||||
return string(SayTypeInfo)
|
||||
case cline.ClineSay_TASK_PROGRESS:
|
||||
return string(SayTypeTaskProgress)
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package types
|
||||
|
||||
import (
|
||||
"sync"
|
||||
)
|
||||
|
||||
// ConversationState manages the state of the conversation
|
||||
type ConversationState struct {
|
||||
mu sync.RWMutex
|
||||
StreamingMessage *StreamingMessage `json:"streamingMessage,omitempty"`
|
||||
}
|
||||
|
||||
// StreamingMessage manages state for streaming message display
|
||||
type StreamingMessage struct {
|
||||
CurrentKey string `json:"currentKey"`
|
||||
LastText string `json:"lastText"`
|
||||
LastToolMessage string `json:"lastToolMessage,omitempty"`
|
||||
}
|
||||
|
||||
// NewConversationState creates a new conversation state
|
||||
func NewConversationState() *ConversationState {
|
||||
return &ConversationState{
|
||||
StreamingMessage: &StreamingMessage{},
|
||||
}
|
||||
}
|
||||
|
||||
// SetStreamingMessage updates the streaming message state
|
||||
func (cs *ConversationState) SetStreamingMessage(key, text string) {
|
||||
cs.mu.Lock()
|
||||
defer cs.mu.Unlock()
|
||||
cs.StreamingMessage.CurrentKey = key
|
||||
cs.StreamingMessage.LastText = text
|
||||
}
|
||||
|
||||
// GetStreamingMessage returns the current streaming message state
|
||||
func (cs *ConversationState) GetStreamingMessage() *StreamingMessage {
|
||||
cs.mu.RLock()
|
||||
defer cs.mu.RUnlock()
|
||||
return &StreamingMessage{
|
||||
CurrentKey: cs.StreamingMessage.CurrentKey,
|
||||
LastText: cs.StreamingMessage.LastText,
|
||||
LastToolMessage: cs.StreamingMessage.LastToolMessage,
|
||||
}
|
||||
}
|
||||
|
||||
// Clear resets state
|
||||
func (cs *ConversationState) Clear() {
|
||||
cs.mu.Lock()
|
||||
defer cs.mu.Unlock()
|
||||
cs.StreamingMessage = &StreamingMessage{}
|
||||
}
|
||||
|
||||
// ExtensionState represents the server-side extension state structure
|
||||
type ExtensionState struct {
|
||||
CurrentTaskItem *CurrentTaskItem `json:"currentTaskItem,omitempty"`
|
||||
}
|
||||
|
||||
// CurrentTaskItem - minimal struct with just what we need
|
||||
type CurrentTaskItem struct {
|
||||
Id string `json:"id"`
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"runtime"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var (
|
||||
// These will be set at build time via ldflags
|
||||
Version = "dev"
|
||||
Commit = "unknown"
|
||||
Date = "unknown"
|
||||
BuiltBy = "unknown"
|
||||
)
|
||||
|
||||
// NewVersionCommand creates the version command
|
||||
func NewVersionCommand() *cobra.Command {
|
||||
var short bool
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "version",
|
||||
Short: "Show version information",
|
||||
Long: `Display version information for the Cline Go host.`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if short {
|
||||
fmt.Println(Version)
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Printf("Cline Go Host\n")
|
||||
fmt.Printf("Version: %s\n", Version)
|
||||
fmt.Printf("Commit: %s\n", Commit)
|
||||
fmt.Printf("Built: %s\n", Date)
|
||||
fmt.Printf("Built by: %s\n", BuiltBy)
|
||||
fmt.Printf("Go version: %s\n", runtime.Version())
|
||||
fmt.Printf("OS/Arch: %s/%s\n", runtime.GOOS, runtime.GOARCH)
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().BoolVar(&short, "short", false, "show only version number")
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package common
|
||||
|
||||
// WE WILL HAVE TO MIGRATE THIS FROM DATA TO v1 LATER
|
||||
const SETTINGS_SUBFOLDER = "data"
|
||||
|
||||
const DEFAULT_CLINE_CORE_PORT = 50052
|
||||
@@ -0,0 +1,54 @@
|
||||
package common
|
||||
|
||||
// Database query constants for the SQLite locks database
|
||||
const (
|
||||
|
||||
// SelectInstanceLocksSQL selects all instance locks ordered by creation time
|
||||
SelectInstanceLocksSQL = `
|
||||
SELECT id, held_by, lock_type, lock_target, locked_at
|
||||
FROM locks
|
||||
WHERE lock_type = 'instance'
|
||||
ORDER BY locked_at ASC
|
||||
`
|
||||
|
||||
SelectInstanceLockByHolderSQL = `
|
||||
SELECT held_by, lock_target, locked_at
|
||||
FROM locks
|
||||
WHERE held_by = ? AND lock_type = 'instance'
|
||||
`
|
||||
SelectInstanceLockHoldersAscSQL = `
|
||||
SELECT held_by, lock_target, locked_at
|
||||
FROM locks
|
||||
WHERE lock_type = 'instance'
|
||||
ORDER BY locked_at ASC
|
||||
`
|
||||
|
||||
// DeleteInstanceLockSQL deletes an instance lock by address
|
||||
DeleteInstanceLockSQL = `
|
||||
DELETE FROM locks
|
||||
WHERE held_by = ? AND lock_type = 'instance'
|
||||
`
|
||||
|
||||
InsertFileLockSQL = `
|
||||
INSERT INTO locks (held_by, lock_type, lock_target, locked_at)
|
||||
VALUES (?, 'file', ?, ?)
|
||||
`
|
||||
|
||||
// DeleteFileLockSQL deletes a file lock by holder and target
|
||||
DeleteFileLockSQL = `
|
||||
DELETE FROM locks
|
||||
WHERE held_by = ? AND lock_type = 'file' AND lock_target = ?
|
||||
`
|
||||
|
||||
// CountInstanceLockSQL counts instance locks for a given address
|
||||
CountInstanceLockSQL = `
|
||||
SELECT COUNT(*) FROM locks
|
||||
WHERE held_by = ? AND lock_type = 'instance'
|
||||
`
|
||||
|
||||
// InsertInstanceLockSQL inserts or replaces an instance lock
|
||||
InsertInstanceLockSQL = `
|
||||
INSERT OR REPLACE INTO locks (held_by, lock_type, lock_target, locked_at)
|
||||
VALUES (?, 'instance', ?, ?)
|
||||
`
|
||||
)
|
||||
@@ -0,0 +1,54 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"google.golang.org/grpc/health/grpc_health_v1"
|
||||
)
|
||||
|
||||
// CoreInstanceInfo represents a discovered Cline instance
|
||||
// This is the canonical definition used across all CLI packages
|
||||
type CoreInstanceInfo struct {
|
||||
// Full core address including port
|
||||
Address string `json:"address"`
|
||||
// Host bridge service address that core holds (host is ALWAYS running on localhost FYI)
|
||||
HostServiceAddress string `json:"host_port"`
|
||||
Status grpc_health_v1.HealthCheckResponse_ServingStatus `json:"status"`
|
||||
LastSeen time.Time `json:"last_seen"`
|
||||
ProcessPID int `json:"process_pid,omitempty"`
|
||||
Version string `json:"version,omitempty"`
|
||||
}
|
||||
|
||||
func (c *CoreInstanceInfo) CorePort() int {
|
||||
_, port, _ := ParseHostPort(c.Address)
|
||||
return port
|
||||
}
|
||||
|
||||
func (c *CoreInstanceInfo) HostPort() int {
|
||||
_, port, _ := ParseHostPort(c.HostServiceAddress)
|
||||
return port
|
||||
}
|
||||
|
||||
func (c *CoreInstanceInfo) StatusString() string {
|
||||
return c.Status.String()
|
||||
}
|
||||
|
||||
// LockRow represents a row in the locks table
|
||||
type LockRow struct {
|
||||
ID int64 `json:"id"`
|
||||
HeldBy string `json:"held_by"`
|
||||
LockType string `json:"lock_type"`
|
||||
LockTarget string `json:"lock_target"`
|
||||
LockedAt int64 `json:"locked_at"`
|
||||
}
|
||||
|
||||
// InstancesOutput represents the JSON output format for instance listing
|
||||
type InstancesOutput struct {
|
||||
DefaultInstance string `json:"default_instance"`
|
||||
CoreInstances []CoreInstanceInfo `json:"instances"`
|
||||
}
|
||||
|
||||
type DefaultCoreInstance struct {
|
||||
Address string `json:"default_instance"`
|
||||
LastUpdated string `json:"last_updated"`
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
"google.golang.org/grpc/health/grpc_health_v1"
|
||||
)
|
||||
|
||||
// ParseHostPort parses a host:port address and returns the host and port separately
|
||||
func ParseHostPort(address string) (string, int, error) {
|
||||
host, portStr, err := net.SplitHostPort(address)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
port, err := strconv.Atoi(portStr)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
return host, port, nil
|
||||
}
|
||||
|
||||
// IsLocalAddress checks if the given host is a local/loopback address
|
||||
// Supports both IPv4 (localhost, 127.0.0.1) and IPv6 (::1) addresses
|
||||
func IsLocalAddress(host string) bool {
|
||||
// Handle common localhost names
|
||||
if host == "localhost" {
|
||||
return true
|
||||
}
|
||||
|
||||
// Parse as IP and check if it's a loopback
|
||||
if ip := net.ParseIP(host); ip != nil {
|
||||
return ip.IsLoopback()
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// PerformHealthCheck performs a gRPC health check on the given address
|
||||
// Will return UNKNOWN if the service is unreachable (error)
|
||||
func PerformHealthCheck(ctx context.Context, address string) (grpc_health_v1.HealthCheckResponse_ServingStatus, error) {
|
||||
conn, err := grpc.DialContext(ctx, address, grpc.WithTransportCredentials(insecure.NewCredentials()))
|
||||
if err != nil {
|
||||
return grpc_health_v1.HealthCheckResponse_UNKNOWN, err
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
healthClient := grpc_health_v1.NewHealthClient(conn)
|
||||
resp, err := healthClient.Check(ctx, &grpc_health_v1.HealthCheckRequest{})
|
||||
if err != nil {
|
||||
return grpc_health_v1.HealthCheckResponse_UNKNOWN, err
|
||||
}
|
||||
|
||||
return resp.Status, nil
|
||||
}
|
||||
|
||||
// It's healthy if we can reach it and it responds with SERVING
|
||||
func IsInstanceHealthy(ctx context.Context, address string) bool {
|
||||
status, err := PerformHealthCheck(ctx, address)
|
||||
return err == nil && status == grpc_health_v1.HealthCheckResponse_SERVING
|
||||
}
|
||||
|
||||
// It's (likely) our instance if we can reach it and it responds to health checks
|
||||
func IsInstanceOurs(ctx context.Context, address string) bool {
|
||||
_, err := PerformHealthCheck(ctx, address)
|
||||
return err != nil
|
||||
}
|
||||
|
||||
// (unreachable or not serving)
|
||||
func IsInstanceStale(ctx context.Context, address string) (grpc_health_v1.HealthCheckResponse_ServingStatus, bool, error) {
|
||||
status, err := PerformHealthCheck(ctx, address)
|
||||
isStale := err != nil || status != grpc_health_v1.HealthCheckResponse_SERVING
|
||||
return status, isStale, err
|
||||
}
|
||||
|
||||
// IsPortAvailable checks if a port is available for binding
|
||||
func IsPortAvailable(port int) bool {
|
||||
address := fmt.Sprintf("localhost:%d", port)
|
||||
listener, err := net.Listen("tcp", address)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
listener.Close()
|
||||
return true
|
||||
}
|
||||
|
||||
// FindAvailablePortPair finds two available ports by letting the OS allocate them
|
||||
func FindAvailablePortPair() (corePort, hostPort int, err error) {
|
||||
coreListener, err := net.Listen("tcp", ":0")
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
defer coreListener.Close()
|
||||
|
||||
hostListener, err := net.Listen("tcp", ":0")
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
defer hostListener.Close()
|
||||
|
||||
corePort = coreListener.Addr().(*net.TCPAddr).Port
|
||||
hostPort = hostListener.Addr().(*net.TCPAddr).Port
|
||||
|
||||
return corePort, hostPort, nil
|
||||
}
|
||||
|
||||
// NormalizeAddressForGRPC converts address to host:port for grpc client with proper normalization
|
||||
func NormalizeAddressForGRPC(address string) (string, error) {
|
||||
host, port, err := ParseHostPort(address)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Normalize local addresses to localhost for gRPC compatibility
|
||||
if IsLocalAddress(host) {
|
||||
return fmt.Sprintf("localhost:%d", port), nil
|
||||
}
|
||||
|
||||
return address, nil
|
||||
}
|
||||
|
||||
// RetryOperation performs an operation with retry logic
|
||||
func RetryOperation(maxRetries int, timeoutPerAttempt time.Duration, operation func() error) error {
|
||||
var lastErr error
|
||||
|
||||
for attempt := 1; attempt <= maxRetries; attempt++ {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeoutPerAttempt)
|
||||
|
||||
// Create a channel to capture the operation result
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- operation()
|
||||
}()
|
||||
|
||||
select {
|
||||
case err := <-done:
|
||||
cancel()
|
||||
if err == nil {
|
||||
return nil // Success
|
||||
}
|
||||
lastErr = err
|
||||
case <-ctx.Done():
|
||||
cancel()
|
||||
lastErr = ctx.Err()
|
||||
}
|
||||
|
||||
// Add delay between attempts (except for the last one)
|
||||
if attempt < maxRetries {
|
||||
time.Sleep(1 * time.Second)
|
||||
}
|
||||
}
|
||||
|
||||
return fmt.Errorf("operation failed after %d attempts: %w", maxRetries, lastErr)
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
package hostbridge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
|
||||
proto "github.com/cline/grpc-go/host"
|
||||
)
|
||||
|
||||
// diffSession represents an in-memory diff editing session
|
||||
type diffSession struct {
|
||||
originalPath string // File path from OpenDiff request
|
||||
originalContent []byte // Original file content (for comparison)
|
||||
currentContent []byte // Current modified content
|
||||
lines []string // Current content split into lines
|
||||
encoding string // File encoding (default: utf8)
|
||||
}
|
||||
|
||||
// DiffService implements the proto.DiffServiceServer interface
|
||||
type DiffService struct {
|
||||
proto.UnimplementedDiffServiceServer
|
||||
verbose bool
|
||||
sessions *sync.Map // thread-safe: diffId -> *diffSession
|
||||
counter *int64 // atomic counter for unique IDs
|
||||
}
|
||||
|
||||
// NewDiffService creates a new DiffService
|
||||
func NewDiffService(verbose bool) *DiffService {
|
||||
counter := int64(0)
|
||||
return &DiffService{
|
||||
verbose: verbose,
|
||||
sessions: &sync.Map{},
|
||||
counter: &counter,
|
||||
}
|
||||
}
|
||||
|
||||
// generateDiffID creates a unique diff ID
|
||||
func (s *DiffService) generateDiffID() string {
|
||||
id := atomic.AddInt64(s.counter, 1)
|
||||
return fmt.Sprintf("diff_%d_%d", os.Getpid(), id)
|
||||
}
|
||||
|
||||
// splitLines splits content into lines, preserving line ending information
|
||||
func splitLines(content string) []string {
|
||||
if content == "" {
|
||||
return []string{}
|
||||
}
|
||||
|
||||
lines := []string{}
|
||||
current := ""
|
||||
|
||||
for _, char := range content {
|
||||
if char == '\n' {
|
||||
lines = append(lines, current)
|
||||
current = ""
|
||||
} else if char != '\r' { // Skip \r characters, handle \r\n as \n
|
||||
current += string(char)
|
||||
}
|
||||
}
|
||||
|
||||
// Add the last line if it doesn't end with newline
|
||||
if current != "" {
|
||||
lines = append(lines, current)
|
||||
}
|
||||
|
||||
return lines
|
||||
}
|
||||
|
||||
// joinLines joins lines back into content with newlines
|
||||
func joinLines(lines []string) string {
|
||||
if len(lines) == 0 {
|
||||
return ""
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
// OpenDiff opens a diff view for the specified file
|
||||
func (s *DiffService) OpenDiff(ctx context.Context, req *proto.OpenDiffRequest) (*proto.OpenDiffResponse, error) {
|
||||
if s.verbose {
|
||||
log.Printf("OpenDiff called for path: %s", req.GetPath())
|
||||
}
|
||||
|
||||
diffID := s.generateDiffID()
|
||||
|
||||
var originalContent []byte
|
||||
|
||||
// Check if file exists and read original content
|
||||
if req.GetPath() != "" {
|
||||
if _, err := os.Stat(req.GetPath()); err == nil {
|
||||
// File exists, read its content
|
||||
var readErr error
|
||||
originalContent, readErr = ioutil.ReadFile(req.GetPath())
|
||||
if readErr != nil {
|
||||
return nil, fmt.Errorf("failed to read original file: %w", readErr)
|
||||
}
|
||||
} else {
|
||||
// File doesn't exist, use empty content
|
||||
originalContent = []byte{}
|
||||
}
|
||||
}
|
||||
|
||||
// Use provided content as the initial current content
|
||||
currentContent := []byte(req.GetContent())
|
||||
|
||||
// Create the diff session
|
||||
session := &diffSession{
|
||||
originalPath: req.GetPath(),
|
||||
originalContent: originalContent,
|
||||
currentContent: currentContent,
|
||||
lines: splitLines(req.GetContent()),
|
||||
encoding: "utf8", // Default encoding
|
||||
}
|
||||
|
||||
// Store the session
|
||||
s.sessions.Store(diffID, session)
|
||||
|
||||
if s.verbose {
|
||||
log.Printf("Created diff session: %s (original: %d bytes, current: %d bytes)",
|
||||
diffID, len(originalContent), len(currentContent))
|
||||
}
|
||||
|
||||
return &proto.OpenDiffResponse{
|
||||
DiffId: &diffID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetDocumentText returns the current content of the diff document
|
||||
func (s *DiffService) GetDocumentText(ctx context.Context, req *proto.GetDocumentTextRequest) (*proto.GetDocumentTextResponse, error) {
|
||||
if s.verbose {
|
||||
log.Printf("GetDocumentText called for diff ID: %s", req.GetDiffId())
|
||||
}
|
||||
|
||||
sessionInterface, exists := s.sessions.Load(req.GetDiffId())
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("diff session not found: %s", req.GetDiffId())
|
||||
}
|
||||
|
||||
session := sessionInterface.(*diffSession)
|
||||
content := string(session.currentContent)
|
||||
|
||||
return &proto.GetDocumentTextResponse{
|
||||
Content: &content,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ReplaceText replaces text in the diff document using line-based operations
|
||||
func (s *DiffService) ReplaceText(ctx context.Context, req *proto.ReplaceTextRequest) (*proto.ReplaceTextResponse, error) {
|
||||
if s.verbose {
|
||||
log.Printf("ReplaceText called for diff ID: %s, lines %d-%d",
|
||||
req.GetDiffId(), req.GetStartLine(), req.GetEndLine())
|
||||
}
|
||||
|
||||
sessionInterface, exists := s.sessions.Load(req.GetDiffId())
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("diff session not found: %s", req.GetDiffId())
|
||||
}
|
||||
|
||||
session := sessionInterface.(*diffSession)
|
||||
|
||||
startLine := int(req.GetStartLine())
|
||||
endLine := int(req.GetEndLine())
|
||||
newContent := req.GetContent()
|
||||
|
||||
// Validate line ranges
|
||||
if startLine < 0 {
|
||||
startLine = 0
|
||||
}
|
||||
if endLine < startLine {
|
||||
endLine = startLine
|
||||
}
|
||||
|
||||
// Split new content into lines
|
||||
newLines := splitLines(newContent)
|
||||
|
||||
// Ensure we have enough lines in the current content
|
||||
for len(session.lines) < endLine {
|
||||
session.lines = append(session.lines, "")
|
||||
}
|
||||
|
||||
// Replace the specified line range
|
||||
if endLine > len(session.lines) {
|
||||
// Extending beyond current content - append new lines
|
||||
session.lines = append(session.lines[:startLine], newLines...)
|
||||
} else {
|
||||
// Replace within existing content
|
||||
result := make([]string, 0, len(session.lines)-endLine+startLine+len(newLines))
|
||||
result = append(result, session.lines[:startLine]...)
|
||||
result = append(result, newLines...)
|
||||
result = append(result, session.lines[endLine:]...)
|
||||
session.lines = result
|
||||
}
|
||||
|
||||
// Update current content
|
||||
session.currentContent = []byte(joinLines(session.lines))
|
||||
|
||||
// Store the updated session
|
||||
s.sessions.Store(req.GetDiffId(), session)
|
||||
|
||||
if s.verbose {
|
||||
log.Printf("Updated diff session %s: %d lines, %d bytes",
|
||||
req.GetDiffId(), len(session.lines), len(session.currentContent))
|
||||
}
|
||||
|
||||
return &proto.ReplaceTextResponse{}, nil
|
||||
}
|
||||
|
||||
// ScrollDiff scrolls the diff view to a specific line (no-op for CLI)
|
||||
func (s *DiffService) ScrollDiff(ctx context.Context, req *proto.ScrollDiffRequest) (*proto.ScrollDiffResponse, error) {
|
||||
if s.verbose {
|
||||
log.Printf("ScrollDiff called for diff ID: %s, line: %d", req.GetDiffId(), req.GetLine())
|
||||
}
|
||||
|
||||
// Verify session exists
|
||||
if _, exists := s.sessions.Load(req.GetDiffId()); !exists {
|
||||
return nil, fmt.Errorf("diff session not found: %s", req.GetDiffId())
|
||||
}
|
||||
|
||||
// In a CLI implementation, scrolling is a no-op
|
||||
// In a GUI implementation, this would scroll the view to the specified line
|
||||
return &proto.ScrollDiffResponse{}, nil
|
||||
}
|
||||
|
||||
// TruncateDocument truncates the diff document at the specified line
|
||||
func (s *DiffService) TruncateDocument(ctx context.Context, req *proto.TruncateDocumentRequest) (*proto.TruncateDocumentResponse, error) {
|
||||
if s.verbose {
|
||||
log.Printf("TruncateDocument called for diff ID: %s, end line: %d", req.GetDiffId(), req.GetEndLine())
|
||||
}
|
||||
|
||||
sessionInterface, exists := s.sessions.Load(req.GetDiffId())
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("diff session not found: %s", req.GetDiffId())
|
||||
}
|
||||
|
||||
session := sessionInterface.(*diffSession)
|
||||
endLine := int(req.GetEndLine())
|
||||
|
||||
// Truncate lines at the specified position
|
||||
if endLine >= 0 && endLine < len(session.lines) {
|
||||
session.lines = session.lines[:endLine]
|
||||
session.currentContent = []byte(joinLines(session.lines))
|
||||
|
||||
// Store the updated session
|
||||
s.sessions.Store(req.GetDiffId(), session)
|
||||
|
||||
if s.verbose {
|
||||
log.Printf("Truncated diff session %s to %d lines", req.GetDiffId(), len(session.lines))
|
||||
}
|
||||
}
|
||||
|
||||
return &proto.TruncateDocumentResponse{}, nil
|
||||
}
|
||||
|
||||
// SaveDocument saves the diff document to the original file
|
||||
func (s *DiffService) SaveDocument(ctx context.Context, req *proto.SaveDocumentRequest) (*proto.SaveDocumentResponse, error) {
|
||||
if s.verbose {
|
||||
log.Printf("SaveDocument called for diff ID: %s", req.GetDiffId())
|
||||
}
|
||||
|
||||
sessionInterface, exists := s.sessions.Load(req.GetDiffId())
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("diff session not found: %s", req.GetDiffId())
|
||||
}
|
||||
|
||||
session := sessionInterface.(*diffSession)
|
||||
|
||||
if session.originalPath == "" {
|
||||
return nil, fmt.Errorf("no file path specified for diff session: %s", req.GetDiffId())
|
||||
}
|
||||
|
||||
// Create parent directories if they don't exist
|
||||
dir := filepath.Dir(session.originalPath)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return nil, fmt.Errorf("failed to create directories: %w", err)
|
||||
}
|
||||
|
||||
// Write the current content to the original file
|
||||
if err := ioutil.WriteFile(session.originalPath, session.currentContent, 0644); err != nil {
|
||||
return nil, fmt.Errorf("failed to save file: %w", err)
|
||||
}
|
||||
|
||||
if s.verbose {
|
||||
log.Printf("Saved diff session %s to file: %s (%d bytes)",
|
||||
req.GetDiffId(), session.originalPath, len(session.currentContent))
|
||||
}
|
||||
|
||||
return &proto.SaveDocumentResponse{}, nil
|
||||
}
|
||||
|
||||
// CloseAllDiffs closes all diff views and cleans up all sessions
|
||||
func (s *DiffService) CloseAllDiffs(ctx context.Context, req *proto.CloseAllDiffsRequest) (*proto.CloseAllDiffsResponse, error) {
|
||||
if s.verbose {
|
||||
log.Printf("CloseAllDiffs called")
|
||||
}
|
||||
|
||||
var count int64
|
||||
|
||||
s.sessions.Range(func(key, value any) bool {
|
||||
// Optional: attempt to close if the value supports it
|
||||
if c, ok := value.(interface{ Close() error }); ok {
|
||||
_ = c.Close() // best-effort; ignore error
|
||||
}
|
||||
|
||||
s.sessions.Delete(key)
|
||||
atomic.AddInt64(&count, 1)
|
||||
return true
|
||||
})
|
||||
|
||||
if s.verbose {
|
||||
log.Printf("Closed %d diff sessions", count)
|
||||
}
|
||||
|
||||
return &proto.CloseAllDiffsResponse{}, nil
|
||||
}
|
||||
|
||||
// OpenMultiFileDiff displays a diff view comparing before/after states for multiple files
|
||||
func (s *DiffService) OpenMultiFileDiff(ctx context.Context, req *proto.OpenMultiFileDiffRequest) (*proto.OpenMultiFileDiffResponse, error) {
|
||||
if s.verbose {
|
||||
log.Printf("OpenMultiFileDiff called with title: %s, %d files", req.GetTitle(), len(req.GetDiffs()))
|
||||
}
|
||||
|
||||
// In a CLI implementation, we could display the diffs to console
|
||||
// For now, we'll just log the information
|
||||
title := req.GetTitle()
|
||||
if title == "" {
|
||||
title = "Multi-file diff"
|
||||
}
|
||||
|
||||
if s.verbose {
|
||||
log.Printf("=== %s ===", title)
|
||||
for i, diff := range req.GetDiffs() {
|
||||
log.Printf("File %d: %s", i+1, diff.GetFilePath())
|
||||
log.Printf(" Left content: %d bytes", len(diff.GetLeftContent()))
|
||||
log.Printf(" Right content: %d bytes", len(diff.GetRightContent()))
|
||||
}
|
||||
}
|
||||
|
||||
// In a more sophisticated CLI implementation, we could:
|
||||
// 1. Use a diff library to generate unified diffs
|
||||
// 2. Display them with colors
|
||||
// 3. Allow navigation between files
|
||||
// For now, this is a no-op that just acknowledges the request
|
||||
|
||||
return &proto.OpenMultiFileDiffResponse{}, nil
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package hostbridge
|
||||
|
||||
import (
|
||||
"log"
|
||||
|
||||
"github.com/cline/grpc-go/host"
|
||||
)
|
||||
|
||||
// WatchService implements the host.WatchServiceServer interface
|
||||
type WatchService struct {
|
||||
host.UnimplementedWatchServiceServer
|
||||
coreAddress string
|
||||
verbose bool
|
||||
}
|
||||
|
||||
// NewWatchService creates a new WatchService
|
||||
func NewWatchService(coreAddress string, verbose bool) *WatchService {
|
||||
return &WatchService{
|
||||
coreAddress: coreAddress,
|
||||
verbose: verbose,
|
||||
}
|
||||
}
|
||||
|
||||
// SubscribeToFile subscribes to file change notifications
|
||||
func (s *WatchService) SubscribeToFile(req *host.SubscribeToFileRequest, stream host.WatchService_SubscribeToFileServer) error {
|
||||
if s.verbose {
|
||||
log.Printf("SubscribeToFile called for path: %s", req.GetPath())
|
||||
}
|
||||
|
||||
// For console implementation, we'll just log that we would watch the file
|
||||
// In a real implementation, we'd use fsnotify or similar to watch file changes
|
||||
log.Printf("[Cline] Would watch file: %s", req.GetPath())
|
||||
|
||||
// Keep the stream open but don't send any events for now
|
||||
// In a real implementation, we'd send FileChangeEvent messages when files change
|
||||
<-stream.Context().Done()
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package hostbridge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
proto "github.com/cline/grpc-go/host"
|
||||
)
|
||||
|
||||
// WindowService implements the proto.WindowServiceServer interface
|
||||
type WindowService struct {
|
||||
proto.UnimplementedWindowServiceServer
|
||||
coreAddress string
|
||||
verbose bool
|
||||
}
|
||||
|
||||
// NewWindowService creates a new WindowService
|
||||
func NewWindowService(coreAddress string, verbose bool) *WindowService {
|
||||
return &WindowService{
|
||||
coreAddress: coreAddress,
|
||||
verbose: verbose,
|
||||
}
|
||||
}
|
||||
|
||||
// ShowTextDocument opens a text document for viewing/editing
|
||||
func (s *WindowService) ShowTextDocument(ctx context.Context, req *proto.ShowTextDocumentRequest) (*proto.TextEditorInfo, error) {
|
||||
if s.verbose {
|
||||
log.Printf("ShowTextDocument called for path: %s", req.GetPath())
|
||||
}
|
||||
|
||||
// For console implementation, we'll just log that we would open the document
|
||||
fmt.Printf("[Cline] Would open document: %s\n", req.GetPath())
|
||||
|
||||
return &proto.TextEditorInfo{
|
||||
DocumentPath: req.GetPath(),
|
||||
IsActive: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ShowOpenDialogue shows a file open dialog
|
||||
func (s *WindowService) ShowOpenDialogue(ctx context.Context, req *proto.ShowOpenDialogueRequest) (*proto.SelectedResources, error) {
|
||||
if s.verbose {
|
||||
log.Printf("ShowOpenDialogue called")
|
||||
}
|
||||
|
||||
// For console implementation, return empty list (user cancelled)
|
||||
return &proto.SelectedResources{
|
||||
Paths: []string{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ShowMessage displays a message to the user
|
||||
func (s *WindowService) ShowMessage(ctx context.Context, req *proto.ShowMessageRequest) (*proto.SelectedResponse, error) {
|
||||
if s.verbose {
|
||||
log.Printf("ShowMessage called: %s", req.GetMessage())
|
||||
}
|
||||
|
||||
// Display message to console
|
||||
fmt.Printf("[Cline] %s\n", req.GetMessage())
|
||||
|
||||
return &proto.SelectedResponse{}, nil
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package hostbridge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"os"
|
||||
|
||||
"github.com/cline/grpc-go/host"
|
||||
)
|
||||
|
||||
// WorkspaceService implements the host.WorkspaceServiceServer interface
|
||||
type WorkspaceService struct {
|
||||
host.UnimplementedWorkspaceServiceServer
|
||||
coreAddress string
|
||||
verbose bool
|
||||
}
|
||||
|
||||
// NewWorkspaceService creates a new WorkspaceService
|
||||
func NewWorkspaceService(coreAddress string, verbose bool) *WorkspaceService {
|
||||
return &WorkspaceService{
|
||||
coreAddress: coreAddress,
|
||||
verbose: verbose,
|
||||
}
|
||||
}
|
||||
|
||||
// GetWorkspacePaths returns the workspace directory paths
|
||||
func (s *WorkspaceService) GetWorkspacePaths(ctx context.Context, req *host.GetWorkspacePathsRequest) (*host.GetWorkspacePathsResponse, error) {
|
||||
if s.verbose {
|
||||
log.Printf("GetWorkspacePaths called")
|
||||
}
|
||||
|
||||
// Get current working directory as the workspace
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &host.GetWorkspacePathsResponse{
|
||||
Paths: []string{cwd},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// SaveOpenDocumentIfDirty saves an open document if it has unsaved changes
|
||||
func (s *WorkspaceService) SaveOpenDocumentIfDirty(ctx context.Context, req *host.SaveOpenDocumentIfDirtyRequest) (*host.SaveOpenDocumentIfDirtyResponse, error) {
|
||||
if s.verbose {
|
||||
log.Printf("SaveOpenDocumentIfDirty called for path: %s", req.GetPath())
|
||||
}
|
||||
|
||||
// For console implementation, we'll assume the document is already saved
|
||||
// In a real implementation, we'd check if the file has unsaved changes
|
||||
return &host.SaveOpenDocumentIfDirtyResponse{
|
||||
WasSaved: false, // Assume no changes to save
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetDiagnostics returns diagnostic information for a file
|
||||
func (s *WorkspaceService) GetDiagnostics(ctx context.Context, req *host.GetDiagnosticsRequest) (*host.GetDiagnosticsResponse, error) {
|
||||
if s.verbose {
|
||||
log.Printf("GetDiagnostics called for path: %s", req.GetPath())
|
||||
}
|
||||
|
||||
// For console implementation, return empty diagnostics
|
||||
return &host.GetDiagnosticsResponse{
|
||||
Diagnostics: []*host.Diagnostic{},
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
package hostbridge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/atotto/clipboard"
|
||||
"github.com/cline/cli/pkg/cli"
|
||||
"github.com/cline/grpc-go/cline"
|
||||
"github.com/cline/grpc-go/host"
|
||||
"github.com/google/uuid"
|
||||
"google.golang.org/protobuf/proto"
|
||||
)
|
||||
|
||||
// Global shutdown channel - simple approach
|
||||
var globalShutdownCh chan struct{}
|
||||
|
||||
func init() {
|
||||
globalShutdownCh = make(chan struct{})
|
||||
}
|
||||
|
||||
// EnvService implements the host.EnvServiceServer interface
|
||||
type EnvService struct {
|
||||
host.UnimplementedEnvServiceServer
|
||||
verbose bool
|
||||
}
|
||||
|
||||
// NewEnvService creates a new EnvService
|
||||
func NewEnvService(verbose bool) *EnvService {
|
||||
return &EnvService{
|
||||
verbose: verbose,
|
||||
}
|
||||
}
|
||||
|
||||
// ClipboardWriteText writes text to the system clipboard
|
||||
func (s *EnvService) ClipboardWriteText(ctx context.Context, req *cline.StringRequest) (*cline.Empty, error) {
|
||||
if s.verbose {
|
||||
log.Printf("ClipboardWriteText called with text length: %d", len(req.GetValue()))
|
||||
}
|
||||
|
||||
err := clipboard.WriteAll(req.GetValue())
|
||||
if err != nil {
|
||||
if s.verbose {
|
||||
log.Printf("Failed to write to clipboard: %v", err)
|
||||
}
|
||||
// Don't fail if clipboard is not available (e.g., headless environment)
|
||||
// Just log and return success
|
||||
}
|
||||
|
||||
return &cline.Empty{}, nil
|
||||
}
|
||||
|
||||
// ClipboardReadText reads text from the system clipboard
|
||||
func (s *EnvService) ClipboardReadText(ctx context.Context, req *cline.EmptyRequest) (*cline.String, error) {
|
||||
if s.verbose {
|
||||
log.Printf("ClipboardReadText called")
|
||||
}
|
||||
|
||||
text, err := clipboard.ReadAll()
|
||||
if err != nil {
|
||||
if s.verbose {
|
||||
log.Printf("Failed to read from clipboard: %v", err)
|
||||
}
|
||||
// Return empty string if clipboard is not available
|
||||
text = ""
|
||||
}
|
||||
|
||||
return &cline.String{
|
||||
Value: text,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// getMachineIdPath returns the path to the machine ID file
|
||||
func getMachineIdPath() (string, error) {
|
||||
homeDir, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(homeDir, ".cline", "machine-id"), nil
|
||||
}
|
||||
|
||||
// GetMachineId returns a stable machine identifier for telemetry distinctId purposes
|
||||
func (s *EnvService) GetMachineId(ctx context.Context, req *cline.EmptyRequest) (*cline.String, error) {
|
||||
if s.verbose {
|
||||
log.Printf("GetMachineId called")
|
||||
}
|
||||
|
||||
idPath, err := getMachineIdPath()
|
||||
if err != nil {
|
||||
if s.verbose {
|
||||
log.Printf("Failed to get machine ID path: %v", err)
|
||||
}
|
||||
return &cline.String{Value: ""}, nil
|
||||
}
|
||||
|
||||
// Try to read existing machine ID
|
||||
if data, err := os.ReadFile(idPath); err == nil {
|
||||
id := strings.TrimSpace(string(data))
|
||||
if id != "" {
|
||||
if s.verbose {
|
||||
log.Printf("Using existing machine ID: %s", id)
|
||||
}
|
||||
return &cline.String{Value: id}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Generate new machine ID
|
||||
id := uuid.New().String()
|
||||
if s.verbose {
|
||||
log.Printf("Generated new machine ID: %s", id)
|
||||
}
|
||||
|
||||
// Ensure directory exists
|
||||
if err := os.MkdirAll(filepath.Dir(idPath), 0755); err != nil {
|
||||
if s.verbose {
|
||||
log.Printf("Failed to create .cline directory: %v", err)
|
||||
}
|
||||
// Still return the ID even if we can't save it
|
||||
return &cline.String{Value: id}, nil
|
||||
}
|
||||
|
||||
// Try to save the machine ID for future use
|
||||
if err := os.WriteFile(idPath, []byte(id), 0644); err != nil {
|
||||
if s.verbose {
|
||||
log.Printf("Failed to save machine ID: %v", err)
|
||||
}
|
||||
// Still return the ID even if we can't save it
|
||||
}
|
||||
|
||||
return &cline.String{Value: id}, nil
|
||||
}
|
||||
|
||||
// GetHostVersion returns the host platform name and version
|
||||
func (s *EnvService) GetHostVersion(ctx context.Context, req *cline.EmptyRequest) (*host.GetHostVersionResponse, error) {
|
||||
if s.verbose {
|
||||
log.Printf("GetHostVersion called")
|
||||
}
|
||||
|
||||
return &host.GetHostVersionResponse{
|
||||
Platform: proto.String("Cline CLI"),
|
||||
Version: proto.String(""),
|
||||
ClineType: proto.String("CLI"),
|
||||
ClineVersion: proto.String(cli.Version),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetIdeRedirectUri returns a URI that will redirect to the host environment
|
||||
func (s *EnvService) GetIdeRedirectUri(ctx context.Context, req *cline.EmptyRequest) (*cline.String, error) {
|
||||
if s.verbose {
|
||||
log.Printf("GetIdeRedirectUri called")
|
||||
}
|
||||
|
||||
// CLI does not have a URI scheme
|
||||
return &cline.String{Value: ""}, nil
|
||||
}
|
||||
|
||||
// GetTelemetrySettings returns the telemetry settings of the host environment
|
||||
func (s *EnvService) GetTelemetrySettings(ctx context.Context, req *cline.EmptyRequest) (*host.GetTelemetrySettingsResponse, error) {
|
||||
if s.verbose {
|
||||
log.Printf("GetTelemetrySettings called")
|
||||
}
|
||||
|
||||
// CLI does not have its own telemetry settings
|
||||
return &host.GetTelemetrySettingsResponse{
|
||||
IsEnabled: host.Setting_UNSUPPORTED,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// SubscribeToTelemetrySettings returns events when the telemetry settings change
|
||||
func (s *EnvService) SubscribeToTelemetrySettings(req *cline.EmptyRequest, stream host.EnvService_SubscribeToTelemetrySettingsServer) error {
|
||||
if s.verbose {
|
||||
log.Printf("SubscribeToTelemetrySettings called")
|
||||
}
|
||||
|
||||
// CLI does not have telemetry settings changes to stream
|
||||
// Just return without sending any events (empty stream)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Shutdown initiates a graceful shutdown of the host bridge service
|
||||
func (s *EnvService) Shutdown(ctx context.Context, req *cline.EmptyRequest) (*cline.Empty, error) {
|
||||
if s.verbose {
|
||||
log.Printf("Shutdown requested via RPC")
|
||||
}
|
||||
|
||||
// Trigger global shutdown signal
|
||||
select {
|
||||
case globalShutdownCh <- struct{}{}:
|
||||
if s.verbose {
|
||||
log.Printf("Shutdown signal sent successfully")
|
||||
}
|
||||
default:
|
||||
if s.verbose {
|
||||
log.Printf("Shutdown signal already pending")
|
||||
}
|
||||
}
|
||||
|
||||
return &cline.Empty{}, nil
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package hostbridge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
|
||||
"github.com/cline/grpc-go/host"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/health"
|
||||
"google.golang.org/grpc/health/grpc_health_v1"
|
||||
)
|
||||
|
||||
// GrpcServer provides gRPC hostbridge functionality
|
||||
type GrpcServer struct {
|
||||
port int
|
||||
verbose bool
|
||||
server *grpc.Server
|
||||
shutdownCh chan struct{}
|
||||
}
|
||||
|
||||
// NewGrpcServer creates a new GrpcServer
|
||||
func NewGrpcServer(port int, verbose bool) *GrpcServer {
|
||||
return &GrpcServer{
|
||||
port: port,
|
||||
verbose: verbose,
|
||||
shutdownCh: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// Start starts the gRPC hostbridge server
|
||||
func (s *GrpcServer) Start(ctx context.Context) error {
|
||||
if s.verbose {
|
||||
log.Printf("Starting gRPC hostbridge server on port %d", s.port)
|
||||
}
|
||||
|
||||
// Create listener
|
||||
lis, err := net.Listen("tcp", fmt.Sprintf(":%d", s.port))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to listen on port %d: %w", s.port, err)
|
||||
}
|
||||
|
||||
// Create gRPC server
|
||||
s.server = grpc.NewServer()
|
||||
|
||||
// Register health service
|
||||
healthServer := health.NewServer()
|
||||
healthServer.SetServingStatus("", grpc_health_v1.HealthCheckResponse_SERVING)
|
||||
grpc_health_v1.RegisterHealthServer(s.server, healthServer)
|
||||
|
||||
// Register services
|
||||
workspaceService := NewSimpleWorkspaceService(s.verbose)
|
||||
host.RegisterWorkspaceServiceServer(s.server, workspaceService)
|
||||
|
||||
windowService := NewWindowService(s.verbose)
|
||||
host.RegisterWindowServiceServer(s.server, windowService)
|
||||
|
||||
diffService := NewDiffService(s.verbose)
|
||||
host.RegisterDiffServiceServer(s.server, diffService)
|
||||
|
||||
envService := NewEnvService(s.verbose)
|
||||
host.RegisterEnvServiceServer(s.server, envService)
|
||||
|
||||
if s.verbose {
|
||||
log.Printf("Registered HealthService")
|
||||
log.Printf("Registered WorkspaceService")
|
||||
log.Printf("Registered WindowService")
|
||||
log.Printf("Registered DiffService")
|
||||
log.Printf("Registered EnvService")
|
||||
}
|
||||
|
||||
// Start server in goroutine
|
||||
go func() {
|
||||
if s.verbose {
|
||||
log.Printf("gRPC server listening on :%d", s.port)
|
||||
}
|
||||
if err := s.server.Serve(lis); err != nil {
|
||||
log.Printf("gRPC server error: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// Wait for context cancellation or global shutdown signal
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
if s.verbose {
|
||||
log.Println("Context cancelled, shutting down gRPC hostbridge server...")
|
||||
}
|
||||
case <-globalShutdownCh:
|
||||
if s.verbose {
|
||||
log.Println("Shutdown requested via RPC, shutting down gRPC hostbridge server...")
|
||||
}
|
||||
}
|
||||
|
||||
// Graceful shutdown
|
||||
s.server.GracefulStop()
|
||||
|
||||
if s.verbose {
|
||||
log.Println("gRPC hostbridge server stopped")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// TriggerShutdown triggers a graceful shutdown of the server
|
||||
func (s *GrpcServer) TriggerShutdown() {
|
||||
select {
|
||||
case s.shutdownCh <- struct{}{}:
|
||||
// Shutdown signal sent
|
||||
default:
|
||||
// Channel already has a signal or is closed
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package hostbridge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
)
|
||||
|
||||
// Simple implementations that don't rely on proto files for now
|
||||
// This allows us to test the basic hostbridge structure
|
||||
|
||||
// SimpleService provides basic hostbridge functionality
|
||||
type SimpleService struct {
|
||||
coreAddress string
|
||||
verbose bool
|
||||
}
|
||||
|
||||
// NewSimpleService creates a new SimpleService
|
||||
func NewSimpleService(coreAddress string, verbose bool) *SimpleService {
|
||||
return &SimpleService{
|
||||
coreAddress: coreAddress,
|
||||
verbose: verbose,
|
||||
}
|
||||
}
|
||||
|
||||
// Start starts the simple hostbridge service
|
||||
func (s *SimpleService) Start(ctx context.Context) error {
|
||||
if s.verbose {
|
||||
log.Printf("Starting simple hostbridge service (connecting to core at %s)", s.coreAddress)
|
||||
}
|
||||
|
||||
// For now, just log that we're running
|
||||
fmt.Printf("[Cline Host Bridge] Service started on core address: %s\n", s.coreAddress)
|
||||
|
||||
// Keep running until context is cancelled
|
||||
<-ctx.Done()
|
||||
|
||||
if s.verbose {
|
||||
log.Println("Simple hostbridge service stopped")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package hostbridge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"os"
|
||||
|
||||
"github.com/cline/grpc-go/cline"
|
||||
"github.com/cline/grpc-go/host"
|
||||
)
|
||||
|
||||
// SimpleWorkspaceService implements a basic workspace service without complex dependencies
|
||||
type SimpleWorkspaceService struct {
|
||||
host.UnimplementedWorkspaceServiceServer
|
||||
verbose bool
|
||||
}
|
||||
|
||||
// NewSimpleWorkspaceService creates a new SimpleWorkspaceService
|
||||
func NewSimpleWorkspaceService(verbose bool) *SimpleWorkspaceService {
|
||||
return &SimpleWorkspaceService{
|
||||
verbose: verbose,
|
||||
}
|
||||
}
|
||||
|
||||
// GetWorkspacePaths returns the workspace directory paths
|
||||
func (s *SimpleWorkspaceService) GetWorkspacePaths(ctx context.Context, req *host.GetWorkspacePathsRequest) (*host.GetWorkspacePathsResponse, error) {
|
||||
if s.verbose {
|
||||
log.Printf("GetWorkspacePaths called")
|
||||
}
|
||||
|
||||
// Get current working directory as the workspace
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &host.GetWorkspacePathsResponse{
|
||||
Paths: []string{cwd},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// SaveOpenDocumentIfDirty saves an open document if it has unsaved changes
|
||||
func (s *SimpleWorkspaceService) SaveOpenDocumentIfDirty(ctx context.Context, req *host.SaveOpenDocumentIfDirtyRequest) (*host.SaveOpenDocumentIfDirtyResponse, error) {
|
||||
if s.verbose {
|
||||
log.Printf("SaveOpenDocumentIfDirty called for path: %s", req.GetFilePath())
|
||||
}
|
||||
|
||||
// For console implementation, we'll assume the document is already saved
|
||||
wasSaved := false
|
||||
return &host.SaveOpenDocumentIfDirtyResponse{
|
||||
WasSaved: &wasSaved,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetDiagnostics returns diagnostic information for a file - simplified version
|
||||
func (s *SimpleWorkspaceService) GetDiagnostics(ctx context.Context, req *host.GetDiagnosticsRequest) (*host.GetDiagnosticsResponse, error) {
|
||||
if s.verbose {
|
||||
log.Printf("GetDiagnostics called")
|
||||
}
|
||||
|
||||
// For console implementation, return empty diagnostics
|
||||
return &host.GetDiagnosticsResponse{
|
||||
FileDiagnostics: []*cline.FileDiagnostics{},
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package hostbridge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
proto "github.com/cline/grpc-go/host"
|
||||
)
|
||||
|
||||
// WindowService implements the proto.WindowServiceServer interface
|
||||
type WindowService struct {
|
||||
proto.UnimplementedWindowServiceServer
|
||||
verbose bool
|
||||
}
|
||||
|
||||
// NewWindowService creates a new WindowService
|
||||
func NewWindowService(verbose bool) *WindowService {
|
||||
return &WindowService{
|
||||
verbose: verbose,
|
||||
}
|
||||
}
|
||||
|
||||
// ShowTextDocument opens a text document for viewing/editing
|
||||
func (s *WindowService) ShowTextDocument(ctx context.Context, req *proto.ShowTextDocumentRequest) (*proto.TextEditorInfo, error) {
|
||||
if s.verbose {
|
||||
log.Printf("ShowTextDocument called for path: %s", req.GetPath())
|
||||
}
|
||||
|
||||
// For console implementation, we'll just log that we would open the document
|
||||
fmt.Printf("[Cline] Would open document: %s\n", req.GetPath())
|
||||
|
||||
return &proto.TextEditorInfo{
|
||||
DocumentPath: req.GetPath(),
|
||||
IsActive: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ShowOpenDialogue shows a file open dialog
|
||||
func (s *WindowService) ShowOpenDialogue(ctx context.Context, req *proto.ShowOpenDialogueRequest) (*proto.SelectedResources, error) {
|
||||
if s.verbose {
|
||||
log.Printf("ShowOpenDialogue called")
|
||||
}
|
||||
|
||||
// For console implementation, return empty list (user cancelled)
|
||||
return &proto.SelectedResources{
|
||||
Paths: []string{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ShowMessage displays a message to the user
|
||||
func (s *WindowService) ShowMessage(ctx context.Context, req *proto.ShowMessageRequest) (*proto.SelectedResponse, error) {
|
||||
if s.verbose {
|
||||
log.Printf("ShowMessage called: %s", req.GetMessage())
|
||||
}
|
||||
|
||||
// Display message to console
|
||||
fmt.Printf("[Cline] %s\n", req.GetMessage())
|
||||
|
||||
return &proto.SelectedResponse{}, nil
|
||||
}
|
||||
|
||||
// ShowInputBox shows an input dialog to the user
|
||||
func (s *WindowService) ShowInputBox(ctx context.Context, req *proto.ShowInputBoxRequest) (*proto.ShowInputBoxResponse, error) {
|
||||
if s.verbose {
|
||||
log.Printf("ShowInputBox called: %s", req.GetTitle())
|
||||
}
|
||||
|
||||
// For console implementation, return empty response (user cancelled)
|
||||
return &proto.ShowInputBoxResponse{}, nil
|
||||
}
|
||||
|
||||
// ShowSaveDialog shows a save file dialog
|
||||
func (s *WindowService) ShowSaveDialog(ctx context.Context, req *proto.ShowSaveDialogRequest) (*proto.ShowSaveDialogResponse, error) {
|
||||
if s.verbose {
|
||||
log.Printf("ShowSaveDialog called")
|
||||
}
|
||||
|
||||
// For console implementation, return empty response (user cancelled)
|
||||
return &proto.ShowSaveDialogResponse{}, nil
|
||||
}
|
||||
|
||||
// OpenFile opens a file in the editor
|
||||
func (s *WindowService) OpenFile(ctx context.Context, req *proto.OpenFileRequest) (*proto.OpenFileResponse, error) {
|
||||
if s.verbose {
|
||||
log.Printf("OpenFile called for path: %s", req.GetFilePath())
|
||||
}
|
||||
|
||||
// For console implementation, just log that we would open the file
|
||||
fmt.Printf("[Cline] Would open file: %s\n", req.GetFilePath())
|
||||
|
||||
return &proto.OpenFileResponse{}, nil
|
||||
}
|
||||
|
||||
// GetOpenTabs returns a list of currently open tabs
|
||||
func (s *WindowService) GetOpenTabs(ctx context.Context, req *proto.GetOpenTabsRequest) (*proto.GetOpenTabsResponse, error) {
|
||||
if s.verbose {
|
||||
log.Printf("GetOpenTabs called")
|
||||
}
|
||||
|
||||
// For console implementation, return empty list
|
||||
return &proto.GetOpenTabsResponse{
|
||||
Paths: []string{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetVisibleTabs returns a list of currently visible tabs
|
||||
func (s *WindowService) GetVisibleTabs(ctx context.Context, req *proto.GetVisibleTabsRequest) (*proto.GetVisibleTabsResponse, error) {
|
||||
if s.verbose {
|
||||
log.Printf("GetVisibleTabs called")
|
||||
}
|
||||
|
||||
// For console implementation, return empty list
|
||||
return &proto.GetVisibleTabsResponse{
|
||||
Paths: []string{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetActiveEditor returns information about the current active editor
|
||||
func (s *WindowService) GetActiveEditor(ctx context.Context, req *proto.GetActiveEditorRequest) (*proto.GetActiveEditorResponse, error) {
|
||||
if s.verbose {
|
||||
log.Printf("GetActiveEditor called")
|
||||
}
|
||||
|
||||
// Return empty response (no active file)
|
||||
return &proto.GetActiveEditorResponse{
|
||||
FilePath: nil,
|
||||
}, nil
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
cel.dev/expr v0.24.0/go.mod h1:hLPLo1W4QUmuYdA72RBX06QTs6MXw941piREPl3Yfiw=
|
||||
cloud.google.com/go/compute/metadata v0.7.0/go.mod h1:j5MvL9PprKL39t166CoB1uVHfQMs4tFQZZcKwksXUjo=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.29.0/go.mod h1:Cz6ft6Dkn3Et6l2v2a9/RpN7epQ1GtDlO6lj8bEcOvw=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443/go.mod h1:W+zGtBO5Y1IgJhy4+A9GOqVhqLpfZi+vwmdNXUehLA8=
|
||||
github.com/envoyproxy/go-control-plane v0.13.4/go.mod h1:kDfuBlDVsSj2MjrLEtRWtHlsWIFcGyB2RMO44Dc5GZA=
|
||||
github.com/envoyproxy/go-control-plane/envoy v1.32.4/go.mod h1:Gzjc5k8JcJswLjAx1Zm+wSYE20UrLtt7JZMWiWQXQEw=
|
||||
github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4=
|
||||
github.com/envoyproxy/protoc-gen-validate v1.2.1/go.mod h1:d/C80l/jxXLdfEIhX1W2TmLfsJ31lvEjwamM4DxlWXU=
|
||||
github.com/go-jose/go-jose/v4 v4.1.1/go.mod h1:BdsZGqgdO3b6tTc6LSE56wcDbMMLuPsw5d4ZD5f94kA=
|
||||
github.com/golang/glog v1.2.5/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwmO+w=
|
||||
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8=
|
||||
github.com/spiffe/go-spiffe/v2 v2.5.0/go.mod h1:P+NxobPc6wXhVtINNtFjNWGBTreew1GBUCwT2wPmb7g=
|
||||
github.com/zeebo/errs v1.4.0/go.mod h1:sgbWHsvVuTPHcqJJGQ1WhI5KbWlHYz+2+2C/LSEtCw4=
|
||||
go.opentelemetry.io/contrib/detectors/gcp v1.36.0/go.mod h1:IbBN8uAIIx734PTonTPxAxnjc2pQTxWNkwfstZ+6H2k=
|
||||
golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=
|
||||
golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
|
||||
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
|
||||
golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ=
|
||||
golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20250707201910-8d1bb00bc6a7/go.mod h1:kXqgZtrWaf6qS3jZOCnCH7WYfrvFjkC51bM8fz3RsCA=
|
||||
@@ -295,12 +295,14 @@
|
||||
"vscode:prepublish": "npm run package",
|
||||
"compile": "npm run check-types && npm run lint && node esbuild.mjs",
|
||||
"compile-standalone": "npm run check-types && npm run lint && node esbuild.mjs --standalone",
|
||||
"compile-cli": "scripts/build-cli.sh",
|
||||
"postcompile-standalone": "node scripts/package-standalone.mjs",
|
||||
"watch": "npm-run-all -p watch:*",
|
||||
"watch:esbuild": "node esbuild.mjs --watch",
|
||||
"watch:tsc": "tsc --noEmit --watch --project tsconfig.json",
|
||||
"package": "npm run check-types && npm run build:webview && npm run lint && node esbuild.mjs --production",
|
||||
"protos": "node scripts/build-proto.mjs",
|
||||
"protos-go": "node scripts/build-go-proto.mjs",
|
||||
"postprotos": "biome format src/shared/proto src/core/controller src/hosts/ webview-ui/src/services src/generated --write --no-errors-on-unmatched",
|
||||
"clean:build": "rimraf dist dist-standalone webview-ui/build src/generated out/",
|
||||
"clean:deps": "rimraf node_modules webview-ui/node_modules",
|
||||
|
||||
@@ -2,6 +2,7 @@ syntax = "proto3";
|
||||
|
||||
package cline;
|
||||
import "cline/common.proto";
|
||||
option go_package = "github.com/cline/grpc-go/cline";
|
||||
option java_package = "bot.cline.proto";
|
||||
option java_multiple_files = true;
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ syntax = "proto3";
|
||||
package cline;
|
||||
import "cline/common.proto";
|
||||
import "cline/state.proto";
|
||||
option go_package = "github.com/cline/grpc-go/cline";
|
||||
option java_package = "bot.cline.proto";
|
||||
option java_multiple_files = true;
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ syntax = "proto3";
|
||||
|
||||
package cline;
|
||||
import "cline/common.proto";
|
||||
option go_package = "github.com/cline/grpc-go/cline";
|
||||
option java_package = "bot.cline.proto";
|
||||
option java_multiple_files = true;
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ syntax = "proto3";
|
||||
|
||||
package cline;
|
||||
import "cline/common.proto";
|
||||
option go_package = "github.com/cline/grpc-go/cline";
|
||||
option java_package = "bot.cline.proto";
|
||||
option java_multiple_files = true;
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package cline;
|
||||
option go_package = "github.com/cline/grpc-go/cline";
|
||||
option java_package = "bot.cline.proto";
|
||||
option java_multiple_files = true;
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ syntax = "proto3";
|
||||
|
||||
package cline;
|
||||
import "cline/common.proto";
|
||||
option go_package = "github.com/cline/grpc-go/cline";
|
||||
option java_package = "bot.cline.proto";
|
||||
option java_multiple_files = true;
|
||||
|
||||
@@ -38,4 +39,4 @@ message RecordingStatus {
|
||||
message Transcription {
|
||||
string text = 1;
|
||||
string error = 2;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ syntax = "proto3";
|
||||
|
||||
package cline;
|
||||
import "cline/common.proto";
|
||||
option go_package = "github.com/cline/grpc-go/cline";
|
||||
option java_package = "bot.cline.proto";
|
||||
option java_multiple_files = true;
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ syntax = "proto3";
|
||||
|
||||
package cline;
|
||||
import "cline/common.proto";
|
||||
option go_package = "github.com/cline/grpc-go/cline";
|
||||
option java_package = "bot.cline.proto";
|
||||
option java_multiple_files = true;
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ syntax = "proto3";
|
||||
|
||||
package cline;
|
||||
import "cline/common.proto";
|
||||
option go_package = "github.com/cline/grpc-go/cline";
|
||||
option java_package = "bot.cline.proto";
|
||||
option java_multiple_files = true;
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ syntax = "proto3";
|
||||
|
||||
package cline;
|
||||
import "cline/common.proto";
|
||||
option go_package = "github.com/cline/grpc-go/cline";
|
||||
option java_package = "bot.cline.proto";
|
||||
option java_multiple_files = true;
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ syntax = "proto3";
|
||||
|
||||
package cline;
|
||||
import "cline/common.proto";
|
||||
option go_package = "github.com/cline/grpc-go/cline";
|
||||
option java_package = "bot.cline.proto";
|
||||
option java_multiple_files = true;
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ syntax = "proto3";
|
||||
package cline;
|
||||
import "cline/common.proto";
|
||||
import "cline/models.proto";
|
||||
option go_package = "github.com/cline/grpc-go/cline";
|
||||
option java_package = "bot.cline.proto";
|
||||
option java_multiple_files = true;
|
||||
|
||||
@@ -20,6 +21,7 @@ service StateService {
|
||||
rpc setWelcomeViewCompleted(BooleanRequest) returns (Empty);
|
||||
rpc updateInfoBannerVersion(Int64Request) returns (Empty);
|
||||
rpc updateModelBannerVersion(Int64Request) returns (Empty);
|
||||
rpc getProcessInfo(EmptyRequest) returns (ProcessInfo);
|
||||
}
|
||||
message DictationSettings {
|
||||
bool feature_enabled = 1;
|
||||
@@ -303,3 +305,10 @@ message Viewport {
|
||||
message UpdateTerminalConnectionTimeoutResponse {
|
||||
optional int32 timeout_ms = 1;
|
||||
}
|
||||
|
||||
|
||||
message ProcessInfo {
|
||||
int32 process_id = 1;
|
||||
optional string version = 2;
|
||||
optional int64 uptime_ms = 3;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import "cline/common.proto";
|
||||
import "cline/state.proto";
|
||||
import "cline/models.proto";
|
||||
import "cline/browser.proto";
|
||||
option go_package = "github.com/cline/grpc-go/cline";
|
||||
option java_package = "bot.cline.proto";
|
||||
option java_multiple_files = true;
|
||||
|
||||
@@ -39,7 +40,7 @@ service TaskService {
|
||||
// Deletes multiple tasks with the given IDs
|
||||
rpc deleteTasksWithIds(StringArrayRequest) returns (Empty);
|
||||
// Creates a new task with the given text and optional images
|
||||
rpc newTask(NewTaskRequest) returns (Empty);
|
||||
rpc newTask(NewTaskRequest) returns (String);
|
||||
// Shows a task with the specified ID
|
||||
rpc showTaskWithId(StringRequest) returns (TaskResponse);
|
||||
// Exports a task with the given ID to markdown
|
||||
@@ -73,7 +74,7 @@ message TaskSettings {
|
||||
string vertex_region = 9;
|
||||
string requesty_base_url = 10;
|
||||
string open_ai_base_url = 11;
|
||||
map<string, string> open_ai_headers = 12;
|
||||
// map<string, string> open_ai_headers = 12;
|
||||
string ollama_base_url = 13;
|
||||
string ollama_api_options_ctx_num = 14;
|
||||
string lm_studio_base_url = 15;
|
||||
|
||||
@@ -2,6 +2,7 @@ syntax = "proto3";
|
||||
|
||||
package cline;
|
||||
import "cline/common.proto";
|
||||
option go_package = "github.com/cline/grpc-go/cline";
|
||||
option java_package = "bot.cline.proto";
|
||||
option java_multiple_files = true;
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ syntax = "proto3";
|
||||
|
||||
package cline;
|
||||
import "cline/common.proto";
|
||||
option go_package = "github.com/cline/grpc-go/cline";
|
||||
option java_package = "bot.cline.proto";
|
||||
option java_multiple_files = true;
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package host;
|
||||
option go_package = "github.com/cline/grpc-go/host";
|
||||
option java_package = "bot.cline.host.proto";
|
||||
option java_multiple_files = true;
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package host;
|
||||
option go_package = "github.com/cline/grpc-go/host";
|
||||
option java_package = "bot.cline.host.proto";
|
||||
option java_multiple_files = true;
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package host;
|
||||
option go_package = "github.com/cline/grpc-go/host";
|
||||
option java_package = "bot.cline.host.proto";
|
||||
option java_multiple_files = true;
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package host;
|
||||
option go_package = "github.com/cline/grpc-go/host";
|
||||
option java_package = "bot.cline.host.proto";
|
||||
option java_multiple_files = true;
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package host;
|
||||
option go_package = "github.com/cline/grpc-go/host";
|
||||
option java_package = "bot.cline.host.proto";
|
||||
option java_multiple_files = true;
|
||||
|
||||
|
||||
Executable
+15
@@ -0,0 +1,15 @@
|
||||
#!/bin/bash
|
||||
set -eux
|
||||
|
||||
npm run protos
|
||||
npm run protos-go
|
||||
|
||||
mkdir -p dist-standalone/extension
|
||||
cp package.json dist-standalone/extension
|
||||
|
||||
cd cli
|
||||
GO111MODULE=on go build -o bin/cline ./cmd/cline
|
||||
echo '🖥️ cli/bin/cline built'
|
||||
GO111MODULE=on go build -o bin/cline-host ./cmd/cline-host
|
||||
|
||||
echo '🖥️ cli/bin/cline-host built'
|
||||
@@ -0,0 +1,599 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import chalk from "chalk"
|
||||
import { execSync } from "child_process"
|
||||
import * as fs from "fs/promises"
|
||||
import { globby } from "globby"
|
||||
import { createRequire } from "module"
|
||||
import * as path from "path"
|
||||
import { fileURLToPath } from "url"
|
||||
import { createServiceNameMap, parseProtoForServices } from "./proto-shared-utils.mjs"
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
const PROTOC = path.join(require.resolve("grpc-tools"), "../bin/protoc")
|
||||
|
||||
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url))
|
||||
const ROOT_DIR = path.resolve(SCRIPT_DIR, "..")
|
||||
const PROTO_DIR = path.resolve(ROOT_DIR, "proto")
|
||||
const GO_PROTO_DIR = path.join(ROOT_DIR, "src", "generated", "grpc-go")
|
||||
const GO_CLIENT_DIR = path.join(GO_PROTO_DIR, "client")
|
||||
const GO_SERVICE_CLIENT_DIR = path.join(GO_CLIENT_DIR, "services")
|
||||
|
||||
const COMMON_TYPES = ["StringRequest", "EmptyRequest", "Empty", "String", "Int64Request", "KeyValuePair"]
|
||||
|
||||
// Check if Go is installed
|
||||
function checkGoInstallation() {
|
||||
try {
|
||||
execSync("go version", { stdio: "pipe" })
|
||||
return true
|
||||
} catch (error) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Check if a Go tool is available
|
||||
function checkGoTool(toolName) {
|
||||
try {
|
||||
execSync(`which ${toolName}`, { stdio: "pipe" })
|
||||
return true
|
||||
} catch (error) {
|
||||
// On Windows, 'which' might not be available, try 'where'
|
||||
try {
|
||||
execSync(`where ${toolName}`, { stdio: "pipe" })
|
||||
return true
|
||||
} catch (windowsError) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Install Go protobuf tools
|
||||
function installGoTools() {
|
||||
console.log(chalk.yellow("Installing Go protobuf tools..."))
|
||||
|
||||
const tools = ["google.golang.org/protobuf/cmd/protoc-gen-go@latest", "google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest"]
|
||||
|
||||
for (const tool of tools) {
|
||||
try {
|
||||
console.log(chalk.cyan(`Installing ${tool}...`))
|
||||
execSync(`GO111MODULE=on go install ${tool}`, {
|
||||
stdio: "inherit",
|
||||
env: { ...process.env, GO111MODULE: "on" },
|
||||
})
|
||||
} catch (error) {
|
||||
console.error(chalk.red(`Failed to install ${tool}:`), error.message)
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
console.log(chalk.green("Go protobuf tools installed successfully!"))
|
||||
}
|
||||
|
||||
// Check if tools are in PATH and provide guidance
|
||||
function checkToolsInPath() {
|
||||
const tools = ["protoc-gen-go", "protoc-gen-go-grpc"]
|
||||
const missingTools = []
|
||||
|
||||
for (const tool of tools) {
|
||||
if (!checkGoTool(tool)) {
|
||||
missingTools.push(tool)
|
||||
}
|
||||
}
|
||||
|
||||
if (missingTools.length > 0) {
|
||||
console.log(chalk.yellow("Warning: Some Go protobuf tools are not in your PATH:"))
|
||||
missingTools.forEach((tool) => console.log(chalk.yellow(` - ${tool}`)))
|
||||
console.log()
|
||||
console.log(chalk.cyan("To fix this, add your Go bin directory to your PATH:"))
|
||||
|
||||
// Get GOPATH and GOBIN
|
||||
let goPath, goBin
|
||||
try {
|
||||
goPath = execSync("go env GOPATH", { encoding: "utf8" }).trim()
|
||||
goBin = execSync("go env GOBIN", { encoding: "utf8" }).trim()
|
||||
} catch (error) {
|
||||
console.log(chalk.red("Could not determine Go paths. Please check your Go installation."))
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const binPath = goBin || path.join(goPath, "bin")
|
||||
|
||||
if (process.platform === "win32") {
|
||||
console.log(chalk.cyan(` Windows (Command Prompt): set PATH=%PATH%;${binPath}`))
|
||||
console.log(chalk.cyan(` Windows (PowerShell): $env:PATH += ";${binPath}"`))
|
||||
console.log(chalk.cyan(` Or add "${binPath}" to your system PATH through System Properties`))
|
||||
} else {
|
||||
console.log(chalk.cyan(` Add this to your shell profile (~/.bashrc, ~/.zshrc, etc.):`))
|
||||
console.log(chalk.cyan(` export PATH="$PATH:${binPath}"`))
|
||||
console.log(chalk.cyan(` Then run: source ~/.bashrc (or restart your terminal)`))
|
||||
}
|
||||
console.log()
|
||||
|
||||
// Try to continue anyway, as the tools might still work
|
||||
console.log(chalk.yellow("Attempting to continue anyway..."))
|
||||
}
|
||||
}
|
||||
|
||||
// Setup Go dependencies
|
||||
async function setupGoDependencies() {
|
||||
console.log(chalk.cyan("Checking Go dependencies..."))
|
||||
|
||||
// Check if Go is installed
|
||||
if (!checkGoInstallation()) {
|
||||
console.error(chalk.red("Error: Go is not installed or not in PATH."))
|
||||
console.error(chalk.red("Please install Go from https://golang.org/dl/ and ensure it's in your PATH."))
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log(chalk.green("✓ Go is installed"))
|
||||
|
||||
// Check if protobuf tools are available
|
||||
const tools = ["protoc-gen-go", "protoc-gen-go-grpc"]
|
||||
const missingTools = tools.filter((tool) => !checkGoTool(tool))
|
||||
|
||||
if (missingTools.length > 0) {
|
||||
console.log(chalk.yellow(`Missing Go protobuf tools: ${missingTools.join(", ")}`))
|
||||
installGoTools()
|
||||
} else {
|
||||
console.log(chalk.green("✓ Go protobuf tools are available"))
|
||||
}
|
||||
|
||||
// Verify tools are in PATH
|
||||
checkToolsInPath()
|
||||
}
|
||||
|
||||
export async function goProtoc(outDir, protoFiles) {
|
||||
// Setup dependencies first
|
||||
await setupGoDependencies()
|
||||
|
||||
// Create output directory if it doesn't exist
|
||||
await fs.mkdir(outDir, { recursive: true })
|
||||
|
||||
// Simple protoc command - proto files now have correct go_package paths
|
||||
const goProtocCommand = [
|
||||
PROTOC,
|
||||
`--proto_path="${PROTO_DIR}"`,
|
||||
`--go_out="${outDir}"`,
|
||||
`--go_opt=module=github.com/cline/grpc-go`,
|
||||
`--go-grpc_out="${outDir}"`,
|
||||
`--go-grpc_opt=module=github.com/cline/grpc-go`,
|
||||
...protoFiles,
|
||||
].join(" ")
|
||||
|
||||
try {
|
||||
console.log(chalk.cyan(`Generating Go code in ${outDir}...`))
|
||||
execSync(goProtocCommand, { stdio: "inherit" })
|
||||
} catch (error) {
|
||||
console.error(chalk.red("Error generating Go code:"), error)
|
||||
|
||||
// Provide additional help if the error might be related to missing tools
|
||||
if (error.message.includes("protoc-gen-go")) {
|
||||
console.log()
|
||||
console.log(chalk.yellow("This error might be caused by Go protobuf tools not being in your PATH."))
|
||||
console.log(chalk.yellow("Please ensure the tools are properly installed and accessible."))
|
||||
}
|
||||
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
await generateGoMod()
|
||||
await generateGoConnection()
|
||||
await generateGoClient()
|
||||
await generateGoServiceClients()
|
||||
}
|
||||
|
||||
async function generateGoMod() {
|
||||
console.log(chalk.cyan("Generating Go module file..."))
|
||||
|
||||
const goModContent = `module github.com/cline/grpc-go
|
||||
|
||||
go 1.21
|
||||
|
||||
require (
|
||||
google.golang.org/grpc v1.65.0
|
||||
google.golang.org/protobuf v1.34.2
|
||||
)
|
||||
|
||||
require (
|
||||
golang.org/x/net v0.26.0 // indirect
|
||||
golang.org/x/sys v0.21.0 // indirect
|
||||
golang.org/x/text v0.16.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20240604185151-ef581f913117 // indirect
|
||||
)
|
||||
`
|
||||
|
||||
const goModPath = path.join(GO_PROTO_DIR, "go.mod")
|
||||
await fs.writeFile(goModPath, goModContent)
|
||||
console.log(chalk.green(`Generated Go module file at ${goModPath}`))
|
||||
}
|
||||
|
||||
async function generateGoConnection() {
|
||||
console.log(chalk.cyan("Generating Go connection manager..."))
|
||||
|
||||
// Create client directory if it doesn't exist
|
||||
await fs.mkdir(GO_CLIENT_DIR, { recursive: true })
|
||||
|
||||
const content = `// AUTO-GENERATED FILE - DO NOT MODIFY DIRECTLY
|
||||
// Generated by scripts/build-go-proto.mjs
|
||||
|
||||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
)
|
||||
|
||||
// ConnectionConfig holds configuration for gRPC connection
|
||||
type ConnectionConfig struct {
|
||||
Address string
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
// ConnectionManager manages gRPC connections
|
||||
type ConnectionManager struct {
|
||||
config *ConnectionConfig
|
||||
conn *grpc.ClientConn
|
||||
mutex sync.RWMutex
|
||||
}
|
||||
|
||||
// NewConnectionManager creates a new connection manager
|
||||
func NewConnectionManager(config *ConnectionConfig) *ConnectionManager {
|
||||
if config.Timeout == 0 {
|
||||
config.Timeout = 30 * time.Second
|
||||
}
|
||||
|
||||
return &ConnectionManager{
|
||||
config: config,
|
||||
}
|
||||
}
|
||||
|
||||
// Connect establishes a gRPC connection
|
||||
func (cm *ConnectionManager) Connect(ctx context.Context) error {
|
||||
cm.mutex.Lock()
|
||||
defer cm.mutex.Unlock()
|
||||
|
||||
if cm.conn != nil {
|
||||
return nil // Already connected
|
||||
}
|
||||
|
||||
// Create context with timeout
|
||||
connectCtx, cancel := context.WithTimeout(ctx, cm.config.Timeout)
|
||||
defer cancel()
|
||||
|
||||
// Establish gRPC connection
|
||||
conn, err := grpc.DialContext(connectCtx, cm.config.Address,
|
||||
grpc.WithTransportCredentials(insecure.NewCredentials()),
|
||||
grpc.WithBlock(),
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to connect to %s: %w", cm.config.Address, err)
|
||||
}
|
||||
|
||||
cm.conn = conn
|
||||
return nil
|
||||
}
|
||||
|
||||
// Disconnect closes the gRPC connection
|
||||
func (cm *ConnectionManager) Disconnect() error {
|
||||
cm.mutex.Lock()
|
||||
defer cm.mutex.Unlock()
|
||||
|
||||
if cm.conn == nil {
|
||||
return nil // Already disconnected
|
||||
}
|
||||
|
||||
err := cm.conn.Close()
|
||||
cm.conn = nil
|
||||
return err
|
||||
}
|
||||
|
||||
// GetConnection returns the current gRPC connection
|
||||
func (cm *ConnectionManager) GetConnection() *grpc.ClientConn {
|
||||
cm.mutex.RLock()
|
||||
defer cm.mutex.RUnlock()
|
||||
return cm.conn
|
||||
}
|
||||
|
||||
// IsConnected returns true if connected
|
||||
func (cm *ConnectionManager) IsConnected() bool {
|
||||
cm.mutex.RLock()
|
||||
defer cm.mutex.RUnlock()
|
||||
return cm.conn != nil
|
||||
}
|
||||
`
|
||||
|
||||
const connectionPath = path.join(GO_CLIENT_DIR, "connection.go")
|
||||
await fs.writeFile(connectionPath, content)
|
||||
console.log(chalk.green(`Generated Go connection manager at ${connectionPath}`))
|
||||
}
|
||||
|
||||
async function generateGoClient() {
|
||||
console.log(chalk.cyan("Generating Go client..."))
|
||||
|
||||
// Create client directory if it doesn't exist
|
||||
await fs.mkdir(GO_CLIENT_DIR, { recursive: true })
|
||||
|
||||
// Get all proto files and parse services
|
||||
const protoFiles = await globby("**/*.proto", { cwd: PROTO_DIR })
|
||||
const services = await parseProtoForServices(protoFiles, PROTO_DIR)
|
||||
const serviceNameMap = createServiceNameMap(services)
|
||||
|
||||
const serviceClients = Object.keys(serviceNameMap)
|
||||
.map(
|
||||
(name) =>
|
||||
`\t${name.charAt(0).toUpperCase() + name.slice(1)} *services.${name.charAt(0).toUpperCase() + name.slice(1)}Client`,
|
||||
)
|
||||
.join("\n")
|
||||
|
||||
const serviceInitializers = Object.keys(serviceNameMap)
|
||||
.map(
|
||||
(name) =>
|
||||
`\tc.${name.charAt(0).toUpperCase() + name.slice(1)} = services.New${name.charAt(0).toUpperCase() + name.slice(1)}Client(conn)`,
|
||||
)
|
||||
.join("\n")
|
||||
|
||||
const serviceNilOut = Object.keys(serviceNameMap)
|
||||
.map((name) => `\tc.${name.charAt(0).toUpperCase() + name.slice(1)} = nil`)
|
||||
.join("\n")
|
||||
|
||||
const content = `// AUTO-GENERATED FILE - DO NOT MODIFY DIRECTLY
|
||||
// Generated by scripts/build-go-proto.mjs
|
||||
|
||||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"google.golang.org/grpc"
|
||||
"github.com/cline/grpc-go/client/services"
|
||||
)
|
||||
|
||||
// ClineClient provides a unified interface to all Cline services
|
||||
type ClineClient struct {
|
||||
connManager *ConnectionManager
|
||||
|
||||
// Service clients
|
||||
${serviceClients}
|
||||
|
||||
// Connection state
|
||||
mutex sync.RWMutex
|
||||
connected bool
|
||||
}
|
||||
|
||||
// NewClineClient creates a new unified Cline client
|
||||
func NewClineClient(address string) (*ClineClient, error) {
|
||||
config := &ConnectionConfig{
|
||||
Address: address,
|
||||
}
|
||||
|
||||
connManager := NewConnectionManager(config)
|
||||
|
||||
return &ClineClient{
|
||||
connManager: connManager,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// NewClineClientWithConfig creates a new Cline client with custom configuration
|
||||
func NewClineClientWithConfig(config *ConnectionConfig) (*ClineClient, error) {
|
||||
connManager := NewConnectionManager(config)
|
||||
|
||||
return &ClineClient{
|
||||
connManager: connManager,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Connect establishes connection to Cline Core and initializes service clients
|
||||
func (c *ClineClient) Connect(ctx context.Context) error {
|
||||
c.mutex.Lock()
|
||||
defer c.mutex.Unlock()
|
||||
|
||||
if c.connected {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Establish gRPC connection
|
||||
if err := c.connManager.Connect(ctx); err != nil {
|
||||
return fmt.Errorf("failed to connect: %w", err)
|
||||
}
|
||||
|
||||
// Initialize service clients
|
||||
conn := c.connManager.GetConnection()
|
||||
${serviceInitializers}
|
||||
|
||||
c.connected = true
|
||||
return nil
|
||||
}
|
||||
|
||||
// Disconnect closes the connection to Cline Core
|
||||
func (c *ClineClient) Disconnect() error {
|
||||
c.mutex.Lock()
|
||||
defer c.mutex.Unlock()
|
||||
|
||||
if !c.connected {
|
||||
return nil
|
||||
}
|
||||
|
||||
err := c.connManager.Disconnect()
|
||||
c.connected = false
|
||||
|
||||
// Clear service clients
|
||||
${serviceNilOut}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// IsConnected returns true if the client is connected to Cline Core
|
||||
func (c *ClineClient) IsConnected() bool {
|
||||
c.mutex.RLock()
|
||||
defer c.mutex.RUnlock()
|
||||
return c.connected
|
||||
}
|
||||
|
||||
// Reconnect closes the current connection and establishes a new one
|
||||
func (c *ClineClient) Reconnect(ctx context.Context) error {
|
||||
c.mutex.Lock()
|
||||
defer c.mutex.Unlock()
|
||||
|
||||
// Disconnect first
|
||||
if c.connected {
|
||||
if err := c.connManager.Disconnect(); err != nil {
|
||||
return fmt.Errorf("failed to disconnect: %w", err)
|
||||
}
|
||||
c.connected = false
|
||||
}
|
||||
|
||||
// Reconnect
|
||||
if err := c.connManager.Connect(ctx); err != nil {
|
||||
return fmt.Errorf("failed to reconnect: %w", err)
|
||||
}
|
||||
|
||||
// Reinitialize service clients
|
||||
conn := c.connManager.GetConnection()
|
||||
${serviceInitializers}
|
||||
|
||||
c.connected = true
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetConnection returns the underlying gRPC connection
|
||||
func (c *ClineClient) GetConnection() *grpc.ClientConn {
|
||||
return c.connManager.GetConnection()
|
||||
}
|
||||
`
|
||||
const clientPath = path.join(GO_CLIENT_DIR, "cline_client.go")
|
||||
await fs.writeFile(clientPath, content)
|
||||
console.log(chalk.green(`Generated Go client at ${clientPath}`))
|
||||
}
|
||||
|
||||
async function generateGoServiceClients() {
|
||||
console.log(chalk.cyan("Generating Go service clients..."))
|
||||
await fs.mkdir(GO_SERVICE_CLIENT_DIR, { recursive: true })
|
||||
|
||||
const protoFiles = await globby("**/*.proto", { cwd: PROTO_DIR })
|
||||
const services = await parseProtoForServices(protoFiles, PROTO_DIR)
|
||||
|
||||
for (const [serviceName, serviceDef] of Object.entries(services)) {
|
||||
const capitalizedServiceName = serviceName.charAt(0).toUpperCase() + serviceName.slice(1)
|
||||
const clientFileName = `${serviceName}_client.go`
|
||||
const clientPath = path.join(GO_SERVICE_CLIENT_DIR, clientFileName)
|
||||
|
||||
const methods = serviceDef.methods
|
||||
.map((method) => {
|
||||
const capitalizedMethodName = method.name.charAt(0).toUpperCase() + method.name.slice(1)
|
||||
|
||||
// Determine if types are from cline package (common types) or proto package (service-specific types)
|
||||
const requestTypeName = method.requestType.split(".").pop()
|
||||
const responseTypeName = method.responseType.split(".").pop()
|
||||
|
||||
// Common types like StringRequest, Empty, etc. are in the cline package
|
||||
const requestType = COMMON_TYPES.includes(requestTypeName)
|
||||
? `*cline.${requestTypeName}`
|
||||
: `*proto.${requestTypeName}`
|
||||
const responseType = COMMON_TYPES.includes(responseTypeName)
|
||||
? `*cline.${responseTypeName}`
|
||||
: `*proto.${responseTypeName}`
|
||||
|
||||
if (method.isResponseStreaming) {
|
||||
return `
|
||||
// ${capitalizedMethodName} subscribes to ${method.name} updates and returns a stream
|
||||
func (sc *${capitalizedServiceName}Client) ${capitalizedMethodName}(ctx context.Context, req ${requestType}) (proto.${serviceDef.name}_${capitalizedMethodName}Client, error) {
|
||||
stream, err := sc.client.${capitalizedMethodName}(ctx, req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to subscribe to ${method.name}: %w", err)
|
||||
}
|
||||
|
||||
return stream, nil
|
||||
}`
|
||||
} else {
|
||||
return `
|
||||
// ${capitalizedMethodName} retrieves the current application ${method.name}
|
||||
func (sc *${capitalizedServiceName}Client) ${capitalizedMethodName}(ctx context.Context, req ${requestType}) (${responseType}, error) {
|
||||
resp, err := sc.client.${capitalizedMethodName}(ctx, req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get latest ${method.name}: %w", err)
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
}`
|
||||
}
|
||||
})
|
||||
.join("\n")
|
||||
|
||||
// Determine the correct proto import path based on the service location
|
||||
const protoImportPath =
|
||||
serviceDef.protoPackage === "host" ? '"github.com/cline/grpc-go/host"' : '"github.com/cline/grpc-go/cline"'
|
||||
|
||||
// Check if we need to import cline package for common types
|
||||
const needsClineImport = serviceDef.methods.some((method) => {
|
||||
const requestTypeName = method.requestType.split(".").pop()
|
||||
const responseTypeName = method.responseType.split(".").pop()
|
||||
const commonTypes = ["StringRequest", "EmptyRequest", "Empty", "String", "Int64Request", "KeyValuePair"]
|
||||
return commonTypes.includes(requestTypeName) || commonTypes.includes(responseTypeName)
|
||||
})
|
||||
|
||||
// Always import cline package if we need common types, regardless of service package
|
||||
const clineImport = needsClineImport ? ' cline "github.com/cline/grpc-go/cline"\n' : ""
|
||||
|
||||
const content = `// AUTO-GENERATED FILE - DO NOT MODIFY DIRECTLY
|
||||
// Generated by scripts/build-go-proto.mjs
|
||||
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
${clineImport} proto ${protoImportPath}
|
||||
"google.golang.org/grpc"
|
||||
)
|
||||
|
||||
// ${capitalizedServiceName}Client wraps the generated ${serviceDef.name} gRPC client
|
||||
type ${capitalizedServiceName}Client struct {
|
||||
client proto.${serviceDef.name}Client
|
||||
}
|
||||
|
||||
// New${capitalizedServiceName}Client creates a new ${capitalizedServiceName}Client
|
||||
func New${capitalizedServiceName}Client(conn *grpc.ClientConn) *${capitalizedServiceName}Client {
|
||||
return &${capitalizedServiceName}Client{
|
||||
client: proto.New${serviceDef.name}Client(conn),
|
||||
}
|
||||
}
|
||||
${methods}
|
||||
`
|
||||
await fs.writeFile(clientPath, content)
|
||||
console.log(chalk.green(`Generated Go service client at ${clientPath}`))
|
||||
}
|
||||
}
|
||||
|
||||
// Main execution block - run if this script is executed directly
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
async function main() {
|
||||
try {
|
||||
console.log(chalk.cyan("Starting Go protobuf code generation..."))
|
||||
|
||||
// Get all proto files
|
||||
const protoFiles = await globby("**/*.proto", { cwd: PROTO_DIR })
|
||||
console.log(chalk.cyan(`Found ${protoFiles.length} proto files`))
|
||||
|
||||
// Set output directory for Go code - use the new location
|
||||
const goOutDir = GO_PROTO_DIR
|
||||
|
||||
// Call the goProtoc function
|
||||
await goProtoc(goOutDir, protoFiles)
|
||||
|
||||
console.log(chalk.green("✓ Go protobuf code generation completed successfully!"))
|
||||
} catch (error) {
|
||||
console.error(chalk.red("Error during Go protobuf generation:"), error)
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
main()
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import * as fs from "fs/promises"
|
||||
import * as path from "path"
|
||||
|
||||
/**
|
||||
* Parse proto files to extract service definitions
|
||||
* @param {string[]} protoFilePaths - Array of proto file paths
|
||||
* @param {string} protoDir - Base proto directory
|
||||
* @returns {Promise<Object>} Services object with service definitions
|
||||
*/
|
||||
export async function parseProtoForServices(protoFilePaths, protoDir) {
|
||||
const services = {}
|
||||
|
||||
for (const protoFilePath of protoFilePaths) {
|
||||
const content = await fs.readFile(path.join(protoDir, protoFilePath), "utf8")
|
||||
const serviceMatches = content.matchAll(/service\s+(\w+Service)\s*\{([\s\S]*?)\}/g)
|
||||
|
||||
// Determine proto package from file path
|
||||
const protoPackage = protoFilePath.startsWith("host/") ? "host" : "cline"
|
||||
|
||||
for (const serviceMatch of serviceMatches) {
|
||||
const serviceName = serviceMatch[1]
|
||||
const serviceKey = serviceName.replace("Service", "").toLowerCase()
|
||||
const serviceBody = serviceMatch[2]
|
||||
const methodMatches = serviceBody.matchAll(
|
||||
/rpc\s+(\w+)\s*\((stream\s)?([\w.]+)\)\s*returns\s*\((stream\s)?([\w.]+)\)/g,
|
||||
)
|
||||
|
||||
const methods = []
|
||||
for (const methodMatch of methodMatches) {
|
||||
methods.push({
|
||||
name: methodMatch[1],
|
||||
requestType: methodMatch[3],
|
||||
responseType: methodMatch[5],
|
||||
isRequestStreaming: !!methodMatch[2],
|
||||
isResponseStreaming: !!methodMatch[4],
|
||||
})
|
||||
}
|
||||
services[serviceKey] = { name: serviceName, methods, protoPackage }
|
||||
}
|
||||
}
|
||||
return services
|
||||
}
|
||||
|
||||
/**
|
||||
* Create service name map from parsed services
|
||||
* @param {Object} services - Services object from parseProtoForServices
|
||||
* @returns {Object} Service name map
|
||||
*/
|
||||
export function createServiceNameMap(services) {
|
||||
const serviceNameMap = {}
|
||||
for (const [serviceKey, serviceDef] of Object.entries(services)) {
|
||||
const packagePrefix = serviceDef.protoPackage === "host" ? "host" : "cline"
|
||||
serviceNameMap[serviceKey] = `${packagePrefix}.${serviceDef.name}`
|
||||
}
|
||||
return serviceNameMap
|
||||
}
|
||||
|
||||
/**
|
||||
* Log message only if verbose flag is set
|
||||
* @param {string} message - Message to log
|
||||
*/
|
||||
export function logVerbose(message) {
|
||||
if (process.argv.includes("-v") || process.argv.includes("--verbose")) {
|
||||
console.log(message)
|
||||
}
|
||||
}
|
||||
@@ -240,6 +240,8 @@ export class Controller {
|
||||
historyItem,
|
||||
taskId,
|
||||
})
|
||||
|
||||
return this.task.taskId
|
||||
}
|
||||
|
||||
async reinitExistingTaskFromId(taskId: string) {
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { EmptyRequest } from "@shared/proto/cline/common"
|
||||
import { ProcessInfo } from "@shared/proto/cline/state"
|
||||
import { Controller } from ".."
|
||||
|
||||
/**
|
||||
* Gets process information including PID, version, and uptime
|
||||
* @param controller The controller instance
|
||||
* @param request Empty request
|
||||
* @returns ProcessInfo with process details
|
||||
*/
|
||||
export async function getProcessInfo(controller: Controller, request: EmptyRequest): Promise<ProcessInfo> {
|
||||
// Get the current state to access the version (same source as webview)
|
||||
const state = await controller.getStateToPostToWebview()
|
||||
|
||||
return ProcessInfo.create({
|
||||
processId: process.pid,
|
||||
version: state.version || "unknown",
|
||||
uptimeMs: Math.floor(process.uptime() * 1000), // Convert seconds to milliseconds
|
||||
})
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Empty } from "@shared/proto/cline/common"
|
||||
import { String } from "@shared/proto/cline/common"
|
||||
import { PlanActMode, OpenaiReasoningEffort as ProtoOpenaiReasoningEffort } from "@shared/proto/cline/state"
|
||||
import { NewTaskRequest } from "@shared/proto/cline/task"
|
||||
import { Settings } from "@/core/storage/state-keys"
|
||||
@@ -13,7 +13,7 @@ import { Controller } from ".."
|
||||
* @param request The new task request containing text and optional images, and optional task settings
|
||||
* @returns Empty response
|
||||
*/
|
||||
export async function newTask(controller: Controller, request: NewTaskRequest): Promise<Empty> {
|
||||
export async function newTask(controller: Controller, request: NewTaskRequest): Promise<String> {
|
||||
const convertOpenaiReasoningEffort = (effort: ProtoOpenaiReasoningEffort): string => {
|
||||
switch (effort) {
|
||||
case ProtoOpenaiReasoningEffort.LOW:
|
||||
@@ -70,6 +70,6 @@ export async function newTask(controller: Controller, request: NewTaskRequest):
|
||||
}).filter(([_, value]) => value !== undefined),
|
||||
)
|
||||
|
||||
await controller.initTask(request.text, request.images, request.files, undefined, filteredTaskSettings)
|
||||
return Empty.create()
|
||||
const taskId = await controller.initTask(request.text, request.images, request.files, undefined, filteredTaskSettings)
|
||||
return String.create({ value: taskId || "" })
|
||||
}
|
||||
|
||||
@@ -5,11 +5,17 @@ import { EmptyRequest } from "@/shared/proto/cline/common"
|
||||
|
||||
// Canonical header names for extra client/host context
|
||||
export const ClineHeaders = {
|
||||
// Platform name, e.g. "Jetbrains IDEA ULtimate"
|
||||
PLATFORM: "X-PLATFORM",
|
||||
// Version of IDE
|
||||
PLATFORM_VERSION: "X-PLATFORM-VERSION",
|
||||
// E.G. Version of Extension, Plugin, or CLI
|
||||
CLIENT_VERSION: "X-CLIENT-VERSION",
|
||||
// Type of client, e.g. "CLI", "Jetbrains", "VS Code"
|
||||
CLIENT_TYPE: "X-CLIENT-TYPE",
|
||||
// Version of Cline Core
|
||||
CORE_VERSION: "X-CORE-VERSION",
|
||||
// Whether this is a multiroot workspace
|
||||
IS_MULTIROOT: "X-IS-MULTIROOT",
|
||||
} as const
|
||||
export type ClineHeaderName = (typeof ClineHeaders)[keyof typeof ClineHeaders]
|
||||
|
||||
Reference in New Issue
Block a user