feat: initial release of muyue - AI-powered dev environment assistant
Complete implementation of muyue v0.1.0, a single-binary Go tool that transforms the development environment with AI-powered orchestration. Core features: - TUI with 5 tabs (Dashboard/Chat/Workflow/Agents/Config) using Charm stack - AI chat via MiniMax M2.7 with async message handling - Structured Plan→Execute workflow engine (gather→plan→review→execute) - System scanner detecting 14 tools + 8 runtimes across Linux/macOS/Windows - Auto-installer for Crush, Claude Code, BMAD, Starship, runtimes - Background update daemon with hourly checks - LSP auto-config for 16 language servers - MCP auto-config for 12 servers (deployed to Crush + Claude Code) - Skills system with 5 built-ins + AI-powered generation - Crush/Claude Code proxy for unified control - HTML preview server for visual outputs - First-time setup wizard with interactive profiling - Cross-platform: Linux (primary), macOS, Windows, WSL CI/CD: - GitHub Actions CI: build + test + lint on Linux/macOS/Windows - Release workflow: cross-compile 6 binaries with checksums on tag push 💘 Generated with Crush Assisted-by: GLM-5.1 via Crush <crush@charm.land>
This commit is contained in:
43
.github/workflows/ci.yml
vendored
Normal file
43
.github/workflows/ci.yml
vendored
Normal 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
156
.github/workflows/release.yml
vendored
Normal 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
19
.gitignore
vendored
Normal 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
48
Makefile
Normal 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
76
README.md
Normal 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
39
go.mod
Normal 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
82
go.sum
Normal 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
167
internal/config/config.go
Normal 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
173
internal/daemon/daemon.go
Normal 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()
|
||||
}
|
||||
319
internal/installer/installer.go
Normal file
319
internal/installer/installer.go
Normal 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
209
internal/lsp/lsp.go
Normal 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
255
internal/mcp/mcp.go
Normal 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
|
||||
}
|
||||
238
internal/orchestrator/orchestrator.go
Normal file
238
internal/orchestrator/orchestrator.go
Normal 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
19
internal/platform/exec.go
Normal 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)
|
||||
}
|
||||
107
internal/platform/platform.go
Normal file
107
internal/platform/platform.go
Normal 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, ", ")
|
||||
}
|
||||
76
internal/preview/preview.go
Normal file
76
internal/preview/preview.go
Normal 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()
|
||||
}
|
||||
125
internal/profiler/profiler.go
Normal file
125
internal/profiler/profiler.go
Normal 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
259
internal/proxy/proxy.go
Normal 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
195
internal/scanner/scanner.go
Normal 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
298
internal/skills/builtins.go
Normal 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
291
internal/skills/skills.go
Normal 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
1091
internal/tui/app.go
Normal file
File diff suppressed because it is too large
Load Diff
162
internal/updater/updater.go
Normal file
162
internal/updater/updater.go
Normal 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()
|
||||
}
|
||||
12
internal/version/version.go
Normal file
12
internal/version/version.go
Normal 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
|
||||
}
|
||||
284
internal/workflow/workflow.go
Normal file
284
internal/workflow/workflow.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user