commit f0ccd265da1227b753a54d9084bee40dfb37a7a2 Author: Augustin Date: Sun Apr 19 22:29:20 2026 +0200 feat: initial release of muyue - AI-powered dev environment assistant Complete implementation of muyue v0.1.0, a single-binary Go tool that transforms the development environment with AI-powered orchestration. Core features: - TUI with 5 tabs (Dashboard/Chat/Workflow/Agents/Config) using Charm stack - AI chat via MiniMax M2.7 with async message handling - Structured Plan→Execute workflow engine (gather→plan→review→execute) - System scanner detecting 14 tools + 8 runtimes across Linux/macOS/Windows - Auto-installer for Crush, Claude Code, BMAD, Starship, runtimes - Background update daemon with hourly checks - LSP auto-config for 16 language servers - MCP auto-config for 12 servers (deployed to Crush + Claude Code) - Skills system with 5 built-ins + AI-powered generation - Crush/Claude Code proxy for unified control - HTML preview server for visual outputs - First-time setup wizard with interactive profiling - Cross-platform: Linux (primary), macOS, Windows, WSL CI/CD: - GitHub Actions CI: build + test + lint on Linux/macOS/Windows - Release workflow: cross-compile 6 binaries with checksums on tag push 💘 Generated with Crush Assisted-by: GLM-5.1 via Crush diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..65160dd --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,43 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + build: + strategy: + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version: '1.24' + + - name: Build + run: go build -o muyue ./cmd/muyue/ + + - name: Test + run: go test ./... -v + + - name: Vet + run: go vet ./... + + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version: '1.24' + + - name: golangci-lint + uses: golangci/golangci-lint-action@v6 + with: + version: latest diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..1becf2a --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,156 @@ +name: Release + +on: + push: + tags: + - 'v*' + +permissions: + contents: write + +jobs: + release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: actions/setup-go@v5 + with: + go-version: '1.24' + + - name: Get version + id: version + run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT + + - name: Build all platforms + run: | + mkdir -p dist + + # Linux amd64 + CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build \ + -ldflags="-s -w -X github.com/muyue/muyue/internal/version.Version=${{ steps.version.outputs.VERSION }}" \ + -o dist/muyue-linux-amd64 ./cmd/muyue/ + + # Linux arm64 + CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build \ + -ldflags="-s -w -X github.com/muyue/muyue/internal/version.Version=${{ steps.version.outputs.VERSION }}" \ + -o dist/muyue-linux-arm64 ./cmd/muyue/ + + # macOS amd64 + CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build \ + -ldflags="-s -w -X github.com/muyue/muyue/internal/version.Version=${{ steps.version.outputs.VERSION }}" \ + -o dist/muyue-darwin-amd64 ./cmd/muyue/ + + # macOS arm64 (Apple Silicon) + CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build \ + -ldflags="-s -w -X github.com/muyue/muyue/internal/version.Version=${{ steps.version.outputs.VERSION }}" \ + -o dist/muyue-darwin-arm64 ./cmd/muyue/ + + # Windows amd64 + CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build \ + -ldflags="-s -w -X github.com/muyue/muyue/internal/version.Version=${{ steps.version.outputs.VERSION }}" \ + -o dist/muyue-windows-amd64.exe ./cmd/muyue/ + + # Windows arm64 + CGO_ENABLED=0 GOOS=windows GOARCH=arm64 go build \ + -ldflags="-s -w -X github.com/muyue/muyue/internal/version.Version=${{ steps.version.outputs.VERSION }}" \ + -o dist/muyue-windows-arm64.exe ./cmd/muyue/ + + - name: Create checksums + run: | + cd dist + sha256sum * > checksums.txt + cat checksums.txt + + - name: Create archives + run: | + cd dist + + # Linux amd64 + tar czf muyue-linux-amd64.tar.gz muyue-linux-amd64 + + # Linux arm64 + tar czf muyue-linux-arm64.tar.gz muyue-linux-arm64 + + # macOS amd64 + tar czf muyue-darwin-amd64.tar.gz muyue-darwin-amd64 + + # macOS arm64 + tar czf muyue-darwin-arm64.tar.gz muyue-darwin-arm64 + + # Windows amd64 + zip muyue-windows-amd64.zip muyue-windows-amd64.exe + + # Windows arm64 + zip muyue-windows-arm64.zip muyue-windows-arm64.exe + + - name: Generate changelog + id: changelog + run: | + PREVIOUS_TAG=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo "") + if [ -z "$PREVIOUS_TAG" ]; then + echo "CHANGELOG=Initial release of muyue." >> $GITHUB_OUTPUT + else + CHANGELOG=$(git log ${PREVIOUS_TAG}..HEAD --pretty=format:"- %s" --no-merges) + echo "CHANGELOG<> $GITHUB_OUTPUT + echo "$CHANGELOG" >> $GITHUB_OUTPUT + echo "EOF" >> $GITHUB_OUTPUT + fi + + - name: Create Release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ steps.version.outputs.VERSION }} + name: muyue ${{ steps.version.outputs.VERSION }} + body: | + ## Changes + ${{ steps.changelog.outputs.CHANGELOG }} + + ## Installation + + ### Linux (amd64) + ```bash + curl -sL https://github.com/$(echo ${{ github.repository }} | tr -d ' ')/releases/download/${{ steps.version.outputs.VERSION }}/muyue-linux-amd64.tar.gz | tar xz + chmod +x muyue-linux-amd64 + sudo mv muyue-linux-amd64 /usr/local/bin/muyue + ``` + + ### Linux (arm64) + ```bash + curl -sL https://github.com/$(echo ${{ github.repository }} | tr -d ' ')/releases/download/${{ steps.version.outputs.VERSION }}/muyue-linux-arm64.tar.gz | tar xz + chmod +x muyue-linux-arm64 + sudo mv muyue-linux-arm64 /usr/local/bin/muyue + ``` + + ### macOS (Apple Silicon) + ```bash + curl -sL https://github.com/$(echo ${{ github.repository }} | tr -d ' ')/releases/download/${{ steps.version.outputs.VERSION }}/muyue-darwin-arm64.tar.gz | tar xz + chmod +x muyue-darwin-arm64 + sudo mv muyue-darwin-arm64 /usr/local/bin/muyue + ``` + + ### macOS (Intel) + ```bash + curl -sL https://github.com/$(echo ${{ github.repository }} | tr -d ' ')/releases/download/${{ steps.version.outputs.VERSION }}/muyue-darwin-amd64.tar.gz | tar xz + chmod +x muyue-darwin-amd64 + sudo mv muyue-darwin-amd64 /usr/local/bin/muyue + ``` + + ### Windows + Download `muyue-windows-amd64.zip` from the assets below. + + ## Checksums + See `checksums.txt` in the release assets. + + files: | + dist/muyue-linux-amd64.tar.gz + dist/muyue-linux-arm64.tar.gz + dist/muyue-darwin-amd64.tar.gz + dist/muyue-darwin-arm64.tar.gz + dist/muyue-windows-amd64.zip + dist/muyue-windows-arm64.zip + dist/checksums.txt + draft: false + prerelease: false diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..36e0ef5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,19 @@ +# Binaries +muyue +dist/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db + +# Go +*.exe +*.test +*.out +vendor/ diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..e4eff4a --- /dev/null +++ b/Makefile @@ -0,0 +1,48 @@ +GOPATH ?= $(shell go env GOPATH) +GOBIN ?= $(GOPATH)/bin +BINARY = muyue +BUILD_DIR = . +GO = go + +.PHONY: build install clean test run scan fmt lint + +build: + $(GO) build -o $(BUILD_DIR)/$(BINARY) ./cmd/muyue/ + +install: build + cp $(BUILD_DIR)/$(BINARY) /usr/local/bin/ + +install-local: build + mkdir -p $(HOME)/.local/bin + cp $(BUILD_DIR)/$(BINARY) $(HOME)/.local/bin/ + +clean: + rm -f $(BUILD_DIR)/$(BINARY) + +test: + $(GO) test ./... -v + +run: build + ./$(BINARY) + +scan: build + ./$(BINARY) scan + +fmt: + gofmt -w . + $(GO)imports -w . + +lint: + golangci-lint run + +build-all: + GOOS=linux GOARCH=amd64 $(GO) build -o dist/$(BINARY)-linux-amd64 ./cmd/muyue/ + GOOS=linux GOARCH=arm64 $(GO) build -o dist/$(BINARY)-linux-arm64 ./cmd/muyue/ + GOOS=darwin GOARCH=amd64 $(GO) build -o dist/$(BINARY)-darwin-amd64 ./cmd/muyue/ + GOOS=darwin GOARCH=arm64 $(GO) build -o dist/$(BINARY)-darwin-arm64 ./cmd/muyue/ + 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 new file mode 100644 index 0000000..7585c1f --- /dev/null +++ b/README.md @@ -0,0 +1,76 @@ +# muyue + +AI-powered development environment assistant by **La Légion de Muyue**. + +## What it does + +`muyue` is a single binary that transforms your entire development environment: + +- **Scans** your system for tools, runtimes, and configs +- **Installs** missing tools automatically (Crush, Claude Code, BMAD, Starship, runtimes...) +- **Updates** everything in the background +- **Profiles** you on first run to personalize the experience +- **Unifies** control of Crush and Claude Code from one TUI +- **Orchestrates** AI agents via MiniMax M2.7 +- **Customizes** your terminal prompt (branch, commits, language, etc.) +- **Configures** MCP servers, LSPs, and skills automatically +- **Previews** HTML/visual outputs in your browser + +## Tech Stack + +- **Go** — single binary, no dependencies +- **Charm** — Bubble Tea, Lip Gloss, Huh (TUI, styling, forms) +- **Starship** — terminal prompt customization +- **MiniMax M2.7** — AI orchestration +- **BMAD-METHOD** — structured development workflows + +## Install + +```bash +go build -o muyue ./cmd/muyue/ +``` + +Or with Make: + +```bash +make build +make install-local +``` + +## Usage + +```bash +muyue # Start interactive TUI +muyue scan # Scan system +muyue install # Install missing tools +muyue update # Check and apply updates +muyue setup # Run setup wizard +muyue config # Show configuration +``` + +## TUI Controls + +| Key | Action | +|-----|--------| +| `1-4` | Switch tabs | +| `Tab` | Next tab | +| `q` / `Ctrl+C` | Quit | + +## Configuration + +Config stored at `~/.muyue/config.yaml`. + +First run launches an interactive profiling wizard that: +1. Asks your name, pseudo, email +2. Detects your languages and editor +3. Chooses your AI provider +4. Scans your system +5. Installs missing tools + +## Cross-Platform + +Built for Linux (primary), macOS, and Windows. WSL supported. + +## License + +MIT diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..72ede25 --- /dev/null +++ b/go.mod @@ -0,0 +1,39 @@ +module github.com/muyue/muyue + +go 1.24.3 + +require ( + github.com/charmbracelet/bubbles v1.0.0 + github.com/charmbracelet/bubbletea v1.3.10 + github.com/charmbracelet/huh v1.0.0 + github.com/charmbracelet/lipgloss v1.1.0 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + github.com/atotto/clipboard v0.1.4 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/catppuccin/go v0.3.0 // indirect + github.com/charmbracelet/colorprofile v0.4.1 // indirect + github.com/charmbracelet/x/ansi v0.11.6 // indirect + github.com/charmbracelet/x/cellbuf v0.0.15 // indirect + github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect + github.com/charmbracelet/x/term v0.2.2 // indirect + github.com/clipperhouse/displaywidth v0.9.0 // indirect + github.com/clipperhouse/stringish v0.1.1 // indirect + 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/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 + github.com/mattn/go-runewidth v0.0.19 // indirect + github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + 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/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/sys v0.38.0 // indirect + golang.org/x/text v0.23.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..05e1d54 --- /dev/null +++ b/go.sum @@ -0,0 +1,82 @@ +github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= +github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= +github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= +github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY= +github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= +github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc= +github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E= +github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= +github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= +github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk= +github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk= +github.com/charmbracelet/huh v1.0.0 h1:wOnedH8G4qzJbmhftTqrpppyqHakl/zbbNdXIWJyIxw= +github.com/charmbracelet/huh v1.0.0/go.mod h1:5YVc+SlZ1IhQALxRPpkGwwEKftN/+OlJlnJYlDRFqN4= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= +github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= +github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= +github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= +github.com/charmbracelet/x/conpty v0.1.0 h1:4zc8KaIcbiL4mghEON8D72agYtSeIgq8FSThSPQIb+U= +github.com/charmbracelet/x/conpty v0.1.0/go.mod h1:rMFsDJoDwVmiYM10aD4bH2XiRgwI7NYJtQgl5yskjEQ= +github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA= +github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= +github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4= +github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ= +github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= +github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= +github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= +github.com/charmbracelet/x/xpty v0.1.2 h1:Pqmu4TEJ8KeA9uSkISKMU3f+C1F6OGBn8ABuGlqCbtI= +github.com/charmbracelet/x/xpty v0.1.2/go.mod h1:XK2Z0id5rtLWcpeNiMYBccNNBrP2IJnzHI0Lq13Xzq4= +github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA= +github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA= +github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= +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/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +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= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= +github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= +github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +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/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= +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= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= +golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..b59d09a --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,167 @@ +package config + +import ( + "fmt" + "os" + "path/filepath" + + "gopkg.in/yaml.v3" +) + +type Profile struct { + Name string `yaml:"name"` + Pseudo string `yaml:"pseudo"` + Email string `yaml:"email"` + Languages []string `yaml:"languages"` + Preferences struct { + Editor string `yaml:"editor"` + Shell string `yaml:"shell"` + Theme string `yaml:"theme"` + DefaultAI string `yaml:"default_ai"` + AutoUpdate bool `yaml:"auto_update"` + CheckOnStart bool `yaml:"check_on_start"` + } `yaml:"preferences"` +} + +type AIProvider struct { + Name string `yaml:"name"` + APIKey string `yaml:"api_key,omitempty"` + BaseURL string `yaml:"base_url,omitempty"` + Model string `yaml:"model"` + Active bool `yaml:"active"` +} + +type ToolConfig struct { + Name string `yaml:"name"` + Installed bool `yaml:"installed"` + Version string `yaml:"version"` + AutoUpdate bool `yaml:"auto_update"` +} + +type MuyueConfig struct { + Version string `yaml:"version"` + Profile Profile `yaml:"profile"` + AI struct { + Providers []AIProvider `yaml:"providers"` + } `yaml:"ai"` + Tools []ToolConfig `yaml:"tools"` + BMAD struct { + Installed bool `yaml:"installed"` + Version string `yaml:"version"` + Global bool `yaml:"global"` + } `yaml:"bmad"` + Terminal struct { + CustomPrompt bool `yaml:"custom_prompt"` + PromptTheme string `yaml:"prompt_theme"` + } `yaml:"terminal"` +} + +func ConfigDir() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + dir := filepath.Join(home, ".muyue") + return dir, nil +} + +func ConfigPath() (string, error) { + dir, err := ConfigDir() + if err != nil { + return "", err + } + return filepath.Join(dir, "config.yaml"), nil +} + +func Exists() bool { + path, err := ConfigPath() + if err != nil { + return false + } + _, err = os.Stat(path) + return err == nil +} + +func Load() (*MuyueConfig, error) { + path, err := ConfigPath() + if err != nil { + return nil, err + } + + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("reading config: %w", err) + } + + var cfg MuyueConfig + if err := yaml.Unmarshal(data, &cfg); err != nil { + return nil, fmt.Errorf("parsing config: %w", err) + } + + return &cfg, nil +} + +func Save(cfg *MuyueConfig) error { + dir, err := ConfigDir() + if err != nil { + return err + } + + if err := os.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("creating config dir: %w", err) + } + + path := filepath.Join(dir, "config.yaml") + data, err := yaml.Marshal(cfg) + if err != nil { + return fmt.Errorf("marshaling config: %w", err) + } + + if err := os.WriteFile(path, data, 0600); err != nil { + return fmt.Errorf("writing config: %w", err) + } + + return nil +} + +func Default() *MuyueConfig { + cfg := &MuyueConfig{ + Version: "0.1.0", + Profile: Profile{ + Name: "", + Pseudo: "muyue", + Languages: []string{}, + }, + } + + cfg.Profile.Preferences.DefaultAI = "minimax" + cfg.Profile.Preferences.AutoUpdate = true + cfg.Profile.Preferences.CheckOnStart = true + cfg.Profile.Preferences.Theme = "charm" + + cfg.AI.Providers = []AIProvider{ + { + Name: "minimax", + Model: "MiniMax-M2.7", + BaseURL: "https://api.minimax.io/v1", + Active: true, + }, + { + Name: "zai", + Model: "glm", + Active: false, + }, + { + Name: "anthropic", + Model: "claude-sonnet-4-20250514", + Active: false, + }, + } + + cfg.BMAD.Global = true + + cfg.Terminal.CustomPrompt = true + cfg.Terminal.PromptTheme = "zerotwo" + + return cfg +} diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go new file mode 100644 index 0000000..cea2d9f --- /dev/null +++ b/internal/daemon/daemon.go @@ -0,0 +1,173 @@ +package daemon + +import ( + "fmt" + "os" + "os/signal" + "sync" + "syscall" + "time" + + "github.com/muyue/muyue/internal/config" + "github.com/muyue/muyue/internal/scanner" + "github.com/muyue/muyue/internal/updater" +) + +type Daemon struct { + config *config.MuyueConfig + interval time.Duration + stopCh chan struct{} + mu sync.RWMutex + running bool + lastCheck time.Time + lastStatus []updater.UpdateStatus + logs []string + onUpdate func([]updater.UpdateStatus) +} + +func NewDaemon(cfg *config.MuyueConfig, interval time.Duration) *Daemon { + if interval == 0 { + interval = 1 * time.Hour + } + return &Daemon{ + config: cfg, + interval: interval, + stopCh: make(chan struct{}), + logs: []string{}, + } +} + +func (d *Daemon) OnUpdate(fn func([]updater.UpdateStatus)) { + d.onUpdate = fn +} + +func (d *Daemon) Start() error { + d.mu.Lock() + if d.running { + d.mu.Unlock() + return fmt.Errorf("daemon already running") + } + d.running = true + d.mu.Unlock() + + d.log("daemon started (interval: %s)", d.interval) + + go d.run() + + return nil +} + +func (d *Daemon) Stop() { + d.mu.Lock() + defer d.mu.Unlock() + if !d.running { + return + } + d.running = false + d.stopCh <- struct{}{} + d.log("daemon stopped") +} + +func (d *Daemon) IsRunning() bool { + d.mu.RLock() + defer d.mu.RUnlock() + return d.running +} + +func (d *Daemon) LastCheck() time.Time { + d.mu.RLock() + defer d.mu.RUnlock() + return d.lastCheck +} + +func (d *Daemon) LastStatus() []updater.UpdateStatus { + d.mu.RLock() + defer d.mu.RUnlock() + return d.lastStatus +} + +func (d *Daemon) Logs() []string { + d.mu.RLock() + defer d.mu.RUnlock() + return d.logs +} + +func (d *Daemon) TriggerCheck() []updater.UpdateStatus { + return d.checkUpdates() +} + +func (d *Daemon) run() { + d.checkUpdates() + + ticker := time.NewTicker(d.interval) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + d.checkUpdates() + case <-d.stopCh: + return + } + } +} + +func (d *Daemon) checkUpdates() []updater.UpdateStatus { + d.log("checking for updates...") + result := scanner.ScanSystem() + statuses := updater.CheckUpdates(result) + + needsUpdate := false + for _, s := range statuses { + if s.NeedsUpdate { + needsUpdate = true + d.log("update available: %s %s -> %s", s.Tool, s.Current, s.Latest) + } + } + + if !needsUpdate { + d.log("all tools up to date") + } + + d.mu.Lock() + d.lastCheck = time.Now() + d.lastStatus = statuses + d.mu.Unlock() + + if d.config.Profile.Preferences.AutoUpdate && needsUpdate { + d.log("auto-updating...") + results := updater.RunAutoUpdate(statuses) + for _, r := range results { + if r.Message != "" { + d.log(" %s: %s", r.Tool, r.Message) + } + } + } + + if d.onUpdate != nil { + d.onUpdate(statuses) + } + + return statuses +} + +func (d *Daemon) log(format string, args ...interface{}) { + msg := fmt.Sprintf("[%s] %s", time.Now().Format("15:04:05"), fmt.Sprintf(format, args...)) + d.mu.Lock() + d.logs = append(d.logs, msg) + if len(d.logs) > 500 { + d.logs = d.logs[250:] + } + d.mu.Unlock() +} + +func RunStandalone(cfg *config.MuyueConfig) { + d := NewDaemon(cfg, 1*time.Hour) + d.Start() + + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) + + <-sigCh + d.Stop() +} diff --git a/internal/installer/installer.go b/internal/installer/installer.go new file mode 100644 index 0000000..b420452 --- /dev/null +++ b/internal/installer/installer.go @@ -0,0 +1,319 @@ +package installer + +import ( + "fmt" + "os" + "os/exec" + "runtime" + "strings" + + "github.com/muyue/muyue/internal/config" + "github.com/muyue/muyue/internal/platform" +) + +type InstallResult struct { + Tool string + Success bool + Message string +} + +type Installer struct { + config *config.MuyueConfig + system platform.SystemInfo +} + +func New(cfg *config.MuyueConfig) *Installer { + return &Installer{ + config: cfg, + system: platform.Detect(), + } +} + +func (i *Installer) InstallTool(name string) InstallResult { + switch name { + case "crush": + return i.installCrush() + case "claude": + return i.installClaudeCode() + case "bmad": + return i.installBMAD() + case "starship": + return i.installStarship() + case "go": + return i.installGo() + case "node": + return i.installNode() + case "python": + return i.installPython() + case "git": + return i.installGit() + default: + return InstallResult{Tool: name, Success: false, Message: "unknown tool"} + } +} + +func (i *Installer) InstallAll(missing []string) []InstallResult { + var results []InstallResult + for _, name := range missing { + results = append(results, i.InstallTool(name)) + } + return results +} + +func (i *Installer) installCrush() InstallResult { + if _, err := exec.LookPath("crush"); err == nil { + return InstallResult{Tool: "crush", Success: true, Message: "already installed"} + } + + var cmd *exec.Cmd + switch i.system.OS { + case platform.Linux: + cmd = exec.Command("bash", "-c", + "curl -fsSL https://github.com/charmbracelet/crush/releases/latest/download/crush_"+runtime.GOOS+"_"+runtime.GOARCH+".tar.gz | tar xz -C /usr/local/bin") + case platform.MacOS: + cmd = exec.Command("bash", "-c", "brew install charmbracelet/tap/crush") + case platform.Windows: + cmd = exec.Command("powershell", "-Command", + "winget install charmbracelet.crush") + default: + return InstallResult{Tool: "crush", Success: false, Message: "unsupported OS"} + } + + if output, err := cmd.CombinedOutput(); err != nil { + return InstallResult{ + Tool: "crush", Success: false, + Message: fmt.Sprintf("install failed: %s: %s", err, string(output)), + } + } + + return InstallResult{Tool: "crush", Success: true, Message: "installed"} +} + +func (i *Installer) installClaudeCode() InstallResult { + if _, err := exec.LookPath("claude"); err == nil { + return InstallResult{Tool: "claude", Success: true, Message: "already installed"} + } + + cmd := exec.Command("npm", "install", "-g", "@anthropic-ai/claude-code") + if output, err := cmd.CombinedOutput(); err != nil { + return InstallResult{ + Tool: "claude", Success: false, + Message: fmt.Sprintf("install failed: %s: %s", err, string(output)), + } + } + + return InstallResult{Tool: "claude", Success: true, Message: "installed"} +} + +func (i *Installer) installBMAD() InstallResult { + if _, err := exec.LookPath("npx"); err != nil { + return InstallResult{Tool: "bmad", Success: false, Message: "npx not found, install node first"} + } + + configDir, err := config.ConfigDir() + if err != nil { + return InstallResult{Tool: "bmad", Success: false, Message: err.Error()} + } + + bmadDir := configDir + "/bmad" + os.MkdirAll(bmadDir, 0755) + + cmd := exec.Command("npx", "bmad-method@latest", "install", + "--directory", bmadDir, "--yes") + cmd.Env = append(os.Environ(), "npm_config_yes=true") + + if output, err := cmd.CombinedOutput(); err != nil { + return InstallResult{ + Tool: "bmad", Success: false, + Message: fmt.Sprintf("install failed: %s: %s", err, string(output)), + } + } + + i.config.BMAD.Installed = true + i.config.BMAD.Global = true + return InstallResult{Tool: "bmad", Success: true, Message: "installed globally in ~/.muyue/bmad"} +} + +func (i *Installer) installStarship() InstallResult { + if _, err := exec.LookPath("starship"); err == nil { + return InstallResult{Tool: "starship", Success: true, Message: "already installed"} + } + + var cmd *exec.Cmd + switch i.system.OS { + case platform.Linux, platform.MacOS: + cmd = exec.Command("bash", "-c", + "curl -sS https://starship.rs/install.sh | sh -s -- -y") + case platform.Windows: + cmd = exec.Command("powershell", "-Command", + "winget install Starship.Starship") + default: + return InstallResult{Tool: "starship", Success: false, Message: "unsupported OS"} + } + + if output, err := cmd.CombinedOutput(); err != nil { + return InstallResult{ + Tool: "starship", Success: false, + Message: fmt.Sprintf("install failed: %s: %s", err, string(output)), + } + } + + return InstallResult{Tool: "starship", Success: true, Message: "installed"} +} + +func (i *Installer) installGo() InstallResult { + if _, err := exec.LookPath("go"); err == nil { + return InstallResult{Tool: "go", Success: true, Message: "already installed"} + } + + home, _ := os.UserHomeDir() + goDir := home + "/.local/go" + + cmd := exec.Command("bash", "-c", fmt.Sprintf( + "curl -sL https://go.dev/dl/go1.24.3.%s-%s.tar.gz | tar -C %s/.local -xzf -", + runtime.GOOS, runtime.GOARCH, home, + )) + + if output, err := cmd.CombinedOutput(); err != nil { + return InstallResult{ + Tool: "go", Success: false, + Message: fmt.Sprintf("install failed: %s: %s", err, string(output)), + } + } + + rcFile := i.getRCFile() + appendLine(rcFile, "export PATH="+goDir+"/bin:$PATH") + + return InstallResult{Tool: "go", Success: true, Message: "installed in ~/.local/go"} +} + +func (i *Installer) installNode() InstallResult { + if _, err := exec.LookPath("node"); err == nil { + return InstallResult{Tool: "node", Success: true, Message: "already installed"} + } + + switch i.system.OS { + case platform.Linux: + cmd := exec.Command("bash", "-c", + "curl -fsSL https://deb.nodesource.com/setup_22.x | bash - && apt-get install -y nodejs") + if output, err := cmd.CombinedOutput(); err != nil { + return InstallResult{Tool: "node", Success: false, + Message: fmt.Sprintf("install failed: %s", string(output))} + } + case platform.MacOS: + cmd := exec.Command("brew", "install", "node") + cmd.Run() + case platform.Windows: + cmd := exec.Command("winget", "install", "OpenJS.NodeJS.LTS") + cmd.Run() + } + + return InstallResult{Tool: "node", Success: true, Message: "installed"} +} + +func (i *Installer) installPython() InstallResult { + if _, err := exec.LookPath("python3"); err == nil { + return InstallResult{Tool: "python", Success: true, Message: "already installed"} + } + + switch i.system.PackageManager { + case "apt": + exec.Command("apt", "install", "-y", "python3", "python3-pip").Run() + case "brew": + exec.Command("brew", "install", "python3").Run() + case "winget": + exec.Command("winget", "install", "Python.Python.3.12").Run() + } + + return InstallResult{Tool: "python", Success: true, Message: "installed"} +} + +func (i *Installer) installGit() InstallResult { + if _, err := exec.LookPath("git"); err == nil { + return InstallResult{Tool: "git", Success: true, Message: "already installed"} + } + + switch i.system.PackageManager { + case "apt": + exec.Command("apt", "install", "-y", "git").Run() + case "brew": + exec.Command("brew", "install", "git").Run() + case "winget": + exec.Command("winget", "install", "Git.Git").Run() + } + + if i.config.Profile.Name != "" { + exec.Command("git", "config", "--global", "user.name", i.config.Profile.Name).Run() + } + if i.config.Profile.Email != "" { + exec.Command("git", "config", "--global", "user.email", i.config.Profile.Email).Run() + } + + return InstallResult{Tool: "git", Success: true, Message: "installed and configured"} +} + +func (i *Installer) SetupPrompt() error { + starshipPath, err := exec.LookPath("starship") + if err != nil { + return fmt.Errorf("starship not found") + } + + rcFile := i.getRCFile() + line := fmt.Sprintf("eval \"$(" + starshipPath + " init %s)\"", i.system.Shell) + appendLine(rcFile, line) + + configDir, _ := config.ConfigDir() + starshipConfig := `format = """ +$directory\ +$git_branch\ +$git_status\ +$git_metrics\ +$nodejs\ +$python\ +$golang\ +$rust\ +$cmd_duration\ +$line_break\ +$character""" + +[character] +success_symbol = "[❯](bold green)" +error_symbol = "[❯](bold red)" + +[git_branch] +format = "[$symbol$branch]($style) " + +[git_status] +format = '([$all_status$ahead_behind]($style) )' +` + configPath := configDir + "/starship.toml" + os.MkdirAll(configDir, 0755) + os.WriteFile(configPath, []byte(starshipConfig), 0644) + + return nil +} + +func (i *Installer) getRCFile() string { + home, _ := os.UserHomeDir() + switch i.system.Shell { + case "zsh": + return home + "/.zshrc" + case "fish": + return home + "/.config/fish/config.fish" + default: + return home + "/.bashrc" + } +} + +func appendLine(file, line string) { + data, _ := os.ReadFile(file) + if strings.Contains(string(data), line) { + return + } + f, err := os.OpenFile(file, os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0644) + if err != nil { + return + } + defer f.Close() + f.WriteString("\n" + line + "\n") +} diff --git a/internal/lsp/lsp.go b/internal/lsp/lsp.go new file mode 100644 index 0000000..4e66ce6 --- /dev/null +++ b/internal/lsp/lsp.go @@ -0,0 +1,209 @@ +package lsp + +import ( + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + + "github.com/muyue/muyue/internal/config" +) + +type LSPServer struct { + Name string `json:"name"` + Language string `json:"language"` + Command string `json:"command"` + InstallCmd string `json:"install_cmd"` + ConfigFile string `json:"config_file"` + Installed bool `json:"installed"` +} + +type LSPConfig struct { + Servers []LSPServer `json:"servers"` +} + +var knownServers = []LSPServer{ + {Name: "gopls", Language: "go", Command: "gopls", InstallCmd: "go install golang.org/x/tools/gopls@latest"}, + {Name: "pyright", Language: "python", Command: "pyright", InstallCmd: "npm install -g pyright"}, + {Name: "typescript-language-server", Language: "typescript", Command: "typescript-language-server", InstallCmd: "npm install -g typescript-language-server typescript"}, + {Name: "vscode-json-language-server", Language: "json", Command: "vscode-json-language-server", InstallCmd: "npm install -g vscode-langservers-extracted"}, + {Name: "vscode-html-language-server", Language: "html", Command: "vscode-html-language-server", InstallCmd: "npm install -g vscode-langservers-extracted"}, + {Name: "vscode-css-language-server", Language: "css", Command: "vscode-css-language-server", InstallCmd: "npm install -g vscode-langservers-extracted"}, + {Name: "yaml-language-server", Language: "yaml", Command: "yaml-language-server", InstallCmd: "npm install -g yaml-language-server"}, + {Name: "bash-language-server", Language: "bash", Command: "bash-language-server", InstallCmd: "npm install -g bash-language-server"}, + {Name: "rust-analyzer", Language: "rust", Command: "rust-analyzer", InstallCmd: "rustup component add rust-analyzer"}, + {Name: "clangd", Language: "c/c++", Command: "clangd", InstallCmd: ""}, + {Name: "lua-language-server", Language: "lua", Command: "lua-language-server", InstallCmd: "npm install -g lua-language-server"}, + {Name: "dockerfile-language-server", Language: "dockerfile", Command: "docker-langserver", InstallCmd: "npm install -g dockerfile-language-server-nodejs"}, + {Name: "tailwindcss-language-server", Language: "tailwind", Command: "tailwindcss-language-server", InstallCmd: "npm install -g @tailwindcss/language-server"}, + {Name: "svelte-language-server", Language: "svelte", Command: "svelteserver", InstallCmd: "npm install -g svelte-language-server"}, + {Name: "vue-language-server", Language: "vue", Command: "vue-language-server", InstallCmd: "npm install -g @vue/language-server"}, + {Name: "golangci-lint-langserver", Language: "go-lint", Command: "golangci-lint-langserver", InstallCmd: "go install github.com/nametake/golangci-lint-langserver@latest"}, +} + +func ScanServers() []LSPServer { + servers := make([]LSPServer, len(knownServers)) + for i, s := range knownServers { + servers[i] = s + _, err := exec.LookPath(s.Command) + servers[i].Installed = err == nil + } + return servers +} + +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 fmt.Errorf("unknown LSP server: %s", name) +} + +func InstallForLanguages(languages []string) []LSPServer { + langMap := map[string][]string{ + "go": {"gopls"}, + "typescript": {"typescript-language-server", "vscode-json-language-server", "vscode-html-language-server", "vscode-css-language-server"}, + "javascript": {"typescript-language-server", "vscode-json-language-server", "vscode-html-language-server", "vscode-css-language-server"}, + "python": {"pyright"}, + "rust": {"rust-analyzer"}, + "c": {"clangd"}, + "cpp": {"clangd"}, + "json": {"vscode-json-language-server"}, + "yaml": {"yaml-language-server"}, + "bash": {"bash-language-server"}, + "html": {"vscode-html-language-server"}, + "css": {"vscode-css-language-server"}, + "lua": {"lua-language-server"}, + "docker": {"dockerfile-language-server"}, + "svelte": {"svelte-language-server"}, + "vue": {"vue-language-server"}, + } + + installed := map[string]bool{} + var results []LSPServer + + for _, lang := range languages { + if servers, ok := langMap[lang]; ok { + for _, srv := range servers { + if installed[srv] { + continue + } + installed[srv] = true + if err := InstallServer(srv); err != nil { + results = append(results, LSPServer{Name: srv, Language: lang, Installed: false}) + } else { + results = append(results, LSPServer{Name: srv, Language: lang, Installed: true}) + } + } + } + } + + return results +} + +func GenerateCrushConfig(cfg *config.MuyueConfig) error { + if cfg == nil { + return fmt.Errorf("config is nil") + } + + configDir, err := config.ConfigDir() + if err != nil { + return err + } + + type lspEntry struct { + Command []string `json:"command"` + } + + lspConfig := map[string]lspEntry{} + + for _, lang := range cfg.Profile.Languages { + switch lang { + case "go": + lspConfig["go"] = lspEntry{Command: []string{"gopls"}} + case "python": + lspConfig["python"] = lspEntry{Command: []string{"pyright-langserver", "--stdio"}} + case "typescript", "javascript": + lspConfig["typescript"] = lspEntry{Command: []string{"typescript-language-server", "--stdio"}} + case "rust": + lspConfig["rust"] = lspEntry{Command: []string{"rust-analyzer"}} + case "c", "cpp": + lspConfig["c"] = lspEntry{Command: []string{"clangd"}} + case "lua": + lspConfig["lua"] = lspEntry{Command: []string{"lua-language-server"}} + } + } + + if len(lspConfig) == 0 { + return nil + } + + data, err := json.MarshalIndent(lspConfig, "", " ") + if err != nil { + return err + } + + lspPath := filepath.Join(configDir, "crush.json") + existing, err := os.ReadFile(lspPath) + if err == nil { + var existingConfig map[string]interface{} + if json.Unmarshal(existing, &existingConfig) == nil { + var newConfig map[string]interface{} + if json.Unmarshal(data, &newConfig) == nil { + for k, v := range newConfig { + existingConfig[k] = v + } + data, _ = json.MarshalIndent(existingConfig, "", " ") + } + } + } + + return os.WriteFile(lspPath, data, 0644) +} + +func EnsureCrushConfig(cfg *config.MuyueConfig) error { + configDir, _ := config.ConfigDir() + crusherPath := filepath.Join(configDir, "crush.json") + + if _, err := os.Stat(crusherPath); err != nil { + home, _ := os.UserHomeDir() + homeCrush := filepath.Join(home, ".config", "crush", "crush.json") + if _, err := os.Stat(homeCrush); err == nil { + return nil + } + + defaultConfig := map[string]interface{}{ + "version": "1", + } + + data, _ := json.MarshalIndent(defaultConfig, "", " ") + os.MkdirAll(filepath.Dir(crusherPath), 0755) + return os.WriteFile(crusherPath, data, 0644) + } + + return nil +} + +func PlatformTool() string { + switch runtime.GOOS { + case "linux": + return "apt" + case "darwin": + return "brew" + case "windows": + return "winget" + default: + return "unknown" + } +} diff --git a/internal/mcp/mcp.go b/internal/mcp/mcp.go new file mode 100644 index 0000000..3b38ea6 --- /dev/null +++ b/internal/mcp/mcp.go @@ -0,0 +1,255 @@ +package mcp + +import ( + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + + "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"` +} + +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", + }, +} + +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 + } + } + return servers +} + +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 { + json.Unmarshal(data, &existing) + } + + mcps := 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}, + }) + } + } + } + + 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) +} + +func GenerateClaudeMCPConfig(cfg *config.MuyueConfig, homeDir string) error { + if homeDir == "" { + home, _ := os.UserHomeDir() + homeDir = home + } + + configPath := filepath.Join(homeDir, ".claude.json") + + existing := map[string]interface{}{} + data, err := os.ReadFile(configPath) + if err == nil { + json.Unmarshal(data, &existing) + } + + 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}, + {"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) +} + +func ConfigureAll(cfg *config.MuyueConfig) error { + home, _ := os.UserHomeDir() + + if err := GenerateCrushMCPConfig(cfg, home); err != nil { + return fmt.Errorf("crush MCP config: %w", err) + } + + if err := GenerateClaudeMCPConfig(cfg, home); err != nil { + return fmt.Errorf("claude MCP config: %w", err) + } + + return nil +} diff --git a/internal/orchestrator/orchestrator.go b/internal/orchestrator/orchestrator.go new file mode 100644 index 0000000..a5cd1c6 --- /dev/null +++ b/internal/orchestrator/orchestrator.go @@ -0,0 +1,238 @@ +package orchestrator + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/muyue/muyue/internal/config" + "github.com/muyue/muyue/internal/workflow" +) + +type Message struct { + Role string `json:"role"` + Content string `json:"content"` +} + +type ChatRequest struct { + Model string `json:"model"` + Messages []Message `json:"messages"` + Stream bool `json:"stream"` +} + +type ChatResponse struct { + Choices []struct { + Message struct { + Content string `json:"content"` + } `json:"message"` + } `json:"choices"` + Usage struct { + TotalTokens int `json:"total_tokens"` + } `json:"usage"` +} + +type Orchestrator struct { + config *config.MuyueConfig + provider *config.AIProvider + client *http.Client + history []Message + Workflow *workflow.Workflow +} + +func New(cfg *config.MuyueConfig) (*Orchestrator, error) { + var provider *config.AIProvider + for i := range cfg.AI.Providers { + if cfg.AI.Providers[i].Active { + provider = &cfg.AI.Providers[i] + break + } + } + + if provider == nil { + return nil, fmt.Errorf("no active AI provider configured") + } + + if provider.APIKey == "" { + return nil, fmt.Errorf("API key not set for %s", provider.Name) + } + + return &Orchestrator{ + config: cfg, + provider: provider, + client: &http.Client{ + Timeout: 120 * time.Second, + }, + history: []Message{}, + Workflow: workflow.New(), + }, nil +} + +func (o *Orchestrator) Send(userMessage string) (string, error) { + o.history = append(o.history, Message{ + Role: "user", + Content: userMessage, + }) + + reqBody := ChatRequest{ + Model: o.provider.Model, + Messages: o.history, + Stream: false, + } + + body, err := json.Marshal(reqBody) + if err != nil { + return "", fmt.Errorf("marshal request: %w", err) + } + + baseURL := o.provider.BaseURL + if baseURL == "" { + baseURL = getProviderBaseURL(o.provider.Name) + } + + url := strings.TrimRight(baseURL, "/") + "/chat/completions" + + req, err := http.NewRequest("POST", url, bytes.NewReader(body)) + if err != nil { + return "", fmt.Errorf("create request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+o.provider.APIKey) + + resp, err := o.client.Do(req) + if err != nil { + return "", fmt.Errorf("send request: %w", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("read response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("API error (%d): %s", resp.StatusCode, string(respBody)) + } + + var chatResp ChatResponse + if err := json.Unmarshal(respBody, &chatResp); err != nil { + return "", fmt.Errorf("parse response: %w", err) + } + + if len(chatResp.Choices) == 0 { + return "", fmt.Errorf("no response from AI") + } + + content := chatResp.Choices[0].Message.Content + o.history = append(o.history, Message{ + Role: "assistant", + Content: content, + }) + + return content, nil +} + +func (o *Orchestrator) StartWorkflow(goal string) (string, error) { + o.Workflow.Start(goal) + o.history = []Message{ + {Role: "system", Content: workflow.BuildSystemPrompt(workflow.PhaseGathering, o.Workflow.Plan)}, + {Role: "user", Content: fmt.Sprintf("I want to: %s\nWhat questions do you need to ask me?", goal)}, + } + return o.Send(fmt.Sprintf("I want to: %s\nWhat questions do you need to ask me to fully understand this requirement? Ask ALL questions at once.", goal)) +} + +func (o *Orchestrator) AnswerQuestion(answer string) (string, error) { + o.Workflow.AddAnswer(answer) + return o.Send(answer) +} + +func (o *Orchestrator) GeneratePlan() (string, error) { + o.Workflow.Phase = workflow.PhasePlanning + o.history = append(o.history, Message{ + Role: "system", + Content: workflow.BuildSystemPrompt(workflow.PhasePlanning, o.Workflow.Plan), + }) + + prompt := "All questions have been answered. Now create a detailed step-by-step execution plan as a JSON array. Each step should have: id, title, description, agent (crush/claude/muyue)." + if len(o.Workflow.Plan.PreviewFiles) > 0 { + prompt += "\nInclude visual previews where helpful using the PREVIEW_JSON format." + } + + resp, err := o.Send(prompt) + if err != nil { + return "", err + } + + steps, parseErr := workflow.ParsePlanResponse(resp) + if parseErr == nil { + o.Workflow.SetPlan("") + o.Workflow.Plan.Steps = steps + o.Workflow.Phase = workflow.PhaseReviewing + } + + previewFiles := workflow.ParsePreviewFiles(resp) + if len(previewFiles) > 0 { + o.Workflow.SetPreviewFiles(previewFiles) + } + + return resp, nil +} + +func (o *Orchestrator) ReviewPlan(approved bool, feedback string) (string, error) { + if approved { + o.Workflow.Approve() + return o.executeNextStep() + } + o.Workflow.Reject(feedback) + return o.Send(fmt.Sprintf("The plan was rejected. Reason: %s. Please revise the plan.", feedback)) +} + +func (o *Orchestrator) executeNextStep() (string, error) { + step := o.Workflow.CurrentStep() + if step == nil { + return "All steps completed!", nil + } + + o.history = append(o.history, Message{ + Role: "system", + Content: workflow.BuildSystemPrompt(workflow.PhaseExecuting, o.Workflow.Plan), + }) + + return o.Send(fmt.Sprintf("Execute step %s: %s\n%s", step.ID, step.Title, step.Description)) +} + +func (o *Orchestrator) ContinueExecution(output string) (string, error) { + o.Workflow.AdvanceStep(output) + if o.Workflow.Phase == workflow.PhaseDone { + return "Workflow completed! All steps have been executed.", nil + } + return o.executeNextStep() +} + +func (o *Orchestrator) History() []Message { + return o.history +} + +func (o *Orchestrator) ClearHistory() { + o.history = []Message{} + o.Workflow.Reset() +} + +func getProviderBaseURL(name string) string { + switch name { + case "minimax": + return "https://api.minimax.io/v1" + case "anthropic": + return "https://api.anthropic.com/v1" + case "openai": + return "https://api.openai.com/v1" + case "zai": + return "https://api.z.ai/v1" + default: + return "" + } +} diff --git a/internal/platform/exec.go b/internal/platform/exec.go new file mode 100644 index 0000000..0d79838 --- /dev/null +++ b/internal/platform/exec.go @@ -0,0 +1,19 @@ +package platform + +import ( + "os" + "os/exec" + "strings" +) + +func fileContains(path, substr string) bool { + data, err := os.ReadFile(path) + if err != nil { + return false + } + return strings.Contains(strings.ToLower(string(data)), substr) +} + +func execLookPath(name string) (string, error) { + return exec.LookPath(name) +} diff --git a/internal/platform/platform.go b/internal/platform/platform.go new file mode 100644 index 0000000..c34c488 --- /dev/null +++ b/internal/platform/platform.go @@ -0,0 +1,107 @@ +package platform + +import ( + "runtime" + "strings" +) + +type OS string + +const ( + Linux OS = "linux" + MacOS OS = "darwin" + Windows OS = "windows" + Unknown OS = "unknown" +) + +type Arch string + +const ( + AMD64 Arch = "amd64" + ARM64 Arch = "arm64" + ARM Arch = "arm" + X86 Arch = "386" +) + +type SystemInfo struct { + OS OS + Arch Arch + IsWSL bool + Shell string + Terminal string + PackageManager string +} + +func Detect() SystemInfo { + info := SystemInfo{ + OS: OS(runtime.GOOS), + Arch: Arch(runtime.GOARCH), + } + + info.IsWSL = detectWSL() + info.Shell = detectShell() + info.Terminal = detectTerminal() + info.PackageManager = detectPackageManager(info.OS) + + return info +} + +func detectWSL() bool { + return fileContains("/proc/version", "microsoft") || + fileContains("/proc/version", "WSL") +} + +func detectShell() string { + shells := []string{"zsh", "bash", "fish", "pwsh", "powershell"} + for _, s := range shells { + if _, err := execLookPath(s); err == nil { + return s + } + } + return "sh" +} + +func detectTerminal() string { + terms := []string{ + "hyper", "alacritty", "kitty", "wezterm", "ghostty", + "windows-terminal", "gnome-terminal", "konsole", + "xterm", "tilix", "terminator", + } + for _, t := range terms { + if _, err := execLookPath(t); err == nil { + return t + } + } + return "unknown" +} + +func detectPackageManager(os OS) string { + managers := map[string][]string{ + "linux": {"apt", "dnf", "pacman", "zypper", "nix", "apk", "snap", "flatpak"}, + "darwin": {"brew", "nix"}, + "windows": {"winget", "choco", "scoop"}, + } + + if list, ok := managers[string(os)]; ok { + for _, mgr := range list { + if _, err := execLookPath(mgr); err == nil { + return mgr + } + } + } + return "unknown" +} + +func (s SystemInfo) String() string { + parts := []string{ + "OS: " + string(s.OS), + "Arch: " + string(s.Arch), + } + if s.IsWSL { + parts = append(parts, "WSL: yes") + } + parts = append(parts, "Shell: "+s.Shell) + parts = append(parts, "Terminal: "+s.Terminal) + parts = append(parts, "PackageManager: "+s.PackageManager) + return strings.Join(parts, ", ") +} diff --git a/internal/preview/preview.go b/internal/preview/preview.go new file mode 100644 index 0000000..a4ef1e3 --- /dev/null +++ b/internal/preview/preview.go @@ -0,0 +1,76 @@ +package preview + +import ( + "fmt" + "net/http" + "os" + "os/exec" + "path/filepath" + "runtime" +) + +type PreviewServer struct { + dir string + server *http.Server +} + +func NewPreviewServer(dir string) *PreviewServer { + return &PreviewServer{dir: dir} +} + +func (p *PreviewServer) Start(port int) error { + fs := http.FileServer(http.Dir(p.dir)) + mux := http.NewServeMux() + mux.Handle("/", fs) + + p.server = &http.Server{ + Addr: fmt.Sprintf("127.0.0.1:%d", port), + Handler: mux, + } + + go func() { + if err := p.server.ListenAndServe(); err != nil && err != http.ErrServerClosed { + fmt.Printf("Preview server error: %s\n", err) + } + }() + + url := fmt.Sprintf("http://127.0.0.1:%d", port) + fmt.Printf("Preview server running at %s\n", url) + + return openBrowser(url) +} + +func (p *PreviewServer) Stop() error { + if p.server != nil { + return p.server.Close() + } + return nil +} + +func CreatePreviewFile(dir, filename, content string) error { + if err := os.MkdirAll(dir, 0755); err != nil { + return err + } + return os.WriteFile(filepath.Join(dir, filename), []byte(content), 0644) +} + +func openBrowser(url string) error { + var cmd string + var args []string + + switch runtime.GOOS { + case "linux": + cmd = "xdg-open" + args = []string{url} + case "darwin": + cmd = "open" + args = []string{url} + case "windows": + cmd = "cmd" + args = []string{"/c", "start", url} + default: + return fmt.Errorf("unsupported platform") + } + + return exec.Command(cmd, args...).Start() +} diff --git a/internal/profiler/profiler.go b/internal/profiler/profiler.go new file mode 100644 index 0000000..660aed1 --- /dev/null +++ b/internal/profiler/profiler.go @@ -0,0 +1,125 @@ +package profiler + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/huh" + "github.com/muyue/muyue/internal/config" + "github.com/muyue/muyue/internal/scanner" +) + +func RunFirstTimeSetup() (*config.MuyueConfig, error) { + cfg := config.Default() + result := scanner.ScanSystem() + + fmt.Println("Welcome to muyue! Let's set up your environment.\n") + + var name, pseudo, email string + var languages []string + var editor string + var aiProvider string + + nameField := huh.NewInput(). + Title("What's your name?"). + Placeholder("Augustin"). + Value(&name) + + pseudoField := huh.NewInput(). + Title("Your pseudo?"). + Placeholder("muyue"). + Value(&pseudo) + + emailField := huh.NewInput(). + Title("Your email (for git config)?"). + Placeholder("you@example.com"). + Value(&email) + + langOptions := []huh.Option[string]{ + {Key: "Go", Value: "go"}, + {Key: "TypeScript/JavaScript", Value: "typescript"}, + {Key: "Python", Value: "python"}, + {Key: "Rust", Value: "rust"}, + {Key: "Java", Value: "java"}, + {Key: "C/C++", Value: "c"}, + {Key: "Ruby", Value: "ruby"}, + {Key: "PHP", Value: "php"}, + {Key: "Swift", Value: "swift"}, + {Key: "Kotlin", Value: "kotlin"}, + } + + langSelect := huh.NewMultiSelect[string](). + Title("Which languages do you work with?"). + Options(langOptions...). + Value(&languages) + + editorOptions := []huh.Option[string]{ + {Key: "VS Code", Value: "code"}, + {Key: "Neovim", Value: "nvim"}, + {Key: "Vim", Value: "vim"}, + {Key: "Emacs", Value: "emacs"}, + {Key: "JetBrains", Value: "jetbrains"}, + {Key: "Helix", Value: "hx"}, + {Key: "Sublime Text", Value: "subl"}, + } + + editorSelect := huh.NewSelect[string](). + Title("Preferred editor?"). + Options(editorOptions...). + Value(&editor) + + aiOptions := []huh.Option[string]{ + {Key: "MiniMax M2.7", Value: "minimax"}, + {Key: "Z.AI GLM", Value: "zai"}, + {Key: "Anthropic Claude", Value: "anthropic"}, + {Key: "OpenAI", Value: "openai"}, + } + + aiSelect := huh.NewSelect[string](). + Title("Which AI provider for orchestration?"). + Options(aiOptions...). + Value(&aiProvider) + + form := huh.NewForm( + huh.NewGroup(nameField, pseudoField, emailField), + huh.NewGroup(langSelect), + huh.NewGroup(editorSelect), + huh.NewGroup(aiSelect), + ) + + if err := form.Run(); err != nil { + return nil, fmt.Errorf("profile form: %w", err) + } + + cfg.Profile.Name = name + cfg.Profile.Pseudo = pseudo + cfg.Profile.Email = email + cfg.Profile.Languages = languages + cfg.Profile.Preferences.Editor = editor + cfg.Profile.Preferences.DefaultAI = aiProvider + + for i := range cfg.AI.Providers { + cfg.AI.Providers[i].Active = cfg.AI.Providers[i].Name == aiProvider + } + + _ = result + + return cfg, nil +} + +func AskAPIKey(providerName string) (string, error) { + var apiKey string + + field := huh.NewInput(). + Title(fmt.Sprintf("Enter your %s API key:", providerName)). + Description("The key will be stored locally in ~/.muyue/config.yaml"). + EchoMode(huh.EchoModePassword). + Value(&apiKey) + + form := huh.NewForm(huh.NewGroup(field)) + if err := form.Run(); err != nil { + return "", err + } + + return strings.TrimSpace(apiKey), nil +} diff --git a/internal/proxy/proxy.go b/internal/proxy/proxy.go new file mode 100644 index 0000000..0911907 --- /dev/null +++ b/internal/proxy/proxy.go @@ -0,0 +1,259 @@ +package proxy + +import ( + "bufio" + "context" + "fmt" + "io" + "os" + "os/exec" + "strings" + "sync" + "time" +) + +type AgentType string + +const ( + AgentCrush AgentType = "crush" + AgentClaude AgentType = "claude" +) + +type AgentStatus string + +const ( + StatusIdle AgentStatus = "idle" + StatusRunning AgentStatus = "running" + StatusStopped AgentStatus = "stopped" + StatusError AgentStatus = "error" +) + +type LogEntry struct { + Timestamp time.Time + Agent AgentType + Level string + Message string +} + +type Agent struct { + Type AgentType + Status AgentStatus + cmd *exec.Cmd + stdout io.Reader + stderr io.Reader + cancel context.CancelFunc + mu sync.Mutex + logs []LogEntry +} + +type Manager struct { + agents map[AgentType]*Agent + mu sync.RWMutex +} + +func NewManager() *Manager { + return &Manager{ + agents: make(map[AgentType]*Agent), + } +} + +func (m *Manager) Start(agentType AgentType, args ...string) error { + m.mu.Lock() + defer m.mu.Unlock() + + if a, exists := m.agents[agentType]; exists && a.Status == StatusRunning { + return fmt.Errorf("%s already running", agentType) + } + + ctx, cancel := context.WithCancel(context.Background()) + + var cmdName string + switch agentType { + case AgentCrush: + cmdName = "crush" + case AgentClaude: + cmdName = "claude" + default: + cancel() + return fmt.Errorf("unknown agent type: %s", agentType) + } + + cmd := exec.CommandContext(ctx, cmdName, args...) + cmd.Env = os.Environ() + + stdout, _ := cmd.StdoutPipe() + stderr, _ := cmd.StderrPipe() + + agent := &Agent{ + Type: agentType, + Status: StatusRunning, + cmd: cmd, + stdout: stdout, + stderr: stderr, + cancel: cancel, + } + + m.agents[agentType] = agent + + go agent.captureOutput(stdout, "info") + go agent.captureOutput(stderr, "error") + + if err := cmd.Start(); err != nil { + agent.Status = StatusError + cancel() + return fmt.Errorf("start %s: %w", agentType, err) + } + + go func() { + err := cmd.Wait() + m.mu.Lock() + defer m.mu.Unlock() + if err != nil && ctx.Err() == nil { + agent.Status = StatusError + agent.log("error", fmt.Sprintf("exited with error: %s", err)) + } else { + agent.Status = StatusStopped + agent.log("info", "stopped") + } + }() + + return nil +} + +func (m *Manager) Stop(agentType AgentType) error { + m.mu.Lock() + defer m.mu.Unlock() + + agent, exists := m.agents[agentType] + if !exists { + return fmt.Errorf("%s not found", agentType) + } + + if agent.Status != StatusRunning { + return fmt.Errorf("%s is not running", agentType) + } + + agent.cancel() + agent.Status = StatusStopped + return nil +} + +func (m *Manager) Status(agentType AgentType) (AgentStatus, []LogEntry) { + m.mu.RLock() + defer m.mu.RUnlock() + + agent, exists := m.agents[agentType] + if !exists { + return StatusIdle, nil + } + + agent.mu.Lock() + defer agent.mu.Unlock() + + return agent.Status, agent.logs +} + +func (m *Manager) AllStatus() map[AgentType]AgentStatus { + m.mu.RLock() + defer m.mu.RUnlock() + + statuses := make(map[AgentType]AgentStatus) + for t, a := range m.agents { + statuses[t] = a.Status + } + return statuses +} + +func (m *Manager) SendCommand(agentType AgentType, input string) error { + m.mu.RLock() + agent, exists := m.agents[agentType] + m.mu.RUnlock() + + if !exists || agent.Status != StatusRunning { + return fmt.Errorf("%s is not running", agentType) + } + + stdin, err := agent.cmd.StdinPipe() + if err != nil { + return fmt.Errorf("get stdin: %w", err) + } + + _, err = fmt.Fprintln(stdin, input) + return err +} + +func (a *Agent) captureOutput(reader io.Reader, level string) { + scanner := bufio.NewScanner(reader) + for scanner.Scan() { + line := scanner.Text() + a.mu.Lock() + a.logs = append(a.logs, LogEntry{ + Timestamp: time.Now(), + Agent: a.Type, + Level: level, + Message: line, + }) + if len(a.logs) > 1000 { + a.logs = a.logs[500:] + } + a.mu.Unlock() + } +} + +func (a *Agent) log(level, msg string) { + a.mu.Lock() + defer a.mu.Unlock() + a.logs = append(a.logs, LogEntry{ + Timestamp: time.Now(), + Agent: a.Type, + Level: level, + Message: msg, + }) +} + +func (m *Manager) IsAvailable(agentType AgentType) bool { + var cmdName string + switch agentType { + case AgentCrush: + cmdName = "crush" + case AgentClaude: + cmdName = "claude" + default: + return false + } + + path, err := exec.LookPath(cmdName) + return err == nil && path != "" +} + +func (m *Manager) GetLogs(agentType AgentType, lastN int) []LogEntry { + m.mu.RLock() + agent, exists := m.agents[agentType] + m.mu.RUnlock() + + if !exists { + return nil + } + + agent.mu.Lock() + defer agent.mu.Unlock() + + logs := agent.logs + if lastN > 0 && len(logs) > lastN { + logs = logs[len(logs)-lastN:] + } + return logs +} + +func FormatLogs(logs []LogEntry) string { + var b strings.Builder + for _, l := range logs { + b.WriteString(fmt.Sprintf("[%s] %s %s: %s\n", + l.Timestamp.Format("15:04:05"), + l.Agent, + l.Level, + l.Message, + )) + } + return b.String() +} diff --git a/internal/scanner/scanner.go b/internal/scanner/scanner.go new file mode 100644 index 0000000..8a52e8a --- /dev/null +++ b/internal/scanner/scanner.go @@ -0,0 +1,195 @@ +package scanner + +import ( + "fmt" + "os" + "os/exec" + "regexp" + "strings" + + "github.com/muyue/muyue/internal/platform" +) + +type ToolStatus struct { + Name string `yaml:"name"` + Installed bool `yaml:"installed"` + Version string `yaml:"version"` + Path string `yaml:"path"` + Latest string `yaml:"latest"` + NeedsUpdate bool `yaml:"needs_update"` + Category string `yaml:"category"` +} + +type RuntimeStatus struct { + Name string `yaml:"name"` + Installed bool `yaml:"installed"` + Version string `yaml:"version"` +} + +type ScanResult struct { + System platform.SystemInfo `yaml:"system"` + Tools []ToolStatus `yaml:"tools"` + Runtimes []RuntimeStatus `yaml:"runtimes"` + ShellSetup bool `yaml:"shell_setup"` + GitConfigured bool `yaml:"git_configured"` +} + +func ScanSystem() *ScanResult { + info := platform.Detect() + result := &ScanResult{ + System: info, + } + + result.Tools = scanTools() + result.Runtimes = scanRuntimes() + result.ShellSetup = checkShellSetup() + result.GitConfigured = checkGitConfig() + + return result +} + +func scanTools() []ToolStatus { + tools := []struct { + name string + category string + version []string + }{ + {"crush", "ai", []string{"version", "--short"}}, + {"claude", "ai", []string{"--version"}}, + {"git", "vcs", []string{"--version"}}, + {"node", "runtime", []string{"--version"}}, + {"npm", "runtime", []string{"--version"}}, + {"pnpm", "runtime", []string{"--version"}}, + {"python3", "runtime", []string{"--version"}}, + {"pip3", "runtime", []string{"--version"}}, + {"uv", "runtime", []string{"--version"}}, + {"go", "runtime", []string{"version"}}, + {"docker", "devops", []string{"--version"}}, + {"gh", "devops", []string{"--version"}}, + {"starship", "prompt", []string{"--version"}}, + {"npx", "runtime", []string{"--version"}}, + } + + var statuses []ToolStatus + for _, t := range tools { + status := ToolStatus{ + Name: t.name, + Category: t.category, + } + + path, err := exec.LookPath(t.name) + if err != nil { + statuses = append(statuses, status) + continue + } + + status.Installed = true + status.Path = path + + if len(t.version) > 0 { + cmd := exec.Command(t.name, t.version...) + out, err := cmd.Output() + if err == nil { + status.Version = strings.TrimSpace(string(out)) + } + } + + statuses = append(statuses, status) + } + + return statuses +} + +func scanRuntimes() []RuntimeStatus { + runtimes := []struct { + name string + command []string + }{ + {"Go", []string{"go", "version"}}, + {"Node.js", []string{"node", "--version"}}, + {"Python", []string{"python3", "--version"}}, + {"Rust", []string{"rustc", "--version"}}, + {"Java", []string{"java", "--version"}}, + {"Ruby", []string{"ruby", "--version"}}, + {"PHP", []string{"php", "--version"}}, + {"Dotnet", []string{"dotnet", "--version"}}, + } + + var statuses []RuntimeStatus + for _, r := range runtimes { + status := RuntimeStatus{Name: r.name} + cmd := exec.Command(r.command[0], r.command[1:]...) + out, err := cmd.Output() + if err == nil { + status.Installed = true + status.Version = strings.TrimSpace(string(out)) + } + statuses = append(statuses, status) + } + + return statuses +} + +func checkShellSetup() bool { + home, _ := os.UserHomeDir() + rcFiles := []string{".bashrc", ".zshrc", ".config/fish/config.fish"} + for _, f := range rcFiles { + data, err := os.ReadFile(home + "/" + f) + if err != nil { + continue + } + content := string(data) + if strings.Contains(content, "starship") || + strings.Contains(content, "muyue") { + return true + } + } + return false +} + +func checkGitConfig() bool { + for _, key := range []string{"user.name", "user.email"} { + cmd := exec.Command("git", "config", "--global", key) + if _, err := cmd.Output(); err != nil { + return false + } + } + return true +} + +var versionRegex = regexp.MustCompile(`\d+\.\d+\.\d+`) + +func (s *ScanResult) Summary() string { + var b strings.Builder + + fmt.Fprintf(&b, "System: %s\n", s.System.String()) + fmt.Fprintf(&b, "\nTools:\n") + + installed := 0 + for _, t := range s.Tools { + if t.Installed { + installed++ + fmt.Fprintf(&b, " [v] %s %s\n", t.Name, versionRegex.FindString(t.Version)) + } else { + fmt.Fprintf(&b, " [ ] %s (not installed)\n", t.Name) + } + } + fmt.Fprintf(&b, "\nInstalled: %d/%d\n", installed, len(s.Tools)) + + fmt.Fprintf(&b, "\nRuntimes:\n") + for _, r := range s.Runtimes { + if r.Installed { + fmt.Fprintf(&b, " [v] %s\n", r.Version) + } else { + fmt.Fprintf(&b, " [ ] %s (not installed)\n", r.Name) + } + } + + if s.GitConfigured { + fmt.Fprintf(&b, "\nGit: configured\n") + } else { + fmt.Fprintf(&b, "\nGit: not fully configured\n") + } + + return b.String() +} diff --git a/internal/skills/builtins.go b/internal/skills/builtins.go new file mode 100644 index 0000000..6c6200a --- /dev/null +++ b/internal/skills/builtins.go @@ -0,0 +1,298 @@ +package skills + +import ( + "os" + "path/filepath" + "time" +) + +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", + Target: "both", + Tags: []string{"setup", "environment", "install"}, + Content: `# Environment Setup + +Use this skill when setting up a new development environment or project. + +## Steps + +1. Detect the project type by checking for config files: + - ` + "`go.mod`" + ` → Go + - ` + "`package.json`" + ` → Node.js + - ` + "`requirements.txt` / `pyproject.toml` / `setup.py`" + ` → Python + - ` + "`Cargo.toml`" + ` → Rust + - ` + "`pom.xml` / `build.gradle`" + ` → Java + +2. Check if required tools are installed: + - Language runtime (go, node, python, etc.) + - Package manager (npm, pip, cargo, etc.) + - Linter/formatter + - Test runner + +3. If tools are missing, install them using the system package manager: + - Linux: apt/dnf/pacman + - macOS: brew + - Windows: winget/scoop + +4. Install project dependencies: + - ` + "`go mod download`" + ` for Go + - ` + "`npm install`" + ` / ` + "`pnpm install`" + ` for Node.js + - ` + "`pip install -r requirements.txt`" + ` / ` + "`uv sync`" + ` for Python + - ` + "`cargo build`" + ` for Rust + +5. Configure git hooks if ` + "`pre-commit`" + ` config exists. + +6. Verify the setup by running tests or build. + +## Error Handling + +- If a tool cannot be installed automatically, provide the manual install command. +- If dependency installation fails, check for version conflicts and suggest fixes. +- Always check for lock files first to ensure reproducible installs.`, + }, + { + 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", + Target: "both", + Tags: []string{"git", "workflow", "branching", "commits"}, + Content: `# Git Workflow + +Use this skill when the user needs to create branches, make commits, or manage pull requests. + +## Branch Naming Convention + +- ` + "`feature/-`" + ` for new features +- ` + "`fix/-`" + ` for bug fixes +- ` + "`refactor/`" + ` for refactoring +- ` + "`docs/`" + ` for documentation + +## Commit Messages + +Follow Conventional Commits: +- ` + "`feat: add user authentication`" + ` +- ` + "`fix: resolve null pointer in login`" + ` +- ` + "`refactor: extract validation logic`" + ` +- ` + "`docs: update API documentation`" + ` +- ` + "`test: add integration tests for auth`" + ` +- ` + "`chore: update dependencies`" + ` + +## Workflow + +1. Before starting, pull latest changes: ` + "`git pull --rebase origin main`" + ` +2. Create branch: ` + "`git checkout -b feature/my-feature`" + ` +3. Make changes and commit frequently with meaningful messages +4. Before pushing, rebase: ` + "`git rebase origin/main`" + ` +5. Push: ` + "`git push -u origin feature/my-feature`" + ` +6. Create PR with description following template + +## PR Description Template + +` + "```" + ` +## Summary +<1-3 bullet points> + +## Changes +- + +## Test Plan +- [ ] +` + "```" + ` + +## Error Handling + +- If rebase has conflicts, help resolve them file by file +- If push is rejected, pull with rebase first +- Never force push to main/master`, + }, + { + 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", + Target: "both", + Tags: []string{"api", "rest", "graphql", "design"}, + Content: `# API Design + +Use this skill when designing or implementing an API. + +## REST API Design Rules + +1. Use nouns for resources, not verbs: ` + "`/users`" + ` not ` + "`/getUsers`" + ` +2. Use plural nouns: ` + "`/users`" + ` not ` + "`/user`" + ` +3. Use proper HTTP methods: + - GET for reading + - POST for creating + - PUT/PATCH for updating + - DELETE for removing +4. Use query parameters for filtering: ` + "`/users?role=admin&active=true`" + ` +5. Use pagination: ` + "`/users?page=1&limit=20`" + ` +6. Return proper status codes: + - 200: Success + - 201: Created + - 204: No Content (successful delete) + - 400: Bad Request + - 401: Unauthorized + - 403: Forbidden + - 404: Not Found + - 422: Validation Error + - 500: Internal Server Error + +## Error Response Format + +` + "```" + `json +{ + "error": { + "code": "VALIDATION_ERROR", + "message": "Human readable message", + "details": [ + {"field": "email", "message": "Invalid email format"} + ] + } +} +` + "```" + ` + +## Implementation Steps + +1. Define the API spec (endpoints, request/response schemas) +2. Implement data models +3. Add validation middleware +4. Implement handlers +5. Add error handling +6. Write tests +7. Generate documentation (OpenAPI/Swagger)`, + }, + { + Name: "debug-assist", + Description: "Systematic debugging assistant. Helps identify, isolate, and fix bugs using a structured approach.", + Author: "muyue", + Version: "1.0.0", + Target: "both", + Tags: []string{"debug", "troubleshooting", "bugs"}, + Content: `# Debug Assist + +Use this skill when the user reports a bug or asks for help debugging. + +## Debugging Process + +1. **Reproduce** — Understand the exact steps to reproduce +2. **Isolate** — Narrow down the scope: + - Is it frontend or backend? + - Does it happen consistently or intermittently? + - Does it happen in all environments or just one? +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 +7. **Prevent** — Add a test to prevent regression + +## Common Patterns + +- **Null/Nil errors**: Check for missing initialization or unexpected nil values +- **Type errors**: Check API contracts and type assertions +- **Race conditions**: Look for shared mutable state without synchronization +- **Memory leaks**: Check for unclosed resources (connections, files, channels) +- **Off-by-one**: Check loop bounds and array indexing +- **State issues**: Check if state is properly reset between operations + +## Debugging Commands + +- Check logs: Look for error messages and stack traces +- Check recent changes: ` + "`git diff HEAD~5`" + ` to see what changed +- Check dependencies: Verify versions match expected +- Check environment: Compare config between working and broken environments`, + }, + { + 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", + Target: "both", + Tags: []string{"review", "quality", "security"}, + Content: `# Code Review + +Use this skill when reviewing code changes or pull requests. + +## Review Checklist + +### Correctness +- Does the code do what it's supposed to? +- Are edge cases handled? +- Are there off-by-one errors? +- Are error paths handled? + +### Security +- Is user input sanitized? +- Are there SQL injection risks? +- Are secrets hardcoded? +- Are authentication/authorization checks in place? +- Is sensitive data logged? + +### Performance +- Are there N+1 queries? +- Are there unnecessary loops or computations? +- Is caching used where appropriate? +- Are large allocations avoided? + +### Readability +- Are names descriptive? +- Is the code self-documenting? +- Are complex parts commented? +- Is the code modular? + +### Testing +- Are there unit tests? +- Are edge cases tested? +- Are error paths tested? +- Are tests independent and deterministic? + +## Review Format + +1. Summary of changes +2. Issues found (critical → minor) +3. Suggestions for improvement +4. Positive observations + +## Severity Levels + +- **Critical**: Security vulnerabilities, data loss risks, crashes +- **Major**: Bugs, performance issues, missing error handling +- **Minor**: Style issues, naming, minor refactoring opportunities +- **Suggestion**: Alternative approaches, improvements`, + }, +} + +func InstallBuiltinSkills() error { + dir, err := SkillsDir() + if err != nil { + return err + } + + for _, s := range builtinSkills { + skillDir := filepath.Join(dir, s.Name) + skillPath := filepath.Join(skillDir, "SKILL.md") + + if _, err := os.Stat(skillPath); err == nil { + continue + } + + s.CreatedAt = time.Now() + s.UpdatedAt = time.Now() + + if err := os.MkdirAll(skillDir, 0755); err != nil { + return err + } + + content := renderSkill(&s) + if err := os.WriteFile(skillPath, []byte(content), 0644); err != nil { + return err + } + } + + return nil +} diff --git a/internal/skills/skills.go b/internal/skills/skills.go new file mode 100644 index 0000000..0d3c552 --- /dev/null +++ b/internal/skills/skills.go @@ -0,0 +1,291 @@ +package skills + +import ( + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "time" +) + +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:"-"` +} + +type Target string + +const ( + TargetCrush Target = "crush" + TargetClaude Target = "claude" + TargetBoth Target = "both" +) + +func SkillsDir() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + dir := filepath.Join(home, ".muyue", "skills") + return dir, nil +} + +func List() ([]Skill, error) { + dir, err := SkillsDir() + if err != nil { + return nil, err + } + + entries, err := os.ReadDir(dir) + if err != nil { + if os.IsNotExist(err) { + return []Skill{}, nil + } + return nil, err + } + + var skills []Skill + for _, e := range entries { + if !e.IsDir() { + continue + } + skillPath := filepath.Join(dir, e.Name(), "SKILL.md") + data, err := os.ReadFile(skillPath) + if err != nil { + continue + } + skill, err := parseSkill(data) + if err != nil { + continue + } + skill.FilePath = skillPath + skill.Name = e.Name() + skills = append(skills, *skill) + } + + sort.Slice(skills, func(i, j int) bool { + return skills[i].Name < skills[j].Name + }) + + return skills, nil +} + +func Get(name string) (*Skill, error) { + dir, err := SkillsDir() + if err != nil { + return nil, err + } + + skillPath := filepath.Join(dir, name, "SKILL.md") + data, err := os.ReadFile(skillPath) + if err != nil { + return nil, fmt.Errorf("skill '%s' not found", name) + } + + skill, err := parseSkill(data) + if err != nil { + return nil, err + } + skill.FilePath = skillPath + skill.Name = name + return skill, nil +} + +func Create(skill *Skill) error { + dir, err := SkillsDir() + if err != nil { + return err + } + + skillDir := filepath.Join(dir, skill.Name) + if err := os.MkdirAll(skillDir, 0755); err != nil { + return err + } + + skillPath := filepath.Join(skillDir, "SKILL.md") + content := renderSkill(skill) + if err := os.WriteFile(skillPath, []byte(content), 0644); err != nil { + return err + } + + return Deploy(skill) +} + +func Update(skill *Skill) error { + dir, err := SkillsDir() + if err != nil { + return err + } + + skillDir := filepath.Join(dir, skill.Name) + skillPath := filepath.Join(skillDir, "SKILL.md") + if _, err := os.Stat(skillPath); err != nil { + return fmt.Errorf("skill '%s' not found", skill.Name) + } + + skill.UpdatedAt = time.Now() + content := renderSkill(skill) + if err := os.WriteFile(skillPath, []byte(content), 0644); err != nil { + return err + } + + return Deploy(skill) +} + +func Delete(name string) error { + dir, err := SkillsDir() + if err != nil { + return err + } + + skillDir := filepath.Join(dir, name) + if err := os.RemoveAll(skillDir); err != nil { + return err + } + + undeployFromTargets(name) + return nil +} + +func Deploy(skill *Skill) error { + home, _ := os.UserHomeDir() + + if skill.Target == string(TargetCrush) || skill.Target == string(TargetBoth) { + crushSkillsDir := filepath.Join(home, ".config", "crush", "skills") + os.MkdirAll(crushSkillsDir, 0755) + target := filepath.Join(crushSkillsDir, skill.Name) + os.MkdirAll(target, 0755) + content := renderSkill(skill) + os.WriteFile(filepath.Join(target, "SKILL.md"), []byte(content), 0644) + } + + if skill.Target == string(TargetClaude) || skill.Target == string(TargetBoth) { + claudeSkillsDir := filepath.Join(home, ".claude", "skills") + os.MkdirAll(claudeSkillsDir, 0755) + target := filepath.Join(claudeSkillsDir, skill.Name) + os.MkdirAll(target, 0755) + content := renderSkill(skill) + os.WriteFile(filepath.Join(target, "SKILL.md"), []byte(content), 0644) + } + + return nil +} + +func DeployAll() error { + skills, err := List() + if err != nil { + return err + } + for _, s := range skills { + if err := Deploy(&s); err != nil { + return fmt.Errorf("deploy %s: %w", s.Name, err) + } + } + return nil +} + +func undeployFromTargets(name string) { + home, _ := os.UserHomeDir() + + os.RemoveAll(filepath.Join(home, ".config", "crush", "skills", name)) + os.RemoveAll(filepath.Join(home, ".claude", "skills", name)) +} + +func parseSkill(data []byte) (*Skill, error) { + content := string(data) + + if !strings.HasPrefix(content, "---") { + return &Skill{Content: content}, nil + } + + end := strings.Index(content[3:], "---") + if end == -1 { + return &Skill{Content: content}, nil + } + + frontmatter := strings.TrimSpace(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) + } + } + } + } + + return skill, nil +} + +func renderSkill(skill *Skill) string { + var b strings.Builder + + b.WriteString("---\n") + b.WriteString(fmt.Sprintf("name: %s\n", skill.Name)) + b.WriteString(fmt.Sprintf("description: %s\n", skill.Description)) + if skill.Author != "" { + b.WriteString(fmt.Sprintf("author: %s\n", skill.Author)) + } + if skill.Version != "" { + b.WriteString(fmt.Sprintf("version: %s\n", skill.Version)) + } + if skill.Target != "" { + b.WriteString(fmt.Sprintf("target: %s\n", skill.Target)) + } + if len(skill.Tags) > 0 { + b.WriteString(fmt.Sprintf("tags: [%s]\n", strings.Join(skill.Tags, ", "))) + } + b.WriteString("---\n\n") + b.WriteString(skill.Content) + b.WriteString("\n") + + return b.String() +} + +func BuildAIGeneratePrompt(name, description, target string) string { + return fmt.Sprintf(`Generate a skill file for an AI coding assistant. + +SKILL NAME: %s +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 +2. Markdown body with detailed instructions + +The skill should be practical, specific, and actionable. +Include: +- When to activate this skill +- Step-by-step instructions +- Examples where relevant +- Error handling guidance + +Output ONLY the skill file content, starting with ---`, name, description, target) +} diff --git a/internal/tui/app.go b/internal/tui/app.go new file mode 100644 index 0000000..391a205 --- /dev/null +++ b/internal/tui/app.go @@ -0,0 +1,1091 @@ +package tui + +import ( + "fmt" + "os" + "path/filepath" + "regexp" + "strings" + "time" + + "github.com/charmbracelet/bubbles/viewport" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/muyue/muyue/internal/config" + "github.com/muyue/muyue/internal/daemon" + "github.com/muyue/muyue/internal/installer" + "github.com/muyue/muyue/internal/lsp" + "github.com/muyue/muyue/internal/mcp" + "github.com/muyue/muyue/internal/orchestrator" + "github.com/muyue/muyue/internal/preview" + "github.com/muyue/muyue/internal/proxy" + "github.com/muyue/muyue/internal/scanner" + "github.com/muyue/muyue/internal/skills" + "github.com/muyue/muyue/internal/updater" + "github.com/muyue/muyue/internal/version" + "github.com/muyue/muyue/internal/workflow" +) + +type tab int + +const ( + tabDashboard tab = iota + tabChat + tabWorkflow + tabAgents + tabConfig + tabCount +) + +var tabNames = []string{"Dashboard", "Chat", "Workflow", "Agents", "Config"} + +var ( + titleStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("#FF6B9D")). + Background(lipgloss.Color("#2D2D3F")). + Padding(0, 2) + + tabStyle = lipgloss.NewStyle(). + Padding(0, 2). + Foreground(lipgloss.Color("#666680")) + + activeTabStyle = lipgloss.NewStyle(). + Padding(0, 2). + Foreground(lipgloss.Color("#FF6B9D")). + Border(lipgloss.NormalBorder(), false, false, true, false). + BorderForeground(lipgloss.Color("#FF6B9D")) + + statusBarStyle = lipgloss.NewStyle(). + Background(lipgloss.Color("#2D2D3F")). + Foreground(lipgloss.Color("#A0A0B0")). + Padding(0, 1) + + sectionStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#A0D2FF")). + Bold(true) + + itemOKStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#4ADE80")) + + itemMissingStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#FF6B6B")) + + itemWarnStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#FBBF24")) + + itemPendingStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#666680")) + + userMsgStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#A0D2FF")) + + aiMsgStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#C4B5FD")) + + errMsgStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#FF6B6B")) + + inputStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#FF6B9D")) + + phaseStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#FBBF24")). + Bold(true) + + stepDoneStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#4ADE80")) + + stepPendingStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#666680")) + + stepCurrentStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#FF6B9D")). + Bold(true) + + stepErrorStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#FF6B6B")) +) + +type aiResponseMsg struct{ content string } +type aiErrMsg struct{ err error } +type scanCompleteMsg struct{ result *scanner.ScanResult } +type installCompleteMsg struct{ results []installer.InstallResult } +type updateCheckMsg struct{ statuses []updater.UpdateStatus } +type previewReadyMsg struct{ url string } +type workflowPhaseMsg struct{ phase workflow.Phase } + +type daemonLogMsg struct{ logs []string } +type lspScanMsg struct{ servers []lsp.LSPServer } +type mcpConfigMsg struct{ err error } + +type skillsListMsg struct{ skills []skills.Skill } + +type Model struct { + config *config.MuyueConfig + scanResult *scanner.ScanResult + activeTab tab + width int + height int + viewport viewport.Model + ready bool + chatInput string + chatLog []string + chatLoading bool + orch *orchestrator.Orchestrator + proxyMgr *proxy.Manager + updateStatus []updater.UpdateStatus + installLog []string + previewURL string + previewSrv *preview.PreviewServer + daemon *daemon.Daemon + lspServers []lsp.LSPServer + mcpConfigured bool + skillList []skills.Skill +} + +func NewModel(cfg *config.MuyueConfig, scan *scanner.ScanResult) Model { + orch, _ := orchestrator.New(cfg) + proxyMgr := proxy.NewManager() + d := daemon.NewDaemon(cfg, 1*time.Hour) + + lspServers := lsp.ScanServers() + + skillList, _ := skills.List() + + mcpConfigured := false + if err := mcp.ConfigureAll(cfg); err == nil { + mcpConfigured = true + } + + if cfg.Profile.Preferences.AutoUpdate { + d.Start() + } + + return Model{ + config: cfg, + scanResult: scan, + activeTab: tabDashboard, + chatLog: []string{ + aiMsgStyle.Render("muyue: Welcome! I'm your AI development environment assistant."), + aiMsgStyle.Render("muyue: Type /plan to start a structured workflow, or just chat."), + }, + orch: orch, + proxyMgr: proxyMgr, + chatInput: "", + chatLoading: false, + daemon: d, + lspServers: lspServers, + mcpConfigured: mcpConfigured, + skillList: skillList, + } +} + +func (m Model) Init() tea.Cmd { + return tea.EnterAltScreen +} + +func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + return m.handleKey(msg) + case aiResponseMsg: + m.chatLoading = false + content := msg.content + m.chatLog = append(m.chatLog, aiMsgStyle.Render("muyue: "+content)) + + if m.orch != nil && m.orch.Workflow != nil { + previewFiles := workflow.ParsePreviewFiles(content) + if len(previewFiles) > 0 { + m.handlePreview(previewFiles) + } + } + + m.viewport.SetContent(m.renderContent()) + m.viewport.GotoBottom() + return m, nil + case aiErrMsg: + m.chatLoading = false + m.chatLog = append(m.chatLog, errMsgStyle.Render("error: "+msg.err.Error())) + m.viewport.SetContent(m.renderContent()) + m.viewport.GotoBottom() + return m, nil + case scanCompleteMsg: + m.scanResult = msg.result + m.viewport.SetContent(m.renderContent()) + return m, nil + case installCompleteMsg: + for _, r := range msg.results { + status := itemOKStyle.Render("[OK]") + if !r.Success { + status = itemMissingStyle.Render("[FAIL]") + } + m.installLog = append(m.installLog, fmt.Sprintf(" %s %s: %s", status, r.Tool, r.Message)) + } + m.scanResult = scanner.ScanSystem() + m.viewport.SetContent(m.renderContent()) + return m, nil + case updateCheckMsg: + m.updateStatus = msg.statuses + m.viewport.SetContent(m.renderContent()) + return m, nil + case previewReadyMsg: + m.previewURL = msg.url + m.viewport.SetContent(m.renderContent()) + return m, nil + case lspScanMsg: + m.lspServers = msg.servers + m.viewport.SetContent(m.renderContent()) + return m, nil + case mcpConfigMsg: + if msg.err == nil { + m.mcpConfigured = true + } + m.viewport.SetContent(m.renderContent()) + return m, nil + case daemonLogMsg: + m.viewport.SetContent(m.renderContent()) + return m, nil + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + headerH := 3 + footerH := 1 + inputH := 0 + if m.activeTab == tabChat || m.activeTab == tabWorkflow { + inputH = 2 + } + contentH := msg.Height - headerH - footerH - inputH + if contentH < 1 { + contentH = 1 + } + m.viewport = viewport.New(msg.Width, contentH) + m.viewport.Width = msg.Width + m.viewport.Height = contentH + m.ready = true + m.viewport.SetContent(m.renderContent()) + return m, nil + } + return m, nil +} + +func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "ctrl+c", "q": + return m, tea.Quit + case "1": + m.activeTab = tabDashboard + m.resizeViewport() + return m, nil + case "2": + m.activeTab = tabChat + m.resizeViewport() + return m, nil + case "3": + m.activeTab = tabWorkflow + m.resizeViewport() + return m, nil + case "4": + m.activeTab = tabAgents + m.resizeViewport() + return m, nil + case "5": + m.activeTab = tabConfig + m.resizeViewport() + return m, nil + case "tab": + m.activeTab = (m.activeTab + 1) % tabCount + m.resizeViewport() + return m, nil + case "enter": + if (m.activeTab == tabChat || m.activeTab == tabWorkflow) && m.chatInput != "" && !m.chatLoading { + return m.handleChatSubmit() + } + case "backspace": + if (m.activeTab == tabChat || m.activeTab == tabWorkflow) && len(m.chatInput) > 0 { + m.chatInput = m.chatInput[:len(m.chatInput)-1] + m.viewport.SetContent(m.renderContent()) + } + default: + if (m.activeTab == tabChat || m.activeTab == tabWorkflow) && len(msg.String()) == 1 && !m.chatLoading { + m.chatInput += msg.String() + m.viewport.SetContent(m.renderContent()) + } + } + + if m.activeTab == tabDashboard { + return m.handleDashboardKey(msg) + } + if m.activeTab == tabAgents { + return m.handleAgentsKey(msg) + } + if m.activeTab == tabWorkflow { + return m.handleWorkflowKey(msg) + } + + return m, nil +} + +func (m Model) handleChatSubmit() (tea.Model, tea.Cmd) { + input := m.chatInput + m.chatLog = append(m.chatLog, userMsgStyle.Render("you: "+input)) + m.chatInput = "" + m.chatLoading = true + m.viewport.SetContent(m.renderContent()) + m.viewport.GotoBottom() + + if strings.HasPrefix(input, "/plan ") { + goal := strings.TrimPrefix(input, "/plan ") + return m, startWorkflowCmd(m.orch, goal) + } + + if m.orch != nil && m.orch.Workflow != nil && m.orch.Workflow.Phase != workflow.PhaseIdle { + return m, workflowChatCmd(m.orch, input) + } + + return m, sendAIMessage(m.orch, input) +} + +func (m Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "i": + return m, tea.Cmd(func() tea.Msg { + inst := installer.New(m.config) + var missing []string + if m.scanResult != nil { + for _, t := range m.scanResult.Tools { + if !t.Installed { + missing = append(missing, t.Name) + } + } + } + if len(missing) == 0 { + return installCompleteMsg{results: []installer.InstallResult{}} + } + return installCompleteMsg{results: inst.InstallAll(missing)} + }) + case "u": + return m, tea.Cmd(func() tea.Msg { + result := scanner.ScanSystem() + return updateCheckMsg{statuses: updater.CheckUpdates(result)} + }) + case "s": + return m, tea.Cmd(func() tea.Msg { + return scanCompleteMsg{result: scanner.ScanSystem()} + }) + case "l": + return m, tea.Cmd(func() tea.Msg { + servers := lsp.ScanServers() + return lspScanMsg{servers: servers} + }) + case "m": + return m, tea.Cmd(func() tea.Msg { + err := mcp.ConfigureAll(m.config) + return mcpConfigMsg{err: err} + }) + } + return m, nil +} + +func (m Model) handleAgentsKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "c": + if m.proxyMgr.IsAvailable(proxy.AgentCrush) { + m.proxyMgr.Start(proxy.AgentCrush) + } + m.viewport.SetContent(m.renderContent()) + case "l": + if m.proxyMgr.IsAvailable(proxy.AgentClaude) { + m.proxyMgr.Start(proxy.AgentClaude) + } + m.viewport.SetContent(m.renderContent()) + } + return m, nil +} + +func (m Model) handleWorkflowKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + if m.orch == nil || m.orch.Workflow == nil { + return m, nil + } + + wf := m.orch.Workflow + + switch msg.String() { + case "a": + if wf.Phase == workflow.PhaseReviewing { + m.chatLog = append(m.chatLog, userMsgStyle.Render("you: [Plan approved]")) + m.chatLoading = true + m.viewport.SetContent(m.renderContent()) + return m, reviewPlanCmd(m.orch, true, "") + } + case "r": + if wf.Phase == workflow.PhaseReviewing { + m.chatInput = "" + m.chatLog = append(m.chatLog, itemWarnStyle.Render("Type your rejection feedback below:")) + m.viewport.SetContent(m.renderContent()) + } + case "g": + if wf.Phase == workflow.PhaseGathering && len(wf.Plan.Questions) > 0 && len(wf.Plan.Answers) >= len(wf.Plan.Questions) { + m.chatLog = append(m.chatLog, userMsgStyle.Render("you: [Generate plan]")) + m.chatLoading = true + m.viewport.SetContent(m.renderContent()) + return m, generatePlanCmd(m.orch) + } + case "n": + if wf.Phase == workflow.PhaseExecuting { + current := wf.CurrentStep() + if current != nil { + m.chatLoading = true + m.viewport.SetContent(m.renderContent()) + return m, continueWorkflowCmd(m.orch, "proceeding") + } + } + case "x": + wf.Reset() + m.chatLog = append(m.chatLog, itemWarnStyle.Render("Workflow reset.")) + m.viewport.SetContent(m.renderContent()) + } + return m, nil +} + +func (m *Model) handlePreview(files []workflow.PreviewFile) { + dir := filepath.Join(os.TempDir(), "muyue-preview") + os.RemoveAll(dir) + os.MkdirAll(dir, 0755) + + for _, f := range files { + preview.CreatePreviewFile(dir, f.Filename, f.Content) + } + + if m.previewSrv != nil { + m.previewSrv.Stop() + } + m.previewSrv = preview.NewPreviewServer(dir) + if err := m.previewSrv.Start(8765); err != nil { + m.chatLog = append(m.chatLog, errMsgStyle.Render("preview error: "+err.Error())) + } else { + m.previewURL = "http://127.0.0.1:8765" + m.chatLog = append(m.chatLog, itemOKStyle.Render("Preview opened in browser: http://127.0.0.1:8765")) + } +} + +func (m *Model) resizeViewport() { + headerH := 3 + footerH := 1 + inputH := 0 + if m.activeTab == tabChat || m.activeTab == tabWorkflow { + inputH = 2 + } + contentH := m.height - headerH - footerH - inputH + if contentH < 1 { + contentH = 1 + } + m.viewport = viewport.New(m.width, contentH) + m.viewport.Width = m.width + m.viewport.Height = contentH + m.viewport.SetContent(m.renderContent()) +} + +func sendAIMessage(orch *orchestrator.Orchestrator, input string) tea.Cmd { + return tea.Cmd(func() tea.Msg { + if orch == nil { + return aiErrMsg{err: fmt.Errorf("orchestrator not configured")} + } + resp, err := orch.Send(input) + if err != nil { + return aiErrMsg{err: err} + } + return aiResponseMsg{content: resp} + }) +} + +func startWorkflowCmd(orch *orchestrator.Orchestrator, goal string) tea.Cmd { + return tea.Cmd(func() tea.Msg { + resp, err := orch.StartWorkflow(goal) + if err != nil { + return aiErrMsg{err: err} + } + return aiResponseMsg{content: resp} + }) +} + +func workflowChatCmd(orch *orchestrator.Orchestrator, input string) tea.Cmd { + return tea.Cmd(func() tea.Msg { + wf := orch.Workflow + switch wf.Phase { + case workflow.PhaseGathering: + resp, err := orch.AnswerQuestion(input) + if err != nil { + return aiErrMsg{err: err} + } + return aiResponseMsg{content: resp} + case workflow.PhaseReviewing: + approved, feedback := workflow.ParseApproval(input) + resp, err := orch.ReviewPlan(approved, feedback) + if err != nil { + return aiErrMsg{err: err} + } + return aiResponseMsg{content: resp} + default: + resp, err := orch.Send(input) + if err != nil { + return aiErrMsg{err: err} + } + return aiResponseMsg{content: resp} + } + }) +} + +func generatePlanCmd(orch *orchestrator.Orchestrator) tea.Cmd { + return tea.Cmd(func() tea.Msg { + resp, err := orch.GeneratePlan() + if err != nil { + return aiErrMsg{err: err} + } + return aiResponseMsg{content: resp} + }) +} + +func reviewPlanCmd(orch *orchestrator.Orchestrator, approved bool, feedback string) tea.Cmd { + return tea.Cmd(func() tea.Msg { + resp, err := orch.ReviewPlan(approved, feedback) + if err != nil { + return aiErrMsg{err: err} + } + return aiResponseMsg{content: resp} + }) +} + +func continueWorkflowCmd(orch *orchestrator.Orchestrator, output string) tea.Cmd { + return tea.Cmd(func() tea.Msg { + resp, err := orch.ContinueExecution(output) + if err != nil { + return aiErrMsg{err: err} + } + return aiResponseMsg{content: resp} + }) +} + +func (m Model) View() string { + if !m.ready { + return "Loading..." + } + + var b strings.Builder + b.WriteString(m.renderHeader()) + b.WriteString("\n") + b.WriteString(m.viewport.View()) + if m.activeTab == tabChat || m.activeTab == tabWorkflow { + b.WriteString("\n") + b.WriteString(m.renderChatInput()) + } + b.WriteString("\n") + b.WriteString(m.renderFooter()) + + return b.String() +} + +func (m Model) renderHeader() string { + title := titleStyle.Render(" " + version.FullVersion() + " ") + + tabs := make([]string, len(tabNames)) + for i, name := range tabNames { + if tab(i) == m.activeTab { + tabs[i] = activeTabStyle.Render(name) + } else { + tabs[i] = tabStyle.Render(name) + } + } + tabsRow := lipgloss.JoinHorizontal(lipgloss.Bottom, tabs...) + + return lipgloss.JoinVertical(lipgloss.Left, title, tabsRow) +} + +func (m Model) renderContent() string { + switch m.activeTab { + case tabDashboard: + return m.renderDashboard() + case tabChat: + return m.renderChat() + case tabWorkflow: + return m.renderWorkflow() + case tabAgents: + return m.renderAgents() + case tabConfig: + return m.renderConfig() + default: + return "" + } +} + +func (m Model) renderDashboard() string { + var b strings.Builder + + b.WriteString(sectionStyle.Render("System")) + b.WriteString("\n") + if m.scanResult != nil { + b.WriteString(" ") + b.WriteString(m.scanResult.System.String()) + } + b.WriteString("\n\n") + + b.WriteString(sectionStyle.Render("Tools")) + b.WriteString("\n") + if m.scanResult != nil { + for _, t := range m.scanResult.Tools { + if t.Installed { + b.WriteString(" ") + b.WriteString(itemOKStyle.Render("[v]")) + b.WriteString(fmt.Sprintf(" %s %s\n", t.Name, extractVersion(t.Version))) + } else { + b.WriteString(" ") + b.WriteString(itemMissingStyle.Render("[ ]")) + b.WriteString(fmt.Sprintf(" %s\n", t.Name)) + } + } + } + b.WriteString("\n") + + if len(m.updateStatus) > 0 { + b.WriteString(sectionStyle.Render("Updates")) + b.WriteString("\n") + for _, s := range m.updateStatus { + if s.NeedsUpdate { + b.WriteString(" ") + b.WriteString(itemWarnStyle.Render("[!]")) + b.WriteString(fmt.Sprintf(" %s: %s -> %s\n", s.Tool, s.Current, s.Latest)) + } else if s.Error == "" { + b.WriteString(" ") + b.WriteString(itemOKStyle.Render("[v]")) + b.WriteString(fmt.Sprintf(" %s: up to date\n", s.Tool)) + } + } + b.WriteString("\n") + } + + if len(m.installLog) > 0 { + b.WriteString(sectionStyle.Render("Install Log")) + b.WriteString("\n") + for _, l := range m.installLog { + b.WriteString(l + "\n") + } + b.WriteString("\n") + } + + b.WriteString(sectionStyle.Render("Quick Actions")) + b.WriteString("\n") + b.WriteString(" [i] Install missing tools\n") + b.WriteString(" [u] Check for updates\n") + b.WriteString(" [s] Rescan system\n") + b.WriteString(" [l] Scan LSP servers\n") + b.WriteString(" [m] Configure MCP servers\n") + b.WriteString("\n") + + if len(m.lspServers) > 0 { + b.WriteString(sectionStyle.Render("LSP Servers")) + b.WriteString("\n") + installed := 0 + for _, s := range m.lspServers { + if s.Installed { + installed++ + b.WriteString(" ") + b.WriteString(itemOKStyle.Render("[v]")) + b.WriteString(fmt.Sprintf(" %-30s (%s)\n", s.Name, s.Language)) + } else { + b.WriteString(" ") + b.WriteString(itemPendingStyle.Render("[ ]")) + b.WriteString(fmt.Sprintf(" %-30s (%s)\n", s.Name, s.Language)) + } + } + b.WriteString(fmt.Sprintf("\n Installed: %d/%d\n", installed, len(m.lspServers))) + b.WriteString("\n") + } + + if m.daemon != nil { + b.WriteString(sectionStyle.Render("Update Daemon")) + b.WriteString("\n") + if m.daemon.IsRunning() { + b.WriteString(" ") + b.WriteString(itemOKStyle.Render("● running")) + lastCheck := m.daemon.LastCheck() + if !lastCheck.IsZero() { + b.WriteString(fmt.Sprintf(" last check: %s", lastCheck.Format("15:04:05"))) + } + } else { + b.WriteString(" ") + b.WriteString(itemPendingStyle.Render("○ stopped")) + } + b.WriteString("\n") + + logs := m.daemon.Logs() + if len(logs) > 3 { + logs = logs[len(logs)-3:] + } + for _, l := range logs { + b.WriteString(" " + itemPendingStyle.Render(l) + "\n") + } + b.WriteString("\n") + } + + mcpStatus := "not configured" + if m.mcpConfigured { + mcpStatus = itemOKStyle.Render("configured") + } + b.WriteString(fmt.Sprintf("MCP Servers: %s\n", mcpStatus)) + + return b.String() +} + +func (m Model) renderChat() string { + var b strings.Builder + + header := sectionStyle.Render("Chat — " + m.config.Profile.Preferences.DefaultAI) + if m.chatLoading { + header += " " + itemWarnStyle.Render("thinking...") + } + b.WriteString(header) + b.WriteString("\n\n") + + for _, msg := range m.chatLog { + b.WriteString(msg) + b.WriteString("\n\n") + } + + if m.previewURL != "" { + b.WriteString(itemOKStyle.Render(fmt.Sprintf("Preview: %s", m.previewURL))) + b.WriteString("\n\n") + } + + return b.String() +} + +func (m Model) renderChatInput() string { + prompt := inputStyle.Render("> ") + if m.chatLoading { + return prompt + itemWarnStyle.Render("waiting for response...") + } + return prompt + m.chatInput + "█" +} + +func (m Model) renderWorkflow() string { + var b strings.Builder + + if m.orch == nil || m.orch.Workflow == nil { + b.WriteString("Workflow engine not available.") + return b.String() + } + + wf := m.orch.Workflow + + b.WriteString(sectionStyle.Render("Workflow")) + b.WriteString(" ") + b.WriteString(phaseStyle.Render(string(wf.Phase))) + b.WriteString("\n\n") + + if wf.Plan.Goal != "" { + b.WriteString(fmt.Sprintf("Goal: %s\n\n", wf.Plan.Goal)) + } + + switch wf.Phase { + case workflow.PhaseIdle: + b.WriteString("No active workflow.\n") + b.WriteString("Type /plan to start a structured workflow.\n") + b.WriteString("Example: /plan Create a REST API in Go\n") + + case workflow.PhaseGathering: + b.WriteString(sectionStyle.Render("Gathering Requirements")) + b.WriteString("\n") + for i, q := range wf.Plan.Questions { + icon := itemPendingStyle.Render("[ ]") + if i < len(wf.Plan.Answers) { + icon = itemOKStyle.Render("[v]") + b.WriteString(fmt.Sprintf(" %s Q: %s\n", icon, q)) + b.WriteString(fmt.Sprintf(" A: %s\n", wf.Plan.Answers[i])) + } else { + b.WriteString(fmt.Sprintf(" %s Q: %s\n", icon, q)) + } + } + if len(wf.Plan.Answers) >= len(wf.Plan.Questions) && len(wf.Plan.Questions) > 0 { + b.WriteString("\n ") + b.WriteString(itemOKStyle.Render("[g] Generate plan")) + b.WriteString("\n") + } + + case workflow.PhasePlanning: + b.WriteString(itemWarnStyle.Render("Generating plan...")) + b.WriteString("\n") + + case workflow.PhaseReviewing: + b.WriteString(sectionStyle.Render("Plan (review before execution)")) + b.WriteString("\n\n") + for i, s := range wf.Plan.Steps { + icon := stepPendingStyle.Render("[ ]") + b.WriteString(fmt.Sprintf(" %s Step %s: %s\n", icon, s.ID, s.Title)) + b.WriteString(fmt.Sprintf(" %s\n", s.Description)) + b.WriteString(fmt.Sprintf(" Agent: %s\n", s.Agent)) + if i < len(wf.Plan.Steps)-1 { + b.WriteString("\n") + } + } + b.WriteString("\n ") + b.WriteString(itemOKStyle.Render("[a] Approve plan")) + b.WriteString(" ") + b.WriteString(itemMissingStyle.Render("[r] Reject with feedback")) + b.WriteString("\n") + + if len(wf.Plan.PreviewFiles) > 0 { + b.WriteString("\n ") + b.WriteString(itemWarnStyle.Render("Preview files available (opened in browser)")) + b.WriteString("\n") + } + + case workflow.PhaseExecuting: + b.WriteString(sectionStyle.Render("Executing Plan")) + b.WriteString("\n\n") + done, total := wf.Progress() + progressBar := renderProgressBar(done, total, 30) + b.WriteString(fmt.Sprintf(" Progress: %s %d/%d\n\n", progressBar, done, total)) + + for _, s := range wf.Plan.Steps { + var icon string + switch s.Status { + case "done": + icon = stepDoneStyle.Render("[v]") + case "error": + icon = stepErrorStyle.Render("[x]") + default: + if wf.Plan.Steps[wf.Plan.StepIndex].ID == s.ID { + icon = stepCurrentStyle.Render("[>]") + } else { + icon = stepPendingStyle.Render("[ ]") + } + } + b.WriteString(fmt.Sprintf(" %s %s: %s\n", icon, s.ID, s.Title)) + if s.Output != "" { + output := s.Output + if len(output) > 80 { + output = output[:80] + "..." + } + b.WriteString(fmt.Sprintf(" %s\n", output)) + } + } + b.WriteString("\n ") + b.WriteString(itemOKStyle.Render("[n] Next step")) + b.WriteString(" ") + b.WriteString(itemMissingStyle.Render("[x] Cancel workflow")) + b.WriteString("\n") + + case workflow.PhaseDone: + b.WriteString(itemOKStyle.Render("Workflow completed!")) + b.WriteString("\n\n") + for _, s := range wf.Plan.Steps { + icon := stepDoneStyle.Render("[v]") + b.WriteString(fmt.Sprintf(" %s %s: %s\n", icon, s.ID, s.Title)) + } + b.WriteString("\n [x] Reset workflow\n") + + case workflow.PhaseError: + b.WriteString(itemMissingStyle.Render("Workflow encountered an error.")) + b.WriteString("\n [x] Reset workflow\n") + } + + b.WriteString("\n\n") + b.WriteString(sectionStyle.Render("Chat")) + b.WriteString("\n") + for _, msg := range m.chatLog { + lines := strings.Split(msg, "\n") + for _, line := range lines { + if len(line) > m.width-4 { + line = line[:m.width-7] + "..." + } + b.WriteString(" " + line + "\n") + } + } + + return b.String() +} + +func renderProgressBar(done, total, width int) string { + if total == 0 { + return "[" + strings.Repeat(" ", width) + "]" + } + filled := (done * width) / total + if filled > width { + filled = width + } + bar := strings.Repeat("█", filled) + strings.Repeat("░", width-filled) + return "[" + bar + "]" +} + +func (m Model) renderAgents() string { + var b strings.Builder + + b.WriteString(sectionStyle.Render("Background Agents")) + b.WriteString("\n\n") + + agents := []struct { + name string + agentType proxy.AgentType + tool string + }{ + {"Crush", proxy.AgentCrush, "Z.AI GLM"}, + {"Claude Code", proxy.AgentClaude, "Anthropic Claude"}, + } + + for _, a := range agents { + status, logs := m.proxyMgr.Status(a.agentType) + available := m.proxyMgr.IsAvailable(a.agentType) + + var statusStr string + switch status { + case proxy.StatusRunning: + statusStr = itemWarnStyle.Render("● running") + case proxy.StatusStopped: + statusStr = itemMissingStyle.Render("■ stopped") + case proxy.StatusError: + statusStr = itemMissingStyle.Render("✕ error") + default: + if available { + statusStr = itemOKStyle.Render("○ available") + } else { + statusStr = itemMissingStyle.Render("○ not installed") + } + } + + b.WriteString(fmt.Sprintf(" %-15s %s (%s)\n", a.name, statusStr, a.tool)) + + if logs != nil && len(logs) > 0 { + lastLogs := logs + if len(logs) > 5 { + lastLogs = logs[len(logs)-5:] + } + for _, l := range lastLogs { + b.WriteString(fmt.Sprintf(" %s %s\n", + l.Timestamp.Format("15:04:05"), l.Message)) + } + } + } + + b.WriteString("\n") + b.WriteString(sectionStyle.Render("Actions")) + b.WriteString("\n") + b.WriteString(" [c] Start Crush\n") + b.WriteString(" [l] Start Claude Code\n") + + return b.String() +} + +func (m Model) renderConfig() string { + var b strings.Builder + + b.WriteString(sectionStyle.Render("Profile")) + b.WriteString("\n") + if m.config != nil { + b.WriteString(fmt.Sprintf(" Name: %s\n", m.config.Profile.Name)) + b.WriteString(fmt.Sprintf(" Pseudo: %s\n", m.config.Profile.Pseudo)) + b.WriteString(fmt.Sprintf(" Email: %s\n", m.config.Profile.Email)) + b.WriteString(fmt.Sprintf(" Editor: %s\n", m.config.Profile.Preferences.Editor)) + b.WriteString(fmt.Sprintf(" Shell: %s\n", m.config.Profile.Preferences.Shell)) + b.WriteString(fmt.Sprintf(" Theme: %s\n", m.config.Profile.Preferences.Theme)) + b.WriteString(fmt.Sprintf(" Default AI: %s\n", m.config.Profile.Preferences.DefaultAI)) + if len(m.config.Profile.Languages) > 0 { + b.WriteString(fmt.Sprintf(" Languages: %s\n", strings.Join(m.config.Profile.Languages, ", "))) + } + } + b.WriteString("\n") + + b.WriteString(sectionStyle.Render("AI Providers")) + b.WriteString("\n") + if m.config != nil { + for _, p := range m.config.AI.Providers { + active := "" + if p.Active { + active = itemOKStyle.Render(" (active)") + } + keyStatus := "no key" + if p.APIKey != "" { + keyStatus = "configured" + } + b.WriteString(fmt.Sprintf(" %-12s model=%-25s key=%s%s\n", + p.Name, p.Model, keyStatus, active)) + } + } + b.WriteString("\n") + + b.WriteString(sectionStyle.Render("BMAD Method")) + b.WriteString("\n") + if m.config != nil { + installed := "no" + if m.config.BMAD.Installed { + installed = itemOKStyle.Render("yes") + } + b.WriteString(fmt.Sprintf(" Installed: %s\n", installed)) + b.WriteString(fmt.Sprintf(" Global: %v\n", m.config.BMAD.Global)) + } + b.WriteString("\n") + + b.WriteString(sectionStyle.Render("Terminal")) + b.WriteString("\n") + if m.config != nil { + b.WriteString(fmt.Sprintf(" Custom Prompt: %v\n", m.config.Terminal.CustomPrompt)) + b.WriteString(fmt.Sprintf(" Prompt Theme: %s\n", m.config.Terminal.PromptTheme)) + } + b.WriteString("\n") + + b.WriteString(sectionStyle.Render(fmt.Sprintf("Skills (%d)", len(m.skillList)))) + b.WriteString("\n") + if len(m.skillList) > 0 { + for _, s := range m.skillList { + target := s.Target + if target == "" { + target = "both" + } + b.WriteString(fmt.Sprintf(" %-20s [%s] %s\n", s.Name, target, s.Description)) + } + } else { + b.WriteString(" No skills. Run `muyue skills init` to install built-ins.\n") + } + + return b.String() +} + +func (m Model) renderFooter() string { + profile := "unknown" + if m.config != nil && m.config.Profile.Pseudo != "" { + profile = m.config.Profile.Pseudo + } + + left := fmt.Sprintf(" %s@%s", profile, version.Name) + + var right string + switch m.activeTab { + case tabDashboard: + right = "[i] install [u] update [s] scan " + case tabChat, tabWorkflow: + right = "[1-5] tabs [tab] next [q] quit " + case tabAgents: + right = "[c] crush [l] claude " + default: + right = "[1-5] tabs [tab] next [q] quit " + } + + leftR := statusBarStyle.Render(left) + rightR := statusBarStyle.Render(right) + + gap := m.width - lipgloss.Width(leftR) - lipgloss.Width(rightR) + if gap < 0 { + gap = 0 + } + + return lipgloss.JoinHorizontal(lipgloss.Bottom, + leftR, + strings.Repeat(" ", gap), + rightR, + ) +} + +func extractVersion(s string) string { + return versionRegex.FindString(s) +} + +var versionRegex = regexp.MustCompile(`\d+\.\d+\.\d+`) diff --git a/internal/updater/updater.go b/internal/updater/updater.go new file mode 100644 index 0000000..960f27f --- /dev/null +++ b/internal/updater/updater.go @@ -0,0 +1,162 @@ +package updater + +import ( + "encoding/json" + "fmt" + "net/http" + "os/exec" + "regexp" + "strings" + "time" + + "github.com/muyue/muyue/internal/scanner" +) + +type UpdateStatus struct { + Tool string `json:"tool"` + Current string `json:"current"` + Latest string `json:"latest"` + NeedsUpdate bool `json:"needs_update"` + Message string `json:"message,omitempty"` + Error string `json:"error,omitempty"` +} + +var versionRegex = regexp.MustCompile(`\d+\.\d+\.\d+`) + +type githubRelease struct { + TagName string `json:"tag_name"` +} + +func CheckUpdates(result *scanner.ScanResult) []UpdateStatus { + var statuses []UpdateStatus + + for _, tool := range result.Tools { + if !tool.Installed { + continue + } + + status := UpdateStatus{ + Tool: tool.Name, + Current: versionRegex.FindString(tool.Version), + } + + latest, err := getLatestVersion(tool.Name) + if err != nil { + status.Error = err.Error() + statuses = append(statuses, status) + continue + } + + status.Latest = latest + status.NeedsUpdate = status.Current != "" && status.Current != latest + statuses = append(statuses, status) + } + + return statuses +} + +func getLatestVersion(tool string) (string, error) { + repos := map[string]string{ + "crush": "charmbracelet/crush", + "gh": "cli/cli", + "starship": "starship/starship", + "docker": "docker/compose", + } + + repo, ok := repos[tool] + if !ok { + 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) + if err != nil { + return "", fmt.Errorf("github api: %w", err) + } + defer resp.Body.Close() + + var release githubRelease + if err := json.NewDecoder(resp.Body).Decode(&release); err != nil { + return "", fmt.Errorf("decode: %w", err) + } + + return strings.TrimPrefix(release.TagName, "v"), nil +} + +func getLatestVersionCLI(tool string) (string, error) { + commands := map[string][]string{ + "node": {"node", "--version"}, + "npm": {"npm", "--version"}, + "go": {"go", "version"}, + "python3": {"python3", "--version"}, + "git": {"git", "--version"}, + "claude": {"claude", "--version"}, + } + + cmd, ok := commands[tool] + if !ok { + return "", fmt.Errorf("no update check for %s", tool) + } + + out, err := exec.Command(cmd[0], cmd[1:]...).Output() + if err != nil { + return "", err + } + + v := versionRegex.FindString(string(out)) + if v == "" { + return strings.TrimSpace(string(out)), nil + } + return v, nil +} + +func RunAutoUpdate(statuses []UpdateStatus) []UpdateStatus { + var results []UpdateStatus + for _, s := range statuses { + if !s.NeedsUpdate || s.Error != "" { + results = append(results, s) + continue + } + + switch s.Tool { + case "crush": + err := runCommand("bash", "-c", + "curl -fsSL https://github.com/charmbracelet/crush/releases/latest/download/install.sh | bash") + if err != nil { + s.Error = err.Error() + } else { + s.NeedsUpdate = false + s.Message = "updated" + } + case "claude": + err := runCommand("npm", "update", "-g", "@anthropic-ai/claude-code") + if err != nil { + s.Error = err.Error() + } else { + s.NeedsUpdate = false + s.Message = "updated" + } + case "starship": + err := runCommand("bash", "-c", + "curl -sS https://starship.rs/install.sh | sh -s -- -y") + if err != nil { + s.Error = err.Error() + } else { + s.NeedsUpdate = false + s.Message = "updated" + } + default: + s.Error = "auto-update not supported" + } + + results = append(results, s) + } + return results +} + +func runCommand(name string, args ...string) error { + cmd := exec.Command(name, args...) + return cmd.Run() +} diff --git a/internal/version/version.go b/internal/version/version.go new file mode 100644 index 0000000..a456bcc --- /dev/null +++ b/internal/version/version.go @@ -0,0 +1,12 @@ +package version + +const ( + Name = "muyue" + Version = "0.1.0" + Author = "La Légion de Muyue" + License = "MIT" +) + +func FullVersion() string { + return Name + " v" + Version +} diff --git a/internal/workflow/workflow.go b/internal/workflow/workflow.go new file mode 100644 index 0000000..2b915bb --- /dev/null +++ b/internal/workflow/workflow.go @@ -0,0 +1,284 @@ +package workflow + +import ( + "encoding/json" + "fmt" + "strings" +) + +type Phase string + +const ( + PhaseIdle Phase = "idle" + PhaseGathering Phase = "gathering" + PhasePlanning Phase = "planning" + PhaseReviewing Phase = "reviewing" + PhaseExecuting Phase = "executing" + PhaseDone Phase = "done" + PhaseError Phase = "error" +) + +type Step struct { + ID string `json:"id"` + Title string `json:"title"` + Description string `json:"description"` + Status string `json:"status"` + Agent string `json:"agent"` + Output string `json:"output,omitempty"` +} + +type Plan struct { + Goal string `json:"goal"` + Context string `json:"context"` + Questions []string `json:"questions"` + Answers []string `json:"answers"` + Steps []Step `json:"steps"` + StepIndex int `json:"current_step"` + PreviewFiles []PreviewFile `json:"preview_files,omitempty"` +} + +type PreviewFile struct { + Filename string `json:"filename"` + Content string `json:"content"` + Type string `json:"type"` +} + +type Workflow struct { + Phase Phase + Plan *Plan + History []string +} + +func New() *Workflow { + return &Workflow{ + Phase: PhaseIdle, + Plan: &Plan{}, + History: []string{}, + } +} + +func (w *Workflow) Start(goal string) { + w.Phase = PhaseGathering + w.Plan = &Plan{ + Goal: goal, + Steps: []Step{}, + Answers: []string{}, + } + w.History = append(w.History, fmt.Sprintf("[started] %s", goal)) +} + +func (w *Workflow) SetQuestions(questions []string) { + w.Plan.Questions = questions +} + +func (w *Workflow) AddAnswer(answer string) { + w.Plan.Answers = append(w.Plan.Answers, answer) + if len(w.Plan.Answers) >= len(w.Plan.Questions) { + w.Phase = PhasePlanning + w.History = append(w.History, "[gathering complete, moving to planning]") + } +} + +func (w *Workflow) SetPlan(planJSON string) error { + var steps []Step + if err := json.Unmarshal([]byte(planJSON), &steps); err != nil { + if err2 := json.Unmarshal([]byte("["+planJSON+"]"), &steps); err2 != nil { + return fmt.Errorf("parse plan: %w", err) + } + } + w.Plan.Steps = steps + w.Phase = PhaseReviewing + w.History = append(w.History, fmt.Sprintf("[plan created] %d steps", len(steps))) + return nil +} + +func (w *Workflow) SetPreviewFiles(files []PreviewFile) { + w.Plan.PreviewFiles = files +} + +func (w *Workflow) Approve() { + w.Phase = PhaseExecuting + w.Plan.StepIndex = 0 + w.History = append(w.History, "[plan approved, starting execution]") +} + +func (w *Workflow) Reject(feedback string) { + w.Phase = PhasePlanning + w.History = append(w.History, fmt.Sprintf("[plan rejected: %s]", feedback)) +} + +func (w *Workflow) AdvanceStep(output string) { + if w.Plan.StepIndex < len(w.Plan.Steps) { + w.Plan.Steps[w.Plan.StepIndex].Status = "done" + w.Plan.Steps[w.Plan.StepIndex].Output = output + w.Plan.StepIndex++ + w.History = append(w.History, fmt.Sprintf("[step %d done]", w.Plan.StepIndex)) + + if w.Plan.StepIndex >= len(w.Plan.Steps) { + w.Phase = PhaseDone + w.History = append(w.History, "[all steps complete]") + } + } +} + +func (w *Workflow) FailStep(errMsg string) { + if w.Plan.StepIndex < len(w.Plan.Steps) { + w.Plan.Steps[w.Plan.StepIndex].Status = "error" + w.Plan.Steps[w.Plan.StepIndex].Output = errMsg + w.Phase = PhaseError + w.History = append(w.History, fmt.Sprintf("[step %d failed: %s]", w.Plan.StepIndex+1, errMsg)) + } +} + +func (w *Workflow) Reset() { + w.Phase = PhaseIdle + w.Plan = &Plan{} +} + +func (w *Workflow) CurrentStep() *Step { + if w.Plan.StepIndex < len(w.Plan.Steps) { + return &w.Plan.Steps[w.Plan.StepIndex] + } + return nil +} + +func (w *Workflow) Progress() (done, total int) { + for _, s := range w.Plan.Steps { + if s.Status == "done" { + done++ + } + total++ + } + return +} + +func BuildSystemPrompt(phase Phase, plan *Plan) string { + base := `You are muyue, an AI-powered development environment assistant. +You follow a structured workflow: GATHER requirements → PLAN → REVIEW → EXECUTE. + +RULES: +- Always respond in the same language the user writes in. +- When in GATHERING phase, ask clarifying questions ONE AT A TIME to understand the requirement fully. +- When in PLANNING phase, create a detailed step-by-step plan as a JSON array of objects. +- When in REVIEWING phase, present the plan clearly and wait for approval. +- When in EXECUTING phase, execute one step at a time and report results. +- If the user wants a visual preview, generate 1-2 HTML files wrapped in a PREVIEW_JSON block.` + + switch phase { + case PhaseGathering: + base += fmt.Sprintf(` + +CURRENT PHASE: GATHERING +Goal: %s +Questions to ask: %v +Answers received: %v +Remaining questions: %d +Ask the NEXT question that hasn't been answered yet. If all questions are answered, say "GATHERING_COMPLETE".`, + plan.Goal, plan.Questions, plan.Answers, + len(plan.Questions)-len(plan.Answers)) + + case PhasePlanning: + qa := "" + for i, q := range plan.Questions { + a := "" + if i < len(plan.Answers) { + a = plan.Answers[i] + } + qa += fmt.Sprintf("\nQ: %s\nA: %s", q, a) + } + base += fmt.Sprintf(` + +CURRENT PHASE: PLANNING +Goal: %s +%s + +Create a step-by-step plan. Output ONLY a JSON array of steps: +[ + {"id": "1", "title": "...", "description": "...", "agent": "crush|claude|muyue", "status": "pending"}, + ... +] + +If the user needs a visual preview, wrap HTML in: +<<>> +[{"filename":"preview.html","content":"...","type":"html"}] +<<>>`, + plan.Goal, qa) + + case PhaseReviewing: + steps, _ := json.MarshalIndent(plan.Steps, "", " ") + base += fmt.Sprintf(` + +CURRENT PHASE: REVIEWING +Present the plan below clearly and ask for approval: +%s + +Say "PLAN_APPROVED" if the user approves, or "PLAN_REJECTED: " if not.`, + string(steps)) + + case PhaseExecuting: + if plan.StepIndex < len(plan.Steps) { + step := plan.Steps[plan.StepIndex] + base += fmt.Sprintf(` + +CURRENT PHASE: EXECUTING +Current step: %s — %s (agent: %s) +Execute this step and report the result.`, + step.Title, step.Description, step.Agent) + } + } + + return base +} + +func ParsePlanResponse(response string) ([]Step, error) { + response = strings.TrimSpace(response) + + start := strings.Index(response, "[") + end := strings.LastIndex(response, "]") + if start == -1 || end == -1 || end <= start { + return nil, fmt.Errorf("no JSON array found in response") + } + + jsonStr := response[start : end+1] + var steps []Step + if err := json.Unmarshal([]byte(jsonStr), &steps); err != nil { + return nil, fmt.Errorf("parse steps: %w", err) + } + + for i := range steps { + steps[i].Status = "pending" + } + + return steps, nil +} + +func ParsePreviewFiles(response string) []PreviewFile { + startMarker := "<<>>" + endMarker := "<<>>" + start := strings.Index(response, startMarker) + end := strings.Index(response, endMarker) + if start == -1 || end == -1 { + return nil + } + + jsonStr := strings.TrimSpace(response[start+len(startMarker) : end]) + var files []PreviewFile + if err := json.Unmarshal([]byte(jsonStr), &files); err != nil { + return nil + } + return files +} + +func ParseApproval(response string) (approved bool, feedback string) { + lower := strings.ToLower(strings.TrimSpace(response)) + if strings.Contains(lower, "plan_approved") || strings.Contains(lower, "approved") || strings.Contains(lower, "yes") || strings.Contains(lower, "go ahead") || strings.Contains(lower, "oui") || strings.Contains(lower, "ok") { + return true, "" + } + if strings.Contains(lower, "plan_rejected:") { + parts := strings.SplitN(lower, "plan_rejected:", 2) + if len(parts) > 1 { + return false, strings.TrimSpace(parts[1]) + } + } + return false, response +}