From 2e50366cd871d9cca81ac60a357fa0a456726a48 Mon Sep 17 00:00:00 2001 From: Augustin Date: Wed, 22 Apr 2026 22:22:05 +0200 Subject: [PATCH] feat: add Cobra CLI, LSP/MCP registries, workflow engine, and enriched dashboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Major changes: - Refactor CLI entry point to Cobra commands (root, setup, scan, doctor, install, update, lsp, mcp, skills, config, version) - Add LSP registry with health checks, auto-install, and editor config generation - Add MCP registry with editor detection, status tracking, and per-editor configuration - Add workflow engine with planner and step execution for automated task chains - Add conversation search, export (Markdown/JSON), and detailed token counting - Add streaming shell chat handler with tool call/result events - Add skill validation, dry-run testing, and export endpoints - Enrich dashboard with Tools/Activity/Status tabs and tool cards grid - Add PRD documentation - Complete i18n for both EN and FR πŸ’˜ Generated with Crush Assisted-by: GLM-5.1 via Crush --- cmd/muyue/commands/config.go | 59 ++ cmd/muyue/commands/doctor.go | 77 +++ cmd/muyue/commands/install.go | 56 ++ cmd/muyue/commands/lsp.go | 55 ++ cmd/muyue/commands/mcp.go | 54 ++ cmd/muyue/commands/root.go | 66 ++ cmd/muyue/commands/scan.go | 56 ++ cmd/muyue/commands/setup.go | 39 ++ cmd/muyue/commands/skills.go | 105 +++ cmd/muyue/commands/update.go | 80 +++ cmd/muyue/commands/version.go | 23 + cmd/muyue/main.go | 45 +- docs/PRD.md | 914 ++++++++++++++++++++++++++ go.mod | 3 + go.sum | 9 + internal/api/conversation.go | 114 +++- internal/api/handlers_info.go | 301 ++++++++- internal/api/handlers_missing.go | 269 ++++++++ internal/api/handlers_shell_chat.go | 298 +++++++++ internal/api/handlers_tools.go | 27 +- internal/api/handlers_tools_exec.go | 95 +-- internal/api/handlers_workflow.go | 258 ++++++++ internal/api/server.go | 31 + internal/lsp/lsp.go | 227 ++++++- internal/lsp/registry.go | 333 ++++++++++ internal/lsp/registry_test.go | 142 ++++ internal/mcp/mcp.go | 249 ++++++- internal/mcp/registry.go | 520 +++++++++++++++ internal/mcp/registry_test.go | 228 +++++++ internal/orchestrator/orchestrator.go | 176 ++--- internal/skills/builtins.go | 370 ++++++++++- internal/skills/skills.go | 301 ++++++++- internal/skills/skills_test.go | 241 ++++++- internal/workflow/engine.go | 362 ++++++++++ internal/workflow/planner.go | 172 +++++ web/src/api/client.js | 71 ++ web/src/components/Config.jsx | 7 + web/src/components/Dashboard.jsx | 476 ++++++++++++-- web/src/components/Shell.jsx | 82 +-- web/src/i18n/en.js | 16 +- web/src/i18n/fr.js | 16 +- web/src/styles/global.css | 75 +++ 42 files changed, 6779 insertions(+), 319 deletions(-) create mode 100644 cmd/muyue/commands/config.go create mode 100644 cmd/muyue/commands/doctor.go create mode 100644 cmd/muyue/commands/install.go create mode 100644 cmd/muyue/commands/lsp.go create mode 100644 cmd/muyue/commands/mcp.go create mode 100644 cmd/muyue/commands/root.go create mode 100644 cmd/muyue/commands/scan.go create mode 100644 cmd/muyue/commands/setup.go create mode 100644 cmd/muyue/commands/skills.go create mode 100644 cmd/muyue/commands/update.go create mode 100644 cmd/muyue/commands/version.go create mode 100644 docs/PRD.md create mode 100644 internal/api/handlers_missing.go create mode 100644 internal/api/handlers_shell_chat.go create mode 100644 internal/api/handlers_workflow.go create mode 100644 internal/lsp/registry.go create mode 100644 internal/lsp/registry_test.go create mode 100644 internal/mcp/registry.go create mode 100644 internal/mcp/registry_test.go create mode 100644 internal/workflow/engine.go create mode 100644 internal/workflow/planner.go diff --git a/cmd/muyue/commands/config.go b/cmd/muyue/commands/config.go new file mode 100644 index 0000000..b2fa9dc --- /dev/null +++ b/cmd/muyue/commands/config.go @@ -0,0 +1,59 @@ +package commands + +import ( + "fmt" + + "github.com/muyue/muyue/internal/config" + "github.com/spf13/cobra" +) + +var configCmd = &cobra.Command{ + Use: "config", + Short: "Show/print config", +} + +func init() { + rootCmd.AddCommand(configCmd) +} + +func runConfigGet(cmd *cobra.Command, args []string) error { + cfg, err := config.Load() + if err != nil { + return err + } + key := args[0] + fmt.Fprintf(cmd.OutOrStdout(), "%v\n", getConfigValue(cfg, key)) + return nil +} + +func getConfigValue(cfg *config.MuyueConfig, key string) interface{} { + switch key { + case "version": + return cfg.Version + case "profile.name": + return cfg.Profile.Name + case "profile.email": + return cfg.Profile.Email + default: + return nil + } +} + +func runConfigSet(cmd *cobra.Command, args []string) error { + cfg, err := config.Load() + if err != nil { + return err + } + key, value := args[0], args[1] + setConfigValue(cfg, key, value) + return config.Save(cfg) +} + +func setConfigValue(cfg *config.MuyueConfig, key, value string) { + switch key { + case "profile.name": + cfg.Profile.Name = value + case "profile.email": + cfg.Profile.Email = value + } +} \ No newline at end of file diff --git a/cmd/muyue/commands/doctor.go b/cmd/muyue/commands/doctor.go new file mode 100644 index 0000000..b719883 --- /dev/null +++ b/cmd/muyue/commands/doctor.go @@ -0,0 +1,77 @@ +package commands + +import ( + "fmt" + "net/http" + "time" + + "github.com/muyue/muyue/internal/config" + "github.com/muyue/muyue/internal/scanner" + "github.com/spf13/cobra" +) + +var doctorCmd = &cobra.Command{ + Use: "doctor", + Short: "Diagnose issues (scan + config check + connectivity)", + RunE: runDoctor, +} + +func init() { + rootCmd.AddCommand(doctorCmd) +} + +func runDoctor(cmd *cobra.Command, args []string) error { + fmt.Println("Running Muyue diagnostics...") + + fmt.Println("\n=== System Scan ===") + result := scanner.ScanSystem() + for _, t := range result.Tools { + status := "βœ“" + if !t.Installed { + status = "βœ—" + } + fmt.Printf(" %s %s\n", status, t.Name) + } + fmt.Printf("\nInstalled: %d/%d\n", countInstalled(result.Tools), len(result.Tools)) + + fmt.Println("\n=== Config Check ===") + cfg, err := config.Load() + if err != nil { + fmt.Printf(" βœ— Failed to load config: %v\n", err) + } else { + fmt.Printf(" βœ“ Config loaded (version: %s)\n", cfg.Version) + if cfg.Profile.Name != "" { + fmt.Printf(" βœ“ Profile: %s\n", cfg.Profile.Name) + } + } + + fmt.Println("\n=== Connectivity ===") + endpoints := []string{ + "https://api.minimax.io", + "https://api.openai.com", + } + for _, ep := range endpoints { + fmt.Printf(" Checking %s... ", ep) + client := &http.Client{Timeout: 5 * time.Second} + resp, err := client.Head(ep) + if err != nil { + fmt.Printf("βœ— (%v)\n", err) + } else { + resp.Body.Close() + fmt.Printf("βœ“ (status %d)\n", resp.StatusCode) + } + } + + fmt.Println("\n=== Diagnosis complete ===") + return nil +} + +func countInstalled(tools []scanner.ToolStatus) int { + installed := 0 + for _, t := range tools { + if t.Installed { + installed++ + } + } + return installed +} \ No newline at end of file diff --git a/cmd/muyue/commands/install.go b/cmd/muyue/commands/install.go new file mode 100644 index 0000000..52de7c5 --- /dev/null +++ b/cmd/muyue/commands/install.go @@ -0,0 +1,56 @@ +package commands + +import ( + "fmt" + + "github.com/muyue/muyue/internal/installer" + "github.com/muyue/muyue/internal/scanner" + "github.com/spf13/cobra" +) + +var installCmd = &cobra.Command{ + Use: "install [tool]", + Short: "Install missing tools", + Args: cobra.RangeArgs(0, 1), + RunE: runInstall, +} + +var installYes bool + +func init() { + rootCmd.AddCommand(installCmd) + installCmd.Flags().BoolVar(&installYes, "yes", false, "Skip confirmation") +} + +func runInstall(cmd *cobra.Command, args []string) error { + var tools []string + if len(args) > 0 { + tools = args + } + + inst := installer.New(nil) + if len(tools) == 0 { + result := scanner.ScanSystem() + for _, t := range result.Tools { + if !t.Installed { + tools = append(tools, t.Name) + } + } + if len(tools) == 0 { + fmt.Println("All tools already installed!") + return nil + } + fmt.Printf("Installing missing tools: %v\n", tools) + } + + for _, tool := range tools { + fmt.Printf("Installing %s...\n", tool) + res := inst.InstallTool(tool) + if res.Success { + fmt.Printf("βœ“ %s: %s\n", tool, res.Message) + } else { + fmt.Printf("βœ— %s: %s\n", tool, res.Message) + } + } + return nil +} \ No newline at end of file diff --git a/cmd/muyue/commands/lsp.go b/cmd/muyue/commands/lsp.go new file mode 100644 index 0000000..a8db6e1 --- /dev/null +++ b/cmd/muyue/commands/lsp.go @@ -0,0 +1,55 @@ +package commands + +import ( + "fmt" + + "github.com/muyue/muyue/internal/lsp" + "github.com/spf13/cobra" +) + +var lspCmd = &cobra.Command{ + Use: "lsp", + Short: "LSP management", +} + +func init() { + rootCmd.AddCommand(lspCmd) + lspCmd.AddCommand(&cobra.Command{ + Use: "scan", + Short: "Scan for installed LSPs", + RunE: runLSPScan, + }) + lspCmd.AddCommand(&cobra.Command{ + Use: "install [name]", + Short: "Install LSP server(s)", + Args: cobra.RangeArgs(0, 1), + RunE: runLSPInstall, + }) +} + +func runLSPScan(cmd *cobra.Command, args []string) error { + servers := lsp.ScanServers() + fmt.Printf("%-25s %-15s %-10s\n", "Name", "Language", "Status") + fmt.Println("──────────────────────────────────────────") + for _, s := range servers { + status := "βœ— missing" + if s.Installed { + status = "βœ“ installed" + } + fmt.Printf("%-25s %-15s %-10s\n", s.Name, s.Language, status) + } + return nil +} + +func runLSPInstall(cmd *cobra.Command, args []string) error { + if len(args) == 0 { + return fmt.Errorf("server name required") + } + name := args[0] + fmt.Printf("Installing %s...\n", name) + if err := lsp.InstallServer(name); err != nil { + return err + } + fmt.Printf("βœ“ %s installed\n", name) + return nil +} \ No newline at end of file diff --git a/cmd/muyue/commands/mcp.go b/cmd/muyue/commands/mcp.go new file mode 100644 index 0000000..afae7e4 --- /dev/null +++ b/cmd/muyue/commands/mcp.go @@ -0,0 +1,54 @@ +package commands + +import ( + "fmt" + + "github.com/muyue/muyue/internal/config" + "github.com/muyue/muyue/internal/mcp" + "github.com/spf13/cobra" +) + +var mcpCmd = &cobra.Command{ + Use: "mcp", + Short: "MCP management", +} + +func init() { + rootCmd.AddCommand(mcpCmd) + mcpCmd.AddCommand(&cobra.Command{ + Use: "config", + Short: "Generate MCP configs for Crush + Claude Code", + RunE: runMCPConfig, + }) + mcpCmd.AddCommand(&cobra.Command{ + Use: "scan", + Short: "Scan available MCP servers", + RunE: runMCPScan, + }) +} + +func runMCPConfig(cmd *cobra.Command, args []string) error { + cfg, err := config.Load() + if err != nil { + return err + } + if err := mcp.ConfigureAll(cfg); err != nil { + return err + } + fmt.Println("MCP configs generated for Crush and Claude Code") + return nil +} + +func runMCPScan(cmd *cobra.Command, args []string) error { + servers := mcp.ScanServers() + fmt.Printf("%-25s %-15s %-10s\n", "Name", "Category", "Status") + fmt.Println("──────────────────────────────────────────") + for _, s := range servers { + status := "βœ— missing" + if s.Installed { + status = "βœ“ installed" + } + fmt.Printf("%-25s %-15s %-10s\n", s.Name, s.Category, status) + } + return nil +} \ No newline at end of file diff --git a/cmd/muyue/commands/root.go b/cmd/muyue/commands/root.go new file mode 100644 index 0000000..e17451d --- /dev/null +++ b/cmd/muyue/commands/root.go @@ -0,0 +1,66 @@ +package commands + +import ( + "fmt" + "os" + + "github.com/muyue/muyue/internal/config" + "github.com/muyue/muyue/internal/desktop" + "github.com/muyue/muyue/internal/profiler" + "github.com/spf13/cobra" +) + +var rootCmd = &cobra.Command{ + Use: "muyue", + Short: "Muyue is your AI-powered development companion", + Long: `Muyue - A modern development environment with AI assistance, tool management, and seamless desktop integration.`, + RunE: func(cmd *cobra.Command, args []string) error { + cfg := loadOrSetupConfig() + return desktop.Run(cfg, os.Args[1:]) + }, +} + +func Execute() error { + return rootCmd.Execute() +} + +func loadOrSetupConfig() *config.MuyueConfig { + if !config.Exists() { + fmt.Println("First time setup detected!") + cfg, err := profiler.RunFirstTimeSetup() + if err != nil { + fmt.Fprintf(os.Stderr, "Setup error: %v\n", err) + os.Exit(1) + } + + for i := range cfg.AI.Providers { + if cfg.AI.Providers[i].Active && cfg.AI.Providers[i].APIKey == "" { + key, err := profiler.AskAPIKey(cfg.AI.Providers[i].Name) + if err == nil && key != "" { + cfg.AI.Providers[i].APIKey = key + } + } + } + + if err := config.Save(cfg); err != nil { + fmt.Fprintf(os.Stderr, "Save error: %v\n", err) + os.Exit(1) + } + + fmt.Println("\nSetup complete! Starting muyue...") + return cfg + } + + cfg, err := config.Load() + if err != nil { + fmt.Fprintf(os.Stderr, "Config load error: %v\n", err) + os.Exit(1) + } + + return cfg +} + +func init() { + rootCmd.PersistentFlags().Int("port", 8080, "HTTP port for the desktop server") + rootCmd.PersistentFlags().Bool("no-open", false, "Don't open browser on startup") +} \ No newline at end of file diff --git a/cmd/muyue/commands/scan.go b/cmd/muyue/commands/scan.go new file mode 100644 index 0000000..4dd1f18 --- /dev/null +++ b/cmd/muyue/commands/scan.go @@ -0,0 +1,56 @@ +package commands + +import ( + "encoding/json" + "fmt" + + "github.com/muyue/muyue/internal/scanner" + "github.com/spf13/cobra" +) + +var scanCmd = &cobra.Command{ + Use: "scan", + Short: "Run system scan and print results table", + RunE: runScan, +} + +func init() { + rootCmd.AddCommand(scanCmd) + scanCmd.Flags().Bool("json", false, "Output results as JSON") +} + +func runScan(cmd *cobra.Command, args []string) error { + useJSON, _ := cmd.Flags().GetBool("json") + result := scanner.ScanSystem() + + if useJSON { + data, err := json.MarshalIndent(result, "", " ") + if err != nil { + return err + } + fmt.Println(string(data)) + return nil + } + + fmt.Printf("%-15s %-20s %-10s %-10s\n", "Tool", "Version", "Status", "Path") + fmt.Println("─────────────────────────────────────────────────") + for _, t := range result.Tools { + status := "βœ“ installed" + if !t.Installed { + status = "βœ— missing" + } + fmt.Printf("%-15s %-20s %-10s %-10s\n", t.Name, t.Version, status, t.Path) + } + fmt.Printf("\n% d/%d tools installed\n", len(result.Tools) - countMissing(result.Tools), len(result.Tools)) + return nil +} + +func countMissing(tools []scanner.ToolStatus) int { + missing := 0 + for _, t := range tools { + if !t.Installed { + missing++ + } + } + return missing +} \ No newline at end of file diff --git a/cmd/muyue/commands/setup.go b/cmd/muyue/commands/setup.go new file mode 100644 index 0000000..5eb068d --- /dev/null +++ b/cmd/muyue/commands/setup.go @@ -0,0 +1,39 @@ +package commands + +import ( + "fmt" + + "github.com/muyue/muyue/internal/config" + "github.com/muyue/muyue/internal/profiler" + "github.com/spf13/cobra" +) + +var setupCmd = &cobra.Command{ + Use: "setup", + Short: "Run first-run wizard (profiler)", + RunE: runSetup, +} + +func init() { + rootCmd.AddCommand(setupCmd) +} + +func runSetup(cmd *cobra.Command, args []string) error { + cfg, err := profiler.RunFirstTimeSetup() + if err != nil { + return err + } + for i := range cfg.AI.Providers { + if cfg.AI.Providers[i].Active && cfg.AI.Providers[i].APIKey == "" { + key, err := profiler.AskAPIKey(cfg.AI.Providers[i].Name) + if err == nil && key != "" { + cfg.AI.Providers[i].APIKey = key + } + } + } + if err := config.Save(cfg); err != nil { + return err + } + fmt.Println("Setup complete!") + return nil +} \ No newline at end of file diff --git a/cmd/muyue/commands/skills.go b/cmd/muyue/commands/skills.go new file mode 100644 index 0000000..9058eec --- /dev/null +++ b/cmd/muyue/commands/skills.go @@ -0,0 +1,105 @@ +package commands + +import ( + "fmt" + + "github.com/muyue/muyue/internal/skills" + "github.com/spf13/cobra" +) + +var skillsCmd = &cobra.Command{ + Use: "skills", + Short: "Skills management", +} + +func init() { + rootCmd.AddCommand(skillsCmd) + skillsCmd.AddCommand(&cobra.Command{ + Use: "list", + Short: "List installed skills", + RunE: runSkillsList, + }) + skillsCmd.AddCommand(&cobra.Command{ + Use: "init", + Short: "Install built-in skills", + RunE: runSkillsInit, + }) + skillsCmd.AddCommand(&cobra.Command{ + Use: "show [name]", + Short: "Show skill details", + Args: cobra.ExactArgs(1), + RunE: runSkillsShow, + }) + skillsCmd.AddCommand(&cobra.Command{ + Use: "generate [name] [description]", + Short: "AI-generate a skill", + Args: cobra.ExactArgs(2), + RunE: runSkillsGenerate, + }) + skillsCmd.AddCommand(&cobra.Command{ + Use: "deploy", + Short: "Deploy skills to Crush/Claude Code", + RunE: runSkillsDeploy, + }) + skillsCmd.AddCommand(&cobra.Command{ + Use: "delete [name]", + Short: "Delete a skill", + Args: cobra.ExactArgs(1), + RunE: runSkillsDelete, + }) +} + +func runSkillsList(cmd *cobra.Command, args []string) error { + list, err := skills.List() + if err != nil { + return err + } + if len(list) == 0 { + fmt.Println("No skills installed") + return nil + } + fmt.Printf("%-20s %-40s\n", "Name", "Description") + fmt.Println("─────────────────────────────────────────────────────") + for _, s := range list { + fmt.Printf("%-20s %-40s\n", s.Name, s.Description) + } + return nil +} + +func runSkillsInit(cmd *cobra.Command, args []string) error { + fmt.Println("Initializing built-in skills...") + return nil +} + +func runSkillsShow(cmd *cobra.Command, args []string) error { + name := args[0] + skill, err := skills.Get(name) + if err != nil { + return err + } + fmt.Printf("Name: %s\nDescription: %s\nAuthor: %s\nVersion: %s\n\n%s\n", + skill.Name, skill.Description, skill.Author, skill.Version, skill.Content) + return nil +} + +func runSkillsGenerate(cmd *cobra.Command, args []string) error { + fmt.Printf("Generating skill '%s': %s\n", args[0], args[1]) + return nil +} + +func runSkillsDeploy(cmd *cobra.Command, args []string) error { + if err := skills.DeployAll(); err != nil { + return err + } + fmt.Println("All skills deployed to Crush and Claude Code") + return nil +} + +func runSkillsDelete(cmd *cobra.Command, args []string) error { + name := args[0] + if err := skills.Delete(name); err != nil { + return err + } + fmt.Printf("Skill '%s' deleted\n", name) + return nil +} \ No newline at end of file diff --git a/cmd/muyue/commands/update.go b/cmd/muyue/commands/update.go new file mode 100644 index 0000000..11bb458 --- /dev/null +++ b/cmd/muyue/commands/update.go @@ -0,0 +1,80 @@ +package commands + +import ( + "fmt" + + "github.com/muyue/muyue/internal/scanner" + "github.com/muyue/muyue/internal/updater" + "github.com/spf13/cobra" +) + +var updateCmd = &cobra.Command{ + Use: "update [tool]", + Short: "Check and apply updates", + Args: cobra.RangeArgs(0, 1), + RunE: runUpdate, +} + +var checkOnly bool + +func init() { + rootCmd.AddCommand(updateCmd) + updateCmd.Flags().BoolVar(&checkOnly, "check", false, "Check only, don't update") +} + +func runUpdate(cmd *cobra.Command, args []string) error { + result := scanner.ScanSystem() + statuses := updater.CheckUpdates(result) + + if len(args) > 0 { + for _, u := range statuses { + if u.Tool == args[0] { + if u.NeedsUpdate { + fmt.Printf("%s: %s β†’ %s\n", u.Tool, u.Current, u.Latest) + if !checkOnly { + updater.RunAutoUpdate([]updater.UpdateStatus{u}) + fmt.Println("Updated!") + } + } else { + fmt.Printf("%s is up to date (%s)\n", u.Tool, u.Current) + } + return nil + } + } + fmt.Printf("Tool '%s' not found\n", args[0]) + return nil + } + + fmt.Printf("%-15s %-10s %-10s %-10s\n", "Tool", "Current", "Latest", "Status") + fmt.Println("─────────────────────────────────────────") + hasUpdates := false + for _, u := range statuses { + status := "βœ“" + if u.NeedsUpdate { + status = "⟳ update" + hasUpdates = true + } + if u.Error != "" { + status = "βœ— " + u.Error + } + fmt.Printf("%-15s %-10s %-10s %-10s\n", u.Tool, u.Current, u.Latest, status) + } + + if checkOnly { + return nil + } + + if hasUpdates { + toUpdate := make([]updater.UpdateStatus, 0) + for _, u := range statuses { + if u.NeedsUpdate { + toUpdate = append(toUpdate, u) + } + } + updater.RunAutoUpdate(toUpdate) + fmt.Println("\nUpdates applied.") + } else { + fmt.Println("\nAll tools are up to date.") + } + return nil +} \ No newline at end of file diff --git a/cmd/muyue/commands/version.go b/cmd/muyue/commands/version.go new file mode 100644 index 0000000..696b612 --- /dev/null +++ b/cmd/muyue/commands/version.go @@ -0,0 +1,23 @@ +package commands + +import ( + "fmt" + + "github.com/muyue/muyue/internal/version" + "github.com/spf13/cobra" +) + +var versionCmd = &cobra.Command{ + Use: "version", + Short: "Print version", + RunE: runVersion, +} + +func init() { + rootCmd.AddCommand(versionCmd) +} + +func runVersion(cmd *cobra.Command, args []string) error { + fmt.Printf("Muyue version %s\n", version.Version) + return nil +} \ No newline at end of file diff --git a/cmd/muyue/main.go b/cmd/muyue/main.go index d9bfe7f..e969121 100644 --- a/cmd/muyue/main.go +++ b/cmd/muyue/main.go @@ -4,51 +4,12 @@ import ( "fmt" "os" - "github.com/muyue/muyue/internal/config" - "github.com/muyue/muyue/internal/desktop" - "github.com/muyue/muyue/internal/profiler" + "github.com/muyue/muyue/cmd/muyue/commands" ) func main() { - cfg := loadOrSetupConfig() - if err := desktop.Run(cfg, os.Args[1:]); err != nil { + if err := commands.Execute(); err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) } -} - -func loadOrSetupConfig() *config.MuyueConfig { - if !config.Exists() { - fmt.Println("First time setup detected!") - cfg, err := profiler.RunFirstTimeSetup() - if err != nil { - fmt.Fprintf(os.Stderr, "Setup error: %v\n", err) - os.Exit(1) - } - - for i := range cfg.AI.Providers { - if cfg.AI.Providers[i].Active && cfg.AI.Providers[i].APIKey == "" { - key, err := profiler.AskAPIKey(cfg.AI.Providers[i].Name) - if err == nil && key != "" { - cfg.AI.Providers[i].APIKey = key - } - } - } - - if err := config.Save(cfg); err != nil { - fmt.Fprintf(os.Stderr, "Save error: %v\n", err) - os.Exit(1) - } - - fmt.Println("\nSetup complete! Starting muyue...") - return cfg - } - - cfg, err := config.Load() - if err != nil { - fmt.Fprintf(os.Stderr, "Config load error: %v\n", err) - os.Exit(1) - } - - return cfg -} +} \ No newline at end of file diff --git a/docs/PRD.md b/docs/PRD.md new file mode 100644 index 0000000..9205e47 --- /dev/null +++ b/docs/PRD.md @@ -0,0 +1,914 @@ +# Muyue PRD v1.0 + +> **Author**: Product Architect +> **Date**: 2026-04-22 +> **Status**: Definitive + +--- + +## 1. Product Vision & Positioning + +### What is Muyue? + +Muyue is a local-first, single-binary development environment assistant that combines an AI orchestration layer, a tool manager, and a cyberpunk-themed desktop UI into one cohesive experience. It scans your system, installs missing tools, configures AI agent environments (MCP servers, LSPs, skills), and provides a Studio for AI-assisted workflows β€” all without requiring cloud infrastructure. + +### What problem does it solve? + +Developers spend significant time setting up and maintaining their dev environments: installing tools, configuring MCP servers for AI agents, managing API keys, and switching between CLI tools. Muyue eliminates this friction by providing a single interface that unifies environment management, AI orchestration, and terminal access. It is the "home base" for developers who use AI coding agents (Crush, Claude Code) daily. + +### Who is it for? + +- **Primary**: Solo developers and small teams who use AI coding agents (Crush, Claude Code) and want a unified control panel. +- **Secondary**: Developers setting up new machines who want a "one-click" environment bootstrap. +- **Not for**: Enterprise teams needing sandboxed environments (Daytona), container orchestration (DevPod), or MCP server registries (MCPM). + +### How is Muyue different? + +| Competitor | What they do | What Muyue does differently | +|---|---|---| +| **Daytona** | Cloud sandbox infrastructure for AI agents (sandboxes, snapshots, multi-tenant) | Muyue is local-first, lightweight, no infra required. Daytona is "cloud VMs for AI"; Muyue is "desktop control panel for your local AI agents". | +| **kasetto** | Declarative AI agent environment manager (Rust, CLI-only) | Muyue adds a desktop GUI, interactive workflows, and a terminal. kasetto is "Nix for AI tools"; Muyue is "a cockpit". | +| **OpenCode** | Terminal-based AI coding agent (Go, TUI, LSP+MCP client) | OpenCode is an AI coding agent itself. Muyue orchestrates agents (Crush, Claude) rather than being one. Muyue provides a desktop UI, tool management, and MCP config generation that OpenCode doesn't. | +| **DevPod** | Dev environment manager using devcontainers (Go, CLI+Desktop) | DevPod manages remote/container environments. Muyue manages your local machine's tools and AI agent configs. No container overlap. | +| **MCPM** | MCP server package manager (Python, CLI, registry) | Muyue generates MCP configs for Crush + Claude Code directly. Delegates server discovery to MCPM where needed. | +| **McpMux** | Desktop MCP gateway/router (Rust) | Muyue manages MCP configs per-tool rather than routing through a gateway. Simpler, no encryption layer needed for local use. | + +### What Muyue should NOT do (anti-scope) + +1. **Not a coding agent** β€” Muyue orchestrates agents (Crush, Claude Code); it does not edit files, run tests, or write code autonomously. The `crush_run` tool delegates to Crush. +2. **Not a sandbox/container manager** β€” No Docker orchestration, no VM provisioning. Use DevPod or Daytona for that. +3. **Not an MCP registry** β€” No server discovery marketplace. Delegate to MCPM for that. +4. **Not a CI/CD tool** β€” No build pipelines, no deployment workflows. +5. **Not a multi-tenant platform** β€” Single-user, local machine only. No org management, no billing. +6. **Not an IDE** β€” No file tree editor, no debugging, no syntax highlighting. Use VS Code, Zed, or Neovim. +7. **Not an LSP client** β€” Muyue installs and manages LSP servers; it does not connect to them as a client. The IDE handles that. +8. **Not a proxy/gateway** β€” No AI proxy agents, no request routing. The orchestrator talks directly to providers. + +--- + +## 2. Feature Matrix + +### P0 β€” Must Have for Launch + +| # | Feature | Priority | Status | Decision | Description | +|---|---------|----------|--------|----------|-------------| +| 1 | System scanning (tools, runtimes, editors, shell, git) | P0 | **EXISTS** | KEEP | Scanner checks 14 tools, 8 runtimes, 8 editors, shell setup, git config. Has 5-min cache, JSON output. | +| 2 | Tool installation (crush, claude, bmad, starship, go, node, python, git, pnpm, uv, docker, gh) | P0 | **EXISTS** | KEEP | Installer handles 12 tools with platform-specific install methods (apt/brew/winget). API endpoint wired. | +| 3 | CLI subcommands (scan, install, update, setup, config, doctor, version, lsp, mcp, skills) | P0 | **EXISTS** | KEEP | Cobra-based CLI with all documented subcommands. Each has appropriate flags and output. | +| 4 | Desktop mode (HTTP server + embedded SPA) | P0 | **EXISTS** | KEEP | `desktop.go` serves frontend via `go:embed`, auto-opens browser, handles `--port` and `--no-open`. | +| 5 | AI orchestration (OpenAI-compatible, multi-provider) | P0 | **EXISTS** | KEEP | Orchestrator supports MiniMax, ZAI, Anthropic, OpenAI, Ollama. History management, tool calling loop. | +| 6 | Agent tools (10 tools: terminal, crush_run, read_file, list_files, search_files, grep_content, get_config, set_provider, manage_ssh, web_fetch) | P0 | **EXISTS** | KEEP | All 10 tools implemented with proper parameter validation, timeouts, and output truncation. | +| 7 | Tool execution endpoint | P0 | **EXISTS** | KEEP | `/api/tool/call` dispatches to agent registry for any registered tool. `/api/tools/list` returns all tools. | +| 8 | MCP server management (scan, configure, generate configs) | P0 | **EXISTS** | KEEP | Scans 12 known MCP servers, generates configs for Crush (`crush.json`) and Claude Code (`.claude.json`). | +| 9 | LSP server management (scan, install) | P0 | **EXISTS** | KEEP | 16 known LSP servers with install commands. `InstallForLanguages()` for batch installs. | +| 10 | Skills management (CRUD, deploy, built-in skills) | P0 | **EXISTS** | KEEP | 5 built-in skills (env-setup, git-workflow, api-design, debug-assist, code-review). YAML frontmatter format. Deploy to Crush + Claude Code. | +| 11 | Conversation persistence (JSON file store) | P0 | **EXISTS** | KEEP | `ConversationStore` with JSON persistence, auto-summarization at 80K tokens. | +| 12 | API key encryption (AES-256-GCM) | P0 | **EXISTS** | KEEP | `internal/secret/` with encrypt/decrypt. Keys encrypted at rest in config.yaml. | +| 13 | Config management (YAML, XDG paths, defaults) | P0 | **EXISTS** | KEEP | Full config schema with profile, AI providers, terminal, tools, SSH. Legacy migration from `~/.muyue`. | +| 14 | Studio tab (AI chat, SSE streaming, tool calls) | P0 | **EXISTS** | KEEP | Full chat UI with SSE streaming, tool call visualization, thinking blocks, markdown rendering. | +| 15 | Shell tab (PTY terminal, tabs, SSH connections) | P0 | **EXISTS** | KEEP | xterm.js with WebSocket PTY, tab management (max 7), SSH connection support, 6 terminal themes. | +| 16 | Config tab (profile, providers, theme, language, skills) | P0 | **EXISTS** | KEEP | Two-column layout with profile editing, provider management, key validation, terminal settings. | +| 17 | First-run profiling wizard (TUI) | P0 | **EXISTS** | KEEP | Charmbracelet/huh TUI wizard: name, pseudo, email, languages, editor, AI provider. Scored suggestions. | +| 18 | Onboarding wizard (web) | P0 | **EXISTS** | KEEP | React-based web wizard for desktop mode. | +| 19 | i18n (FR/EN, keyboard layout awareness) | P0 | **EXISTS** | KEEP | Full FR/EN translations, AZERTY/QWERTY/QWERTZ layouts affecting shortcut display. | +| 20 | Theming (4 cyberpunk themes, CSS custom properties) | P0 | **EXISTS** | KEEP | 4 themes (Red, Pink, Blue, Green) with 30+ CSS variables. Runtime injection. | +| 21 | Workflow engine (Planβ†’Execute) | P0 | **EXISTS** | KEEP | State machine with steps (tool_call, condition, approval). JSON persistence. SSE streaming execution. | + +### P0 β€” Needs Implementation/Completion + +| # | Feature | Priority | Status | Decision | Description | +|---|---------|----------|--------|----------|-------------| +| 22 | Dashboard tab (tools grid, notifications, quick actions) | P0 | **PARTIAL** | KEEP, BUILD | Currently shows empty workflow/activity placeholders. Needs: tools grid with status badges, update notifications, quick actions (install missing, check updates, rescan, configure MCP). | +| 23 | Shell AI panel (real AI backend) | P0 | **EXISTS** | KEEP | Was fake, now uses `/api/shell/chat` with real AI backend + tool calling. Functional. | +| 24 | Tool updates (check + auto-update) | P0 | **EXISTS** | KEEP | `internal/updater/` checks versions and runs auto-updates. API + CLI endpoints wired. | + +### P1 β€” Post-Launch + +| # | Feature | Priority | Status | Decision | Description | +|---|---------|----------|--------|----------|-------------| +| 25 | AI-generated skills (via Studio chat) | P1 | **STUBBED** | KEEP | `BuildAIGeneratePrompt()` exists but CLI `skills generate` is a stub. Need to wire to orchestrator. | +| 26 | SSH test connectivity | P1 | **STUBBED** | KEEP | `handleSSHTest()` returns "not implemented". Add `net.DialTimeout` check. | +| 27 | Conversation list/switch (multiple conversations) | P1 | **PARTIAL** | KEEP | `/api/conversations` list + delete exist. No create/switch/load. Need multi-conversation support in Studio. | +| 28 | Dashboard activity log with real events | P1 | **MISSING** | KEEP | Wire install/scan/update events to a notification system that Dashboard renders. | +| 29 | Starship prompt integration (multi-theme) | P1 | **EXISTS** | KEEP | 3 theme configs (charm, zerotwo, default). `handleApplyStarshipTheme` writes TOML + patches RC files. | +| 30 | Terminal settings persistence (font, theme) | P1 | **EXISTS** | KEEP | Settings saved to config, loaded on startup. | + +### P2 β€” Nice-to-Have + +| # | Feature | Priority | Status | Decision | Description | +|---|---------|----------|--------|----------|-------------| +| 31 | Background daemon (`internal/daemon/`) | P2 | **MISSING** | DEFER | README mentions it. Not needed for launch. Tools can run on-demand. | +| 32 | HTML preview server (`internal/preview/`) | P2 | **MISSING** | DROP | Use browser or IDE instead. Not Muyue's job. | +| 33 | AI proxy agents (`internal/proxy/`) | P2 | **MISSING** | DROP | Direct provider communication is sufficient. No proxy layer needed. | + +### DROPPED + +| # | Feature | Reason | Replacement | +|---|---------|--------|-------------| +| 34 | HTML preview server | Not core value. IDEs handle this. | Browser / VS Code Live Preview | +| 35 | AI proxy agents | Adds complexity without benefit for local-first tool. | Direct provider API calls | +| 36 | MCP server registry / marketplace | Out of scope. | MCPM (`mcpm install `) | +| 37 | Sandboxed code execution | Out of scope. Requires infra. | Daytona sandboxes | +| 38 | Dev container management | Out of scope. | DevPod | +| 39 | Full IDE features (file tree, debugger) | Out of scope. | VS Code / Zed / Neovim | +| 40 | LSP client mode (connecting to LSPs) | Out of scope. Muyue installs LSPs, doesn't consume them. | IDE handles LSP client | + +--- + +## 3. User Flows + +### 3.1 First-Time User Opens `muyue` + +``` +1. User runs `muyue` (or downloaded binary) +2. No config exists β†’ loadOrSetupConfig() detects first run +3. Profiler TUI wizard launches: + a. Asks: name, pseudo, email + b. Scans system β†’ detects languages β†’ shows scored language options + c. Detects editors β†’ shows scored editor options + d. Shows AI provider options β†’ user picks one +4. If selected provider has no API key β†’ asks for key (masked input) +5. Config saved to ~/.config/muyue/config.yaml (API key encrypted) +6. Built-in skills installed to ~/.muyue/skills/ +7. MCP configs generated for Crush + Claude Code +8. Desktop server starts on port 8080 +9. Browser opens to http://127.0.0.1:8080 +10. Onboarding wizard checks if profile is empty β†’ shows web wizard as fallback +11. Dashboard tab loads β†’ shows tools grid (some installed, some missing) +``` + +**Edge cases:** +- Config file exists but is corrupted β†’ show error, offer `muyue setup` to recreate +- No internet β†’ profiler still works (local scan only), AI features unavailable +- API key invalid β†’ doctor command detects, Config tab shows "Invalid key" badge + +### 3.2 Returning User Opens `muyue` + +``` +1. User runs `muyue` +2. Config exists β†’ loads from ~/.config/muyue/config.yaml +3. Desktop server starts β†’ browser opens (or reconnects) +4. Previous conversation loaded from conversation.json +5. Dashboard shows current tool status (cached, 5-min TTL) +6. If checkOnStart=true β†’ background update check runs +7. User picks up where they left off +``` + +### 3.3 User Installs a Missing Tool from Dashboard + +``` +1. Dashboard shows tools grid with status badges: + - Green βœ“ = installed + - Red βœ— = missing + - Yellow ⟳ = update available +2. User clicks "Install" on a missing tool (e.g., "pnpm") +3. Frontend calls POST /api/install {"tools": ["pnpm"]} +4. Backend spawns installer.InstallTool("pnpm") in goroutine +5. Installer checks if already installed β†’ if yes, returns success +6. Installer runs `npm install -g pnpm` +7. Result returned: {"status": "done", "tools": ["pnpm"], "results": [{...}]} +8. Frontend updates tool status badge to green βœ“ +9. Activity log entry added: "Installed pnpm" +10. System scan cache invalidated +``` + +**Edge cases:** +- Install fails (permission denied) β†’ show error in results, suggest `sudo` or manual install +- Tool requires Node.js but Node isn't installed β†’ installer returns "npx not found, install node first" +- Multiple tools installed in parallel β†’ `sync.WaitGroup` handles concurrent installs + +### 3.4 User Starts a Chat in Studio + +``` +1. User clicks Studio tab (Ctrl+2) +2. Chat history loaded from /api/chat/history +3. If no history β†’ welcome message shown +4. User types message in textarea, presses Enter +5. Frontend calls POST /api/chat {"message": "...", "stream": true} +6. SSE connection opens +7. Backend: + a. Adds message to conversation store + b. Checks if summarization needed (>80K tokens) + c. Creates orchestrator with active provider + d. Sets system prompt (Studio prompt with agent context) + e. Sets tools (all 10 agent tools as OpenAI function specs) + f. Sends to AI provider API +8. Streaming begins: + a. Content chunks β†’ SSE {"content": "char"} events + b. Tool calls β†’ SSE {"tool_call": {...}} events + c. Tool results β†’ SSE {"tool_result": {...}} events + d. Max 15 tool iterations +9. Frontend renders: + a. Text content streamed character-by-character + b. Tool calls shown as expandable blocks with icon + status + c. Thinking blocks (if any) shown with spinner +10. Final response stored in conversation +11. SSE {"done": "true"} closes stream +``` + +**Edge cases:** +- AI provider returns error β†’ SSE error event, shown as red message +- Tool execution times out β†’ error result returned to AI, may retry +- No active provider configured β†’ 503 error, redirect to Config tab +- API key invalid β†’ 401 error, show "Configure your API key" prompt + +### 3.5 User Runs a Planβ†’Execute Workflow + +``` +1. User types `/plan Set up a Go project with Docker` in Studio +2. Frontend calls POST /api/workflow/plan {"goal": "Set up a Go project..."} +3. Backend: + a. Creates Planner with AI orchestrator + b. Sends goal to AI with planning prompt + c. AI generates JSON array of steps + d. Planner parses response into []Step + e. Workflow Engine creates workflow with steps +4. Workflow returned to frontend with ID and steps +5. Frontend shows workflow panel: + - Step 1: "Check Go installation" β†’ tool: terminal, args: {command: "go version"} + - Step 2: "Create project directory" β†’ tool: terminal, args: {command: "mkdir -p ..."} + - Step 3: "Initialize Go module" β†’ tool: terminal, args: {command: "go mod init ..."} + - etc. +6. User clicks "Execute" +7. Frontend calls POST /api/workflow/execute/{id}?stream=true +8. SSE stream: + a. Each step: {"event": "started", "step": {...}} + b. On completion: {"event": "done", "step": {...}} + c. On failure: {"event": "failed", "step": {...}} + d. If approval step: {"event": "awaiting_approval", "step": {...}} +9. User can approve/skip steps via POST /api/workflow/approve/{id} +10. Final event: {"event": "workflow_done", "status": "done|failed"} +``` + +**Edge cases:** +- AI generates invalid JSON β†’ planner returns error, shown in chat +- Step fails mid-workflow β†’ remaining steps skipped, workflow marked "failed" +- Approval step β†’ execution pauses until user approves +- Workflow exceeds 10 steps β†’ planner prompt limits to 10 + +### 3.6 User Opens Shell, Connects via SSH + +``` +1. User clicks Shell tab (Ctrl+3) +2. Default "Local Shell" tab created with xterm.js terminal +3. WebSocket connects to /api/ws/terminal with {type: "shell", data: ""} +4. Backend creates PTY via creack/pty, pipes I/O through WebSocket +5. User sees their shell prompt (starship if configured) +6. To add SSH tab: + a. User clicks "+" β†’ dropdown shows: + - System terminals (zsh, bash, fish) + - Saved SSH connections (from config) + - "Add SSH connection" button + b. User selects saved connection or adds new one + c. New tab created with {type: "ssh", data: JSON.stringify({host, port, user, key_path})} + d. Backend establishes SSH connection, creates PTY + e. Tab shows connected indicator (green dot) +7. User can rename tabs (double-click), close tabs (Γ—), switch with Alt+1-7 +8. AI assistant panel on right: + a. User types question + b. Frontend calls POST /api/shell/chat with message + terminal context + c. AI responds with shell-aware answers (commands, explanations) + d. Can execute tools (terminal, read_file, etc.) to help user +``` + +**Edge cases:** +- SSH connection fails β†’ tab shows "Connection error" in terminal +- WebSocket disconnects β†’ terminal shows "Connection closed" message +- Tab limit (7) reached β†’ "+" button disabled +- SSH key not found β†’ connection fails, suggest key path + +### 3.7 User Changes AI Provider + +``` +1. User clicks Config tab (Ctrl+4) +2. "AI Providers" panel shows list of providers: + - MiniMax (active) β€” Key configured βœ“ + - Z.AI β€” No key + - Anthropic β€” No key + - OpenAI β€” No key + - Ollama β€” No key (local) +3. User clicks "Configure" on Anthropic +4. Modal opens with fields: API Key, Model, Base URL +5. User enters API key +6. User clicks "Validate" β†’ POST /api/providers/validate +7. Backend sends test request to Anthropic API with key +8. Response: {"status": "valid"} or error +9. User clicks "Activate" β†’ provider set active, others deactivated +10. Config saved β†’ new orchestrator instances use Anthropic +``` + +**Edge cases:** +- Key validation fails β†’ show "Invalid key" badge, don't save +- No internet β†’ validation times out, show "Connection failed" +- Ollama selected but not running β†’ user sees local URL, no validation needed +- Switching provider mid-conversation β†’ new messages use new provider, old messages preserved + +### 3.8 User Manages MCP Servers + +``` +1. User opens Config tab β†’ MCP section + OR: User runs `muyue mcp scan` from CLI +2. System scans 12 known MCP servers (filesystem, github, git, fetch, memory, etc.) +3. Each server shows: name, category, installed status (npx available) +4. User clicks "Configure MCP" β†’ POST /api/mcp/configure +5. Backend: + a. Generates MCP config for Crush: ~/.config/crush/crush.json β†’ {"mcps": {...}} + b. Generates MCP config for Claude Code: ~/.claude.json β†’ {"mcpServers": {...}} + c. Core servers: filesystem, fetch, memory + d. Provider-specific: minimax-web-search, minimax-image (if API key set) + e. Claude-specific: sequential-thinking +6. Configs written with 0600 permissions +``` + +**Edge cases:** +- Existing configs not overwritten (merged) β€” `writeMCPConfig` merges into existing JSON +- No API key for provider-specific servers β†’ those servers omitted +- Crush or Claude Code not installed β†’ configs still generated (for when they are installed) + +### 3.9 User Manages LSP Servers + +``` +1. User runs `muyue lsp scan` from CLI + OR: views LSP section in Config tab +2. System checks 16 known LSP servers +3. Each shows: name, language, command path, installed status +4. User installs specific LSP: + - CLI: `muyue lsp install gopls` + - API: POST /api/lsp/install {"name": "gopls"} +5. Backend runs install command (e.g., `go install golang.org/x/tools/gopls@latest`) +6. Result: success or error +``` + +**Edge cases:** +- LSP has no auto-install command (e.g., clangd) β†’ return "install manually" message +- Install fails (network error) β†’ show error, suggest retry +- Language mapping: TypeScript installs 4 servers (TS, JSON, HTML, CSS) + +### 3.10 User Creates/Deploys a Skill + +``` +1. User runs `muyue skills init` β†’ installs 5 built-in skills to ~/.muyue/skills/ +2. User creates custom skill: + - Manually: create ~/.muyue/skills/my-skill/SKILL.md with YAML frontmatter + - CLI: `muyue skills generate my-skill "Does X for Y" crush` + - API: POST /api/skills (via Config tab or Studio chat) +3. SKILL.md format: + ```yaml + --- + name: my-skill + description: What it does + author: username + version: 1.0.0 + target: crush|claude|both + tags: [tag1, tag2] + --- + # Skill instructions in markdown + ``` +4. Deploy: `muyue skills deploy` +5. Skill copied to: + - Crush: ~/.config/crush/skills/my-skill/SKILL.md + - Claude Code: ~/.claude/skills/my-skill/SKILL.md +``` + +**Edge cases:** +- Skill already exists at target β†’ overwritten +- Target is "both" β†’ deployed to both Crush and Claude +- Delete removes from all locations (source + targets) + +### 3.11 User Runs `muyue scan` from CLI + +``` +1. User runs `muyue scan` +2. Scanner runs full system scan (tools, runtimes, shell, git) +3. Output: formatted table with columns: Tool, Version, Status, Path +4. Summary line: "Installed: 8/14" +5. With --json flag: full JSON output +``` + +### 3.12 User Runs `muyue doctor` from CLI + +``` +1. User runs `muyue doctor` +2. Three checks run: + a. System scan β†’ shows installed/missing tools + b. Config check β†’ loads config, validates profile + c. Connectivity check β†’ HEAD requests to AI provider endpoints +3. Output: diagnostic report with βœ“/βœ— indicators +4. User sees what's broken and can take action +``` + +--- + +## 4. API Contract + +### Existing Endpoints (37 routes) + +| Method | Path | Request Body | Response Body | Status | +|--------|------|-------------|---------------|--------| +| GET | `/api/info` | β€” | `{name, version, author}` | EXISTS | +| GET | `/api/system` | β€” | `{system: {os, arch, platform, shell, ...}}` | EXISTS | +| GET | `/api/tools` | β€” | `{tools: [{name, installed, version, path}], total}` | EXISTS | +| GET | `/api/config` | β€” | `{profile, terminal, bmad}` | EXISTS | +| GET | `/api/providers` | β€” | `{providers: [{name, model, active, ...}]}` | EXISTS | +| GET | `/api/skills` | β€” | `{skills: [...], count}` | EXISTS | +| GET | `/api/lsp` | β€” | `{servers: [{name, language, command, installed}]}` | EXISTS | +| GET | `/api/mcp` | β€” | `{servers: [...], configured}` | EXISTS | +| GET | `/api/updates` | β€” | `{updates: [{tool, current, latest, needsUpdate}]}` | EXISTS | +| GET | `/api/editors` | β€” | `{editors: [{name, installed, version, path}]}` | EXISTS | +| GET | `/api/terminal/sessions` | β€” | `{ssh: [...], system: [...]}` | EXISTS | +| GET | `/api/terminal/themes` | β€” | `{themes: [{id, name}]}` | EXISTS | +| GET | `/api/chat/history` | β€” | `{messages: [...], tokens}` | EXISTS | +| GET | `/api/tools/list` | β€” | `{tools: [...], count}` | EXISTS | +| GET | `/api/workflow/list` | β€” | `{workflows: [...], count}` | EXISTS | +| GET | `/api/workflow/{id}` | β€” | `{id, name, steps, status, ...}` | EXISTS | +| GET | `/api/conversations` | β€” | `{conversations: [...]}` | EXISTS | +| GET | `/api/ssh/connections` | β€” | `{connections: [...]}` | EXISTS | +| POST | `/api/scan` | β€” | `{status: "ok"}` | EXISTS | +| POST | `/api/install` | `{tools: [string]}` | `{status, tools, results: [{tool, success, message}]}` | EXISTS | +| POST | `/api/mcp/configure` | β€” | `{status: "ok"}` | EXISTS | +| POST | `/api/terminal` | `{command, cwd}` | `{output, error}` | EXISTS | +| POST | `/api/chat` | `{message, stream}` | SSE stream or `{content}` | EXISTS | +| POST | `/api/chat/clear` | β€” | `{status: "ok"}` | EXISTS | +| POST | `/api/tool/call` | `{tool, args}` | `{success, tool, result, error}` | EXISTS | +| POST | `/api/shell/chat` | `{message, context, history, cwd, platform, stream}` | SSE stream or `{content, tool_calls}` | EXISTS | +| POST | `/api/workflow` | `{name, description, type}` | `{id, name, steps, status, ...}` | EXISTS | +| POST | `/api/workflow/plan` | `{goal}` | `{id, name, steps, status, ...}` | EXISTS | +| POST | `/api/workflow/execute/{id}` | `?stream=true` optional | SSE stream or workflow object | EXISTS | +| POST | `/api/workflow/approve/{id}` | `{step_id}` | `{status: "approved"}` | EXISTS | +| POST | `/api/lsp/install` | `{name}` | `{success, server}` or `{success, error}` | EXISTS | +| POST | `/api/skills/deploy` | `{name}` optional | `{status, skill}` | EXISTS | +| POST | `/api/config/reset` | β€” | `{status: "ok"}` | EXISTS | +| POST | `/api/providers/validate` | `{name, api_key, model, base_url}` | `{status: "valid"}` or error | EXISTS | +| POST | `/api/update/run` | `{tool}` optional | `{status, updated}` or `{status, tool}` | EXISTS | +| POST | `/api/ssh/test` | `{host, port, user}` | `{success, message}` (stubbed) | PARTIAL | +| POST | `/api/starship/apply-theme` | `{theme}` | `{status, config}` | EXISTS | +| PUT | `/api/preferences` | `{language, keyboard_layout}` | `{status: "ok"}` | EXISTS | +| PUT | `/api/config/profile` | `{name, pseudo, email, editor, shell}` | `{status: "ok"}` | EXISTS | +| PUT | `/api/config/provider` | `{name, api_key, model, base_url, active}` | `{status: "ok"}` | EXISTS | +| PUT | `/api/terminal/settings` | `{font_size, font_family, theme}` | `{status, theme}` | EXISTS | +| DELETE | `/api/conversations/{id}` | β€” | `{status: "deleted"}` | EXISTS | +| DELETE | `/api/terminal/sessions/{name}` | β€” | (removes SSH connection) | EXISTS | +| WS | `/api/ws/terminal` | `{type, data}` | `{type, data}` | EXISTS | + +### Error Response Format (all endpoints) + +```json +{"error": "Human-readable error message"} +``` + +HTTP status codes: 400 (bad request), 401 (unauthorized), 404 (not found), 405 (method not allowed), 500 (internal), 503 (service unavailable β€” AI provider not configured). + +### SSE Event Format + +``` +data: {"content": "character"} +data: {"tool_call": {"tool_call_id": "...", "name": "...", "args": "..."}} +data: {"tool_result": {"tool_call_id": "...", "content": "...", "is_error": false}} +data: {"done": "true"} +``` + +--- + +## 5. CLI Contract + +### Root Command + +``` +muyue Launch desktop app (opens browser) +muyue --port=8080 Launch on specific port +muyue --no-open Launch without opening browser +``` + +### Subcommands + +| Command | Flags | Output | Status | +|---------|-------|--------|--------| +| `muyue scan` | `--json` | Table or JSON of tools/runtimes | EXISTS | +| `muyue install [tool]` | `--yes` | Install progress per tool | EXISTS | +| `muyue update [tool]` | `--check` | Table of versions + status | EXISTS | +| `muyue setup` | β€” | Interactive TUI wizard | EXISTS | +| `muyue config` | β€” | (subcommand stub) | PARTIAL | +| `muyue doctor` | β€” | Diagnostic report | EXISTS | +| `muyue version` | β€” | `Muyue version X.Y.Z` | EXISTS | +| `muyue lsp scan` | β€” | Table of LSP servers | EXISTS | +| `muyue lsp install ` | β€” | Install progress | EXISTS | +| `muyue mcp config` | β€” | Confirmation message | EXISTS | +| `muyue mcp scan` | β€” | Table of MCP servers | EXISTS | +| `muyue skills list` | β€” | Table of skills | EXISTS | +| `muyue skills init` | β€” | Confirmation | STUBBED | +| `muyue skills show ` | β€” | Skill details | EXISTS | +| `muyue skills generate ` | β€” | (stub) | STUBBED | +| `muyue skills deploy` | β€” | Confirmation | EXISTS | +| `muyue skills delete ` | β€” | Confirmation | EXISTS | + +### CLI Commands Needing Work + +| Command | Issue | Fix | +|---------|-------|-----| +| `muyue config` | No subcommands (get/set are defined but not registered) | Register `config get ` and `config set ` as subcommands | +| `muyue skills init` | Just prints message, doesn't call `skills.InstallBuiltinSkills()` | Wire to actual function | +| `muyue skills generate` | Just prints message, doesn't call AI | Wire to orchestrator | +| `muyue install` | Passes `nil` config to installer | Pass loaded config | + +--- + +## 6. Data Model + +### 6.1 Config YAML Schema (`~/.config/muyue/config.yaml`) + +```yaml +version: "0.2.1" + +profile: + name: "Augustin" + pseudo: "muyue" + email: "augustin@example.com" + languages: ["go", "typescript", "python"] + preferences: + editor: "nvim" + shell: "zsh" + theme: "cyberpunk-red" # cyberpunk-red | cyberpunk-pink | midnight-blue | matrix-green + default_ai: "minimax" + auto_update: true + check_on_start: true + language: "fr" # fr | en + keyboard_layout: "azerty" # azerty | qwerty | qwertz + +ai: + providers: + - name: "minimax" + api_key: "enc:AES256GCM..." # encrypted at rest + base_url: "https://api.minimax.io/v1" + model: "MiniMax-M2.7" + active: true + - name: "zai" + model: "glm" + active: false + - name: "anthropic" + api_key: "enc:AES256GCM..." + model: "claude-sonnet-4-20250514" + active: false + - name: "openai" + api_key: "enc:AES256GCM..." + base_url: "https://api.openai.com/v1" + model: "gpt-4o" + active: false + - name: "ollama" + model: "llama3" + base_url: "http://localhost:11434/api" + active: false + +tools: + - name: "crush" + installed: true + version: "v1.2.3" + auto_update: true + +bmad: + installed: true + version: "latest" + global: true + +terminal: + custom_prompt: true + prompt_theme: "zerotwo" # charm | zerotwo | default + ssh: + - name: "prod-server" + host: "192.168.1.100" + port: 22 + user: "deploy" + key_path: "~/.ssh/id_rsa" + font_size: 14 + font_family: "'JetBrains Mono', monospace" + theme: "default" # default | monokai | gruvbox | nord | solarized-dark | dracula +``` + +### 6.2 Conversation JSON Schema (`~/.config/muyue/conversation.json`) + +```json +{ + "messages": [ + { + "id": "20260422150000.000-1234567890", + "role": "user|assistant|system", + "content": "message text or JSON-encoded {content, tool_calls}", + "time": "2026-04-22T15:00:00Z" + } + ], + "summary": "Auto-generated conversation summary when >80K tokens", + "created_at": "2026-04-22T15:00:00Z", + "updated_at": "2026-04-22T15:30:00Z" +} +``` + +### 6.3 Skill SKILL.md Format + +```markdown +--- +name: skill-name +description: What this skill does +author: muyue +version: 1.0.0 +target: both # crush | claude | both +tags: [tag1, tag2] +--- + +# Skill Title + +Instructions for the AI agent in markdown. +Includes: when to activate, step-by-step instructions, examples, error handling. +``` + +### 6.4 MCP Config JSON Format + +**For Crush** (`~/.config/crush/crush.json`): +```json +{ + "mcps": { + "filesystem": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-filesystem", "/home/user/projects"] + }, + "fetch": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-fetch"] + } + } +} +``` + +**For Claude Code** (`~/.claude.json`): +```json +{ + "mcpServers": { + "filesystem": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-filesystem", "/home/user/projects"] + }, + "sequential-thinking": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-sequential-thinking"] + } + } +} +``` + +### 6.5 Workflow JSON Schema (`~/.config/muyue/workflows.json`) + +```json +[ + { + "id": "wf-1234567890", + "name": "Plan: Set up Go project", + "description": "Full goal description", + "type": "plan_execute", + "steps": [ + { + "id": "step-0", + "name": "Check Go installation", + "type": "tool_call", + "tool": "terminal", + "args": {"command": "go version"}, + "status": "pending|running|done|failed|awaiting_approval|skipped", + "result": "", + "error": "", + "depends_on": [], + "started_at": null, + "ended_at": null + } + ], + "status": "pending|running|done|failed", + "created_at": "2026-04-22T15:00:00Z", + "updated_at": "2026-04-22T15:00:00Z" + } +] +``` + +--- + +## 7. Technical Decisions + +### 7.1 CLI Framework: **Keep Cobra** βœ“ + +**Decision**: Keep `spf13/cobra` (already in `go.mod`, already used for all 11 subcommands). + +**Rationale**: Cobra is the de-facto standard for Go CLIs. All commands are already implemented. No benefit to switching to `urfave/cli`. + +### 7.2 HTTP Router: **Keep stdlib `http.ServeMux`** βœ“ + +**Decision**: Keep `net/http.ServeMux`. Do NOT add chi, echo, or gin. + +**Rationale**: +- 37 routes registered. Stdlib handles this fine. +- Go 1.22+ `ServeMux` supports method-based routing (`GET /api/foo`). +- Adding a framework adds a dependency and learning curve for no benefit. +- Performance is irrelevant at localhost scale. + +**One improvement**: Use Go 1.22 method-based patterns to clean up manual method checks: +```go +mux.HandleFunc("GET /api/tools", s.handleTools) +mux.HandleFunc("POST /api/install", s.handleInstall) +``` + +### 7.3 WebSocket: **Keep gorilla/websocket** βœ“ + +**Decision**: Keep `gorilla/websocket` for terminal PTY. + +**Rationale**: Already working for terminal WebSocket. Only used for one endpoint (`/api/ws/terminal`). No need for a framework. + +### 7.4 Frontend Framework: **Keep vanilla React** βœ“ + +**Decision**: Keep React 19 + vanilla state management. Do NOT add zustand or react-query. + +**Rationale**: +- 4 components, ~1200 lines total. State is simple (tab switching, form inputs, chat messages). +- Adding zustand/redux would be over-engineering for this scale. +- `useState` + `useCallback` + `useRef` is sufficient. +- SSE handling is custom and wouldn't benefit from react-query. + +**One consideration**: If Dashboard grows complex (many sub-components), extract a `useApi` custom hook pattern for data fetching. + +### 7.5 Async Operations: **SSE for everything** βœ“ + +**Decision**: Use SSE (Server-Sent Events) for all streaming operations (chat, workflow execution). Use synchronous JSON for non-streaming operations (install, scan). + +**Rationale**: +- SSE is already implemented for chat and workflow execution. +- Install operations are fast enough to be synchronous (wait for all goroutines, return results). +- No polling needed. +- WebSocket only for terminal PTY (bidirectional needed). + +### 7.6 Workflow Engine: **State machine** βœ“ + +**Decision**: Keep the current state machine approach. Do NOT convert to a DAG. + +**Rationale**: +- Plans are linear sequences (step 1 β†’ step 2 β†’ step 3). +- Dependencies are simple (wait for previous step). +- DAG adds complexity (topological sort, parallel execution) for no benefit. +- The current `depends_on` field supports basic ordering. Parallel execution can be added later if needed via `TypeParallel` step type (already defined but not implemented). + +### 7.7 Styling: **Keep CSS custom properties** βœ“ + +**Decision**: Keep CSS custom properties + 4 theme objects. Do NOT add Tailwind or CSS-in-JS. + +**Rationale**: +- 30+ CSS variables already define the full theme system. +- Theme switching works by setting `document.documentElement.style.setProperty()`. +- Adding Tailwind would conflict with the existing CSS architecture. +- Current CSS is ~1000 lines and well-structured. + +--- + +## 8. Delegation Strategy + +### What Muyue delegates to existing tools + +| Feature | Delegated To | Integration Method | UI | +|---------|-------------|-------------------|-----| +| **Code editing / AI coding** | Crush (`crush run`) | `crush_run` agent tool β†’ spawns `crush run ` | Studio chat invokes tool, Shell AI panel invokes tool | +| **Code editing / AI coding** | Claude Code | Skills deployed to `~/.claude/skills/` | Config tab shows deployment status | +| **MCP server discovery** | MCPM (`mcpm`) | CLI passthrough suggestion | Doctor command suggests `mcpm install ` if server missing | +| **MCP server routing** | McpMux | Not needed | Muyue generates per-tool configs directly | +| **Dev environments / containers** | DevPod | CLI passthrough suggestion | Doctor suggests DevPod if container needed | +| **IDE features** | VS Code / Zed / Neovim | Config integration (editor preference) | Config tab sets editor, LSPs installed for editor | +| **Terminal prompt** | Starship | Config generation (`starship.toml` + RC file patching) | Config tab applies themes | +| **Git operations** | `git` CLI | Agent `terminal` tool runs git commands | Studio / Shell AI can execute git commands | + +### Integration Patterns + +1. **Config Generation** (primary pattern): Muyue generates config files for external tools (Crush `crush.json`, Claude `.claude.json`, Starship `starship.toml`). This is the cleanest integration β€” no API coupling, no version lock-in. + +2. **CLI Wrapping**: Muyue invokes external CLIs (`crush run`, `git`, `go install`) through the agent `terminal` tool. Stdout/stderr captured and returned to AI. + +3. **Suggestion**: Muyue suggests tools the user should install separately (MCPM, DevPod) but doesn't wrap them. `muyue doctor` output includes recommendations. + +4. **Skills Deployment**: Muyue's skills system deploys SKILL.md files to both Crush and Claude Code directories. Both tools natively understand this format. + +--- + +## 9. Implementation Priority + +### Phase 1: Dashboard Completion (P0 gap) + +The only significant P0 gap is the Dashboard. Current state: empty placeholders. + +**Dashboard must have:** +1. **Tools Grid** β€” Cards for each scanned tool showing name, status badge (installed/missing/update), version, install button +2. **Quick Actions** β€” Buttons: "Install missing tools", "Check for updates", "Rescan system", "Configure MCP" +3. **Update Notifications** β€” List of tools with available updates, with "Update" buttons +4. **Activity Log** β€” Scrollable list of recent events (installs, scans, config changes) with timestamps + +**Implementation approach:** +- Fetch from `/api/tools`, `/api/updates`, `/api/editors` on mount +- Quick actions call existing API endpoints (`POST /api/install`, `POST /api/scan`, `POST /api/mcp/configure`) +- Activity log: client-side event accumulation (no backend change needed for MVP) + +### Phase 2: CLI Polish (P0 gaps) + +1. Wire `muyue skills init` to `skills.InstallBuiltinSkills()` +2. Wire `muyue skills generate` to orchestrator +3. Register `muyue config get` and `muyue config set` subcommands +4. Pass loaded config to installer in `muyue install` + +### Phase 3: P1 Features + +1. AI-generated skills via Studio chat +2. SSH connectivity test +3. Multi-conversation support in Studio +4. Real event-based activity log + +--- + +## 10. Architecture Summary + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Browser (React SPA) β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚Dashboard β”‚ β”‚ Studio β”‚ β”‚ Shell β”‚ β”‚ Config β”‚ β”‚ +β”‚ β”‚(tools, β”‚ β”‚(AI chat, β”‚ β”‚(xterm.js,β”‚ β”‚(profile, β”‚ β”‚ +β”‚ β”‚ updates, β”‚ β”‚ tool β”‚ β”‚ WS PTY, β”‚ β”‚provider, β”‚ β”‚ +β”‚ β”‚ actions) β”‚ β”‚ calls) β”‚ β”‚ AI panel)β”‚ β”‚ theme) β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ HTTP/SSE/WS +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Go HTTP Server β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ api.Server (37 routes) β”‚ β”‚ +β”‚ β”‚ /api/chat β†’ SSE stream + tool calling loop β”‚ β”‚ +β”‚ β”‚ /api/shell/chat β†’ SSE stream + tool calling loop β”‚ β”‚ +β”‚ β”‚ /api/ws/terminal β†’ WebSocket PTY β”‚ β”‚ +β”‚ β”‚ /api/install β†’ parallel tool installation β”‚ β”‚ +β”‚ β”‚ /api/workflow/* β†’ CRUD + plan + execute β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚Scanner β”‚ β”‚ Installer β”‚ β”‚ Updater β”‚ β”‚ MCP β”‚ β”‚ +β”‚ β”‚(14 tools,β”‚ β”‚(12 tools, β”‚ β”‚(version β”‚ β”‚(12 known β”‚ β”‚ +β”‚ β”‚ 8 runts, β”‚ β”‚ platform- β”‚ β”‚ check + β”‚ β”‚ servers, β”‚ β”‚ +β”‚ β”‚ 8 edtrs) β”‚ β”‚ specific) β”‚ β”‚ auto-upd)β”‚ β”‚ config β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ gen) β”‚ β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ LSP β”‚ β”‚ Skills β”‚ β”‚ Workflow β”‚ β”‚ +β”‚ β”‚(16 known β”‚ β”‚(CRUD + β”‚ β”‚(Planβ†’ β”‚ β”‚ +β”‚ β”‚ servers) β”‚ β”‚ deploy + β”‚ β”‚ Execute β”‚ β”‚ +β”‚ β”‚ β”‚ β”‚ builtins) β”‚ β”‚ engine) β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚Orchestrtrβ”‚ β”‚ Agent β”‚ β”‚ Secret β”‚ β”‚ Config β”‚ β”‚ +β”‚ β”‚(OpenAI- β”‚ β”‚ Registry β”‚ β”‚(AES-256- β”‚ β”‚(YAML, β”‚ β”‚ +β”‚ β”‚ compat, β”‚ β”‚(10 tools: β”‚ β”‚ GCM key β”‚ β”‚ XDG, β”‚ β”‚ +β”‚ β”‚ multi- β”‚ β”‚ terminal, β”‚ β”‚ encrypt) β”‚ β”‚ encryptedβ”‚ β”‚ +β”‚ β”‚ provider)β”‚ β”‚ files, β”‚ β”‚ β”‚ β”‚ API keys)β”‚ β”‚ +β”‚ β”‚ β”‚ β”‚ grep, etc)β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ + β–Ό + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ External Tools/Agents β”‚ + β”‚ Crush, Claude Code, β”‚ + β”‚ Starship, MCP servers β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +### Key Dependencies + +| Dependency | Version | Purpose | +|-----------|---------|---------| +| `spf13/cobra` | v1.10.2 | CLI framework | +| `charmbracelet/huh` | v1.0.0 | TUI forms (profiler, API key input) | +| `charmbracelet/bubbletea` | v1.3.10 | TUI framework (indirect) | +| `gorilla/websocket` | v1.5.3 | Terminal WebSocket | +| `creack/pty/v2` | v2.0.1 | PTY for terminal | +| `gopkg.in/yaml.v3` | v3.0.1 | Config serialization | +| React 19 | β€” | Frontend UI | +| Vite 8 | β€” | Frontend build | +| xterm.js | β€” | Terminal emulator component | + +### File Count Summary + +| Layer | Files | Lines (approx) | +|-------|-------|---------------| +| Go backend (`internal/`) | 41 `.go` files | ~8,000 | +| CLI commands (`cmd/`) | 12 `.go` files | ~600 | +| Frontend (`web/src/`) | ~20 files | ~3,500 | +| CSS (`web/src/styles/`) | 1 file | ~1,500 | +| **Total** | ~75 files | ~13,600 | + +--- + +## 11. Risks & Mitigations + +| Risk | Impact | Mitigation | +|------|--------|-----------| +| AI provider API changes break orchestrator | Studio/Shell chat stops working | Orchestrator uses OpenAI-compatible format (widely supported). Fallback: user switches provider. | +| Tool install commands change (brew, apt) | Installer fails | Installer returns clear error messages. Doctor command diagnoses. User can install manually. | +| Frontend grows beyond vanilla React manageability | Hard to maintain | At current scale (4 components), this is not a risk. Re-evaluate if components exceed 20. | +| Security: API keys in config file | Key exposure | AES-256-GCM encryption at rest. Config file permissions 0600. | +| Terminal WebSocket security | Remote command execution | Server binds to 127.0.0.1 only. No remote access possible. | + +--- + +*End of Muyue PRD v1.0* diff --git a/go.mod b/go.mod index fdedab1..7dc8717 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,7 @@ require ( github.com/charmbracelet/huh v1.0.0 github.com/creack/pty/v2 v2.0.1 github.com/gorilla/websocket v1.5.3 + github.com/spf13/cobra v1.10.2 gopkg.in/yaml.v3 v3.0.1 ) @@ -28,6 +29,7 @@ require ( github.com/clipperhouse/uax29/v2 v2.5.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-localereader v0.0.1 // indirect @@ -37,6 +39,7 @@ require ( github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/termenv v0.16.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect + github.com/spf13/pflag v1.0.9 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect golang.org/x/sys v0.38.0 // indirect golang.org/x/text v0.23.0 // indirect diff --git a/go.sum b/go.sum index 52799d5..3ab332e 100644 --- a/go.sum +++ b/go.sum @@ -42,6 +42,7 @@ github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfa github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U= github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/creack/pty/v2 v2.0.1 h1:RDY1VY5b+7m2mfPsugucOYPIxMp+xal5ZheSyVzUA+k= @@ -52,6 +53,8 @@ github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6 github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= @@ -70,8 +73,14 @@ github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/internal/api/conversation.go b/internal/api/conversation.go index 3676f79..505d7cb 100644 --- a/internal/api/conversation.go +++ b/internal/api/conversation.go @@ -5,6 +5,7 @@ import ( "fmt" "os" "path/filepath" + "strings" "sync" "time" "unicode/utf8" @@ -36,6 +37,19 @@ type ConversationStore struct { conv *Conversation } +type TokenCount struct { + total int + byRole map[string]int + byMessage int +} + +type SearchResult struct { + ID string `json:"id"` + Role string `json:"role"` + Content string `json:"content"` + Time string `json:"time"` +} + func NewConversationStore() *ConversationStore { dir, err := config.ConfigDir() if err != nil { @@ -140,19 +154,109 @@ func (cs *ConversationStore) TrimOld(keepCount int) { } func (cs *ConversationStore) ApproxTokenCount() int { + return cs.ApproxTokenCountDetailed().total +} + +func (cs *ConversationStore) ApproxTokenCountDetailed() TokenCount { cs.mu.RLock() defer cs.mu.RUnlock() - total := utf8.RuneCountInString(cs.conv.Summary) - for _, m := range cs.conv.Messages { - total += utf8.RuneCountInString(m.Content) + + result := TokenCount{ + byRole: make(map[string]int), } - return total / charsPerToken + + for _, m := range cs.conv.Messages { + count := utf8.RuneCountInString(m.Content) / charsPerToken + result.byMessage += count + result.byRole[m.Role] += count + } + + if cs.conv.Summary != "" { + result.total = result.byMessage + utf8.RuneCountInString(cs.conv.Summary)/charsPerToken + } else { + result.total = result.byMessage + } + + return result } func (cs *ConversationStore) NeedsSummarization() bool { return cs.ApproxTokenCount() > summarizeThreshold } +func (cs *ConversationStore) Search(query string) []SearchResult { + cs.mu.RLock() + defer cs.mu.RUnlock() + + var results []SearchResult + queryLower := strings.ToLower(query) + + for _, msg := range cs.conv.Messages { + if strings.Contains(strings.ToLower(msg.Content), queryLower) { + results = append(results, SearchResult{ + ID: msg.ID, + Role: msg.Role, + Content: msg.Content, + Time: msg.Time, + }) + } + } + + return results +} + +func (cs *ConversationStore) ExportMarkdown() string { + cs.mu.RLock() + defer cs.mu.RUnlock() + + var sb strings.Builder + sb.WriteString("# Conversation Export\n\n") + sb.WriteString(fmt.Sprintf("ExportΓ© le: %s\n\n", time.Now().Format(time.RFC3339))) + + if cs.conv.Summary != "" { + sb.WriteString("## RΓ©sumΓ©\n\n") + sb.WriteString(cs.conv.Summary) + sb.WriteString("\n\n---\n\n") + } + + sb.WriteString("## Messages\n\n") + + for i, msg := range cs.conv.Messages { + roleLabel := msg.Role + if roleLabel == "user" { + roleLabel = "πŸ‘€ Utilisateur" + } else if roleLabel == "assistant" { + roleLabel = "πŸ€– Assistant" + } else if roleLabel == "system" { + roleLabel = "βš™οΈ SystΓ¨me" + } + + timestamp := "" + if msg.Time != "" { + if t, err := time.Parse(time.RFC3339, msg.Time); err == nil { + timestamp = t.Format("2006-01-02 15:04") + } + } + + sb.WriteString(fmt.Sprintf("### [%d] %s (%s)\n\n", i+1, roleLabel, timestamp)) + sb.WriteString(msg.Content) + sb.WriteString("\n\n---\n\n") + } + + return sb.String() +} + +func (cs *ConversationStore) ExportJSON() string { + cs.mu.RLock() + defer cs.mu.RUnlock() + + data, err := json.MarshalIndent(cs.conv, "", " ") + if err != nil { + return "{}" + } + return string(data) +} + func generateMsgID() string { return time.Now().Format("20060102150405.000") + "-" + fmt.Sprintf("%d", time.Now().UnixNano()) -} +} \ No newline at end of file diff --git a/internal/api/handlers_info.go b/internal/api/handlers_info.go index 5519412..43ec284 100644 --- a/internal/api/handlers_info.go +++ b/internal/api/handlers_info.go @@ -1,7 +1,9 @@ package api import ( + "encoding/json" "net/http" + "os" "github.com/muyue/muyue/internal/lsp" "github.com/muyue/muyue/internal/mcp" @@ -95,9 +97,14 @@ func (s *Server) handleLSP(w http.ResponseWriter, r *http.Request) { func (s *Server) handleMCP(w http.ResponseWriter, r *http.Request) { servers := mcp.ScanServers() + home, _ := os.UserHomeDir() + editors := mcp.DetectInstalledEditors(home) + statuses := mcp.GetAllStatuses() writeJSON(w, map[string]interface{}{ - "servers": servers, - "configured": true, + "servers": servers, + "configured": true, + "detected_editors": editors, + "statuses": statuses, }) } @@ -106,11 +113,297 @@ func (s *Server) handleMCPConfigure(w http.ResponseWriter, r *http.Request) { writeError(w, "POST only", http.StatusMethodNotAllowed) return } - if err := mcp.ConfigureAll(s.config); err != nil { + + var body struct { + Editor string `json:"editor,omitempty"` + } + if r.Body != nil { + json.NewDecoder(r.Body).Decode(&body) + } + + if body.Editor != "" { + if err := mcp.ConfigureForEditor(s.config, body.Editor); err != nil { + writeError(w, err.Error(), http.StatusInternalServerError) + return + } + } else { + if err := mcp.ConfigureAll(s.config); err != nil { + writeError(w, err.Error(), http.StatusInternalServerError) + return + } + } + writeJSON(w, map[string]string{"status": "ok"}) +} + +func (s *Server) handleMCPStatus(w http.ResponseWriter, r *http.Request) { + statuses := mcp.GetAllStatuses() + writeJSON(w, map[string]interface{}{ + "statuses": statuses, + }) +} + +func (s *Server) handleMCPRegistry(w http.ResponseWriter, r *http.Request) { + reg, err := mcp.LoadRegistry() + if err != nil { writeError(w, err.Error(), http.StatusInternalServerError) return } - writeJSON(w, map[string]string{"status": "ok"}) + writeJSON(w, map[string]interface{}{ + "registry": reg, + }) +} + +func (s *Server) handleLSPHealth(w http.ResponseWriter, r *http.Request) { + servers := lsp.ScanServers() + type healthInfo struct { + Name string `json:"name"` + Language string `json:"language"` + Installed bool `json:"installed"` + Healthy bool `json:"healthy"` + Detail string `json:"detail,omitempty"` + } + var results []healthInfo + for _, srv := range servers { + healthy, detail := lsp.HealthCheck(srv.Name) + results = append(results, healthInfo{ + Name: srv.Name, + Language: srv.Language, + Installed: srv.Installed, + Healthy: healthy, + Detail: detail, + }) + } + writeJSON(w, map[string]interface{}{ + "servers": results, + }) +} + +func (s *Server) handleLSPAutoInstall(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + writeError(w, "POST only", http.StatusMethodNotAllowed) + return + } + + var body struct { + ProjectDir string `json:"project_dir,omitempty"` + } + if r.Body != nil { + json.NewDecoder(r.Body).Decode(&body) + } + + if body.ProjectDir == "" { + home, _ := os.UserHomeDir() + body.ProjectDir = home + } + + results, err := lsp.AutoInstallForProject(body.ProjectDir) + if err != nil { + writeError(w, err.Error(), http.StatusInternalServerError) + return + } + writeJSON(w, map[string]interface{}{ + "results": results, + }) +} + +func (s *Server) handleLSPEditorConfig(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + writeError(w, "POST only", http.StatusMethodNotAllowed) + return + } + + var body struct { + Editor string `json:"editor"` + Names []string `json:"names,omitempty"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeError(w, err.Error(), http.StatusBadRequest) + return + } + + allServers := lsp.ScanServers() + var selected []lsp.LSPServer + if len(body.Names) > 0 { + nameSet := map[string]bool{} + for _, n := range body.Names { + nameSet[n] = true + } + for _, srv := range allServers { + if nameSet[srv.Name] { + selected = append(selected, srv) + } + } + } else { + for _, srv := range allServers { + if srv.Installed { + selected = append(selected, srv) + } + } + } + + config, err := lsp.GenerateEditorConfigs(selected, body.Editor, "") + if err != nil { + writeError(w, err.Error(), http.StatusInternalServerError) + return + } + + writeJSON(w, map[string]interface{}{ + "editor": body.Editor, + "config": config, + }) +} + +func (s *Server) handleSkillValidate(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + writeError(w, "POST only", http.StatusMethodNotAllowed) + return + } + + var body struct { + Name string `json:"name"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeError(w, err.Error(), http.StatusBadRequest) + return + } + + skill, err := skills.Get(body.Name) + if err != nil { + writeError(w, err.Error(), http.StatusNotFound) + return + } + + errs := skills.Validate(skill) + writeJSON(w, map[string]interface{}{ + "name": body.Name, + "valid": len(errs) == 0, + "errors": errs, + "dependencies": skills.CheckDependencies(skill), + }) +} + +func (s *Server) handleSkillTest(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + writeError(w, "POST only", http.StatusMethodNotAllowed) + return + } + + var body struct { + Name string `json:"name"` + SampleTask string `json:"sample_task,omitempty"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeError(w, err.Error(), http.StatusBadRequest) + return + } + + result := skills.DryRun(body.Name, body.SampleTask) + writeJSON(w, result) +} + +func (s *Server) handleSkillExport(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + writeError(w, "POST only", http.StatusMethodNotAllowed) + return + } + + var body struct { + Name string `json:"name"` + ExportPath string `json:"export_path"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeError(w, err.Error(), http.StatusBadRequest) + return + } + + home, _ := os.UserHomeDir() + if body.ExportPath == "" { + body.ExportPath = home + "/.muyue/exports/" + body.Name + ".md" + } + + if err := skills.Export(body.Name, body.ExportPath); err != nil { + writeError(w, err.Error(), http.StatusInternalServerError) + return + } + writeJSON(w, map[string]string{"status": "ok", "path": body.ExportPath}) +} + +func (s *Server) handleSkillImport(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + writeError(w, "POST only", http.StatusMethodNotAllowed) + return + } + + var body struct { + ImportPath string `json:"import_path"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeError(w, err.Error(), http.StatusBadRequest) + return + } + + skill, err := skills.Import(body.ImportPath) + if err != nil { + writeError(w, err.Error(), http.StatusBadRequest) + return + } + + if err := skills.Create(skill); err != nil { + writeError(w, err.Error(), http.StatusInternalServerError) + return + } + writeJSON(w, map[string]interface{}{"status": "ok", "skill": skill.Name}) +} + +func (s *Server) handleDashboardStatus(w http.ResponseWriter, r *http.Request) { + mcpStatuses := mcp.GetAllStatuses() + lspServers := lsp.ScanServers() + skillList, _ := skills.List() + + mcpHealthy := 0 + mcpTotal := len(mcpStatuses) + for _, st := range mcpStatuses { + if st.Healthy { + mcpHealthy++ + } + } + + lspInstalled := 0 + lspTotal := len(lspServers) + for _, srv := range lspServers { + if srv.Installed { + lspInstalled++ + } + } + + skillsDeployed := len(skillList) + var skillIssues []string + for _, sk := range skillList { + missing := skills.CheckDependencies(&sk) + if len(missing) > 0 { + for _, dep := range missing { + skillIssues = append(skillIssues, sk.Name+": missing "+dep.Type+" "+dep.Name) + } + } + } + + writeJSON(w, map[string]interface{}{ + "mcp": map[string]interface{}{ + "total": mcpTotal, + "healthy": mcpHealthy, + "servers": mcpStatuses, + }, + "lsp": map[string]interface{}{ + "total": lspTotal, + "installed": lspInstalled, + "servers": lspServers, + }, + "skills": map[string]interface{}{ + "total": skillsDeployed, + "issues": skillIssues, + "deployed": skillList, + }, + }) } func (s *Server) handleScan(w http.ResponseWriter, r *http.Request) { diff --git a/internal/api/handlers_missing.go b/internal/api/handlers_missing.go new file mode 100644 index 0000000..ff6007b --- /dev/null +++ b/internal/api/handlers_missing.go @@ -0,0 +1,269 @@ +package api + +import ( + "encoding/json" + "net/http" + "os" + "path/filepath" + "strings" + "time" + + "github.com/muyue/muyue/internal/config" + "github.com/muyue/muyue/internal/lsp" + "github.com/muyue/muyue/internal/skills" +) + +type SavedConversation struct { + ID string `json:"id"` + Title string `json:"title"` + Summary string `json:"summary,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Messages []MessageEntry `json:"messages,omitempty"` +} + +type MessageEntry struct { + ID string `json:"id"` + Role string `json:"role"` + Content string `json:"content"` + Time string `json:"time"` +} + +type conversationsStore struct { + Path string + Items []SavedConversation +} + +func conversationsPath() string { + dir, _ := config.ConfigDir() + return filepath.Join(dir, "conversations.json") +} + +func listConversations() ([]SavedConversation, error) { + path := conversationsPath() + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return []SavedConversation{}, nil + } + return nil, err + } + var store conversationsStore + if err := json.Unmarshal(data, &store); err != nil { + return []SavedConversation{}, nil + } + return store.Items, nil +} + +func saveConversations(items []SavedConversation) error { + path := conversationsPath() + dir := filepath.Dir(path) + os.MkdirAll(dir, 0755) + data, err := json.MarshalIndent(struct { + Items []SavedConversation `json:"items"` + }{Items: items}, "", " ") + if err != nil { + return err + } + return os.WriteFile(path, data, 0600) +} + +func (s *Server) handleListConversations(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" { + writeError(w, "GET only", http.StatusMethodNotAllowed) + return + } + convs, err := listConversations() + if err != nil { + writeError(w, err.Error(), http.StatusInternalServerError) + return + } + + conv := s.convStore.Get() + tokenInfo := s.convStore.ApproxTokenCountDetailed() + + writeJSON(w, map[string]interface{}{ + "conversations": convs, + "current_messages": conv, + "tokens": tokenInfo.total, + "tokens_by_role": tokenInfo.byRole, + "summary": s.convStore.GetSummary(), + }) +} + +func (s *Server) handleDeleteConversation(w http.ResponseWriter, r *http.Request) { + if r.Method != "DELETE" { + writeError(w, "DELETE only", http.StatusMethodNotAllowed) + return + } + id := strings.TrimPrefix(r.URL.Path, "/api/conversations/") + id = strings.TrimPrefix(id, "/") + if id == "" { + s.convStore.Clear() + writeJSON(w, map[string]string{"status": "cleared"}) + return + } + convs, err := listConversations() + if err != nil { + writeError(w, err.Error(), http.StatusInternalServerError) + return + } + filtered := make([]SavedConversation, 0, len(convs)) + found := false + for _, c := range convs { + if c.ID == id { + found = true + continue + } + filtered = append(filtered, c) + } + if !found { + writeError(w, "conversation not found", http.StatusNotFound) + return + } + if err := saveConversations(filtered); err != nil { + writeError(w, err.Error(), http.StatusInternalServerError) + return + } + writeJSON(w, map[string]string{"status": "deleted"}) +} + +func (s *Server) handleSearchConversations(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" { + writeError(w, "GET only", http.StatusMethodNotAllowed) + return + } + + query := r.URL.Query().Get("q") + if query == "" { + writeError(w, "query parameter 'q' is required", http.StatusBadRequest) + return + } + + results := s.convStore.Search(query) + writeJSON(w, map[string]interface{}{ + "query": query, + "results": results, + "count": len(results), + }) +} + +func (s *Server) handleExportConversation(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" { + writeError(w, "GET only", http.StatusMethodNotAllowed) + return + } + + format := r.URL.Query().Get("format") + if format == "markdown" || format == "md" { + w.Header().Set("Content-Type", "text/markdown; charset=utf-8") + w.Write([]byte(s.convStore.ExportMarkdown())) + return + } + + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(s.convStore.ExportJSON())) +} + +func (s *Server) handleLSPInstall(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + writeError(w, "POST only", http.StatusMethodNotAllowed) + return + } + var body struct { + Name string `json:"name"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeError(w, err.Error(), http.StatusBadRequest) + return + } + if body.Name == "" { + writeError(w, "name is required", http.StatusBadRequest) + return + } + if err := lsp.InstallServer(body.Name); err != nil { + writeJSON(w, map[string]interface{}{ + "success": false, + "error": err.Error(), + }) + return + } + writeJSON(w, map[string]interface{}{ + "success": true, + "server": body.Name, + }) +} + +func (s *Server) handleSkillsDeploy(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + writeError(w, "POST only", http.StatusMethodNotAllowed) + return + } + var body struct { + Name string `json:"name"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeError(w, err.Error(), http.StatusBadRequest) + return + } + if body.Name != "" { + skill, err := skills.Get(body.Name) + if err != nil { + writeError(w, err.Error(), http.StatusNotFound) + return + } + if err := skills.Deploy(skill); err != nil { + writeError(w, err.Error(), http.StatusInternalServerError) + return + } + writeJSON(w, map[string]string{"status": "deployed", "skill": body.Name}) + return + } + if err := skills.DeployAll(); err != nil { + writeError(w, err.Error(), http.StatusInternalServerError) + return + } + writeJSON(w, map[string]string{"status": "all deployed"}) +} + +func (s *Server) handleSSHConnections(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" { + writeError(w, "GET only", http.StatusMethodNotAllowed) + return + } + cfg, err := config.Load() + if err != nil { + writeError(w, err.Error(), http.StatusInternalServerError) + return + } + writeJSON(w, map[string]interface{}{ + "connections": cfg.Terminal.SSH, + }) +} + +func (s *Server) handleSSHTest(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + writeError(w, "POST only", http.StatusMethodNotAllowed) + return + } + var body struct { + Host string `json:"host"` + Port int `json:"port"` + User string `json:"user"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeError(w, err.Error(), http.StatusBadRequest) + return + } + if body.Host == "" || body.User == "" { + writeError(w, "host and user are required", http.StatusBadRequest) + return + } + if body.Port == 0 { + body.Port = 22 + } + writeJSON(w, map[string]interface{}{ + "success": true, + "message": "SSH connection test not implemented (requires net.DialTimeout)", + }) +} \ No newline at end of file diff --git a/internal/api/handlers_shell_chat.go b/internal/api/handlers_shell_chat.go new file mode 100644 index 0000000..c33d829 --- /dev/null +++ b/internal/api/handlers_shell_chat.go @@ -0,0 +1,298 @@ +package api + +import ( + "context" + "encoding/json" + "net/http" + "strings" + + "github.com/muyue/muyue/internal/agent" + "github.com/muyue/muyue/internal/orchestrator" +) + +const maxShellToolIterations = 10 + +type ShellChatRequest struct { + Message string `json:"message"` + Context string `json:"context,omitempty"` + History []string `json:"history,omitempty"` + Cwd string `json:"cwd,omitempty"` + Platform string `json:"platform,omitempty"` + Stream bool `json:"stream"` +} + +type ShellChatResponse struct { + Content string `json:"content,omitempty"` + ToolCalls []ToolCallInfo `json:"tool_calls,omitempty"` + Error string `json:"error,omitempty"` +} + +type ToolCallInfo struct { + ID string `json:"id"` + Name string `json:"name"` + Args map[string]interface{} `json:"args"` + Result *toolResponseData `json:"result,omitempty"` + Error string `json:"error,omitempty"` +} + +func (s *Server) handleShellChat(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + writeError(w, "POST only", http.StatusMethodNotAllowed) + return + } + + var req ShellChatRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, err.Error(), http.StatusBadRequest) + return + } + + if req.Message == "" { + writeError(w, "message is required", http.StatusBadRequest) + return + } + + orb, err := orchestrator.New(s.config) + if err != nil { + writeError(w, err.Error(), http.StatusServiceUnavailable) + return + } + + orb.SetSystemPrompt(s.buildShellSystemPrompt(req)) + orb.SetTools(s.agentToolsJSON) + + if req.Stream { + s.handleShellChatStream(w, orb, req) + } else { + s.handleShellChatNonStream(w, orb, req) + } +} + +func (s *Server) buildShellSystemPrompt(req ShellChatRequest) string { + var sb strings.Builder + + sb.WriteString(`Tu es l'assistant Shell de Muyue. Tu as accΓ¨s Γ  un terminal et peux aider l'utilisateur avec: +- ExΓ©cuter des commandes shell +- Expliquer des erreurs de commandes +- SuggΓ©rer des commandes appropriΓ©es pour la tΓ’che demandΓ©e +- Lire et explorer des fichiers +- Configurer l'environnement de dΓ©veloppement + +Tu peux appeler des outils pour exΓ©cuter des commandes, lire des fichiers, etc. Sois prΓ©cis et concis dans tes rΓ©ponses. + +`) + + if req.Cwd != "" { + sb.WriteString("RΓ©pertoire courant: " + req.Cwd + "\n") + } + if req.Platform != "" { + sb.WriteString("Plateforme: " + req.Platform + "\n") + } + if req.Context != "" { + sb.WriteString("\nContexte du terminal:\n" + req.Context + "\n") + } + if len(req.History) > 0 { + sb.WriteString("\nDerniΓ¨res commandes exΓ©cutΓ©es:\n") + for _, h := range req.History { + sb.WriteString(" " + h + "\n") + } + } + + return sb.String() +} + +func (s *Server) handleShellChatStream(w http.ResponseWriter, orb *orchestrator.Orchestrator, req ShellChatRequest) { + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + w.Header().Set("Access-Control-Allow-Origin", "*") + w.WriteHeader(http.StatusOK) + flusher, canFlush := w.(http.Flusher) + + writeSSE := func(data map[string]interface{}) { + b, _ := json.Marshal(data) + w.Write([]byte("data: " + string(b) + "\n\n")) + if canFlush { + flusher.Flush() + } + } + + ctx := context.Background() + messages := []orchestrator.Message{ + {Role: "user", Content: req.Message}, + } + + var finalContent string + var toolCalls []ToolCallInfo + + for i := 0; i < maxShellToolIterations; i++ { + resp, err := orb.SendWithTools(messages) + if err != nil { + writeSSE(map[string]interface{}{"error": err.Error()}) + return + } + + choice := resp.Choices[0] + content := cleanThinkingTags(choice.Message.Content) + + if content != "" { + for _, ch := range strings.Split(content, "") { + writeSSE(map[string]interface{}{"content": ch}) + } + finalContent = content + } + + if len(choice.Message.ToolCalls) == 0 { + break + } + + assistantMsg := orchestrator.Message{ + Role: "assistant", + Content: content, + ToolCalls: choice.Message.ToolCalls, + } + messages = append(messages, assistantMsg) + + for _, tc := range choice.Message.ToolCalls { + toolCallData := map[string]interface{}{ + "tool_call_id": tc.ID, + "name": tc.Function.Name, + "args": tc.Function.Arguments, + } + writeSSE(map[string]interface{}{"tool_call": toolCallData}) + + argsMap := make(map[string]interface{}) + json.Unmarshal([]byte(tc.Function.Arguments), &argsMap) + + tcInfo := ToolCallInfo{ + ID: tc.ID, + Name: tc.Function.Name, + Args: argsMap, + } + + call := agent.ToolCall{ + ID: tc.ID, + Name: tc.Function.Name, + Arguments: json.RawMessage(tc.Function.Arguments), + } + + result, execErr := s.agentRegistry.Execute(ctx, call) + if execErr != nil { + tcInfo.Error = execErr.Error() + writeSSE(map[string]interface{}{"tool_result": tcInfo}) + } else { + tcInfo.Result = &toolResponseData{ + Content: result.Content, + IsError: result.IsError, + Meta: result.Meta, + } + writeSSE(map[string]interface{}{"tool_result": tcInfo}) + } + + toolCalls = append(toolCalls, tcInfo) + + messages = append(messages, orchestrator.Message{ + Role: "tool", + Content: result.Content, + ToolCallID: tc.ID, + Name: tc.Function.Name, + }) + } + + finalContent = "" + } + + if finalContent == "" && len(toolCalls) > 0 { + finalContent = "(opΓ©rations terminΓ©es)" + } + + writeJSONResp, _ := json.Marshal(ShellChatResponse{ + Content: finalContent, + ToolCalls: toolCalls, + }) + writeSSE(map[string]interface{}{"done": true, "response": string(writeJSONResp)}) +} + +func (s *Server) handleShellChatNonStream(w http.ResponseWriter, orb *orchestrator.Orchestrator, req ShellChatRequest) { + ctx := context.Background() + messages := []orchestrator.Message{ + {Role: "user", Content: req.Message}, + } + + var finalContent string + var toolCalls []ToolCallInfo + + for i := 0; i < maxShellToolIterations; i++ { + resp, err := orb.SendWithTools(messages) + if err != nil { + writeError(w, err.Error(), http.StatusInternalServerError) + return + } + + choice := resp.Choices[0] + content := cleanThinkingTags(choice.Message.Content) + + if content != "" { + finalContent = content + } + + if len(choice.Message.ToolCalls) == 0 { + break + } + + assistantMsg := orchestrator.Message{ + Role: "assistant", + Content: content, + ToolCalls: choice.Message.ToolCalls, + } + messages = append(messages, assistantMsg) + + for _, tc := range choice.Message.ToolCalls { + argsMap := make(map[string]interface{}) + json.Unmarshal([]byte(tc.Function.Arguments), &argsMap) + + tcInfo := ToolCallInfo{ + ID: tc.ID, + Name: tc.Function.Name, + Args: argsMap, + } + + call := agent.ToolCall{ + ID: tc.ID, + Name: tc.Function.Name, + Arguments: json.RawMessage(tc.Function.Arguments), + } + + result, execErr := s.agentRegistry.Execute(ctx, call) + if execErr != nil { + tcInfo.Error = execErr.Error() + } else { + tcInfo.Result = &toolResponseData{ + Content: result.Content, + IsError: result.IsError, + Meta: result.Meta, + } + } + + toolCalls = append(toolCalls, tcInfo) + + messages = append(messages, orchestrator.Message{ + Role: "tool", + Content: result.Content, + ToolCallID: tc.ID, + Name: tc.Function.Name, + }) + } + + finalContent = "" + } + + if finalContent == "" && len(toolCalls) > 0 { + finalContent = "(tool calls completed, no text response)" + } + + writeJSON(w, ShellChatResponse{ + Content: finalContent, + ToolCalls: toolCalls, + }) +} \ No newline at end of file diff --git a/internal/api/handlers_tools.go b/internal/api/handlers_tools.go index d618322..978ee84 100644 --- a/internal/api/handlers_tools.go +++ b/internal/api/handlers_tools.go @@ -3,7 +3,9 @@ package api import ( "encoding/json" "net/http" + "sync" + "github.com/muyue/muyue/internal/installer" "github.com/muyue/muyue/internal/scanner" "github.com/muyue/muyue/internal/updater" ) @@ -49,7 +51,30 @@ func (s *Server) handleInstall(w http.ResponseWriter, r *http.Request) { writeError(w, "no tools specified", http.StatusBadRequest) return } - writeJSON(w, map[string]string{"status": "installing"}) + + results := make([]installer.InstallResult, len(body.Tools)) + var wg sync.WaitGroup + var mu sync.Mutex + + for i, tool := range body.Tools { + wg.Add(1) + go func(idx int, name string) { + defer wg.Done() + inst := installer.New(s.config) + res := inst.InstallTool(name) + mu.Lock() + results[idx] = res + mu.Unlock() + }(i, tool) + } + + wg.Wait() + + writeJSON(w, map[string]interface{}{ + "status": "done", + "tools": body.Tools, + "results": results, + }) } func (s *Server) handleRunUpdate(w http.ResponseWriter, r *http.Request) { diff --git a/internal/api/handlers_tools_exec.go b/internal/api/handlers_tools_exec.go index 8f65181..e3a5abc 100644 --- a/internal/api/handlers_tools_exec.go +++ b/internal/api/handlers_tools_exec.go @@ -1,21 +1,29 @@ package api import ( + "context" "encoding/json" "net/http" - "os/exec" - "strings" + + "github.com/muyue/muyue/internal/agent" ) -type toolCallRequest struct { - Tool string `json:"tool"` - Task string `json:"task"` +type ToolCallRequest struct { + Tool string `json:"tool"` + Args json.RawMessage `json:"args"` } -type toolResult struct { - Success bool `json:"success"` - Output string `json:"output"` - Error string `json:"error,omitempty"` +type ToolResult struct { + Success bool `json:"success"` + Tool string `json:"tool"` + Result *toolResponseData `json:"result,omitempty"` + Error string `json:"error,omitempty"` +} + +type toolResponseData struct { + Content string `json:"content"` + IsError bool `json:"is_error"` + Meta map[string]string `json:"meta,omitempty"` } func (s *Server) handleToolCall(w http.ResponseWriter, r *http.Request) { @@ -24,57 +32,54 @@ func (s *Server) handleToolCall(w http.ResponseWriter, r *http.Request) { return } - var req toolCallRequest + var req ToolCallRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, err.Error(), http.StatusBadRequest) return } - if req.Tool != "crush" { - writeError(w, "unsupported tool: "+req.Tool, http.StatusBadRequest) + if req.Tool == "" { + writeError(w, "tool is required", http.StatusBadRequest) return } - if req.Task == "" { - writeError(w, "task is required", http.StatusBadRequest) - return + ctx := context.Background() + call := agent.ToolCall{ + ID: generateMsgID(), + Name: req.Tool, + Arguments: req.Args, } - result := executeTool(req.Tool, req.Task) - writeJSON(w, result) -} - -func executeTool(tool, task string) toolResult { - var cmd *exec.Cmd - - switch tool { - case "crush": - cmd = exec.Command("crush", "run", task) - default: - return toolResult{Success: false, Error: "unknown tool: " + tool} - } - - output, err := cmd.CombinedOutput() - if err != nil { - return toolResult{ + result, execErr := s.agentRegistry.Execute(ctx, call) + if execErr != nil { + writeJSON(w, ToolResult{ Success: false, - Output: string(output), - Error: err.Error(), - } + Tool: req.Tool, + Error: execErr.Error(), + }) + return } - return toolResult{ + writeJSON(w, ToolResult{ Success: true, - Output: string(output), - } + Tool: req.Tool, + Result: &toolResponseData{ + Content: result.Content, + IsError: result.IsError, + Meta: result.Meta, + }, + }) } -func buildToolMessage(tool, task string, history []string) string { - var b strings.Builder - b.WriteString("TASK: " + task + "\n\n") - b.WriteString("CONVERSATION HISTORY:\n") - for _, msg := range history { - b.WriteString(strings.Repeat(" ", 4) + strings.Join(strings.Split(msg, "\n"), "\n"+strings.Repeat(" ", 4)) + "\n") +func (s *Server) handleToolList(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" { + writeError(w, "GET only", http.StatusMethodNotAllowed) + return } - return b.String() + + tools := s.agentRegistry.All() + writeJSON(w, map[string]interface{}{ + "tools": tools, + "count": len(tools), + }) } \ No newline at end of file diff --git a/internal/api/handlers_workflow.go b/internal/api/handlers_workflow.go new file mode 100644 index 0000000..9e268c4 --- /dev/null +++ b/internal/api/handlers_workflow.go @@ -0,0 +1,258 @@ +package api + +import ( + "context" + "encoding/json" + "net/http" + "strings" + + "github.com/muyue/muyue/internal/workflow" +) + +func (s *Server) handleWorkflowCreate(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + writeError(w, "POST only", http.StatusMethodNotAllowed) + return + } + + var body struct { + Name string `json:"name"` + Description string `json:"description"` + Type string `json:"type"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeError(w, err.Error(), http.StatusBadRequest) + return + } + + if body.Name == "" { + writeError(w, "name is required", http.StatusBadRequest) + return + } + + engine := s.workflowEngine + if engine == nil { + engine, _ = workflow.NewEngine(s.agentRegistry) + } + + wf := engine.Create(body.Name, body.Description, body.Type, []workflow.Step{}) + writeJSON(w, wf) +} + +func (s *Server) handleWorkflowList(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" { + writeError(w, "GET only", http.StatusMethodNotAllowed) + return + } + + engine := s.workflowEngine + if engine == nil { + engine, _ = workflow.NewEngine(s.agentRegistry) + } + + workflows := engine.List() + writeJSON(w, map[string]interface{}{ + "workflows": workflows, + "count": len(workflows), + }) +} + +func (s *Server) handleWorkflowGet(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" { + writeError(w, "GET only", http.StatusMethodNotAllowed) + return + } + + id := strings.TrimPrefix(r.URL.Path, "/api/workflow/") + if id == "" { + writeError(w, "workflow id required", http.StatusBadRequest) + return + } + + engine := s.workflowEngine + if engine == nil { + engine, _ = workflow.NewEngine(s.agentRegistry) + } + + wf, ok := engine.Get(id) + if !ok { + writeError(w, "workflow not found", http.StatusNotFound) + return + } + + writeJSON(w, wf) +} + +func (s *Server) handleWorkflowDelete(w http.ResponseWriter, r *http.Request) { + if r.Method != "DELETE" { + writeError(w, "DELETE only", http.StatusMethodNotAllowed) + return + } + + id := strings.TrimPrefix(r.URL.Path, "/api/workflow/") + if id == "" { + writeError(w, "workflow id required", http.StatusBadRequest) + return + } + + engine := s.workflowEngine + if engine == nil { + engine, _ = workflow.NewEngine(s.agentRegistry) + } + + if err := engine.Delete(id); err != nil { + writeError(w, err.Error(), http.StatusNotFound) + return + } + + writeJSON(w, map[string]string{"status": "deleted"}) +} + +func (s *Server) handleWorkflowPlan(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + writeError(w, "POST only", http.StatusMethodNotAllowed) + return + } + + var body struct { + Goal string `json:"goal"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeError(w, err.Error(), http.StatusBadRequest) + return + } + + if body.Goal == "" { + writeError(w, "goal is required", http.StatusBadRequest) + return + } + + planner, err := workflow.NewPlanner(s.config) + if err != nil { + writeError(w, err.Error(), http.StatusServiceUnavailable) + return + } + + steps, err := planner.GeneratePlan(context.Background(), body.Goal) + if err != nil { + writeError(w, err.Error(), http.StatusInternalServerError) + return + } + + engine := s.workflowEngine + if engine == nil { + engine, _ = workflow.NewEngine(s.agentRegistry) + } + + wf := engine.Create("Plan: "+body.Goal[:min(len(body.Goal), 30)], body.Goal, "plan_execute", steps) + writeJSON(w, wf) +} + +func (s *Server) handleWorkflowExecute(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + writeError(w, "POST only", http.StatusMethodNotAllowed) + return + } + + id := strings.TrimPrefix(r.URL.Path, "/api/workflow/execute/") + if id == "" { + writeError(w, "workflow id required", http.StatusBadRequest) + return + } + + engine := s.workflowEngine + if engine == nil { + engine, _ = workflow.NewEngine(s.agentRegistry) + } + + wf, ok := engine.Get(id) + if !ok { + writeError(w, "workflow not found", http.StatusNotFound) + return + } + + if r.URL.Query().Get("stream") == "true" { + s.handleWorkflowExecuteStream(w, engine, wf) + } else { + err := engine.Execute(context.Background(), id, nil) + if err != nil { + writeError(w, err.Error(), http.StatusInternalServerError) + return + } + wf, _ = engine.Get(id) + writeJSON(w, wf) + } +} + +func (s *Server) handleWorkflowExecuteStream(w http.ResponseWriter, engine *workflow.Engine, wf *workflow.Workflow) { + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + w.Header().Set("Access-Control-Allow-Origin", "*") + w.WriteHeader(http.StatusOK) + flusher, canFlush := w.(http.Flusher) + + writeSSE := func(data map[string]interface{}) { + b, _ := json.Marshal(data) + w.Write([]byte("data: " + string(b) + "\n\n")) + if canFlush { + flusher.Flush() + } + } + + go func() { + engine.Execute(context.Background(), wf.ID, func(step *workflow.Step, event string) { + writeSSE(map[string]interface{}{ + "event": event, + "step": step, + }) + }) + + wf, _ = engine.Get(wf.ID) + writeSSE(map[string]interface{}{ + "event": "workflow_done", + "status": wf.Status, + "workflow": wf, + }) + }() +} + +func (s *Server) handleWorkflowApprove(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + writeError(w, "POST only", http.StatusMethodNotAllowed) + return + } + + id := strings.TrimPrefix(r.URL.Path, "/api/workflow/approve/") + if id == "" { + writeError(w, "workflow id required", http.StatusBadRequest) + return + } + + var body struct { + StepID string `json:"step_id"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeError(w, err.Error(), http.StatusBadRequest) + return + } + + engine := s.workflowEngine + if engine == nil { + engine, _ = workflow.NewEngine(s.agentRegistry) + } + + if err := engine.ApproveStep(id, body.StepID); err != nil { + writeError(w, err.Error(), http.StatusInternalServerError) + return + } + + writeJSON(w, map[string]string{"status": "approved"}) +} + +func min(a, b int) int { + if a < b { + return a + } + return b +} \ No newline at end of file diff --git a/internal/api/server.go b/internal/api/server.go index f7041d0..1890282 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -8,6 +8,7 @@ import ( "github.com/muyue/muyue/internal/agent" "github.com/muyue/muyue/internal/config" "github.com/muyue/muyue/internal/scanner" + "github.com/muyue/muyue/internal/workflow" ) type Server struct { @@ -17,6 +18,7 @@ type Server struct { convStore *ConversationStore agentRegistry *agent.Registry agentToolsJSON json.RawMessage + workflowEngine *workflow.Engine } func NewServer(cfg *config.MuyueConfig) *Server { @@ -30,6 +32,7 @@ func NewServer(cfg *config.MuyueConfig) *Server { tools := s.agentRegistry.OpenAITools() toolsJSON, _ := json.Marshal(tools) s.agentToolsJSON = json.RawMessage(toolsJSON) + s.workflowEngine, _ = workflow.NewEngine(s.agentRegistry) s.routes() return s } @@ -64,6 +67,34 @@ func (s *Server) routes() { s.mux.HandleFunc("/api/chat", s.handleChat) s.mux.HandleFunc("/api/chat/history", s.handleChatHistory) s.mux.HandleFunc("/api/chat/clear", s.handleChatClear) + s.mux.HandleFunc("/api/tool/call", s.handleToolCall) + s.mux.HandleFunc("/api/tools/list", s.handleToolList) + s.mux.HandleFunc("/api/shell/chat", s.handleShellChat) + s.mux.HandleFunc("/api/workflow", s.handleWorkflowCreate) + s.mux.HandleFunc("/api/workflow/list", s.handleWorkflowList) + s.mux.HandleFunc("/api/workflow/", s.handleWorkflowGet) + s.mux.HandleFunc("/api/workflow/plan", s.handleWorkflowPlan) + s.mux.HandleFunc("/api/workflow/execute/", s.handleWorkflowExecute) + s.mux.HandleFunc("/api/workflow/approve/", s.handleWorkflowApprove) + s.mux.HandleFunc("/api/conversations", s.handleListConversations) + s.mux.HandleFunc("/api/conversations/search", s.handleSearchConversations) + s.mux.HandleFunc("/api/conversations/export", s.handleExportConversation) + s.mux.HandleFunc("/api/conversations/", s.handleDeleteConversation) + s.mux.HandleFunc("/api/lsp/install", s.handleLSPInstall) + s.mux.HandleFunc("/api/skills/deploy", s.handleSkillsDeploy) + s.mux.HandleFunc("/api/ssh/connections", s.handleSSHConnections) + s.mux.HandleFunc("/api/ssh/test", s.handleSSHTest) + + s.mux.HandleFunc("/api/mcp/status", s.handleMCPStatus) + s.mux.HandleFunc("/api/mcp/registry", s.handleMCPRegistry) + s.mux.HandleFunc("/api/lsp/health", s.handleLSPHealth) + s.mux.HandleFunc("/api/lsp/auto-install", s.handleLSPAutoInstall) + s.mux.HandleFunc("/api/lsp/editor-config", s.handleLSPEditorConfig) + s.mux.HandleFunc("/api/skills/validate", s.handleSkillValidate) + s.mux.HandleFunc("/api/skills/test", s.handleSkillTest) + s.mux.HandleFunc("/api/skills/export", s.handleSkillExport) + s.mux.HandleFunc("/api/skills/import", s.handleSkillImport) + s.mux.HandleFunc("/api/dashboard/status", s.handleDashboardStatus) } func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { diff --git a/internal/lsp/lsp.go b/internal/lsp/lsp.go index d39d611..2c68614 100644 --- a/internal/lsp/lsp.go +++ b/internal/lsp/lsp.go @@ -1,9 +1,13 @@ package lsp import ( + "encoding/json" "fmt" "os" "os/exec" + "path/filepath" + "strings" + "time" ) type LSPServer struct { @@ -12,6 +16,10 @@ type LSPServer struct { Command string `json:"command"` InstallCmd string `json:"install_cmd"` Installed bool `json:"installed"` + Version string `json:"version,omitempty"` + Healthy bool `json:"healthy,omitempty"` + Description string `json:"description,omitempty"` + Category string `json:"category,omitempty"` } var knownServers = []LSPServer{ @@ -39,27 +47,131 @@ func ScanServers() []LSPServer { servers[i] = s _, err := exec.LookPath(s.Command) servers[i].Installed = err == nil + servers[i].Version = getInstalledLSPVersion(s.Name) } + + regServers, err := scanLSPRegistryServers() + if err == nil { + servers = append(servers, regServers...) + } + return servers } +func scanLSPRegistryServers() ([]LSPServer, error) { + reg, err := LoadLSPRegistry() + if err != nil { + return nil, err + } + + knownNames := map[string]bool{} + for _, s := range knownServers { + knownNames[s.Name] = true + } + + var servers []LSPServer + for _, rs := range reg.Servers { + if knownNames[rs.Name] { + continue + } + servers = append(servers, LSPServer{ + Name: rs.Name, + Language: rs.Language, + Command: rs.Command, + InstallCmd: rs.InstallCmd, + Installed: isLSPCommandAvailable(rs.Command), + Description: rs.Description, + Category: rs.Category, + Version: getInstalledLSPVersion(rs.Name), + }) + } + return servers, nil +} + +func isLSPCommandAvailable(cmd string) bool { + _, err := exec.LookPath(cmd) + return err == nil +} + +func getInstalledLSPVersion(name string) string { + home, _ := os.UserHomeDir() + if home == "" { + return "" + } + receiptPath := filepath.Join(home, ".muyue", "receipts", "lsp", name+".json") + data, err := os.ReadFile(receiptPath) + if err != nil { + return "" + } + var receipt struct { + Version string `json:"version"` + } + if json.Unmarshal(data, &receipt) == nil { + return receipt.Version + } + return "" +} + +func saveLSPReceipt(name, version string) error { + home, _ := os.UserHomeDir() + if home == "" { + return nil + } + receiptDir := filepath.Join(home, ".muyue", "receipts", "lsp") + os.MkdirAll(receiptDir, 0755) + + receipt := struct { + Name string `json:"name"` + Version string `json:"version"` + UpdatedAt string `json:"updated_at"` + }{ + Name: name, + Version: version, + UpdatedAt: time.Now().Format(time.RFC3339), + } + + data, _ := json.MarshalIndent(receipt, "", " ") + return os.WriteFile(filepath.Join(receiptDir, name+".json"), data, 0644) +} + func InstallServer(name string) error { for _, s := range knownServers { if s.Name == name { - if s.InstallCmd == "" { - return fmt.Errorf("%s has no auto-install command, install manually", name) - } - cmd := exec.Command("bash", "-c", s.InstallCmd) - cmd.Env = os.Environ() - if output, err := cmd.CombinedOutput(); err != nil { - return fmt.Errorf("install %s: %s: %w", name, string(output), err) - } - return nil + return doInstallLSP(s) } } + + reg, err := LoadLSPRegistry() + if err == nil { + for _, s := range reg.Servers { + if s.Name == name { + return doInstallLSP(LSPServer{ + Name: s.Name, + Language: s.Language, + Command: s.Command, + InstallCmd: s.InstallCmd, + }) + } + } + } + return fmt.Errorf("unknown LSP server: %s", name) } +func doInstallLSP(s LSPServer) error { + if s.InstallCmd == "" { + return fmt.Errorf("%s has no auto-install command, install manually", s.Name) + } + cmd := exec.Command("bash", "-c", s.InstallCmd) + cmd.Env = os.Environ() + if output, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("install %s: %s: %w", s.Name, string(output), err) + } + + saveLSPReceipt(s.Name, "latest") + return nil +} + func InstallForLanguages(languages []string) []LSPServer { langMap := map[string][]string{ "go": {"gopls"}, @@ -101,3 +213,100 @@ func InstallForLanguages(languages []string) []LSPServer { return results } + +func AutoInstallForProject(projectDir string) ([]LSPServer, error) { + languages := DetectProjectLanguages(projectDir) + if len(languages) == 0 { + return nil, nil + } + results := InstallForLanguages(languages) + return results, nil +} + +func HealthCheck(name string) (bool, string) { + for _, s := range knownServers { + if s.Name == name { + return healthCheckServer(s) + } + } + return false, "unknown server" +} + +func healthCheckServer(s LSPServer) (bool, string) { + path, err := exec.LookPath(s.Command) + if err != nil { + return false, fmt.Sprintf("command %q not found in PATH", s.Command) + } + + versionArgs := map[string][]string{ + "gopls": {"version"}, + "pyright": {"--version"}, + "typescript-language-server": {"--version"}, + "rust-analyzer": {"--version"}, + "clangd": {"--version"}, + "lua-language-server": {"--version"}, + "bash-language-server": {"--version"}, + "yaml-language-server": {"--version"}, + } + + if args, ok := versionArgs[s.Command]; ok { + cmd := exec.Command(path, args...) + output, err := cmd.CombinedOutput() + if err != nil { + return true, fmt.Sprintf("installed at %s but version check failed", path) + } + version := strings.TrimSpace(string(output)) + if idx := strings.Index(version, "\n"); idx > 0 { + version = version[:idx] + } + saveLSPReceipt(s.Name, version) + return true, version + } + + return true, fmt.Sprintf("installed at %s", path) +} + +func GenerateEditorConfigs(servers []LSPServer, editor string, homeDir string) (string, error) { + if homeDir == "" { + home, _ := os.UserHomeDir() + homeDir = home + } + + reg, err := LoadLSPRegistry() + if err != nil { + return "", err + } + + regMap := map[string]RegistryEntry{} + for _, s := range reg.Servers { + regMap[s.Name] = s + } + + var regEntries []RegistryEntry + for _, s := range servers { + if re, ok := regMap[s.Name]; ok { + regEntries = append(regEntries, re) + } + } + + switch editor { + case "neovim", "nvim": + return GenerateNeovimConfig(regEntries), nil + case "helix", "hx": + return GenerateHelixConfig(regEntries), nil + case "vscode", "code", "cursor": + exts := GenerateVSCodeRecommendations(regEntries) + var b strings.Builder + b.WriteString("{\n \"recommendations\": [\n") + for i, ext := range exts { + if i > 0 { + b.WriteString(",\n") + } + b.WriteString(" \"" + ext + "\"") + } + b.WriteString("\n ]\n}") + return b.String(), nil + default: + return "", fmt.Errorf("unsupported editor: %s", editor) + } +} diff --git a/internal/lsp/registry.go b/internal/lsp/registry.go new file mode 100644 index 0000000..caff04e --- /dev/null +++ b/internal/lsp/registry.go @@ -0,0 +1,333 @@ +package lsp + +import ( + "os" + "path/filepath" + "time" + + "gopkg.in/yaml.v3" +) + +type RegistryEntry struct { + Name string `yaml:"name" json:"name"` + Language string `yaml:"language" json:"language"` + Description string `yaml:"description" json:"description"` + Command string `yaml:"command" json:"command"` + InstallCmd string `yaml:"install_cmd" json:"install_cmd"` + InstallType string `yaml:"install_type" json:"install_type"` + Category string `yaml:"category" json:"category"` + FilePatterns []string `yaml:"file_patterns,omitempty" json:"file_patterns,omitempty"` + ConfigFiles []string `yaml:"config_files,omitempty" json:"config_files,omitempty"` + Tags []string `yaml:"tags,omitempty" json:"tags,omitempty"` + HomePage string `yaml:"homepage,omitempty" json:"homepage,omitempty"` + + NeovimSetup string `yaml:"neovim_setup,omitempty" json:"neovim_setup,omitempty"` + HelixLanguage string `yaml:"helix_language,omitempty" json:"helix_language,omitempty"` +} + +type LSPRegistry struct { + SchemaVersion string `yaml:"schema_version"` + UpdatedAt time.Time `yaml:"updated_at"` + Servers []RegistryEntry `yaml:"servers"` +} + +func DefaultLSPRegistry() *LSPRegistry { + return &LSPRegistry{ + SchemaVersion: "v1", + UpdatedAt: time.Now(), + Servers: []RegistryEntry{ + { + Name: "gopls", Language: "go", Description: "Go language server", + Command: "gopls", InstallCmd: "go install golang.org/x/tools/gopls@latest", + InstallType: "go", Category: "lsp", FilePatterns: []string{"*.go"}, + ConfigFiles: []string{"go.mod"}, Tags: []string{"go", "linting", "completion"}, + HomePage: "https://github.com/golang/tools", + NeovimSetup: `lspconfig.gopls.setup{}`, + HelixLanguage: "go", + }, + { + Name: "pyright", Language: "python", Description: "Python type checker and language server", + Command: "pyright", InstallCmd: "npm install -g pyright", + InstallType: "npm", Category: "lsp", FilePatterns: []string{"*.py", "*.pyi"}, + ConfigFiles: []string{"requirements.txt", "pyproject.toml", "setup.py", "Pipfile"}, + Tags: []string{"python", "type-checking"}, HomePage: "https://github.com/microsoft/pyright", + NeovimSetup: `lspconfig.pyright.setup{}`, + HelixLanguage: "python", + }, + { + Name: "typescript-language-server", Language: "typescript", Description: "TypeScript and JavaScript language server", + Command: "typescript-language-server", InstallCmd: "npm install -g typescript-language-server typescript", + InstallType: "npm", Category: "lsp", FilePatterns: []string{"*.ts", "*.tsx", "*.js", "*.jsx"}, + ConfigFiles: []string{"tsconfig.json", "package.json"}, + Tags: []string{"typescript", "javascript"}, HomePage: "https://github.com/typescript-language-server/typescript-language-server", + NeovimSetup: `lspconfig.tsserver.setup{}`, + HelixLanguage: "typescript", + }, + { + Name: "vscode-json-language-server", Language: "json", Description: "JSON language server", + Command: "vscode-json-language-server", InstallCmd: "npm install -g vscode-langservers-extracted", + InstallType: "npm", Category: "lsp", FilePatterns: []string{"*.json", "*.jsonc"}, + Tags: []string{"json"}, NeovimSetup: `lspconfig.jsonls.setup{}`, + HelixLanguage: "json", + }, + { + Name: "vscode-html-language-server", Language: "html", Description: "HTML language server", + Command: "vscode-html-language-server", InstallCmd: "npm install -g vscode-langservers-extracted", + InstallType: "npm", Category: "lsp", FilePatterns: []string{"*.html", "*.htm"}, + Tags: []string{"html"}, NeovimSetup: `lspconfig.html.setup{}`, + HelixLanguage: "html", + }, + { + Name: "vscode-css-language-server", Language: "css", Description: "CSS/SCSS/LESS language server", + Command: "vscode-css-language-server", InstallCmd: "npm install -g vscode-langservers-extracted", + InstallType: "npm", Category: "lsp", FilePatterns: []string{"*.css", "*.scss", "*.less"}, + Tags: []string{"css"}, NeovimSetup: `lspconfig.cssls.setup{}`, + HelixLanguage: "css", + }, + { + Name: "yaml-language-server", Language: "yaml", Description: "YAML language server", + Command: "yaml-language-server", InstallCmd: "npm install -g yaml-language-server", + InstallType: "npm", Category: "lsp", FilePatterns: []string{"*.yml", "*.yaml"}, + Tags: []string{"yaml"}, NeovimSetup: `lspconfig.yamlls.setup{}`, + HelixLanguage: "yaml", + }, + { + Name: "bash-language-server", Language: "bash", Description: "Bash language server", + Command: "bash-language-server", InstallCmd: "npm install -g bash-language-server", + InstallType: "npm", Category: "lsp", FilePatterns: []string{"*.sh", "*.bash"}, + Tags: []string{"bash", "shell"}, NeovimSetup: `lspconfig.bashls.setup{}`, + HelixLanguage: "bash", + }, + { + Name: "rust-analyzer", Language: "rust", Description: "Rust language server", + Command: "rust-analyzer", InstallCmd: "rustup component add rust-analyzer", + InstallType: "rustup", Category: "lsp", FilePatterns: []string{"*.rs"}, + ConfigFiles: []string{"Cargo.toml"}, Tags: []string{"rust"}, + HomePage: "https://github.com/rust-lang/rust-analyzer", + NeovimSetup: `lspconfig.rust_analyzer.setup{}`, + HelixLanguage: "rust", + }, + { + Name: "clangd", Language: "c/c++", Description: "C/C++ language server", + Command: "clangd", InstallCmd: "", InstallType: "system", + Category: "lsp", FilePatterns: []string{"*.c", "*.cpp", "*.h", "*.hpp"}, + ConfigFiles: []string{"CMakeLists.txt", "Makefile"}, Tags: []string{"c", "cpp"}, + NeovimSetup: `lspconfig.clangd.setup{}`, + HelixLanguage: "c", + }, + { + Name: "lua-language-server", Language: "lua", Description: "Lua language server", + Command: "lua-language-server", InstallCmd: "npm install -g lua-language-server", + InstallType: "npm", Category: "lsp", FilePatterns: []string{"*.lua"}, + Tags: []string{"lua"}, NeovimSetup: `lspconfig.lua_ls.setup{}`, + HelixLanguage: "lua", + }, + { + Name: "dockerfile-language-server", Language: "dockerfile", Description: "Dockerfile language server", + Command: "docker-langserver", InstallCmd: "npm install -g dockerfile-language-server-nodejs", + InstallType: "npm", Category: "lsp", FilePatterns: []string{"Dockerfile", "Dockerfile.*"}, + Tags: []string{"docker"}, NeovimSetup: `lspconfig.dockerls.setup{}`, + HelixLanguage: "dockerfile", + }, + { + Name: "tailwindcss-language-server", Language: "tailwind", Description: "Tailwind CSS language server", + Command: "tailwindcss-language-server", InstallCmd: "npm install -g @tailwindcss/language-server", + InstallType: "npm", Category: "lsp", FilePatterns: []string{"*.html", "*.tsx", "*.jsx"}, + ConfigFiles: []string{"tailwind.config.js", "tailwind.config.ts"}, + Tags: []string{"tailwind", "css"}, NeovimSetup: `lspconfig.tailwindcss.setup{}`, + }, + { + Name: "svelte-language-server", Language: "svelte", Description: "Svelte language server", + Command: "svelteserver", InstallCmd: "npm install -g svelte-language-server", + InstallType: "npm", Category: "lsp", FilePatterns: []string{"*.svelte"}, + Tags: []string{"svelte"}, NeovimSetup: `lspconfig.svelte.setup{}`, + HelixLanguage: "svelte", + }, + { + Name: "vue-language-server", Language: "vue", Description: "Vue language server", + Command: "vue-language-server", InstallCmd: "npm install -g @vue/language-server", + InstallType: "npm", Category: "lsp", FilePatterns: []string{"*.vue"}, + Tags: []string{"vue"}, NeovimSetup: `lspconfig.vuels.setup{}`, + }, + { + Name: "golangci-lint-langserver", Language: "go-lint", Description: "Go linter language server", + Command: "golangci-lint-langserver", InstallCmd: "go install github.com/nametake/golangci-lint-langserver@latest", + InstallType: "go", Category: "lsp", FilePatterns: []string{"*.go"}, + Tags: []string{"go", "linting"}, + }, + }, + } +} + +var lspRegistryPath string + +func init() { + home, _ := os.UserHomeDir() + if home != "" { + lspRegistryPath = filepath.Join(home, ".muyue", "lsp-registry.yaml") + } +} + +func SetLSPRegistryPath(p string) { + lspRegistryPath = p +} + +func LoadLSPRegistry() (*LSPRegistry, error) { + if lspRegistryPath == "" { + return DefaultLSPRegistry(), nil + } + + data, err := os.ReadFile(lspRegistryPath) + if err != nil { + return DefaultLSPRegistry(), nil + } + + var reg LSPRegistry + if err := yaml.Unmarshal(data, ®); err != nil { + return nil, err + } + return ®, nil +} + +func SaveLSPRegistry(reg *LSPRegistry) error { + if lspRegistryPath == "" { + return nil + } + reg.UpdatedAt = time.Now() + data, err := yaml.Marshal(reg) + if err != nil { + return err + } + os.MkdirAll(filepath.Dir(lspRegistryPath), 0755) + return os.WriteFile(lspRegistryPath, data, 0644) +} + +func InitLSPRegistry() error { + if lspRegistryPath == "" { + return nil + } + if _, err := os.Stat(lspRegistryPath); err == nil { + return nil + } + return SaveLSPRegistry(DefaultLSPRegistry()) +} + +func DetectProjectLanguages(projectDir string) []string { + if projectDir == "" { + return nil + } + + langDetectors := map[string][]string{ + "go": {"go.mod", "go.sum"}, + "python": {"requirements.txt", "pyproject.toml", "setup.py", "Pipfile", "uv.lock"}, + "typescript": {"tsconfig.json", "package.json"}, + "javascript": {"package.json"}, + "rust": {"Cargo.toml"}, + "ruby": {"Gemfile"}, + "java": {"pom.xml", "build.gradle"}, + "c": {"CMakeLists.txt", "Makefile"}, + "cpp": {"CMakeLists.txt"}, + "php": {"composer.json"}, + "lua": {".luarc.json"}, + "docker": {"Dockerfile"}, + } + + extDetectors := map[string]string{ + ".go": "go", + ".py": "python", + ".rs": "rust", + ".ts": "typescript", + ".tsx": "typescript", + ".js": "javascript", + ".jsx": "javascript", + ".rb": "ruby", + ".java": "java", + ".c": "c", + ".cpp": "cpp", + ".h": "c", + ".lua": "lua", + ".vue": "vue", + ".svelte": "svelte", + } + + detected := map[string]bool{} + for lang, files := range langDetectors { + for _, f := range files { + if _, err := os.Stat(filepath.Join(projectDir, f)); err == nil { + detected[lang] = true + break + } + } + } + + entries, err := os.ReadDir(projectDir) + if err == nil { + for _, e := range entries { + if e.IsDir() { + continue + } + ext := filepath.Ext(e.Name()) + if lang, ok := extDetectors[ext]; ok { + detected[lang] = true + } + } + } + + var languages []string + for lang := range detected { + languages = append(languages, lang) + } + return languages +} + +func GenerateNeovimConfig(servers []RegistryEntry) string { + config := `-- Generated by Muyue LSP Manager +-- Add to your init.lua or require from lspconfig setup +local lspconfig = require('lspconfig') + +` + for _, s := range servers { + if s.NeovimSetup != "" { + config += s.NeovimSetup + "\n" + } + } + return config +} + +func GenerateHelixConfig(servers []RegistryEntry) string { + config := `# Generated by Muyue LSP Manager +# Add to ~/.config/helix/languages.toml + +` + for _, s := range servers { + if s.HelixLanguage != "" { + config += "[[language]]\n" + config += "name = \"" + s.HelixLanguage + "\"\n" + config += "language-servers = [\"" + s.Name + "\"]\n\n" + } + } + return config +} + +func GenerateVSCodeRecommendations(servers []RegistryEntry) []string { + extensionMap := map[string][]string{ + "gopls": {"golang.go"}, + "pyright": {"ms-python.python", "ms-python.vscode-pylance"}, + "typescript-language-server": {"ms-vscode.vscode-typescript-next"}, + "rust-analyzer": {"rust-lang.rust-analyzer"}, + "lua-language-server": {"sumneko.lua"}, + "tailwindcss-language-server": {"bradlc.vscode-tailwindcss"}, + "svelte-language-server": {"svelte.svelte-vscode"}, + "vue-language-server": {"vue.volar"}, + "yaml-language-server": {"redhat.vscode-yaml"}, + "bash-language-server": {"mads-hartmann.bash-ide-vscode"}, + } + + var extensions []string + for _, s := range servers { + if exts, ok := extensionMap[s.Name]; ok { + extensions = append(extensions, exts...) + } + } + return extensions +} diff --git a/internal/lsp/registry_test.go b/internal/lsp/registry_test.go new file mode 100644 index 0000000..e697484 --- /dev/null +++ b/internal/lsp/registry_test.go @@ -0,0 +1,142 @@ +package lsp + +import ( + "os" + "path/filepath" + "testing" +) + +func TestDefaultLSPRegistry(t *testing.T) { + reg := DefaultLSPRegistry() + if reg.SchemaVersion != "v1" { + t.Errorf("Expected v1, got %s", reg.SchemaVersion) + } + if len(reg.Servers) == 0 { + t.Error("Default LSP registry should have servers") + } + + names := map[string]bool{} + for _, s := range reg.Servers { + if names[s.Name] { + t.Errorf("Duplicate server name: %s", s.Name) + } + names[s.Name] = true + if s.Command == "" { + t.Errorf("Server %s missing command", s.Name) + } + if s.Language == "" { + t.Errorf("Server %s missing language", s.Name) + } + } +} + +func TestSaveAndLoadLSPRegistry(t *testing.T) { + tmpDir := t.TempDir() + SetLSPRegistryPath(filepath.Join(tmpDir, "lsp-registry.yaml")) + + reg := DefaultLSPRegistry() + if err := SaveLSPRegistry(reg); err != nil { + t.Fatalf("SaveLSPRegistry failed: %v", err) + } + + loaded, err := LoadLSPRegistry() + if err != nil { + t.Fatalf("LoadLSPRegistry failed: %v", err) + } + if len(loaded.Servers) != len(reg.Servers) { + t.Errorf("Expected %d servers, got %d", len(reg.Servers), len(loaded.Servers)) + } +} + +func TestInitLSPRegistry(t *testing.T) { + tmpDir := t.TempDir() + SetLSPRegistryPath(filepath.Join(tmpDir, "lsp-reg.yaml")) + + if err := InitLSPRegistry(); err != nil { + t.Fatalf("InitLSPRegistry failed: %v", err) + } + + if _, err := os.Stat(filepath.Join(tmpDir, "lsp-reg.yaml")); os.IsNotExist(err) { + t.Error("LSP registry file should be created") + } +} + +func TestDetectProjectLanguages(t *testing.T) { + tmpDir := t.TempDir() + + os.WriteFile(filepath.Join(tmpDir, "go.mod"), []byte("module test\ngo 1.24\n"), 0644) + os.WriteFile(filepath.Join(tmpDir, "package.json"), []byte(`{"name": "test"}`), 0644) + + languages := DetectProjectLanguages(tmpDir) + if len(languages) == 0 { + t.Error("Should detect languages") + } + + langSet := map[string]bool{} + for _, l := range languages { + langSet[l] = true + } + if !langSet["go"] { + t.Error("Should detect Go") + } + if !langSet["typescript"] { + t.Error("Should detect TypeScript/JS from package.json") + } +} + +func TestDetectProjectLanguagesEmpty(t *testing.T) { + tmpDir := t.TempDir() + languages := DetectProjectLanguages(tmpDir) + if len(languages) != 0 { + t.Errorf("Empty dir should detect no languages, got %v", languages) + } +} + +func TestGenerateNeovimConfig(t *testing.T) { + servers := []RegistryEntry{ + {Name: "gopls", Language: "go", NeovimSetup: "lspconfig.gopls.setup{}"}, + {Name: "pyright", Language: "python", NeovimSetup: "lspconfig.pyright.setup{}"}, + } + + config := GenerateNeovimConfig(servers) + if config == "" { + t.Error("Config should not be empty") + } + if len(config) < 50 { + t.Error("Config seems too short") + } +} + +func TestGenerateHelixConfig(t *testing.T) { + servers := []RegistryEntry{ + {Name: "gopls", Language: "go", HelixLanguage: "go"}, + } + + config := GenerateHelixConfig(servers) + if config == "" { + t.Error("Config should not be empty") + } +} + +func TestGenerateVSCodeRecommendations(t *testing.T) { + servers := []RegistryEntry{ + {Name: "gopls", Language: "go"}, + {Name: "pyright", Language: "python"}, + } + + exts := GenerateVSCodeRecommendations(servers) + if len(exts) == 0 { + t.Error("Should return some extensions") + } +} + +func TestHealthCheck(t *testing.T) { + healthy, detail := HealthCheck("gopls") + if healthy && detail == "" { + t.Error("If healthy, should have version detail") + } +} + +func TestHealthCheckUnknown(t *testing.T) { + _, _ = HealthCheck("nonexistent-server") +} diff --git a/internal/mcp/mcp.go b/internal/mcp/mcp.go index 3bcced8..0a464a5 100644 --- a/internal/mcp/mcp.go +++ b/internal/mcp/mcp.go @@ -6,17 +6,22 @@ import ( "os" "os/exec" "path/filepath" + "strings" + "time" "github.com/muyue/muyue/internal/config" ) type MCPServer struct { - Name string `json:"name"` - Command string `json:"command"` - Args []string `json:"args"` - Env map[string]string `json:"env,omitempty"` - Installed bool `json:"installed"` - Category string `json:"category"` + Name string `json:"name"` + Command string `json:"command"` + Args []string `json:"args"` + Env map[string]string `json:"env,omitempty"` + Installed bool `json:"installed"` + Category string `json:"category"` + Description string `json:"description,omitempty"` + Version string `json:"version,omitempty"` + Status string `json:"status,omitempty"` } type mcpEntry struct { @@ -47,10 +52,52 @@ func ScanServers() []MCPServer { servers[i] = s _, err := exec.LookPath(s.Command) servers[i].Installed = err == nil + servers[i].Version = GetInstalledVersion(s.Name) } + + regServers, err := scanRegistryServers() + if err == nil { + servers = append(servers, regServers...) + } + return servers } +func scanRegistryServers() ([]MCPServer, error) { + reg, err := LoadRegistry() + if err != nil { + return nil, err + } + + knownNames := map[string]bool{} + for _, s := range knownMCPServers { + knownNames[s.Name] = true + } + + var servers []MCPServer + for _, rs := range reg.Servers { + if knownNames[rs.Name] { + continue + } + servers = append(servers, MCPServer{ + Name: rs.Name, + Command: rs.Command, + Args: rs.Args, + Env: rs.Env, + Category: rs.Category, + Description: rs.Description, + Installed: isCommandAvailable(rs.Command), + Version: GetInstalledVersion(rs.Name), + }) + } + return servers, nil +} + +func isCommandAvailable(cmd string) bool { + _, err := exec.LookPath(cmd) + return err == nil +} + func getCoreEntries(homeDir string) []mcpEntry { return []mcpEntry{ {"filesystem", "npx", []string{"-y", "@modelcontextprotocol/server-filesystem", filepath.Join(homeDir, "projects")}, nil}, @@ -98,7 +145,8 @@ func writeMCPConfig(configPath string, mcpKey string, entries []mcpEntry) error "args": e.args, } if len(e.env) > 0 { - entry["env"] = e.env + resolved := ResolveEnv(e.env, nil) + entry["env"] = resolved } mcpMap[e.name] = entry } @@ -110,7 +158,49 @@ func writeMCPConfig(configPath string, mcpKey string, entries []mcpEntry) error return err } - return os.WriteFile(configPath, out, 0600) + if err := os.WriteFile(configPath, out, 0600); err != nil { + return err + } + + return ValidateConfig(configPath) +} + +func writeMCPConfigForEditor(editor EditorConfig, entries []mcpEntry) error { + configDir := filepath.Dir(editor.ConfigPath) + if err := os.MkdirAll(configDir, 0700); err != nil { + return fmt.Errorf("create config dir %s: %w", editor.Name, err) + } + + existing := map[string]interface{}{} + data, err := os.ReadFile(editor.ConfigPath) + if err == nil { + _ = json.Unmarshal(data, &existing) + } + + mcpMap := map[string]interface{}{} + for _, e := range entries { + if editor.TransformCommand != nil { + mcpMap[e.name] = editor.TransformCommand(e) + } else { + entry := map[string]interface{}{ + "command": e.cmd, + "args": e.args, + } + if len(e.env) > 0 { + entry["env"] = e.env + } + mcpMap[e.name] = entry + } + } + + existing[editor.ConfigKey] = mcpMap + + out, err := json.MarshalIndent(existing, "", " ") + if err != nil { + return err + } + + return os.WriteFile(editor.ConfigPath, out, 0600) } func GenerateCrushMCPConfig(cfg *config.MuyueConfig, homeDir string) error { @@ -140,19 +230,154 @@ func GenerateClaudeMCPConfig(cfg *config.MuyueConfig, homeDir string) error { return writeMCPConfig(configPath, "mcpServers", entries) } +func GenerateCursorMCPConfig(cfg *config.MuyueConfig, homeDir string) error { + if homeDir == "" { + home, _ := os.UserHomeDir() + homeDir = home + } + + core := getCoreEntries(homeDir) + entries := withProviderEntries(core, cfg, nil) + editor := EditorConfig{ + Name: "cursor", + ConfigPath: filepath.Join(homeDir, ".cursor", "mcp.json"), + ConfigKey: "mcpServers", + Format: "json", + TransformCommand: func(e mcpEntry) interface{} { + m := map[string]interface{}{ + "type": "stdio", + "command": e.cmd, + "args": e.args, + } + if len(e.env) > 0 { + m["env"] = e.env + } + return m + }, + } + return writeMCPConfigForEditor(editor, entries) +} + +func GenerateVSCodeMCPConfig(cfg *config.MuyueConfig, homeDir string) error { + if homeDir == "" { + home, _ := os.UserHomeDir() + homeDir = home + } + + core := getCoreEntries(homeDir) + entries := withProviderEntries(core, cfg, nil) + editor := EditorConfig{ + Name: "vscode", + ConfigPath: filepath.Join(homeDir, ".vscode", "mcp.json"), + ConfigKey: "servers", + Format: "json", + } + return writeMCPConfigForEditor(editor, entries) +} + +func GenerateWindsurfMCPConfig(cfg *config.MuyueConfig, homeDir string) error { + if homeDir == "" { + home, _ := os.UserHomeDir() + homeDir = home + } + + core := getCoreEntries(homeDir) + entries := withProviderEntries(core, cfg, nil) + editor := EditorConfig{ + Name: "windsurf", + ConfigPath: filepath.Join(homeDir, ".windsurf", "mcp.json"), + ConfigKey: "mcpServers", + Format: "json", + } + return writeMCPConfigForEditor(editor, entries) +} + func ConfigureAll(cfg *config.MuyueConfig) error { home, err := os.UserHomeDir() if err != nil { return fmt.Errorf("get home dir: %w", err) } - if err := GenerateCrushMCPConfig(cfg, home); err != nil { - return fmt.Errorf("crush MCP config: %w", err) + editors := []struct { + name string + fn func(*config.MuyueConfig, string) error + }{ + {"crush", GenerateCrushMCPConfig}, + {"claude", GenerateClaudeMCPConfig}, + {"cursor", GenerateCursorMCPConfig}, + {"vscode", GenerateVSCodeMCPConfig}, + {"windsurf", GenerateWindsurfMCPConfig}, } - if err := GenerateClaudeMCPConfig(cfg, home); err != nil { - return fmt.Errorf("claude MCP config: %w", err) + var errs []string + for _, e := range editors { + if err := e.fn(cfg, home); err != nil { + errs = append(errs, fmt.Sprintf("%s: %s", e.name, err)) + } + } + + SaveReceipt("all", time.Now().Format("2006-01-02")) + + if len(errs) > 0 { + return fmt.Errorf("MCP config errors: %s", strings.Join(errs, "; ")) } return nil } + +func ConfigureForEditor(cfg *config.MuyueConfig, editorName string) error { + home, err := os.UserHomeDir() + if err != nil { + return fmt.Errorf("get home dir: %w", err) + } + + switch editorName { + case "crush": + return GenerateCrushMCPConfig(cfg, home) + case "claude", "claude-code": + return GenerateClaudeMCPConfig(cfg, home) + case "cursor": + return GenerateCursorMCPConfig(cfg, home) + case "vscode", "code": + return GenerateVSCodeMCPConfig(cfg, home) + case "windsurf": + return GenerateWindsurfMCPConfig(cfg, home) + default: + return fmt.Errorf("unknown editor: %s (supported: crush, claude-code, cursor, vscode, windsurf)", editorName) + } +} + +func DetectInstalledEditors(homeDir string) []string { + if homeDir == "" { + home, _ := os.UserHomeDir() + homeDir = home + } + + editors := []struct { + name string + path string + }{ + {"crush", filepath.Join(homeDir, ".config", "crush", "crush.json")}, + {"claude-code", filepath.Join(homeDir, ".claude.json")}, + {"cursor", filepath.Join(homeDir, ".cursor")}, + {"vscode", filepath.Join(homeDir, ".vscode")}, + {"windsurf", filepath.Join(homeDir, ".windsurf")}, + } + + var detected []string + for _, e := range editors { + if _, err := os.Stat(e.path); err == nil { + detected = append(detected, e.name) + } + } + return detected +} + +func GetAllStatuses() []MCPStatus { + servers := ScanServers() + statuses := make([]MCPStatus, len(servers)) + for i, s := range servers { + statuses[i] = CheckServerStatus(s.Name) + } + return statuses +} diff --git a/internal/mcp/registry.go b/internal/mcp/registry.go new file mode 100644 index 0000000..bd7651a --- /dev/null +++ b/internal/mcp/registry.go @@ -0,0 +1,520 @@ +package mcp + +import ( + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "time" + + "gopkg.in/yaml.v3" +) + +type RegistryServer struct { + Name string `yaml:"name" json:"name"` + Description string `yaml:"description" json:"description"` + Category string `yaml:"category" json:"category"` + Package string `yaml:"package" json:"package"` + Command string `yaml:"command" json:"command"` + Args []string `yaml:"args" json:"args"` + Env map[string]string `yaml:"env,omitempty" json:"env,omitempty"` + RequiredEnv []string `yaml:"required_env,omitempty" json:"required_env,omitempty"` + HomePage string `yaml:"homepage,omitempty" json:"homepage,omitempty"` + Tags []string `yaml:"tags,omitempty" json:"tags,omitempty"` + Version string `yaml:"version,omitempty" json:"version,omitempty"` + InstallType string `yaml:"install_type" json:"install_type"` +} + +type Registry struct { + SchemaVersion string `yaml:"schema_version"` + UpdatedAt time.Time `yaml:"updated_at"` + Servers []RegistryServer `yaml:"servers"` +} + +type MCPStatus struct { + Name string `json:"name"` + Installed bool `json:"installed"` + Running bool `json:"running"` + Healthy bool `json:"healthy"` + Version string `json:"version"` + Error string `json:"error,omitempty"` +} + +type EditorConfig struct { + Name string + ConfigPath string + ConfigKey string + LocalConfigPath string + Format string + TransformCommand func(entry mcpEntry) interface{} +} + +var ( + registryMu sync.RWMutex + registryCache *Registry + registryPath string +) + +func init() { + home, _ := os.UserHomeDir() + if home != "" { + registryPath = filepath.Join(home, ".muyue", "mcp-registry.yaml") + } +} + +func SetRegistryPath(p string) { + registryMu.Lock() + defer registryMu.Unlock() + registryPath = p + registryCache = nil +} + +func DefaultRegistry() *Registry { + return &Registry{ + SchemaVersion: "v1", + UpdatedAt: time.Now(), + Servers: []RegistryServer{ + { + Name: "filesystem", Description: "File system operations for AI tools", + Category: "core", Package: "@modelcontextprotocol/server-filesystem", + Command: "npx", Args: []string{"-y", "@modelcontextprotocol/server-filesystem"}, + InstallType: "npm", Tags: []string{"files", "core"}, + }, + { + Name: "github", Description: "GitHub API integration", + Category: "vcs", Package: "@modelcontextprotocol/server-github", + Command: "npx", Args: []string{"-y", "@modelcontextprotocol/server-github"}, + Env: map[string]string{"GITHUB_PERSONAL_ACCESS_TOKEN": ""}, + RequiredEnv: []string{"GITHUB_PERSONAL_ACCESS_TOKEN"}, + InstallType: "npm", Tags: []string{"github", "git"}, + }, + { + Name: "git", Description: "Git repository operations", + Category: "vcs", Package: "@modelcontextprotocol/server-git", + Command: "npx", Args: []string{"-y", "@modelcontextprotocol/server-git"}, + InstallType: "npm", Tags: []string{"git"}, + }, + { + Name: "fetch", Description: "Web fetching and HTTP requests", + Category: "web", Package: "@modelcontextprotocol/server-fetch", + Command: "npx", Args: []string{"-y", "@modelcontextprotocol/server-fetch"}, + InstallType: "npm", Tags: []string{"web", "http"}, + }, + { + Name: "memory", Description: "Persistent memory/knowledge graph", + Category: "core", Package: "@modelcontextprotocol/server-memory", + Command: "npx", Args: []string{"-y", "@modelcontextprotocol/server-memory"}, + InstallType: "npm", Tags: []string{"memory", "core"}, + }, + { + Name: "sequential-thinking", Description: "Structured reasoning and chain-of-thought", + Category: "ai", Package: "@modelcontextprotocol/server-sequential-thinking", + Command: "npx", Args: []string{"-y", "@modelcontextprotocol/server-sequential-thinking"}, + InstallType: "npm", Tags: []string{"ai", "reasoning"}, + }, + { + Name: "brave-search", Description: "Web search via Brave Search API", + Category: "web", Package: "@modelcontextprotocol/server-brave-search", + Command: "npx", Args: []string{"-y", "@modelcontextprotocol/server-brave-search"}, + Env: map[string]string{"BRAVE_API_KEY": ""}, + RequiredEnv: []string{"BRAVE_API_KEY"}, + InstallType: "npm", Tags: []string{"search", "web"}, + }, + { + Name: "sqlite", Description: "SQLite database operations", + Category: "database", Package: "@modelcontextprotocol/server-sqlite", + Command: "npx", Args: []string{"-y", "@modelcontextprotocol/server-sqlite"}, + InstallType: "npm", Tags: []string{"database", "sqlite"}, + }, + { + Name: "postgres", Description: "PostgreSQL database operations", + Category: "database", Package: "@modelcontextprotocol/server-postgres", + Command: "npx", Args: []string{"-y", "@modelcontextprotocol/server-postgres"}, + InstallType: "npm", Tags: []string{"database", "postgres"}, + }, + { + Name: "docker", Description: "Docker container management", + Category: "devops", Package: "@modelcontextprotocol/server-docker", + Command: "npx", Args: []string{"-y", "@modelcontextprotocol/server-docker"}, + InstallType: "npm", Tags: []string{"docker", "devops"}, + }, + { + Name: "minimax-web-search", Description: "Web search via MiniMax API", + Category: "ai", Package: "@minimax/mcp-web-search", + Command: "npx", Args: []string{"-y", "@minimax/mcp-web-search"}, + Env: map[string]string{"MINIMAX_API_KEY": ""}, + RequiredEnv: []string{"MINIMAX_API_KEY"}, + InstallType: "npm", Tags: []string{"ai", "search"}, + }, + { + Name: "minimax-image", Description: "Image understanding via MiniMax API", + Category: "ai", Package: "@minimax/mcp-image-understanding", + Command: "npx", Args: []string{"-y", "@minimax/mcp-image-understanding"}, + Env: map[string]string{"MINIMAX_API_KEY": ""}, + RequiredEnv: []string{"MINIMAX_API_KEY"}, + InstallType: "npm", Tags: []string{"ai", "image"}, + }, + { + Name: "puppeteer", Description: "Browser automation with Puppeteer", + Category: "web", Package: "@modelcontextprotocol/server-puppeteer", + Command: "npx", Args: []string{"-y", "@modelcontextprotocol/server-puppeteer"}, + InstallType: "npm", Tags: []string{"browser", "automation"}, + }, + { + Name: "everything", Description: "Test/debug MCP server with all features", + Category: "testing", Package: "@modelcontextprotocol/server-everything", + Command: "npx", Args: []string{"-y", "@modelcontextprotocol/server-everything"}, + InstallType: "npm", Tags: []string{"testing", "debug"}, + }, + { + Name: "slack", Description: "Slack workspace integration", + Category: "communication", Package: "@modelcontextprotocol/server-slack", + Command: "npx", Args: []string{"-y", "@modelcontextprotocol/server-slack"}, + Env: map[string]string{"SLACK_BOT_TOKEN": ""}, + RequiredEnv: []string{"SLACK_BOT_TOKEN"}, + InstallType: "npm", Tags: []string{"slack", "communication"}, + }, + { + Name: "google-maps", Description: "Google Maps integration", + Category: "web", Package: "@modelcontextprotocol/server-google-maps", + Command: "npx", Args: []string{"-y", "@modelcontextprotocol/server-google-maps"}, + Env: map[string]string{"GOOGLE_MAPS_API_KEY": ""}, + RequiredEnv: []string{"GOOGLE_MAPS_API_KEY"}, + InstallType: "npm", Tags: []string{"maps", "location"}, + }, + }, + } +} + +func LoadRegistry() (*Registry, error) { + registryMu.RLock() + if registryCache != nil { + defer registryMu.RUnlock() + return registryCache, nil + } + registryMu.RUnlock() + + reg, err := loadRegistryFromDisk() + if err != nil { + defaultReg := DefaultRegistry() + registryMu.Lock() + registryCache = defaultReg + registryMu.Unlock() + return defaultReg, nil + } + + registryMu.Lock() + registryCache = reg + registryMu.Unlock() + return reg, nil +} + +func loadRegistryFromDisk() (*Registry, error) { + if registryPath == "" { + return nil, fmt.Errorf("registry path not set") + } + + data, err := os.ReadFile(registryPath) + if err != nil { + return nil, err + } + + var reg Registry + if err := yaml.Unmarshal(data, ®); err != nil { + return nil, fmt.Errorf("parse registry: %w", err) + } + + return ®, nil +} + +func SaveRegistry(reg *Registry) error { + if registryPath == "" { + return fmt.Errorf("registry path not set") + } + + reg.UpdatedAt = time.Now() + data, err := yaml.Marshal(reg) + if err != nil { + return fmt.Errorf("marshal registry: %w", err) + } + + if err := os.MkdirAll(filepath.Dir(registryPath), 0755); err != nil { + return err + } + + if err := os.WriteFile(registryPath, data, 0644); err != nil { + return err + } + + registryMu.Lock() + registryCache = reg + registryMu.Unlock() + return nil +} + +func AddToRegistry(server RegistryServer) error { + reg, err := LoadRegistry() + if err != nil { + return err + } + + for _, s := range reg.Servers { + if s.Name == server.Name { + return fmt.Errorf("server %q already exists in registry", server.Name) + } + } + + reg.Servers = append(reg.Servers, server) + return SaveRegistry(reg) +} + +func RemoveFromRegistry(name string) error { + reg, err := LoadRegistry() + if err != nil { + return err + } + + for i, s := range reg.Servers { + if s.Name == name { + reg.Servers = append(reg.Servers[:i], reg.Servers[i+1:]...) + return SaveRegistry(reg) + } + } + + return fmt.Errorf("server %q not found in registry", name) +} + +func InitRegistry() error { + if _, err := os.Stat(registryPath); err == nil { + return nil + } + return SaveRegistry(DefaultRegistry()) +} + +func ResolveEnv(env map[string]string, providerKeys map[string]string) map[string]string { + resolved := make(map[string]string) + for k, v := range env { + if v != "" { + resolved[k] = v + continue + } + + if providerKeys != nil { + for providerKey, apiKey := range providerKeys { + if strings.EqualFold(k, providerKey) || strings.Contains(strings.ToUpper(k), strings.ToUpper(providerKey)) { + if apiKey != "" { + resolved[k] = apiKey + } + } + } + } + + if resolved[k] == "" { + if envVal := os.Getenv(k); envVal != "" { + resolved[k] = envVal + } + } + } + return resolved +} + +func ValidateConfig(configPath string) error { + data, err := os.ReadFile(configPath) + if err != nil { + return fmt.Errorf("read config: %w", err) + } + + var cfg map[string]interface{} + if err := json.Unmarshal(data, &cfg); err != nil { + return fmt.Errorf("parse config: %w", err) + } + + return nil +} + +func DiscoverNpmServers() ([]RegistryServer, error) { + var servers []RegistryServer + + packages := []struct { + pkg string + name string + desc string + cat string + args []string + }{ + {"@modelcontextprotocol/server-filesystem", "filesystem", "File system operations", "core", []string{"-y", "@modelcontextprotocol/server-filesystem"}}, + {"@modelcontextprotocol/server-github", "github", "GitHub API integration", "vcs", []string{"-y", "@modelcontextprotocol/server-github"}}, + {"@modelcontextprotocol/server-fetch", "fetch", "Web fetching", "web", []string{"-y", "@modelcontextprotocol/server-fetch"}}, + {"@modelcontextprotocol/server-memory", "memory", "Persistent memory", "core", []string{"-y", "@modelcontextprotocol/server-memory"}}, + } + + for _, p := range packages { + servers = append(servers, RegistryServer{ + Name: p.name, + Description: p.desc, + Category: p.cat, + Package: p.pkg, + Command: "npx", + Args: p.args, + InstallType: "npm", + }) + } + + return servers, nil +} + +func GetInstalledVersion(name string) string { + home, _ := os.UserHomeDir() + if home == "" { + return "" + } + receiptPath := filepath.Join(home, ".muyue", "receipts", "mcp", name+".json") + data, err := os.ReadFile(receiptPath) + if err != nil { + return "" + } + var receipt struct { + Version string `json:"version"` + } + if json.Unmarshal(data, &receipt) == nil { + return receipt.Version + } + return "" +} + +func SaveReceipt(name, version string) error { + home, _ := os.UserHomeDir() + if home == "" { + return nil + } + receiptDir := filepath.Join(home, ".muyue", "receipts", "mcp") + os.MkdirAll(receiptDir, 0755) + + receipt := struct { + Name string `json:"name"` + Version string `json:"version"` + UpdatedAt string `json:"updated_at"` + }{ + Name: name, + Version: version, + UpdatedAt: time.Now().Format(time.RFC3339), + } + + data, _ := json.MarshalIndent(receipt, "", " ") + return os.WriteFile(filepath.Join(receiptDir, name+".json"), data, 0644) +} + +func BuildProviderKeyMap(cfg interface{ GetAPIKeys() map[string]string }) map[string]string { + if cfg == nil { + return nil + } + return cfg.GetAPIKeys() +} + +func EditorConfigs(homeDir string) []EditorConfig { + if homeDir == "" { + home, _ := os.UserHomeDir() + homeDir = home + } + + transformStdio := func(e mcpEntry) interface{} { + m := map[string]interface{}{ + "command": e.cmd, + "args": e.args, + } + if len(e.env) > 0 { + m["env"] = e.env + } + return m + } + + transformCursor := func(e mcpEntry) interface{} { + m := map[string]interface{}{ + "type": "stdio", + "command": e.cmd, + "args": e.args, + } + if len(e.env) > 0 { + m["env"] = e.env + } + return m + } + + return []EditorConfig{ + { + Name: "crush", ConfigPath: filepath.Join(homeDir, ".config", "crush", "crush.json"), + ConfigKey: "mcps", Format: "json", TransformCommand: transformStdio, + }, + { + Name: "claude-code", ConfigPath: filepath.Join(homeDir, ".claude.json"), + ConfigKey: "mcpServers", Format: "json", TransformCommand: transformStdio, + }, + { + Name: "cursor", ConfigPath: filepath.Join(homeDir, ".cursor", "mcp.json"), + LocalConfigPath: ".cursor/mcp.json", ConfigKey: "mcpServers", + Format: "json", TransformCommand: transformCursor, + }, + { + Name: "vscode", ConfigPath: filepath.Join(homeDir, ".vscode", "mcp.json"), + LocalConfigPath: ".vscode/mcp.json", ConfigKey: "servers", + Format: "json", TransformCommand: transformStdio, + }, + { + Name: "windsurf", ConfigPath: filepath.Join(homeDir, ".windsurf", "mcp.json"), + ConfigKey: "mcpServers", Format: "json", TransformCommand: transformStdio, + }, + } +} + +func CheckServerStatus(name string) MCPStatus { + status := MCPStatus{Name: name} + + reg, err := LoadRegistry() + if err != nil { + status.Error = "registry unavailable" + return status + } + + var server *RegistryServer + for i := range reg.Servers { + if reg.Servers[i].Name == name { + server = ®.Servers[i] + break + } + } + if server == nil { + status.Error = "not in registry" + return status + } + + _, err = exec.LookPath(server.Command) + if err != nil { + status.Error = fmt.Sprintf("command %q not found", server.Command) + return status + } + status.Installed = true + + status.Version = GetInstalledVersion(name) + + home, _ := os.UserHomeDir() + if home != "" { + crushingPath := filepath.Join(home, ".config", "crush", "crush.json") + data, err := os.ReadFile(crushingPath) + if err == nil { + var cfg map[string]interface{} + if json.Unmarshal(data, &cfg) == nil { + if mcps, ok := cfg["mcps"].(map[string]interface{}); ok { + if _, exists := mcps[name]; exists { + status.Running = true + status.Healthy = true + } + } + } + } + } + + return status +} diff --git a/internal/mcp/registry_test.go b/internal/mcp/registry_test.go new file mode 100644 index 0000000..e8f9a42 --- /dev/null +++ b/internal/mcp/registry_test.go @@ -0,0 +1,228 @@ +package mcp + +import ( + "os" + "path/filepath" + "testing" +) + +func TestDefaultRegistry(t *testing.T) { + reg := DefaultRegistry() + if reg.SchemaVersion != "v1" { + t.Errorf("Expected v1, got %s", reg.SchemaVersion) + } + if len(reg.Servers) == 0 { + t.Error("Default registry should have servers") + } + + names := map[string]bool{} + for _, s := range reg.Servers { + if names[s.Name] { + t.Errorf("Duplicate server name: %s", s.Name) + } + names[s.Name] = true + if s.Command == "" { + t.Errorf("Server %s missing command", s.Name) + } + } +} + +func TestSaveAndLoadRegistry(t *testing.T) { + tmpDir := t.TempDir() + registryPath := filepath.Join(tmpDir, "mcp-registry.yaml") + SetRegistryPath(registryPath) + + reg := DefaultRegistry() + if err := SaveRegistry(reg); err != nil { + t.Fatalf("SaveRegistry failed: %v", err) + } + + if _, err := os.Stat(registryPath); os.IsNotExist(err) { + t.Error("Registry file should exist") + } + + loaded, err := LoadRegistry() + if err != nil { + t.Fatalf("LoadRegistry failed: %v", err) + } + if len(loaded.Servers) != len(reg.Servers) { + t.Errorf("Expected %d servers, got %d", len(reg.Servers), len(loaded.Servers)) + } +} + +func TestAddAndRemoveFromRegistry(t *testing.T) { + tmpDir := t.TempDir() + SetRegistryPath(filepath.Join(tmpDir, "mcp-registry.yaml")) + SaveRegistry(DefaultRegistry()) + + newServer := RegistryServer{ + Name: "test-server", + Description: "Test server", + Category: "test", + Command: "npx", + Args: []string{"-y", "test-pkg"}, + InstallType: "npm", + } + + if err := AddToRegistry(newServer); err != nil { + t.Fatalf("AddToRegistry failed: %v", err) + } + + reg, _ := LoadRegistry() + found := false + for _, s := range reg.Servers { + if s.Name == "test-server" { + found = true + break + } + } + if !found { + t.Error("test-server should be in registry") + } + + if err := RemoveFromRegistry("test-server"); err != nil { + t.Fatalf("RemoveFromRegistry failed: %v", err) + } + + reg, _ = LoadRegistry() + for _, s := range reg.Servers { + if s.Name == "test-server" { + t.Error("test-server should have been removed") + } + } +} + +func TestResolveEnv(t *testing.T) { + env := map[string]string{ + "API_KEY": "", + "HOST": "localhost", + } + + os.Setenv("API_KEY", "from-env") + defer os.Unsetenv("API_KEY") + + resolved := ResolveEnv(env, nil) + if resolved["API_KEY"] != "from-env" { + t.Errorf("Expected from-env, got %s", resolved["API_KEY"]) + } + if resolved["HOST"] != "localhost" { + t.Errorf("Expected localhost, got %s", resolved["HOST"]) + } +} + +func TestValidateConfig(t *testing.T) { + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "test-config.json") + os.WriteFile(configPath, []byte(`{"mcps":{}}`), 0644) + + if err := ValidateConfig(configPath); err != nil { + t.Errorf("Valid config should pass: %v", err) + } + + badPath := filepath.Join(tmpDir, "nonexistent.json") + if err := ValidateConfig(badPath); err == nil { + t.Error("Nonexistent config should fail") + } +} + +func TestEditorConfigs(t *testing.T) { + configs := EditorConfigs("/tmp") + if len(configs) < 3 { + t.Errorf("Expected at least 3 editor configs, got %d", len(configs)) + } + + names := map[string]bool{} + for _, c := range configs { + if names[c.Name] { + t.Errorf("Duplicate editor: %s", c.Name) + } + names[c.Name] = true + if c.ConfigPath == "" { + t.Errorf("Editor %s missing config path", c.Name) + } + if c.ConfigKey == "" { + t.Errorf("Editor %s missing config key", c.Name) + } + } +} + +func TestDiscoverNpmServers(t *testing.T) { + servers, err := DiscoverNpmServers() + if err != nil { + t.Fatalf("DiscoverNpmServers failed: %v", err) + } + if len(servers) == 0 { + t.Error("Should discover some npm servers") + } +} + +func TestReceiptRoundTrip(t *testing.T) { + tmpDir := t.TempDir() + os.Setenv("HOME", tmpDir) + defer os.Unsetenv("HOME") + + SetRegistryPath(filepath.Join(tmpDir, "reg.yaml")) + + if err := SaveReceipt("test-server", "1.2.3"); err != nil { + t.Fatalf("SaveReceipt failed: %v", err) + } + + version := GetInstalledVersion("test-server") + if version != "1.2.3" { + t.Errorf("Expected 1.2.3, got %s", version) + } +} + +func TestInitRegistry(t *testing.T) { + tmpDir := t.TempDir() + SetRegistryPath(filepath.Join(tmpDir, "init-reg.yaml")) + + if err := InitRegistry(); err != nil { + t.Fatalf("InitRegistry failed: %v", err) + } + + if _, err := os.Stat(filepath.Join(tmpDir, "init-reg.yaml")); os.IsNotExist(err) { + t.Error("Registry file should be created") + } + + if err := InitRegistry(); err != nil { + t.Fatalf("Second InitRegistry should not fail: %v", err) + } +} + +func TestDetectInstalledEditors(t *testing.T) { + tmpDir := t.TempDir() + os.MkdirAll(filepath.Join(tmpDir, ".config", "crush"), 0755) + os.WriteFile(filepath.Join(tmpDir, ".config", "crush", "crush.json"), []byte(`{}`), 0644) + os.MkdirAll(filepath.Join(tmpDir, ".cursor"), 0755) + + editors := DetectInstalledEditors(tmpDir) + if len(editors) < 2 { + t.Errorf("Expected at least 2 editors, got %d", len(editors)) + } + + found := map[string]bool{} + for _, e := range editors { + found[e] = true + } + if !found["crush"] { + t.Error("Should detect crush") + } + if !found["cursor"] { + t.Error("Should detect cursor") + } +} + +func TestCheckServerStatus(t *testing.T) { + tmpDir := t.TempDir() + os.Setenv("HOME", tmpDir) + defer os.Unsetenv("HOME") + + SetRegistryPath(filepath.Join(tmpDir, "reg.yaml")) + SaveRegistry(DefaultRegistry()) + + status := CheckServerStatus("nonexistent") + if status.Error == "" { + t.Error("Should have error for nonexistent server") + } +} diff --git a/internal/orchestrator/orchestrator.go b/internal/orchestrator/orchestrator.go index b531502..ee8b171 100644 --- a/internal/orchestrator/orchestrator.go +++ b/internal/orchestrator/orchestrator.go @@ -158,48 +158,9 @@ func (o *Orchestrator) Send(userMessage string) (string, error) { } o.histMu.Unlock() - body, err := json.Marshal(reqBody) + chatResp, providerName, err := o.sendWithFallback(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") + return "", err } content := cleanAIResponse(chatResp.Choices[0].Message.Content) @@ -208,6 +169,7 @@ func (o *Orchestrator) Send(userMessage string) (string, error) { Role: "assistant", Content: content, }) + _ = providerName o.histMu.Unlock() return content, nil @@ -326,51 +288,16 @@ func (o *Orchestrator) SendWithTools(messages []Message) (*ChatResponse, error) Tools: o.tools, } - body, err := json.Marshal(reqBody) + chatResp, _, err := o.sendWithFallback(reqBody, "") if err != nil { - return nil, 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 nil, 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 nil, fmt.Errorf("send request: %w", err) - } - defer resp.Body.Close() - - respBody, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("read response: %w", err) - } - - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("API error (%d): %s", resp.StatusCode, string(respBody)) - } - - var chatResp ChatResponse - if err := json.Unmarshal(respBody, &chatResp); err != nil { - return nil, fmt.Errorf("parse response: %w", err) + return nil, err } if len(chatResp.Choices) == 0 { return nil, fmt.Errorf("no response from AI") } - return &chatResp, nil + return chatResp, nil } func cleanAIResponse(content string) string { @@ -411,3 +338,94 @@ func getProviderBaseURL(name string) string { return "" } } + +func (o *Orchestrator) getAvailableProviders() []*config.AIProvider { + var providers []*config.AIProvider + for i := range o.config.AI.Providers { + prov := &o.config.AI.Providers[i] + if prov.APIKey != "" { + providers = append(providers, prov) + } + } + return providers +} + +func (o *Orchestrator) sendWithFallback(reqBody ChatRequest, baseURLOverride string) (*ChatResponse, string, error) { + providers := o.getAvailableProviders() + + if len(providers) == 0 { + return nil, "", fmt.Errorf("no providers available") + } + + providerOrder := make([]*config.AIProvider, 0, len(providers)) + if o.provider != nil { + providerOrder = append(providerOrder, o.provider) + } + for _, p := range providers { + if o.provider == nil || p.Name != o.provider.Name { + providerOrder = append(providerOrder, p) + } + } + + var lastErr error + for _, prov := range providerOrder { + baseURL := baseURLOverride + if baseURL == "" { + baseURL = prov.BaseURL + if baseURL == "" { + baseURL = getProviderBaseURL(prov.Name) + } + } + + url := strings.TrimRight(baseURL, "/") + "/chat/completions" + + body, err := json.Marshal(reqBody) + if err != nil { + lastErr = fmt.Errorf("marshal request: %w", err) + continue + } + + req, err := http.NewRequest("POST", url, bytes.NewReader(body)) + if err != nil { + lastErr = fmt.Errorf("create request: %w", err) + continue + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+prov.APIKey) + + resp, err := o.client.Do(req) + if err != nil { + lastErr = fmt.Errorf("send request to %s: %w", prov.Name, err) + continue + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + lastErr = fmt.Errorf("read response: %w", err) + continue + } + + if resp.StatusCode != http.StatusOK { + lastErr = fmt.Errorf("API error (%d): %s", resp.StatusCode, string(respBody)) + continue + } + + var chatResp ChatResponse + if err := json.Unmarshal(respBody, &chatResp); err != nil { + lastErr = fmt.Errorf("parse response: %w", err) + continue + } + + if len(chatResp.Choices) == 0 { + lastErr = fmt.Errorf("no response from AI") + continue + } + + o.provider = prov + return &chatResp, prov.Name, nil + } + + return nil, "", lastErr +} diff --git a/internal/skills/builtins.go b/internal/skills/builtins.go index 6c6200a..61237de 100644 --- a/internal/skills/builtins.go +++ b/internal/skills/builtins.go @@ -11,9 +11,10 @@ var builtinSkills = []Skill{ Name: "env-setup", Description: "Set up a complete development environment for any language. Detects missing tools, installs them, and configures the project.", Author: "muyue", - Version: "1.0.0", + Version: "1.1.0", Target: "both", Tags: []string{"setup", "environment", "install"}, + Category: "setup", Content: `# Environment Setup Use this skill when setting up a new development environment or project. @@ -58,9 +59,14 @@ Use this skill when setting up a new development environment or project. Name: "git-workflow", Description: "Manage git branches, commits, and pull requests following best practices. Handles branching strategy, conventional commits, and PR creation.", Author: "muyue", - Version: "1.0.0", + Version: "1.1.0", Target: "both", Tags: []string{"git", "workflow", "branching", "commits"}, + Category: "workflow", + Dependencies: []SkillDependency{ + {Type: "tool", Name: "git", Required: true}, + {Type: "tool", Name: "gh", Required: false}, + }, Content: `# Git Workflow Use this skill when the user needs to create branches, make commits, or manage pull requests. @@ -114,9 +120,10 @@ Follow Conventional Commits: Name: "api-design", Description: "Design and implement REST or GraphQL APIs following best practices. Includes endpoint design, error handling, and documentation.", Author: "muyue", - Version: "1.0.0", + Version: "1.1.0", Target: "both", Tags: []string{"api", "rest", "graphql", "design"}, + Category: "design", Content: `# API Design Use this skill when designing or implementing an API. @@ -171,9 +178,10 @@ Use this skill when designing or implementing an API. Name: "debug-assist", Description: "Systematic debugging assistant. Helps identify, isolate, and fix bugs using a structured approach.", Author: "muyue", - Version: "1.0.0", + Version: "1.1.0", Target: "both", Tags: []string{"debug", "troubleshooting", "bugs"}, + Category: "debugging", Content: `# Debug Assist Use this skill when the user reports a bug or asks for help debugging. @@ -188,7 +196,7 @@ Use this skill when the user reports a bug or asks for help debugging. 3. **Hypothesize** β€” Form a hypothesis about the root cause 4. **Verify** β€” Add logging or breakpoints to confirm 5. **Fix** β€” Make the minimal change to fix the issue -6. **Test** β€” Verify the fix works and doesn't break other things +6. **Test** β€” Verify the fix works and does not break other things 7. **Prevent** β€” Add a test to prevent regression ## Common Patterns @@ -211,9 +219,10 @@ Use this skill when the user reports a bug or asks for help debugging. Name: "code-review", Description: "Perform a thorough code review. Checks for bugs, security issues, performance problems, and style consistency.", Author: "muyue", - Version: "1.0.0", + Version: "1.1.0", Target: "both", Tags: []string{"review", "quality", "security"}, + Category: "quality", Content: `# Code Review Use this skill when reviewing code changes or pull requests. @@ -221,7 +230,7 @@ Use this skill when reviewing code changes or pull requests. ## Review Checklist ### Correctness -- Does the code do what it's supposed to? +- Does the code do what it is supposed to? - Are edge cases handled? - Are there off-by-one errors? - Are error paths handled? @@ -254,7 +263,7 @@ Use this skill when reviewing code changes or pull requests. ## Review Format 1. Summary of changes -2. Issues found (critical β†’ minor) +2. Issues found (critical to minor) 3. Suggestions for improvement 4. Positive observations @@ -265,6 +274,351 @@ Use this skill when reviewing code changes or pull requests. - **Minor**: Style issues, naming, minor refactoring opportunities - **Suggestion**: Alternative approaches, improvements`, }, + { + Name: "docker-setup", + Description: "Set up Docker and docker-compose for a project with best practices including multi-stage builds, health checks, and proper networking.", + Author: "muyue", + Version: "1.0.0", + Target: "both", + Tags: []string{"docker", "containers", "devops", "compose"}, + Category: "devops", + Dependencies: []SkillDependency{ + {Type: "tool", Name: "docker", Required: true}, + }, + Content: `# Docker Setup + +Use this skill when the user needs Docker configuration for a project. + +## Dockerfile Best Practices + +1. Use multi-stage builds to reduce image size: + - Builder stage: install dependencies, compile + - Runtime stage: copy only the binary/artifacts + +2. Use specific base image tags (not ` + "`latest`" + `): + - ` + "`golang:1.24-alpine`" + ` for Go + - ` + "`node:22-slim`" + ` for Node.js + - ` + "`python:3.12-slim`" + ` for Python + +3. Order layers for cache efficiency: + - Copy dependency files first (go.mod, package.json, requirements.txt) + - Install dependencies + - Copy source code last + +4. Add health checks: + ` + "```" + `dockerfile + HEALTHCHECK --interval=30s --timeout=3s CMD curl -f http://localhost:8080/health || exit 1 + ` + "```" + ` + +5. Run as non-root user: + ` + "```" + `dockerfile + RUN adduser -D appuser + USER appuser + ` + "```" + ` + +## docker-compose.yml Structure + +` + "```" + `yaml +version: "3.9" +services: + app: + build: . + ports: + - "8080:8080" + environment: + - DATABASE_URL=postgres://user:pass@db:5432/app + depends_on: + db: + condition: service_healthy + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8080/health"] + interval: 30s + timeout: 3s + retries: 3 + + db: + image: postgres:16-alpine + environment: + POSTGRES_USER: user + POSTGRES_PASSWORD: pass + POSTGRES_DB: app + volumes: + - pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U user"] + interval: 10s + timeout: 3s + retries: 5 + +volumes: + pgdata: +` + "```" + ` + +## Error Handling + +- If Docker is not installed, provide install instructions for the platform +- If port is already in use, suggest alternative ports +- If build fails, check for missing .dockerignore and suggest one`, + }, + { + Name: "security-audit", + Description: "Perform a security audit on code, dependencies, and configuration. Checks for OWASP Top 10 vulnerabilities, dependency vulnerabilities, and misconfigurations.", + Author: "muyue", + Version: "1.0.0", + Target: "both", + Tags: []string{"security", "audit", "vulnerabilities", "owasp"}, + Category: "security", + Content: `# Security Audit + +Use this skill when the user needs a security review or vulnerability assessment. + +## Audit Checklist + +### Input Validation (OWASP A03:2021) +- All user input is validated and sanitized +- SQL queries use parameterized statements +- File paths are validated (no path traversal) +- Input length limits are enforced + +### Authentication and Authorization (OWASP A07:2021) +- Passwords are hashed with bcrypt/argon2 (never MD5/SHA1) +- JWT tokens have short expiry with refresh rotation +- Session management is secure +- RBAC or ABAC is properly implemented +- API endpoints have proper auth checks + +### Data Protection (OWASP A02:2021) +- Secrets are not in source code (use env vars or secret managers) +- Sensitive data is encrypted at rest and in transit +- PII is properly handled and not logged +- TLS is enforced for all connections + +### Dependency Security (OWASP A06:2021) +- Run ` + "`npm audit`" + `, ` + "`pip audit`" + `, or ` + "`go vuln check`" + ` +- Check for known CVEs in dependencies +- Keep dependencies up to date +- Use lock files for reproducible builds + +### Configuration Security +- Debug mode is disabled in production +- CORS is properly configured +- Rate limiting is in place +- Security headers are set (CSP, HSTS, X-Frame-Options) +- Error messages do not leak internal details + +## Automated Checks + +Run these tools if available: +- ` + "`gosec ./...`" + ` for Go security +- ` + "`bandit -r .`" + ` for Python security +- ` + "`npm audit`" + ` for Node.js vulnerabilities +- ` + "`trivy fs .`" + ` for container/Dockerfile scanning + +## Report Format + +1. Executive Summary (risk level, total findings) +2. Critical findings (immediate action required) +3. High findings (fix within 24h) +4. Medium findings (fix within sprint) +5. Low findings (address when convenient) +6. Recommendations`, + }, + { + Name: "mcp-setup", + Description: "Configure MCP (Model Context Protocol) servers for AI tools. Discovers, installs, and configures MCP servers across multiple editors.", + Author: "muyue", + Version: "1.0.0", + Target: "both", + Tags: []string{"mcp", "ai", "configuration", "editors"}, + Category: "setup", + Dependencies: []SkillDependency{ + {Type: "tool", Name: "npx", Required: true}, + }, + Content: `# MCP Server Setup + +Use this skill when the user wants to configure MCP servers for their AI coding tools. + +## Supported Editors + +Muyue can generate MCP configs for: +- **Crush**: ` + "`~/.config/crush/crush.json`" + ` (key: ` + "`mcps`" + `) +- **Claude Code**: ` + "`~/.claude.json`" + ` (key: ` + "`mcpServers`" + `) +- **Cursor**: ` + "`~/.cursor/mcp.json`" + ` (key: ` + "`mcpServers`" + `, adds ` + "`type: stdio`" + `) +- **VS Code**: ` + "`~/.vscode/mcp.json`" + ` (key: ` + "`servers`" + `) +- **Windsurf**: ` + "`~/.windsurf/mcp.json`" + ` (key: ` + "`mcpServers`" + `) + +## Common MCP Servers + +| Server | Package | Required Env | +|--------|---------|-------------| +| filesystem | @modelcontextprotocol/server-filesystem | None | +| fetch | @modelcontextprotocol/server-fetch | None | +| github | @modelcontextprotocol/server-github | GITHUB_PERSONAL_ACCESS_TOKEN | +| brave-search | @modelcontextprotocol/server-brave-search | BRAVE_API_KEY | +| memory | @modelcontextprotocol/server-memory | None | +| postgres | @modelcontextprotocol/server-postgres | DATABASE_URL | +| sqlite | @modelcontextprotocol/server-sqlite | None | +| docker | @modelcontextprotocol/server-docker | None | + +## Setup Steps + +1. Ask which editors the user wants to configure +2. Ask which MCP servers they need +3. For servers requiring API keys, prompt for the key +4. Generate configs for each selected editor +5. Validate configs (check JSON is valid, commands exist) +6. Test connectivity if possible + +## Credential Management + +- API keys should be stored in the Muyue config (encrypted) +- When generating MCP configs, inject keys from the Muyue config +- Never hardcode API keys in config files in version control +- Suggest adding MCP config files to ` + "`.gitignore`" + ` + +## Troubleshooting + +- If npx fails, suggest ` + "`npm install -g`" + ` the package +- If a server does not start, check the command and args +- If auth fails, verify the API key is correct and active`, + }, + { + Name: "lsp-setup", + Description: "Configure Language Server Protocol servers for code intelligence. Detects project languages, installs LSPs, and generates editor configs.", + Author: "muyue", + Version: "1.0.0", + Target: "both", + Tags: []string{"lsp", "language-server", "ide", "configuration"}, + Category: "setup", + Content: `# LSP Server Setup + +Use this skill when the user wants to set up language servers for code intelligence. + +## Supported Languages + +| Language | Server | Install Method | +|----------|--------|---------------| +| Go | gopls | ` + "`go install`" + ` | +| Python | pyright | ` + "`npm install -g`" + ` | +| TypeScript/JS | typescript-language-server | ` + "`npm install -g`" + ` | +| Rust | rust-analyzer | ` + "`rustup component add`" + ` | +| C/C++ | clangd | System package | +| Lua | lua-language-server | ` + "`npm install -g`" + ` | +| HTML | vscode-html-language-server | ` + "`npm install -g vscode-langservers-extracted`" + ` | +| CSS | vscode-css-language-server | ` + "`npm install -g vscode-langservers-extracted`" + ` | +| JSON | vscode-json-language-server | ` + "`npm install -g vscode-langservers-extracted`" + ` | +| YAML | yaml-language-server | ` + "`npm install -g`" + ` | +| Bash | bash-language-server | ` + "`npm install -g`" + ` | +| Docker | dockerfile-language-server | ` + "`npm install -g`" + ` | +| Vue | vue-language-server | ` + "`npm install -g`" + ` | +| Svelte | svelte-language-server | ` + "`npm install -g`" + ` | + +## Auto-Detection + +Detect project languages from: +- Config files: ` + "`go.mod`" + `, ` + "`package.json`" + `, ` + "`Cargo.toml`" + `, ` + "`pyproject.toml`" + ` +- Source file extensions: ` + "`*.go`" + `, ` + "`*.py`" + `, ` + "`*.ts`" + `, ` + "`*.rs`" + ` + +## Editor Config Generation + +### Neovim +Generate ` + "`lspconfig`" + ` setup snippet for each LSP. + +### Helix +Generate ` + "`languages.toml`" + ` entries with language-server mappings. + +### VS Code / Cursor +Generate ` + "`extensions.json`" + ` recommendations for each LSP. + +## Health Checks + +After installation, verify: +1. The binary is in PATH +2. The version matches expected +3. A basic ` + "`initialize`" + ` request succeeds (if applicable)`, + }, + { + Name: "workflow-design", + Description: "Design development workflows and automations. Creates CI/CD pipelines, git hooks, and development process documentation.", + Author: "muyue", + Version: "1.0.0", + Target: "both", + Tags: []string{"workflow", "ci-cd", "automation", "process"}, + Category: "workflow", + Content: `# Workflow Design + +Use this skill when the user wants to establish development workflows or CI/CD pipelines. + +## CI/CD Pipeline Design + +### GitHub Actions Template + +` + "```" + `yaml +name: CI +on: + push: + branches: [main, develop] + pull_request: + branches: [main] + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: { go-version: "1.24" } + - run: go vet ./... + - run: golint ./... + + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: { go-version: "1.24" } + - run: go test -race -coverprofile=coverage.out ./... + - run: go tool cover -func=coverage.out + + build: + needs: [lint, test] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - run: go build -o bin/app ./cmd/app +` + "```" + ` + +## Git Hooks + +Use ` + "`pre-commit`" + ` framework: +- ` + "`pre-commit`" + `: lint, format check, trailing whitespace +- ` + "`commit-msg`" + `: validate conventional commit format +- ` + "`pre-push`" + `: run tests + +## Branch Protection Rules + +- Require PR reviews (at least 1 approval) +- Require status checks to pass +- Require up-to-date branch before merge +- Require linear history (rebase merge) + +## Development Process + +1. Pick a task from the backlog +2. Create a feature branch +3. Implement with tests +4. Run linter and tests locally +5. Push and create PR +6. Address review feedback +7. Merge when approved and CI passes +8. Delete feature branch + +## Error Handling + +- If CI fails, provide clear error output and suggested fixes +- If hooks fail, explain what failed and how to fix +- Suggest ` + "`--no-verify`" + ` only as a last resort, with a warning`, + }, } func InstallBuiltinSkills() error { diff --git a/internal/skills/skills.go b/internal/skills/skills.go index 1953a5d..13270f6 100644 --- a/internal/skills/skills.go +++ b/internal/skills/skills.go @@ -1,27 +1,53 @@ package skills import ( + "encoding/json" "fmt" "os" "path/filepath" - "sort" + "regexp" "strings" "time" "gopkg.in/yaml.v3" ) +type SkillDependency struct { + Type string `yaml:"type,omitempty" json:"type,omitempty"` + Name string `yaml:"name,omitempty" json:"name,omitempty"` + Version string `yaml:"version,omitempty" json:"version,omitempty"` + Required bool `yaml:"required,omitempty" json:"required,omitempty"` +} + type Skill struct { - Name string `yaml:"name" json:"name"` - Description string `yaml:"description" json:"description"` - Content string `yaml:"content" json:"content"` - Author string `yaml:"author" json:"author"` - Version string `yaml:"version" json:"version"` - CreatedAt time.Time `yaml:"created_at" json:"created_at"` - UpdatedAt time.Time `yaml:"updated_at" json:"updated_at"` - Tags []string `yaml:"tags" json:"tags"` - Target string `yaml:"target" json:"target"` - FilePath string `yaml:"-" json:"-"` + Name string `yaml:"name" json:"name"` + Description string `yaml:"description" json:"description"` + Content string `yaml:"content" json:"content"` + Author string `yaml:"author" json:"author"` + Version string `yaml:"version" json:"version"` + CreatedAt time.Time `yaml:"created_at" json:"created_at"` + UpdatedAt time.Time `yaml:"updated_at" json:"updated_at"` + Tags []string `yaml:"tags" json:"tags"` + Target string `yaml:"target" json:"target"` + FilePath string `yaml:"-" json:"-"` + Dependencies []SkillDependency `yaml:"dependencies,omitempty" json:"dependencies,omitempty"` + Languages []string `yaml:"languages,omitempty" json:"languages,omitempty"` + Category string `yaml:"category,omitempty" json:"category,omitempty"` +} + +type ValidationError struct { + Field string `json:"field"` + Message string `json:"message"` +} + +func (v ValidationError) Error() string { + return fmt.Sprintf("%s: %s", v.Field, v.Message) +} + +type SkillTestResult struct { + Name string `json:"name"` + Passed bool `json:"passed"` + Message string `json:"message"` } func SkillsDir() (string, error) { @@ -66,10 +92,6 @@ func List() ([]Skill, error) { skills = append(skills, *skill) } - sort.Slice(skills, func(i, j int) bool { - return skills[i].Name < skills[j].Name - }) - return skills, nil } @@ -95,6 +117,10 @@ func Get(name string) (*Skill, error) { } func Create(skill *Skill) error { + if errs := Validate(skill); len(errs) > 0 { + return fmt.Errorf("validation failed: %v", errs) + } + dir, err := SkillsDir() if err != nil { return err @@ -129,6 +155,28 @@ func Delete(name string) error { return nil } +func Update(skill *Skill) error { + if errs := Validate(skill); len(errs) > 0 { + return fmt.Errorf("validation failed: %v", errs) + } + + dir, err := SkillsDir() + if err != nil { + return err + } + + skillDir := filepath.Join(dir, skill.Name) + skillPath := filepath.Join(skillDir, "SKILL.md") + + skill.UpdatedAt = time.Now() + content := renderSkill(skill) + if err := os.WriteFile(skillPath, []byte(content), 0644); err != nil { + return err + } + + return Deploy(skill) +} + func Deploy(skill *Skill) error { home, err := os.UserHomeDir() if err != nil { @@ -188,6 +236,206 @@ func undeployFromTargets(name string) { os.RemoveAll(filepath.Join(home, ".claude", "skills", name)) } +func Validate(skill *Skill) []ValidationError { + var errs []ValidationError + + if skill.Name == "" { + errs = append(errs, ValidationError{Field: "name", Message: "name is required"}) + } + + if skill.Name != "" { + if matched, _ := regexp.MatchString(`^[a-z0-9][a-z0-9-]*$`, skill.Name); !matched { + errs = append(errs, ValidationError{Field: "name", Message: "name must be lowercase alphanumeric with dashes"}) + } + } + + if skill.Description == "" { + errs = append(errs, ValidationError{Field: "description", Message: "description is required"}) + } + + if skill.Content == "" { + errs = append(errs, ValidationError{Field: "content", Message: "content is required"}) + } + + if skill.Target != "" && skill.Target != "crush" && skill.Target != "claude" && skill.Target != "both" { + errs = append(errs, ValidationError{Field: "target", Message: "target must be crush, claude, or both"}) + } + + if skill.Version != "" { + if matched, _ := regexp.MatchString(`^\d+\.\d+\.\d+$`, skill.Version); !matched { + errs = append(errs, ValidationError{Field: "version", Message: "version must be semver (e.g. 1.0.0)"}) + } + } + + for i, dep := range skill.Dependencies { + if dep.Type != "mcp_server" && dep.Type != "lsp" && dep.Type != "tool" && dep.Type != "runtime" && dep.Type != "" { + errs = append(errs, ValidationError{ + Field: fmt.Sprintf("dependencies[%d].type", i), + Message: "dependency type must be mcp_server, lsp, tool, or runtime", + }) + } + if dep.Name == "" { + errs = append(errs, ValidationError{ + Field: fmt.Sprintf("dependencies[%d].name", i), + Message: "dependency name is required", + }) + } + } + + return errs +} + +func CheckDependencies(skill *Skill) []SkillDependency { + var missing []SkillDependency + for _, dep := range skill.Dependencies { + switch dep.Type { + case "mcp_server": + if !isMCPServerAvailable(dep.Name) { + missing = append(missing, dep) + } + case "lsp", "tool", "runtime": + if !isToolAvailable(dep.Name) { + missing = append(missing, dep) + } + } + } + return missing +} + +func isToolAvailable(name string) bool { + _, err := lookPath(name) + return err == nil +} + +func lookPath(name string) (string, error) { + pathEnv := os.Getenv("PATH") + home, _ := os.UserHomeDir() + if home != "" { + pathEnv = home + "/.local/bin:" + home + "/go/bin:" + pathEnv + } + for _, dir := range filepath.SplitList(pathEnv) { + candidate := filepath.Join(dir, name) + if info, err := os.Stat(candidate); err == nil && !info.IsDir() { + return candidate, nil + } + } + return "", fmt.Errorf("%s not found", name) +} + +func isMCPServerAvailable(name string) bool { + home, _ := os.UserHomeDir() + if home == "" { + return false + } + configPath := filepath.Join(home, ".config", "crush", "crush.json") + data, err := os.ReadFile(configPath) + if err != nil { + return false + } + var cfg map[string]interface{} + if err := json.Unmarshal(data, &cfg); err != nil { + return false + } + mcps, ok := cfg["mcps"].(map[string]interface{}) + if !ok { + return false + } + _, exists := mcps[name] + return exists +} + +func Export(name string, exportPath string) error { + skill, err := Get(name) + if err != nil { + return err + } + + if err := os.MkdirAll(filepath.Dir(exportPath), 0755); err != nil { + return err + } + + content := renderSkill(skill) + return os.WriteFile(exportPath, []byte(content), 0644) +} + +func Import(exportPath string) (*Skill, error) { + data, err := os.ReadFile(exportPath) + if err != nil { + return nil, fmt.Errorf("read export file: %w", err) + } + + skill, err := parseSkill(data) + if err != nil { + return nil, err + } + + name := filepath.Base(filepath.Dir(exportPath)) + if skill.Name == "" { + skill.Name = strings.TrimSuffix(filepath.Base(exportPath), ".md") + if skill.Name == "SKILL" { + skill.Name = filepath.Base(filepath.Dir(exportPath)) + } + } + + _ = name + if errs := Validate(skill); len(errs) > 0 { + return nil, fmt.Errorf("validation failed: %v", errs) + } + + return skill, nil +} + +func DryRun(name string, sampleTask string) SkillTestResult { + skill, err := Get(name) + if err != nil { + return SkillTestResult{Name: name, Passed: false, Message: fmt.Sprintf("skill not found: %s", err)} + } + + if skill.Content == "" { + return SkillTestResult{Name: name, Passed: false, Message: "skill has no content"} + } + + if len(skill.Dependencies) > 0 { + missing := CheckDependencies(skill) + if len(missing) > 0 { + var names []string + for _, d := range missing { + names = append(names, d.Name) + } + return SkillTestResult{ + Name: name, + Passed: false, + Message: fmt.Sprintf("missing dependencies: %s", strings.Join(names, ", ")), + } + } + } + + if sampleTask != "" { + tags := skill.Tags + taskLower := strings.ToLower(sampleTask) + matched := false + for _, tag := range tags { + if strings.Contains(taskLower, strings.ToLower(tag)) { + matched = true + break + } + } + if len(tags) > 0 && !matched { + return SkillTestResult{ + Name: name, + Passed: true, + Message: "skill loaded but sample task does not match skill tags", + } + } + } + + return SkillTestResult{ + Name: name, + Passed: true, + Message: "skill validated successfully", + } +} + func parseSkill(data []byte) (*Skill, error) { content := string(data) @@ -227,9 +475,25 @@ func renderSkill(skill *Skill) string { if skill.Target != "" { b.WriteString(fmt.Sprintf("target: %s\n", skill.Target)) } + if skill.Category != "" { + b.WriteString(fmt.Sprintf("category: %s\n", skill.Category)) + } if len(skill.Tags) > 0 { b.WriteString(fmt.Sprintf("tags: [%s]\n", strings.Join(skill.Tags, ", "))) } + if len(skill.Languages) > 0 { + b.WriteString(fmt.Sprintf("languages: [%s]\n", strings.Join(skill.Languages, ", "))) + } + if len(skill.Dependencies) > 0 { + b.WriteString("dependencies:\n") + for _, dep := range skill.Dependencies { + req := "" + if dep.Required { + req = ", required: true" + } + b.WriteString(fmt.Sprintf(" - type: %s, name: %s%s\n", dep.Type, dep.Name, req)) + } + } b.WriteString("---\n\n") b.WriteString(skill.Content) b.WriteString("\n") @@ -245,7 +509,7 @@ DESCRIPTION: %s TARGET: %s (crush = Crush with GLM, claude = Claude Code, both = both tools) The skill must follow this EXACT format: -1. YAML frontmatter with: name, description +1. YAML frontmatter with: name, description, tags, dependencies (if needed) 2. Markdown body with detailed instructions The skill should be practical, specific, and actionable. @@ -255,5 +519,10 @@ Include: - Examples where relevant - Error handling guidance +If the skill requires specific tools, MCP servers, or LSP servers, declare them as dependencies: + - type: mcp_server, name: + - type: lsp, name: + - type: tool, name: + Output ONLY the skill file content, starting with ---`, name, description, target) } diff --git a/internal/skills/skills_test.go b/internal/skills/skills_test.go index 872cc80..17c0a18 100644 --- a/internal/skills/skills_test.go +++ b/internal/skills/skills_test.go @@ -113,7 +113,7 @@ func TestCreateAndGet(t *testing.T) { Description: "Test description", Content: "Test content body", Author: "tester", - Version: "0.1", + Version: "1.0.0", Target: "both", } @@ -198,3 +198,242 @@ func TestInstallBuiltinSkills(t *testing.T) { t.Error("Expected env-setup skill") } } + +func TestValidate(t *testing.T) { + skill := &Skill{ + Name: "valid-skill", + Description: "A valid skill", + Content: "## Steps\nDo things", + Version: "1.0.0", + Target: "both", + } + + errs := Validate(skill) + if len(errs) != 0 { + t.Errorf("Valid skill should have no errors, got %v", errs) + } +} + +func TestValidateMissingFields(t *testing.T) { + skill := &Skill{} + errs := Validate(skill) + if len(errs) == 0 { + t.Error("Empty skill should have validation errors") + } + + fields := map[string]bool{} + for _, e := range errs { + fields[e.Field] = true + } + if !fields["name"] { + t.Error("Should require name") + } + if !fields["description"] { + t.Error("Should require description") + } + if !fields["content"] { + t.Error("Should require content") + } +} + +func TestValidateBadVersion(t *testing.T) { + skill := &Skill{ + Name: "test-skill", + Description: "desc", + Content: "content", + Version: "not-semver", + } + errs := Validate(skill) + hasVersionErr := false + for _, e := range errs { + if e.Field == "version" { + hasVersionErr = true + } + } + if !hasVersionErr { + t.Error("Should reject non-semver version") + } +} + +func TestValidateBadTarget(t *testing.T) { + skill := &Skill{ + Name: "test", + Description: "desc", + Content: "content", + Target: "invalid", + } + errs := Validate(skill) + hasTargetErr := false + for _, e := range errs { + if e.Field == "target" { + hasTargetErr = true + } + } + if !hasTargetErr { + t.Error("Should reject invalid target") + } +} + +func TestValidateBadName(t *testing.T) { + skill := &Skill{ + Name: "INVALID", + Description: "desc", + Content: "content", + } + errs := Validate(skill) + hasNameErr := false + for _, e := range errs { + if e.Field == "name" { + hasNameErr = true + } + } + if !hasNameErr { + t.Error("Should reject uppercase name") + } +} + +func TestValidateDependencies(t *testing.T) { + skill := &Skill{ + Name: "test", + Description: "desc", + Content: "content", + Dependencies: []SkillDependency{ + {Type: "mcp_server", Name: "github", Required: true}, + {Type: "invalid_type", Name: "test"}, + }, + } + errs := Validate(skill) + hasDepErr := false + for _, e := range errs { + if e.Field == "dependencies[1].type" { + hasDepErr = true + } + } + if !hasDepErr { + t.Error("Should reject invalid dependency type") + } +} + +func TestExportImport(t *testing.T) { + tmpDir := t.TempDir() + os.Setenv("HOME", tmpDir) + defer os.Setenv("HOME", tmpDir) + + skill := &Skill{ + Name: "export-test", + Description: "Export test skill", + Content: "## Content", + Author: "tester", + Version: "1.0.0", + Target: "both", + Tags: []string{"test"}, + } + Create(skill) + + exportPath := filepath.Join(tmpDir, "export", "export-test.md") + if err := Export("export-test", exportPath); err != nil { + t.Fatalf("Export failed: %v", err) + } + + if _, err := os.Stat(exportPath); os.IsNotExist(err) { + t.Error("Export file should exist") + } + + imported, err := Import(exportPath) + if err != nil { + t.Fatalf("Import failed: %v", err) + } + if imported.Description != "Export test skill" { + t.Errorf("Expected 'Export test skill', got %s", imported.Description) + } +} + +func TestDryRun(t *testing.T) { + tmpDir := t.TempDir() + os.Setenv("HOME", tmpDir) + defer os.Setenv("HOME", tmpDir) + + skill := &Skill{ + Name: "dry-run-test", + Description: "Dry run test", + Content: "## Steps\nDo something", + Version: "1.0.0", + Target: "both", + Tags: []string{"test"}, + } + Create(skill) + + result := DryRun("dry-run-test", "test something") + if !result.Passed { + t.Errorf("DryRun should pass, got: %s", result.Message) + } +} + +func TestDryRunMissing(t *testing.T) { + result := DryRun("nonexistent", "") + if result.Passed { + t.Error("DryRun of nonexistent skill should fail") + } +} + +func TestUpdate(t *testing.T) { + tmpDir := t.TempDir() + os.Setenv("HOME", tmpDir) + defer os.Setenv("HOME", tmpDir) + + skill := &Skill{ + Name: "update-test", + Description: "Original", + Content: "Original content", + Version: "1.0.0", + Target: "both", + } + Create(skill) + + skill.Description = "Updated" + skill.Content = "Updated content" + skill.Version = "2.0.0" + if err := Update(skill); err != nil { + t.Fatalf("Update failed: %v", err) + } + + got, err := Get("update-test") + if err != nil { + t.Fatalf("Get failed: %v", err) + } + if got.Description != "Updated" { + t.Errorf("Expected 'Updated', got %s", got.Description) + } +} + +func TestBuiltinSkillCount(t *testing.T) { + if len(builtinSkills) < 5 { + t.Errorf("Expected at least 5 builtin skills, got %d", len(builtinSkills)) + } + + expectedSkills := []string{"env-setup", "git-workflow", "api-design", "debug-assist", "code-review", "docker-setup", "security-audit", "mcp-setup", "lsp-setup", "workflow-design"} + for _, name := range expectedSkills { + found := false + for _, s := range builtinSkills { + if s.Name == name { + found = true + break + } + } + if !found { + t.Errorf("Expected builtin skill: %s", name) + } + } +} + +func TestBuiltinSkillsHaveDependencies(t *testing.T) { + hasDeps := 0 + for _, s := range builtinSkills { + if len(s.Dependencies) > 0 { + hasDeps++ + } + } + if hasDeps == 0 { + t.Error("At least some builtin skills should declare dependencies") + } +} diff --git a/internal/workflow/engine.go b/internal/workflow/engine.go new file mode 100644 index 0000000..1764020 --- /dev/null +++ b/internal/workflow/engine.go @@ -0,0 +1,362 @@ +package workflow + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "sync" + "time" + + "github.com/muyue/muyue/internal/agent" + "github.com/muyue/muyue/internal/config" +) + +type Status string + +const ( + StatusPending Status = "pending" + StatusRunning Status = "running" + StatusDone Status = "done" + StatusFailed Status = "failed" + StatusSkipped Status = "skipped" + StatusAwaiting Status = "awaiting_approval" +) + +type StepType string + +const ( + TypeToolCall StepType = "tool_call" + TypeCondition StepType = "condition" + TypeParallel StepType = "parallel" + TypeApproval StepType = "approval" +) + +type Step struct { + ID string `json:"id"` + Name string `json:"name"` + Type StepType `json:"type"` + Tool string `json:"tool,omitempty"` + Args json.RawMessage `json:"args,omitempty"` + Status Status `json:"status"` + Result string `json:"result,omitempty"` + Error string `json:"error,omitempty"` + Condition string `json:"condition,omitempty"` + DependsOn []string `json:"depends_on,omitempty"` + ApproveRole string `json:"approve_role,omitempty"` + StartedAt *time.Time `json:"started_at,omitempty"` + EndedAt *time.Time `json:"ended_at,omitempty"` +} + +type Workflow struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Type string `json:"type"` + Steps []Step `json:"steps"` + Status Status `json:"status"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type Engine struct { + mu sync.RWMutex + workflows map[string]*Workflow + agentRegistry *agent.Registry + storePath string +} + +func NewEngine(registry *agent.Registry) (*Engine, error) { + dir, err := config.ConfigDir() + if err != nil { + dir = "/tmp/muyue" + } + + storePath := filepath.Join(dir, "workflows.json") + engine := &Engine{ + workflows: make(map[string]*Workflow), + agentRegistry: registry, + storePath: storePath, + } + + engine.load() + return engine, nil +} + +func (e *Engine) load() { + data, err := os.ReadFile(e.storePath) + if err != nil { + return + } + + var workflows []*Workflow + if err := json.Unmarshal(data, &workflows); err != nil { + return + } + + for _, w := range workflows { + e.workflows[w.ID] = w + } +} + +func (e *Engine) save() error { + dir := filepath.Dir(e.storePath) + os.MkdirAll(dir, 0755) + + e.mu.RLock() + workflows := make([]*Workflow, 0, len(e.workflows)) + for _, w := range e.workflows { + workflows = append(workflows, w) + } + e.mu.RUnlock() + + data, err := json.MarshalIndent(workflows, "", " ") + if err != nil { + return err + } + + return os.WriteFile(e.storePath, data, 0600) +} + +func (e *Engine) Create(name, description, wfType string, steps []Step) *Workflow { + wf := &Workflow{ + ID: fmt.Sprintf("wf-%d", time.Now().UnixNano()), + Name: name, + Description: description, + Type: wfType, + Steps: steps, + Status: StatusPending, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + + for i := range wf.Steps { + if wf.Steps[i].ID == "" { + wf.Steps[i].ID = fmt.Sprintf("step-%d", i) + } + if wf.Steps[i].Status == "" { + wf.Steps[i].Status = StatusPending + } + } + + e.mu.Lock() + e.workflows[wf.ID] = wf + e.mu.Unlock() + + e.save() + return wf +} + +func (e *Engine) Get(id string) (*Workflow, bool) { + e.mu.RLock() + defer e.mu.RUnlock() + wf, ok := e.workflows[id] + return wf, ok +} + +func (e *Engine) List() []*Workflow { + e.mu.RLock() + defer e.mu.RUnlock() + result := make([]*Workflow, 0, len(e.workflows)) + for _, w := range e.workflows { + result = append(result, w) + } + return result +} + +func (e *Engine) Delete(id string) error { + e.mu.Lock() + defer e.mu.Unlock() + if _, ok := e.workflows[id]; !ok { + return fmt.Errorf("workflow not found: %s", id) + } + delete(e.workflows, id) + return e.save() +} + +func (e *Engine) UpdateStep(workflowID, stepID string, update func(*Step)) error { + e.mu.Lock() + defer e.mu.Unlock() + + wf, ok := e.workflows[workflowID] + if !ok { + return fmt.Errorf("workflow not found: %s", workflowID) + } + + for i := range wf.Steps { + if wf.Steps[i].ID == stepID { + update(&wf.Steps[i]) + wf.UpdatedAt = time.Now() + e.save() + return nil + } + } + + return fmt.Errorf("step not found: %s", stepID) +} + +func (e *Engine) UpdateWorkflowStatus(workflowID string, status Status) error { + e.mu.Lock() + defer e.mu.Unlock() + + wf, ok := e.workflows[workflowID] + if !ok { + return fmt.Errorf("workflow not found: %s", workflowID) + } + + wf.Status = status + wf.UpdatedAt = time.Now() + return e.save() +} + +func (e *Engine) Execute(ctx context.Context, workflowID string, onStep func(step *Step, event string)) error { + wf, ok := e.Get(workflowID) + if !ok { + return fmt.Errorf("workflow not found: %s", workflowID) + } + + if err := e.UpdateWorkflowStatus(workflowID, StatusRunning); err != nil { + return err + } + + stepStatuses := make(map[string]Status) + for _, step := range wf.Steps { + stepStatuses[step.ID] = StatusPending + } + + resolveDeps := func(stepID string) bool { + step := wf.findStep(stepID) + if step == nil { + return false + } + for _, dep := range step.DependsOn { + if stepStatuses[dep] != StatusDone { + return false + } + } + return true + } + + executeStep := func(step *Step) error { + now := time.Now() + e.UpdateStep(workflowID, step.ID, func(s *Step) { + s.Status = StatusRunning + s.StartedAt = &now + }) + + if onStep != nil { + onStep(step, "started") + } + + var result string + var stepErr error + + switch step.Type { + case TypeToolCall: + if step.Tool == "" { + stepErr = fmt.Errorf("tool not specified for step %s", step.ID) + } else { + call := agent.ToolCall{ + ID: step.ID, + Name: step.Tool, + Arguments: step.Args, + } + resp, err := e.agentRegistry.Execute(ctx, call) + if err != nil { + stepErr = err + } else { + result = resp.Content + if resp.IsError { + stepErr = fmt.Errorf("%s", result) + } + } + } + + case TypeApproval: + e.UpdateStep(workflowID, step.ID, func(s *Step) { + s.Status = StatusAwaiting + }) + if onStep != nil { + onStep(step, "awaiting_approval") + } + return nil + + case TypeCondition: + result = fmt.Sprintf("condition '%s' evaluated", step.Condition) + + default: + stepErr = fmt.Errorf("unknown step type: %s", step.Type) + } + + endTime := time.Now() + if stepErr != nil { + e.UpdateStep(workflowID, step.ID, func(s *Step) { + s.Status = StatusFailed + s.Error = stepErr.Error() + s.EndedAt = &endTime + }) + if onStep != nil { + onStep(step, "failed") + } + } else { + e.UpdateStep(workflowID, step.ID, func(s *Step) { + s.Status = StatusDone + s.Result = result + s.EndedAt = &endTime + }) + stepStatuses[step.ID] = StatusDone + if onStep != nil { + onStep(step, "done") + } + } + + return stepErr + } + + hasFailures := false + + for _, step := range wf.Steps { + if step.Type == TypeParallel { + continue + } + + for !resolveDeps(step.ID) { + time.Sleep(100 * time.Millisecond) + } + + if err := executeStep(&step); err != nil { + hasFailures = true + break + } + } + + if hasFailures { + e.UpdateWorkflowStatus(workflowID, StatusFailed) + } else { + e.UpdateWorkflowStatus(workflowID, StatusDone) + } + + return nil +} + +func (w *Workflow) findStep(id string) *Step { + for i := range w.Steps { + if w.Steps[i].ID == id { + return &w.Steps[i] + } + } + return nil +} + +func (e *Engine) ApproveStep(workflowID, stepID string) error { + return e.UpdateStep(workflowID, stepID, func(s *Step) { + s.Status = StatusDone + }) +} + +func (e *Engine) SkipStep(workflowID, stepID string) error { + return e.UpdateStep(workflowID, stepID, func(s *Step) { + s.Status = StatusSkipped + }) +} \ No newline at end of file diff --git a/internal/workflow/planner.go b/internal/workflow/planner.go new file mode 100644 index 0000000..ccec148 --- /dev/null +++ b/internal/workflow/planner.go @@ -0,0 +1,172 @@ +package workflow + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "github.com/muyue/muyue/internal/config" + "github.com/muyue/muyue/internal/orchestrator" +) + +type Planner struct { + orchestrator *orchestrator.Orchestrator +} + +func NewPlanner(cfg *config.MuyueConfig) (*Planner, error) { + orb, err := orchestrator.New(cfg) + if err != nil { + return nil, err + } + orb.SetSystemPrompt(plannerSystemPrompt) + return &Planner{orchestrator: orb}, nil +} + +func (p *Planner) GeneratePlan(ctx context.Context, goal string) ([]Step, error) { + prompt := buildPlanPrompt(goal) + + messages := []orchestrator.Message{ + {Role: "user", Content: prompt}, + } + + resp, err := p.orchestrator.SendWithTools(messages) + if err != nil { + return nil, err + } + + if len(resp.Choices) == 0 || resp.Choices[0].Message.Content == "" { + return nil, fmt.Errorf("no plan generated") + } + + content := resp.Choices[0].Message.Content + plan, err := parsePlanResponse(content) + if err != nil { + return nil, err + } + + return plan, nil +} + +func buildPlanPrompt(goal string) string { + return fmt.Sprintf(`Tu es un planificateur de workflows pour Muyue. L'utilisateur veut accomplir la tΓ’che suivante: + +"%s" + +Analyse cette tΓ’che et gΓ©nΓ¨re un plan d'exΓ©cution en une sΓ©rie d'Γ©tapes. Chaque Γ©tape est un appel d'outil. + +Les outils disponibles sont: +- terminal: ExΓ©cuter une commande shell +- read_file: Lire un fichier +- list_files: Lister les fichiers d'un rΓ©pertoire +- search_files: Rechercher des fichiers par pattern +- grep_content: Rechercher du texte dans des fichiers +- get_config: Lire la configuration Muyue +- set_provider: Configurer un provider AI +- manage_ssh: GΓ©rer les connexions SSH +- web_fetch: RΓ©cupΓ©rer le contenu d'une URL + +RΓ©ponds UNIQUEMENT avec un JSON valide reprΓ©sentant un tableau d'Γ©tapes, sans texte avant ou aprΓ¨s: + +[ + {"name": "Nom de l'Γ©tape", "tool": "terminal", "args": {"command": "ls -la"}}, + {"name": "Lire le fichier config", "tool": "read_file", "args": {"path": "~/.muyue/config.json"}} +] + +RΓ¨gles: +- Chaque Γ©tape doit avoir: name, tool, args +- Les args varient selon le tool (voir les dΓ©finitions) +- Sois prΓ©cis dans les commandes +- SΓ©pare en Γ©tapes logiques +- Ne gΓ©nΓ¨re pas plus de 10 Γ©tapes`, goal) +} + +func parsePlanResponse(content string) ([]Step, error) { + content = strings.TrimSpace(content) + + var jsonStr string + if strings.HasPrefix(content, "```json") { + lines := strings.Split(content, "\n") + var jsonLines []string + for _, line := range lines[1:] { + if strings.HasPrefix(line, "```") { + break + } + jsonLines = append(jsonLines, line) + } + jsonStr = strings.Join(jsonLines, "\n") + } else if strings.HasPrefix(content, "```") { + lines := strings.Split(content, "\n") + var jsonLines []string + for _, line := range lines[1:] { + if strings.HasPrefix(line, "```") { + break + } + jsonLines = append(jsonLines, line) + } + jsonStr = strings.Join(jsonLines, "\n") + } else { + jsonStr = content + } + + var rawSteps []map[string]interface{} + if err := json.Unmarshal([]byte(jsonStr), &rawSteps); err != nil { + return nil, fmt.Errorf("failed to parse plan JSON: %v\nContent: %s", err, content) + } + + steps := make([]Step, 0, len(rawSteps)) + for i, raw := range rawSteps { + step := Step{ + ID: fmt.Sprintf("step-%d", i), + Status: StatusPending, + } + + if name, ok := raw["name"].(string); ok { + step.Name = name + } else { + step.Name = fmt.Sprintf("Step %d", i+1) + } + + if tool, ok := raw["tool"].(string); ok { + step.Tool = tool + step.Type = TypeToolCall + } + + if args, ok := raw["args"].(map[string]interface{}); ok { + argsJSON, err := json.Marshal(args) + if err == nil { + step.Args = argsJSON + } + } + + if tool, ok := raw["type"].(string); ok { + switch tool { + case "approval": + step.Type = TypeApproval + case "condition": + step.Type = TypeCondition + if cond, ok := raw["condition"].(string); ok { + step.Condition = cond + } + default: + step.Type = TypeToolCall + } + } + + steps = append(steps, step) + } + + return steps, nil +} + +const plannerSystemPrompt = `Tu es un assistant de planification de workflows pour Muyue. Tu gΓ©nΓ¨res des plans d'exΓ©cution sous forme de JSON. Chaque plan est une sΓ©quence d'Γ©tapes (steps) reprΓ©sentant des appels d'outils. + +Pour gΓ©nΓ©rer un plan: +1. Comprends l'objectif de l'utilisateur +2. Identifie les outils nΓ©cessaires +3. DΓ©compose en Γ©tapes logiques +4. SpΓ©cifie les paramΓ¨tres de chaque outil + +RΓ©ponds toujours en JSON valide, sans texte additionnel.` + +var _ = plannerSystemPrompt \ No newline at end of file diff --git a/web/src/api/client.js b/web/src/api/client.js index d86dd89..91a8cc4 100644 --- a/web/src/api/client.js +++ b/web/src/api/client.js @@ -26,6 +26,17 @@ const api = { runScan: () => request('/scan', { method: 'POST' }), installTools: (tools) => request('/install', { method: 'POST', body: JSON.stringify({ tools }) }), configureMCP: () => request('/mcp/configure', { method: 'POST' }), + configureMCPForEditor: (editor) => request('/mcp/configure', { method: 'POST', body: JSON.stringify({ editor }) }), + getMCPStatus: () => request('/mcp/status'), + getMCPRegistry: () => request('/mcp/registry'), + getLSPHealth: () => request('/lsp/health'), + autoInstallLSP: (projectDir) => request('/lsp/auto-install', { method: 'POST', body: JSON.stringify({ project_dir: projectDir || '' }) }), + generateLSPConfig: (editor, names) => request('/lsp/editor-config', { method: 'POST', body: JSON.stringify({ editor, names }) }), + validateSkill: (name) => request('/skills/validate', { method: 'POST', body: JSON.stringify({ name }) }), + testSkill: (name, sampleTask) => request('/skills/test', { method: 'POST', body: JSON.stringify({ name, sample_task: sampleTask || '' }) }), + exportSkill: (name) => request('/skills/export', { method: 'POST', body: JSON.stringify({ name }) }), + importSkill: (path) => request('/skills/import', { method: 'POST', body: JSON.stringify({ import_path: path }) }), + getDashboardStatus: () => request('/dashboard/status'), savePreferences: (prefs) => request('/preferences', { method: 'PUT', body: JSON.stringify(prefs) }), saveProfile: (profile) => request('/config/profile', { method: 'PUT', body: JSON.stringify(profile) }), saveProvider: (provider) => request('/config/provider', { method: 'PUT', body: JSON.stringify(provider) }), @@ -84,6 +95,66 @@ const api = { }).catch(reject) }) }, + sendShellChat: (message, context = {}, stream = true, onChunk) => { + const payload = { + message, + context: context.context || '', + history: context.history || [], + cwd: context.cwd || '', + platform: context.platform || '', + stream, + } + if (!stream) { + return request('/shell/chat', { method: 'POST', body: JSON.stringify(payload) }) + } + return new Promise((resolve, reject) => { + fetch(`${API_BASE}/shell/chat`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }).then(async (res) => { + if (!res.ok) { + const err = await res.json().catch(() => ({ error: res.statusText })) + reject(new Error(err.error || res.statusText)) + return + } + const reader = res.body.getReader() + const decoder = new TextDecoder() + let full = '' + let toolCalls = [] + while (true) { + const { done, value } = await reader.read() + if (done) break + const text = decoder.decode(value, { stream: true }) + for (const line of text.split('\n')) { + if (!line.startsWith('data: ')) continue + try { + const data = JSON.parse(line.slice(6)) + if (data.error) { reject(new Error(data.error)); return } + if (data.done) { + resolve({ content: full, tool_calls: toolCalls }) + return + } + if (data.content) { + full += data.content + if (onChunk) onChunk(full, data) + } else if (data.tool_call) { + toolCalls.push(data.tool_call) + if (onChunk) onChunk(full, data, toolCalls) + } else if (data.tool_result) { + const idx = toolCalls.findIndex(tc => tc.tool_call_id === data.tool_result.id) + if (idx >= 0) { + toolCalls[idx].result = data.tool_result + } + if (onChunk) onChunk(full, data, toolCalls) + } + } catch {} + } + } + resolve({ content: full, tool_calls: toolCalls }) + }).catch(reject) + }) + }, } export default api diff --git a/web/src/components/Config.jsx b/web/src/components/Config.jsx index baf6ce0..11d6b4d 100644 --- a/web/src/components/Config.jsx +++ b/web/src/components/Config.jsx @@ -447,7 +447,14 @@ function PanelSkills({ skillList, t }) {
{s.name} {s.target || 'both'} + {s.version && {s.version}} + {s.category && {s.category}} {s.description} + {s.dependencies && s.dependencies.length > 0 && ( +
+ deps: {s.dependencies.map(d => d.name).join(', ')} +
+ )}
)) )} diff --git a/web/src/components/Dashboard.jsx b/web/src/components/Dashboard.jsx index 3f3fec8..3efde8f 100644 --- a/web/src/components/Dashboard.jsx +++ b/web/src/components/Dashboard.jsx @@ -1,58 +1,438 @@ -import { useState } from 'react' +import { useState, useEffect, useCallback } from 'react' import { useI18n } from '../i18n' -export default function Dashboard({ api }) { +const TOOL_ICONS = { + crush: '⚑', + claude: 'πŸ€–', + go: 'πŸ”·', + node: '🟒', + python: '🐍', + docker: '🐳', + git: 'πŸ“š', + ssh: '🌐', + starship: 'πŸš€', + rust: 'πŸ¦€', +} + +function ToolCard({ tool, onInstall, installing }) { const { t } = useI18n() - const [notifications, setNotifications] = useState([]) + const [showInstall, setShowInstall] = useState(false) + + const icon = TOOL_ICONS[tool.name?.toLowerCase()] || 'πŸ”§' + const isInstalled = tool.installed || tool.status === 'installed' + const version = tool.version || '' + const hasUpdate = tool.hasUpdate || tool.updateAvailable return ( -
-
-
-
-
-
{t('studio.workflows')}
-
-
-
-
{t('studio.workflows')}
-
- {t('studio.noWorkflow')} -
-
-
-
{t('studio.activeAgents')}
-
- {t('studio.noWorkflow')} -
-
-
-
- -
-
-
{t('dashboard.activityLog')}
- {notifications.length > 0 && ( - {notifications.length} - )} -
- {notifications.length === 0 ? ( -
{t('dashboard.noUpdateData')}
- ) : ( -
- {notifications.map(n => ( -
- - {n.time.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', second: '2-digit' })} - - {n.text} -
- ))} -
- )} -
+
+
{icon}
+
+
{tool.name || 'Unknown'}
+
+ {isInstalled ? ( + {t('dashboard.installed')} + ) : ( + {t('dashboard.missing')} + )} + {version && {version}}
+
+ {isInstalled && hasUpdate && ( + + ↑ {tool.latestVersion || 'new'} + + )} + {!isInstalled && ( + + )} +
) } + +function ActivityItem({ entry }) { + const time = entry.time + ? new Date(entry.time).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', second: '2-digit' }) + : '' + const type = entry.type || entry.level || 'info' + const text = entry.message || entry.text || entry.content || '' + + const typeClass = { + ok: 'notif-ok', + success: 'notif-ok', + install: 'notif-ok', + update: 'notif-info', + info: 'notif-info', + warn: 'notif-warn', + warning: 'notif-warn', + error: 'notif-error', + fail: 'notif-error', + }[type] || 'notif-info' + + const icon = { + ok: 'βœ“', success: 'βœ“', install: 'βœ“', update: 'β†’', + info: 'β„Ή', warn: '⚠', warning: '⚠', error: 'βœ—', fail: 'βœ—', + }[type] || 'β€’' + + return ( +
+ {time} + {icon} + {text} +
+ ) +} + +function QuickActionButton({ icon, label, onClick, loading, disabled }) { + return ( + + ) +} + +export default function Dashboard({ api }) { + const { t } = useI18n() + const [activeTab, setActiveTab] = useState('tools') + const [tools, setTools] = useState([]) + const [updates, setUpdates] = useState([]) + const [systemInfo, setSystemInfo] = useState(null) + const [notifications, setNotifications] = useState([]) + const [loading, setLoading] = useState(false) + const [installing, setInstalling] = useState(false) + const [scanLoading, setScanLoading] = useState(false) + const [mcpLoading, setMcpLoading] = useState(false) + const [dashboardStatus, setDashboardStatus] = useState(null) + + const loadData = useCallback(async () => { + try { + const [toolsData, updatesData, systemData] = await Promise.all([ + api.getTools().catch(() => ({ tools: [] })), + api.getUpdates().catch(() => ({ updates: [] })), + api.getSystem().catch(() => null), + ]) + setTools(toolsData.tools || toolsData || []) + setUpdates(updatesData.updates || updatesData || []) + setSystemInfo(systemData) + api.getDashboardStatus().then(d => setDashboardStatus(d)).catch(() => {}) + } catch (err) { + console.error('Failed to load dashboard data:', err) + } + }, [api]) + + useEffect(() => { + loadData() + }, [loadData]) + + const addNotification = (message, type = 'info') => { + const entry = { id: Date.now(), time: new Date().toISOString(), message, type } + setNotifications(prev => [entry, ...prev].slice(0, 100)) + } + + const handleRescan = async () => { + setScanLoading(true) + addNotification(t('dashboard.rescanning'), 'info') + try { + await api.runScan() + await loadData() + addNotification(t('dashboard.scanComplete'), 'ok') + } catch (err) { + addNotification(`${t('dashboard.scanFailed')}: ${err.message}`, 'error') + } finally { + setScanLoading(false) + } + } + + const handleInstallMissing = async () => { + const missing = tools.filter(t => !t.installed && t.status !== 'installed') + if (missing.length === 0) return + setInstalling(true) + addNotification(t('dashboard.installing', { count: missing.length }), 'info') + try { + await api.installTools(missing.map(t => t.name)) + addNotification(t('dashboard.installStarted'), 'ok') + setTimeout(() => handleRescan(), 2000) + } catch (err) { + addNotification(`${t('dashboard.installFailed')}: ${err.message}`, 'error') + } finally { + setInstalling(false) + } + } + + const handleCheckUpdates = async () => { + setLoading(true) + addNotification(t('config.checking'), 'info') + try { + const data = await api.getUpdates() + setUpdates(data.updates || data || []) + const count = (data.updates || data || []).length + if (count > 0) { + addNotification(t('dashboard.updatesCount', { count }), 'warn') + } else { + addNotification(t('dashboard.allUpToDate'), 'ok') + } + } catch (err) { + addNotification(`${t('dashboard.checkUpdatesFailed')}: ${err.message}`, 'error') + } finally { + setLoading(false) + } + } + + const handleConfigureMCP = async () => { + setMcpLoading(true) + addNotification(t('dashboard.configuringMCP'), 'info') + try { + await api.configureMCP() + addNotification(t('dashboard.mcpConfigured'), 'ok') + } catch (err) { + addNotification(`${t('dashboard.mcpConfigFailed')}: ${err.message}`, 'error') + } finally { + setMcpLoading(false) + } + } + + const handleInstallTool = async (name) => { + setInstalling(true) + addNotification(`${t('dashboard.installing')} ${name}...`, 'info') + try { + await api.installTools([name]) + addNotification(`${name} ${t('dashboard.installed')}`, 'ok') + setTimeout(() => loadData(), 2000) + } catch (err) { + addNotification(`${t('dashboard.installFailed')}: ${err.message}`, 'error') + } finally { + setInstalling(false) + } + } + + const installedCount = tools.filter(t => t.installed || t.status === 'installed').length + const missingCount = tools.length - installedCount + + return ( +
+
+ + + + +
+ +
+ {activeTab === 'tools' && ( +
+
+
{t('dashboard.systemOverview')}
+
+ {installedCount} {t('dashboard.installed')} + {missingCount > 0 && {missingCount} {t('dashboard.missing')}} +
+
+ {systemInfo && ( +
+ {systemInfo.os || systemInfo.platform || 'Unknown'} + Β· + {systemInfo.arch || 'Unknown'} + {systemInfo.shell && <>Β·{systemInfo.shell}} +
+ )} +
+ {tools.length === 0 && ( +
{t('dashboard.noTools')}
+ )} + {tools.map((tool, i) => ( + + ))} +
+
+ )} + + {activeTab === 'activity' && ( +
+
+
{t('dashboard.activityLog')}
+ +
+ {notifications.length === 0 ? ( +
{t('dashboard.noActivity')}
+ ) : ( +
+ {notifications.map(entry => ( + + ))} +
+ )} +
+ )} + + {activeTab === 'actions' && ( +
+
+
{t('dashboard.quickActions')}
+
+
+ + + + +
+ + {updates.length > 0 && ( +
+
+
{t('dashboard.updates')}
+ {updates.length} +
+
+ {updates.map((update, i) => ( +
+
+ {update.name || 'Unknown'} + + {update.current || update.version || '?'} β†’ {update.latest || update.target || '?'} + +
+ +
+ ))} +
+
+ )} +
+ )} + + {activeTab === 'status' && ( +
+ {dashboardStatus ? ( + <> +
+
MCP Servers
+ {dashboardStatus.mcp?.healthy || 0}/{dashboardStatus.mcp?.total || 0} healthy +
+
+ {(dashboardStatus.mcp?.servers || []).map((s, i) => ( +
+
+
{s.name}
+
+ {s.healthy ? healthy : + s.installed ? installed : + not found} +
+
+
+ ))} +
+ +
+
LSP Servers
+ {dashboardStatus.lsp?.installed || 0}/{dashboardStatus.lsp?.total || 0} installed +
+
+ {(dashboardStatus.lsp?.servers || []).filter(s => s.installed).map((s, i) => ( +
+
+
{s.name}
+
+ {s.language} +
+
+
+ ))} +
+ +
+
Skills
+ {dashboardStatus.skills?.total || 0} deployed + {(dashboardStatus.skills?.issues || []).length > 0 && ( + {(dashboardStatus.skills.issues || []).length} issues + )} +
+ {(dashboardStatus.skills?.issues || []).length > 0 && ( +
+ {(dashboardStatus.skills.issues || []).map((issue, i) => ( +
{issue}
+ ))} +
+ )} + + ) : ( +
Loading status...
+ )} +
+ )} +
+
+ ) +} \ No newline at end of file diff --git a/web/src/components/Shell.jsx b/web/src/components/Shell.jsx index 2d0dbbb..df1beef 100644 --- a/web/src/components/Shell.jsx +++ b/web/src/components/Shell.jsx @@ -378,61 +378,49 @@ export default function Shell({ api }) { setAiMessages(prev => [...prev, { role: 'user', content: text }]) setAiInput('') setAiLoading(true) + + const currentTab = tabs.find(t => t.id === activeTab) + const context = { + cwd: currentTab?.cwd || '', + platform: navigator.platform || '', + } + try { - const res = await api.runCommand(`echo "AI: ${text}"`, '') - const output = res.output || t('shell.noResponse') - parseAndAddAiMessages(output) - } catch (err) { - setAiMessages(prev => [...prev, { role: 'ai', content: `${t('shell.error')}: ${err.message}` }]) - } - setAiLoading(false) - } - - const parseAndAddAiMessages = (text) => { - const lines = text.split('\n') - let buffer = '' - let inBlock = false - - const flushBuffer = () => { - if (buffer.trim()) { - setAiMessages(prev => [...prev, { role: 'ai', content: buffer.trim() }]) - } - buffer = '' - } - - for (const line of lines) { - const toolMatch = line.match(/^\[TOOL_CALL:\{.*\}\]$/) - if (toolMatch) { - flushBuffer() - try { - const toolData = JSON.parse(toolMatch[0].slice(10, -1)) + let accumulated = '' + await api.sendShellChat(text, context, true, (partial, event) => { + if (event && event.tool_call) { setAiMessages(prev => [...prev, { role: 'tool', - content: `${t('shell.toolLaunched')}: ${toolData.tool || 'tool'}`, - args: toolData.task || toolData.args || '', + content: `${t('shell.toolLaunched')}: ${event.tool_call.name || 'tool'}`, + args: event.tool_call.args ? JSON.stringify(event.tool_call.args).slice(0, 100) : '', }]) - } catch { - setAiMessages(prev => [...prev, { role: 'tool', content: line, args: '' }]) + return } - } else if (line.match(/^(Reflexion|Thought|thinking):/i) || line.startsWith('>')) { - if (buffer.trim() && !inBlock) { - flushBuffer() + if (event && event.tool_result) { + const resultText = event.tool_result.result?.content || event.tool_result.error || 'completed' + setAiMessages(prev => [...prev, { + role: 'tool_result', + content: resultText, + isError: event.tool_result.result?.is_error, + }]) + return } - inBlock = true - const cleaned = line.replace(/^(Reflexion|Thought|thinking):\s*/i, '').replace(/^>\s*/, '') - if (buffer) buffer += ' ' - buffer += cleaned - } else { - if (inBlock && buffer.trim()) { - setAiMessages(prev => [...prev, { role: 'thinking', content: buffer.trim() }]) - buffer = '' - } - inBlock = false - if (buffer) buffer += '\n' - buffer += line + if (event && event.done) return + accumulated = partial + setAiMessages(prev => { + const filtered = prev.filter(m => !m._streaming) + return [...filtered, { role: 'ai', content: partial, _streaming: true }] + }) + }) + + setAiMessages(prev => prev.filter(m => !m._streaming)) + if (accumulated) { + setAiMessages(prev => [...prev.filter(m => !m._streaming), { role: 'ai', content: accumulated }]) } + } catch (err) { + setAiMessages(prev => [...prev.filter(m => !m._streaming), { role: 'ai', content: `${t('shell.error')}: ${err.message}` }]) } - flushBuffer() + setAiLoading(false) } return ( diff --git a/web/src/i18n/en.js b/web/src/i18n/en.js index 978873a..ba56892 100644 --- a/web/src/i18n/en.js +++ b/web/src/i18n/en.js @@ -22,7 +22,9 @@ const en = { dashboard: { systemOverview: 'System Overview', - tools: 'tools', + tools: 'Tools', + activity: 'Activity', + toolsCount: '{count} tools installed', installed: 'Installed', missing: 'Missing', quickActions: 'Quick Actions', @@ -39,9 +41,20 @@ const en = { installStarted: 'Install started. Rescanning...', done: 'Done.', scanComplete: 'Scan complete.', + scanFailed: 'Scan failed', updatesCount: '{count} updates available.', allUpToDate: 'All tools up to date.', mcpConfigured: 'MCP configured.', + mcpConfigFailed: 'MCP configuration failed', + status: 'Status', + clearLog: 'Clear', + noActivity: 'No recent activity.', + rescanning: 'Scanning...', + install: 'Install', + installFailed: 'Install failed', + checkUpdatesFailed: 'Check failed', + configuringMCP: 'Configuring MCP...', + mcpConfigFailed: 'MCP configuration failed', }, studio: { @@ -111,6 +124,7 @@ const en = { aiWelcome: 'Hello! I can help you with terminal commands. Ask me anything!', askAi: 'Ask AI assistant...', toolLaunched: 'Tool launched', + toolResult: 'Result', }, config: { diff --git a/web/src/i18n/fr.js b/web/src/i18n/fr.js index df89ecf..408c5a5 100644 --- a/web/src/i18n/fr.js +++ b/web/src/i18n/fr.js @@ -22,7 +22,9 @@ const fr = { dashboard: { systemOverview: 'Vue d\u2019ensemble du syst\u00e8me', - tools: 'outils', + tools: 'Outils', + activity: 'Activit\u00e9', + toolsCount: '{count} outils install\u00e9s', installed: 'Install\u00e9', missing: 'Manquant', quickActions: 'Actions rapides', @@ -39,9 +41,20 @@ const fr = { installStarted: 'Installation lanc\u00e9e. Rescan en cours...', done: 'Termin\u00e9.', scanComplete: 'Scan termin\u00e9.', + scanFailed: '\u00c9chec du scan', updatesCount: '{count} mises \u00e0 jour disponibles.', allUpToDate: 'Tous les outils sont \u00e0 jour.', mcpConfigured: 'MCP configur\u00e9.', + status: 'Statut', + noTools: 'Aucun outil d\u00e9tect\u00e9. Ex\u00e9cutez un scan.', + clearLog: 'Effacer', + noActivity: 'Aucune activit\u00e9 r\u00e9cente.', + rescanning: 'Scan en cours...', + install: 'Installer', + installFailed: '\u00c9chec de l\u2019installation', + checkUpdatesFailed: '\u00c9chec de la v\u00e9rification', + configuringMCP: 'Configuration MCP en cours...', + mcpConfigFailed: '\u00c9chec de la configuration MCP', }, studio: { @@ -111,6 +124,7 @@ const fr = { aiWelcome: 'Bonjour ! Je peux vous aider avec les commandes du terminal. Demandez-moi n\'importe quoi !', askAi: 'Interroger l\'assistant IA...', toolLaunched: 'Outil lanc\u00e9', + toolResult: 'R\u00e9sultat', }, config: { diff --git a/web/src/styles/global.css b/web/src/styles/global.css index 9834e41..7e855ac 100644 --- a/web/src/styles/global.css +++ b/web/src/styles/global.css @@ -565,6 +565,81 @@ input::placeholder { color: var(--text-disabled); } letter-spacing: 1px; margin-bottom: 12px; padding-bottom: 6px; border-bottom: 1px solid var(--border); } +/* ── Dashboard Tabs ── */ +.dashboard-tabs { + display: flex; gap: 4px; padding: 12px 20px 0; + border-bottom: 1px solid var(--border); background: var(--bg-surface); flex-shrink: 0; +} +.dashboard-tab { + padding: 8px 16px; border-radius: var(--radius) var(--radius) 0 0; + border: 1px solid transparent; border-bottom: none; background: transparent; + color: var(--text-tertiary); font-size: 12px; font-weight: 600; cursor: pointer; + display: flex; align-items: center; gap: 6px; transition: all 0.15s; +} +.dashboard-tab:hover { color: var(--text-primary); background: var(--bg-hover); } +.dashboard-tab.active { background: var(--bg-card); color: var(--accent); border-color: var(--border); } +.dashboard-tab .tab-icon { font-size: 14px; } +.dashboard-tab .tab-count { + background: var(--bg-input); padding: 1px 6px; border-radius: 10px; font-size: 10px; font-family: var(--font-mono); +} +.dashboard-tab .tab-count.warn { background: var(--accent-bg); color: var(--accent); } + +.dashboard-tools-panel { padding: 20px 24px; } +.dashboard-tools-stats { display: flex; gap: 12px; font-size: 12px; } +.stat-ok { color: var(--success); font-family: var(--font-mono); } +.stat-missing { color: var(--error); font-family: var(--font-mono); } + +.dashboard-system-info { display: flex; gap: 8px; align-items: center; margin-bottom: 16px; font-size: 12px; color: var(--text-tertiary); } +.sys-info-item { font-family: var(--font-mono); } +.sys-info-sep { color: var(--text-disabled); } + +.tools-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 12px; margin-top: 8px; } +.tool-card { + background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius-lg); + padding: 14px 16px; display: flex; align-items: center; gap: 12px; transition: border-color 0.2s; +} +.tool-card:hover { border-color: var(--accent-dim); } +.tool-card.installed { border-left: 3px solid var(--success); } +.tool-card.missing { border-left: 3px solid var(--error); } +.tool-card-icon { font-size: 20px; flex-shrink: 0; } +.tool-card-info { flex: 1; min-width: 0; } +.tool-card-name { font-weight: 600; font-size: 13px; color: var(--text-primary); margin-bottom: 2px; } +.tool-card-version { font-size: 11px; color: var(--text-tertiary); display: flex; align-items: center; gap: 6px; } +.tool-version-text { font-family: var(--font-mono); font-size: 10px; color: var(--text-disabled); } +.status-ok { color: var(--success); } +.status-missing { color: var(--error); } +.tool-card-actions { flex-shrink: 0; display: flex; align-items: center; gap: 6px; } +.tool-update-badge { background: var(--accent-bg); color: var(--accent); font-size: 10px; font-family: var(--font-mono); padding: 2px 6px; border-radius: 4px; cursor: pointer; } +.tool-update-badge:hover { background: var(--accent-dim); } + +.dashboard-activity-panel { padding: 20px 24px; } +.activity-log { display: flex; flex-direction: column; gap: 2px; } +.notif-icon { font-size: 12px; width: 16px; text-align: center; } + +.dashboard-actions-panel { padding: 20px 24px; } +.quick-actions-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 10px; margin-bottom: 24px; } +.quick-action-btn { + background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius-lg); + padding: 16px 20px; display: flex; align-items: center; gap: 12px; cursor: pointer; + transition: all 0.2s; font-size: 13px; color: var(--text-secondary); +} +.quick-action-btn:hover:not(:disabled) { border-color: var(--accent-dim); background: var(--bg-hover); color: var(--text-primary); } +.quick-action-btn:disabled { opacity: 0.5; cursor: not-allowed; } +.quick-action-icon { font-size: 18px; } +.quick-action-label { font-weight: 600; } + +.dashboard-updates-section { margin-top: 16px; } +.updates-list { display: flex; flex-direction: column; gap: 6px; } +.update-row { + display: flex; align-items: center; justify-content: space-between; + padding: 10px 14px; border-radius: var(--radius); background: var(--bg-card); + border: 1px solid var(--border); +} +.update-row:hover { border-color: var(--accent-dim); } +.update-info { display: flex; align-items: center; gap: 16px; } +.update-name { font-weight: 600; color: var(--text-primary); font-size: 13px; min-width: 100px; } +.update-versions { color: var(--text-tertiary); font-size: 12px; font-family: var(--font-mono); } + .panel-header { display: flex; align-items: center; justify-content: space-between; padding: 10px 16px; border-bottom: 1px solid var(--border); background: var(--bg-surface);