feat: initial release of muyue - AI-powered dev environment assistant
Some checks failed
CI / build (macos-latest) (push) Has been cancelled
CI / build (windows-latest) (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / build (ubuntu-latest) (push) Has been cancelled

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 <crush@charm.land>
This commit is contained in:
Augustin
2026-04-19 22:29:20 +02:00
commit f0ccd265da
25 changed files with 4743 additions and 0 deletions

43
.github/workflows/ci.yml vendored Normal file
View File

@@ -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

156
.github/workflows/release.yml vendored Normal file
View File

@@ -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<<EOF" >> $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

19
.gitignore vendored Normal file
View File

@@ -0,0 +1,19 @@
# Binaries
muyue
dist/
# IDE
.idea/
.vscode/
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# Go
*.exe
*.test
*.out
vendor/

48
Makefile Normal file
View File

@@ -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

76
README.md Normal file
View File

@@ -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

39
go.mod Normal file
View File

@@ -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
)

82
go.sum Normal file
View File

@@ -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=

167
internal/config/config.go Normal file
View File

@@ -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
}

173
internal/daemon/daemon.go Normal file
View File

@@ -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()
}

View File

@@ -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")
}

209
internal/lsp/lsp.go Normal file
View File

@@ -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"
}
}

255
internal/mcp/mcp.go Normal file
View File

@@ -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
}

View File

@@ -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 ""
}
}

19
internal/platform/exec.go Normal file
View File

@@ -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)
}

View File

@@ -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, ", ")
}

View File

@@ -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()
}

View File

@@ -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
}

259
internal/proxy/proxy.go Normal file
View File

@@ -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()
}

195
internal/scanner/scanner.go Normal file
View File

@@ -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()
}

298
internal/skills/builtins.go Normal file
View File

@@ -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/<ticket>-<description>`" + ` for new features
- ` + "`fix/<ticket>-<description>`" + ` for bug fixes
- ` + "`refactor/<description>`" + ` for refactoring
- ` + "`docs/<description>`" + ` 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
- <list of key changes>
## Test Plan
- [ ] <checklist>
` + "```" + `
## 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
}

291
internal/skills/skills.go Normal file
View File

@@ -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)
}

1091
internal/tui/app.go Normal file

File diff suppressed because it is too large Load Diff

162
internal/updater/updater.go Normal file
View File

@@ -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()
}

View File

@@ -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
}

View File

@@ -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:
<<<PREVIEW_JSON>>>
[{"filename":"preview.html","content":"<html>...</html>","type":"html"}]
<<<END_PREVIEW>>>`,
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: <reason>" 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 := "<<<PREVIEW_JSON>>>"
endMarker := "<<<END_PREVIEW>>>"
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
}