feat: add Cobra CLI, LSP/MCP registries, workflow engine, and enriched dashboard
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 <crush@charm.land>
This commit is contained in:
59
cmd/muyue/commands/config.go
Normal file
59
cmd/muyue/commands/config.go
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
77
cmd/muyue/commands/doctor.go
Normal file
77
cmd/muyue/commands/doctor.go
Normal file
@@ -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
|
||||
}
|
||||
56
cmd/muyue/commands/install.go
Normal file
56
cmd/muyue/commands/install.go
Normal file
@@ -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
|
||||
}
|
||||
55
cmd/muyue/commands/lsp.go
Normal file
55
cmd/muyue/commands/lsp.go
Normal file
@@ -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
|
||||
}
|
||||
54
cmd/muyue/commands/mcp.go
Normal file
54
cmd/muyue/commands/mcp.go
Normal file
@@ -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
|
||||
}
|
||||
66
cmd/muyue/commands/root.go
Normal file
66
cmd/muyue/commands/root.go
Normal file
@@ -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")
|
||||
}
|
||||
56
cmd/muyue/commands/scan.go
Normal file
56
cmd/muyue/commands/scan.go
Normal file
@@ -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
|
||||
}
|
||||
39
cmd/muyue/commands/setup.go
Normal file
39
cmd/muyue/commands/setup.go
Normal file
@@ -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
|
||||
}
|
||||
105
cmd/muyue/commands/skills.go
Normal file
105
cmd/muyue/commands/skills.go
Normal file
@@ -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
|
||||
}
|
||||
80
cmd/muyue/commands/update.go
Normal file
80
cmd/muyue/commands/update.go
Normal file
@@ -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
|
||||
}
|
||||
23
cmd/muyue/commands/version.go
Normal file
23
cmd/muyue/commands/version.go
Normal file
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
914
docs/PRD.md
Normal file
914
docs/PRD.md
Normal file
@@ -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 <server>`) |
|
||||
| 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 <name>` | — | 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 <name>` | — | Skill details | EXISTS |
|
||||
| `muyue skills generate <name> <desc>` | — | (stub) | STUBBED |
|
||||
| `muyue skills deploy` | — | Confirmation | EXISTS |
|
||||
| `muyue skills delete <name>` | — | Confirmation | EXISTS |
|
||||
|
||||
### CLI Commands Needing Work
|
||||
|
||||
| Command | Issue | Fix |
|
||||
|---------|-------|-----|
|
||||
| `muyue config` | No subcommands (get/set are defined but not registered) | Register `config get <key>` and `config set <key> <value>` 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 <task>` | 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 <server>` 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*
|
||||
3
go.mod
3
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
|
||||
|
||||
9
go.sum
9
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=
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
269
internal/api/handlers_missing.go
Normal file
269
internal/api/handlers_missing.go
Normal file
@@ -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)",
|
||||
})
|
||||
}
|
||||
298
internal/api/handlers_shell_chat.go
Normal file
298
internal/api/handlers_shell_chat.go
Normal file
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
}
|
||||
258
internal/api/handlers_workflow.go
Normal file
258
internal/api/handlers_workflow.go
Normal file
@@ -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
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
333
internal/lsp/registry.go
Normal file
333
internal/lsp/registry.go
Normal file
@@ -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
|
||||
}
|
||||
142
internal/lsp/registry_test.go
Normal file
142
internal/lsp/registry_test.go
Normal file
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
520
internal/mcp/registry.go
Normal file
520
internal/mcp/registry.go
Normal file
@@ -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
|
||||
}
|
||||
228
internal/mcp/registry_test.go
Normal file
228
internal/mcp/registry_test.go
Normal file
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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: <server-name>
|
||||
- type: lsp, name: <language-server-name>
|
||||
- type: tool, name: <tool-name>
|
||||
|
||||
Output ONLY the skill file content, starting with ---`, name, description, target)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
362
internal/workflow/engine.go
Normal file
362
internal/workflow/engine.go
Normal file
@@ -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
|
||||
})
|
||||
}
|
||||
172
internal/workflow/planner.go
Normal file
172
internal/workflow/planner.go
Normal file
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -447,7 +447,14 @@ function PanelSkills({ skillList, t }) {
|
||||
<div key={i} className="config-skill-row">
|
||||
<span className="config-skill-name">{s.name}</span>
|
||||
<span className="badge neutral">{s.target || 'both'}</span>
|
||||
{s.version && <span className="badge" style={{ fontSize: 10 }}>{s.version}</span>}
|
||||
{s.category && <span className="badge" style={{ fontSize: 10, opacity: 0.7 }}>{s.category}</span>}
|
||||
<span className="config-skill-desc">{s.description}</span>
|
||||
{s.dependencies && s.dependencies.length > 0 && (
|
||||
<div style={{ marginTop: 4, fontSize: 10, color: 'var(--muted)' }}>
|
||||
deps: {s.dependencies.map(d => d.name).join(', ')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
|
||||
@@ -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 (
|
||||
<div className="dashboard-layout">
|
||||
<div className="dashboard-content">
|
||||
<div className="dashboard-grid">
|
||||
<div className="dashboard-section">
|
||||
<div className="dashboard-section-header">
|
||||
<div className="dashboard-section-title">{t('studio.workflows')}</div>
|
||||
</div>
|
||||
<div className="dashboard-workflows-inline">
|
||||
<div className="workflow-section">
|
||||
<div className="section-label">{t('studio.workflows')}</div>
|
||||
<div className="empty-state" style={{ padding: 20 }}>
|
||||
{t('studio.noWorkflow')}
|
||||
</div>
|
||||
</div>
|
||||
<div className="workflow-section">
|
||||
<div className="section-label">{t('studio.activeAgents')}</div>
|
||||
<div className="empty-state" style={{ padding: 20 }}>
|
||||
{t('studio.noWorkflow')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="dashboard-section">
|
||||
<div className="dashboard-section-header">
|
||||
<div className="dashboard-section-title">{t('dashboard.activityLog')}</div>
|
||||
{notifications.length > 0 && (
|
||||
<span className="badge warn">{notifications.length}</span>
|
||||
)}
|
||||
</div>
|
||||
{notifications.length === 0 ? (
|
||||
<div className="empty-state">{t('dashboard.noUpdateData')}</div>
|
||||
) : (
|
||||
<div className="dashboard-notifications-inline">
|
||||
{notifications.map(n => (
|
||||
<div key={n.id} className={`notif-row notif-${n.type}`}>
|
||||
<span className="notif-time">
|
||||
{n.time.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', second: '2-digit' })}
|
||||
</span>
|
||||
<span className="notif-text">{n.text}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className={`tool-card ${isInstalled ? 'installed' : 'missing'}`}>
|
||||
<div className="tool-card-icon">{icon}</div>
|
||||
<div className="tool-card-info">
|
||||
<div className="tool-card-name">{tool.name || 'Unknown'}</div>
|
||||
<div className="tool-card-version">
|
||||
{isInstalled ? (
|
||||
<span className="status-ok">{t('dashboard.installed')}</span>
|
||||
) : (
|
||||
<span className="status-missing">{t('dashboard.missing')}</span>
|
||||
)}
|
||||
{version && <span className="tool-version-text">{version}</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="tool-card-actions">
|
||||
{isInstalled && hasUpdate && (
|
||||
<span className="tool-update-badge" title={`Update to ${tool.latestVersion || 'latest'}`}>
|
||||
↑ {tool.latestVersion || 'new'}
|
||||
</span>
|
||||
)}
|
||||
{!isInstalled && (
|
||||
<button
|
||||
className="sm primary"
|
||||
onClick={() => onInstall(tool.name)}
|
||||
disabled={installing}
|
||||
>
|
||||
{installing ? '...' : t('dashboard.install')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className={`notif-row ${typeClass}`}>
|
||||
<span className="notif-time">{time}</span>
|
||||
<span className="notif-icon">{icon}</span>
|
||||
<span className="notif-text">{text}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function QuickActionButton({ icon, label, onClick, loading, disabled }) {
|
||||
return (
|
||||
<button
|
||||
className="quick-action-btn"
|
||||
onClick={onClick}
|
||||
disabled={disabled || loading}
|
||||
>
|
||||
{loading ? <span className="spinner" style={{ width: 14, height: 14 }} /> : <span className="quick-action-icon">{icon}</span>}
|
||||
<span className="quick-action-label">{label}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="dashboard-layout">
|
||||
<div className="dashboard-tabs">
|
||||
<button
|
||||
className={`dashboard-tab ${activeTab === 'tools' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('tools')}
|
||||
>
|
||||
<span className="tab-icon">🔧</span>
|
||||
{t('dashboard.tools')}
|
||||
<span className="tab-count">{installedCount}</span>
|
||||
</button>
|
||||
<button
|
||||
className={`dashboard-tab ${activeTab === 'activity' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('activity')}
|
||||
>
|
||||
<span className="tab-icon">📋</span>
|
||||
{t('dashboard.activity')}
|
||||
{notifications.length > 0 && <span className="tab-count warn">{notifications.length}</span>}
|
||||
</button>
|
||||
<button
|
||||
className={`dashboard-tab ${activeTab === 'actions' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('actions')}
|
||||
>
|
||||
<span className="tab-icon">⚡</span>
|
||||
{t('dashboard.quickActions')}
|
||||
</button>
|
||||
<button
|
||||
className={`dashboard-tab ${activeTab === 'status' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('status')}
|
||||
>
|
||||
<span className="tab-icon">📡</span>
|
||||
{t('dashboard.status') || 'Status'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="dashboard-content">
|
||||
{activeTab === 'tools' && (
|
||||
<div className="dashboard-tools-panel">
|
||||
<div className="dashboard-section-header">
|
||||
<div className="dashboard-section-title">{t('dashboard.systemOverview')}</div>
|
||||
<div className="dashboard-tools-stats">
|
||||
<span className="stat-ok">{installedCount} {t('dashboard.installed')}</span>
|
||||
{missingCount > 0 && <span className="stat-missing">{missingCount} {t('dashboard.missing')}</span>}
|
||||
</div>
|
||||
</div>
|
||||
{systemInfo && (
|
||||
<div className="dashboard-system-info">
|
||||
<span className="sys-info-item">{systemInfo.os || systemInfo.platform || 'Unknown'}</span>
|
||||
<span className="sys-info-sep">·</span>
|
||||
<span className="sys-info-item">{systemInfo.arch || 'Unknown'}</span>
|
||||
{systemInfo.shell && <><span className="sys-info-sep">·</span><span className="sys-info-item">{systemInfo.shell}</span></>}
|
||||
</div>
|
||||
)}
|
||||
<div className="tools-grid">
|
||||
{tools.length === 0 && (
|
||||
<div className="empty-state">{t('dashboard.noTools')}</div>
|
||||
)}
|
||||
{tools.map((tool, i) => (
|
||||
<ToolCard
|
||||
key={tool.name || i}
|
||||
tool={tool}
|
||||
onInstall={handleInstallTool}
|
||||
installing={installing}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'activity' && (
|
||||
<div className="dashboard-activity-panel">
|
||||
<div className="dashboard-section-header">
|
||||
<div className="dashboard-section-title">{t('dashboard.activityLog')}</div>
|
||||
<button className="sm ghost" onClick={() => setNotifications([])} disabled={notifications.length === 0}>
|
||||
{t('dashboard.clearLog')}
|
||||
</button>
|
||||
</div>
|
||||
{notifications.length === 0 ? (
|
||||
<div className="empty-state">{t('dashboard.noActivity')}</div>
|
||||
) : (
|
||||
<div className="activity-log">
|
||||
{notifications.map(entry => (
|
||||
<ActivityItem key={entry.id} entry={entry} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'actions' && (
|
||||
<div className="dashboard-actions-panel">
|
||||
<div className="dashboard-section-header">
|
||||
<div className="dashboard-section-title">{t('dashboard.quickActions')}</div>
|
||||
</div>
|
||||
<div className="quick-actions-grid">
|
||||
<QuickActionButton
|
||||
icon="🔍"
|
||||
label={t('dashboard.rescanSystem')}
|
||||
onClick={handleRescan}
|
||||
loading={scanLoading}
|
||||
/>
|
||||
<QuickActionButton
|
||||
icon="📦"
|
||||
label={t('dashboard.installMissing')}
|
||||
onClick={handleInstallMissing}
|
||||
loading={installing}
|
||||
disabled={missingCount === 0}
|
||||
/>
|
||||
<QuickActionButton
|
||||
icon="🔄"
|
||||
label={t('dashboard.checkUpdates')}
|
||||
onClick={handleCheckUpdates}
|
||||
loading={loading}
|
||||
/>
|
||||
<QuickActionButton
|
||||
icon="⚙"
|
||||
label={t('dashboard.configureMCP')}
|
||||
onClick={handleConfigureMCP}
|
||||
loading={mcpLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{updates.length > 0 && (
|
||||
<div className="dashboard-updates-section">
|
||||
<div className="dashboard-section-header">
|
||||
<div className="dashboard-section-title">{t('dashboard.updates')}</div>
|
||||
<span className="badge warn">{updates.length}</span>
|
||||
</div>
|
||||
<div className="updates-list">
|
||||
{updates.map((update, i) => (
|
||||
<div key={update.name || i} className="update-row">
|
||||
<div className="update-info">
|
||||
<span className="update-name">{update.name || 'Unknown'}</span>
|
||||
<span className="update-versions">
|
||||
{update.current || update.version || '?'} → {update.latest || update.target || '?'}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
className="sm"
|
||||
onClick={() => api.runUpdate(update.name)}
|
||||
disabled={loading}
|
||||
>
|
||||
{t('dashboard.update')}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'status' && (
|
||||
<div className="dashboard-status-panel">
|
||||
{dashboardStatus ? (
|
||||
<>
|
||||
<div className="dashboard-section-header">
|
||||
<div className="dashboard-section-title">MCP Servers</div>
|
||||
<span className="badge">{dashboardStatus.mcp?.healthy || 0}/{dashboardStatus.mcp?.total || 0} healthy</span>
|
||||
</div>
|
||||
<div className="tools-grid" style={{ marginBottom: 16 }}>
|
||||
{(dashboardStatus.mcp?.servers || []).map((s, i) => (
|
||||
<div key={i} className={`tool-card ${s.healthy ? 'installed' : s.installed ? '' : 'missing'}`}>
|
||||
<div className="tool-card-info">
|
||||
<div className="tool-card-name">{s.name}</div>
|
||||
<div className="tool-card-version">
|
||||
{s.healthy ? <span className="status-ok">healthy</span> :
|
||||
s.installed ? <span className="status-missing">installed</span> :
|
||||
<span className="status-missing">not found</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="dashboard-section-header">
|
||||
<div className="dashboard-section-title">LSP Servers</div>
|
||||
<span className="badge">{dashboardStatus.lsp?.installed || 0}/{dashboardStatus.lsp?.total || 0} installed</span>
|
||||
</div>
|
||||
<div className="tools-grid" style={{ marginBottom: 16 }}>
|
||||
{(dashboardStatus.lsp?.servers || []).filter(s => s.installed).map((s, i) => (
|
||||
<div key={i} className="tool-card installed">
|
||||
<div className="tool-card-info">
|
||||
<div className="tool-card-name">{s.name}</div>
|
||||
<div className="tool-card-version">
|
||||
<span className="status-ok">{s.language}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="dashboard-section-header">
|
||||
<div className="dashboard-section-title">Skills</div>
|
||||
<span className="badge">{dashboardStatus.skills?.total || 0} deployed</span>
|
||||
{(dashboardStatus.skills?.issues || []).length > 0 && (
|
||||
<span className="badge warn">{(dashboardStatus.skills.issues || []).length} issues</span>
|
||||
)}
|
||||
</div>
|
||||
{(dashboardStatus.skills?.issues || []).length > 0 && (
|
||||
<div style={{ fontSize: 12, color: 'var(--warning)', marginBottom: 8 }}>
|
||||
{(dashboardStatus.skills.issues || []).map((issue, i) => (
|
||||
<div key={i}>{issue}</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="empty-state">Loading status...</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user