diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go deleted file mode 100644 index 2e9a986..0000000 --- a/internal/daemon/daemon.go +++ /dev/null @@ -1,173 +0,0 @@ -package daemon - -import ( - "fmt" - "os" - "os/signal" - "sync" - "syscall" - "time" - - "github.com/muyue/muyue/internal/config" - "github.com/muyue/muyue/internal/scanner" - "github.com/muyue/muyue/internal/updater" -) - -type Daemon struct { - config *config.MuyueConfig - interval time.Duration - stopCh chan struct{} - mu sync.RWMutex - running bool - lastCheck time.Time - lastStatus []updater.UpdateStatus - logs []string - onUpdate func([]updater.UpdateStatus) -} - -func NewDaemon(cfg *config.MuyueConfig, interval time.Duration) *Daemon { - if interval == 0 { - interval = 1 * time.Hour - } - return &Daemon{ - config: cfg, - interval: interval, - stopCh: make(chan struct{}, 1), - logs: []string{}, - } -} - -func (d *Daemon) OnUpdate(fn func([]updater.UpdateStatus)) { - d.onUpdate = fn -} - -func (d *Daemon) Start() error { - d.mu.Lock() - if d.running { - d.mu.Unlock() - return fmt.Errorf("daemon already running") - } - d.running = true - d.mu.Unlock() - - d.log("daemon started (interval: %s)", d.interval) - - go d.run() - - return nil -} - -func (d *Daemon) Stop() { - d.mu.Lock() - defer d.mu.Unlock() - if !d.running { - return - } - d.running = false - d.stopCh <- struct{}{} - d.log("daemon stopped") -} - -func (d *Daemon) IsRunning() bool { - d.mu.RLock() - defer d.mu.RUnlock() - return d.running -} - -func (d *Daemon) LastCheck() time.Time { - d.mu.RLock() - defer d.mu.RUnlock() - return d.lastCheck -} - -func (d *Daemon) LastStatus() []updater.UpdateStatus { - d.mu.RLock() - defer d.mu.RUnlock() - return d.lastStatus -} - -func (d *Daemon) Logs() []string { - d.mu.RLock() - defer d.mu.RUnlock() - return d.logs -} - -func (d *Daemon) TriggerCheck() []updater.UpdateStatus { - return d.checkUpdates() -} - -func (d *Daemon) run() { - d.checkUpdates() - - ticker := time.NewTicker(d.interval) - defer ticker.Stop() - - for { - select { - case <-ticker.C: - d.checkUpdates() - case <-d.stopCh: - return - } - } -} - -func (d *Daemon) checkUpdates() []updater.UpdateStatus { - d.log("checking for updates...") - result := scanner.ScanSystem() - statuses := updater.CheckUpdates(result) - - needsUpdate := false - for _, s := range statuses { - if s.NeedsUpdate { - needsUpdate = true - d.log("update available: %s %s -> %s", s.Tool, s.Current, s.Latest) - } - } - - if !needsUpdate { - d.log("all tools up to date") - } - - d.mu.Lock() - d.lastCheck = time.Now() - d.lastStatus = statuses - d.mu.Unlock() - - if d.config.Profile.Preferences.AutoUpdate && needsUpdate { - d.log("auto-updating...") - results := updater.RunAutoUpdate(statuses) - for _, r := range results { - if r.Message != "" { - d.log(" %s: %s", r.Tool, r.Message) - } - } - } - - if d.onUpdate != nil { - d.onUpdate(statuses) - } - - return statuses -} - -func (d *Daemon) log(format string, args ...interface{}) { - msg := fmt.Sprintf("[%s] %s", time.Now().Format("15:04:05"), fmt.Sprintf(format, args...)) - d.mu.Lock() - d.logs = append(d.logs, msg) - if len(d.logs) > 500 { - d.logs = d.logs[250:] - } - d.mu.Unlock() -} - -func RunStandalone(cfg *config.MuyueConfig) { - d := NewDaemon(cfg, 1*time.Hour) - d.Start() - - sigCh := make(chan os.Signal, 1) - signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) - - <-sigCh - d.Stop() -} diff --git a/internal/desktop/desktop.go b/internal/desktop/desktop.go index 23d2241..88328a9 100644 --- a/internal/desktop/desktop.go +++ b/internal/desktop/desktop.go @@ -26,11 +26,11 @@ type options struct { type option func(*options) -func WithPort(port int) option { +func withPort(port int) option { return func(o *options) { o.port = port } } -func WithNoOpen(noOpen bool) option { +func withNoOpen(noOpen bool) option { return func(o *options) { o.noOpen = noOpen } } @@ -39,10 +39,10 @@ func parseFlags(args []string) []option { for _, arg := range args { switch { case arg == "--no-open": - opts = append(opts, WithNoOpen(true)) + opts = append(opts, withNoOpen(true)) case strings.HasPrefix(arg, "--port="): if p, err := strconv.Atoi(strings.TrimPrefix(arg, "--port=")); err == nil { - opts = append(opts, WithPort(p)) + opts = append(opts, withPort(p)) } case arg == "--port": // handled as prefix case diff --git a/internal/installer/installer.go b/internal/installer/installer.go index edad4ff..850973d 100644 --- a/internal/installer/installer.go +++ b/internal/installer/installer.go @@ -290,46 +290,6 @@ func (i *Installer) installGit() InstallResult { return InstallResult{Tool: "git", Success: true, Message: "installed and configured"} } -func (i *Installer) SetupPrompt() error { - starshipPath, err := exec.LookPath("starship") - if err != nil { - return fmt.Errorf("starship not found") - } - - rcFile := i.getRCFile() - line := fmt.Sprintf("eval \"$(" + starshipPath + " init %s)\"", i.system.Shell) - appendLine(rcFile, line) - - configDir, _ := config.ConfigDir() - starshipConfig := `format = """ -$directory\ -$git_branch\ -$git_status\ -$git_metrics\ -$nodejs\ -$python\ -$golang\ -$rust\ -$cmd_duration\ -$line_break\ -$character""" - -[character] -success_symbol = "[❯](bold green)" -error_symbol = "[❯](bold red)" - -[git_branch] -format = "[$symbol$branch]($style) " - -[git_status] -format = '([$all_status$ahead_behind]($style) )' -` - configPath := configDir + "/starship.toml" - os.MkdirAll(configDir, 0755) - os.WriteFile(configPath, []byte(starshipConfig), 0644) - - return nil -} func (i *Installer) getRCFile() string { home, _ := os.UserHomeDir() diff --git a/internal/lsp/lsp.go b/internal/lsp/lsp.go index 6e8ece5..6d55614 100644 --- a/internal/lsp/lsp.go +++ b/internal/lsp/lsp.go @@ -1,13 +1,9 @@ package lsp import ( - "encoding/json" "fmt" "os" "os/exec" - "path/filepath" - - "github.com/muyue/muyue/internal/config" ) type LSPServer struct { @@ -15,14 +11,9 @@ type LSPServer struct { Language string `json:"language"` Command string `json:"command"` InstallCmd string `json:"install_cmd"` - ConfigFile string `json:"config_file"` Installed bool `json:"installed"` } -type LSPConfig struct { - Servers []LSPServer `json:"servers"` -} - var knownServers = []LSPServer{ {Name: "gopls", Language: "go", Command: "gopls", InstallCmd: "go install golang.org/x/tools/gopls@latest"}, {Name: "pyright", Language: "python", Command: "pyright", InstallCmd: "npm install -g pyright"}, @@ -111,85 +102,4 @@ func InstallForLanguages(languages []string) []LSPServer { return results } -func GenerateCrushConfig(cfg *config.MuyueConfig) error { - if cfg == nil { - return fmt.Errorf("config is nil") - } - configDir, err := config.ConfigDir() - if err != nil { - return err - } - - type lspEntry struct { - Command []string `json:"command"` - } - - lspConfig := map[string]lspEntry{} - - for _, lang := range cfg.Profile.Languages { - switch lang { - case "go": - lspConfig["go"] = lspEntry{Command: []string{"gopls"}} - case "python": - lspConfig["python"] = lspEntry{Command: []string{"pyright-langserver", "--stdio"}} - case "typescript", "javascript": - lspConfig["typescript"] = lspEntry{Command: []string{"typescript-language-server", "--stdio"}} - case "rust": - lspConfig["rust"] = lspEntry{Command: []string{"rust-analyzer"}} - case "c", "cpp": - lspConfig["c"] = lspEntry{Command: []string{"clangd"}} - case "lua": - lspConfig["lua"] = lspEntry{Command: []string{"lua-language-server"}} - } - } - - if len(lspConfig) == 0 { - return nil - } - - data, err := json.MarshalIndent(lspConfig, "", " ") - if err != nil { - return err - } - - lspPath := filepath.Join(configDir, "crush.json") - existing, err := os.ReadFile(lspPath) - if err == nil { - var existingConfig map[string]interface{} - if unmarshalErr := json.Unmarshal(existing, &existingConfig); unmarshalErr == nil { - var newConfig map[string]interface{} - if unmarshalErr2 := json.Unmarshal(data, &newConfig); unmarshalErr2 == nil { - for k, v := range newConfig { - existingConfig[k] = v - } - data, _ = json.MarshalIndent(existingConfig, "", " ") - } - } - } - - return os.WriteFile(lspPath, data, 0644) -} - -func EnsureCrushConfig(cfg *config.MuyueConfig) error { - configDir, _ := config.ConfigDir() - crusherPath := filepath.Join(configDir, "crush.json") - - if _, err := os.Stat(crusherPath); err != nil { - home, _ := os.UserHomeDir() - homeCrush := filepath.Join(home, ".config", "crush", "crush.json") - if _, err := os.Stat(homeCrush); err == nil { - return nil - } - - defaultConfig := map[string]interface{}{ - "version": "1", - } - - data, _ := json.MarshalIndent(defaultConfig, "", " ") - os.MkdirAll(filepath.Dir(crusherPath), 0755) - return os.WriteFile(crusherPath, data, 0644) - } - - return nil -} diff --git a/internal/orchestrator/orchestrator.go b/internal/orchestrator/orchestrator.go index 3717ba7..792ec24 100644 --- a/internal/orchestrator/orchestrator.go +++ b/internal/orchestrator/orchestrator.go @@ -12,7 +12,6 @@ import ( "time" "github.com/muyue/muyue/internal/config" - "github.com/muyue/muyue/internal/workflow" ) var thinkRegex = regexp.MustCompile(`(?s)<[Tt]hink[^>]*>.*?`) @@ -47,7 +46,6 @@ type Orchestrator struct { client *http.Client history []Message histMu sync.Mutex - Workflow *workflow.Workflow } var sharedHTTPClient = &http.Client{ @@ -72,11 +70,10 @@ func New(cfg *config.MuyueConfig) (*Orchestrator, error) { } return &Orchestrator{ - config: cfg, + config: cfg, provider: provider, - client: sharedHTTPClient, - history: []Message{}, - Workflow: workflow.New(), + client: sharedHTTPClient, + history: []Message{}, }, nil } @@ -153,156 +150,6 @@ func (o *Orchestrator) Send(userMessage string) (string, error) { return content, nil } -func (o *Orchestrator) StartWorkflow(goal string) (string, error) { - o.Workflow.Start(goal) - prompt := fmt.Sprintf("I want to: %s\nWhat questions do you need to ask me to fully understand this requirement? Ask ALL questions at once.", goal) - o.history = []Message{ - {Role: "system", Content: workflow.BuildSystemPrompt(workflow.PhaseGathering, o.Workflow.Plan)}, - {Role: "user", Content: prompt}, - } - - reqBody := ChatRequest{ - Model: o.provider.Model, - Messages: o.history, - Stream: false, - } - - body, err := json.Marshal(reqBody) - if err != nil { - return "", fmt.Errorf("marshal request: %w", err) - } - - baseURL := o.provider.BaseURL - if baseURL == "" { - baseURL = getProviderBaseURL(o.provider.Name) - } - - url := strings.TrimRight(baseURL, "/") + "/chat/completions" - - req, err := http.NewRequest("POST", url, bytes.NewReader(body)) - if err != nil { - return "", fmt.Errorf("create request: %w", err) - } - - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Authorization", "Bearer "+o.provider.APIKey) - - resp, err := o.client.Do(req) - if err != nil { - return "", fmt.Errorf("send request: %w", err) - } - defer resp.Body.Close() - - respBody, err := io.ReadAll(resp.Body) - if err != nil { - return "", fmt.Errorf("read response: %w", err) - } - - if resp.StatusCode != http.StatusOK { - return "", fmt.Errorf("API error (%d): %s", resp.StatusCode, string(respBody)) - } - - var chatResp ChatResponse - if err := json.Unmarshal(respBody, &chatResp); err != nil { - return "", fmt.Errorf("parse response: %w", err) - } - - if len(chatResp.Choices) == 0 { - return "", fmt.Errorf("no response from AI") - } - - content := cleanAIResponse(chatResp.Choices[0].Message.Content) - o.history = append(o.history, Message{ - Role: "assistant", - Content: content, - }) - - return content, nil -} - -func (o *Orchestrator) AnswerQuestion(answer string) (string, error) { - o.Workflow.AddAnswer(answer) - return o.Send(answer) -} - -func (o *Orchestrator) GeneratePlan() (string, error) { - o.Workflow.Phase = workflow.PhasePlanning - o.history = append(o.history, Message{ - Role: "system", - Content: workflow.BuildSystemPrompt(workflow.PhasePlanning, o.Workflow.Plan), - }) - - prompt := "All questions have been answered. Now create a detailed step-by-step execution plan as a JSON array. Each step should have: id, title, description, agent (crush/claude/muyue)." - if len(o.Workflow.Plan.PreviewFiles) > 0 { - prompt += "\nInclude visual previews where helpful using the PREVIEW_JSON format." - } - - resp, err := o.Send(prompt) - if err != nil { - return "", err - } - - steps, parseErr := workflow.ParsePlanResponse(resp) - if parseErr == nil { - o.Workflow.SetPlan("") - o.Workflow.Plan.Steps = steps - o.Workflow.Phase = workflow.PhaseReviewing - } - - previewFiles := workflow.ParsePreviewFiles(resp) - if len(previewFiles) > 0 { - o.Workflow.SetPreviewFiles(previewFiles) - } - - return resp, nil -} - -func (o *Orchestrator) ReviewPlan(approved bool, feedback string) (string, error) { - if approved { - o.Workflow.Approve() - return o.executeNextStep() - } - o.Workflow.Reject(feedback) - return o.Send(fmt.Sprintf("The plan was rejected. Reason: %s. Please revise the plan.", feedback)) -} - -func (o *Orchestrator) executeNextStep() (string, error) { - step := o.Workflow.CurrentStep() - if step == nil { - return "All steps completed!", nil - } - - o.history = append(o.history, Message{ - Role: "system", - Content: workflow.BuildSystemPrompt(workflow.PhaseExecuting, o.Workflow.Plan), - }) - - return o.Send(fmt.Sprintf("Execute step %s: %s\n%s", step.ID, step.Title, step.Description)) -} - -func (o *Orchestrator) ContinueExecution(output string) (string, error) { - o.Workflow.AdvanceStep(output) - if o.Workflow.Phase == workflow.PhaseDone { - return "Workflow completed! All steps have been executed.", nil - } - return o.executeNextStep() -} - -func (o *Orchestrator) History() []Message { - o.histMu.Lock() - defer o.histMu.Unlock() - cp := make([]Message, len(o.history)) - copy(cp, o.history) - return cp -} - -func (o *Orchestrator) ClearHistory() { - o.histMu.Lock() - o.history = []Message{} - o.histMu.Unlock() - o.Workflow.Reset() -} - func cleanAIResponse(content string) string { content = thinkRegex.ReplaceAllString(content, "") lines := strings.Split(content, "\n") diff --git a/internal/orchestrator/orchestrator_test.go b/internal/orchestrator/orchestrator_test.go index ff93e44..ee9b372 100644 --- a/internal/orchestrator/orchestrator_test.go +++ b/internal/orchestrator/orchestrator_test.go @@ -7,13 +7,6 @@ import ( "github.com/muyue/muyue/internal/config" ) -func testConfig() *config.MuyueConfig { - cfg := config.Default() - cfg.AI.Providers[0].Active = true - cfg.AI.Providers[0].APIKey = "test-api-key-12345" - return cfg -} - func TestCleanAIResponse(t *testing.T) { tests := []struct { name string @@ -153,58 +146,3 @@ func TestNewNoAPIKey(t *testing.T) { t.Error("Should fail with no API key") } } - -func TestHistoryManagement(t *testing.T) { - cfg := testConfig() - orch, err := New(cfg) - if err != nil { - t.Fatalf("New failed: %v", err) - } - - h := orch.History() - if len(h) != 0 { - t.Errorf("Expected empty history, got %d", len(h)) - } - - orch.ClearHistory() - h = orch.History() - if len(h) != 0 { - t.Errorf("Expected 0 after clear, got %d", len(h)) - } -} - -func TestHistoryCopy(t *testing.T) { - cfg := testConfig() - orch, _ := New(cfg) - - orch.history = []Message{ - {Role: "user", Content: "hello"}, - } - - h := orch.History() - h[0].Content = "modified" - - orig := orch.History() - if orig[0].Content == "modified" { - t.Error("History should return a copy") - } -} - -func TestMaxHistorySize(t *testing.T) { - cfg := testConfig() - orch, _ := New(cfg) - - for i := 0; i < maxHistorySize+10; i++ { - orch.histMu.Lock() - orch.history = append(orch.history, Message{Role: "user", Content: "msg"}) - if len(orch.history) > maxHistorySize { - orch.history = orch.history[len(orch.history)-maxHistorySize:] - } - orch.histMu.Unlock() - } - - h := orch.History() - if len(h) > maxHistorySize { - t.Errorf("History should be capped at %d, got %d", maxHistorySize, len(h)) - } -} diff --git a/internal/preview/preview.go b/internal/preview/preview.go deleted file mode 100644 index 1e5b155..0000000 --- a/internal/preview/preview.go +++ /dev/null @@ -1,79 +0,0 @@ -package preview - -import ( - "fmt" - "net/http" - "os" - "os/exec" - "path/filepath" - "runtime" - "time" -) - -type PreviewServer struct { - dir string - server *http.Server -} - -func NewPreviewServer(dir string) *PreviewServer { - return &PreviewServer{dir: dir} -} - -func (p *PreviewServer) Start(port int) error { - fs := http.FileServer(http.Dir(p.dir)) - mux := http.NewServeMux() - mux.Handle("/", fs) - - p.server = &http.Server{ - Addr: fmt.Sprintf("127.0.0.1:%d", port), - Handler: mux, - ReadTimeout: 30 * time.Second, - WriteTimeout: 30 * time.Second, - } - - go func() { - if err := p.server.ListenAndServe(); err != nil && err != http.ErrServerClosed { - fmt.Printf("Preview server error: %s\n", err) - } - }() - - url := fmt.Sprintf("http://127.0.0.1:%d", port) - fmt.Printf("Preview server running at %s\n", url) - - return openBrowser(url) -} - -func (p *PreviewServer) Stop() error { - if p.server != nil { - return p.server.Close() - } - return nil -} - -func CreatePreviewFile(dir, filename, content string) error { - if err := os.MkdirAll(dir, 0755); err != nil { - return err - } - return os.WriteFile(filepath.Join(dir, filename), []byte(content), 0644) -} - -func openBrowser(url string) error { - var cmd string - var args []string - - switch runtime.GOOS { - case "linux": - cmd = "xdg-open" - args = []string{url} - case "darwin": - cmd = "open" - args = []string{url} - case "windows": - cmd = "cmd" - args = []string{"/c", "start", url} - default: - return fmt.Errorf("unsupported platform") - } - - return exec.Command(cmd, args...).Start() -} diff --git a/internal/proxy/proxy.go b/internal/proxy/proxy.go deleted file mode 100644 index 1e16c17..0000000 --- a/internal/proxy/proxy.go +++ /dev/null @@ -1,250 +0,0 @@ -package proxy - -import ( - "bufio" - "context" - "fmt" - "io" - "os" - "os/exec" - "strings" - "sync" - "time" -) - -type AgentType string - -const ( - AgentCrush AgentType = "crush" - AgentClaude AgentType = "claude" -) - -type AgentStatus string - -const ( - StatusIdle AgentStatus = "idle" - StatusRunning AgentStatus = "running" - StatusStopped AgentStatus = "stopped" - StatusError AgentStatus = "error" -) - -type LogEntry struct { - Timestamp time.Time - Agent AgentType - Level string - Message string -} - -type Agent struct { - Type AgentType - Status AgentStatus - cmd *exec.Cmd - stdout io.Reader - stderr io.Reader - cancel context.CancelFunc - mu sync.Mutex - logs []LogEntry -} - -type Manager struct { - agents map[AgentType]*Agent - mu sync.RWMutex -} - -func NewManager() *Manager { - return &Manager{ - agents: make(map[AgentType]*Agent), - } -} - -func (m *Manager) Start(agentType AgentType, args ...string) error { - m.mu.Lock() - defer m.mu.Unlock() - - if a, exists := m.agents[agentType]; exists && a.Status == StatusRunning { - return fmt.Errorf("%s already running", agentType) - } - - ctx, cancel := context.WithCancel(context.Background()) - - var cmdName string - switch agentType { - case AgentCrush: - cmdName = "crush" - case AgentClaude: - cmdName = "claude" - default: - cancel() - return fmt.Errorf("unknown agent type: %s", agentType) - } - - cmd := exec.CommandContext(ctx, cmdName, args...) - cmd.Env = os.Environ() - - stdout, pipeErr := cmd.StdoutPipe() - if pipeErr != nil { - cancel() - return fmt.Errorf("stdout pipe: %w", pipeErr) - } - stderr, pipeErr := cmd.StderrPipe() - if pipeErr != nil { - cancel() - return fmt.Errorf("stderr pipe: %w", pipeErr) - } - - agent := &Agent{ - Type: agentType, - Status: StatusRunning, - cmd: cmd, - stdout: stdout, - stderr: stderr, - cancel: cancel, - } - - m.agents[agentType] = agent - - go agent.captureOutput(stdout, "info") - go agent.captureOutput(stderr, "error") - - if err := cmd.Start(); err != nil { - agent.Status = StatusError - cancel() - return fmt.Errorf("start %s: %w", agentType, err) - } - - go func() { - err := cmd.Wait() - m.mu.Lock() - defer m.mu.Unlock() - if err != nil && ctx.Err() == nil { - agent.Status = StatusError - agent.log("error", fmt.Sprintf("exited with error: %s", err)) - } else { - agent.Status = StatusStopped - agent.log("info", "stopped") - } - }() - - return nil -} - -func (m *Manager) Stop(agentType AgentType) error { - m.mu.Lock() - defer m.mu.Unlock() - - agent, exists := m.agents[agentType] - if !exists { - return fmt.Errorf("%s not found", agentType) - } - - if agent.Status != StatusRunning { - return fmt.Errorf("%s is not running", agentType) - } - - agent.cancel() - agent.Status = StatusStopped - return nil -} - -func (m *Manager) Status(agentType AgentType) (AgentStatus, []LogEntry) { - m.mu.RLock() - defer m.mu.RUnlock() - - agent, exists := m.agents[agentType] - if !exists { - return StatusIdle, nil - } - - agent.mu.Lock() - defer agent.mu.Unlock() - - return agent.Status, agent.logs -} - -func (m *Manager) AllStatus() map[AgentType]AgentStatus { - m.mu.RLock() - defer m.mu.RUnlock() - - statuses := make(map[AgentType]AgentStatus) - for t, a := range m.agents { - statuses[t] = a.Status - } - return statuses -} - - -func (a *Agent) captureOutput(reader io.Reader, level string) { - scanner := bufio.NewScanner(reader) - for scanner.Scan() { - line := scanner.Text() - a.mu.Lock() - a.logs = append(a.logs, LogEntry{ - Timestamp: time.Now(), - Agent: a.Type, - Level: level, - Message: line, - }) - if len(a.logs) > 1000 { - a.logs = a.logs[500:] - } - a.mu.Unlock() - } -} - -func (a *Agent) log(level, msg string) { - a.mu.Lock() - defer a.mu.Unlock() - a.logs = append(a.logs, LogEntry{ - Timestamp: time.Now(), - Agent: a.Type, - Level: level, - Message: msg, - }) -} - -func (m *Manager) IsAvailable(agentType AgentType) bool { - var cmdName string - switch agentType { - case AgentCrush: - cmdName = "crush" - case AgentClaude: - cmdName = "claude" - default: - return false - } - - path, err := exec.LookPath(cmdName) - return err == nil && path != "" -} - -func (m *Manager) GetLogs(agentType AgentType, lastN int) []LogEntry { - m.mu.RLock() - agent, exists := m.agents[agentType] - m.mu.RUnlock() - - if !exists { - return nil - } - - agent.mu.Lock() - defer agent.mu.Unlock() - - logs := agent.logs - if lastN > 0 && len(logs) > lastN { - logs = logs[len(logs)-lastN:] - } - return logs -} - -func FormatLogs(logs []LogEntry) string { - var b strings.Builder - for _, l := range logs { - b.WriteString(fmt.Sprintf("[%s] %s %s: %s\n", - l.Timestamp.Format("15:04:05"), - l.Agent, - l.Level, - l.Message, - )) - } - return b.String() -} diff --git a/internal/skills/skills.go b/internal/skills/skills.go index 7e39b63..1953a5d 100644 --- a/internal/skills/skills.go +++ b/internal/skills/skills.go @@ -24,14 +24,6 @@ type Skill struct { FilePath string `yaml:"-" json:"-"` } -type Target string - -const ( - TargetCrush Target = "crush" - TargetClaude Target = "claude" - TargetBoth Target = "both" -) - func SkillsDir() (string, error) { home, err := os.UserHomeDir() if err != nil { @@ -122,27 +114,6 @@ func Create(skill *Skill) error { return Deploy(skill) } -func Update(skill *Skill) error { - dir, err := SkillsDir() - if err != nil { - return err - } - - skillDir := filepath.Join(dir, skill.Name) - skillPath := filepath.Join(skillDir, "SKILL.md") - if _, err := os.Stat(skillPath); err != nil { - return fmt.Errorf("skill '%s' not found", skill.Name) - } - - skill.UpdatedAt = time.Now() - content := renderSkill(skill) - if err := os.WriteFile(skillPath, []byte(content), 0644); err != nil { - return err - } - - return Deploy(skill) -} - func Delete(name string) error { dir, err := SkillsDir() if err != nil { @@ -164,7 +135,7 @@ func Deploy(skill *Skill) error { return fmt.Errorf("get home dir: %w", err) } - if skill.Target == string(TargetCrush) || skill.Target == string(TargetBoth) { + if skill.Target == "crush" || skill.Target == "both" { crushSkillsDir := filepath.Join(home, ".config", "crush", "skills") if err := os.MkdirAll(crushSkillsDir, 0755); err != nil { return fmt.Errorf("create crush skills dir: %w", err) @@ -179,7 +150,7 @@ func Deploy(skill *Skill) error { } } - if skill.Target == string(TargetClaude) || skill.Target == string(TargetBoth) { + if skill.Target == "claude" || skill.Target == "both" { claudeSkillsDir := filepath.Join(home, ".claude", "skills") if err := os.MkdirAll(claudeSkillsDir, 0755); err != nil { return fmt.Errorf("create claude skills dir: %w", err) diff --git a/internal/version/version.go b/internal/version/version.go index 5f7df83..fef4eda 100644 --- a/internal/version/version.go +++ b/internal/version/version.go @@ -4,15 +4,8 @@ const ( Name = "muyue" Version = "0.2.1" Author = "La Légion de Muyue" - License = "MIT" ) -var Prerelease string - func FullVersion() string { - v := Name + " v" + Version - if Prerelease != "" { - v += "-" + Prerelease - } - return v + return Name + " v" + Version } diff --git a/internal/version/version_test.go b/internal/version/version_test.go index 8041a7d..f829516 100644 --- a/internal/version/version_test.go +++ b/internal/version/version_test.go @@ -15,28 +15,6 @@ func TestFullVersion(t *testing.T) { } } -func TestFullVersionWithPrerelease(t *testing.T) { - original := Prerelease - Prerelease = "beta.1" - defer func() { Prerelease = original }() - - v := FullVersion() - if !strings.Contains(v, "beta.1") { - t.Errorf("FullVersion should contain prerelease suffix, got %s", v) - } -} - -func TestFullVersionWithoutPrerelease(t *testing.T) { - original := Prerelease - Prerelease = "" - defer func() { Prerelease = original }() - - v := FullVersion() - if strings.Contains(v, "-") { - t.Errorf("FullVersion should not contain prerelease suffix, got %s", v) - } -} - func TestConstants(t *testing.T) { if Name == "" { t.Error("Name should not be empty") @@ -47,7 +25,4 @@ func TestConstants(t *testing.T) { if Author == "" { t.Error("Author should not be empty") } - if License == "" { - t.Error("License should not be empty") - } } diff --git a/internal/workflow/workflow.go b/internal/workflow/workflow.go deleted file mode 100644 index 564150b..0000000 --- a/internal/workflow/workflow.go +++ /dev/null @@ -1,280 +0,0 @@ -package workflow - -import ( - "encoding/json" - "fmt" - "strings" -) - -type Phase string - -const ( - PhaseIdle Phase = "idle" - PhaseGathering Phase = "gathering" - PhasePlanning Phase = "planning" - PhaseReviewing Phase = "reviewing" - PhaseExecuting Phase = "executing" - PhaseDone Phase = "done" - PhaseError Phase = "error" -) - -type Step struct { - ID string `json:"id"` - Title string `json:"title"` - Description string `json:"description"` - Status string `json:"status"` - Agent string `json:"agent"` - Output string `json:"output,omitempty"` -} - -type Plan struct { - Goal string `json:"goal"` - Context string `json:"context"` - Questions []string `json:"questions"` - Answers []string `json:"answers"` - Steps []Step `json:"steps"` - StepIndex int `json:"current_step"` - PreviewFiles []PreviewFile `json:"preview_files,omitempty"` -} - -type PreviewFile struct { - Filename string `json:"filename"` - Content string `json:"content"` - Type string `json:"type"` -} - -type Workflow struct { - Phase Phase - Plan *Plan - History []string -} - -func New() *Workflow { - return &Workflow{ - Phase: PhaseIdle, - Plan: &Plan{}, - History: []string{}, - } -} - -func (w *Workflow) Start(goal string) { - w.Phase = PhaseGathering - w.Plan = &Plan{ - Goal: goal, - Steps: []Step{}, - Answers: []string{}, - } - w.History = append(w.History, fmt.Sprintf("[started] %s", goal)) -} - -func (w *Workflow) AddAnswer(answer string) { - w.Plan.Answers = append(w.Plan.Answers, answer) - if len(w.Plan.Answers) >= len(w.Plan.Questions) { - w.Phase = PhasePlanning - w.History = append(w.History, "[gathering complete, moving to planning]") - } -} - -func (w *Workflow) SetPlan(planJSON string) error { - var steps []Step - if err := json.Unmarshal([]byte(planJSON), &steps); err != nil { - if err2 := json.Unmarshal([]byte("["+planJSON+"]"), &steps); err2 != nil { - return fmt.Errorf("parse plan: %w", err) - } - } - w.Plan.Steps = steps - w.Phase = PhaseReviewing - w.History = append(w.History, fmt.Sprintf("[plan created] %d steps", len(steps))) - return nil -} - -func (w *Workflow) SetPreviewFiles(files []PreviewFile) { - w.Plan.PreviewFiles = files -} - -func (w *Workflow) Approve() { - w.Phase = PhaseExecuting - w.Plan.StepIndex = 0 - w.History = append(w.History, "[plan approved, starting execution]") -} - -func (w *Workflow) Reject(feedback string) { - w.Phase = PhasePlanning - w.History = append(w.History, fmt.Sprintf("[plan rejected: %s]", feedback)) -} - -func (w *Workflow) AdvanceStep(output string) { - if w.Plan.StepIndex < len(w.Plan.Steps) { - w.Plan.Steps[w.Plan.StepIndex].Status = "done" - w.Plan.Steps[w.Plan.StepIndex].Output = output - w.Plan.StepIndex++ - w.History = append(w.History, fmt.Sprintf("[step %d done]", w.Plan.StepIndex)) - - if w.Plan.StepIndex >= len(w.Plan.Steps) { - w.Phase = PhaseDone - w.History = append(w.History, "[all steps complete]") - } - } -} - -func (w *Workflow) FailStep(errMsg string) { - if w.Plan.StepIndex < len(w.Plan.Steps) { - w.Plan.Steps[w.Plan.StepIndex].Status = "error" - w.Plan.Steps[w.Plan.StepIndex].Output = errMsg - w.Phase = PhaseError - w.History = append(w.History, fmt.Sprintf("[step %d failed: %s]", w.Plan.StepIndex+1, errMsg)) - } -} - -func (w *Workflow) Reset() { - w.Phase = PhaseIdle - w.Plan = &Plan{} -} - -func (w *Workflow) CurrentStep() *Step { - if w.Plan.StepIndex < len(w.Plan.Steps) { - return &w.Plan.Steps[w.Plan.StepIndex] - } - return nil -} - -func (w *Workflow) Progress() (done, total int) { - for _, s := range w.Plan.Steps { - if s.Status == "done" { - done++ - } - total++ - } - return -} - -func BuildSystemPrompt(phase Phase, plan *Plan) string { - base := `You are muyue, an AI-powered development environment assistant. -You follow a structured workflow: GATHER requirements → PLAN → REVIEW → EXECUTE. - -RULES: -- Always respond in the same language the user writes in. -- When in GATHERING phase, ask clarifying questions ONE AT A TIME to understand the requirement fully. -- When in PLANNING phase, create a detailed step-by-step plan as a JSON array of objects. -- When in REVIEWING phase, present the plan clearly and wait for approval. -- When in EXECUTING phase, execute one step at a time and report results. -- If the user wants a visual preview, generate 1-2 HTML files wrapped in a PREVIEW_JSON block.` - - switch phase { - case PhaseGathering: - base += fmt.Sprintf(` - -CURRENT PHASE: GATHERING -Goal: %s -Questions to ask: %v -Answers received: %v -Remaining questions: %d -Ask the NEXT question that hasn't been answered yet. If all questions are answered, say "GATHERING_COMPLETE".`, - plan.Goal, plan.Questions, plan.Answers, - len(plan.Questions)-len(plan.Answers)) - - case PhasePlanning: - qa := "" - for i, q := range plan.Questions { - a := "" - if i < len(plan.Answers) { - a = plan.Answers[i] - } - qa += fmt.Sprintf("\nQ: %s\nA: %s", q, a) - } - base += fmt.Sprintf(` - -CURRENT PHASE: PLANNING -Goal: %s -%s - -Create a step-by-step plan. Output ONLY a JSON array of steps: -[ - {"id": "1", "title": "...", "description": "...", "agent": "crush|claude|muyue", "status": "pending"}, - ... -] - -If the user needs a visual preview, wrap HTML in: -<<>> -[{"filename":"preview.html","content":"...","type":"html"}] -<<>>`, - plan.Goal, qa) - - case PhaseReviewing: - steps, _ := json.MarshalIndent(plan.Steps, "", " ") - base += fmt.Sprintf(` - -CURRENT PHASE: REVIEWING -Present the plan below clearly and ask for approval: -%s - -Say "PLAN_APPROVED" if the user approves, or "PLAN_REJECTED: " if not.`, - string(steps)) - - case PhaseExecuting: - if plan.StepIndex < len(plan.Steps) { - step := plan.Steps[plan.StepIndex] - base += fmt.Sprintf(` - -CURRENT PHASE: EXECUTING -Current step: %s — %s (agent: %s) -Execute this step and report the result.`, - step.Title, step.Description, step.Agent) - } - } - - return base -} - -func ParsePlanResponse(response string) ([]Step, error) { - response = strings.TrimSpace(response) - - start := strings.Index(response, "[") - end := strings.LastIndex(response, "]") - if start == -1 || end == -1 || end <= start { - return nil, fmt.Errorf("no JSON array found in response") - } - - jsonStr := response[start : end+1] - var steps []Step - if err := json.Unmarshal([]byte(jsonStr), &steps); err != nil { - return nil, fmt.Errorf("parse steps: %w", err) - } - - for i := range steps { - steps[i].Status = "pending" - } - - return steps, nil -} - -func ParsePreviewFiles(response string) []PreviewFile { - startMarker := "<<>>" - endMarker := "<<>>" - start := strings.Index(response, startMarker) - end := strings.Index(response, endMarker) - if start == -1 || end == -1 { - return nil - } - - jsonStr := strings.TrimSpace(response[start+len(startMarker) : end]) - var files []PreviewFile - if err := json.Unmarshal([]byte(jsonStr), &files); err != nil { - return nil - } - return files -} - -func ParseApproval(response string) (approved bool, feedback string) { - lower := strings.ToLower(strings.TrimSpace(response)) - if strings.Contains(lower, "plan_approved") || strings.Contains(lower, "approved") || strings.Contains(lower, "yes") || strings.Contains(lower, "go ahead") || strings.Contains(lower, "oui") || strings.Contains(lower, "ok") { - return true, "" - } - if strings.Contains(lower, "plan_rejected:") { - parts := strings.SplitN(lower, "plan_rejected:", 2) - if len(parts) > 1 { - return false, strings.TrimSpace(parts[1]) - } - } - return false, response -} diff --git a/internal/workflow/workflow_test.go b/internal/workflow/workflow_test.go deleted file mode 100644 index 9825a9a..0000000 --- a/internal/workflow/workflow_test.go +++ /dev/null @@ -1,255 +0,0 @@ -package workflow - -import ( - "testing" -) - -func TestNew(t *testing.T) { - wf := New() - if wf.Phase != PhaseIdle { - t.Errorf("Expected PhaseIdle, got %s", wf.Phase) - } - if wf.Plan == nil { - t.Error("Plan should not be nil") - } -} - -func TestStart(t *testing.T) { - wf := New() - wf.Start("Build a REST API") - if wf.Phase != PhaseGathering { - t.Errorf("Expected PhaseGathering, got %s", wf.Phase) - } - if wf.Plan.Goal != "Build a REST API" { - t.Errorf("Expected goal 'Build a REST API', got %s", wf.Plan.Goal) - } -} - -func TestAddAnswer(t *testing.T) { - wf := New() - wf.Start("test goal") - wf.Plan.Questions = []string{"Q1?", "Q2?"} - - wf.AddAnswer("A1") - if wf.Phase != PhaseGathering { - t.Errorf("Should still be gathering, got %s", wf.Phase) - } - - wf.AddAnswer("A2") - if wf.Phase != PhasePlanning { - t.Errorf("Should move to planning, got %s", wf.Phase) - } -} - -func TestSetPlan(t *testing.T) { - wf := New() - planJSON := `[{"id":"1","title":"Step 1","description":"Do something","agent":"crush","status":"pending"}]` - err := wf.SetPlan(planJSON) - if err != nil { - t.Fatalf("SetPlan failed: %v", err) - } - if len(wf.Plan.Steps) != 1 { - t.Errorf("Expected 1 step, got %d", len(wf.Plan.Steps)) - } - if wf.Phase != PhaseReviewing { - t.Errorf("Expected PhaseReviewing, got %s", wf.Phase) - } -} - -func TestApprove(t *testing.T) { - wf := New() - wf.Start("test") - wf.Plan.Steps = []Step{{ID: "1", Title: "Step 1", Status: "pending"}} - wf.Phase = PhaseReviewing - wf.Approve() - if wf.Phase != PhaseExecuting { - t.Errorf("Expected PhaseExecuting, got %s", wf.Phase) - } - if wf.Plan.StepIndex != 0 { - t.Errorf("Expected step index 0, got %d", wf.Plan.StepIndex) - } -} - -func TestReject(t *testing.T) { - wf := New() - wf.Phase = PhaseReviewing - wf.Reject("too complex") - if wf.Phase != PhasePlanning { - t.Errorf("Expected PhasePlanning, got %s", wf.Phase) - } -} - -func TestAdvanceStep(t *testing.T) { - wf := New() - wf.Plan.Steps = []Step{ - {ID: "1", Title: "Step 1", Status: "pending"}, - {ID: "2", Title: "Step 2", Status: "pending"}, - } - wf.Phase = PhaseExecuting - - wf.AdvanceStep("output1") - if wf.Plan.Steps[0].Status != "done" { - t.Error("First step should be done") - } - if wf.Plan.StepIndex != 1 { - t.Errorf("Expected step index 1, got %d", wf.Plan.StepIndex) - } - if wf.Phase != PhaseExecuting { - t.Errorf("Should still be executing, got %s", wf.Phase) - } - - wf.AdvanceStep("output2") - if wf.Phase != PhaseDone { - t.Errorf("Expected PhaseDone, got %s", wf.Phase) - } -} - -func TestFailStep(t *testing.T) { - wf := New() - wf.Plan.Steps = []Step{{ID: "1", Title: "Step 1"}} - wf.Phase = PhaseExecuting - - wf.FailStep("something broke") - if wf.Phase != PhaseError { - t.Errorf("Expected PhaseError, got %s", wf.Phase) - } - if wf.Plan.Steps[0].Status != "error" { - t.Error("Step should have error status") - } -} - -func TestReset(t *testing.T) { - wf := New() - wf.Start("test") - wf.Phase = PhaseExecuting - wf.Reset() - if wf.Phase != PhaseIdle { - t.Errorf("Expected PhaseIdle, got %s", wf.Phase) - } -} - -func TestCurrentStep(t *testing.T) { - wf := New() - if wf.CurrentStep() != nil { - t.Error("Should be nil with no steps") - } - - wf.Plan.Steps = []Step{{ID: "1"}, {ID: "2"}} - wf.Plan.StepIndex = 0 - step := wf.CurrentStep() - if step == nil || step.ID != "1" { - t.Error("Should return first step") - } - - wf.Plan.StepIndex = 2 - if wf.CurrentStep() != nil { - t.Error("Should be nil when past all steps") - } -} - -func TestProgress(t *testing.T) { - wf := New() - wf.Plan.Steps = []Step{ - {ID: "1", Status: "done"}, - {ID: "2", Status: "pending"}, - {ID: "3", Status: "done"}, - } - done, total := wf.Progress() - if done != 2 || total != 3 { - t.Errorf("Expected 2/3, got %d/%d", done, total) - } -} - -func TestParsePlanResponse(t *testing.T) { - resp := `Here is the plan: -[ - {"id": "1", "title": "Setup", "description": "Init project", "agent": "crush"}, - {"id": "2", "title": "Build", "description": "Write code", "agent": "claude"} -]` - steps, err := ParsePlanResponse(resp) - if err != nil { - t.Fatalf("ParsePlanResponse failed: %v", err) - } - if len(steps) != 2 { - t.Errorf("Expected 2 steps, got %d", len(steps)) - } - if steps[0].ID != "1" { - t.Errorf("Expected step ID 1, got %s", steps[0].ID) - } - for _, s := range steps { - if s.Status != "pending" { - t.Errorf("Steps should be pending, got %s", s.Status) - } - } -} - -func TestParsePlanResponseInvalid(t *testing.T) { - _, err := ParsePlanResponse("no json here") - if err == nil { - t.Error("Should fail with no JSON") - } -} - -func TestParseApproval(t *testing.T) { - tests := []struct { - input string - approved bool - }{ - {"plan_approved", true}, - {"approved", true}, - {"yes", true}, - {"ok", true}, - {"oui", true}, - {"go ahead", true}, - {"no", false}, - {"plan_rejected: too complex", false}, - {"I don't like it", false}, - } - for _, tt := range tests { - approved, feedback := ParseApproval(tt.input) - if approved != tt.approved { - t.Errorf("ParseApproval(%q) = %v, want %v", tt.input, approved, tt.approved) - } - if !approved && tt.input == "plan_rejected: too complex" { - if feedback != "too complex" { - t.Errorf("Expected feedback 'too complex', got %s", feedback) - } - } - } -} - -func TestParsePreviewFiles(t *testing.T) { - resp := `Some text -<<>> -[{"filename":"test.html","content":"

Hello

","type":"html"}] -<<>>` - files := ParsePreviewFiles(resp) - if len(files) != 1 { - t.Fatalf("Expected 1 file, got %d", len(files)) - } - if files[0].Filename != "test.html" { - t.Errorf("Expected test.html, got %s", files[0].Filename) - } -} - -func TestParsePreviewFilesNone(t *testing.T) { - files := ParsePreviewFiles("no preview here") - if files != nil { - t.Error("Should return nil") - } -} - -func TestBuildSystemPrompt(t *testing.T) { - prompt := BuildSystemPrompt(PhaseIdle, &Plan{}) - if prompt == "" { - t.Error("Prompt should not be empty") - } - if len(prompt) < 100 { - t.Error("Prompt seems too short") - } - - prompt = BuildSystemPrompt(PhaseGathering, &Plan{Goal: "test"}) - if prompt == "" { - t.Error("Gathering prompt should not be empty") - } -}