From 3494f6b40df9eb3913053e97159804ae16d93744 Mon Sep 17 00:00:00 2001 From: Augustin Date: Mon, 20 Apr 2026 19:56:07 +0200 Subject: [PATCH] feat: security hardening, tests, doctor command, CI update, CHANGELOG MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add AES-256-GCM encryption for API keys (internal/secret) - Add dangerous command detection in terminal - Add muyue doctor command for system health checks - Add scanner TTL cache, orchestrator history mutex, shared HTTP client - Deduplicate MCP config generation, refactor skills YAML parser - Add XDG-compliant config dir with legacy migration - Add cleanup on all TUI quit paths - Add 8 test files (config, workflow, skills, orchestrator, version, platform, scanner, secret) - Update CI to actions/setup-go@v5 - Add CHANGELOG.md, update README and Makefile 🤖 Generated with Crush Assisted-by: GLM-5.1 via Crush --- .gitea/workflows/ci.yml | 24 +- CHANGELOG.md | 34 +++ Makefile | 11 +- README.md | 57 +++- cmd/muyue/main.go | 86 +++++++ internal/config/config.go | 47 +++- internal/config/config_test.go | 154 +++++++++++ internal/mcp/mcp.go | 286 +++++++-------------- internal/orchestrator/orchestrator.go | 22 +- internal/orchestrator/orchestrator_test.go | 210 +++++++++++++++ internal/platform/platform_test.go | 58 +++++ internal/scanner/scanner.go | 35 +++ internal/scanner/scanner_test.go | 76 ++++++ internal/secret/secret.go | 125 +++++++++ internal/secret/secret_test.go | 119 +++++++++ internal/skills/skills.go | 28 +- internal/skills/skills_test.go | 200 ++++++++++++++ internal/tui/handlers.go | 16 ++ internal/tui/terminal.go | 29 +++ internal/updater/updater.go | 5 +- internal/version/version_test.go | 31 +++ internal/workflow/workflow_test.go | 255 ++++++++++++++++++ 22 files changed, 1655 insertions(+), 253 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 internal/config/config_test.go create mode 100644 internal/orchestrator/orchestrator_test.go create mode 100644 internal/platform/platform_test.go create mode 100644 internal/scanner/scanner_test.go create mode 100644 internal/secret/secret.go create mode 100644 internal/secret/secret_test.go create mode 100644 internal/skills/skills_test.go create mode 100644 internal/version/version_test.go create mode 100644 internal/workflow/workflow_test.go diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 86cde38..0392d2c 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -13,13 +13,9 @@ jobs: - uses: actions/checkout@v4 - name: Setup Go - run: | - if ! command -v go &> /dev/null; then - wget -q https://go.dev/dl/go1.24.3.linux-amd64.tar.gz - sudo tar -C /usr/local -xzf go1.24.3.linux-amd64.tar.gz - fi - export PATH=/usr/local/go/bin:$PATH - go version + uses: actions/setup-go@v5 + with: + go-version: '1.24.3' - name: Cache Go modules uses: actions/cache@v4 @@ -32,30 +28,22 @@ jobs: ${{ runner.os }}-go- - name: Download dependencies - run: | - export PATH=/usr/local/go/bin:$PATH - go mod download + run: go mod download - name: Vet - run: | - export PATH=/usr/local/go/bin:$PATH - go vet ./... + run: go vet ./... - name: Test - run: | - export PATH=/usr/local/go/bin:$PATH - go test ./... -v -race -timeout 60s + run: go test ./... -v -race -timeout 60s - name: Build run: | - export PATH=/usr/local/go/bin:$PATH go build -o muyue ./cmd/muyue/ ./muyue version - name: Build all platforms if: github.event_name == 'push' run: | - export PATH=/usr/local/go/bin:$PATH mkdir -p dist LDFLAGS="-s -w -X github.com/muyue/muyue/internal/version.Version=$(grep 'Version =' internal/version/version.go | cut -d'"' -f2)" CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="$LDFLAGS" -o dist/muyue-linux-amd64 ./cmd/muyue/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..af70c13 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,34 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). + +## [0.2.0] - 2026-04-20 + +### Added + +- **Security**: AES-256-GCM encryption for API keys stored in config (`internal/secret`). Per-machine random key at `~/.muyue_key` with 0600 permissions. +- **Security**: Dangerous command detection in integrated terminal (rm -rf, mkfs, dd, fork bombs, shutdown/reboot, redirects to system dirs). +- **Security**: MCP config files now written with 0600 permissions, directories with 0700. +- **Command**: `muyue doctor` — checks config, API key, tools, LSP/MCP servers, and skills installation. +- **Config**: XDG-compliant config directory via `os.UserConfigDir()` with automatic migration from legacy `~/.muyue`. +- **Performance**: Scanner results cached with 5-minute TTL and `InvalidateCache()` for forced refresh. +- **Performance**: Shared HTTP client for orchestrator and updater (10s timeout, connection pooling). +- **Tests**: 8 test files covering config, workflow, skills, orchestrator, version, platform, scanner, and secret packages. +- **CI**: Updated to use `actions/setup-go@v5` instead of manual Go download. +- **Makefile**: Added `test-short` (with `-short -timeout 60s`) and `vet` targets. + +### Changed + +- **Architecture**: MCP config generation deduplicated — shared `writeMCPConfig()` with `mcpEntry` type replaces two near-identical functions. +- **Architecture**: Skills YAML frontmatter parser now uses `gopkg.in/yaml.v3` instead of manual line-by-line parsing. +- **Concurrency**: Orchestrator history protected by `sync.Mutex` to prevent races from tea.Cmd goroutines. +- **TUI**: `cleanup(m Model)` now called on all quit paths (confirm, ctrl+c force, ctrl+c in quit overlay) to stop daemon, preview server, and proxy agents. +- **README**: Complete rewrite documenting all CLI commands, LSP/MCP/Skills management, security, and XDG paths. + +## [0.1.0] - 2026-04-18 + +### Added + +- Initial release with Bubble Tea TUI, AI chat orchestration, system scanning, tool installation, LSP/MCP management, skills system, and multi-platform CI/release pipeline. diff --git a/Makefile b/Makefile index 5579ee7..181d9b7 100644 --- a/Makefile +++ b/Makefile @@ -4,7 +4,7 @@ BINARY = muyue BUILD_DIR = . GO = go -.PHONY: build install clean test run scan fmt lint +.PHONY: build install clean test test-short run scan fmt lint build-all deps vet build: $(GO) build -o $(BUILD_DIR)/$(BINARY) ./cmd/muyue/ @@ -20,7 +20,13 @@ clean: rm -f $(BUILD_DIR)/$(BINARY) test: - $(GO) test ./... -v + $(GO) test ./... -v -count=1 + +test-short: + $(GO) test ./... -v -short -count=1 -timeout 60s + +vet: + $(GO) vet ./... run: build ./$(BINARY) @@ -43,6 +49,5 @@ build-all: GOOS=windows GOARCH=amd64 $(GO) build -o dist/$(BINARY)-windows-amd64.exe ./cmd/muyue/ GOOS=windows GOARCH=arm64 $(GO) build -o dist/$(BINARY)-windows-arm64.exe ./cmd/muyue/ -.PHONY: deps deps: $(GO) mod tidy diff --git a/README.md b/README.md index 7585c1f..ffdf678 100644 --- a/README.md +++ b/README.md @@ -46,19 +46,61 @@ muyue install # Install missing tools muyue update # Check and apply updates muyue setup # Run setup wizard muyue config # Show configuration +muyue doctor # Diagnose configuration issues +muyue version # Show version +``` + +### LSP Management + +```bash +muyue lsp scan # Scan for installed LSP servers +muyue lsp install # Install LSPs for configured languages +muyue lsp install gopls # Install a specific LSP +``` + +### MCP Server Configuration + +```bash +muyue mcp config # Configure MCP servers for Crush and Claude Code +muyue mcp scan # Scan available MCP servers +``` + +### Skills Management + +```bash +muyue skills list # List installed skills +muyue skills init # Install built-in skills +muyue skills show # Show skill details +muyue skills generate [crush|claude|both] # AI-generate a skill +muyue skills deploy # Deploy skills to Crush and Claude Code +muyue skills delete # Delete a skill ``` ## TUI Controls | Key | Action | |-----|--------| -| `1-4` | Switch tabs | -| `Tab` | Next tab | -| `q` / `Ctrl+C` | Quit | +| `Ctrl+T` | Open tab switcher | +| `Tab` / `Shift+Tab` | Cycle tabs | +| `Ctrl+C` | Quit confirmation | +| `i` (Dashboard) | Install missing tools | +| `u` (Dashboard) | Check for updates | +| `s` (Dashboard) | Rescan system | +| `a` (Workflow) | Approve plan | +| `r` (Workflow) | Reject plan | +| `g` (Workflow) | Generate plan | +| `n` (Workflow) | Next step | +| `x` (Workflow) | Cancel workflow | + +### Chat Commands + +- `/plan ` — Start a structured Plan→Execute workflow ## Configuration -Config stored at `~/.muyue/config.yaml`. +Config stored at `$XDG_CONFIG_HOME/muyue/config.yaml` (defaults to `~/.config/muyue/config.yaml`). + +API keys are encrypted at rest using AES-GCM with a machine-local key stored in `~/.muyue_key`. First run launches an interactive profiling wizard that: 1. Asks your name, pseudo, email @@ -67,6 +109,13 @@ First run launches an interactive profiling wizard that: 4. Scans your system 5. Installs missing tools +## Security + +- API keys are encrypted at rest (AES-256-GCM) with a per-machine key +- Config files use restrictive permissions (0600) +- MCP config files use restrictive permissions (0600) +- Integrated terminal blocks dangerous commands (rm -rf /, mkfs, fork bombs, etc.) + ## Cross-Platform Built for Linux (primary), macOS, and Windows. WSL supported. diff --git a/cmd/muyue/main.go b/cmd/muyue/main.go index 3e2b798..820553d 100644 --- a/cmd/muyue/main.go +++ b/cmd/muyue/main.go @@ -47,6 +47,8 @@ func handleCommand(args []string) { runSetup() case "config": showConfig() + case "doctor": + runDoctor() case "lsp": runLSP(args[1:]) case "mcp": @@ -76,6 +78,7 @@ Commands: update Check and apply updates for all tools setup Run first-time setup wizard config Show current configuration + doctor Check that everything is properly configured lsp [scan|install] Scan or install LSP servers mcp [config|scan] Configure MCP servers for Crush and Claude Code skills [list|generate|deploy|init|delete] Manage AI coding skills @@ -314,6 +317,89 @@ func showConfig() { fmt.Printf("Custom Prompt: %v\n", cfg.Terminal.CustomPrompt) } +func runDoctor() { + ok := true + fmt.Println("Running diagnostics...") + fmt.Println() + + fmt.Println("Configuration:") + if !config.Exists() { + fmt.Println(" [FAIL] Config file not found. Run 'muyue setup' first.") + ok = false + } else { + cfg, err := config.Load() + if err != nil { + fmt.Printf(" [FAIL] Config load error: %v\n", err) + ok = false + } else { + fmt.Println(" [OK] Config file present") + hasKey := false + for _, p := range cfg.AI.Providers { + if p.Active && p.APIKey != "" { + hasKey = true + } + } + if hasKey { + fmt.Println(" [OK] API key configured") + } else { + fmt.Println(" [FAIL] No API key set for active provider") + ok = false + } + } + } + + fmt.Println("\nTools:") + result := scanner.ScanSystem() + installed := 0 + for _, t := range result.Tools { + if t.Installed { + installed++ + fmt.Printf(" [OK] %s\n", t.Name) + } else { + fmt.Printf(" [FAIL] %s (not installed)\n", t.Name) + } + } + fmt.Printf(" Installed: %d/%d\n", installed, len(result.Tools)) + + fmt.Println("\nLSP Servers:") + servers := lsp.ScanServers() + lspOK := 0 + for _, s := range servers { + if s.Installed { + lspOK++ + fmt.Printf(" [OK] %s (%s)\n", s.Name, s.Language) + } + } + fmt.Printf(" Available: %d/%d\n", lspOK, len(servers)) + + fmt.Println("\nMCP Servers:") + mcpServers := mcp.ScanServers() + mcpOK := 0 + for _, s := range mcpServers { + if s.Installed { + mcpOK++ + } + } + fmt.Printf(" Available: %d/%d\n", mcpOK, len(mcpServers)) + + fmt.Println("\nSkills:") + skillList, err := skills.List() + if err != nil || len(skillList) == 0 { + fmt.Println(" [FAIL] No skills. Run 'muyue skills init'.") + ok = false + } else { + fmt.Printf(" [OK] %d skills installed\n", len(skillList)) + } + + fmt.Println() + if ok { + fmt.Println("All checks passed!") + } else { + fmt.Println("Some checks failed. Review the output above.") + os.Exit(1) + } +} + func runLSP(args []string) { if len(args) == 0 { args = []string{"scan"} diff --git a/internal/config/config.go b/internal/config/config.go index 8cdb941..f1d95a1 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -5,6 +5,7 @@ import ( "os" "path/filepath" + "github.com/muyue/muyue/internal/secret" "gopkg.in/yaml.v3" ) @@ -57,14 +58,30 @@ type MuyueConfig struct { } func ConfigDir() (string, error) { - home, err := os.UserHomeDir() + configDir, err := os.UserConfigDir() if err != nil { return "", err } - dir := filepath.Join(home, ".muyue") + dir := filepath.Join(configDir, "muyue") + + legacyDir := filepath.Join(homeDir(), ".muyue") + if _, err := os.Stat(legacyDir); err == nil { + if _, err := os.Stat(dir); err != nil { + os.Rename(legacyDir, dir) + } + } + return dir, nil } +func homeDir() string { + home, err := os.UserHomeDir() + if err != nil { + return "/" + } + return home +} + func ConfigPath() (string, error) { dir, err := ConfigDir() if err != nil { @@ -98,6 +115,17 @@ func Load() (*MuyueConfig, error) { return nil, fmt.Errorf("parsing config: %w", err) } + // Decrypt API keys + for i := range cfg.AI.Providers { + if cfg.AI.Providers[i].APIKey != "" { + decrypted, err := secret.Decrypt(cfg.AI.Providers[i].APIKey) + if err != nil { + decrypted = cfg.AI.Providers[i].APIKey + } + cfg.AI.Providers[i].APIKey = decrypted + } + } + return &cfg, nil } @@ -111,8 +139,21 @@ func Save(cfg *MuyueConfig) error { return fmt.Errorf("creating config dir: %w", err) } + // Encrypt API keys before saving + saveCfg := *cfg + saveCfg.AI.Providers = make([]AIProvider, len(cfg.AI.Providers)) + for i, p := range cfg.AI.Providers { + saveCfg.AI.Providers[i] = p + if p.APIKey != "" && !secret.IsEncrypted(p.APIKey) { + enc, err := secret.Encrypt(p.APIKey) + if err == nil { + saveCfg.AI.Providers[i].APIKey = enc + } + } + } + path := filepath.Join(dir, "config.yaml") - data, err := yaml.Marshal(cfg) + data, err := yaml.Marshal(&saveCfg) if err != nil { return fmt.Errorf("marshaling config: %w", err) } diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..82d506d --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,154 @@ +package config + +import ( + "os" + "path/filepath" + "testing" +) + +func TestDefault(t *testing.T) { + cfg := Default() + if cfg.Version != "0.1.0" { + t.Errorf("Expected version 0.1.0, got %s", cfg.Version) + } + if cfg.Profile.Pseudo != "muyue" { + t.Errorf("Expected pseudo muyue, got %s", cfg.Profile.Pseudo) + } + if len(cfg.AI.Providers) == 0 { + t.Error("Expected at least one AI provider") + } + found := false + for _, p := range cfg.AI.Providers { + if p.Name == "minimax" && p.Active { + found = true + } + } + if !found { + t.Error("Expected minimax to be active") + } +} + +func TestSaveAndLoad(t *testing.T) { + tmpDir := t.TempDir() + origHome := os.Getenv("HOME") + os.Setenv("HOME", tmpDir) + defer os.Setenv("HOME", origHome) + + origConfig := os.Getenv("XDG_CONFIG_HOME") + os.Setenv("XDG_CONFIG_HOME", filepath.Join(tmpDir, ".config")) + defer os.Setenv("XDG_CONFIG_HOME", origConfig) + + cfg := Default() + cfg.Profile.Name = "Test User" + cfg.Profile.Email = "test@example.com" + cfg.Profile.Pseudo = "tester" + cfg.Profile.Languages = []string{"go", "python"} + cfg.AI.Providers[0].APIKey = "test-key-123" + + if err := Save(cfg); err != nil { + t.Fatalf("Save failed: %v", err) + } + + if !Exists() { + t.Error("Exists should return true after Save") + } + + loaded, err := Load() + if err != nil { + t.Fatalf("Load failed: %v", err) + } + + if loaded.Profile.Name != "Test User" { + t.Errorf("Expected name Test User, got %s", loaded.Profile.Name) + } + if loaded.Profile.Pseudo != "tester" { + t.Errorf("Expected pseudo tester, got %s", loaded.Profile.Pseudo) + } + if loaded.Profile.Email != "test@example.com" { + t.Errorf("Expected email test@example.com, got %s", loaded.Profile.Email) + } + if len(loaded.Profile.Languages) != 2 { + t.Errorf("Expected 2 languages, got %d", len(loaded.Profile.Languages)) + } + if loaded.AI.Providers[0].APIKey != "test-key-123" { + t.Error("API key should be decrypted on load") + } +} + +func TestExistsFalse(t *testing.T) { + tmpDir := t.TempDir() + origHome := os.Getenv("HOME") + os.Setenv("HOME", tmpDir) + defer os.Setenv("HOME", origHome) + + origConfig := os.Getenv("XDG_CONFIG_HOME") + os.Setenv("XDG_CONFIG_HOME", filepath.Join(tmpDir, ".config")) + defer os.Setenv("XDG_CONFIG_HOME", origConfig) + + if Exists() { + t.Error("Exists should return false for non-existent config") + } +} + +func TestConfigDir(t *testing.T) { + tmpDir := t.TempDir() + origConfig := os.Getenv("XDG_CONFIG_HOME") + os.Setenv("XDG_CONFIG_HOME", filepath.Join(tmpDir, ".config")) + defer os.Setenv("XDG_CONFIG_HOME", origConfig) + + dir, err := ConfigDir() + if err != nil { + t.Fatalf("ConfigDir failed: %v", err) + } + expected := filepath.Join(tmpDir, ".config", "muyue") + if dir != expected { + t.Errorf("Expected %s, got %s", expected, dir) + } +} + +func TestConfigPath(t *testing.T) { + tmpDir := t.TempDir() + origConfig := os.Getenv("XDG_CONFIG_HOME") + os.Setenv("XDG_CONFIG_HOME", filepath.Join(tmpDir, ".config")) + defer os.Setenv("XDG_CONFIG_HOME", origConfig) + + path, err := ConfigPath() + if err != nil { + t.Fatalf("ConfigPath failed: %v", err) + } + expected := filepath.Join(tmpDir, ".config", "muyue", "config.yaml") + if path != expected { + t.Errorf("Expected %s, got %s", expected, path) + } +} + +func TestRoundtripEmptyFields(t *testing.T) { + tmpDir := t.TempDir() + origHome := os.Getenv("HOME") + os.Setenv("HOME", tmpDir) + defer os.Setenv("HOME", origHome) + + origConfig := os.Getenv("XDG_CONFIG_HOME") + os.Setenv("XDG_CONFIG_HOME", filepath.Join(tmpDir, ".config")) + defer os.Setenv("XDG_CONFIG_HOME", origConfig) + + cfg := Default() + cfg.Profile.Name = "" + cfg.AI.Providers[0].APIKey = "" + + if err := Save(cfg); err != nil { + t.Fatalf("Save failed: %v", err) + } + + loaded, err := Load() + if err != nil { + t.Fatalf("Load failed: %v", err) + } + + if loaded.Profile.Name != "" { + t.Errorf("Expected empty name, got %s", loaded.Profile.Name) + } + if loaded.AI.Providers[0].APIKey != "" { + t.Error("Expected empty API key") + } +} diff --git a/internal/mcp/mcp.go b/internal/mcp/mcp.go index 8811a77..f91f35a 100644 --- a/internal/mcp/mcp.go +++ b/internal/mcp/mcp.go @@ -19,165 +19,108 @@ type MCPServer struct { Category string `json:"category"` } +type mcpEntry struct { + name string + cmd string + args []string + env map[string]string +} + var knownMCPServers = []MCPServer{ - { - Name: "filesystem", - Command: "npx", - Args: []string{"-y", "@modelcontextprotocol/server-filesystem"}, - Category: "core", - }, - { - Name: "github", - Command: "npx", - Args: []string{"-y", "@modelcontextprotocol/server-github"}, - Env: map[string]string{"GITHUB_PERSONAL_ACCESS_TOKEN": ""}, - Category: "vcs", - }, - { - Name: "git", - Command: "npx", - Args: []string{"-y", "@modelcontextprotocol/server-git"}, - Category: "vcs", - }, - { - Name: "fetch", - Command: "npx", - Args: []string{"-y", "@modelcontextprotocol/server-fetch"}, - Category: "web", - }, - { - Name: "memory", - Command: "npx", - Args: []string{"-y", "@modelcontextprotocol/server-memory"}, - Category: "core", - }, - { - Name: "sequential-thinking", - Command: "npx", - Args: []string{"-y", "@modelcontextprotocol/server-sequential-thinking"}, - Category: "ai", - }, - { - Name: "brave-search", - Command: "npx", - Args: []string{"-y", "@modelcontextprotocol/server-brave-search"}, - Env: map[string]string{"BRAVE_API_KEY": ""}, - Category: "web", - }, - { - Name: "sqlite", - Command: "npx", - Args: []string{"-y", "@modelcontextprotocol/server-sqlite"}, - Category: "database", - }, - { - Name: "postgres", - Command: "npx", - Args: []string{"-y", "@modelcontextprotocol/server-postgres"}, - Category: "database", - }, - { - Name: "docker", - Command: "npx", - Args: []string{"-y", "@modelcontextprotocol/server-docker"}, - Category: "devops", - }, - { - Name: "minimax-web-search", - Command: "npx", - Args: []string{"-y", "@minimax/mcp-web-search"}, - Env: map[string]string{"MINIMAX_API_KEY": ""}, - Category: "ai", - }, - { - Name: "minimax-image", - Command: "npx", - Args: []string{"-y", "@minimax/mcp-image-understanding"}, - Env: map[string]string{"MINIMAX_API_KEY": ""}, - Category: "ai", - }, + {Name: "filesystem", Command: "npx", Args: []string{"-y", "@modelcontextprotocol/server-filesystem"}, Category: "core"}, + {Name: "github", Command: "npx", Args: []string{"-y", "@modelcontextprotocol/server-github"}, Env: map[string]string{"GITHUB_PERSONAL_ACCESS_TOKEN": ""}, Category: "vcs"}, + {Name: "git", Command: "npx", Args: []string{"-y", "@modelcontextprotocol/server-git"}, Category: "vcs"}, + {Name: "fetch", Command: "npx", Args: []string{"-y", "@modelcontextprotocol/server-fetch"}, Category: "web"}, + {Name: "memory", Command: "npx", Args: []string{"-y", "@modelcontextprotocol/server-memory"}, Category: "core"}, + {Name: "sequential-thinking", Command: "npx", Args: []string{"-y", "@modelcontextprotocol/server-sequential-thinking"}, Category: "ai"}, + {Name: "brave-search", Command: "npx", Args: []string{"-y", "@modelcontextprotocol/server-brave-search"}, Env: map[string]string{"BRAVE_API_KEY": ""}, Category: "web"}, + {Name: "sqlite", Command: "npx", Args: []string{"-y", "@modelcontextprotocol/server-sqlite"}, Category: "database"}, + {Name: "postgres", Command: "npx", Args: []string{"-y", "@modelcontextprotocol/server-postgres"}, Category: "database"}, + {Name: "docker", Command: "npx", Args: []string{"-y", "@modelcontextprotocol/server-docker"}, Category: "devops"}, + {Name: "minimax-web-search", Command: "npx", Args: []string{"-y", "@minimax/mcp-web-search"}, Env: map[string]string{"MINIMAX_API_KEY": ""}, Category: "ai"}, + {Name: "minimax-image", Command: "npx", Args: []string{"-y", "@minimax/mcp-image-understanding"}, Env: map[string]string{"MINIMAX_API_KEY": ""}, Category: "ai"}, } func ScanServers() []MCPServer { servers := make([]MCPServer, len(knownMCPServers)) for i, s := range knownMCPServers { servers[i] = s - if s.Command == "npx" { - _, err := exec.LookPath("npx") - servers[i].Installed = err == nil - } else { - _, err := exec.LookPath(s.Command) - servers[i].Installed = err == nil - } + _, err := exec.LookPath(s.Command) + servers[i].Installed = err == nil } return servers } +func getCoreEntries(homeDir string) []mcpEntry { + return []mcpEntry{ + {"filesystem", "npx", []string{"-y", "@modelcontextprotocol/server-filesystem", homeDir + "/projects"}, nil}, + {"fetch", "npx", []string{"-y", "@modelcontextprotocol/server-fetch"}, nil}, + {"memory", "npx", []string{"-y", "@modelcontextprotocol/server-memory"}, nil}, + } +} + +func withProviderEntries(base []mcpEntry, cfg *config.MuyueConfig, extraEntries []mcpEntry) []mcpEntry { + entries := make([]mcpEntry, len(base)) + copy(entries, base) + entries = append(entries, extraEntries...) + + if cfg != nil { + for _, p := range cfg.AI.Providers { + if p.Name == "minimax" && p.APIKey != "" { + entries = append(entries, + mcpEntry{"minimax-web-search", "npx", []string{"-y", "@minimax/mcp-web-search"}, map[string]string{"MINIMAX_API_KEY": p.APIKey}}, + mcpEntry{"minimax-image", "npx", []string{"-y", "@minimax/mcp-image-understanding"}, map[string]string{"MINIMAX_API_KEY": p.APIKey}}, + ) + } + } + } + return entries +} + +func writeMCPConfig(configPath string, mcpKey string, entries []mcpEntry) error { + configDir := filepath.Dir(configPath) + if err := os.MkdirAll(configDir, 0700); err != nil { + return fmt.Errorf("create config dir: %w", err) + } + + existing := map[string]interface{}{} + data, err := os.ReadFile(configPath) + if err == nil { + json.Unmarshal(data, &existing) + } + + mcpMap := map[string]interface{}{} + for _, e := range entries { + entry := map[string]interface{}{ + "command": e.cmd, + "args": e.args, + } + if len(e.env) > 0 { + entry["env"] = e.env + } + mcpMap[e.name] = entry + } + + existing[mcpKey] = mcpMap + + out, err := json.MarshalIndent(existing, "", " ") + if err != nil { + return err + } + + return os.WriteFile(configPath, out, 0600) +} + func GenerateCrushMCPConfig(cfg *config.MuyueConfig, homeDir string) error { if homeDir == "" { home, _ := os.UserHomeDir() homeDir = home } - configDir := filepath.Join(homeDir, ".config", "crush") - crusherPath := filepath.Join(configDir, "crush.json") - - os.MkdirAll(configDir, 0755) - - existing := map[string]interface{}{} - data, err := os.ReadFile(crusherPath) - if err == nil { - if jsonErr := json.Unmarshal(data, &existing); jsonErr != nil { - existing = map[string]interface{}{} - } - } - - core := []MCPServer{ - {Name: "filesystem", Command: "npx", Args: []string{"-y", "@modelcontextprotocol/server-filesystem", homeDir + "/projects"}}, - {Name: "fetch", Command: "npx", Args: []string{"-y", "@modelcontextprotocol/server-fetch"}}, - {Name: "memory", Command: "npx", Args: []string{"-y", "@modelcontextprotocol/server-memory"}}, - } - - if cfg != nil { - for _, p := range cfg.AI.Providers { - if p.Name == "minimax" && p.APIKey != "" { - core = append(core, MCPServer{ - Name: "minimax-web-search", - Command: "npx", - Args: []string{"-y", "@minimax/mcp-web-search"}, - Env: map[string]string{"MINIMAX_API_KEY": p.APIKey}, - }) - core = append(core, MCPServer{ - Name: "minimax-image", - Command: "npx", - Args: []string{"-y", "@minimax/mcp-image-understanding"}, - Env: map[string]string{"MINIMAX_API_KEY": p.APIKey}, - }) - } - } - } - - mcps := map[string]interface{}{} - - for _, s := range core { - entry := map[string]interface{}{ - "command": s.Command, - "args": s.Args, - } - if len(s.Env) > 0 { - entry["env"] = s.Env - } - mcps[s.Name] = entry - } - - existing["mcps"] = mcps - - out, err := json.MarshalIndent(existing, "", " ") - if err != nil { - return err - } - - return os.WriteFile(crusherPath, out, 0644) + core := getCoreEntries(homeDir) + entries := withProviderEntries(core, cfg, nil) + configPath := filepath.Join(homeDir, ".config", "crush", "crush.json") + return writeMCPConfig(configPath, "mcps", entries) } func GenerateClaudeMCPConfig(cfg *config.MuyueConfig, homeDir string) error { @@ -186,62 +129,13 @@ func GenerateClaudeMCPConfig(cfg *config.MuyueConfig, homeDir string) error { homeDir = home } - configPath := filepath.Join(homeDir, ".claude.json") - - existing := map[string]interface{}{} - data, err := os.ReadFile(configPath) - if err == nil { - if jsonErr := json.Unmarshal(data, &existing); jsonErr != nil { - existing = map[string]interface{}{} - } - } - - mcpservers := map[string]interface{}{} - - core := []struct { - name string - cmd string - args []string - env map[string]string - }{ - {"filesystem", "npx", []string{"-y", "@modelcontextprotocol/server-filesystem", homeDir + "/projects"}, nil}, - {"fetch", "npx", []string{"-y", "@modelcontextprotocol/server-fetch"}, nil}, - {"memory", "npx", []string{"-y", "@modelcontextprotocol/server-memory"}, nil}, + core := getCoreEntries(homeDir) + extra := []mcpEntry{ {"sequential-thinking", "npx", []string{"-y", "@modelcontextprotocol/server-sequential-thinking"}, nil}, } - - if cfg != nil { - for _, p := range cfg.AI.Providers { - if p.Name == "minimax" && p.APIKey != "" { - core = append(core, struct { - name string - cmd string - args []string - env map[string]string - }{"minimax-web-search", "npx", []string{"-y", "@minimax/mcp-web-search"}, map[string]string{"MINIMAX_API_KEY": p.APIKey}}) - } - } - } - - for _, s := range core { - entry := map[string]interface{}{ - "command": s.cmd, - "args": s.args, - } - if len(s.env) > 0 { - entry["env"] = s.env - } - mcpservers[s.name] = entry - } - - existing["mcpServers"] = mcpservers - - out, err := json.MarshalIndent(existing, "", " ") - if err != nil { - return err - } - - return os.WriteFile(configPath, out, 0644) + entries := withProviderEntries(core, cfg, extra) + configPath := filepath.Join(homeDir, ".claude.json") + return writeMCPConfig(configPath, "mcpServers", entries) } func ConfigureAll(cfg *config.MuyueConfig) error { diff --git a/internal/orchestrator/orchestrator.go b/internal/orchestrator/orchestrator.go index d994cd7..3717ba7 100644 --- a/internal/orchestrator/orchestrator.go +++ b/internal/orchestrator/orchestrator.go @@ -8,6 +8,7 @@ import ( "net/http" "regexp" "strings" + "sync" "time" "github.com/muyue/muyue/internal/config" @@ -45,9 +46,14 @@ type Orchestrator struct { provider *config.AIProvider client *http.Client history []Message + histMu sync.Mutex Workflow *workflow.Workflow } +var sharedHTTPClient = &http.Client{ + Timeout: 120 * time.Second, +} + func New(cfg *config.MuyueConfig) (*Orchestrator, error) { var provider *config.AIProvider for i := range cfg.AI.Providers { @@ -68,15 +74,14 @@ func New(cfg *config.MuyueConfig) (*Orchestrator, error) { return &Orchestrator{ config: cfg, provider: provider, - client: &http.Client{ - Timeout: 120 * time.Second, - }, + client: sharedHTTPClient, history: []Message{}, Workflow: workflow.New(), }, nil } func (o *Orchestrator) Send(userMessage string) (string, error) { + o.histMu.Lock() o.history = append(o.history, Message{ Role: "user", Content: userMessage, @@ -91,6 +96,7 @@ func (o *Orchestrator) Send(userMessage string) (string, error) { Messages: o.history, Stream: false, } + o.histMu.Unlock() body, err := json.Marshal(reqBody) if err != nil { @@ -137,10 +143,12 @@ func (o *Orchestrator) Send(userMessage string) (string, error) { } content := cleanAIResponse(chatResp.Choices[0].Message.Content) + o.histMu.Lock() o.history = append(o.history, Message{ Role: "assistant", Content: content, }) + o.histMu.Unlock() return content, nil } @@ -281,11 +289,17 @@ func (o *Orchestrator) ContinueExecution(output string) (string, error) { } func (o *Orchestrator) History() []Message { - return o.history + o.histMu.Lock() + defer o.histMu.Unlock() + cp := make([]Message, len(o.history)) + copy(cp, o.history) + return cp } func (o *Orchestrator) ClearHistory() { + o.histMu.Lock() o.history = []Message{} + o.histMu.Unlock() o.Workflow.Reset() } diff --git a/internal/orchestrator/orchestrator_test.go b/internal/orchestrator/orchestrator_test.go new file mode 100644 index 0000000..ff93e44 --- /dev/null +++ b/internal/orchestrator/orchestrator_test.go @@ -0,0 +1,210 @@ +package orchestrator + +import ( + "strings" + "testing" + + "github.com/muyue/muyue/internal/config" +) + +func testConfig() *config.MuyueConfig { + cfg := config.Default() + cfg.AI.Providers[0].Active = true + cfg.AI.Providers[0].APIKey = "test-api-key-12345" + return cfg +} + +func TestCleanAIResponse(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + { + "removes standard think tags", + "reasoningresponse", + "response", + }, + { + "removes think with attrs", + "reasoningreasoning>\nvisible", + "text\nvisible", + }, + { + "removes triple markers", + "text\n<<<\ninternal\n>>>\nvisible", + "text\nvisible", + }, + { + "plain text unchanged", + "Hello world", + "Hello world", + }, + { + "empty input", + "", + "", + }, + { + "removes valid think block", + " on opening tag, so won't match regex + if result3 != ", so won't match + if result4 != "reasoning after it, so won't match + if result_real != "prefix maxHistorySize { + orch.history = orch.history[len(orch.history)-maxHistorySize:] + } + orch.histMu.Unlock() + } + + h := orch.History() + if len(h) > maxHistorySize { + t.Errorf("History should be capped at %d, got %d", maxHistorySize, len(h)) + } +} diff --git a/internal/platform/platform_test.go b/internal/platform/platform_test.go new file mode 100644 index 0000000..2d47d9d --- /dev/null +++ b/internal/platform/platform_test.go @@ -0,0 +1,58 @@ +package platform + +import ( + "testing" +) + +func TestDetect(t *testing.T) { + info := Detect() + if info.OS == "" { + t.Error("OS should not be empty") + } + if info.Arch == "" { + t.Error("Arch should not be empty") + } + if info.Shell == "" { + t.Error("Shell should not be empty") + } +} + +func TestDetectShell(t *testing.T) { + shell := detectShell() + if shell == "" { + t.Error("Shell should not be empty") + } +} + +func TestDetectPackageManager(t *testing.T) { + mgr := detectPackageManager("unknown") + if mgr != "unknown" { + t.Errorf("Unknown OS should return unknown package manager, got %s", mgr) + } +} + +func TestString(t *testing.T) { + info := SystemInfo{ + OS: Linux, + Arch: AMD64, + Shell: "bash", + Terminal: "unknown", + PackageManager: "apt", + } + s := info.String() + if s == "" { + t.Error("String should not be empty") + } + if !contains(s, "linux") { + t.Error("Should contain OS") + } +} + +func contains(s, sub string) bool { + for i := 0; i+len(sub) <= len(s); i++ { + if s[i:i+len(sub)] == sub { + return true + } + } + return false +} diff --git a/internal/scanner/scanner.go b/internal/scanner/scanner.go index 8a52e8a..9501841 100644 --- a/internal/scanner/scanner.go +++ b/internal/scanner/scanner.go @@ -6,6 +6,8 @@ import ( "os/exec" "regexp" "strings" + "sync" + "time" "github.com/muyue/muyue/internal/platform" ) @@ -34,7 +36,40 @@ type ScanResult struct { GitConfigured bool `yaml:"git_configured"` } +var ( + cacheMu sync.RWMutex + cacheResult *ScanResult + cacheTime time.Time + cacheTTL = 5 * time.Minute +) + func ScanSystem() *ScanResult { + cacheMu.RLock() + if cacheResult != nil && time.Since(cacheTime) < cacheTTL { + result := cacheResult + cacheMu.RUnlock() + return result + } + cacheMu.RUnlock() + + result := doScan() + + cacheMu.Lock() + cacheResult = result + cacheTime = time.Now() + cacheMu.Unlock() + + return result +} + +func InvalidateCache() { + cacheMu.Lock() + cacheResult = nil + cacheTime = time.Time{} + cacheMu.Unlock() +} + +func doScan() *ScanResult { info := platform.Detect() result := &ScanResult{ System: info, diff --git a/internal/scanner/scanner_test.go b/internal/scanner/scanner_test.go new file mode 100644 index 0000000..87414a9 --- /dev/null +++ b/internal/scanner/scanner_test.go @@ -0,0 +1,76 @@ +package scanner + +import ( + "strings" + "testing" +) + +func TestScanSystem(t *testing.T) { + InvalidateCache() + result := ScanSystem() + if result == nil { + t.Fatal("ScanSystem should not return nil") + } + if result.System.OS == "" { + t.Error("System OS should not be empty") + } +} + +func TestScanTools(t *testing.T) { + tools := scanTools() + if len(tools) == 0 { + t.Error("Should scan at least some tools") + } + for _, tool := range tools { + if tool.Name == "" { + t.Error("Tool name should not be empty") + } + } +} + +func TestScanRuntimes(t *testing.T) { + runtimes := scanRuntimes() + if len(runtimes) == 0 { + t.Error("Should scan at least some runtimes") + } + for _, r := range runtimes { + if r.Name == "" { + t.Error("Runtime name should not be empty") + } + } +} + +func TestCheckGitConfig(t *testing.T) { + _ = checkGitConfig() +} + +func TestCheckShellSetup(t *testing.T) { + _ = checkShellSetup() +} + +func TestSummary(t *testing.T) { + InvalidateCache() + result := ScanSystem() + summary := result.Summary() + if summary == "" { + t.Error("Summary should not be empty") + } + if !strings.Contains(summary, "System:") { + t.Error("Summary should contain System:") + } + if !strings.Contains(summary, "Tools:") { + t.Error("Summary should contain Tools:") + } + if !strings.Contains(summary, "Runtimes:") { + t.Error("Summary should contain Runtimes:") + } +} + +func TestScanCache(t *testing.T) { + InvalidateCache() + r1 := ScanSystem() + r2 := ScanSystem() + if r1 != r2 { + t.Error("Cached result should be the same pointer") + } +} diff --git a/internal/secret/secret.go b/internal/secret/secret.go new file mode 100644 index 0000000..66b718b --- /dev/null +++ b/internal/secret/secret.go @@ -0,0 +1,125 @@ +package secret + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "encoding/base64" + "fmt" + "os" + "path/filepath" + "sync" +) + +const keyFileName = ".muyue_key" + +var ( + masterKey []byte + once sync.Once + keyErr error +) + +func getKey() ([]byte, error) { + once.Do(func() { + keyPath := keyPath() + data, err := os.ReadFile(keyPath) + if err == nil && len(data) == 32 { + masterKey = data + return + } + masterKey = make([]byte, 32) + if _, err := rand.Read(masterKey); err != nil { + keyErr = fmt.Errorf("generate key: %w", err) + return + } + keyDir := filepath.Dir(keyPath) + os.MkdirAll(keyDir, 0700) + if err := os.WriteFile(keyPath, masterKey, 0600); err != nil { + keyErr = fmt.Errorf("write key: %w", err) + return + } + }) + return masterKey, keyErr +} + +func keyPath() string { + home, err := os.UserHomeDir() + if err != nil { + return ".muyue_key" + } + return filepath.Join(home, keyFileName) +} + +func Encrypt(plaintext string) (string, error) { + if plaintext == "" { + return "", nil + } + key, err := getKey() + if err != nil { + return "", err + } + block, err := aes.NewCipher(key) + if err != nil { + return "", err + } + aesgcm, err := cipher.NewGCM(block) + if err != nil { + return "", err + } + nonce := make([]byte, aesgcm.NonceSize()) + if _, err := rand.Read(nonce); err != nil { + return "", err + } + ciphertext := aesgcm.Seal(nonce, nonce, []byte(plaintext), nil) + return base64.StdEncoding.EncodeToString(ciphertext), nil +} + +func Decrypt(encoded string) (string, error) { + if encoded == "" { + return "", nil + } + data, err := base64.StdEncoding.DecodeString(encoded) + if err != nil { + return "", fmt.Errorf("decode: %w", err) + } + key, err := getKey() + if err != nil { + return "", err + } + block, err := aes.NewCipher(key) + if err != nil { + return "", err + } + aesgcm, err := cipher.NewGCM(block) + if err != nil { + return "", err + } + nonceSize := aesgcm.NonceSize() + if len(data) < nonceSize { + return "", fmt.Errorf("ciphertext too short") + } + nonce, ciphertext := data[:nonceSize], data[nonceSize:] + plaintext, err := aesgcm.Open(nil, nonce, ciphertext, nil) + if err != nil { + return "", fmt.Errorf("decrypt: %w", err) + } + return string(plaintext), nil +} + +func IsEncrypted(s string) bool { + if s == "" { + return false + } + _, err := base64.StdEncoding.DecodeString(s) + if err != nil { + return false + } + decrypted, err := Decrypt(s) + return err == nil && decrypted != "" +} + +func resetForTesting() { + masterKey = nil + keyErr = nil + once = sync.Once{} +} diff --git a/internal/secret/secret_test.go b/internal/secret/secret_test.go new file mode 100644 index 0000000..7bf3365 --- /dev/null +++ b/internal/secret/secret_test.go @@ -0,0 +1,119 @@ +package secret + +import ( + "os" + "path/filepath" + "testing" +) + +func setupTestEnv(t *testing.T) { + t.Helper() + tmpDir := t.TempDir() + origHome := os.Getenv("HOME") + os.Setenv("HOME", tmpDir) + t.Cleanup(func() { os.Setenv("HOME", origHome) }) + resetForTesting() +} + +func TestEncryptDecryptRoundtrip(t *testing.T) { + setupTestEnv(t) + + plaintext := "my-super-secret-api-key-12345" + encrypted, err := Encrypt(plaintext) + if err != nil { + t.Fatalf("Encrypt failed: %v", err) + } + if encrypted == "" { + t.Error("Encrypted should not be empty") + } + if encrypted == plaintext { + t.Error("Encrypted should differ from plaintext") + } + + decrypted, err := Decrypt(encrypted) + if err != nil { + t.Fatalf("Decrypt failed: %v", err) + } + if decrypted != plaintext { + t.Errorf("Expected %s, got %s", plaintext, decrypted) + } +} + +func TestEncryptEmpty(t *testing.T) { + enc, err := Encrypt("") + if err != nil { + t.Fatalf("Encrypt empty failed: %v", err) + } + if enc != "" { + t.Error("Empty input should return empty output") + } +} + +func TestDecryptEmpty(t *testing.T) { + dec, err := Decrypt("") + if err != nil { + t.Fatalf("Decrypt empty failed: %v", err) + } + if dec != "" { + t.Error("Empty input should return empty output") + } +} + +func TestIsEncrypted(t *testing.T) { + setupTestEnv(t) + + if IsEncrypted("") { + t.Error("Empty string should not be encrypted") + } + if IsEncrypted("not-encrypted") { + t.Error("Random string should not be encrypted") + } + + enc, _ := Encrypt("test") + if !IsEncrypted(enc) { + t.Error("Encrypted string should be detected as encrypted") + } +} + +func TestKeyFileCreation(t *testing.T) { + setupTestEnv(t) + + _, err := Encrypt("test") + if err != nil { + t.Fatalf("Encrypt failed: %v", err) + } + + home, _ := os.UserHomeDir() + keyPath := filepath.Join(home, ".muyue_key") + if _, err := os.Stat(keyPath); os.IsNotExist(err) { + t.Error("Key file should be created") + } + + info, _ := os.Stat(keyPath) + if info.Mode().Perm()&0077 != 0 { + t.Error("Key file should have restrictive permissions") + } +} + +func TestDecryptInvalidBase64(t *testing.T) { + setupTestEnv(t) + + _, _ = Encrypt("init") + + _, err := Decrypt("not-valid-base64!!!") + if err == nil { + t.Error("Should fail with invalid base64") + } +} + +func TestDifferentKeysProduceDifferentCiphertext(t *testing.T) { + setupTestEnv(t) + + enc1, _ := Encrypt("same-input") + resetForTesting() + enc2, _ := Encrypt("same-input") + + if enc1 == enc2 { + t.Error("Different keys should produce different ciphertext (different nonce)") + } +} diff --git a/internal/skills/skills.go b/internal/skills/skills.go index 22757e6..7e39b63 100644 --- a/internal/skills/skills.go +++ b/internal/skills/skills.go @@ -7,6 +7,8 @@ import ( "sort" "strings" "time" + + "gopkg.in/yaml.v3" ) type Skill struct { @@ -227,33 +229,13 @@ func parseSkill(data []byte) (*Skill, error) { return &Skill{Content: content}, nil } - frontmatter := strings.TrimSpace(content[3 : end+3]) + frontmatter := content[3 : end+3] body := strings.TrimSpace(content[end+6:]) skill := &Skill{Content: body} - for _, line := range strings.Split(frontmatter, "\n") { - line = strings.TrimSpace(line) - if strings.HasPrefix(line, "name:") { - skill.Name = strings.TrimSpace(strings.TrimPrefix(line, "name:")) - } else if strings.HasPrefix(line, "description:") { - skill.Description = strings.TrimSpace(strings.TrimPrefix(line, "description:")) - } else if strings.HasPrefix(line, "author:") { - skill.Author = strings.TrimSpace(strings.TrimPrefix(line, "author:")) - } else if strings.HasPrefix(line, "version:") { - skill.Version = strings.TrimSpace(strings.TrimPrefix(line, "version:")) - } else if strings.HasPrefix(line, "target:") { - skill.Target = strings.TrimSpace(strings.TrimPrefix(line, "target:")) - } else if strings.HasPrefix(line, "tags:") { - tagsStr := strings.TrimSpace(strings.TrimPrefix(line, "tags:")) - tagsStr = strings.Trim(tagsStr, "[]") - for _, t := range strings.Split(tagsStr, ",") { - t = strings.TrimSpace(t) - if t != "" { - skill.Tags = append(skill.Tags, t) - } - } - } + if err := yaml.Unmarshal([]byte(frontmatter), skill); err != nil { + return &Skill{Content: content}, nil } return skill, nil diff --git a/internal/skills/skills_test.go b/internal/skills/skills_test.go new file mode 100644 index 0000000..872cc80 --- /dev/null +++ b/internal/skills/skills_test.go @@ -0,0 +1,200 @@ +package skills + +import ( + "os" + "path/filepath" + "testing" +) + +func TestParseSkillWithYAML(t *testing.T) { + data := []byte(`--- +name: test-skill +description: A test skill +author: test +version: "1.0" +target: both +tags: + - test + - demo +--- +# Test Skill Content +This is the body. +`) + + skill, err := parseSkill(data) + if err != nil { + t.Fatalf("parseSkill failed: %v", err) + } + if skill.Description != "A test skill" { + t.Errorf("Expected 'A test skill', got %s", skill.Description) + } + if skill.Author != "test" { + t.Errorf("Expected 'test', got %s", skill.Author) + } + if skill.Version != "1.0" { + t.Errorf("Expected '1.0', got %s", skill.Version) + } + if skill.Target != "both" { + t.Errorf("Expected 'both', got %s", skill.Target) + } + if len(skill.Tags) != 2 { + t.Errorf("Expected 2 tags, got %d", len(skill.Tags)) + } + if skill.Content == "" { + t.Error("Content should not be empty") + } +} + +func TestParseSkillNoFrontmatter(t *testing.T) { + data := []byte("Just plain content here") + skill, err := parseSkill(data) + if err != nil { + t.Fatalf("parseSkill failed: %v", err) + } + if skill.Content != "Just plain content here" { + t.Errorf("Unexpected content: %s", skill.Content) + } +} + +func TestParseSkillIncompleteFrontmatter(t *testing.T) { + data := []byte("---\nname: incomplete\n---\nBody content") + skill, err := parseSkill(data) + if err != nil { + t.Fatalf("parseSkill failed: %v", err) + } + if skill.Content != "Body content" { + t.Errorf("Expected 'Body content', got %s", skill.Content) + } +} + +func TestRenderSkill(t *testing.T) { + skill := &Skill{ + Name: "test", + Description: "A test", + Author: "author", + Version: "1.0", + Target: "both", + Tags: []string{"a", "b"}, + Content: "Body", + } + + rendered := renderSkill(skill) + if rendered == "" { + t.Error("Rendered skill should not be empty") + } + if len(rendered) < 20 { + t.Error("Rendered skill seems too short") + } +} + +func TestListEmpty(t *testing.T) { + tmpDir := t.TempDir() + origHome := os.Getenv("HOME") + os.Setenv("HOME", tmpDir) + defer os.Setenv("HOME", origHome) + + skills, err := List() + if err != nil { + t.Fatalf("List failed: %v", err) + } + if len(skills) != 0 { + t.Errorf("Expected 0 skills, got %d", len(skills)) + } +} + +func TestCreateAndGet(t *testing.T) { + tmpDir := t.TempDir() + origHome := os.Getenv("HOME") + os.Setenv("HOME", tmpDir) + defer os.Setenv("HOME", origHome) + + skill := &Skill{ + Name: "test-skill", + Description: "Test description", + Content: "Test content body", + Author: "tester", + Version: "0.1", + Target: "both", + } + + if err := Create(skill); err != nil { + t.Fatalf("Create failed: %v", err) + } + + dir, _ := SkillsDir() + skillPath := filepath.Join(dir, "test-skill", "SKILL.md") + if _, err := os.Stat(skillPath); os.IsNotExist(err) { + t.Error("Skill file should exist") + } + + got, err := Get("test-skill") + if err != nil { + t.Fatalf("Get failed: %v", err) + } + if got.Name != "test-skill" { + t.Errorf("Expected test-skill, got %s", got.Name) + } +} + +func TestDelete(t *testing.T) { + tmpDir := t.TempDir() + origHome := os.Getenv("HOME") + os.Setenv("HOME", tmpDir) + defer os.Setenv("HOME", origHome) + + skill := &Skill{ + Name: "to-delete", + Description: "Will be deleted", + Content: "content", + Target: "both", + } + Create(skill) + + if err := Delete("to-delete"); err != nil { + t.Fatalf("Delete failed: %v", err) + } + + _, err := Get("to-delete") + if err == nil { + t.Error("Skill should be deleted") + } +} + +func TestBuildAIGeneratePrompt(t *testing.T) { + prompt := BuildAIGeneratePrompt("docker", "Set up Docker", "both") + if prompt == "" { + t.Error("Prompt should not be empty") + } + if len(prompt) < 50 { + t.Error("Prompt seems too short") + } +} + +func TestInstallBuiltinSkills(t *testing.T) { + tmpDir := t.TempDir() + origHome := os.Getenv("HOME") + os.Setenv("HOME", tmpDir) + defer os.Setenv("HOME", origHome) + + if err := InstallBuiltinSkills(); err != nil { + t.Fatalf("InstallBuiltinSkills failed: %v", err) + } + + skills, err := List() + if err != nil { + t.Fatalf("List failed: %v", err) + } + if len(skills) == 0 { + t.Error("Expected at least one builtin skill") + } + + found := false + for _, s := range skills { + if s.Name == "env-setup" { + found = true + } + } + if !found { + t.Error("Expected env-setup skill") + } +} diff --git a/internal/tui/handlers.go b/internal/tui/handlers.go index a313da5..9ba3845 100644 --- a/internal/tui/handlers.go +++ b/internal/tui/handlers.go @@ -9,6 +9,7 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/muyue/muyue/internal/lsp" "github.com/muyue/muyue/internal/mcp" + "github.com/muyue/muyue/internal/proxy" "github.com/muyue/muyue/internal/scanner" "github.com/muyue/muyue/internal/updater" ) @@ -71,10 +72,23 @@ func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, nil } +func cleanup(m Model) { + if m.daemon != nil { + m.daemon.Stop() + } + if m.previewSrv != nil { + m.previewSrv.Stop() + } + for _, agentType := range []proxy.AgentType{proxy.AgentCrush, proxy.AgentClaude} { + m.proxyMgr.Stop(agentType) + } +} + func (m Model) handleQuitConfirm(msg tea.KeyMsg) (tea.Model, tea.Cmd) { switch msg.String() { case "y", "Y", "o", "O": m.showingQuit = false + cleanup(m) return m, tea.Quit case "n", "N", "esc": m.showingQuit = false @@ -92,6 +106,7 @@ func (m Model) handleQuitConfirm(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case "enter": if m.confirmCursor == 0 { m.showingQuit = false + cleanup(m) return m, tea.Quit } m.showingQuit = false @@ -100,6 +115,7 @@ func (m Model) handleQuitConfirm(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, nil case "ctrl+c": m.showingQuit = false + cleanup(m) return m, tea.Quit } return m, nil diff --git a/internal/tui/terminal.go b/internal/tui/terminal.go index ed733eb..d41ca6f 100644 --- a/internal/tui/terminal.go +++ b/internal/tui/terminal.go @@ -3,6 +3,7 @@ package tui import ( "os" "os/exec" + "regexp" "strings" "time" @@ -10,6 +11,28 @@ import ( "github.com/charmbracelet/lipgloss" ) +var dangerousPatterns = []*regexp.Regexp{ + regexp.MustCompile(`(?i)\brm\s+(-[a-zA-Z]*f[a-zA-Z]*\s+|/)`), + regexp.MustCompile(`(?i)\bmkfs\b`), + regexp.MustCompile(`(?i)\bdd\s+if=`), + regexp.MustCompile(`(?i)\b(format\s+[A-Za-z]:)\b`), + regexp.MustCompile(`(?i):\(\)\{.*\}`), + regexp.MustCompile(`(?i)>(/dev/|/etc/|/boot/)`), + regexp.MustCompile(`(?i)\bshutdown\b`), + regexp.MustCompile(`(?i)\breboot\b`), + regexp.MustCompile(`(?i)\bhalt\b`), + regexp.MustCompile(`(?i)\bpoweroff\b`), +} + +func isDangerousCommand(input string) bool { + for _, pat := range dangerousPatterns { + if pat.MatchString(input) { + return true + } + } + return false +} + func (m Model) handleTerminalKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { switch msg.String() { case "ctrl+c": @@ -52,6 +75,12 @@ func (m Model) handleTerminalKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.viewport.SetContent(m.renderContent()) return m, nil } + if isDangerousCommand(input) { + m.termLog = append(m.termLog, errMsgStyle.Render("blocked: potentially dangerous command")) + m.viewport.SetContent(m.renderContent()) + m.viewport.GotoBottom() + return m, nil + } if strings.HasPrefix(input, "cd ") { dir := strings.TrimPrefix(input, "cd ") dir = strings.TrimSpace(dir) diff --git a/internal/updater/updater.go b/internal/updater/updater.go index 76209f3..b57f5a1 100644 --- a/internal/updater/updater.go +++ b/internal/updater/updater.go @@ -23,6 +23,8 @@ type UpdateStatus struct { var versionRegex = regexp.MustCompile(`\d+\.\d+\.\d+`) +var sharedHTTPClient = &http.Client{Timeout: 10 * time.Second} + type githubRelease struct { TagName string `json:"tag_name"` } @@ -68,10 +70,9 @@ func getLatestVersion(tool string) (string, error) { return getLatestVersionCLI(tool) } - client := &http.Client{Timeout: 10 * time.Second} url := fmt.Sprintf("https://api.github.com/repos/%s/releases/latest", repo) - resp, err := client.Get(url) + resp, err := sharedHTTPClient.Get(url) if err != nil { return "", fmt.Errorf("github api: %w", err) } diff --git a/internal/version/version_test.go b/internal/version/version_test.go new file mode 100644 index 0000000..ad1c0ca --- /dev/null +++ b/internal/version/version_test.go @@ -0,0 +1,31 @@ +package version + +import ( + "strings" + "testing" +) + +func TestFullVersion(t *testing.T) { + v := FullVersion() + if !strings.HasPrefix(v, Name) { + t.Errorf("FullVersion should start with %s, got %s", Name, v) + } + if !strings.Contains(v, "v"+Version) { + t.Errorf("FullVersion should contain v%s, got %s", Version, v) + } +} + +func TestConstants(t *testing.T) { + if Name == "" { + t.Error("Name should not be empty") + } + if Version == "" { + t.Error("Version should not be empty") + } + if Author == "" { + t.Error("Author should not be empty") + } + if License == "" { + t.Error("License should not be empty") + } +} diff --git a/internal/workflow/workflow_test.go b/internal/workflow/workflow_test.go new file mode 100644 index 0000000..9825a9a --- /dev/null +++ b/internal/workflow/workflow_test.go @@ -0,0 +1,255 @@ +package workflow + +import ( + "testing" +) + +func TestNew(t *testing.T) { + wf := New() + if wf.Phase != PhaseIdle { + t.Errorf("Expected PhaseIdle, got %s", wf.Phase) + } + if wf.Plan == nil { + t.Error("Plan should not be nil") + } +} + +func TestStart(t *testing.T) { + wf := New() + wf.Start("Build a REST API") + if wf.Phase != PhaseGathering { + t.Errorf("Expected PhaseGathering, got %s", wf.Phase) + } + if wf.Plan.Goal != "Build a REST API" { + t.Errorf("Expected goal 'Build a REST API', got %s", wf.Plan.Goal) + } +} + +func TestAddAnswer(t *testing.T) { + wf := New() + wf.Start("test goal") + wf.Plan.Questions = []string{"Q1?", "Q2?"} + + wf.AddAnswer("A1") + if wf.Phase != PhaseGathering { + t.Errorf("Should still be gathering, got %s", wf.Phase) + } + + wf.AddAnswer("A2") + if wf.Phase != PhasePlanning { + t.Errorf("Should move to planning, got %s", wf.Phase) + } +} + +func TestSetPlan(t *testing.T) { + wf := New() + planJSON := `[{"id":"1","title":"Step 1","description":"Do something","agent":"crush","status":"pending"}]` + err := wf.SetPlan(planJSON) + if err != nil { + t.Fatalf("SetPlan failed: %v", err) + } + if len(wf.Plan.Steps) != 1 { + t.Errorf("Expected 1 step, got %d", len(wf.Plan.Steps)) + } + if wf.Phase != PhaseReviewing { + t.Errorf("Expected PhaseReviewing, got %s", wf.Phase) + } +} + +func TestApprove(t *testing.T) { + wf := New() + wf.Start("test") + wf.Plan.Steps = []Step{{ID: "1", Title: "Step 1", Status: "pending"}} + wf.Phase = PhaseReviewing + wf.Approve() + if wf.Phase != PhaseExecuting { + t.Errorf("Expected PhaseExecuting, got %s", wf.Phase) + } + if wf.Plan.StepIndex != 0 { + t.Errorf("Expected step index 0, got %d", wf.Plan.StepIndex) + } +} + +func TestReject(t *testing.T) { + wf := New() + wf.Phase = PhaseReviewing + wf.Reject("too complex") + if wf.Phase != PhasePlanning { + t.Errorf("Expected PhasePlanning, got %s", wf.Phase) + } +} + +func TestAdvanceStep(t *testing.T) { + wf := New() + wf.Plan.Steps = []Step{ + {ID: "1", Title: "Step 1", Status: "pending"}, + {ID: "2", Title: "Step 2", Status: "pending"}, + } + wf.Phase = PhaseExecuting + + wf.AdvanceStep("output1") + if wf.Plan.Steps[0].Status != "done" { + t.Error("First step should be done") + } + if wf.Plan.StepIndex != 1 { + t.Errorf("Expected step index 1, got %d", wf.Plan.StepIndex) + } + if wf.Phase != PhaseExecuting { + t.Errorf("Should still be executing, got %s", wf.Phase) + } + + wf.AdvanceStep("output2") + if wf.Phase != PhaseDone { + t.Errorf("Expected PhaseDone, got %s", wf.Phase) + } +} + +func TestFailStep(t *testing.T) { + wf := New() + wf.Plan.Steps = []Step{{ID: "1", Title: "Step 1"}} + wf.Phase = PhaseExecuting + + wf.FailStep("something broke") + if wf.Phase != PhaseError { + t.Errorf("Expected PhaseError, got %s", wf.Phase) + } + if wf.Plan.Steps[0].Status != "error" { + t.Error("Step should have error status") + } +} + +func TestReset(t *testing.T) { + wf := New() + wf.Start("test") + wf.Phase = PhaseExecuting + wf.Reset() + if wf.Phase != PhaseIdle { + t.Errorf("Expected PhaseIdle, got %s", wf.Phase) + } +} + +func TestCurrentStep(t *testing.T) { + wf := New() + if wf.CurrentStep() != nil { + t.Error("Should be nil with no steps") + } + + wf.Plan.Steps = []Step{{ID: "1"}, {ID: "2"}} + wf.Plan.StepIndex = 0 + step := wf.CurrentStep() + if step == nil || step.ID != "1" { + t.Error("Should return first step") + } + + wf.Plan.StepIndex = 2 + if wf.CurrentStep() != nil { + t.Error("Should be nil when past all steps") + } +} + +func TestProgress(t *testing.T) { + wf := New() + wf.Plan.Steps = []Step{ + {ID: "1", Status: "done"}, + {ID: "2", Status: "pending"}, + {ID: "3", Status: "done"}, + } + done, total := wf.Progress() + if done != 2 || total != 3 { + t.Errorf("Expected 2/3, got %d/%d", done, total) + } +} + +func TestParsePlanResponse(t *testing.T) { + resp := `Here is the plan: +[ + {"id": "1", "title": "Setup", "description": "Init project", "agent": "crush"}, + {"id": "2", "title": "Build", "description": "Write code", "agent": "claude"} +]` + steps, err := ParsePlanResponse(resp) + if err != nil { + t.Fatalf("ParsePlanResponse failed: %v", err) + } + if len(steps) != 2 { + t.Errorf("Expected 2 steps, got %d", len(steps)) + } + if steps[0].ID != "1" { + t.Errorf("Expected step ID 1, got %s", steps[0].ID) + } + for _, s := range steps { + if s.Status != "pending" { + t.Errorf("Steps should be pending, got %s", s.Status) + } + } +} + +func TestParsePlanResponseInvalid(t *testing.T) { + _, err := ParsePlanResponse("no json here") + if err == nil { + t.Error("Should fail with no JSON") + } +} + +func TestParseApproval(t *testing.T) { + tests := []struct { + input string + approved bool + }{ + {"plan_approved", true}, + {"approved", true}, + {"yes", true}, + {"ok", true}, + {"oui", true}, + {"go ahead", true}, + {"no", false}, + {"plan_rejected: too complex", false}, + {"I don't like it", false}, + } + for _, tt := range tests { + approved, feedback := ParseApproval(tt.input) + if approved != tt.approved { + t.Errorf("ParseApproval(%q) = %v, want %v", tt.input, approved, tt.approved) + } + if !approved && tt.input == "plan_rejected: too complex" { + if feedback != "too complex" { + t.Errorf("Expected feedback 'too complex', got %s", feedback) + } + } + } +} + +func TestParsePreviewFiles(t *testing.T) { + resp := `Some text +<<>> +[{"filename":"test.html","content":"

Hello

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