claude-web-chat/claude_cli.go
djuka 3283888738
All checks were successful
Tests / unit-tests (push) Successful in 51s
Inicijalna implementacija Claude Web Chat (Faza 1 - CLI mod)
- Login sa session cookie autentifikacijom
- Lista projekata iz filesystem-a
- Chat sa Claude CLI preko WebSocket-a
- Streaming NDJSON parsiranje iz CLI stdout-a
- Sesija zivi nezavisno od browsera (reconnect replay)
- Sidebar sa .md fajlovima i markdown renderovanjem
- Dark tema, htmx + Go templates
- 47 unit testova

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 05:03:40 +00:00

197 lines
4.1 KiB
Go

package main
import (
"bufio"
"encoding/json"
"fmt"
"io"
"os"
"os/exec"
"strings"
"sync"
)
// CLIEvent represents a parsed NDJSON event from claude CLI stdout.
type CLIEvent struct {
Type string `json:"type"`
Subtype string `json:"subtype,omitempty"`
// For assistant message events
Message *CLIMessage `json:"message,omitempty"`
// For content_block_delta
Index int `json:"index,omitempty"`
Delta *CLIDelta `json:"delta,omitempty"`
// For result events
Result *CLIResult `json:"result,omitempty"`
}
type CLIMessage struct {
Role string `json:"role,omitempty"`
Content []CLIContent `json:"content,omitempty"`
}
type CLIContent struct {
Type string `json:"type"`
Text string `json:"text,omitempty"`
Name string `json:"name,omitempty"`
Input any `json:"input,omitempty"`
ID string `json:"id,omitempty"`
}
type CLIDelta struct {
Type string `json:"type"`
Text string `json:"text,omitempty"`
}
type CLIResult struct {
Duration float64 `json:"duration_ms,omitempty"`
NumTurns int `json:"num_turns,omitempty"`
CostUSD float64 `json:"cost_usd,omitempty"`
SessionID string `json:"session_id,omitempty"`
}
// CLIProcess manages a running claude CLI process.
type CLIProcess struct {
cmd *exec.Cmd
stdin io.WriteCloser
stdout io.ReadCloser
stderr io.ReadCloser
Events chan CLIEvent
Errors chan error
done chan struct{}
mu sync.Mutex
}
// SpawnCLI starts a new claude CLI process for the given project directory.
func SpawnCLI(projectDir string) (*CLIProcess, error) {
args := []string{
"-p",
"--output-format", "stream-json",
"--verbose",
}
cmd := exec.Command("claude", args...)
cmd.Dir = projectDir
// Filter CLAUDECODE env var to prevent nested session detection
env := filterEnv(os.Environ(), "CLAUDECODE")
cmd.Env = env
stdin, err := cmd.StdinPipe()
if err != nil {
return nil, fmt.Errorf("stdin pipe: %w", err)
}
stdout, err := cmd.StdoutPipe()
if err != nil {
return nil, fmt.Errorf("stdout pipe: %w", err)
}
stderr, err := cmd.StderrPipe()
if err != nil {
return nil, fmt.Errorf("stderr pipe: %w", err)
}
if err := cmd.Start(); err != nil {
return nil, fmt.Errorf("start claude: %w", err)
}
cp := &CLIProcess{
cmd: cmd,
stdin: stdin,
stdout: stdout,
stderr: stderr,
Events: make(chan CLIEvent, 100),
Errors: make(chan error, 10),
done: make(chan struct{}),
}
go cp.readOutput()
go cp.readErrors()
return cp, nil
}
// Send writes a message to the claude CLI process stdin.
func (cp *CLIProcess) Send(message string) error {
cp.mu.Lock()
defer cp.mu.Unlock()
msg := strings.TrimSpace(message) + "\n"
_, err := io.WriteString(cp.stdin, msg)
return err
}
// Close terminates the claude CLI process.
func (cp *CLIProcess) Close() error {
cp.mu.Lock()
defer cp.mu.Unlock()
cp.stdin.Close()
return cp.cmd.Process.Kill()
}
// Done returns a channel that's closed when the process exits.
func (cp *CLIProcess) Done() <-chan struct{} {
return cp.done
}
func (cp *CLIProcess) readOutput() {
defer close(cp.done)
defer close(cp.Events)
scanner := bufio.NewScanner(cp.stdout)
scanner.Buffer(make([]byte, 0, 1024*1024), 1024*1024) // 1MB buffer
for scanner.Scan() {
line := scanner.Text()
if line == "" {
continue
}
var event CLIEvent
if err := json.Unmarshal([]byte(line), &event); err != nil {
cp.Errors <- fmt.Errorf("parse event: %w (line: %s)", err, truncate(line, 200))
continue
}
cp.Events <- event
}
if err := scanner.Err(); err != nil {
cp.Errors <- fmt.Errorf("scanner: %w", err)
}
}
func (cp *CLIProcess) readErrors() {
scanner := bufio.NewScanner(cp.stderr)
for scanner.Scan() {
line := scanner.Text()
if line != "" {
cp.Errors <- fmt.Errorf("stderr: %s", line)
}
}
}
// filterEnv returns a copy of env with the named variable removed.
func filterEnv(env []string, name string) []string {
prefix := name + "="
result := make([]string, 0, len(env))
for _, e := range env {
if !strings.HasPrefix(e, prefix) {
result = append(result, e)
}
}
return result
}
func truncate(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n] + "..."
}