Compare commits

...

242 Commits

Author SHA1 Message Date
CI Bot
9f014448a1 chore: update CHANGELOG for v0.8.0 2026-04-27 15:02:30 +00:00
Augustin
5094815de1 fix(ci): create dist/ before moving extension zips
All checks were successful
Stable Release / stable (push) Successful in 1m25s
Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-27 16:59:57 +02:00
Augustin
693b0e932e fix(ci): create dist/ before moving extension zips
All checks were successful
Beta Release / beta (push) Successful in 1m34s
Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-27 16:59:25 +02:00
Augustin
a60bd92858 release: v0.8.0 — browser extension for Chrome/Edge/Firefox
Some checks failed
Stable Release / stable (push) Failing after 48s
Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-27 16:51:00 +02:00
Augustin
9f9f2bd2c6 feat(extension): browser extension for Chrome/Edge/Firefox + CI + v0.8.0
Some checks failed
Beta Release / beta (push) Failing after 48s
Adds a WXT-based browser extension that replaces manual JS snippet
injection for AI-driven browser testing. The extension auto-connects
to the Muyue server via WebSocket on every page, using the exact
same protocol as the existing snippet — zero backend changes needed.

- Chrome/Edge (MV3) + Firefox (MV2) from single codebase via WXT
- Content script: auto-connect WS, console capture, URL tracking, RPC
- Background service worker: token management, screenshots, badge
- Popup + side panel with server status, sessions, URL config
- CI workflows: build extension, attach .zip to releases
- Makefile targets: ext, ext-chrome, ext-firefox, ext-zip
- Version bumped to 0.8.0

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-27 16:50:04 +02:00
Muyue
97a25295fc feat(browser-test): persistent token + auto-reconnect + screenshot + smarter strategy (v0.7.9)
Four user-reported issues with the AI-driven browser test feature:

1. WS dies on every page reload / navigation (token was single-use,
   5 min TTL → AI loses session permanently).
2. AI can't see new pages opened by its actions (same root cause).
3. No screenshot capability — AI cannot capture page state visually.
4. AI burns ~150 tool calls for what's 5 human actions, mostly
   list_clickables loops.

Fixes:

- Token sliding TTL (60 min): ConsumeToken no longer deletes the
  token; it refreshes its expiration on each successful WS connect.
  Same token survives reload / re-paste / navigation as long as
  there's no 60-min idle gap.

- Snippet auto-reconnect: WS onclose schedules reconnect with
  500ms × attempt backoff (max ~2.5s). Handles transient drops,
  server restarts, and WS hiccups without user intervention. Full
  navigation kills the JS context and is unrecoverable from JS — but
  the user just re-pastes the snippet, same token works.

- New 'screenshot' action: snippet captures via SVG foreignObject +
  canvas → base64 PNG → sent back over the existing WS reply
  channel. Server decodes and writes to ~/.muyue/screenshots/
  <filename>.png (sanitized name, timestamp default). Filename
  characters limited to a safe charset to prevent path escape.
  Best-effort: external CSS / cross-origin images / iframes won't
  inline.

- Studio system prompt rewritten <browser_test_strategy>:
  - Explicit rule: don't list_clickables after every click
  - Action cost table (cheap vs expensive)
  - When to re-list (URL change, dialog, click-not-found only)
  - Standard final report format ✓ / ✗ / ⚠ / 📸

Also bundles v0.7.8 (cherry-picked): unsafe.Pointer(uintptr(hPC))
instead of unsafe.Pointer(&hPC) in UpdateProcThreadAttribute, so
PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE is correctly applied and the
spawned shell attaches to the embedded xterm.js instead of opening
a separate external console window (regression fix from v0.7.6).

- internal/api/browsertest.go: token sliding, screenshot save,
  param schema, snippet rewrite, helpers
- internal/agent/prompts/studio_system.md: strategy rewrite
- internal/version/version.go: 0.7.7 → 0.7.9
- CHANGELOG.md: v0.7.9 entry covering all fixes
2026-04-27 16:50:04 +02:00
Augustin ROUX
5fd8cceabd Merge pull request 'fix(windows/conpty): pass HPCON value, not &hPC (v0.7.8)' (#18) from release/v0.7.8 into develop
All checks were successful
Beta Release / beta (push) Successful in 1m15s
Reviewed-on: #18
2026-04-27 13:49:15 +00:00
Muyue
a3487392c0 fix(windows/conpty): pass HPCON value, not &hPC (v0.7.8)
All checks were successful
PR Check / check (pull_request) Successful in 1m3s
User reported regression introduced in v0.7.6: PowerShell / cmd open
in a separate external console window instead of attaching to the
xterm.js tab (v0.7.5 worked).

Root cause: the ConPTY wiring used
  attrList.Update(PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE,
                  unsafe.Pointer(&hPC),    // ← wrong
                  unsafe.Sizeof(hPC))

The PSEUDOCONSOLE attribute is a Win32 API quirk: lpValue must be
the HPCON *value* (cast to PVOID), not a pointer to the local
variable holding the handle. With &hPC the kernel reads garbage,
silently drops the attribute, and CreateProcessW spawns the child
with a fresh console — hence the external window.

Fix is one line:
  unsafe.Pointer(uintptr(hPC))

Confirmed against Microsoft's EchoCon sample and Go libraries that
work in production (UserExistsError/conpty, aymanbagabas/go-pty).

- internal/version/version.go: 0.7.7 → 0.7.8
- CHANGELOG.md: v0.7.8 entry with the diagnostic write-up
2026-04-27 14:39:26 +02:00
CI Bot
6e4ddc192e chore: update CHANGELOG for v0.7.7 2026-04-27 12:37:08 +00:00
Augustin ROUX
71978adb5f Merge pull request 'release: v0.7.7 stable — promote develop to main' (#17) from develop into main
All checks were successful
Stable Release / stable (push) Successful in 1m15s
Reviewed-on: #17
2026-04-27 12:35:44 +00:00
Augustin ROUX
af5fbf9324 Merge pull request 'fix(install): kill running muyue before extracting (v0.7.7)' (#16) from release/v0.7.7 into develop
All checks were successful
Beta Release / beta (push) Successful in 1m18s
PR Check / check (pull_request) Successful in 58s
Reviewed-on: #16
2026-04-27 12:31:32 +00:00
Muyue
29953bde6d fix(install): kill running muyue before extracting (v0.7.7)
All checks were successful
PR Check / check (pull_request) Successful in 1m3s
User reported v0.7.6 install silently no-op'd when v0.7.5 was still
running:

  $dest = "$env:LOCALAPPDATA\Muyue"
  Expand-Archive -Path "$env:TEMP\muyue.zip" -DestinationPath $dest -Force
  # No error, but the running v0.7.5 .exe stays in place because
  # Windows refuses to overwrite a locked file. After 'install', the
  # 'muyue' command still launches v0.7.5.

Add a Stop-Process step at the top of the install snippet:

  Get-Process muyue, muyue-windows-amd64 -ErrorAction SilentlyContinue |
    Stop-Process -Force
  Start-Sleep -Milliseconds 500

-ErrorAction SilentlyContinue makes it idempotent (no error on a
clean first install). The 500ms sleep gives Windows time to release
the file handle before Expand-Archive opens the destination paths.

Snippet bumps to 6 lines; explanatory note added so users updating
from a previous version know why this step matters.

- internal/version/version.go: 0.7.6 → 0.7.7
- CHANGELOG.md: v0.7.7 entry
2026-04-27 14:29:59 +02:00
CI Bot
6d155e483b chore: update CHANGELOG for v0.7.6 2026-04-27 12:09:54 +00:00
Augustin ROUX
e621b13926 Merge pull request 'release: v0.7.6 stable — promote develop to main' (#15) from develop into main
All checks were successful
Stable Release / stable (push) Successful in 1m12s
Reviewed-on: #15
2026-04-27 12:08:17 +00:00
Augustin ROUX
9d1d717999 Merge pull request 'fix(windows): ConPTY + kernel32 metrics + agent loop cap (v0.7.6)' (#14) from release/v0.7.6 into develop
All checks were successful
Beta Release / beta (push) Successful in 1m15s
PR Check / check (pull_request) Successful in 54s
Reviewed-on: #14
2026-04-27 12:06:17 +00:00
Muyue
d557b8e74c fix(windows): native ConPTY + kernel32 metrics + agent loop cap (v0.7.6)
All checks were successful
PR Check / check (pull_request) Successful in 1m0s
Three issues reported on Windows + one user-requested limit bump:

1. Dashboard CPU/RAM/Network all at 0
   handleSystemMetrics read /proc/* exclusively. Replaced with a
   platform-split:
   - metrics_unix.go (!windows): existing /proc reading code.
   - metrics_windows.go: kernel32!GetSystemTimes for CPU
     (delta of idle vs kernel+user FILETIMEs) and
     kernel32!GlobalMemoryStatusEx for memory. Network left at zero
     for now — MIB_IF_ROW2 is too version-sensitive to parse by hand.
   handlers_info.go::handleSystemMetrics reduced to one delegating
   call.

2. Terminal black screen on Windows
   creack/pty/v2 returns "unsupported" on Windows; the v0.7.1 pipe
   fallback works but pipes don't carry TTY signals, so cmd/pwsh/wsl
   go silent. Implemented native ConPTY:
   - terminal_conpty_windows.go: CreatePseudoConsole + STARTUPINFOEX
     + PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE wiring via
     windows.NewProcThreadAttributeList. CreateProcessW launches
     child with the PC attached, full ANSI / line discipline /
     resize.
   - canUseConPTY() probes once at startup (Win10 1809+ check).
   - Restructure: terminal_session.go now holds just the interface
     + ptySession + pipeSession structs. terminal_session_unix.go
     wires creack/pty. terminal_session_windows.go tries ConPTY
     first, falls back to pipeSession.

3. Agent stops after 15 tool calls
   MaxToolIterations bumped 15 → 500. Doc comment explains why the
   cap exists at all (infinite-loop safety) and that 500 is well
   above realistic usage.

- internal/version/version.go: 0.7.5 → 0.7.6
- CHANGELOG.md: v0.7.6 entry covers the three fixes
2026-04-27 14:04:41 +02:00
CI Bot
e31a01d200 chore: update CHANGELOG for v0.7.5 2026-04-27 11:43:08 +00:00
Augustin ROUX
b3a9a49680 Merge pull request 'release: v0.7.5 stable — promote develop to main' (#13) from develop into main
All checks were successful
Stable Release / stable (push) Successful in 1m15s
Reviewed-on: #13
2026-04-27 11:41:39 +00:00
Augustin ROUX
87e606c853 Merge pull request 'fix(windows): GUI subsystem + AttachConsole + muyue.exe canonical (v0.7.5)' (#12) from release/v0.7.5 into develop
Some checks failed
PR Check / check (pull_request) Has been cancelled
Beta Release / beta (push) Has been cancelled
Reviewed-on: #12
2026-04-27 11:40:28 +00:00
Muyue
79e467c32a fix(windows): GUI subsystem + parent-console attach + canonical muyue.exe (v0.7.5)
All checks were successful
PR Check / check (pull_request) Successful in 58s
Three Windows install/launch issues reported by the user:

1. Double-click on Desktop shortcut → dialog "This is a command line
   tool. You need to open cmd.exe and run it from there."
   Cause: charmbracelet/huh detects no TTY when launched via Explorer
   and aborts. Fix:
   - cmd/muyue/commands/root.go: skip RunFirstTimeSetup when
     os.Stdin is not a character device; persist config.Default()
     and let the React onboarding wizard handle first-run UX.
   - ci-{main,develop}.yml: build Windows binaries with
     -ldflags="-H=windowsgui" so the .exe is a GUI subsystem app —
     no console window flashes on double-click.

2. CLI sub-commands (`muyue scan`, `muyue install-shortcuts`, etc.)
   would lose all output under -H=windowsgui when launched from
   cmd.exe / PowerShell. Mitigation:
   - cmd/muyue/console_windows.go (new, build-tagged): on init(),
     call kernel32!AttachConsole(ATTACH_PARENT_PROCESS). If the
     parent has a console, rebind os.Stdout/os.Stderr/os.Stdin to
     it and call log.SetOutput(os.Stderr) so existing log.Printf
     calls surface. If no parent console (Explorer), exit silently.

3. After install, `muyue` not recognized in PowerShell.
   Causes: (a) the extracted binary is muyue-windows-amd64.exe, not
   muyue.exe; (b) the user PATH update by install-shortcuts doesn't
   propagate to the existing PowerShell session.
   Fix in install-shortcuts:
   - Copy self to <installDir>/muyue.exe (rename impossible — the
     running .exe is locked on Windows) so `muyue` resolves once
     PATH is set.
   - Update Desktop + Start Menu .lnk to target the canonical
     muyue.exe rather than the platform-suffixed binary.
   - Print the line `$env:Path += ';<installDir>'` for the user to
     paste, refreshing the current session immediately.
   - ci-main.yml install snippet bumps to 5 lines, last being
     `$env:Path += ";$dest"`.

- internal/version/version.go: 0.7.4 → 0.7.5
- CHANGELOG.md: v0.7.5 entry covers all three fixes
2026-04-27 13:39:22 +02:00
CI Bot
075d168dcd chore: update CHANGELOG for v0.7.4 2026-04-27 11:25:44 +00:00
Augustin ROUX
ed4c963576 Merge pull request 'release: v0.7.4 stable — promote develop to main' (#11) from develop into main
All checks were successful
Stable Release / stable (push) Successful in 1m9s
Reviewed-on: #11
2026-04-27 11:24:09 +00:00
Augustin ROUX
1ce5c49622 Merge pull request 'feat: integrate Muyue logo (icon embedded + web favicon) v0.7.4' (#10) from release/v0.7.4 into develop
All checks were successful
Beta Release / beta (push) Successful in 1m12s
PR Check / check (pull_request) Successful in 55s
Reviewed-on: #10
2026-04-27 11:19:46 +00:00
Muyue
830e085c2a feat: integrate Muyue logo (icon embedded in Windows binary + web favicon)
All checks were successful
PR Check / check (pull_request) Successful in 58s
Logo dropped at project root by user. Bake it everywhere it matters:

Assets:
- assets/muyue.ico — multi-res (16/24/32/48/64/128/256) generated via PIL
- assets/muyue-{16,32,64,128,256,512}.png — clean PNG resizes
- LogoMuyue.png kept at root as the source of truth

Windows binary (.exe):
- CI runs `rsrc -ico assets/muyue.ico -arch {amd64,arm64} -o cmd/muyue/rsrc_windows_{amd64,arm64}.syso`
  before `go build` (both ci-main.yml and ci-develop.yml)
- Go automatically links *.syso files matching the target GOOS/GOARCH —
  no code change in the cmd/muyue main package
- .syso files are gitignored: regenerated at every build, never committed
- Existing install-shortcuts subcommand already uses IconLocation =
  "$exe,0" so the embedded icon flows automatically into Desktop +
  Start Menu .lnk files

Web UI:
- web/public/favicon-{16,32}.png + muyue.png + muyue-64.png
- web/index.html: real <link rel="icon"> tags (16/32 PNG + apple-touch),
  replacing the placeholder SVG hexagon
- App.jsx header: 22×22 logo image rendered next to the "MUYUE" wordmark
  (rounded 4px corners for visual consistency with the source logo)

Install snippet (ci-main.yml changelog template):
- Idempotent first line: `New-Item -ItemType Directory -Force -Path $dest`
  to handle the case where the user re-runs after a partial install

Versioning unchanged (still v0.7.3 — these additions stay on the same
release branch / PR #9).
2026-04-27 13:13:56 +02:00
CI Bot
f8d706cdca chore: update CHANGELOG for v0.7.2 2026-04-27 10:57:30 +00:00
Augustin ROUX
24b09f5700 Merge pull request 'feat: onboarding MiniMax+MiMo + Windows install w/o admin (v0.7.3)' (#9) from release/v0.7.3 into develop
All checks were successful
Beta Release / beta (push) Successful in 1m10s
Reviewed-on: #9
2026-04-27 10:55:48 +00:00
Augustin ROUX
a9eedab0b5 Merge pull request 'release: v0.7.2 stable — promote develop to main' (#8) from develop into main
All checks were successful
Stable Release / stable (push) Successful in 1m4s
Reviewed-on: #8
2026-04-27 10:55:38 +00:00
Muyue
1442b4fd8a feat: onboarding 2-keys + Windows install w/o admin (v0.7.3)
All checks were successful
PR Check / check (pull_request) Successful in 56s
Two user-reported pain points:

1. First-run setup proposed only MiniMax (no MiMo step). Onboarding
   now offers both keys side-by-side under a single "apikey" step,
   with per-key Validate buttons. At least one must be valid to
   proceed; the rest of the providers (OpenAI/Anthropic/Z.AI/Ollama)
   are not shown in the wizard — they're configured later via the
   Config tab. Active provider = MiniMax if valid, else MiMo.

2. Windows install instructions failed: Move-Item to C:\Windows
   requires admin. Replaced with a no-admin 4-line snippet that
   installs to %LOCALAPPDATA%\Muyue and calls a new subcommand
   `muyue install-shortcuts` to create Desktop + Start Menu .lnk
   files and add the install dir to the user PATH. Shortcut creation
   uses WScript.Shell COM via PowerShell — keeps Go binary
   dependency-free. Folder paths resolved through
   [Environment]::GetFolderPath so OneDrive/redirected profiles
   work too.

- cmd/muyue/commands/install_shortcuts.go: new file
- web/src/components/OnboardingWizard.jsx: 2-key apikey step
- .gitea/workflows/ci-main.yml: updated install snippet
- internal/version/version.go: 0.7.2 → 0.7.3
- CHANGELOG.md: v0.7.3 entry
2026-04-27 12:12:18 +02:00
Augustin ROUX
a1da9da3db Merge pull request 'feat(studio): auto-réflexion avancée pendant les tests (v0.7.2)' (#7) from release/v0.7.2 into develop
All checks were successful
Beta Release / beta (push) Successful in 1m7s
PR Check / check (pull_request) Successful in 52s
Reviewed-on: #7
2026-04-27 10:04:47 +00:00
Muyue
a7d4b31a0d feat(studio): force advanced reflection during browser-test sessions (v0.7.2)
All checks were successful
PR Check / check (pull_request) Successful in 55s
When at least one browser_test session is connected, every chat
message in Studio now auto-enables advanced reflection regardless of
the user toggle. The intent: during AI-driven UI testing, having a
second model produce a preliminary [RAPPORT PRÉALABLE] materially
improves which clicks the active model decides to perform and the
quality of the final ✓/✗ report.

- handlers_chat: derive wantReflection from body.AdvancedReflection
  OR (browserTestStore has any active session). The user toggle still
  works for normal conversations; tests just override it.
- Silent fallback when no inactive provider is configured (no error,
  no behaviour change for single-provider setups).
- Tests.jsx: add a hint explaining the auto-on behaviour so the user
  understands why the Studio toggle appears bypassed.
- Version 0.7.1 → 0.7.2 + CHANGELOG entry.
2026-04-27 12:02:04 +02:00
Augustin ROUX
0ee006f71f Merge pull request 'fix(terminal/windows): pipes fallback for v0.7.1' (#6) from release/v0.7.1 into develop
All checks were successful
Beta Release / beta (push) Successful in 1m7s
Reviewed-on: #6
2026-04-27 09:59:15 +00:00
Muyue
fc7a5b9d87 fix(terminal/windows): fallback to pipes when PTY unsupported (v0.7.1)
All checks were successful
PR Check / check (pull_request) Successful in 57s
The terminal tab was unusable on Windows: creack/pty has no native
Windows ConPTY support, so pty.Start() returned "operating system not
supported" and the WebSocket closed immediately on any tab click —
even though the menu detection (wsl --list --quiet, pwsh, cmd) worked.

Introduce a termSession interface with two implementations selected at
runtime:
- ptySession (unix): unchanged behaviour, real PTY via creack/pty,
  resize works, vim/top behave normally.
- pipeSession (windows): plain stdin + merged stdout/stderr pipes,
  forwarded to the WebSocket. Resize is a no-op (no SIGWINCH without a
  TTY), so full-screen TUIs misbehave in this mode — but launching
  wsl.exe, pwsh, or cmd works for line-based interaction, which is
  what the menu shortcuts target.

handleTerminalWS now goes through startTermSession(cmd); the unix path
is unchanged, the windows fallback kicks in only when pty.Start would
have failed.

Bump v0.7.0 → v0.7.1; CHANGELOG entry added.
2026-04-27 11:56:40 +02:00
CI Bot
654444ccc8 chore: update CHANGELOG for v0.7.0 2026-04-27 09:25:31 +00:00
Augustin ROUX
991878939b Merge pull request 'release: v0.7.0 stable — promote develop to main' (#5) from develop into main
All checks were successful
Stable Release / stable (push) Successful in 1m1s
Reviewed-on: #5
2026-04-27 09:24:20 +00:00
Augustin ROUX
dbb97cc164 Merge pull request 'release: v0.7.0 — Tests pilotés par l'IA' (#4) from release/v0.7.0 into develop
All checks were successful
Beta Release / beta (push) Successful in 1m7s
PR Check / check (pull_request) Successful in 50s
Reviewed-on: #4
2026-04-27 09:20:37 +00:00
Muyue
6d2f174ae8 fix(ci): rename browser_test.go → browsertest.go
All checks were successful
PR Check / check (pull_request) Successful in 54s
The suffix _test.go makes Go treat the file as a test file (only
compiled during 'go test'), so server.go could not see the exported
BrowserTestStore / NewBrowserTestStore / RegisterBrowserTestTool and
the four handler methods at build time. Rename to a non-test name
keeps the same package-level visibility but compiles into the regular
api package.
2026-04-27 11:16:36 +02:00
Augustin ROUX
0d1d8d3ec3 Merge pull request 'release: v0.6.0 — security audit fixes + 7 new features' (#3) from release/v0.6.0 into develop
All checks were successful
Beta Release / beta (push) Successful in 1m6s
Reviewed-on: #3
2026-04-27 09:14:46 +00:00
Muyue
c820d55710 feat: AI-driven browser tests — Tests tab + browser_test agent tool
Some checks failed
PR Check / check (pull_request) Failing after 33s
New feature: give Studio's AI control of any browser tab to test buttons,
read the console, and report which buttons work / fail.

Backend (internal/api/browser_test.go, ~480 LOC):
- WebSocket endpoint /api/ws/browser-test, auth by single-use 5-min token
- BrowserTestStore: session map (capped at 16, LRU evict), token store
- REST: /api/test/snippet (issues token + JS snippet), /api/test/sessions,
  /api/test/console/{id}
- Agent tool 'browser_test' wired into the registry, with actions:
  list_clickables / click / eval / console / current_url / type / wait /
  summary. click returns the console_delta produced during the click.
- Embedded JS runner: opens WS, hooks console + window.onerror +
  unhandledrejection, dispatches dispatcher commands, replies with
  correlation IDs, watches for URL changes.

Frontend:
- New Tests tab (web/src/components/Tests.jsx): snippet copy + connected
  sessions list + live console viewer
- App.jsx: 5th tab + Ctrl+4 shortcut (Config moves to Ctrl+5)
- api/client.js: getTestSnippet / getTestSessions / getTestConsole

Studio prompt:
- internal/agent/prompts/studio_system.md: added browser_test entry to the
  tools table + <browser_test_strategy> section explaining the recommended
  loop (summary → list_clickables → click → check console_delta → report)

Versioning:
- v0.6.0 → v0.7.0
- CHANGELOG.md: full entry under v0.7.0
2026-04-27 11:02:05 +02:00
Muyue
6a7b4d8001 release: v0.6.0 — security audit fixes + 7 new features
All checks were successful
PR Check / check (pull_request) Successful in 57s
Audit corrections (security, concurrency, stability):
- chat_engine: bound resp.Choices[0] access, release tool slot per-iteration
- conversation_multi: synchronous save under existing lock (was racy fire-and-forget)
- workflow/engine: short-circuit on failed deps (no more infinite busy-wait); track failed/skipped status
- handlers_workflow: rune-aware truncate for plan goal (UTF-8 safe)
- server: CORS limited to localhost origins (was wildcard)
- handlers_info / terminal: mask API keys and SSH passwords as "***" in GET responses; preserve stored secret if "***" sent on update
- terminal: sshpass uses -e + SSHPASS env var (was both -p and -e)
- handlers_chat: MaxBytesReader 50 MB on /api/chat
- image_cache: 10 MB cap per image
- handlers_config: font size <= 72; profile-save unmarshal errors propagated
- handlers_info: /lsp/auto-install ProjectDir restricted to user home
- Shell.jsx: parenthesized resize-condition (operator precedence)
- orchestrator_test: CleanAIResponse capitalization (fixes failing vet)

New features:
- platform: detect OS name (Debian, Ubuntu, Windows 11, macOS X.Y) and inject in Studio system prompt next to the date
- agents: default timeout 30 min for crush_run/claude_run (cap also 30 min)
- agents: new cwd, wsl_distro, wsl_user params; on Windows hosts launch via "wsl -d <distro> -u <user> --cd <cwd> --"
- agents: new claude_run tool (mirror of crush_run for Claude Code CLI)
- terminal: list installed WSL distros individually in new-tab menu (Windows only)
- studio: system prompt rewritten around BMAD-METHOD personas + mandatory delegation template
- studio: "Réflexion avancée" toggle — inactive provider produces a preliminary report injected as [RAPPORT PRÉALABLE] context for the active provider
- studio: "Historique compressé" toggle — collapses past tool calls to last action only, with "Tout afficher" expansion
2026-04-27 10:12:11 +02:00
Augustin
0753167fb9 merge: develop into main (v0.5.0)
Some checks failed
Stable Release / stable (push) Failing after 32s
💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-27 00:18:28 +02:00
Augustin
2a6647b5cb chore: bump version to 0.5.0
Some checks failed
Beta Release / beta (push) Failing after 31s
💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-27 00:16:28 +02:00
Augustin
3740454201 feat: agent concurrency, conversation summaries, AI tools config, UI polish
Some checks failed
Stable Release / stable (push) Failing after 33s
- Agent slot limiter for concurrent tool execution
- Conversation summarization with soft-delete (MarkSummarized)
- ANSI stripping in terminal tool output
- Configurable crush-run timeout (default 600s, max 900s)
- Starship theme refactor, AI tools config grid, system update UI
- Streaming segments refactor, summarized messages block in feed
- CSS: headings, scrollbars, tool cards, summary block styles
- i18n additions (en+fr) for tools, updates, config

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-27 00:01:36 +02:00
Augustin
d98110ce8a feat: AI task API, token-based context windows, SSH password auth, sudo bypass detection
Replace message-count context windows with token-budget based ones for both
studio and shell. Add /api/ai/task endpoint for background tool
check/install/update. Enhance sudo blocking to catch piped/chained elevation
commands. Add SSH password support via sshpass and connection editing UI.
Remove realTokens persistence in favor of consumption tracking. Bump to 0.4.1.

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-27 00:01:36 +02:00
Augustin
d2bb42b212 feat: agent concurrency, conversation summaries, AI tools config, UI polish
Some checks failed
Beta Release / beta (push) Failing after 33s
- Agent slot limiter for concurrent tool execution
- Conversation summarization with soft-delete (MarkSummarized)
- ANSI stripping in terminal tool output
- Configurable crush-run timeout (default 600s, max 900s)
- Starship theme refactor, AI tools config grid, system update UI
- Streaming segments refactor, summarized messages block in feed
- CSS: headings, scrollbars, tool cards, summary block styles
- i18n additions (en+fr) for tools, updates, config

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-27 00:01:22 +02:00
Augustin
e8a289ccf3 feat: AI task API, token-based context windows, SSH password auth, sudo bypass detection
All checks were successful
Beta Release / beta (push) Successful in 1m6s
Replace message-count context windows with token-budget based ones for both
studio and shell. Add /api/ai/task endpoint for background tool
check/install/update. Enhance sudo blocking to catch piped/chained elevation
commands. Add SSH password support via sshpass and connection editing UI.
Remove realTokens persistence in favor of consumption tracking. Bump to 0.4.1.

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-26 20:06:20 +02:00
CI Bot
c9f2932147 chore: update CHANGELOG for v0.4.0 2026-04-26 13:22:30 +00:00
Augustin
f05181b2db Merge branch 'main' of https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace
All checks were successful
Stable Release / stable (push) Successful in 57s
2026-04-26 15:21:12 +02:00
Augustin
95e6cdaf41 Merge branch 'develop'
# Conflicts:
#	internal/api/handlers_info.go
#	internal/config/config.go
#	internal/version/version.go
#	web/src/components/App.jsx
#	web/src/components/Config.jsx
#	web/src/components/Dashboard.jsx
#	web/src/components/Shell.jsx
#	web/src/components/Studio.jsx
2026-04-26 15:20:30 +02:00
Augustin
12000e523c fix: token persistence, context windows, CSS tables/bullets/hr, image attachments
All checks were successful
Beta Release / beta (push) Successful in 1m1s
- Fix token count reset on app restart: persist realTokens in conversation.json
- Fix token/context window values: Studio 150K (summarize at 120K), Terminal 100K
- Fix table rendering in terminal tab: correct thead/tbody display model
- Fix copy button always top-right in Studio code blocks
- Add markdown horizontal rule (---) support in Studio and Terminal
- Fix bullet list double dot: remove CSS ::before duplicate bullet point
- Add image attachments support (VLM description, file mentions @file.ext)
- Add sudo detection with cache (sync.Once)
- Fix message content serialization (TextContent wrapper)
- Guide AI to use read_file instead of cat in studio prompt

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-26 15:19:26 +02:00
Augustin
cb3d35756a feat: terminal sudo blocking, token tracking, mermaid & consumption UI
All checks were successful
Beta Release / beta (push) Successful in 1m3s
- Block sudo/doas commands when not running as root
- Add real token counting from API responses
- Track and display consumption by provider/day
- Add Mermaid diagram rendering in Shell and Studio
- Add copy-to-clipboard buttons for code blocks
- Support tables in AI message rendering
- Update system prompt with context (date, time, root status)

💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-26 12:43:15 +02:00
Augustin
0830e64ae6 fix(shell,config): terminal font size, AI tools, provider keys
All checks were successful
Beta Release / beta (push) Successful in 56s
- Fix terminal default fontSize from 6px to 14px across all references
- Add terminal tool to shell AI via ChatEngine with tool_call streaming
- Fix provider key detection (apiKey → api_key, baseURL → base_url)
- Add mimo provider migration and validation endpoint
- Bump version to 0.4.0

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-25 22:03:35 +02:00
CI Bot
b43e3352e7 chore: update CHANGELOG for v0.3.5 2026-04-25 19:25:42 +00:00
Augustin
a60435d002 fix(shell): set default terminal fontSize to 6px
All checks were successful
Stable Release / stable (push) Successful in 48s
All fallbacks were still using 12px. User confirmed 6px is the
correct baseline on their display.

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-25 21:24:39 +02:00
Augustin
6b0fcfbd31 fix(shell): default fontSize 10px and init new tabs immediately
- Base font size reduced from 12px to 10px
- New tabs now initialize directly when added (was waiting for
  tab switch because the MutationObserver only fired on visibility
  changes, not on tab additions)
- Zoom level applied to newly created terminals

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-25 21:24:39 +02:00
Augustin
df46b5c14e feat(shell): add Ctrl+/- zoom and display all shortcuts in footer
- Ctrl+/Ctrl-/Ctrl+0 to zoom in/out/reset terminal font size
- Zoom badge indicator in tab bar
- All shell shortcuts now shown in statusbar footer
- Added i18n labels for search, zoom, switch tab, next tab

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-25 21:24:39 +02:00
Augustin
7240813de6 fix(deps): upgrade @xterm/xterm to 6.1.0-beta.203 for addon compatibility
The addon-web-links registerApcHandler API requires xterm >= 6.1.0.

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-25 21:24:39 +02:00
Augustin
97bfb803a6 fix(shell): enable allowProposedApi for Unicode11 addon
💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-25 21:24:39 +02:00
Augustin
3104179109 fix(ci): add .npmrc with legacy-peer-deps for xterm addon resolution
💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-25 21:24:39 +02:00
Augustin
e21b47a27c feat(shell): integrate Hyper-like terminal technologies (WebGL, search, unicode11, image)
Add xterm addons from Vercel Hyper terminal: WebGL renderer with DOM
fallback, search bar (Ctrl+Shift+F), Unicode 11 grapheme support, and
inline image protocol. All existing functionality preserved.

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-25 21:24:39 +02:00
Augustin
2e98701104 fix(shell): restore all missing imports, constants, and utility functions
- Restore xterm imports (Terminal, FitAddon, WebLinksAddon)
- Restore all lucide-react icons (Globe, X, Plus, ChevronDown, etc.)
- Restore module-level constants (AI_TAB_ID, MAX_TABS, SHELL_MAX_TOKENS,
  TABS_STORAGE_KEY, TERMINAL_BUFFER_KEY)
- Restore renderContent() and formatText() utility functions
- Add @xterm/xterm CSS import
- Remove duplicate constants from inside Shell component

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-25 21:24:39 +02:00
Augustin
f9d56de65a fix(shell): add missing Monitor import from lucide-react
💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-25 21:24:39 +02:00
Augustin
0e7340891c fix(shell): restore missing MAX_TABS, TABS_STORAGE_KEY, TERMINAL_BUFFER_KEY constants
💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-25 21:24:39 +02:00
Augustin
3b819be5ac fix(shell): add missing useI18n import
💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-25 21:24:39 +02:00
Augustin
c607943ca3 fix(shell): remove stray 'impo' typo causing ReferenceError
💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-25 21:24:39 +02:00
Augustin
3312005be4 fix(terminal): improve dimensions handling and add system theme for xterm
💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-25 21:24:39 +02:00
Augustin
6cc86b7f89 fix(shell): resolve savedTabs undefined ReferenceError in activeTab init
💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-25 21:24:39 +02:00
Augustin
1885616068 fix(terminal): improve dimension calculation and tab init reliability
- Guarantee minimum 24x80 dimensions on WebSocket open
- Force reflow before init attempts
- Multiple fit attempts with increasing delays (0/50/100/200/400ms)
- Validate saved tabs structure from localStorage
- Resize active tab after closing another tab

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-25 21:24:39 +02:00
Augustin
c8506d4dfc fix(dashboard): show MiMo quota instead of ZAI on dashboard
Replace Z.AI quota display with MiMo provider in the API Quota card.
ZAI is now a hidden fallback and should not appear in the dashboard.

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-25 21:24:39 +02:00
Augustin
68acabd6a1 feat(ai): add Xiaomi MiMo provider, ZAI as last-resort fallback
Add MiMo-V2.5-Pro from Xiaomi Token Plan as a new AI provider with
base URL https://token-plan-ams.xiaomimimo.com/v1. The /model change
command now switches between MiniMax and MiMo only. ZAI is always
placed last in the fallback chain as the provider of ultimate resort.
Config panel shows MiniMax and MiMo cards.

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-25 21:24:39 +02:00
Augustin
b80562a669 fix(terminal): use absolute positioning for content panels
height:100% on .content>div fails because .content uses flex:1
without explicit height. Switch to position:absolute;inset:0 which
correctly fills the content area and gives xterm proper container
dimensions for fitAddon.

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-25 21:24:39 +02:00
Augustin
c562972da3 feat(terminal): add Ctrl+Shift+C/V copy/paste shortcuts
xterm captures all keyboard input which prevents standard clipboard
operations. Add custom key handler to intercept Ctrl+Shift+C for
copy (selection) and Ctrl+Shift+V for paste, without interfering
with Ctrl+C (SIGINT) or browser devtools shortcut.

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-25 21:24:39 +02:00
Augustin
3651f62127 fix(shell): prevent Enter in AI chat from leaking to terminal
Stop propagation of Enter keydown in AI input and defer terminal
focus to next event loop tick to prevent xterm from capturing the
same key event.

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-25 21:24:39 +02:00
Augustin
18e83479d6 fix(terminal): improve terminal dimensions and fit timing
Use min-height:0 on xterm-wrapper (flex child) instead of height:100%
to properly fill available space in flex layout. Add delayed fit()
calls after initialization to let the layout stabilize before
calculating terminal cell dimensions.

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-25 21:24:39 +02:00
Augustin
6596d86db6 fix(terminal): detect shell tab visibility via MutationObserver
Shell is always mounted inside a display:none parent when the app
loads on a different tab. Added MutationObserver on the wrapper to
detect when the shell tab becomes visible and initialize/fit all
pending terminals at that moment. Removed attempt limit so retries
continue until the tab is actually shown.

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-25 21:24:39 +02:00
Augustin
9fb5aa8dbf fix(terminal): init all tabs on load, fix excessive zoom
Use visibility:hidden instead of display:none for inactive terminal tabs
so xterm containers retain their dimensions. This allows all terminals
to initialize independently and prevents fitAddon from miscalculating
cell sizes on zero-height containers.

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-25 21:24:39 +02:00
Augustin
ab3641d00d fix(terminal): improve tab visibility checks and positioning
- Add null check for container before accessing offsetHeight
- Validate activeTabRef during initialization and fit operations
- Check for display:none as visibility indicator
- Simplify useEffect dependency array
- Use absolute positioning for terminal wrapper/instance

💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-25 21:24:39 +02:00
Augustin
5dac191d9a fix(ui): adjust global CSS styles
💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-25 21:24:39 +02:00
Augustin
e6da61f460 fix(terminal): use display:none instead of visibility for tab hiding
Replace visibility-based hiding with display property for reliable tab
detection. Use offsetParent and offsetHeight checks instead of style
properties to properly detect hidden terminals.

💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-25 21:24:39 +02:00
Augustin
a994749dcf feat(ui): refactor copy state to Set and add helper functions
- Change copiedIdx (number) to copiedSet (Set) for tracking multiple copied items
- Add copyCmd function to handle clipboard and timeout cleanup
- Add relativeTime function for displaying relative timestamps

💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-25 21:24:39 +02:00
Augustin
b394ef9979 feat(ui): add recentUnique to deduplicate recent commands in Dashboard
💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-25 21:24:39 +02:00
Augustin
fca53440e6 feat(ui): redesign recent commands display and fix terminal visibility
- Dashboard: add frequency bars for top commands, click-to-copy, time display
- Shell: switch from display:none to visibility:hidden for terminal containers
- CSS: restyle command list with improved hover states and copy indicators

💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-25 21:24:39 +02:00
Augustin
0a3123ec17 fix(shell): initialize activeTabRef with activeTab and move useEffect
Reorder code to follow React hooks rules - initialize ref with value
instead of null, then update via useEffect.

💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-25 21:24:39 +02:00
Augustin
e6447f2f5a fix(config): remove unused import, reorder hooks, and improve variable naming
Reorder validateKey function and useEffect to avoid referencing before definition.
Rename loop variable from 't' to 'tool' for clarity.

💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-25 21:24:39 +02:00
Augustin
16c5ed6dd9 fix(studio): add tool results serialization and improve message handling
- Add tool_results array to AI message content with tool_call_id, result, and is_error
- Convert cleanContent to let for potential reuse
- Reset accumulated and streaming state on tool_call events

💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-25 21:24:39 +02:00
Augustin
e8924be182 fix(shell): improve tab reference stability and command queueing
Add refs to track activeTab and pending commands outside render cycle.
Flush queued commands after terminal initialization completes.
Fix sendToTerminal to use stable refs instead of stale state.
Enhance debug logging for tab operations.

💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-25 21:24:39 +02:00
Augustin
a905f22f1a fix(shell): add debug logging for tab tracking and WebSocket state
Track which tab messages belong to via _tabId field to ensure AI
responses are sent to the correct terminal tab. Add console.log in
initTerminal, sendToTerminal for troubleshooting tab lifecycle issues.

💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-25 21:24:39 +02:00
Augustin
183dd27407 fix(terminal): refactor WebSocket cleanup, buffer management, and disposal
- Add proper disposal tracking to prevent memory leaks
- Move terminal buffer from localStorage to sessionStorage
- Restore buffer immediately after first WS message
- Fix clear detection logic and error handling
- Add signal parameter support for abortable fetch requests

💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-25 21:24:39 +02:00
Augustin
203f57fa31 fix(terminal): refactor WS cleanup, improve clear detection, fix sendToTerminal
- Move defer cleanup after async goroutine setup to prevent premature closure
- Remove unused Password field from terminal sessions struct
- Fix line calculation in clear detection using viewportY instead of baseY
- Add onStateChange callback to connectWebSocket for connection state
- Add tabId parameter to sendToTerminal for targeted tab control
- Simplify ShellAIMessage to use specific tab for command sending

💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-25 21:24:39 +02:00
Augustin
a1046da67b fix: restore buffer after WebSocket init, fix clear detection, fix streaming chunks
- Delay buffer restoration by 300ms to avoid race condition with WebSocket init
- Read current line from terminal buffer on Enter (reliable) instead of keystroke tracking
- Fix streaming to emit full content instead of word-by-word chunks
- Fix WebSocket readyState check in sendToTerminal
- Extract and deduplicate AI message sending logic
- Fix localStorage cleanup on tab close

💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-25 21:24:39 +02:00
Augustin
02ee41c12b refactor: remove locale panel, improve provider validation and terminal buffer persistence
- Remove locale panel from config (language/keyboard already handled elsewhere)
- Add per-provider key validation status with auto-check on load
- Add missing tools section with AI-powered installation
- Improve reset confirmation with modal
- Persist terminal buffer to localStorage with auto-save
- Detect clear command to wipe saved buffer
- Remove AI tab concept (commands routed to active tab instead)
- Remove renderTick hacks, use proper message keys

💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-25 21:24:39 +02:00
Augustin
06810be9a3 bump: v0.3.5
💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-25 21:24:39 +02:00
Augustin
8db3bd7c6b fix: display all quota models, center card content vertically
- Handle all quota types in providersQuota, not just TIME_LIMIT
- Extract model name from model field or type field
- Use explicit limit value when available
- Add vertical center alignment to quota card content

💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-25 21:24:39 +02:00
Augustin
20237c022f fix: AI terminal init, Shift+Tab nav, code block rendering, command filtering
- Fix AI terminal not initializing (wait for shell col visibility, remove offsetHeight guard)
- Add Shift+Tab to cycle between shell terminals
- Handle unclosed code blocks in renderContent (Shell + Studio)
- Filter irrelevant commands from history (short/non-alpha backend + expanded frontend exclude list)

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-25 21:24:39 +02:00
Augustin
9a218b1904 fix(shell): set default terminal fontSize to 6px
All checks were successful
Beta Release / beta (push) Successful in 49s
All fallbacks were still using 12px. User confirmed 6px is the
correct baseline on their display.

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-24 22:41:47 +02:00
Augustin
399b845e14 fix(shell): default fontSize 10px and init new tabs immediately
All checks were successful
Beta Release / beta (push) Successful in 48s
- Base font size reduced from 12px to 10px
- New tabs now initialize directly when added (was waiting for
  tab switch because the MutationObserver only fired on visibility
  changes, not on tab additions)
- Zoom level applied to newly created terminals

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-24 22:33:49 +02:00
Augustin
436d5c6149 feat(shell): add Ctrl+/- zoom and display all shortcuts in footer
All checks were successful
Beta Release / beta (push) Successful in 48s
- Ctrl+/Ctrl-/Ctrl+0 to zoom in/out/reset terminal font size
- Zoom badge indicator in tab bar
- All shell shortcuts now shown in statusbar footer
- Added i18n labels for search, zoom, switch tab, next tab

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-24 22:28:15 +02:00
Augustin
5a9edc076e fix(deps): upgrade @xterm/xterm to 6.1.0-beta.203 for addon compatibility
All checks were successful
Beta Release / beta (push) Successful in 49s
The addon-web-links registerApcHandler API requires xterm >= 6.1.0.

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-24 22:19:12 +02:00
Augustin
5bdc7a6429 fix(shell): enable allowProposedApi for Unicode11 addon
All checks were successful
Beta Release / beta (push) Successful in 48s
💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-24 22:16:23 +02:00
Augustin
5a0480bae0 fix(ci): add .npmrc with legacy-peer-deps for xterm addon resolution
All checks were successful
Beta Release / beta (push) Successful in 49s
💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-24 22:12:05 +02:00
Augustin
80de4dd523 feat(shell): integrate Hyper-like terminal technologies (WebGL, search, unicode11, image)
Some checks failed
Beta Release / beta (push) Failing after 20s
Add xterm addons from Vercel Hyper terminal: WebGL renderer with DOM
fallback, search bar (Ctrl+Shift+F), Unicode 11 grapheme support, and
inline image protocol. All existing functionality preserved.

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-24 22:10:15 +02:00
Augustin
de52f4ebd6 fix(shell): restore all missing imports, constants, and utility functions
All checks were successful
Beta Release / beta (push) Successful in 49s
- Restore xterm imports (Terminal, FitAddon, WebLinksAddon)
- Restore all lucide-react icons (Globe, X, Plus, ChevronDown, etc.)
- Restore module-level constants (AI_TAB_ID, MAX_TABS, SHELL_MAX_TOKENS,
  TABS_STORAGE_KEY, TERMINAL_BUFFER_KEY)
- Restore renderContent() and formatText() utility functions
- Add @xterm/xterm CSS import
- Remove duplicate constants from inside Shell component

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-24 22:02:36 +02:00
Augustin
98ff0dd578 fix(shell): add missing Monitor import from lucide-react
All checks were successful
Beta Release / beta (push) Successful in 47s
💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-24 21:56:32 +02:00
Augustin
9a1ff6e8dc fix(shell): restore missing MAX_TABS, TABS_STORAGE_KEY, TERMINAL_BUFFER_KEY constants
All checks were successful
Beta Release / beta (push) Successful in 48s
💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-24 21:53:38 +02:00
Augustin
034b9ee0e4 fix(shell): add missing useI18n import
All checks were successful
Beta Release / beta (push) Successful in 45s
💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-24 21:51:54 +02:00
Augustin
c1b1fc653f fix(shell): remove stray 'impo' typo causing ReferenceError
All checks were successful
Beta Release / beta (push) Successful in 44s
💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-24 21:50:12 +02:00
Augustin
50ca75180c fix(terminal): improve dimensions handling and add system theme for xterm
All checks were successful
Beta Release / beta (push) Successful in 47s
💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-24 21:43:10 +02:00
Augustin
b8aa935bec fix(shell): resolve savedTabs undefined ReferenceError in activeTab init
All checks were successful
Beta Release / beta (push) Successful in 50s
💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-24 21:36:25 +02:00
Augustin
5627ddd2ce fix(terminal): improve dimension calculation and tab init reliability
All checks were successful
Beta Release / beta (push) Successful in 48s
- Guarantee minimum 24x80 dimensions on WebSocket open
- Force reflow before init attempts
- Multiple fit attempts with increasing delays (0/50/100/200/400ms)
- Validate saved tabs structure from localStorage
- Resize active tab after closing another tab

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-24 21:30:07 +02:00
Augustin
d27872572a fix(dashboard): show MiMo quota instead of ZAI on dashboard
All checks were successful
Beta Release / beta (push) Successful in 47s
Replace Z.AI quota display with MiMo provider in the API Quota card.
ZAI is now a hidden fallback and should not appear in the dashboard.

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-24 21:28:22 +02:00
Augustin
7d0f807fb0 feat(ai): add Xiaomi MiMo provider, ZAI as last-resort fallback
All checks were successful
Beta Release / beta (push) Successful in 57s
Add MiMo-V2.5-Pro from Xiaomi Token Plan as a new AI provider with
base URL https://token-plan-ams.xiaomimimo.com/v1. The /model change
command now switches between MiniMax and MiMo only. ZAI is always
placed last in the fallback chain as the provider of ultimate resort.
Config panel shows MiniMax and MiMo cards.

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-24 21:22:34 +02:00
Augustin
cbf623b98b fix(terminal): use absolute positioning for content panels
All checks were successful
Beta Release / beta (push) Successful in 50s
height:100% on .content>div fails because .content uses flex:1
without explicit height. Switch to position:absolute;inset:0 which
correctly fills the content area and gives xterm proper container
dimensions for fitAddon.

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-24 21:13:20 +02:00
Augustin
b85ebb8e54 feat(terminal): add Ctrl+Shift+C/V copy/paste shortcuts
All checks were successful
Beta Release / beta (push) Successful in 48s
xterm captures all keyboard input which prevents standard clipboard
operations. Add custom key handler to intercept Ctrl+Shift+C for
copy (selection) and Ctrl+Shift+V for paste, without interfering
with Ctrl+C (SIGINT) or browser devtools shortcut.

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-24 21:10:24 +02:00
Augustin
7cc206dc20 fix(shell): prevent Enter in AI chat from leaking to terminal
All checks were successful
Beta Release / beta (push) Successful in 48s
Stop propagation of Enter keydown in AI input and defer terminal
focus to next event loop tick to prevent xterm from capturing the
same key event.

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-24 21:07:36 +02:00
Augustin
bf8c0fd380 fix(terminal): improve terminal dimensions and fit timing
All checks were successful
Beta Release / beta (push) Successful in 47s
Use min-height:0 on xterm-wrapper (flex child) instead of height:100%
to properly fill available space in flex layout. Add delayed fit()
calls after initialization to let the layout stabilize before
calculating terminal cell dimensions.

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-24 20:35:49 +02:00
Augustin
08dc1fd53b fix(terminal): detect shell tab visibility via MutationObserver
All checks were successful
Beta Release / beta (push) Successful in 49s
Shell is always mounted inside a display:none parent when the app
loads on a different tab. Added MutationObserver on the wrapper to
detect when the shell tab becomes visible and initialize/fit all
pending terminals at that moment. Removed attempt limit so retries
continue until the tab is actually shown.

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-24 20:28:02 +02:00
Augustin
13e937a11b fix(terminal): init all tabs on load, fix excessive zoom
All checks were successful
Beta Release / beta (push) Successful in 46s
Use visibility:hidden instead of display:none for inactive terminal tabs
so xterm containers retain their dimensions. This allows all terminals
to initialize independently and prevents fitAddon from miscalculating
cell sizes on zero-height containers.

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-24 20:13:21 +02:00
Augustin
3cf701b002 fix(terminal): improve tab visibility checks and positioning
All checks were successful
Beta Release / beta (push) Successful in 48s
- Add null check for container before accessing offsetHeight
- Validate activeTabRef during initialization and fit operations
- Check for display:none as visibility indicator
- Simplify useEffect dependency array
- Use absolute positioning for terminal wrapper/instance

💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-24 17:59:48 +02:00
Augustin
3a09e0e0c2 fix(ui): adjust global CSS styles
All checks were successful
Beta Release / beta (push) Successful in 45s
💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-24 17:38:21 +02:00
Augustin
47fa2e01bb fix(terminal): use display:none instead of visibility for tab hiding
All checks were successful
Beta Release / beta (push) Successful in 49s
Replace visibility-based hiding with display property for reliable tab
detection. Use offsetParent and offsetHeight checks instead of style
properties to properly detect hidden terminals.

💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-24 17:23:54 +02:00
Augustin
401292ec5b feat(ui): refactor copy state to Set and add helper functions
All checks were successful
Beta Release / beta (push) Successful in 46s
- Change copiedIdx (number) to copiedSet (Set) for tracking multiple copied items
- Add copyCmd function to handle clipboard and timeout cleanup
- Add relativeTime function for displaying relative timestamps

💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-24 17:04:38 +02:00
Augustin
199a7e409a feat(ui): add recentUnique to deduplicate recent commands in Dashboard
All checks were successful
Beta Release / beta (push) Successful in 47s
💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-24 17:01:08 +02:00
Augustin
c91931f42f feat(ui): redesign recent commands display and fix terminal visibility
All checks were successful
Beta Release / beta (push) Successful in 44s
- Dashboard: add frequency bars for top commands, click-to-copy, time display
- Shell: switch from display:none to visibility:hidden for terminal containers
- CSS: restyle command list with improved hover states and copy indicators

💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-24 16:53:59 +02:00
Augustin
cbbb224725 fix(shell): initialize activeTabRef with activeTab and move useEffect
All checks were successful
Beta Release / beta (push) Successful in 45s
Reorder code to follow React hooks rules - initialize ref with value
instead of null, then update via useEffect.

💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-24 16:44:02 +02:00
Augustin
8d10d2182e fix(config): remove unused import, reorder hooks, and improve variable naming
All checks were successful
Beta Release / beta (push) Successful in 42s
Reorder validateKey function and useEffect to avoid referencing before definition.
Rename loop variable from 't' to 'tool' for clarity.

💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-24 16:33:09 +02:00
Augustin
e9696ef82b fix(studio): add tool results serialization and improve message handling
All checks were successful
Beta Release / beta (push) Successful in 43s
- Add tool_results array to AI message content with tool_call_id, result, and is_error
- Convert cleanContent to let for potential reuse
- Reset accumulated and streaming state on tool_call events

💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-24 16:22:54 +02:00
Augustin
1edd4f053a fix(shell): improve tab reference stability and command queueing
All checks were successful
Beta Release / beta (push) Successful in 47s
Add refs to track activeTab and pending commands outside render cycle.
Flush queued commands after terminal initialization completes.
Fix sendToTerminal to use stable refs instead of stale state.
Enhance debug logging for tab operations.

💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-24 16:10:54 +02:00
Augustin
92f943c3e6 fix(shell): add debug logging for tab tracking and WebSocket state
All checks were successful
Beta Release / beta (push) Successful in 46s
Track which tab messages belong to via _tabId field to ensure AI
responses are sent to the correct terminal tab. Add console.log in
initTerminal, sendToTerminal for troubleshooting tab lifecycle issues.

💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-24 15:53:13 +02:00
Augustin
1704b196cf fix(terminal): refactor WebSocket cleanup, buffer management, and disposal
All checks were successful
Beta Release / beta (push) Successful in 52s
- Add proper disposal tracking to prevent memory leaks
- Move terminal buffer from localStorage to sessionStorage
- Restore buffer immediately after first WS message
- Fix clear detection logic and error handling
- Add signal parameter support for abortable fetch requests

💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-24 15:41:01 +02:00
Augustin
40ec493bae fix(terminal): refactor WS cleanup, improve clear detection, fix sendToTerminal
All checks were successful
Beta Release / beta (push) Successful in 49s
- Move defer cleanup after async goroutine setup to prevent premature closure
- Remove unused Password field from terminal sessions struct
- Fix line calculation in clear detection using viewportY instead of baseY
- Add onStateChange callback to connectWebSocket for connection state
- Add tabId parameter to sendToTerminal for targeted tab control
- Simplify ShellAIMessage to use specific tab for command sending

💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-24 15:20:48 +02:00
Augustin
233368c954 fix: restore buffer after WebSocket init, fix clear detection, fix streaming chunks
All checks were successful
Beta Release / beta (push) Successful in 50s
- Delay buffer restoration by 300ms to avoid race condition with WebSocket init
- Read current line from terminal buffer on Enter (reliable) instead of keystroke tracking
- Fix streaming to emit full content instead of word-by-word chunks
- Fix WebSocket readyState check in sendToTerminal
- Extract and deduplicate AI message sending logic
- Fix localStorage cleanup on tab close

💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-24 14:15:14 +02:00
Augustin
00118f0803 refactor: remove locale panel, improve provider validation and terminal buffer persistence
All checks were successful
Beta Release / beta (push) Successful in 47s
- Remove locale panel from config (language/keyboard already handled elsewhere)
- Add per-provider key validation status with auto-check on load
- Add missing tools section with AI-powered installation
- Improve reset confirmation with modal
- Persist terminal buffer to localStorage with auto-save
- Detect clear command to wipe saved buffer
- Remove AI tab concept (commands routed to active tab instead)
- Remove renderTick hacks, use proper message keys

💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-24 13:49:12 +02:00
Augustin
167ab82978 bump: v0.3.5
All checks were successful
Beta Release / beta (push) Successful in 56s
💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-24 13:16:08 +02:00
Augustin
a23c0c5b94 fix: display all quota models, center card content vertically
Some checks failed
Beta Release / beta (push) Has been cancelled
- Handle all quota types in providersQuota, not just TIME_LIMIT
- Extract model name from model field or type field
- Use explicit limit value when available
- Add vertical center alignment to quota card content

💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-24 13:15:51 +02:00
Augustin
24b31b0b47 fix: AI terminal init, Shift+Tab nav, code block rendering, command filtering
All checks were successful
Beta Release / beta (push) Successful in 45s
- Fix AI terminal not initializing (wait for shell col visibility, remove offsetHeight guard)
- Add Shift+Tab to cycle between shell terminals
- Handle unclosed code blocks in renderContent (Shell + Studio)
- Filter irrelevant commands from history (short/non-alpha backend + expanded frontend exclude list)

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-23 23:24:43 +02:00
CI Bot
c39203cc4b chore: update CHANGELOG for v0.3.4 2026-04-23 21:14:19 +00:00
Augustin
869bf154cc Merge branch 'develop'
All checks were successful
Stable Release / stable (push) Successful in 41s
2026-04-23 23:13:04 +02:00
Augustin
7ae4017672 fix(ci): replace jq with python3 in release step, add debug output
All checks were successful
Beta Release / beta (push) Successful in 40s
💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-23 23:13:03 +02:00
Augustin
52a785ec9a Merge branch 'develop'
Some checks failed
Stable Release / stable (push) Failing after 34s
2026-04-23 23:08:10 +02:00
Augustin
8c540eba93 feat: AI terminal, Z.AI quota, /model change, formatting fixes, update redirects
All checks were successful
Beta Release / beta (push) Successful in 49s
- Add dedicated AI Terminal tab (non-deletable) shared between user and AI
- Add Z.AI quota display on dashboard via /api/monitor/usage/quota/limit
- Add /model change command in Studio to toggle MiniMax/ZAI
- Apply Studio formatting (formatText, renderContent) to Shell AI messages
- Add render tick refresh for Shell (1s streaming, 5s idle)
- Add analysis viewer modal (Eye button) in Shell panel
- Fix multi-shell tab creation with retry init and settings ref
- Persist shell tabs to localStorage
- Fix line spacing in Studio (line-height 1.7→1.5, cleanup stray <br/>)
- Redirect Config updates to AI terminal via custom events
- Fix CI: delete existing release before recreating
- Bump version to 0.3.4

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-23 23:07:54 +02:00
Augustin
0b6d5281df Merge branch 'develop'
Some checks failed
Stable Release / stable (push) Failing after 32s
2026-04-23 22:13:03 +02:00
Augustin
1074b019d3 feat(studio): Tab focuses textarea, autocomplete commands
All checks were successful
Beta Release / beta (push) Successful in 41s
- Tab outside textarea focuses it
- Tab inside textarea autocompletes / commands (/clear, /summarize, etc.)

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-23 22:13:02 +02:00
Augustin
745e03d00a Merge branch 'develop'
Some checks failed
Stable Release / stable (push) Failing after 31s
2026-04-23 22:10:55 +02:00
Augustin
2da0cf9421 fix(studio): convert newlines to <br/> in AI message rendering
All checks were successful
Beta Release / beta (push) Successful in 39s
formatText now replaces \n with <br/> so AI responses display
with proper line breaks instead of a single unbroken block.

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-23 22:10:54 +02:00
Augustin
f88c7a4f3f Merge branch 'develop'
Some checks failed
Stable Release / stable (push) Failing after 33s
2026-04-23 22:08:41 +02:00
Augustin
9987a586e2 fix(config): replace hardcoded model list with free text input
All checks were successful
Beta Release / beta (push) Successful in 41s
Removed PROVIDER_MODELS hardcoded map. Model is now a simple text
input pre-filled with the current model value.

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-23 22:08:41 +02:00
Augustin
028fb364ba Merge branch 'develop'
Some checks failed
Stable Release / stable (push) Failing after 33s
2026-04-23 22:06:22 +02:00
Augustin
2827acfe96 feat(config): providers panel shows only MINIMAX/ZAI with model selector
All checks were successful
Beta Release / beta (push) Successful in 42s
- Only MINIMAX and ZAI displayed (names in uppercase)
- Each provider shows selectable model chips (MiniMax-M2.7, glm-4, etc.)
- Save button always visible when editing, not just after validation
- Removed setup hint text

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-23 22:06:21 +02:00
Augustin
85edea9ed9 Merge branch 'develop'
Some checks failed
Stable Release / stable (push) Has been cancelled
2026-04-23 22:04:38 +02:00
Augustin
afb6e77c7f feat(dashboard): show top 5 most used commands as clickable chips
All checks were successful
Beta Release / beta (push) Successful in 43s
Top commands (excluding ls/cd/pwd/clear/exit/history) displayed as
large chips with usage count. Click to copy. Full history below.

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-23 22:04:37 +02:00
Augustin
0232bd7afe Merge branch 'develop'
Some checks failed
Stable Release / stable (push) Failing after 33s
2026-04-23 21:57:01 +02:00
Augustin
84be22661b fix: tab containers height, dashboard 2-row grid, studio scroll buttons
All checks were successful
Beta Release / beta (push) Successful in 41s
- .content > div now inherits full height so all tabs fill the viewport
- Dashboard grid uses grid-template-rows: repeat(2, 1fr) for 6 equal tiles
- Studio gets floating scroll-to-top / scroll-to-bottom buttons
- Wrapped studio-feed in scroll-wrap for proper overflow

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-23 21:57:00 +02:00
Augustin
49a0f5c8c3 Merge branch 'develop'
Some checks failed
Stable Release / stable (push) Failing after 31s
2026-04-23 21:50:06 +02:00
Augustin
f9c4cf11ff feat(shell): dedicated System Analyst AI, no code execution, analyze system
All checks were successful
Beta Release / beta (push) Successful in 45s
- New ShellConvStore with persistent history (shell_conversation.json)
- 100k token limit — input grays out, must /clear to continue
- Commands limited to /clear and /help only
- Shell AI has NO tools — read-only analysis, never executes code
- "Analyste Système" panel with system analysis button
- System analysis uses Studio AI to write system_analysis.md,
  prepended as context on every conversation start
- Code blocks show "Copier" and "Terminal" buttons to copy or
  send code directly to the active terminal via WebSocket
- Token bar shows usage with warning at 80%

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-23 21:50:06 +02:00
Augustin
d3755028fb Merge branch 'develop'
Some checks failed
Stable Release / stable (push) Failing after 33s
2026-04-23 21:35:00 +02:00
Augustin
eda7293286 fix: keep all tabs mounted, switch via CSS display instead of unmount
All checks were successful
Beta Release / beta (push) Successful in 42s
All 4 tabs (Dashboard, Studio, Shell, Config) are now always mounted
and toggled via .tab-hidden (display:none). This preserves:
- Dashboard graph history across tab switches
- Terminal session state and progress
- Studio chat context
- Config form state

Dashboard polling pauses after 3 ticks when hidden to save resources
and auto-resumes when the tab becomes visible again.

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-23 21:34:59 +02:00
Augustin
41cbee8928 Merge branch 'develop'
Some checks failed
Stable Release / stable (push) Failing after 33s
2026-04-23 21:30:02 +02:00
Augustin
b55feaed09 refactor(config): locale panel with edit/save flow like profile
All checks were successful
Beta Release / beta (push) Successful in 40s
Show current language and keyboard as read-only values, then
"Modifier" button opens chip selection, "Sauvegarder" persists
via API. Centered layout matching profile panel style.

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-23 21:30:02 +02:00
Augustin
1d521cbf90 Merge branch 'develop'
Some checks failed
Stable Release / stable (push) Has been cancelled
2026-04-23 21:29:08 +02:00
Augustin
54621bd960 feat(config): split profile into Personal Info + Preferences sections, centered
All checks were successful
Beta Release / beta (push) Successful in 40s
- Profile panel now shows two distinct cards with section titles
- Personal Info (name, pseudo, email, languages) and Preferences
  (editor, shell, theme, etc.) are visually separated
- Content centered with max-width 540px
- Added i18n keys for section titles

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-23 21:29:07 +02:00
Augustin
6bad2948c5 feat(studio): improve context compression UI and provider display
All checks were successful
Beta Release / beta (push) Successful in 45s
- Add visual indicator when messages are collapsed (folder icon)
- Add animation to token bar during compression (pulse effect)
- Token bar becomes more compact after compression with "· compressé" label
- Button "voir plus" to expand collapsed messages
- Add 24px spacing at end of feed to avoid last message clipping
- Simplify provider display: show name only, badge "active" instead of key status
- Dashboard: show provider name only without model suffix
- Studio /model: show just provider name, not model
- Z.AI (GLM): mark as crush-only, no external quota check
- Claude: check /usr/bin/claude installation instead of API

💘 Generated with Crush
2026-04-23 21:21:59 +02:00
Augustin
d9d1ec5cb7 Merge branch 'develop'
Some checks failed
Stable Release / stable (push) Failing after 33s
2026-04-23 21:20:31 +02:00
Augustin
92eb783df0 fix(config): locale panel show active language/keyboard, add save button
All checks were successful
Beta Release / beta (push) Successful in 41s
- Fix language prop passing keyboard value instead of language
- Add save button that persists language + keyboard_layout via API
- Add local toast for save confirmation

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-23 21:20:30 +02:00
Augustin
45884ee75c Merge branch 'develop'
Some checks failed
Stable Release / stable (push) Failing after 32s
2026-04-23 21:14:56 +02:00
Augustin
8005e978f0 feat(config): dynamic profile panel, generic save, tabs margin fix
All checks were successful
Beta Release / beta (push) Successful in 44s
- Config tabs now have bottom padding for visual spacing
- Profile panel dynamically renders all config fields (strings, bools,
  arrays, nested objects) — new struct fields appear automatically
- handleSaveProfile uses generic JSON merge via deepMerge, so any
  new Profile field works without handler changes
- RenderFields recursively renders config sections with edit/view modes

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-23 21:14:47 +02:00
Augustin
6f7f588e51 merge develop: resolve conflicts, accept develop versions
Some checks failed
Stable Release / stable (push) Failing after 31s
💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-23 21:03:22 +02:00
Augustin
6e76e7dca6 fix(dashboard): remove bg graphs, add scrollable lists, show used/total quota
All checks were successful
Beta Release / beta (push) Successful in 40s
Remove BgGraph background SVGs that were misaligned with foreground graphs.
Add max-height: 270px with overflow-y scroll to quota/processes/commands lists.
Change API quota display from remaining/total to used/total.

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-23 21:02:53 +02:00
Augustin
e8f6dc4b4d feat(chat): add auto-summarization with token tracking UI
All checks were successful
Beta Release / beta (push) Successful in 47s
Add /summarize command, token usage bar, and summary endpoint.
Add JSON tags to config/platform/scanner structs for API serialization.

💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-23 20:42:43 +02:00
Augustin
bb03c9fe2d feat(dashboard): add background graphs to cards and improve layout
All checks were successful
Beta Release / beta (push) Successful in 39s
- Add BgGraph component for subtle background SVG graphs
- Add gradient fills to MiniGraph components
- Track process count over time
- Calculate total API quota usage
- Improve card styling with overlay content

💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-23 19:55:10 +02:00
Augustin
328e9e6457 feat(dashboard): single-view grid with live CPU/RAM/Net graphs, API quota, processes, and sudo indicator
All checks were successful
Stable Release / stable (push) Successful in 39s
- Rewrite dashboard from 4 tabs to single grid view with 5s auto-refresh
- Add live CPU/RAM/Network SVG graphs with rolling 30-point history
- Add backend /api/system/metrics reading /proc/stat, /proc/meminfo, /proc/net/dev
- Add backend /api/providers/quota for MiniMax and Z.AI quota monitoring
- Add backend /api/recent-commands reading bash/zsh history
- Add backend /api/running-processes filtering editors/IDEs/languages
- Add sudo/root indicator ( ROOT) in footer when running as root
- Remove duplicate Ctrl+1-4 shortcut from page-specific footer (keep only right side)
- Add Ctrl+R shortcut on dashboard for metrics-only refresh
- Make API key mandatory in onboarding, auto-scan editors via AI chat
- Remove manual editor input, only show AI-detected editors
- Bump version to 0.3.3

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-23 19:47:00 +02:00
Augustin
c81ebb4e46 feat(dashboard): add quota monitoring, process list, and command history
- New API endpoints: /providers/quota, /recent-commands, /running-processes
- New grid-based dashboard layout with cards for tools, quota, processes, commands
- Improved OnboardingWizard with required API key validation and scanning feedback
- Auto-initialize config on first run

💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-23 19:47:00 +02:00
Augustin
b0865bc598 refactor(chat): deduplicate streaming code, add multi-conv, and XSS protection
- Add ChatEngine for deduplicated chat logic (handlers_chat/shell_chat)
- Add SendWithToolsStream for real-time streaming responses
- Add /help, /plan, /export, /model commands in Studio
- Fix XSS: sanitize HTML after markdown rendering
- Add ConversationStoreMulti for multi-conversation support
- Add Anthropic headers (x-api-key, anthropic-version)
- Add fallback logging when provider switch occurs
- Add API handler tests (handlers_test.go)
- Polish Studio: max-height 200px, word-break on tool args
- Update CLI version to show full info (version, go, platform)

🤖 Generated with Crush

Assisted-by: MiniMax-M2.5 via Crush <crush@charm.land>
2026-04-23 19:47:00 +02:00
Augustin
0d8e1b1e1a fix(studio): improve chat context, thinking tags, streaming, and tool results
- Fix cleanThinkingTags to use proper regex instead of naive ReplaceAll
- Send conversation history (last 20 messages + summary) to AI instead of single message
- Store tool results alongside tool calls so history shows complete execution info
- Stream words instead of characters for smoother SSE rendering
- Add stop button to cancel in-progress AI requests (AbortController)
- Fix markdown rendering: add h2 support, use div for bullets
- Add i18n keys for cancel/stop (EN + FR)

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-23 19:47:00 +02:00
Augustin
485e085bb0 feat: add Cobra CLI, LSP/MCP registries, workflow engine, and enriched dashboard
Major changes:
- Refactor CLI entry point to Cobra commands (root, setup, scan, doctor, install, update, lsp, mcp, skills, config, version)
- Add LSP registry with health checks, auto-install, and editor config generation
- Add MCP registry with editor detection, status tracking, and per-editor configuration
- Add workflow engine with planner and step execution for automated task chains
- Add conversation search, export (Markdown/JSON), and detailed token counting
- Add streaming shell chat handler with tool call/result events
- Add skill validation, dry-run testing, and export endpoints
- Enrich dashboard with Tools/Activity/Status tabs and tool cards grid
- Add PRD documentation
- Complete i18n for both EN and FR

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-23 19:47:00 +02:00
Augustin
61da8039bc feat(agent): refactor AI chat with streaming, agent registry, and tool execution
- Replace old tool-call regex with proper agent registry
- Add streaming chat via SSE (handleStreamChat / handleNonStreamChat)
- Add internal/agent package with tool definitions and execution
- Add orchestrator with system prompt and tool scaffolding
- Add internal/agent/ directory
- Studio.jsx: streaming chat with thinking indicator and tool result rendering
- global.css: chat bubble styles, streaming animation, thinking dots
- handlers_chat.go: full rewrite using new agent/orchestrator architecture

💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-23 19:47:00 +02:00
Augustin
65df15498b feat(onboarding): add minimax api key step and AI-powered editor scan
- Add apikey step in onboarding wizard (optional, with validation)
- Add ScanEditors() in scanner package detecting vim/nvim/code/emacs/nano/helix/subl/zed
- Add GET /api/editors endpoint
- Editor step now has scan button to detect installed editors via backend
- MiniMax API key is saved to provider config if provided

💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-23 19:47:00 +02:00
Augustin
b6147ddb12 fix(onboarding): require fields before advancing steps
- Validate each step before allowing goNext
- Show required error message on name step if empty
- Clear error on input change

💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-23 19:47:00 +02:00
Augustin
275a9a4cc7 fix: register missing /api/config/reset and /api/starship/apply-theme routes
- Add resetConfig and applyStarshipTheme to frontend api client
- Register handleResetConfig and handleApplyStarshipTheme in server mux

💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-23 19:47:00 +02:00
Augustin
e92a2f00f5 fix(config): per-provider form state to avoid field cross-talk
- providerForm is now keyed by provider name
- Each provider (minimax/glm/claude) has isolated form data
- Validation and save target the specific provider being edited

💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-23 19:47:00 +02:00
Augustin
1f12b8a4fb fix(onboarding): auto-save on done step, keyboard nav, error feedback
- Trigger save automatically when reaching done step
- Add Escape to go back, Enter to advance (works in text fields)
- Add back button visible between step 1 and last step
- Fix accent encoding in done message
- Show saving state and error with retry button

💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-23 19:47:00 +02:00
Augustin
9188231a05 feat(config): add system panel with reset and starship theme, add onboarding wizard
- Add PanelSystem with reset config and apply starship theme (charm/zerotwo/default)
- Add OnboardingWizard that activates when profile is empty on first run
- Fix <thing> tag parsing in Shell AI messages (wait for </thing> before rendering)
- Add /api/config/reset and /api/starship/apply-theme endpoints
- Wire wizard trigger in App.jsx based on profile completeness

💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-23 19:47:00 +02:00
Augustin
79d082180c feat(dashboard): single-view grid with live CPU/RAM/Net graphs, API quota, processes, and sudo indicator
All checks were successful
Beta Release / beta (push) Successful in 46s
- Rewrite dashboard from 4 tabs to single grid view with 5s auto-refresh
- Add live CPU/RAM/Network SVG graphs with rolling 30-point history
- Add backend /api/system/metrics reading /proc/stat, /proc/meminfo, /proc/net/dev
- Add backend /api/providers/quota for MiniMax and Z.AI quota monitoring
- Add backend /api/recent-commands reading bash/zsh history
- Add backend /api/running-processes filtering editors/IDEs/languages
- Add sudo/root indicator ( ROOT) in footer when running as root
- Remove duplicate Ctrl+1-4 shortcut from page-specific footer (keep only right side)
- Add Ctrl+R shortcut on dashboard for metrics-only refresh
- Make API key mandatory in onboarding, auto-scan editors via AI chat
- Remove manual editor input, only show AI-detected editors
- Bump version to 0.3.3

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-23 19:46:16 +02:00
Augustin
7682717093 feat(dashboard): add quota monitoring, process list, and command history
All checks were successful
Beta Release / beta (push) Successful in 44s
- New API endpoints: /providers/quota, /recent-commands, /running-processes
- New grid-based dashboard layout with cards for tools, quota, processes, commands
- Improved OnboardingWizard with required API key validation and scanning feedback
- Auto-initialize config on first run

💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-23 19:24:23 +02:00
Augustin
3948a4c656 refactor(chat): deduplicate streaming code, add multi-conv, and XSS protection
All checks were successful
Beta Release / beta (push) Successful in 2m23s
- Add ChatEngine for deduplicated chat logic (handlers_chat/shell_chat)
- Add SendWithToolsStream for real-time streaming responses
- Add /help, /plan, /export, /model commands in Studio
- Fix XSS: sanitize HTML after markdown rendering
- Add ConversationStoreMulti for multi-conversation support
- Add Anthropic headers (x-api-key, anthropic-version)
- Add fallback logging when provider switch occurs
- Add API handler tests (handlers_test.go)
- Polish Studio: max-height 200px, word-break on tool args
- Update CLI version to show full info (version, go, platform)

🤖 Generated with Crush

Assisted-by: MiniMax-M2.5 via Crush <crush@charm.land>
2026-04-22 22:58:05 +02:00
Augustin
65804aae4e fix(studio): improve chat context, thinking tags, streaming, and tool results
All checks were successful
Beta Release / beta (push) Successful in 39s
- Fix cleanThinkingTags to use proper regex instead of naive ReplaceAll
- Send conversation history (last 20 messages + summary) to AI instead of single message
- Store tool results alongside tool calls so history shows complete execution info
- Stream words instead of characters for smoother SSE rendering
- Add stop button to cancel in-progress AI requests (AbortController)
- Fix markdown rendering: add h2 support, use div for bullets
- Add i18n keys for cancel/stop (EN + FR)

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-22 22:37:45 +02:00
Augustin
2e50366cd8 feat: add Cobra CLI, LSP/MCP registries, workflow engine, and enriched dashboard
All checks were successful
Beta Release / beta (push) Successful in 2m24s
Major changes:
- Refactor CLI entry point to Cobra commands (root, setup, scan, doctor, install, update, lsp, mcp, skills, config, version)
- Add LSP registry with health checks, auto-install, and editor config generation
- Add MCP registry with editor detection, status tracking, and per-editor configuration
- Add workflow engine with planner and step execution for automated task chains
- Add conversation search, export (Markdown/JSON), and detailed token counting
- Add streaming shell chat handler with tool call/result events
- Add skill validation, dry-run testing, and export endpoints
- Enrich dashboard with Tools/Activity/Status tabs and tool cards grid
- Add PRD documentation
- Complete i18n for both EN and FR

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-22 22:22:05 +02:00
Augustin
66b773ff86 feat(agent): refactor AI chat with streaming, agent registry, and tool execution
All checks were successful
Beta Release / beta (push) Successful in 47s
- Replace old tool-call regex with proper agent registry
- Add streaming chat via SSE (handleStreamChat / handleNonStreamChat)
- Add internal/agent package with tool definitions and execution
- Add orchestrator with system prompt and tool scaffolding
- Add internal/agent/ directory
- Studio.jsx: streaming chat with thinking indicator and tool result rendering
- global.css: chat bubble styles, streaming animation, thinking dots
- handlers_chat.go: full rewrite using new agent/orchestrator architecture

💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-22 21:19:36 +02:00
Augustin
bc5c2956b4 feat(onboarding): add minimax api key step and AI-powered editor scan
Some checks failed
Beta Release / beta (push) Failing after 22s
- Add apikey step in onboarding wizard (optional, with validation)
- Add ScanEditors() in scanner package detecting vim/nvim/code/emacs/nano/helix/subl/zed
- Add GET /api/editors endpoint
- Editor step now has scan button to detect installed editors via backend
- MiniMax API key is saved to provider config if provided

💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-22 21:04:27 +02:00
Augustin
e19122dad9 fix(onboarding): require fields before advancing steps
All checks were successful
Beta Release / beta (push) Successful in 39s
- Validate each step before allowing goNext
- Show required error message on name step if empty
- Clear error on input change

💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-22 20:58:36 +02:00
Augustin
8b6a7e8bc3 fix: register missing /api/config/reset and /api/starship/apply-theme routes
All checks were successful
Beta Release / beta (push) Successful in 40s
- Add resetConfig and applyStarshipTheme to frontend api client
- Register handleResetConfig and handleApplyStarshipTheme in server mux

💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-22 20:57:25 +02:00
Augustin
58f8cb0bd3 fix(config): per-provider form state to avoid field cross-talk
All checks were successful
Beta Release / beta (push) Successful in 38s
- providerForm is now keyed by provider name
- Each provider (minimax/glm/claude) has isolated form data
- Validation and save target the specific provider being edited

💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-22 20:56:04 +02:00
Augustin
b52feccc17 fix(onboarding): auto-save on done step, keyboard nav, error feedback
All checks were successful
Beta Release / beta (push) Successful in 40s
- Trigger save automatically when reaching done step
- Add Escape to go back, Enter to advance (works in text fields)
- Add back button visible between step 1 and last step
- Fix accent encoding in done message
- Show saving state and error with retry button

💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-22 20:53:12 +02:00
Augustin
5bbac499a7 feat(config): add system panel with reset and starship theme, add onboarding wizard
All checks were successful
Beta Release / beta (push) Successful in 41s
- Add PanelSystem with reset config and apply starship theme (charm/zerotwo/default)
- Add OnboardingWizard that activates when profile is empty on first run
- Fix <thing> tag parsing in Shell AI messages (wait for </thing> before rendering)
- Add /api/config/reset and /api/starship/apply-theme endpoints
- Wire wizard trigger in App.jsx based on profile completeness

💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-22 20:36:36 +02:00
CI Bot
28e5113733 chore: update CHANGELOG for v0.3.2 2026-04-22 18:31:39 +00:00
Augustin
51a599fc83 chore: update CHANGELOG for v0.3.2-beta.1
All checks were successful
Stable Release / stable (push) Successful in 47s
💾 Generated with Crush

Assisted-by: GLM-5-Turbo via Crush <crush@charm.land>
2026-04-22 20:29:54 +02:00
Augustin
d8384cad00 merge develop into main for v0.3.2-beta.1 2026-04-22 20:29:46 +02:00
Augustin
83d7a573c7 fix: correct version from 3.2 to 0.3.2
All checks were successful
Beta Release / beta (push) Successful in 41s
The version was incorrectly bumped to 3.2 instead of 0.3.2.
This follows the existing semver pattern (v0.2.0, v0.2.1, v0.3.1).

💾 Generated with Crush

Assisted-by: GLM-5-Turbo via Crush <crush@charm.land>
2026-04-22 20:29:09 +02:00
CI Bot
5b4a70e690 chore: update CHANGELOG for v0.3.1 2026-04-22 18:21:00 +00:00
Augustin
0fe82f67df chore: bump version to 3.2
All checks were successful
Beta Release / beta (push) Successful in 45s
2026-04-22 20:19:47 +02:00
Augustin
4b9f2c377d Merge branch 'main' into develop 2026-04-22 20:19:35 +02:00
Augustin
95bd824259 refactor(config): remove Terminal sub-tab from Configuration page
All checks were successful
Stable Release / stable (push) Successful in 41s
2026-04-22 20:19:29 +02:00
Augustin
252f178bbd fix(terminal): init payload never sent due to ws.onopen being overwritten
connectWebSocket set ws.onopen to send the shell init payload, but
initTerminal immediately overwrote it with a state-only handler.
Switched to addEventListener so both handlers coexist.
2026-04-22 20:19:29 +02:00
Augustin
7dcf505360 fix(terminal): improve shell resolution with better error handling and ws proxy support
The `len(shell) <= 1` guard was too aggressive and provided no diagnostic info.
Now trims whitespace, resolves path in all cases, falls back to /bin/sh, and
logs detailed context for debugging. Also enable WebSocket proxying in Vite dev.
2026-04-22 20:19:29 +02:00
Augustin
8fb93fa47e feat(studio): parse AI thinking and tool launch messages in terminal panel
- Add message type detection: thinking (Reflexion/Thought/>), tool (TOOL_CALL),
  and normal AI responses
- Style thinking messages with italic blue, tool messages with yellow border
- Add toolLaunched i18n key for both fr and en locales

💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-22 20:19:29 +02:00
Augustin
5ec373cd6a fix(studio): forward AI thinking chunks to frontend instead of dropping them
The ThinkingBlock component existed but was dead code — the backend
silently discarded all <think chunks. Now emits thinking SSE events
so the UI can display AI reflections in real-time.

\xe2\x98\x85 Generated with Crush

Assisted-by: GLM-5-Turbo via Crush <crush@charm.land>
2026-04-22 20:19:29 +02:00
Augustin
1eb5a6d00f feat(studio): add tool execution and hide AI thinking tags
Changes:
- Hide <think> tags from user in Studio chat
- Add tool call detection [TOOL_CALL:{...}] in AI responses
- Execute crush tool when requested by AI
- Show loading animation while AI is thinking

The AI can now:
1. Respond directly to user
2. Request tool execution via [TOOL_CALL:{"tool":"crush","task":"..."}]

The system automatically executes the tool and includes results.

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-22 20:19:29 +02:00
Augustin
cd5ebe083c fix(terminal): ignore invalid shell config from race condition
Reject shell paths with length <= 1 to prevent errors when user
input is accidentally sent as init message.

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-22 20:19:29 +02:00
Augustin
2004c15dd7 feat(shell): restore AI assistant panel
Re-add the AI assistant panel that was removed in previous refactoring.
The panel includes:
- Message history display
- Input field for AI queries
- Loading state indicator

Also restored the associated CSS styles and i18n translations.

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-22 20:19:29 +02:00
Augustin
9306152736 fix(terminal): restore terminal input and cursor visibility
- Fix shell execution to avoid --login flag causing issues on some shells
- Improve terminal initialization timing with requestAnimationFrame
- Force display visibility on xterm instances via CSS
- Ensure container has proper min-height and overflow handling

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-22 20:19:29 +02:00
Augustin
e15a034de5 refactor(api): split monolithic handlers.go into focused modules
Break down the 627-line handlers.go into specialized modules:
- handlers_chat.go: chat and streaming endpoints
- handlers_config.go: configuration endpoints
- handlers_common.go: shared utilities
- handlers_info.go: info and status endpoints
- handlers_terminal.go: terminal/shell endpoints
- handlers_tools.go: tool-related endpoints

Also includes config improvements, orchestrator enhancements, and
web component updates.

💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-22 20:19:29 +02:00
Augustin
3b6cc38ea0 refactor(config): remove Terminal sub-tab from Configuration page
All checks were successful
Beta Release / beta (push) Successful in 41s
2026-04-22 20:13:17 +02:00
Augustin
93a22d4075 fix(terminal): init payload never sent due to ws.onopen being overwritten
All checks were successful
Beta Release / beta (push) Successful in 40s
connectWebSocket set ws.onopen to send the shell init payload, but
initTerminal immediately overwrote it with a state-only handler.
Switched to addEventListener so both handlers coexist.
2026-04-22 20:05:10 +02:00
Augustin
e0e1e73bca fix(terminal): improve shell resolution with better error handling and ws proxy support
All checks were successful
Beta Release / beta (push) Successful in 40s
The `len(shell) <= 1` guard was too aggressive and provided no diagnostic info.
Now trims whitespace, resolves path in all cases, falls back to /bin/sh, and
logs detailed context for debugging. Also enable WebSocket proxying in Vite dev.
2026-04-22 20:02:55 +02:00
Augustin
0496ca789b feat(studio): parse AI thinking and tool launch messages in terminal panel
All checks were successful
Beta Release / beta (push) Successful in 40s
- Add message type detection: thinking (Reflexion/Thought/>), tool (TOOL_CALL),
  and normal AI responses
- Style thinking messages with italic blue, tool messages with yellow border
- Add toolLaunched i18n key for both fr and en locales

💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-22 19:41:42 +02:00
Augustin
b407ab879b fix(studio): forward AI thinking chunks to frontend instead of dropping them
All checks were successful
Beta Release / beta (push) Successful in 40s
The ThinkingBlock component existed but was dead code — the backend
silently discarded all <think chunks. Now emits thinking SSE events
so the UI can display AI reflections in real-time.

\xe2\x98\x85 Generated with Crush

Assisted-by: GLM-5-Turbo via Crush <crush@charm.land>
2026-04-22 19:12:32 +02:00
Augustin
12df184e11 feat(studio): add tool execution and hide AI thinking tags
All checks were successful
Beta Release / beta (push) Successful in 40s
Changes:
- Hide <think> tags from user in Studio chat
- Add tool call detection [TOOL_CALL:{...}] in AI responses
- Execute crush tool when requested by AI
- Show loading animation while AI is thinking

The AI can now:
1. Respond directly to user
2. Request tool execution via [TOOL_CALL:{"tool":"crush","task":"..."}]

The system automatically executes the tool and includes results.

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-22 19:04:59 +02:00
Augustin
8af6d25e28 fix(terminal): ignore invalid shell config from race condition
All checks were successful
Beta Release / beta (push) Successful in 40s
Reject shell paths with length <= 1 to prevent errors when user
input is accidentally sent as init message.

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-22 18:56:33 +02:00
Augustin
4fd599adec feat(shell): restore AI assistant panel
All checks were successful
Beta Release / beta (push) Successful in 38s
Re-add the AI assistant panel that was removed in previous refactoring.
The panel includes:
- Message history display
- Input field for AI queries
- Loading state indicator

Also restored the associated CSS styles and i18n translations.

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-22 18:51:33 +02:00
Augustin
bcba5932d5 fix(terminal): restore terminal input and cursor visibility
All checks were successful
Beta Release / beta (push) Successful in 38s
- Fix shell execution to avoid --login flag causing issues on some shells
- Improve terminal initialization timing with requestAnimationFrame
- Force display visibility on xterm instances via CSS
- Ensure container has proper min-height and overflow handling

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-22 18:46:29 +02:00
Augustin
04b0fff791 refactor(api): split monolithic handlers.go into focused modules
All checks were successful
Beta Release / beta (push) Successful in 44s
Break down the 627-line handlers.go into specialized modules:
- handlers_chat.go: chat and streaming endpoints
- handlers_config.go: configuration endpoints
- handlers_common.go: shared utilities
- handlers_info.go: info and status endpoints
- handlers_terminal.go: terminal/shell endpoints
- handlers_tools.go: tool-related endpoints

Also includes config improvements, orchestrator enhancements, and
web component updates.

💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-22 18:34:14 +02:00
CI Bot
80c11cab3f chore: update CHANGELOG for v0.3.0 2026-04-21 21:08:06 +00:00
Augustin
0b221094f2 fix(terminal): resolve PTY shell exec error, simplify CLI, unify Config tabs, restore Studio CSS
All checks were successful
Beta Release / beta (push) Successful in 37s
Stable Release / stable (push) Successful in 37s
- Fix detectShell() to return full paths via LookPath (was returning bare
  names causing exec error on some systems)
- Add shell path validation before pty.Start to prevent crashes
- Simplify CLI: remove all subcommands, keep only desktop launch with --port
- Restore missing Studio shared CSS (code blocks, input area, animations)
- Replace Config vertical sidebar with horizontal nav-tabs matching main layout

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-21 23:06:39 +02:00
Augustin
7f674730c7 feat: add API key validation flow for AI provider config
All checks were successful
Beta Release / beta (push) Successful in 37s
- Add POST /api/providers/validate backend endpoint that sends a test
  request to the provider's chat/completions API to verify the key
- Add validateProvider to frontend API client
- Redesign PanelProviders: show token input inline with Validate button,
  display valid/invalid badge after validation, Save only appears after
  successful validation
- Add i18n keys (EN/FR) for validation flow

💾 Generated with Crush

Assisted-by: GLM-5-Turbo via Crush <crush@charm.land>
2026-04-21 22:53:52 +02:00
Augustin
040e482c75 feat(studio): replace sidebar layout with unified execution feed styles
All checks were successful
Beta Release / beta (push) Successful in 36s
Replace old Studio sidebar/chat bubble CSS with new feed-based layout.
Add studio.cleared i18n key for /clear command feedback.

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-21 22:46:36 +02:00
Augustin
c8903efa5e fix: guard against empty tabs array in closeTab
All checks were successful
Beta Release / beta (push) Successful in 37s
💾 Generated with Crush

Assisted-by: GLM-5-Turbo via Crush <crush@charm.land>
2026-04-21 22:43:38 +02:00
Augustin
f3cb306053 refactor: redesign Config as settings window with sidebar panels, remove system overview from Dashboard
All checks were successful
Beta Release / beta (push) Successful in 38s
- Config: sidebar navigation with 5 panels (Profile, AI Providers, Updates, Locale, Skills)
- Dashboard: remove duplicated system overview section, keep workflows and activity log
- New CSS for config window layout, cards, provider cards, update rows
- Add i18n panel keys (FR/EN)

💾 Generated with Crush

Assisted-by: GLM-5-Turbo via Crush <crush@charm.land>
2026-04-21 22:41:25 +02:00
Augustin
3cdcb22068 feat: add multi-tab terminal with SSH support, config editing, and dashboard redesign
All checks were successful
Beta Release / beta (push) Successful in 39s
- Terminal: multi-tab sessions, SSH connections, shell detection (zsh/bash/fish/wsl/powershell)
- Config: inline profile & provider editing, system update management
- Dashboard: grid layout with inline tools/notifications/workflows sections
- Add lucide-react icons, i18n keys (FR/EN), and new CSS components

💾 Generated with Crush

Assisted-by: GLM-5-Turbo via Crush <crush@charm.land>
2026-04-21 22:35:49 +02:00
Augustin
ee18bbeb53 feat(studio): add i18n keys and CSS for redesigned AI chat interface
All checks were successful
Beta Release / beta (push) Successful in 2m10s
Add missing English translations and full cyberpunk-themed CSS for the
new Studio tab: central AI chat with context panels (plans, agents,
activity), streaming cursor, plan detail expansion, and collapsible
sidebar.

Generated with Crush

Assisted-by: GLM-5-Turbo via Crush <crush@charm.land>
2026-04-21 22:18:31 +02:00
Augustin
b0b0e1d308 chore: bump version to 0.3.0
Some checks failed
Beta Release / beta (push) Has been cancelled
feat(shell): real terminal with xterm.js + PTY over WebSocket

Replace fake shell input with a full PTY-backed terminal using xterm.js.
Apps like btop, vim, htop now work. AI chat panel is always visible.

Backend:
- Add WebSocket handler /api/ws/terminal with creack/pty
- Allocate real pseudo-terminal with TERM=xterm-256color
- Bidirectional I/O + dynamic resize via pty.Setsize
- Skip JSON headers on /api/ws/* paths for WebSocket upgrade

Frontend:
- Integrate xterm.js with FitAddon and WebLinksAddon
- Cyberpunk color theme matching app design
- ResizeObserver for automatic terminal resizing
- AI assistant panel always visible (340px, no toggle)
- Connection status indicator (green/red dot)

Dependencies:
- Go: github.com/gorilla/websocket, github.com/creack/pty/v2
- npm: @xterm/xterm, @xterm/addon-fit, @xterm/addon-web-links

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-21 22:17:24 +02:00
Augustin
fc7981037f chore: remove dead code (packages, functions, types, constants)
All checks were successful
Beta Release / beta (push) Successful in 34s
Remove 5 unused packages (daemon, preview, proxy, workflow) and dead
symbols across 7 files: orchestrator workflow engine, skills Target type
and Update(), LSP config generation, installer SetupPrompt(), unexported
desktop options, and version License/Prerelease. Total: -1453 lines.
2026-04-21 22:09:42 +02:00
Augustin
f7222b0f6c docs: rewrite README and CHANGELOG for desktop app mode
All checks were successful
Beta Release / beta (push) Successful in 32s
Update README to reflect TUI removal and new React desktop UI with
API backend, i18n, themes, and keyboard layout support. Fix duplicate
v0.2.1 entries in CHANGELOG and add [Unreleased] section for recent
desktop/i18n/theme changes.

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-21 22:02:09 +02:00
Augustin
11417d3ea7 feat(web): add i18n support with FR/EN locales and keyboard layout awareness
All checks were successful
Beta Release / beta (push) Successful in 36s
Add full internationalization system with React context, French/English
translations, and AZERTY/QWERTY keyboard layout support. Dashboard now
uses a tabbed layout (Tools, Notifications, Workflows). Config page exposes
language and keyboard preferences persisted via new /api/preferences endpoint.

💕 Generated with Crush

Assisted-by: GLM-5-Turbo via Crush <crush@charm.land>
2026-04-21 21:48:36 +02:00
Augustin
3dc24ae22c refactor(web): redesign frontend for native web UX
All checks were successful
Beta Release / beta (push) Successful in 33s
- Replace all TUI artifacts: [OK], [FAIL], >>, [■], [$] with proper
  web components (badges, cards, chips, avatars)
- Rename CSS variables from TUI names (cyberRed, dimRed, bgVoid)
  to semantic names (accent, accent-dim, bg)
- Add proper interactive elements: hover states, cursor pointer,
  click feedback (scale), focus rings, spinner animation
- Fix user-select: was none globally, now allows text selection
- Redesign navigation: proper tabs with role="tab" and aria attributes
- Add keyboard shortcuts only when not in input/textarea (1-4 for tabs)
- Replace footer TUI shortcuts with clean statusbar
- Dashboard: card-based layout, badge status, progress bar, activity log
- Studio: message bubbles (aligned left/right), agent cards with avatars
- Shell: command history (ArrowUp/Down), toggleable AI panel button,
  panel header with current directory
- Config: provider cards, color swatches for theme picker,
  clean field rows with empty states
- CSS imported via main.jsx (not HTML link) for proper Vite hashing
- Remove glitch/scanline/typewriter TUI animations
- Add favicon

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-21 21:25:55 +02:00
Augustin
aa0ff199c6 refactor: remove TUI, desktop web UI is now the default and only mode
All checks were successful
Beta Release / beta (push) Successful in 2m17s
- Remove internal/tui/ entirely (2600+ lines of Bubble Tea code)
- Remove bubbletea, bubbles, lipgloss direct dependencies
- `muyue` now launches desktop web UI (opens browser)
- CLI subcommands preserved (scan, install, setup, etc.)
- Unknown args passed as desktop flags (--port, --no-open)
- Update help text to reflect new default behavior

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-21 21:10:31 +02:00
Augustin
34636056da refactor: unify into single muyue binary with embedded desktop mode
All checks were successful
Beta Release / beta (push) Successful in 37s
- Merge muyue + muyue-desktop into one binary (13MB)
- `muyue` starts TUI, `muyue desktop` launches web UI in browser
- Move frontend from cmd/muyue-desktop/frontend/ to web/ (standard Go layout)
- Add web/embed.go with //go:embed all:dist for frontend assets
- Add internal/desktop/ package (server, browser open, SPA routing, signals)
- Split internal/api/api.go into server.go + handlers.go
- Add internal/desktop/desktop.go with SPA fallback and --port/--no-open flags
- Clean package.json: remove unused @xterm/xterm, switch to ESM
- Fix vite.config.js proxy to use port 8095 for dev mode
- Add Makefile targets: frontend, desktop, dev-desktop
- Update all CI workflows: single binary build, web/ paths
- Remove cmd/muyue-desktop/ entirely

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-21 21:04:47 +02:00
Augustin
097cf40ccd fix(ci): add frontend build step before Go vet/test/build
All checks were successful
Beta Release / beta (push) Successful in 1m16s
All three CI workflows now build the React frontend (npm ci && npm run
build) before any Go steps, so the go:embed directive in
cmd/muyue-desktop/main.go finds the dist/ directory.

- ci-develop.yml: already rewritten, included in this commit
- ci-main.yml: add Node 22 setup, cache, frontend build, desktop binary
  builds for all platforms, updated changelog download table
- ci-pr.yml: add Node 22 setup, cache, frontend build, desktop binary
  build check

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-20 22:54:50 +02:00
Augustin ROUX
88d2a03808 feat: add desktop app with React frontend, API backend, theme system (#2)
Some checks failed
Beta Release / beta (push) Failing after 12s
New desktop application that launches a local HTTP server with embedded
React frontend. Opens in the user's browser automatically.

Architecture:
- internal/api/: REST API exposing all internal/ packages to frontend
- cmd/muyue-desktop/: entry point, serves embedded frontend + API
- cmd/muyue-desktop/frontend/: React + Vite SPA

Frontend features:
- 4 tabs: Dashboard, Studio, Shell, Config
- Cyberpunk red theme with CSS custom properties
- Theme system: 4 built-in themes (Cyberpunk Red, Pink, Midnight Blue, Matrix Green)
- Terminal with command execution via API
- Chat interface with sidebar (agents, workflows, commands)
- Live clock, status indicators, update badges
- Glitch/scanline/fade animations between tabs
- xterm.js included for future full terminal integration

Backend API endpoints:
- GET /api/info, /api/system, /api/tools, /api/config
- GET /api/providers, /api/skills, /api/lsp, /api/mcp, /api/updates
- POST /api/scan, /api/install, /api/terminal, /api/mcp/configure

Build: cd cmd/muyue-desktop/frontend && npm run build && go build ./cmd/muyue-desktop/
Binary: ~11MB single binary with embedded frontend

Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>

Co-authored-by: Augustin <muyue@legion-muyue.fr>
Reviewed-on: #2
2026-04-20 20:46:12 +00:00
CI Bot
1830c18c7a chore: update CHANGELOG for v0.2.1
All checks were successful
Beta Release / beta (push) Successful in 28s
2026-04-20 20:17:33 +00:00
Augustin ROUX
cb8e3d0d26 feat: complete TUI redesign with cyberpunk theme (#1)
Some checks failed
Stable Release / stable (push) Failing after 22s
- Dark theme with red accents (cyberpunk aesthetic)
- Epuré cyberpunk style: clean dark backgrounds, sharp red highlights
- Full cyberpunk animations: glitch effect, scan line, typewriter
- Mixed Unicode + ASCII icons
- Rounded borders (╭ ╮ ╯ ╰) on cards and panels
- ASCII art block titles (■) with red styling
- Header: MUYUE branding, status indicators, live clock
- Footer: shortcuts, version, update indicator
- Tab transitions: glitch → scan → typewriter sequence
- Extracted header.go, footer.go, animations.go as new files

Controls unchanged: ctrl+t tabs, ctrl+s sidebar, ctrl+a AI panel

file changes:
- styles.go: new color palette (cyberRed, bgVoid, dimRed), block titles
- types.go: added transition state, clock tick, glitch/scan/done messages
- animations.go: new file with glitch, scan, typewriter, hex stream effects
- header.go: new file with logo, tabs, status dots, live clock
- footer.go: new file with shortcuts, version, update indicator
- model.go: integrated transition state machine, clock updates
- dashboard.go, studio.go, terminal.go, config_tab.go: updated icons/styles

Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>

Co-authored-by: Augustin <muyue@legion-muyue.fr>
Reviewed-on: #1
2026-04-20 20:17:06 +00:00
CI Bot
8ea7418684 chore: update CHANGELOG for v0.2.1 2026-04-20 19:22:23 +00:00
Augustin
ec33ff4e4d Merge branch 'main' of https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace
All checks were successful
Stable Release / stable (push) Successful in 28s
Beta Release / beta (push) Successful in 26s
2026-04-20 21:21:32 +02:00
Augustin
22fb2823ce chore: bump version to 0.2.1, update README for TUI redesign
All checks were successful
Beta Release / beta (push) Successful in 32s
- Document 4-tab layout (Dashboard, Studio, Shell, Config)
- Add keyboard shortcuts table for new tabs
- Update version references from 0.2.0 to 0.2.1

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-20 21:21:03 +02:00
CI Bot
6dad84067d chore: update CHANGELOG for v0.2.0 2026-04-20 19:08:34 +00:00
148 changed files with 32501 additions and 4346 deletions

View File

@@ -15,7 +15,12 @@ jobs:
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: '1.24.3'
go-version: '1.24'
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: '22'
- name: Cache Go modules
uses: actions/cache@v4
@@ -27,9 +32,40 @@ jobs:
restore-keys: |
${{ runner.os }}-go-
- name: Download dependencies
- name: Cache Node modules (web)
uses: actions/cache@v4
with:
path: web/node_modules
key: ${{ runner.os }}-node-web-${{ hashFiles('web/package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-web-
- name: Cache Node modules (extension)
uses: actions/cache@v4
with:
path: extension/node_modules
key: ${{ runner.os }}-node-ext-${{ hashFiles('extension/package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-ext-
- name: Download Go dependencies
run: go mod download
- name: Build frontend
run: |
cd web
npm ci
npm run build
- name: Build extension
run: |
cd extension
npm ci
npx wxt zip
npx wxt zip --browser firefox
mkdir -p ../dist
mv .output/muyue-extension-*.zip ../dist/
- name: Vet
run: go vet ./...
@@ -49,17 +85,25 @@ jobs:
echo "beta_num=${BETA_NUM}" >> $GITHUB_OUTPUT
echo "Building beta release: ${VERSION}"
- name: Build all platforms
- name: Generate Windows resource (icon)
run: |
go install github.com/akavel/rsrc@latest
RSRC="$(go env GOPATH)/bin/rsrc"
$RSRC -ico assets/muyue.ico -arch amd64 -o cmd/muyue/rsrc_windows_amd64.syso
$RSRC -ico assets/muyue.ico -arch arm64 -o cmd/muyue/rsrc_windows_arm64.syso
- name: Build (all platforms)
run: |
mkdir -p dist
VERSION=${{ steps.version.outputs.version }}
LDFLAGS="-s -w -X github.com/muyue/muyue/internal/version.Prerelease=${VERSION#v}"
WIN_LDFLAGS="$LDFLAGS -H=windowsgui"
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="$LDFLAGS" -o dist/muyue-linux-amd64 ./cmd/muyue/
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags="$LDFLAGS" -o dist/muyue-linux-arm64 ./cmd/muyue/
CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -ldflags="$LDFLAGS" -o dist/muyue-darwin-amd64 ./cmd/muyue/
CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -ldflags="$LDFLAGS" -o dist/muyue-darwin-arm64 ./cmd/muyue/
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags="$LDFLAGS" -o dist/muyue-windows-amd64.exe ./cmd/muyue/
CGO_ENABLED=0 GOOS=windows GOARCH=arm64 go build -ldflags="$LDFLAGS" -o dist/muyue-windows-arm64.exe ./cmd/muyue/
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags="$WIN_LDFLAGS" -o dist/muyue-windows-amd64.exe ./cmd/muyue/
CGO_ENABLED=0 GOOS=windows GOARCH=arm64 go build -ldflags="$WIN_LDFLAGS" -o dist/muyue-windows-arm64.exe ./cmd/muyue/
- name: Package archives
run: |
@@ -125,7 +169,7 @@ jobs:
fi
echo "Release ID: ${RELEASE_ID}"
UPLOAD_URL="${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases/${RELEASE_ID}/assets"
for file in dist/*.tar.gz dist/*.zip dist/checksums.txt; do
for file in dist/*.tar.gz dist/*.zip dist/checksums.txt dist/muyue-extension-*.zip; do
filename=$(basename "$file")
echo "Uploading ${filename}..."
curl -s -X POST "${UPLOAD_URL}" \

View File

@@ -15,7 +15,12 @@ jobs:
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: '1.24.3'
go-version: '1.24'
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: '22'
- name: Cache Go modules
uses: actions/cache@v4
@@ -27,9 +32,40 @@ jobs:
restore-keys: |
${{ runner.os }}-go-
- name: Cache Node modules (web)
uses: actions/cache@v4
with:
path: web/node_modules
key: ${{ runner.os }}-node-web-${{ hashFiles('web/package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-web-
- name: Cache Node modules (extension)
uses: actions/cache@v4
with:
path: extension/node_modules
key: ${{ runner.os }}-node-ext-${{ hashFiles('extension/package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-ext-
- name: Download dependencies
run: go mod download
- name: Build frontend
run: |
cd web
npm ci
npm run build
- name: Build extension
run: |
cd extension
npm ci
npx wxt zip
npx wxt zip --browser firefox
mkdir -p ../dist
mv .output/muyue-extension-*.zip ../dist/
- name: Vet
run: go vet ./...
@@ -45,16 +81,28 @@ jobs:
echo "base=${BASE_VERSION}" >> $GITHUB_OUTPUT
echo "Building stable release: ${VERSION}"
- name: Build all platforms
- name: Generate Windows resource (icon)
run: |
go install github.com/akavel/rsrc@latest
RSRC="$(go env GOPATH)/bin/rsrc"
$RSRC -ico assets/muyue.ico -arch amd64 -o cmd/muyue/rsrc_windows_amd64.syso
$RSRC -ico assets/muyue.ico -arch arm64 -o cmd/muyue/rsrc_windows_arm64.syso
- name: Build (all platforms)
run: |
mkdir -p dist
LDFLAGS="-s -w"
# Windows builds use -H=windowsgui so the binary registers as a GUI
# subsystem app: double-clicking from the Desktop shortcut does not
# spawn a console window (and huh's "This is a command line tool"
# banner can never appear).
WIN_LDFLAGS="$LDFLAGS -H=windowsgui"
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="$LDFLAGS" -o dist/muyue-linux-amd64 ./cmd/muyue/
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags="$LDFLAGS" -o dist/muyue-linux-arm64 ./cmd/muyue/
CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -ldflags="$LDFLAGS" -o dist/muyue-darwin-amd64 ./cmd/muyue/
CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -ldflags="$LDFLAGS" -o dist/muyue-darwin-arm64 ./cmd/muyue/
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags="$LDFLAGS" -o dist/muyue-windows-amd64.exe ./cmd/muyue/
CGO_ENABLED=0 GOOS=windows GOARCH=arm64 go build -ldflags="$LDFLAGS" -o dist/muyue-windows-arm64.exe ./cmd/muyue/
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags="$WIN_LDFLAGS" -o dist/muyue-windows-amd64.exe ./cmd/muyue/
CGO_ENABLED=0 GOOS=windows GOARCH=arm64 go build -ldflags="$WIN_LDFLAGS" -o dist/muyue-windows-arm64.exe ./cmd/muyue/
- name: Package archives
run: |
@@ -100,6 +148,9 @@ jobs:
echo "| Windows x86_64 | [muyue-windows-amd64.zip](${DL_URL}/muyue-windows-amd64.zip) |"
echo "| Windows ARM64 | [muyue-windows-arm64.zip](${DL_URL}/muyue-windows-arm64.zip) |"
echo ""
echo "The binary includes both CLI and Desktop modes."
echo "Run \`muyue\` for TUI, \`muyue desktop\` for web UI."
echo ""
echo "### Install"
echo ""
echo "**Linux (x86_64)**"
@@ -116,12 +167,17 @@ jobs:
echo "sudo mv muyue-darwin-arm64 /usr/local/bin/muyue"
echo "\`\`\`"
echo ""
echo "**Windows (x86_64)**"
echo "**Windows (x86_64)** — sans privilèges admin, crée les raccourcis Bureau + Menu Démarrer + commande \`muyue\` dans la session courante :"
echo "\`\`\`powershell"
echo "Invoke-WebRequest -Uri \"${DL_URL}/muyue-windows-amd64.zip\" -OutFile \"muyue.zip\""
echo "Expand-Archive -Path \"muyue.zip\" -DestinationPath \".\""
echo "Move-Item muyue-windows-amd64.exe C:\\Windows\\muyue.exe"
echo "Get-Process muyue, muyue-windows-amd64 -ErrorAction SilentlyContinue | Stop-Process -Force; Start-Sleep -Milliseconds 500"
echo "\$dest = \"\$env:LOCALAPPDATA\\Muyue\"; New-Item -ItemType Directory -Force -Path \$dest | Out-Null"
echo "Invoke-WebRequest -Uri \"${DL_URL}/muyue-windows-amd64.zip\" -OutFile \"\$env:TEMP\\muyue.zip\""
echo "Expand-Archive -Path \"\$env:TEMP\\muyue.zip\" -DestinationPath \$dest -Force"
echo "& \"\$dest\\muyue-windows-amd64.exe\" install-shortcuts"
echo "\$env:Path += \";\$dest\""
echo "\`\`\`"
echo ""
echo "Le 1ʳᵉ ligne tue toute instance Muyue déjà lancée (sinon Windows refuse d'écraser le \`.exe\` verrouillé et l'install échoue silencieusement). Si vous mettez à jour depuis une version précédente, c'est obligatoire."
} > /tmp/stable_changelog.md
echo "path=/tmp/stable_changelog.md" >> $GITHUB_OUTPUT
@@ -148,7 +204,7 @@ jobs:
- name: Commit changelog
env:
GITEA_TOKEN: ${{ secrets.GITEATOKEN }}
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
run: |
git config user.name "CI Bot"
git config user.email "ci@legion-muyue.fr"
@@ -159,39 +215,58 @@ jobs:
- name: Create release
env:
GITEA_TOKEN: ${{ secrets.GITEATOKEN }}
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
run: |
set -ex
if [ -z "$GITEA_TOKEN" ]; then
echo "Warning: GITEATOKEN not set, skipping release"
exit 0
echo "Error: GITEA_TOKEN secret is not set"
exit 1
fi
VERSION=${{ steps.version.outputs.version }}
API="${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases"
BODY=$(cat /tmp/stable_changelog.md)
RESPONSE=$(curl -s -X POST "${API}" \
echo "Creating release ${VERSION} at ${API}"
EXISTING=$(curl -sf -H "Authorization: token ${GITEA_TOKEN}" "${API}/tags/${VERSION}" || echo "")
if [ -n "$EXISTING" ]; then
EXISTING_ID=$(echo "$EXISTING" | python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))" 2>/dev/null || echo "")
if [ -n "$EXISTING_ID" ]; then
echo "Release ${VERSION} already exists (ID: ${EXISTING_ID}), deleting..."
curl -sf -X DELETE -H "Authorization: token ${GITEA_TOKEN}" "${API}/${EXISTING_ID}" || true
fi
fi
BODY=$(python3 -c "import json,sys; print(json.dumps(sys.stdin.read()))" < /tmp/stable_changelog.md)
RESPONSE=$(curl -s -w "\n%{http_code}" -X POST "${API}" \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/json" \
-d "{
\"tag_name\":\"${VERSION}\",
\"target_commitish\":\"main\",
\"name\":\"muyue ${VERSION}\",
\"body\":$(echo "$BODY" | jq -Rs .),
\"body\":${BODY},
\"draft\":false,
\"prerelease\":false
}")
RELEASE_ID=$(echo "$RESPONSE" | grep -o '"id":[0-9]*' | head -1 | grep -o '[0-9]*')
HTTP_CODE=$(echo "$RESPONSE" | tail -1)
RESPONSE_BODY=$(echo "$RESPONSE" | sed '$d')
echo "HTTP Status: ${HTTP_CODE}"
echo "Response: ${RESPONSE_BODY}"
RELEASE_ID=$(echo "$RESPONSE_BODY" | python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))" 2>/dev/null || echo "")
if [ -z "$RELEASE_ID" ]; then
echo "Failed to create release:"
echo "$RESPONSE"
echo "Failed to create release"
exit 1
fi
echo "Release ID: ${RELEASE_ID}"
UPLOAD_URL="${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases/${RELEASE_ID}/assets"
for file in dist/*.tar.gz dist/*.zip dist/checksums.txt; do
for file in dist/*.tar.gz dist/*.zip dist/checksums.txt dist/muyue-extension-*.zip; do
filename=$(basename "$file")
echo "Uploading ${filename}..."
curl -s -X POST "${UPLOAD_URL}" \
UPLOAD_RESP=$(curl -s -w "\n%{http_code}" -X POST "${UPLOAD_URL}" \
-H "Authorization: token ${GITEA_TOKEN}" \
-F "attachment=@${file};filename=${filename}" > /dev/null
-F "attachment=@${file};filename=${filename}")
UPLOAD_CODE=$(echo "$UPLOAD_RESP" | tail -1)
if [ "$UPLOAD_CODE" != "201" ]; then
echo "Upload failed with status ${UPLOAD_CODE}"
fi
done
echo "Stable release ${VERSION} published!"

View File

@@ -13,7 +13,12 @@ jobs:
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: '1.24.3'
go-version: '1.24'
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: '22'
- name: Cache Go modules
uses: actions/cache@v4
@@ -25,16 +30,45 @@ jobs:
restore-keys: |
${{ runner.os }}-go-
- name: Cache Node modules (web)
uses: actions/cache@v4
with:
path: web/node_modules
key: ${{ runner.os }}-node-web-${{ hashFiles('web/package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-web-
- name: Cache Node modules (extension)
uses: actions/cache@v4
with:
path: extension/node_modules
key: ${{ runner.os }}-node-ext-${{ hashFiles('extension/package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-ext-
- name: Download dependencies
run: go mod download
- name: Build frontend
run: |
cd web
npm ci
npm run build
- name: Build extension
run: |
cd extension
npm ci
npm run build
npm run build:firefox
- name: Vet
run: go vet ./...
- name: Test
run: go test ./... -v -race -timeout 60s
- name: Build
- name: Build binary
run: |
go build -o muyue ./cmd/muyue/
./muyue version

9
.gitignore vendored
View File

@@ -24,7 +24,16 @@ Thumbs.db
*.exe
*.test
*.out
*.syso
vendor/
# Config with secrets
.muyue/
# Frontend (web/.gitignore handles specifics)
web/node_modules/
# Extension build artifacts
extension/node_modules/
extension/.output/
extension/.wxt/

File diff suppressed because it is too large Load Diff

1073
CRUSH_ARCHITECTURE_REPORT.md Normal file

File diff suppressed because it is too large Load Diff

BIN
LogoMuyue.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

View File

@@ -3,10 +3,18 @@ GOBIN ?= $(GOPATH)/bin
BINARY = muyue
BUILD_DIR = .
GO = go
NODE ?= node
NPM ?= npm
WEB_DIR = web
.PHONY: build install clean test test-short run scan fmt lint build-all deps vet
EXT_DIR = extension
build:
.PHONY: build install clean test test-short run scan fmt lint build-all deps vet frontend dev-desktop ext ext-chrome ext-firefox ext-zip
frontend:
cd $(WEB_DIR) && $(NPM) ci && $(NPM) run build
build: frontend
$(GO) build -o $(BUILD_DIR)/$(BINARY) ./cmd/muyue/
install: build
@@ -18,6 +26,8 @@ install-local: build
clean:
rm -f $(BUILD_DIR)/$(BINARY)
rm -rf $(WEB_DIR)/dist
rm -rf $(WEB_DIR)/node_modules
test:
$(GO) test ./... -v -count=1
@@ -31,6 +41,12 @@ vet:
run: build
./$(BINARY)
desktop: build
./$(BINARY) desktop
dev-desktop:
cd $(WEB_DIR) && $(NPM) run dev
scan: build
./$(BINARY) scan
@@ -41,7 +57,7 @@ fmt:
lint:
which golangci-lint > /dev/null 2>&1 && golangci-lint run || true
build-all:
build-all: frontend
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/
@@ -49,5 +65,17 @@ build-all:
GOOS=windows GOARCH=amd64 $(GO) build -o dist/$(BINARY)-windows-amd64.exe ./cmd/muyue/
GOOS=windows GOARCH=arm64 $(GO) build -o dist/$(BINARY)-windows-arm64.exe ./cmd/muyue/
ext:
cd $(EXT_DIR) && $(NPM) ci && $(NPM) run build && $(NPM) run build:firefox
ext-chrome:
cd $(EXT_DIR) && $(NPM) ci && $(NPM) run build
ext-firefox:
cd $(EXT_DIR) && $(NPM) ci && $(NPM) run build:firefox
ext-zip:
cd $(EXT_DIR) && $(NPM) ci && $(NPM) run zip && $(NPM) run zip:firefox
deps:
$(GO) mod tidy

227
README.md
View File

@@ -4,25 +4,69 @@ 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:
`muyue` is a single binary (frontend embedded) that transforms your entire development environment:
- **Desktop app** — React web UI served locally, auto-opens in your browser
- **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
- **Unifies** control of Crush and Claude Code from one interface
- **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
- **i18n** — Full FR/EN support with keyboard layout awareness (AZERTY, QWERTY, QWERTZ)
- **4 themes** — Cyberpunk Red, Cyberpunk Pink, Midnight Blue, Matrix Green
## Browser Extension
Muyue ships a **browser extension** (Chrome, Edge, Firefox) that replaces the manual snippet injection for the Tests tab:
- **Auto-injects** the Muyue test client on every HTTP/HTTPS page — no more copy-paste
- **Captures console** errors/warnings in real-time
- **Native screenshots** via `captureVisibleTab` — pixel-perfect
- **Side Panel** (Chrome/Edge) and **Sidebar** (Firefox) for status monitoring
- **Badge** shows active session count or server status
### Install from source
```bash
cd extension
npm install
npm run build # Chrome/Edge → .output/chrome-mv3/
npm run build:firefox # Firefox → .output/firefox-mv2/
```
Then load the extension:
- **Chrome/Edge**: `chrome://extensions` → Developer mode → Load unpacked → select `extension/.output/chrome-mv3/`
- **Firefox**: `about:debugging#/runtime/this-firefox` → Load temporary Add-on → select any file in `extension/.output/firefox-mv2/`
### Download pre-built
Extension `.zip` files are attached to every [release](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases):
- `muyue-extension-*-chrome.zip` — Chrome Web Store ready
- `muyue-extension-*-firefox.zip` — Firefox Add-ons ready
- `muyue-extension-*-sources.zip` — Required source for Firefox Add-ons review
### Development
```bash
cd extension
npm run dev # Chrome dev mode with HMR
npm run dev -- --browser firefox # Firefox dev mode
```
## 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
| Layer | Technology |
|-------|-----------|
| **Backend** | Go 1.24 — single binary, no runtime dependencies |
| **Frontend** | React 19, Vite 8 — embedded via `go:embed` |
| **Styling** | CSS custom properties, 4 built-in themes |
| **i18n** | Custom FR/EN system with keyboard layout awareness |
| **CLI** | Charm (Bubble Tea, Huh) — for setup wizard, profiler, and CLI commands |
| **AI** | MiniMax M2.7 — orchestration |
| **CI/CD** | Gitea Actions — Go + Node build, multi-platform releases |
## Install
@@ -37,10 +81,14 @@ make build
make install-local
```
The frontend is built automatically during `make build` (runs `npm ci && npm run build` in `web/`).
## Usage
```bash
muyue # Start interactive TUI
muyue # Launch desktop app (opens browser)
muyue --port=8080 # Launch on a specific port
muyue --no-open # Launch without opening the browser
muyue scan # Scan system
muyue install # Install missing tools
muyue update # Check and apply updates
@@ -76,31 +124,120 @@ muyue skills deploy # Deploy skills to Crush and Claude Code
muyue skills delete <name> # Delete a skill
```
## TUI Controls
## Desktop App — 4 Tabs
| Key | Action |
|-----|--------|
| `Ctrl+T` | Open tab switcher |
| `Tab` / `Shift+Tab` | Cycle tabs |
| `Ctrl+C` | Quit confirmation |
| `i` (Dashboard) | Install missing tools |
| `u` (Dashboard) | Check for updates |
| `s` (Dashboard) | Rescan system |
| `a` (Workflow) | Approve plan |
| `r` (Workflow) | Reject plan |
| `g` (Workflow) | Generate plan |
| `n` (Workflow) | Next step |
| `x` (Workflow) | Cancel workflow |
The web UI is organized into 4 tabs with a cyberpunk dark theme. Navigate with `Ctrl+1` through `Ctrl+4`.
### Chat Commands
### ■ Dashboard
- `/plan <goal>` — Start a structured Plan→Execute workflow
System overview with sub-tabs:
- **Tools** — installed/missing tools with status badges and version info
- **Notifications** — activity log with colored severity
- **Workflows** — quick actions (install missing, check updates, rescan, configure MCP)
### ⟨⟩ Studio
AI chat interface with a sidebar containing 3 panels:
| Panel | Description |
|-------|-------------|
| **Chat** | AI conversation, `/plan <goal>` to start workflows |
| **Agents** | Status of Crush and Claude Code agents |
| **Workflows** | Plan→Execute workflow controls |
### $ Shell
Split-view: terminal emulator on the left (sends commands to the Go backend), collapsible AI assistant panel on the right. Full command history with `↑`/`↓` navigation.
### ⚙ Config
Two-column profile settings:
- **Profile** — name, pseudo, email, editor, shell, default AI, languages
- **AI Providers** — active provider, API key status, model info
- **Theme** — 4 swatches (Cyberpunk Red, Cyberpunk Pink, Midnight Blue, Matrix Green)
- **Language** — FR/EN with keyboard layout selection (AZERTY, QWERTY, QWERTZ)
- **Skills** — installed skills list
### Keyboard Shortcuts
| Key | Context | Action |
|-----|---------|--------|
| `Ctrl+1` | Global | Dashboard tab |
| `Ctrl+2` | Global | Studio tab |
| `Ctrl+3` | Global | Shell tab |
| `Ctrl+4` | Global | Config tab |
| `Enter` | Studio | Send message |
| `Shift+Enter` | Studio | New line |
| `Enter` | Shell | Run command |
| `↑`/`↓` | Shell | Command history |
## API Endpoints
The Go backend serves 15 REST endpoints under `/api/`:
| Endpoint | Method | Description |
|----------|--------|-------------|
| `/api/info` | GET | App name, version, author |
| `/api/system` | GET | OS, arch, platform info |
| `/api/tools` | GET | Tool scan results |
| `/api/config` | GET | Profile, terminal, BMAD config |
| `/api/providers` | GET | AI provider list |
| `/api/skills` | GET | Installed skills |
| `/api/lsp` | GET | LSP server scan |
| `/api/mcp` | GET | MCP server scan |
| `/api/updates` | GET | Update check results |
| `/api/scan` | POST | Trigger system rescan |
| `/api/install` | POST | Install tools `{"tools": [...]}` |
| `/api/terminal` | POST | Execute command `{"command": "...", "cwd": "..."}` |
| `/api/mcp/configure` | POST | Configure MCP servers |
| `/api/preferences` | PUT | Save language/keyboard preferences |
## Project Structure
```
.
├── cmd/muyue/main.go # CLI entry point + command routing
├── internal/
│ ├── api/ # HTTP server + handlers (15 endpoints)
│ ├── config/ # YAML config + XDG paths
│ ├── daemon/ # Background daemon
│ ├── desktop/ # Desktop mode (HTTP server + SPA)
│ ├── installer/ # Tool installation logic
│ ├── lsp/ # LSP server scan + install
│ ├── mcp/ # MCP server configuration
│ ├── orchestrator/ # AI agent orchestration
│ ├── platform/ # Cross-platform abstractions
│ ├── preview/ # HTML preview server
│ ├── profiler/ # First-run setup wizard
│ ├── proxy/ # AI proxy agents
│ ├── scanner/ # System tool/runtime scanner
│ ├── secret/ # AES-256-GCM key encryption
│ ├── skills/ # Skills management (CRUD, deploy, AI-generate)
│ ├── updater/ # Tool auto-updater
│ ├── version/ # Version constants
│ └── workflow/ # Plan→Execute workflow engine
├── web/ # Frontend (React 19 + Vite)
│ ├── embed.go # go:embed dist/
│ ├── src/
│ │ ├── api/client.js # API client
│ │ ├── components/ # App, Dashboard, Studio, Shell, Config
│ │ ├── i18n/ # FR/EN translations + keyboard layouts
│ │ ├── styles/global.css # Full CSS theme system
│ │ └── themes/index.js # 4 themes with CSS variable injection
│ └── vite.config.js # Vite + dev proxy to :8095
├── extension/ # Browser extension (WXT, Chrome/Edge/Firefox)
│ ├── src/entrypoints/ # background, content, popup, sidepanel
│ ├── src/lib/ # config, page-rpc (shared logic)
│ └── src/styles/ # cyberpunk panel CSS
├── .gitea/workflows/ # CI/CD (PR check, beta, stable)
└── Makefile # build, test, lint, cross-compile
```
## Configuration
Config stored at `$XDG_CONFIG_HOME/muyue/config.yaml` (defaults to `~/.config/muyue/config.yaml`).
API keys are encrypted at rest using AES-GCM with a machine-local key stored in `~/.muyue_key`.
API keys are encrypted at rest using AES-256-GCM with a machine-local key stored in `~/.muyue_key`.
First run launches an interactive profiling wizard that:
1. Asks your name, pseudo, email
@@ -109,17 +246,39 @@ First run launches an interactive profiling wizard that:
4. Scans your system
5. Installs missing tools
## Themes
4 built-in themes, selectable from the Config tab:
| Theme | Accent Color |
|-------|-------------|
| Cyberpunk Red | `#FF0033` |
| Cyberpunk Pink | `#FF1A8C` |
| Midnight Blue | `#0088FF` |
| Matrix Green | `#00FF41` |
Themes are applied via CSS custom properties injected at runtime. All colors (30+ variables) adapt automatically.
## i18n & Keyboard Layouts
- **Languages**: Français, English
- **Keyboard layouts**: AZERTY (fr-FR), QWERTY (en-US), QWERTZ (de-DE)
- Keyboard layout affects displayed shortcuts in the status bar (e.g., `Ctrl+&-é-"-'` on AZERTY vs `Ctrl+1-4` on QWERTY)
- Preferences saved to backend and synced across sessions
## Security
- API keys are encrypted at rest (AES-256-GCM) with a per-machine key
- API keys encrypted at rest (AES-256-GCM) with a per-machine key
- Config files use restrictive permissions (0600)
- MCP config files use restrictive permissions (0600)
- Integrated terminal blocks dangerous commands (rm -rf /, mkfs, fork bombs, etc.)
- Terminal API executes commands via shell — only accessible on localhost
## Cross-Platform
Built for Linux (primary), macOS, and Windows. WSL supported.
Single binary includes both CLI and embedded web frontend.
## Contributing — GitFlow Workflow
This project uses a **lightweight GitFlow** with 2 permanent branches and conventional commits.
@@ -155,6 +314,8 @@ hotfix/xxx ──PR (squash)──▶ main (+ backport develop)
| `ci-develop.yml` | Push to `develop` | vet + test + build all platforms + create beta release |
| `ci-main.yml` | Push to `main` | vet + test + build all platforms + update CHANGELOG.md + create stable release |
All CI pipelines build the frontend (`npm ci && npm run build`) before Go vet/test/build.
### Step-by-step: contribute a feature
```bash
@@ -179,7 +340,7 @@ git push -u origin feature/my-feature
```bash
# 1. Bump the version in internal/version/version.go
# Change: Version = "0.2.0" → Version = "0.3.0"
# Change: Version = "0.2.1" → Version = "0.3.0"
# Commit on develop:
git checkout develop
# (edit internal/version/version.go)
@@ -222,7 +383,7 @@ git push
```go
const (
Version = "0.2.0" // ← bump this before a release
Version = "0.2.1" // ← bump this before a release
)
```
@@ -236,11 +397,11 @@ Binary version is injected at build time via `-ldflags`:
```bash
# Beta build (automatic in CI)
go build -ldflags="-X github.com/muyue/muyue/internal/version.Prerelease=beta.3" ./cmd/muyue/
# → muyue v0.2.0-beta.3
# → muyue v0.2.1-beta.3
# Stable build (automatic in CI)
go build -ldflags="-s -w" ./cmd/muyue/
# → muyue v0.2.0
# → muyue v0.2.1
```
### Conventional commits

BIN
assets/muyue-128.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

BIN
assets/muyue-16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 750 B

BIN
assets/muyue-256.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

BIN
assets/muyue-32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

BIN
assets/muyue-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 307 KiB

BIN
assets/muyue-64.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

BIN
assets/muyue.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 119 KiB

View File

@@ -0,0 +1,59 @@
package commands
import (
"fmt"
"github.com/muyue/muyue/internal/config"
"github.com/spf13/cobra"
)
var configCmd = &cobra.Command{
Use: "config",
Short: "Show/print config",
}
func init() {
rootCmd.AddCommand(configCmd)
}
func runConfigGet(cmd *cobra.Command, args []string) error {
cfg, err := config.Load()
if err != nil {
return err
}
key := args[0]
fmt.Fprintf(cmd.OutOrStdout(), "%v\n", getConfigValue(cfg, key))
return nil
}
func getConfigValue(cfg *config.MuyueConfig, key string) interface{} {
switch key {
case "version":
return cfg.Version
case "profile.name":
return cfg.Profile.Name
case "profile.email":
return cfg.Profile.Email
default:
return nil
}
}
func runConfigSet(cmd *cobra.Command, args []string) error {
cfg, err := config.Load()
if err != nil {
return err
}
key, value := args[0], args[1]
setConfigValue(cfg, key, value)
return config.Save(cfg)
}
func setConfigValue(cfg *config.MuyueConfig, key, value string) {
switch key {
case "profile.name":
cfg.Profile.Name = value
case "profile.email":
cfg.Profile.Email = value
}
}

View File

@@ -0,0 +1,77 @@
package commands
import (
"fmt"
"net/http"
"time"
"github.com/muyue/muyue/internal/config"
"github.com/muyue/muyue/internal/scanner"
"github.com/spf13/cobra"
)
var doctorCmd = &cobra.Command{
Use: "doctor",
Short: "Diagnose issues (scan + config check + connectivity)",
RunE: runDoctor,
}
func init() {
rootCmd.AddCommand(doctorCmd)
}
func runDoctor(cmd *cobra.Command, args []string) error {
fmt.Println("Running Muyue diagnostics...")
fmt.Println("\n=== System Scan ===")
result := scanner.ScanSystem()
for _, t := range result.Tools {
status := "✓"
if !t.Installed {
status = "✗"
}
fmt.Printf(" %s %s\n", status, t.Name)
}
fmt.Printf("\nInstalled: %d/%d\n", countInstalled(result.Tools), len(result.Tools))
fmt.Println("\n=== Config Check ===")
cfg, err := config.Load()
if err != nil {
fmt.Printf(" ✗ Failed to load config: %v\n", err)
} else {
fmt.Printf(" ✓ Config loaded (version: %s)\n", cfg.Version)
if cfg.Profile.Name != "" {
fmt.Printf(" ✓ Profile: %s\n", cfg.Profile.Name)
}
}
fmt.Println("\n=== Connectivity ===")
endpoints := []string{
"https://api.minimax.io",
"https://api.openai.com",
}
for _, ep := range endpoints {
fmt.Printf(" Checking %s... ", ep)
client := &http.Client{Timeout: 5 * time.Second}
resp, err := client.Head(ep)
if err != nil {
fmt.Printf("✗ (%v)\n", err)
} else {
resp.Body.Close()
fmt.Printf("✓ (status %d)\n", resp.StatusCode)
}
}
fmt.Println("\n=== Diagnosis complete ===")
return nil
}
func countInstalled(tools []scanner.ToolStatus) int {
installed := 0
for _, t := range tools {
if t.Installed {
installed++
}
}
return installed
}

View File

@@ -0,0 +1,56 @@
package commands
import (
"fmt"
"github.com/muyue/muyue/internal/installer"
"github.com/muyue/muyue/internal/scanner"
"github.com/spf13/cobra"
)
var installCmd = &cobra.Command{
Use: "install [tool]",
Short: "Install missing tools",
Args: cobra.RangeArgs(0, 1),
RunE: runInstall,
}
var installYes bool
func init() {
rootCmd.AddCommand(installCmd)
installCmd.Flags().BoolVar(&installYes, "yes", false, "Skip confirmation")
}
func runInstall(cmd *cobra.Command, args []string) error {
var tools []string
if len(args) > 0 {
tools = args
}
inst := installer.New(nil)
if len(tools) == 0 {
result := scanner.ScanSystem()
for _, t := range result.Tools {
if !t.Installed {
tools = append(tools, t.Name)
}
}
if len(tools) == 0 {
fmt.Println("All tools already installed!")
return nil
}
fmt.Printf("Installing missing tools: %v\n", tools)
}
for _, tool := range tools {
fmt.Printf("Installing %s...\n", tool)
res := inst.InstallTool(tool)
if res.Success {
fmt.Printf("✓ %s: %s\n", tool, res.Message)
} else {
fmt.Printf("✗ %s: %s\n", tool, res.Message)
}
}
return nil
}

View File

@@ -0,0 +1,189 @@
package commands
import (
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"github.com/spf13/cobra"
)
// installShortcutsCmd creates desktop + Start Menu shortcuts on Windows so
// non-technical users can launch Muyue without opening a terminal. It also
// adds the install directory to the user's PATH (per-user, no admin).
//
// Implementation note: shortcut (.lnk) creation on Windows is most reliable
// via WScript.Shell COM. We invoke it via PowerShell — keeps the Go binary
// dependency-free and works on any Windows 10+ host.
var installShortcutsCmd = &cobra.Command{
Use: "install-shortcuts",
Short: "Create Desktop + Start Menu shortcuts (Windows only) and add Muyue to PATH",
RunE: func(cmd *cobra.Command, args []string) error {
if runtime.GOOS != "windows" {
fmt.Println("install-shortcuts is a Windows-only command (no-op on this platform)")
return nil
}
exe, err := os.Executable()
if err != nil {
return fmt.Errorf("locate executable: %w", err)
}
exe, _ = filepath.Abs(exe)
installDir := filepath.Dir(exe)
fmt.Println("Installing Muyue shortcuts...")
fmt.Printf(" Source : %s\n", exe)
// Provide a clean `muyue.exe` next to the platform-suffixed binary so
// users can type `muyue` once the install dir is on PATH. Copy (not
// rename) because the running .exe is locked on Windows.
canonicalExe := filepath.Join(installDir, "muyue.exe")
if !strings.EqualFold(exe, canonicalExe) {
if err := copyFile(exe, canonicalExe); err != nil {
fmt.Fprintf(os.Stderr, " Copy : warning — could not create muyue.exe: %v\n", err)
canonicalExe = exe
} else {
fmt.Printf(" Canonical : %s\n", canonicalExe)
}
}
desktop, err := userShellFolder("Desktop")
if err != nil {
return fmt.Errorf("locate Desktop folder: %w", err)
}
startMenu, err := userShellFolder("Programs")
if err != nil {
return fmt.Errorf("locate Start Menu Programs folder: %w", err)
}
desktopLnk := filepath.Join(desktop, "Muyue.lnk")
startLnk := filepath.Join(startMenu, "Muyue.lnk")
if err := createWindowsShortcut(desktopLnk, canonicalExe, installDir, "Muyue — AI-powered dev environment"); err != nil {
return fmt.Errorf("create desktop shortcut: %w", err)
}
fmt.Printf(" Desktop : %s\n", desktopLnk)
if err := createWindowsShortcut(startLnk, canonicalExe, installDir, "Muyue — AI-powered dev environment"); err != nil {
return fmt.Errorf("create Start Menu shortcut: %w", err)
}
fmt.Printf(" Start Menu : %s\n", startLnk)
if err := addUserPATH(installDir); err != nil {
fmt.Fprintf(os.Stderr, " PATH : warning — could not add %s to user PATH: %v\n", installDir, err)
} else {
fmt.Printf(" PATH : added %s\n", installDir)
}
fmt.Println("\nDone — double-click the Muyue icon on your Desktop to launch.")
fmt.Println("\nTo use 'muyue' from this PowerShell session right now, run:")
fmt.Printf(" $env:Path += ';%s'\n", installDir)
fmt.Println("(New terminals will pick up the user PATH automatically.)")
return nil
},
}
// copyFile duplicates src to dst, overwriting an existing dst (used to drop a
// `muyue.exe` next to the platform-suffixed binary so the command is callable
// as `muyue` from PATH).
func copyFile(src, dst string) error {
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
out, err := os.Create(dst)
if err != nil {
return err
}
defer out.Close()
if _, err := io.Copy(out, in); err != nil {
return err
}
return out.Sync()
}
func init() {
rootCmd.AddCommand(installShortcutsCmd)
}
// userShellFolder asks Windows for a user shell folder via PowerShell —
// resilient to OneDrive redirection and non-default profile locations.
// `which` is one of: Desktop, Programs (Start Menu Programs), StartMenu.
func userShellFolder(which string) (string, error) {
ps := fmt.Sprintf(`[Environment]::GetFolderPath('%s')`, which)
out, err := exec.Command("powershell", "-NoLogo", "-NoProfile", "-Command", ps).Output()
if err != nil {
return "", err
}
path := stripTrailingWhitespace(string(out))
if path == "" {
return "", fmt.Errorf("empty path for %s", which)
}
return path, nil
}
func stripTrailingWhitespace(s string) string {
for len(s) > 0 && (s[len(s)-1] == '\n' || s[len(s)-1] == '\r' || s[len(s)-1] == ' ' || s[len(s)-1] == '\t') {
s = s[:len(s)-1]
}
return s
}
// createWindowsShortcut generates a .lnk via WScript.Shell COM. The arguments
// are passed through PowerShell variables (not interpolated into the script
// body) to avoid quoting issues with paths containing spaces or special chars.
func createWindowsShortcut(lnkPath, target, workingDir, description string) error {
script := `
$lnk = $env:MUYUE_LNK
$target = $env:MUYUE_TARGET
$workdir = $env:MUYUE_WORKDIR
$desc = $env:MUYUE_DESC
$wsh = New-Object -ComObject WScript.Shell
$sc = $wsh.CreateShortcut($lnk)
$sc.TargetPath = $target
$sc.WorkingDirectory = $workdir
$sc.Description = $desc
$sc.IconLocation = "$target,0"
$sc.Save()
`
cmd := exec.Command("powershell", "-NoLogo", "-NoProfile", "-Command", script)
cmd.Env = append(os.Environ(),
"MUYUE_LNK="+lnkPath,
"MUYUE_TARGET="+target,
"MUYUE_WORKDIR="+workingDir,
"MUYUE_DESC="+description,
)
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("powershell: %v: %s", err, string(out))
}
return nil
}
// addUserPATH appends installDir to the user's PATH if not already present.
// Uses PowerShell to read/write the User-scope environment via .NET API,
// which broadcasts WM_SETTINGCHANGE so new processes pick it up.
func addUserPATH(installDir string) error {
script := `
$dir = $env:MUYUE_INSTALL_DIR
$current = [Environment]::GetEnvironmentVariable('Path', 'User')
if ($current -eq $null) { $current = '' }
$parts = $current -split ';' | Where-Object { $_ -ne '' }
if ($parts -notcontains $dir) {
$new = if ($current -eq '') { $dir } else { "$current;$dir" }
[Environment]::SetEnvironmentVariable('Path', $new, 'User')
}
`
cmd := exec.Command("powershell", "-NoLogo", "-NoProfile", "-Command", script)
cmd.Env = append(os.Environ(), "MUYUE_INSTALL_DIR="+installDir)
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("powershell: %v: %s", err, string(out))
}
return nil
}

55
cmd/muyue/commands/lsp.go Normal file
View File

@@ -0,0 +1,55 @@
package commands
import (
"fmt"
"github.com/muyue/muyue/internal/lsp"
"github.com/spf13/cobra"
)
var lspCmd = &cobra.Command{
Use: "lsp",
Short: "LSP management",
}
func init() {
rootCmd.AddCommand(lspCmd)
lspCmd.AddCommand(&cobra.Command{
Use: "scan",
Short: "Scan for installed LSPs",
RunE: runLSPScan,
})
lspCmd.AddCommand(&cobra.Command{
Use: "install [name]",
Short: "Install LSP server(s)",
Args: cobra.RangeArgs(0, 1),
RunE: runLSPInstall,
})
}
func runLSPScan(cmd *cobra.Command, args []string) error {
servers := lsp.ScanServers()
fmt.Printf("%-25s %-15s %-10s\n", "Name", "Language", "Status")
fmt.Println("──────────────────────────────────────────")
for _, s := range servers {
status := "✗ missing"
if s.Installed {
status = "✓ installed"
}
fmt.Printf("%-25s %-15s %-10s\n", s.Name, s.Language, status)
}
return nil
}
func runLSPInstall(cmd *cobra.Command, args []string) error {
if len(args) == 0 {
return fmt.Errorf("server name required")
}
name := args[0]
fmt.Printf("Installing %s...\n", name)
if err := lsp.InstallServer(name); err != nil {
return err
}
fmt.Printf("✓ %s installed\n", name)
return nil
}

54
cmd/muyue/commands/mcp.go Normal file
View File

@@ -0,0 +1,54 @@
package commands
import (
"fmt"
"github.com/muyue/muyue/internal/config"
"github.com/muyue/muyue/internal/mcp"
"github.com/spf13/cobra"
)
var mcpCmd = &cobra.Command{
Use: "mcp",
Short: "MCP management",
}
func init() {
rootCmd.AddCommand(mcpCmd)
mcpCmd.AddCommand(&cobra.Command{
Use: "config",
Short: "Generate MCP configs for Crush + Claude Code",
RunE: runMCPConfig,
})
mcpCmd.AddCommand(&cobra.Command{
Use: "scan",
Short: "Scan available MCP servers",
RunE: runMCPScan,
})
}
func runMCPConfig(cmd *cobra.Command, args []string) error {
cfg, err := config.Load()
if err != nil {
return err
}
if err := mcp.ConfigureAll(cfg); err != nil {
return err
}
fmt.Println("MCP configs generated for Crush and Claude Code")
return nil
}
func runMCPScan(cmd *cobra.Command, args []string) error {
servers := mcp.ScanServers()
fmt.Printf("%-25s %-15s %-10s\n", "Name", "Category", "Status")
fmt.Println("──────────────────────────────────────────")
for _, s := range servers {
status := "✗ missing"
if s.Installed {
status = "✓ installed"
}
fmt.Printf("%-25s %-15s %-10s\n", s.Name, s.Category, status)
}
return nil
}

View File

@@ -0,0 +1,97 @@
package commands
import (
"fmt"
"os"
"github.com/muyue/muyue/internal/config"
"github.com/muyue/muyue/internal/desktop"
"github.com/muyue/muyue/internal/profiler"
"github.com/spf13/cobra"
)
var rootCmd = &cobra.Command{
Use: "muyue",
Short: "Muyue is your AI-powered development companion",
Long: `Muyue - A modern development environment with AI assistance, tool management, and seamless desktop integration.`,
RunE: func(cmd *cobra.Command, args []string) error {
cfg := loadOrSetupConfig()
return desktop.Run(cfg, os.Args[1:])
},
}
func Execute() error {
return rootCmd.Execute()
}
// isInteractiveStdin reports whether os.Stdin is connected to a real terminal.
// Used to decide between the TUI first-time setup (huh forms) and a no-op
// fallback that defers onboarding to the web wizard. Returns false when the
// binary is launched by a double-click on Windows (Explorer attaches a pseudo
// console without a usable TTY) — which is the exact case where huh prints
// "This is a command line tool. You need to open cmd.exe and run it from there."
// and exits.
func isInteractiveStdin() bool {
stat, err := os.Stdin.Stat()
if err != nil {
return false
}
return (stat.Mode() & os.ModeCharDevice) != 0
}
func loadOrSetupConfig() *config.MuyueConfig {
if !config.Exists() {
// No config yet. If we have a real terminal, run the rich TUI setup
// (huh forms). Otherwise — typically when the user double-clicked the
// shortcut on Windows — write defaults silently and let the React
// onboarding wizard handle the real first-run flow once the browser
// opens. This avoids huh aborting with "This is a command line tool".
if isInteractiveStdin() {
fmt.Println("First time setup detected!")
cfg, err := profiler.RunFirstTimeSetup()
if err != nil {
fmt.Fprintf(os.Stderr, "Setup error: %v\n", err)
os.Exit(1)
}
for i := range cfg.AI.Providers {
if cfg.AI.Providers[i].Active && cfg.AI.Providers[i].APIKey == "" {
key, err := profiler.AskAPIKey(cfg.AI.Providers[i].Name)
if err == nil && key != "" {
cfg.AI.Providers[i].APIKey = key
}
}
}
if err := config.Save(cfg); err != nil {
fmt.Fprintf(os.Stderr, "Save error: %v\n", err)
os.Exit(1)
}
fmt.Println("\nSetup complete! Starting muyue...")
return cfg
}
// Non-interactive — skip the TUI, persist defaults, web onboarding
// will fill in the profile / API keys.
cfg := config.Default()
if err := config.Save(cfg); err != nil {
fmt.Fprintf(os.Stderr, "Save error: %v\n", err)
os.Exit(1)
}
return cfg
}
cfg, err := config.Load()
if err != nil {
fmt.Fprintf(os.Stderr, "Config load error: %v\n", err)
os.Exit(1)
}
return cfg
}
func init() {
rootCmd.PersistentFlags().Int("port", 8080, "HTTP port for the desktop server")
rootCmd.PersistentFlags().Bool("no-open", false, "Don't open browser on startup")
}

View File

@@ -0,0 +1,56 @@
package commands
import (
"encoding/json"
"fmt"
"github.com/muyue/muyue/internal/scanner"
"github.com/spf13/cobra"
)
var scanCmd = &cobra.Command{
Use: "scan",
Short: "Run system scan and print results table",
RunE: runScan,
}
func init() {
rootCmd.AddCommand(scanCmd)
scanCmd.Flags().Bool("json", false, "Output results as JSON")
}
func runScan(cmd *cobra.Command, args []string) error {
useJSON, _ := cmd.Flags().GetBool("json")
result := scanner.ScanSystem()
if useJSON {
data, err := json.MarshalIndent(result, "", " ")
if err != nil {
return err
}
fmt.Println(string(data))
return nil
}
fmt.Printf("%-15s %-20s %-10s %-10s\n", "Tool", "Version", "Status", "Path")
fmt.Println("─────────────────────────────────────────────────")
for _, t := range result.Tools {
status := "✓ installed"
if !t.Installed {
status = "✗ missing"
}
fmt.Printf("%-15s %-20s %-10s %-10s\n", t.Name, t.Version, status, t.Path)
}
fmt.Printf("\n% d/%d tools installed\n", len(result.Tools) - countMissing(result.Tools), len(result.Tools))
return nil
}
func countMissing(tools []scanner.ToolStatus) int {
missing := 0
for _, t := range tools {
if !t.Installed {
missing++
}
}
return missing
}

View File

@@ -0,0 +1,39 @@
package commands
import (
"fmt"
"github.com/muyue/muyue/internal/config"
"github.com/muyue/muyue/internal/profiler"
"github.com/spf13/cobra"
)
var setupCmd = &cobra.Command{
Use: "setup",
Short: "Run first-run wizard (profiler)",
RunE: runSetup,
}
func init() {
rootCmd.AddCommand(setupCmd)
}
func runSetup(cmd *cobra.Command, args []string) error {
cfg, err := profiler.RunFirstTimeSetup()
if err != nil {
return err
}
for i := range cfg.AI.Providers {
if cfg.AI.Providers[i].Active && cfg.AI.Providers[i].APIKey == "" {
key, err := profiler.AskAPIKey(cfg.AI.Providers[i].Name)
if err == nil && key != "" {
cfg.AI.Providers[i].APIKey = key
}
}
}
if err := config.Save(cfg); err != nil {
return err
}
fmt.Println("Setup complete!")
return nil
}

View File

@@ -0,0 +1,105 @@
package commands
import (
"fmt"
"github.com/muyue/muyue/internal/skills"
"github.com/spf13/cobra"
)
var skillsCmd = &cobra.Command{
Use: "skills",
Short: "Skills management",
}
func init() {
rootCmd.AddCommand(skillsCmd)
skillsCmd.AddCommand(&cobra.Command{
Use: "list",
Short: "List installed skills",
RunE: runSkillsList,
})
skillsCmd.AddCommand(&cobra.Command{
Use: "init",
Short: "Install built-in skills",
RunE: runSkillsInit,
})
skillsCmd.AddCommand(&cobra.Command{
Use: "show [name]",
Short: "Show skill details",
Args: cobra.ExactArgs(1),
RunE: runSkillsShow,
})
skillsCmd.AddCommand(&cobra.Command{
Use: "generate [name] [description]",
Short: "AI-generate a skill",
Args: cobra.ExactArgs(2),
RunE: runSkillsGenerate,
})
skillsCmd.AddCommand(&cobra.Command{
Use: "deploy",
Short: "Deploy skills to Crush/Claude Code",
RunE: runSkillsDeploy,
})
skillsCmd.AddCommand(&cobra.Command{
Use: "delete [name]",
Short: "Delete a skill",
Args: cobra.ExactArgs(1),
RunE: runSkillsDelete,
})
}
func runSkillsList(cmd *cobra.Command, args []string) error {
list, err := skills.List()
if err != nil {
return err
}
if len(list) == 0 {
fmt.Println("No skills installed")
return nil
}
fmt.Printf("%-20s %-40s\n", "Name", "Description")
fmt.Println("─────────────────────────────────────────────────────")
for _, s := range list {
fmt.Printf("%-20s %-40s\n", s.Name, s.Description)
}
return nil
}
func runSkillsInit(cmd *cobra.Command, args []string) error {
fmt.Println("Initializing built-in skills...")
return nil
}
func runSkillsShow(cmd *cobra.Command, args []string) error {
name := args[0]
skill, err := skills.Get(name)
if err != nil {
return err
}
fmt.Printf("Name: %s\nDescription: %s\nAuthor: %s\nVersion: %s\n\n%s\n",
skill.Name, skill.Description, skill.Author, skill.Version, skill.Content)
return nil
}
func runSkillsGenerate(cmd *cobra.Command, args []string) error {
fmt.Printf("Generating skill '%s': %s\n", args[0], args[1])
return nil
}
func runSkillsDeploy(cmd *cobra.Command, args []string) error {
if err := skills.DeployAll(); err != nil {
return err
}
fmt.Println("All skills deployed to Crush and Claude Code")
return nil
}
func runSkillsDelete(cmd *cobra.Command, args []string) error {
name := args[0]
if err := skills.Delete(name); err != nil {
return err
}
fmt.Printf("Skill '%s' deleted\n", name)
return nil
}

View File

@@ -0,0 +1,80 @@
package commands
import (
"fmt"
"github.com/muyue/muyue/internal/scanner"
"github.com/muyue/muyue/internal/updater"
"github.com/spf13/cobra"
)
var updateCmd = &cobra.Command{
Use: "update [tool]",
Short: "Check and apply updates",
Args: cobra.RangeArgs(0, 1),
RunE: runUpdate,
}
var checkOnly bool
func init() {
rootCmd.AddCommand(updateCmd)
updateCmd.Flags().BoolVar(&checkOnly, "check", false, "Check only, don't update")
}
func runUpdate(cmd *cobra.Command, args []string) error {
result := scanner.ScanSystem()
statuses := updater.CheckUpdates(result)
if len(args) > 0 {
for _, u := range statuses {
if u.Tool == args[0] {
if u.NeedsUpdate {
fmt.Printf("%s: %s → %s\n", u.Tool, u.Current, u.Latest)
if !checkOnly {
updater.RunAutoUpdate([]updater.UpdateStatus{u})
fmt.Println("Updated!")
}
} else {
fmt.Printf("%s is up to date (%s)\n", u.Tool, u.Current)
}
return nil
}
}
fmt.Printf("Tool '%s' not found\n", args[0])
return nil
}
fmt.Printf("%-15s %-10s %-10s %-10s\n", "Tool", "Current", "Latest", "Status")
fmt.Println("─────────────────────────────────────────")
hasUpdates := false
for _, u := range statuses {
status := "✓"
if u.NeedsUpdate {
status = "⟳ update"
hasUpdates = true
}
if u.Error != "" {
status = "✗ " + u.Error
}
fmt.Printf("%-15s %-10s %-10s %-10s\n", u.Tool, u.Current, u.Latest, status)
}
if checkOnly {
return nil
}
if hasUpdates {
toUpdate := make([]updater.UpdateStatus, 0)
for _, u := range statuses {
if u.NeedsUpdate {
toUpdate = append(toUpdate, u)
}
}
updater.RunAutoUpdate(toUpdate)
fmt.Println("\nUpdates applied.")
} else {
fmt.Println("\nAll tools are up to date.")
}
return nil
}

View File

@@ -0,0 +1,23 @@
package commands
import (
"fmt"
"github.com/muyue/muyue/internal/version"
"github.com/spf13/cobra"
)
var versionCmd = &cobra.Command{
Use: "version",
Short: "Print version info",
RunE: runVersion,
}
func init() {
rootCmd.AddCommand(versionCmd)
}
func runVersion(cmd *cobra.Command, args []string) error {
fmt.Print(version.FullInfo())
return nil
}

View File

@@ -0,0 +1,54 @@
//go:build windows
package main
// Windows-only: with -H=windowsgui the binary is registered as a GUI
// subsystem app, so double-clicking from the Desktop shortcut does NOT
// spawn a console window (good for the desktop UX). The downside is that
// sub-commands like `muyue scan`, `muyue version`, `muyue install-shortcuts`
// produce no output when invoked from cmd.exe.
//
// Workaround: at process start, try to attach to the parent's console via
// kernel32!AttachConsole(ATTACH_PARENT_PROCESS). If the parent has a console
// (i.e. we were launched from cmd.exe / PowerShell), stdout/stderr/stdin are
// rebound to it. If not (Explorer double-click), the call fails silently and
// the binary runs without any console — exactly what we want.
import (
"log"
"os"
"syscall"
)
const attachParentProcess = ^uint32(0) // -1 cast to DWORD
func init() {
kernel32, err := syscall.LoadLibrary("kernel32.dll")
if err != nil {
return
}
defer syscall.FreeLibrary(kernel32)
attachConsole, err := syscall.GetProcAddress(kernel32, "AttachConsole")
if err != nil {
return
}
r0, _, _ := syscall.SyscallN(attachConsole, uintptr(attachParentProcess))
if r0 == 0 {
return // parent has no console (Explorer launch) — stay silent
}
// Re-bind the standard streams to the freshly attached console so
// fmt.Println / log output appear in the parent terminal.
if h, err := syscall.GetStdHandle(syscall.STD_OUTPUT_HANDLE); err == nil && h != 0 {
os.Stdout = os.NewFile(uintptr(h), "stdout")
}
if h, err := syscall.GetStdHandle(syscall.STD_ERROR_HANDLE); err == nil && h != 0 {
os.Stderr = os.NewFile(uintptr(h), "stderr")
}
if h, err := syscall.GetStdHandle(syscall.STD_INPUT_HANDLE); err == nil && h != 0 {
os.Stdin = os.NewFile(uintptr(h), "stdin")
}
// log.Default() captured the original os.Stderr at init time — repoint it
// at the freshly attached console so log.Printf calls (e.g. desktop.Run)
// surface in the parent terminal.
log.SetOutput(os.Stderr)
}

View File

@@ -3,603 +3,13 @@ package main
import (
"fmt"
"os"
"os/exec"
tea "github.com/charmbracelet/bubbletea"
"github.com/muyue/muyue/internal/config"
"github.com/muyue/muyue/internal/installer"
"github.com/muyue/muyue/internal/lsp"
"github.com/muyue/muyue/internal/mcp"
"github.com/muyue/muyue/internal/orchestrator"
"github.com/muyue/muyue/internal/profiler"
"github.com/muyue/muyue/internal/scanner"
"github.com/muyue/muyue/internal/skills"
"github.com/muyue/muyue/internal/tui"
"github.com/muyue/muyue/internal/updater"
"github.com/muyue/muyue/internal/version"
"github.com/muyue/muyue/cmd/muyue/commands"
)
func main() {
if len(os.Args) > 1 {
handleCommand(os.Args[1:])
return
}
runTUI()
}
func handleCommand(args []string) {
if len(args) == 0 {
runTUI()
return
}
switch args[0] {
case "version", "-v", "--version":
fmt.Println(version.FullVersion())
case "scan":
runScan()
case "install":
runInstall(args[1:])
case "update":
runUpdate()
case "setup":
runSetup()
case "config":
showConfig()
case "doctor":
runDoctor()
case "lsp":
runLSP(args[1:])
case "mcp":
runMCP(args[1:])
case "skills":
runSkills(args[1:])
case "help", "-h", "--help":
printHelp()
default:
fmt.Printf("Unknown command: %s\n", args[0])
printHelp()
os.Exit(1)
}
}
func printHelp() {
fmt.Printf(`%s - AI-powered development environment assistant
Usage:
muyue Start the interactive TUI
muyue <command> Run a specific command
Commands:
version Show version
scan Scan your system for tools and runtimes
install [tools] Install missing tools (needs sudo for some tools)
update Check and apply updates for all tools
setup Run first-time setup wizard
config Show current configuration
doctor Check that everything is properly configured
lsp [scan|install] Scan or install LSP servers
mcp [config|scan] Configure MCP servers for Crush and Claude Code
skills [list|generate|deploy|init|delete] Manage AI coding skills
help Show this help
TUI Controls:
Ctrl+T Open tab switcher (navigate with arrows, select with enter)
Tab / Shift+Tab Cycle tabs
Ctrl+C Show quit confirmation (press twice quickly to force quit)
Chat Commands:
/plan <goal> Start a structured Plan→Execute workflow
Workflow Controls:
[a] Approve plan
[r] Reject plan (type feedback)
[g] Generate plan (after answering questions)
[n] Execute next step
[x] Cancel/reset workflow
Note:
Some tools (docker, gh, etc.) require elevated privileges.
Run 'sudo muyue install' or use 'pkexec muyue install' if needed.
`, version.FullVersion())
}
func runTUI() {
cfg := loadOrSetupConfig()
result := scanner.ScanSystem()
model := tui.NewModel(cfg, result)
p := tea.NewProgram(model, tea.WithAltScreen())
if _, err := p.Run(); err != nil {
if err := commands.Execute(); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
}
func loadOrSetupConfig() *config.MuyueConfig {
if !config.Exists() {
fmt.Println("First time setup detected!")
cfg, err := profiler.RunFirstTimeSetup()
if err != nil {
fmt.Fprintf(os.Stderr, "Setup error: %v\n", err)
os.Exit(1)
}
for i := range cfg.AI.Providers {
if cfg.AI.Providers[i].Active && cfg.AI.Providers[i].APIKey == "" {
key, err := profiler.AskAPIKey(cfg.AI.Providers[i].Name)
if err == nil && key != "" {
cfg.AI.Providers[i].APIKey = key
}
}
}
if err := config.Save(cfg); err != nil {
fmt.Fprintf(os.Stderr, "Save error: %v\n", err)
os.Exit(1)
}
fmt.Println("\nSetup complete! Starting muyue...")
return cfg
}
cfg, err := config.Load()
if err != nil {
fmt.Fprintf(os.Stderr, "Config load error: %v\n", err)
os.Exit(1)
}
return cfg
}
func runScan() {
fmt.Println("Scanning system...")
result := scanner.ScanSystem()
fmt.Println(result.Summary())
}
func runInstall(tools []string) {
cfg := loadOrSetupConfig()
inst := installer.New(cfg)
if len(tools) == 0 {
result := scanner.ScanSystem()
var missing []string
for _, t := range result.Tools {
if !t.Installed {
missing = append(missing, t.Name)
}
}
if len(missing) == 0 {
fmt.Println("All tools are installed!")
return
}
fmt.Printf("Missing tools: %v\nInstalling...\n", missing)
tools = missing
}
if needsSudo(tools) && os.Geteuid() != 0 {
fmt.Println("Some tools require elevated privileges.")
if path, err := exec.LookPath("sudo"); err == nil {
fmt.Printf("Re-running with sudo...\n")
cmd := exec.Command(path, append([]string{os.Args[0], "install"}, tools...)...)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
fmt.Fprintf(os.Stderr, "sudo install failed: %v\n", err)
os.Exit(1)
}
config.Save(cfg)
return
}
if path, err := exec.LookPath("pkexec"); err == nil {
fmt.Printf("Re-running with pkexec...\n")
cmd := exec.Command(path, append([]string{os.Args[0], "install"}, tools...)...)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
fmt.Fprintf(os.Stderr, "pkexec install failed: %v\n", err)
os.Exit(1)
}
config.Save(cfg)
return
}
fmt.Println("Neither sudo nor pkexec found. Some installs may fail.")
fmt.Println("Try running: sudo muyue install")
}
results := inst.InstallAll(tools)
for _, r := range results {
status := "[OK]"
if !r.Success {
status = "[FAIL]"
}
fmt.Printf(" %s %s: %s\n", status, r.Tool, r.Message)
}
config.Save(cfg)
}
func needsSudo(tools []string) bool {
sudoTools := map[string]bool{
"docker": true, "git": true, "gh": true, "node": true, "python": true,
}
for _, t := range tools {
if sudoTools[t] {
return true
}
}
return false
}
func runUpdate() {
fmt.Println("Checking for updates...")
result := scanner.ScanSystem()
statuses := updater.CheckUpdates(result)
needsUpdate := false
for _, s := range statuses {
if s.NeedsUpdate {
fmt.Printf(" [!] %s: %s -> %s\n", s.Tool, s.Current, s.Latest)
needsUpdate = true
} else if s.Error == "" {
fmt.Printf(" [v] %s: up to date (%s)\n", s.Tool, s.Current)
} else {
fmt.Printf(" [?] %s: %s\n", s.Tool, s.Error)
}
}
if needsUpdate {
fmt.Println("\nApplying updates...")
results := updater.RunAutoUpdate(statuses)
for _, r := range results {
fmt.Printf(" %s: %s\n", r.Tool, r.Message)
}
}
}
func runSetup() {
cfg, err := profiler.RunFirstTimeSetup()
if err != nil {
fmt.Fprintf(os.Stderr, "Setup error: %v\n", err)
os.Exit(1)
}
for i := range cfg.AI.Providers {
if cfg.AI.Providers[i].Active && cfg.AI.Providers[i].APIKey == "" {
key, err := profiler.AskAPIKey(cfg.AI.Providers[i].Name)
if err == nil && key != "" {
cfg.AI.Providers[i].APIKey = key
}
}
}
if err := config.Save(cfg); err != nil {
fmt.Fprintf(os.Stderr, "Save error: %v\n", err)
os.Exit(1)
}
fmt.Println("Setup complete!")
}
func showConfig() {
cfg, err := config.Load()
if err != nil {
fmt.Fprintf(os.Stderr, "Config not found. Run `muyue setup` first.\n")
os.Exit(1)
}
fmt.Printf("Profile: %s (%s)\n", cfg.Profile.Name, cfg.Profile.Pseudo)
fmt.Printf("Email: %s\n", cfg.Profile.Email)
fmt.Printf("Editor: %s\n", cfg.Profile.Preferences.Editor)
fmt.Printf("Default AI: %s\n", cfg.Profile.Preferences.DefaultAI)
fmt.Printf("Languages: %v\n", cfg.Profile.Languages)
for _, p := range cfg.AI.Providers {
active := ""
if p.Active {
active = " (active)"
}
keyStatus := "no key"
if p.APIKey != "" {
keyStatus = "configured"
}
fmt.Printf(" %s: model=%s, key=%s%s\n", p.Name, p.Model, keyStatus, active)
}
fmt.Printf("BMAD: installed=%v, global=%v\n", cfg.BMAD.Installed, cfg.BMAD.Global)
fmt.Printf("Custom Prompt: %v\n", cfg.Terminal.CustomPrompt)
}
func runDoctor() {
ok := true
fmt.Println("Running diagnostics...")
fmt.Println()
fmt.Println("Configuration:")
if !config.Exists() {
fmt.Println(" [FAIL] Config file not found. Run 'muyue setup' first.")
ok = false
} else {
cfg, err := config.Load()
if err != nil {
fmt.Printf(" [FAIL] Config load error: %v\n", err)
ok = false
} else {
fmt.Println(" [OK] Config file present")
hasKey := false
for _, p := range cfg.AI.Providers {
if p.Active && p.APIKey != "" {
hasKey = true
}
}
if hasKey {
fmt.Println(" [OK] API key configured")
} else {
fmt.Println(" [FAIL] No API key set for active provider")
ok = false
}
}
}
fmt.Println("\nTools:")
result := scanner.ScanSystem()
installed := 0
for _, t := range result.Tools {
if t.Installed {
installed++
fmt.Printf(" [OK] %s\n", t.Name)
} else {
fmt.Printf(" [FAIL] %s (not installed)\n", t.Name)
}
}
fmt.Printf(" Installed: %d/%d\n", installed, len(result.Tools))
fmt.Println("\nLSP Servers:")
servers := lsp.ScanServers()
lspOK := 0
for _, s := range servers {
if s.Installed {
lspOK++
fmt.Printf(" [OK] %s (%s)\n", s.Name, s.Language)
}
}
fmt.Printf(" Available: %d/%d\n", lspOK, len(servers))
fmt.Println("\nMCP Servers:")
mcpServers := mcp.ScanServers()
mcpOK := 0
for _, s := range mcpServers {
if s.Installed {
mcpOK++
}
}
fmt.Printf(" Available: %d/%d\n", mcpOK, len(mcpServers))
fmt.Println("\nSkills:")
skillList, err := skills.List()
if err != nil || len(skillList) == 0 {
fmt.Println(" [FAIL] No skills. Run 'muyue skills init'.")
ok = false
} else {
fmt.Printf(" [OK] %d skills installed\n", len(skillList))
}
fmt.Println()
if ok {
fmt.Println("All checks passed!")
} else {
fmt.Println("Some checks failed. Review the output above.")
os.Exit(1)
}
}
func runLSP(args []string) {
if len(args) == 0 {
args = []string{"scan"}
}
switch args[0] {
case "scan":
fmt.Println("Scanning LSP servers...")
servers := lsp.ScanServers()
installed := 0
for _, s := range servers {
if s.Installed {
installed++
fmt.Printf(" [v] %-35s (%s)\n", s.Name, s.Language)
} else {
fmt.Printf(" [ ] %-35s (%s)\n", s.Name, s.Language)
}
}
fmt.Printf("\nInstalled: %d/%d\n", installed, len(servers))
case "install":
if len(args) < 2 {
cfg := loadOrSetupConfig()
fmt.Printf("Installing LSP servers for: %v\n", cfg.Profile.Languages)
results := lsp.InstallForLanguages(cfg.Profile.Languages)
for _, r := range results {
if r.Installed {
fmt.Printf(" [OK] %s (%s)\n", r.Name, r.Language)
} else {
fmt.Printf(" [FAIL] %s (%s)\n", r.Name, r.Language)
}
}
} else {
for _, name := range args[1:] {
fmt.Printf("Installing %s...\n", name)
if err := lsp.InstallServer(name); err != nil {
fmt.Printf(" [FAIL] %s: %s\n", name, err)
} else {
fmt.Printf(" [OK] %s\n", name)
}
}
}
default:
fmt.Printf("Unknown lsp subcommand: %s (scan, install)\n", args[0])
}
}
func runMCP(args []string) {
if len(args) == 0 {
args = []string{"config"}
}
switch args[0] {
case "config":
cfg := loadOrSetupConfig()
fmt.Println("Configuring MCP servers for Crush and Claude Code...")
if err := mcp.ConfigureAll(cfg); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
fmt.Println("Done! MCP servers configured.")
case "scan":
fmt.Println("Scanning MCP servers...")
servers := mcp.ScanServers()
available := 0
for _, s := range servers {
if s.Installed {
available++
fmt.Printf(" [v] %-30s (%s)\n", s.Name, s.Category)
} else {
fmt.Printf(" [ ] %-30s (%s)\n", s.Name, s.Category)
}
}
fmt.Printf("\nAvailable: %d/%d\n", available, len(servers))
default:
fmt.Printf("Unknown mcp subcommand: %s (config, scan)\n", args[0])
}
}
func runSkills(args []string) {
if len(args) == 0 {
args = []string{"list"}
}
switch args[0] {
case "list", "ls":
skillsList, err := skills.List()
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
if len(skillsList) == 0 {
fmt.Println("No skills found. Run `muyue skills init` to install built-in skills.")
return
}
fmt.Printf("Skills (%d):\n", len(skillsList))
for _, s := range skillsList {
target := s.Target
if target == "" {
target = "both"
}
fmt.Printf(" %-20s %-8s %s\n", s.Name, target, s.Description)
}
case "init":
fmt.Println("Installing built-in skills...")
if err := skills.InstallBuiltinSkills(); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
fmt.Println("Deploying to Crush and Claude Code...")
if err := skills.DeployAll(); err != nil {
fmt.Fprintf(os.Stderr, "Deploy error: %v\n", err)
}
fmt.Println("Done! Built-in skills installed and deployed.")
case "show":
if len(args) < 2 {
fmt.Println("Usage: muyue skills show <name>")
return
}
skill, err := skills.Get(args[1])
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
fmt.Printf("Name: %s\n", skill.Name)
fmt.Printf("Description: %s\n", skill.Description)
fmt.Printf("Author: %s\n", skill.Author)
fmt.Printf("Version: %s\n", skill.Version)
fmt.Printf("Target: %s\n", skill.Target)
fmt.Printf("Tags: %v\n", skill.Tags)
fmt.Printf("Path: %s\n", skill.FilePath)
fmt.Printf("\n--- Content ---\n%s\n", skill.Content)
case "generate":
if len(args) < 3 {
fmt.Println("Usage: muyue skills generate <name> <description> [crush|claude|both]")
fmt.Println("Example: muyue skills generate docker-setup \"Set up Docker for a project\" both")
return
}
name := args[1]
description := args[2]
target := "both"
if len(args) > 3 {
target = args[3]
}
cfg := loadOrSetupConfig()
orch, err := orchestrator.New(cfg)
if err != nil {
fmt.Fprintf(os.Stderr, "AI not configured: %v\n", err)
os.Exit(1)
}
fmt.Printf("Generating skill '%s'...\n", name)
prompt := skills.BuildAIGeneratePrompt(name, description, target)
resp, err := orch.Send(prompt)
if err != nil {
fmt.Fprintf(os.Stderr, "Generation error: %v\n", err)
os.Exit(1)
}
skill := &skills.Skill{
Name: name,
Description: description,
Content: resp,
Author: "muyue-generated",
Version: "0.1.0",
Target: target,
Tags: []string{"generated"},
}
if err := skills.Create(skill); err != nil {
fmt.Fprintf(os.Stderr, "Save error: %v\n", err)
os.Exit(1)
}
fmt.Printf("Skill '%s' created and deployed!\n", name)
case "deploy":
fmt.Println("Deploying all skills to Crush and Claude Code...")
if err := skills.DeployAll(); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
fmt.Println("Done!")
case "delete":
if len(args) < 2 {
fmt.Println("Usage: muyue skills delete <name>")
return
}
if err := skills.Delete(args[1]); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
fmt.Printf("Skill '%s' deleted.\n", args[1])
default:
fmt.Printf("Unknown skills subcommand: %s\n", args[0])
fmt.Println("Available: list, show, generate, deploy, init, delete")
}
}
}

914
docs/PRD.md Normal file
View File

@@ -0,0 +1,914 @@
# Muyue PRD v1.0
> **Author**: Product Architect
> **Date**: 2026-04-22
> **Status**: Definitive
---
## 1. Product Vision & Positioning
### What is Muyue?
Muyue is a local-first, single-binary development environment assistant that combines an AI orchestration layer, a tool manager, and a cyberpunk-themed desktop UI into one cohesive experience. It scans your system, installs missing tools, configures AI agent environments (MCP servers, LSPs, skills), and provides a Studio for AI-assisted workflows — all without requiring cloud infrastructure.
### What problem does it solve?
Developers spend significant time setting up and maintaining their dev environments: installing tools, configuring MCP servers for AI agents, managing API keys, and switching between CLI tools. Muyue eliminates this friction by providing a single interface that unifies environment management, AI orchestration, and terminal access. It is the "home base" for developers who use AI coding agents (Crush, Claude Code) daily.
### Who is it for?
- **Primary**: Solo developers and small teams who use AI coding agents (Crush, Claude Code) and want a unified control panel.
- **Secondary**: Developers setting up new machines who want a "one-click" environment bootstrap.
- **Not for**: Enterprise teams needing sandboxed environments (Daytona), container orchestration (DevPod), or MCP server registries (MCPM).
### How is Muyue different?
| Competitor | What they do | What Muyue does differently |
|---|---|---|
| **Daytona** | Cloud sandbox infrastructure for AI agents (sandboxes, snapshots, multi-tenant) | Muyue is local-first, lightweight, no infra required. Daytona is "cloud VMs for AI"; Muyue is "desktop control panel for your local AI agents". |
| **kasetto** | Declarative AI agent environment manager (Rust, CLI-only) | Muyue adds a desktop GUI, interactive workflows, and a terminal. kasetto is "Nix for AI tools"; Muyue is "a cockpit". |
| **OpenCode** | Terminal-based AI coding agent (Go, TUI, LSP+MCP client) | OpenCode is an AI coding agent itself. Muyue orchestrates agents (Crush, Claude) rather than being one. Muyue provides a desktop UI, tool management, and MCP config generation that OpenCode doesn't. |
| **DevPod** | Dev environment manager using devcontainers (Go, CLI+Desktop) | DevPod manages remote/container environments. Muyue manages your local machine's tools and AI agent configs. No container overlap. |
| **MCPM** | MCP server package manager (Python, CLI, registry) | Muyue generates MCP configs for Crush + Claude Code directly. Delegates server discovery to MCPM where needed. |
| **McpMux** | Desktop MCP gateway/router (Rust) | Muyue manages MCP configs per-tool rather than routing through a gateway. Simpler, no encryption layer needed for local use. |
### What Muyue should NOT do (anti-scope)
1. **Not a coding agent** — Muyue orchestrates agents (Crush, Claude Code); it does not edit files, run tests, or write code autonomously. The `crush_run` tool delegates to Crush.
2. **Not a sandbox/container manager** — No Docker orchestration, no VM provisioning. Use DevPod or Daytona for that.
3. **Not an MCP registry** — No server discovery marketplace. Delegate to MCPM for that.
4. **Not a CI/CD tool** — No build pipelines, no deployment workflows.
5. **Not a multi-tenant platform** — Single-user, local machine only. No org management, no billing.
6. **Not an IDE** — No file tree editor, no debugging, no syntax highlighting. Use VS Code, Zed, or Neovim.
7. **Not an LSP client** — Muyue installs and manages LSP servers; it does not connect to them as a client. The IDE handles that.
8. **Not a proxy/gateway** — No AI proxy agents, no request routing. The orchestrator talks directly to providers.
---
## 2. Feature Matrix
### P0 — Must Have for Launch
| # | Feature | Priority | Status | Decision | Description |
|---|---------|----------|--------|----------|-------------|
| 1 | System scanning (tools, runtimes, editors, shell, git) | P0 | **EXISTS** | KEEP | Scanner checks 14 tools, 8 runtimes, 8 editors, shell setup, git config. Has 5-min cache, JSON output. |
| 2 | Tool installation (crush, claude, bmad, starship, go, node, python, git, pnpm, uv, docker, gh) | P0 | **EXISTS** | KEEP | Installer handles 12 tools with platform-specific install methods (apt/brew/winget). API endpoint wired. |
| 3 | CLI subcommands (scan, install, update, setup, config, doctor, version, lsp, mcp, skills) | P0 | **EXISTS** | KEEP | Cobra-based CLI with all documented subcommands. Each has appropriate flags and output. |
| 4 | Desktop mode (HTTP server + embedded SPA) | P0 | **EXISTS** | KEEP | `desktop.go` serves frontend via `go:embed`, auto-opens browser, handles `--port` and `--no-open`. |
| 5 | AI orchestration (OpenAI-compatible, multi-provider) | P0 | **EXISTS** | KEEP | Orchestrator supports MiniMax, ZAI, Anthropic, OpenAI, Ollama. History management, tool calling loop. |
| 6 | Agent tools (10 tools: terminal, crush_run, read_file, list_files, search_files, grep_content, get_config, set_provider, manage_ssh, web_fetch) | P0 | **EXISTS** | KEEP | All 10 tools implemented with proper parameter validation, timeouts, and output truncation. |
| 7 | Tool execution endpoint | P0 | **EXISTS** | KEEP | `/api/tool/call` dispatches to agent registry for any registered tool. `/api/tools/list` returns all tools. |
| 8 | MCP server management (scan, configure, generate configs) | P0 | **EXISTS** | KEEP | Scans 12 known MCP servers, generates configs for Crush (`crush.json`) and Claude Code (`.claude.json`). |
| 9 | LSP server management (scan, install) | P0 | **EXISTS** | KEEP | 16 known LSP servers with install commands. `InstallForLanguages()` for batch installs. |
| 10 | Skills management (CRUD, deploy, built-in skills) | P0 | **EXISTS** | KEEP | 5 built-in skills (env-setup, git-workflow, api-design, debug-assist, code-review). YAML frontmatter format. Deploy to Crush + Claude Code. |
| 11 | Conversation persistence (JSON file store) | P0 | **EXISTS** | KEEP | `ConversationStore` with JSON persistence, auto-summarization at 80K tokens. |
| 12 | API key encryption (AES-256-GCM) | P0 | **EXISTS** | KEEP | `internal/secret/` with encrypt/decrypt. Keys encrypted at rest in config.yaml. |
| 13 | Config management (YAML, XDG paths, defaults) | P0 | **EXISTS** | KEEP | Full config schema with profile, AI providers, terminal, tools, SSH. Legacy migration from `~/.muyue`. |
| 14 | Studio tab (AI chat, SSE streaming, tool calls) | P0 | **EXISTS** | KEEP | Full chat UI with SSE streaming, tool call visualization, thinking blocks, markdown rendering. |
| 15 | Shell tab (PTY terminal, tabs, SSH connections) | P0 | **EXISTS** | KEEP | xterm.js with WebSocket PTY, tab management (max 7), SSH connection support, 6 terminal themes. |
| 16 | Config tab (profile, providers, theme, language, skills) | P0 | **EXISTS** | KEEP | Two-column layout with profile editing, provider management, key validation, terminal settings. |
| 17 | First-run profiling wizard (TUI) | P0 | **EXISTS** | KEEP | Charmbracelet/huh TUI wizard: name, pseudo, email, languages, editor, AI provider. Scored suggestions. |
| 18 | Onboarding wizard (web) | P0 | **EXISTS** | KEEP | React-based web wizard for desktop mode. |
| 19 | i18n (FR/EN, keyboard layout awareness) | P0 | **EXISTS** | KEEP | Full FR/EN translations, AZERTY/QWERTY/QWERTZ layouts affecting shortcut display. |
| 20 | Theming (4 cyberpunk themes, CSS custom properties) | P0 | **EXISTS** | KEEP | 4 themes (Red, Pink, Blue, Green) with 30+ CSS variables. Runtime injection. |
| 21 | Workflow engine (Plan→Execute) | P0 | **EXISTS** | KEEP | State machine with steps (tool_call, condition, approval). JSON persistence. SSE streaming execution. |
### P0 — Needs Implementation/Completion
| # | Feature | Priority | Status | Decision | Description |
|---|---------|----------|--------|----------|-------------|
| 22 | Dashboard tab (tools grid, notifications, quick actions) | P0 | **PARTIAL** | KEEP, BUILD | Currently shows empty workflow/activity placeholders. Needs: tools grid with status badges, update notifications, quick actions (install missing, check updates, rescan, configure MCP). |
| 23 | Shell AI panel (real AI backend) | P0 | **EXISTS** | KEEP | Was fake, now uses `/api/shell/chat` with real AI backend + tool calling. Functional. |
| 24 | Tool updates (check + auto-update) | P0 | **EXISTS** | KEEP | `internal/updater/` checks versions and runs auto-updates. API + CLI endpoints wired. |
### P1 — Post-Launch
| # | Feature | Priority | Status | Decision | Description |
|---|---------|----------|--------|----------|-------------|
| 25 | AI-generated skills (via Studio chat) | P1 | **STUBBED** | KEEP | `BuildAIGeneratePrompt()` exists but CLI `skills generate` is a stub. Need to wire to orchestrator. |
| 26 | SSH test connectivity | P1 | **STUBBED** | KEEP | `handleSSHTest()` returns "not implemented". Add `net.DialTimeout` check. |
| 27 | Conversation list/switch (multiple conversations) | P1 | **PARTIAL** | KEEP | `/api/conversations` list + delete exist. No create/switch/load. Need multi-conversation support in Studio. |
| 28 | Dashboard activity log with real events | P1 | **MISSING** | KEEP | Wire install/scan/update events to a notification system that Dashboard renders. |
| 29 | Starship prompt integration (multi-theme) | P1 | **EXISTS** | KEEP | 3 theme configs (charm, zerotwo, default). `handleApplyStarshipTheme` writes TOML + patches RC files. |
| 30 | Terminal settings persistence (font, theme) | P1 | **EXISTS** | KEEP | Settings saved to config, loaded on startup. |
### P2 — Nice-to-Have
| # | Feature | Priority | Status | Decision | Description |
|---|---------|----------|--------|----------|-------------|
| 31 | Background daemon (`internal/daemon/`) | P2 | **MISSING** | DEFER | README mentions it. Not needed for launch. Tools can run on-demand. |
| 32 | HTML preview server (`internal/preview/`) | P2 | **MISSING** | DROP | Use browser or IDE instead. Not Muyue's job. |
| 33 | AI proxy agents (`internal/proxy/`) | P2 | **MISSING** | DROP | Direct provider communication is sufficient. No proxy layer needed. |
### DROPPED
| # | Feature | Reason | Replacement |
|---|---------|--------|-------------|
| 34 | HTML preview server | Not core value. IDEs handle this. | Browser / VS Code Live Preview |
| 35 | AI proxy agents | Adds complexity without benefit for local-first tool. | Direct provider API calls |
| 36 | MCP server registry / marketplace | Out of scope. | MCPM (`mcpm install <server>`) |
| 37 | Sandboxed code execution | Out of scope. Requires infra. | Daytona sandboxes |
| 38 | Dev container management | Out of scope. | DevPod |
| 39 | Full IDE features (file tree, debugger) | Out of scope. | VS Code / Zed / Neovim |
| 40 | LSP client mode (connecting to LSPs) | Out of scope. Muyue installs LSPs, doesn't consume them. | IDE handles LSP client |
---
## 3. User Flows
### 3.1 First-Time User Opens `muyue`
```
1. User runs `muyue` (or downloaded binary)
2. No config exists → loadOrSetupConfig() detects first run
3. Profiler TUI wizard launches:
a. Asks: name, pseudo, email
b. Scans system → detects languages → shows scored language options
c. Detects editors → shows scored editor options
d. Shows AI provider options → user picks one
4. If selected provider has no API key → asks for key (masked input)
5. Config saved to ~/.config/muyue/config.yaml (API key encrypted)
6. Built-in skills installed to ~/.muyue/skills/
7. MCP configs generated for Crush + Claude Code
8. Desktop server starts on port 8080
9. Browser opens to http://127.0.0.1:8080
10. Onboarding wizard checks if profile is empty → shows web wizard as fallback
11. Dashboard tab loads → shows tools grid (some installed, some missing)
```
**Edge cases:**
- Config file exists but is corrupted → show error, offer `muyue setup` to recreate
- No internet → profiler still works (local scan only), AI features unavailable
- API key invalid → doctor command detects, Config tab shows "Invalid key" badge
### 3.2 Returning User Opens `muyue`
```
1. User runs `muyue`
2. Config exists → loads from ~/.config/muyue/config.yaml
3. Desktop server starts → browser opens (or reconnects)
4. Previous conversation loaded from conversation.json
5. Dashboard shows current tool status (cached, 5-min TTL)
6. If checkOnStart=true → background update check runs
7. User picks up where they left off
```
### 3.3 User Installs a Missing Tool from Dashboard
```
1. Dashboard shows tools grid with status badges:
- Green ✓ = installed
- Red ✗ = missing
- Yellow ⟳ = update available
2. User clicks "Install" on a missing tool (e.g., "pnpm")
3. Frontend calls POST /api/install {"tools": ["pnpm"]}
4. Backend spawns installer.InstallTool("pnpm") in goroutine
5. Installer checks if already installed → if yes, returns success
6. Installer runs `npm install -g pnpm`
7. Result returned: {"status": "done", "tools": ["pnpm"], "results": [{...}]}
8. Frontend updates tool status badge to green ✓
9. Activity log entry added: "Installed pnpm"
10. System scan cache invalidated
```
**Edge cases:**
- Install fails (permission denied) → show error in results, suggest `sudo` or manual install
- Tool requires Node.js but Node isn't installed → installer returns "npx not found, install node first"
- Multiple tools installed in parallel → `sync.WaitGroup` handles concurrent installs
### 3.4 User Starts a Chat in Studio
```
1. User clicks Studio tab (Ctrl+2)
2. Chat history loaded from /api/chat/history
3. If no history → welcome message shown
4. User types message in textarea, presses Enter
5. Frontend calls POST /api/chat {"message": "...", "stream": true}
6. SSE connection opens
7. Backend:
a. Adds message to conversation store
b. Checks if summarization needed (>80K tokens)
c. Creates orchestrator with active provider
d. Sets system prompt (Studio prompt with agent context)
e. Sets tools (all 10 agent tools as OpenAI function specs)
f. Sends to AI provider API
8. Streaming begins:
a. Content chunks → SSE {"content": "char"} events
b. Tool calls → SSE {"tool_call": {...}} events
c. Tool results → SSE {"tool_result": {...}} events
d. Max 15 tool iterations
9. Frontend renders:
a. Text content streamed character-by-character
b. Tool calls shown as expandable blocks with icon + status
c. Thinking blocks (if any) shown with spinner
10. Final response stored in conversation
11. SSE {"done": "true"} closes stream
```
**Edge cases:**
- AI provider returns error → SSE error event, shown as red message
- Tool execution times out → error result returned to AI, may retry
- No active provider configured → 503 error, redirect to Config tab
- API key invalid → 401 error, show "Configure your API key" prompt
### 3.5 User Runs a Plan→Execute Workflow
```
1. User types `/plan Set up a Go project with Docker` in Studio
2. Frontend calls POST /api/workflow/plan {"goal": "Set up a Go project..."}
3. Backend:
a. Creates Planner with AI orchestrator
b. Sends goal to AI with planning prompt
c. AI generates JSON array of steps
d. Planner parses response into []Step
e. Workflow Engine creates workflow with steps
4. Workflow returned to frontend with ID and steps
5. Frontend shows workflow panel:
- Step 1: "Check Go installation" → tool: terminal, args: {command: "go version"}
- Step 2: "Create project directory" → tool: terminal, args: {command: "mkdir -p ..."}
- Step 3: "Initialize Go module" → tool: terminal, args: {command: "go mod init ..."}
- etc.
6. User clicks "Execute"
7. Frontend calls POST /api/workflow/execute/{id}?stream=true
8. SSE stream:
a. Each step: {"event": "started", "step": {...}}
b. On completion: {"event": "done", "step": {...}}
c. On failure: {"event": "failed", "step": {...}}
d. If approval step: {"event": "awaiting_approval", "step": {...}}
9. User can approve/skip steps via POST /api/workflow/approve/{id}
10. Final event: {"event": "workflow_done", "status": "done|failed"}
```
**Edge cases:**
- AI generates invalid JSON → planner returns error, shown in chat
- Step fails mid-workflow → remaining steps skipped, workflow marked "failed"
- Approval step → execution pauses until user approves
- Workflow exceeds 10 steps → planner prompt limits to 10
### 3.6 User Opens Shell, Connects via SSH
```
1. User clicks Shell tab (Ctrl+3)
2. Default "Local Shell" tab created with xterm.js terminal
3. WebSocket connects to /api/ws/terminal with {type: "shell", data: ""}
4. Backend creates PTY via creack/pty, pipes I/O through WebSocket
5. User sees their shell prompt (starship if configured)
6. To add SSH tab:
a. User clicks "+" → dropdown shows:
- System terminals (zsh, bash, fish)
- Saved SSH connections (from config)
- "Add SSH connection" button
b. User selects saved connection or adds new one
c. New tab created with {type: "ssh", data: JSON.stringify({host, port, user, key_path})}
d. Backend establishes SSH connection, creates PTY
e. Tab shows connected indicator (green dot)
7. User can rename tabs (double-click), close tabs (×), switch with Alt+1-7
8. AI assistant panel on right:
a. User types question
b. Frontend calls POST /api/shell/chat with message + terminal context
c. AI responds with shell-aware answers (commands, explanations)
d. Can execute tools (terminal, read_file, etc.) to help user
```
**Edge cases:**
- SSH connection fails → tab shows "Connection error" in terminal
- WebSocket disconnects → terminal shows "Connection closed" message
- Tab limit (7) reached → "+" button disabled
- SSH key not found → connection fails, suggest key path
### 3.7 User Changes AI Provider
```
1. User clicks Config tab (Ctrl+4)
2. "AI Providers" panel shows list of providers:
- MiniMax (active) — Key configured ✓
- Z.AI — No key
- Anthropic — No key
- OpenAI — No key
- Ollama — No key (local)
3. User clicks "Configure" on Anthropic
4. Modal opens with fields: API Key, Model, Base URL
5. User enters API key
6. User clicks "Validate" → POST /api/providers/validate
7. Backend sends test request to Anthropic API with key
8. Response: {"status": "valid"} or error
9. User clicks "Activate" → provider set active, others deactivated
10. Config saved → new orchestrator instances use Anthropic
```
**Edge cases:**
- Key validation fails → show "Invalid key" badge, don't save
- No internet → validation times out, show "Connection failed"
- Ollama selected but not running → user sees local URL, no validation needed
- Switching provider mid-conversation → new messages use new provider, old messages preserved
### 3.8 User Manages MCP Servers
```
1. User opens Config tab → MCP section
OR: User runs `muyue mcp scan` from CLI
2. System scans 12 known MCP servers (filesystem, github, git, fetch, memory, etc.)
3. Each server shows: name, category, installed status (npx available)
4. User clicks "Configure MCP" → POST /api/mcp/configure
5. Backend:
a. Generates MCP config for Crush: ~/.config/crush/crush.json → {"mcps": {...}}
b. Generates MCP config for Claude Code: ~/.claude.json → {"mcpServers": {...}}
c. Core servers: filesystem, fetch, memory
d. Provider-specific: minimax-web-search, minimax-image (if API key set)
e. Claude-specific: sequential-thinking
6. Configs written with 0600 permissions
```
**Edge cases:**
- Existing configs not overwritten (merged) — `writeMCPConfig` merges into existing JSON
- No API key for provider-specific servers → those servers omitted
- Crush or Claude Code not installed → configs still generated (for when they are installed)
### 3.9 User Manages LSP Servers
```
1. User runs `muyue lsp scan` from CLI
OR: views LSP section in Config tab
2. System checks 16 known LSP servers
3. Each shows: name, language, command path, installed status
4. User installs specific LSP:
- CLI: `muyue lsp install gopls`
- API: POST /api/lsp/install {"name": "gopls"}
5. Backend runs install command (e.g., `go install golang.org/x/tools/gopls@latest`)
6. Result: success or error
```
**Edge cases:**
- LSP has no auto-install command (e.g., clangd) → return "install manually" message
- Install fails (network error) → show error, suggest retry
- Language mapping: TypeScript installs 4 servers (TS, JSON, HTML, CSS)
### 3.10 User Creates/Deploys a Skill
```
1. User runs `muyue skills init` → installs 5 built-in skills to ~/.muyue/skills/
2. User creates custom skill:
- Manually: create ~/.muyue/skills/my-skill/SKILL.md with YAML frontmatter
- CLI: `muyue skills generate my-skill "Does X for Y" crush`
- API: POST /api/skills (via Config tab or Studio chat)
3. SKILL.md format:
```yaml
---
name: my-skill
description: What it does
author: username
version: 1.0.0
target: crush|claude|both
tags: [tag1, tag2]
---
# Skill instructions in markdown
```
4. Deploy: `muyue skills deploy`
5. Skill copied to:
- Crush: ~/.config/crush/skills/my-skill/SKILL.md
- Claude Code: ~/.claude/skills/my-skill/SKILL.md
```
**Edge cases:**
- Skill already exists at target → overwritten
- Target is "both" → deployed to both Crush and Claude
- Delete removes from all locations (source + targets)
### 3.11 User Runs `muyue scan` from CLI
```
1. User runs `muyue scan`
2. Scanner runs full system scan (tools, runtimes, shell, git)
3. Output: formatted table with columns: Tool, Version, Status, Path
4. Summary line: "Installed: 8/14"
5. With --json flag: full JSON output
```
### 3.12 User Runs `muyue doctor` from CLI
```
1. User runs `muyue doctor`
2. Three checks run:
a. System scan → shows installed/missing tools
b. Config check → loads config, validates profile
c. Connectivity check → HEAD requests to AI provider endpoints
3. Output: diagnostic report with ✓/✗ indicators
4. User sees what's broken and can take action
```
---
## 4. API Contract
### Existing Endpoints (37 routes)
| Method | Path | Request Body | Response Body | Status |
|--------|------|-------------|---------------|--------|
| GET | `/api/info` | — | `{name, version, author}` | EXISTS |
| GET | `/api/system` | — | `{system: {os, arch, platform, shell, ...}}` | EXISTS |
| GET | `/api/tools` | — | `{tools: [{name, installed, version, path}], total}` | EXISTS |
| GET | `/api/config` | — | `{profile, terminal, bmad}` | EXISTS |
| GET | `/api/providers` | — | `{providers: [{name, model, active, ...}]}` | EXISTS |
| GET | `/api/skills` | — | `{skills: [...], count}` | EXISTS |
| GET | `/api/lsp` | — | `{servers: [{name, language, command, installed}]}` | EXISTS |
| GET | `/api/mcp` | — | `{servers: [...], configured}` | EXISTS |
| GET | `/api/updates` | — | `{updates: [{tool, current, latest, needsUpdate}]}` | EXISTS |
| GET | `/api/editors` | — | `{editors: [{name, installed, version, path}]}` | EXISTS |
| GET | `/api/terminal/sessions` | — | `{ssh: [...], system: [...]}` | EXISTS |
| GET | `/api/terminal/themes` | — | `{themes: [{id, name}]}` | EXISTS |
| GET | `/api/chat/history` | — | `{messages: [...], tokens}` | EXISTS |
| GET | `/api/tools/list` | — | `{tools: [...], count}` | EXISTS |
| GET | `/api/workflow/list` | — | `{workflows: [...], count}` | EXISTS |
| GET | `/api/workflow/{id}` | — | `{id, name, steps, status, ...}` | EXISTS |
| GET | `/api/conversations` | — | `{conversations: [...]}` | EXISTS |
| GET | `/api/ssh/connections` | — | `{connections: [...]}` | EXISTS |
| POST | `/api/scan` | — | `{status: "ok"}` | EXISTS |
| POST | `/api/install` | `{tools: [string]}` | `{status, tools, results: [{tool, success, message}]}` | EXISTS |
| POST | `/api/mcp/configure` | — | `{status: "ok"}` | EXISTS |
| POST | `/api/terminal` | `{command, cwd}` | `{output, error}` | EXISTS |
| POST | `/api/chat` | `{message, stream}` | SSE stream or `{content}` | EXISTS |
| POST | `/api/chat/clear` | — | `{status: "ok"}` | EXISTS |
| POST | `/api/tool/call` | `{tool, args}` | `{success, tool, result, error}` | EXISTS |
| POST | `/api/shell/chat` | `{message, context, history, cwd, platform, stream}` | SSE stream or `{content, tool_calls}` | EXISTS |
| POST | `/api/workflow` | `{name, description, type}` | `{id, name, steps, status, ...}` | EXISTS |
| POST | `/api/workflow/plan` | `{goal}` | `{id, name, steps, status, ...}` | EXISTS |
| POST | `/api/workflow/execute/{id}` | `?stream=true` optional | SSE stream or workflow object | EXISTS |
| POST | `/api/workflow/approve/{id}` | `{step_id}` | `{status: "approved"}` | EXISTS |
| POST | `/api/lsp/install` | `{name}` | `{success, server}` or `{success, error}` | EXISTS |
| POST | `/api/skills/deploy` | `{name}` optional | `{status, skill}` | EXISTS |
| POST | `/api/config/reset` | — | `{status: "ok"}` | EXISTS |
| POST | `/api/providers/validate` | `{name, api_key, model, base_url}` | `{status: "valid"}` or error | EXISTS |
| POST | `/api/update/run` | `{tool}` optional | `{status, updated}` or `{status, tool}` | EXISTS |
| POST | `/api/ssh/test` | `{host, port, user}` | `{success, message}` (stubbed) | PARTIAL |
| POST | `/api/starship/apply-theme` | `{theme}` | `{status, config}` | EXISTS |
| PUT | `/api/preferences` | `{language, keyboard_layout}` | `{status: "ok"}` | EXISTS |
| PUT | `/api/config/profile` | `{name, pseudo, email, editor, shell}` | `{status: "ok"}` | EXISTS |
| PUT | `/api/config/provider` | `{name, api_key, model, base_url, active}` | `{status: "ok"}` | EXISTS |
| PUT | `/api/terminal/settings` | `{font_size, font_family, theme}` | `{status, theme}` | EXISTS |
| DELETE | `/api/conversations/{id}` | — | `{status: "deleted"}` | EXISTS |
| DELETE | `/api/terminal/sessions/{name}` | — | (removes SSH connection) | EXISTS |
| WS | `/api/ws/terminal` | `{type, data}` | `{type, data}` | EXISTS |
### Error Response Format (all endpoints)
```json
{"error": "Human-readable error message"}
```
HTTP status codes: 400 (bad request), 401 (unauthorized), 404 (not found), 405 (method not allowed), 500 (internal), 503 (service unavailable — AI provider not configured).
### SSE Event Format
```
data: {"content": "character"}
data: {"tool_call": {"tool_call_id": "...", "name": "...", "args": "..."}}
data: {"tool_result": {"tool_call_id": "...", "content": "...", "is_error": false}}
data: {"done": "true"}
```
---
## 5. CLI Contract
### Root Command
```
muyue Launch desktop app (opens browser)
muyue --port=8080 Launch on specific port
muyue --no-open Launch without opening browser
```
### Subcommands
| Command | Flags | Output | Status |
|---------|-------|--------|--------|
| `muyue scan` | `--json` | Table or JSON of tools/runtimes | EXISTS |
| `muyue install [tool]` | `--yes` | Install progress per tool | EXISTS |
| `muyue update [tool]` | `--check` | Table of versions + status | EXISTS |
| `muyue setup` | — | Interactive TUI wizard | EXISTS |
| `muyue config` | — | (subcommand stub) | PARTIAL |
| `muyue doctor` | — | Diagnostic report | EXISTS |
| `muyue version` | — | `Muyue version X.Y.Z` | EXISTS |
| `muyue lsp scan` | — | Table of LSP servers | EXISTS |
| `muyue lsp install <name>` | — | Install progress | EXISTS |
| `muyue mcp config` | — | Confirmation message | EXISTS |
| `muyue mcp scan` | — | Table of MCP servers | EXISTS |
| `muyue skills list` | — | Table of skills | EXISTS |
| `muyue skills init` | — | Confirmation | STUBBED |
| `muyue skills show <name>` | — | Skill details | EXISTS |
| `muyue skills generate <name> <desc>` | — | (stub) | STUBBED |
| `muyue skills deploy` | — | Confirmation | EXISTS |
| `muyue skills delete <name>` | — | Confirmation | EXISTS |
### CLI Commands Needing Work
| Command | Issue | Fix |
|---------|-------|-----|
| `muyue config` | No subcommands (get/set are defined but not registered) | Register `config get <key>` and `config set <key> <value>` as subcommands |
| `muyue skills init` | Just prints message, doesn't call `skills.InstallBuiltinSkills()` | Wire to actual function |
| `muyue skills generate` | Just prints message, doesn't call AI | Wire to orchestrator |
| `muyue install` | Passes `nil` config to installer | Pass loaded config |
---
## 6. Data Model
### 6.1 Config YAML Schema (`~/.config/muyue/config.yaml`)
```yaml
version: "0.2.1"
profile:
name: "Augustin"
pseudo: "muyue"
email: "augustin@example.com"
languages: ["go", "typescript", "python"]
preferences:
editor: "nvim"
shell: "zsh"
theme: "cyberpunk-red" # cyberpunk-red | cyberpunk-pink | midnight-blue | matrix-green
default_ai: "minimax"
auto_update: true
check_on_start: true
language: "fr" # fr | en
keyboard_layout: "azerty" # azerty | qwerty | qwertz
ai:
providers:
- name: "minimax"
api_key: "enc:AES256GCM..." # encrypted at rest
base_url: "https://api.minimax.io/v1"
model: "MiniMax-M2.7"
active: true
- name: "zai"
model: "glm"
active: false
- name: "anthropic"
api_key: "enc:AES256GCM..."
model: "claude-sonnet-4-20250514"
active: false
- name: "openai"
api_key: "enc:AES256GCM..."
base_url: "https://api.openai.com/v1"
model: "gpt-4o"
active: false
- name: "ollama"
model: "llama3"
base_url: "http://localhost:11434/api"
active: false
tools:
- name: "crush"
installed: true
version: "v1.2.3"
auto_update: true
bmad:
installed: true
version: "latest"
global: true
terminal:
custom_prompt: true
prompt_theme: "zerotwo" # charm | zerotwo | default
ssh:
- name: "prod-server"
host: "192.168.1.100"
port: 22
user: "deploy"
key_path: "~/.ssh/id_rsa"
font_size: 14
font_family: "'JetBrains Mono', monospace"
theme: "default" # default | monokai | gruvbox | nord | solarized-dark | dracula
```
### 6.2 Conversation JSON Schema (`~/.config/muyue/conversation.json`)
```json
{
"messages": [
{
"id": "20260422150000.000-1234567890",
"role": "user|assistant|system",
"content": "message text or JSON-encoded {content, tool_calls}",
"time": "2026-04-22T15:00:00Z"
}
],
"summary": "Auto-generated conversation summary when >80K tokens",
"created_at": "2026-04-22T15:00:00Z",
"updated_at": "2026-04-22T15:30:00Z"
}
```
### 6.3 Skill SKILL.md Format
```markdown
---
name: skill-name
description: What this skill does
author: muyue
version: 1.0.0
target: both # crush | claude | both
tags: [tag1, tag2]
---
# Skill Title
Instructions for the AI agent in markdown.
Includes: when to activate, step-by-step instructions, examples, error handling.
```
### 6.4 MCP Config JSON Format
**For Crush** (`~/.config/crush/crush.json`):
```json
{
"mcps": {
"filesystem": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/home/user/projects"]
},
"fetch": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-fetch"]
}
}
}
```
**For Claude Code** (`~/.claude.json`):
```json
{
"mcpServers": {
"filesystem": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/home/user/projects"]
},
"sequential-thinking": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-sequential-thinking"]
}
}
}
```
### 6.5 Workflow JSON Schema (`~/.config/muyue/workflows.json`)
```json
[
{
"id": "wf-1234567890",
"name": "Plan: Set up Go project",
"description": "Full goal description",
"type": "plan_execute",
"steps": [
{
"id": "step-0",
"name": "Check Go installation",
"type": "tool_call",
"tool": "terminal",
"args": {"command": "go version"},
"status": "pending|running|done|failed|awaiting_approval|skipped",
"result": "",
"error": "",
"depends_on": [],
"started_at": null,
"ended_at": null
}
],
"status": "pending|running|done|failed",
"created_at": "2026-04-22T15:00:00Z",
"updated_at": "2026-04-22T15:00:00Z"
}
]
```
---
## 7. Technical Decisions
### 7.1 CLI Framework: **Keep Cobra** ✓
**Decision**: Keep `spf13/cobra` (already in `go.mod`, already used for all 11 subcommands).
**Rationale**: Cobra is the de-facto standard for Go CLIs. All commands are already implemented. No benefit to switching to `urfave/cli`.
### 7.2 HTTP Router: **Keep stdlib `http.ServeMux`** ✓
**Decision**: Keep `net/http.ServeMux`. Do NOT add chi, echo, or gin.
**Rationale**:
- 37 routes registered. Stdlib handles this fine.
- Go 1.22+ `ServeMux` supports method-based routing (`GET /api/foo`).
- Adding a framework adds a dependency and learning curve for no benefit.
- Performance is irrelevant at localhost scale.
**One improvement**: Use Go 1.22 method-based patterns to clean up manual method checks:
```go
mux.HandleFunc("GET /api/tools", s.handleTools)
mux.HandleFunc("POST /api/install", s.handleInstall)
```
### 7.3 WebSocket: **Keep gorilla/websocket** ✓
**Decision**: Keep `gorilla/websocket` for terminal PTY.
**Rationale**: Already working for terminal WebSocket. Only used for one endpoint (`/api/ws/terminal`). No need for a framework.
### 7.4 Frontend Framework: **Keep vanilla React** ✓
**Decision**: Keep React 19 + vanilla state management. Do NOT add zustand or react-query.
**Rationale**:
- 4 components, ~1200 lines total. State is simple (tab switching, form inputs, chat messages).
- Adding zustand/redux would be over-engineering for this scale.
- `useState` + `useCallback` + `useRef` is sufficient.
- SSE handling is custom and wouldn't benefit from react-query.
**One consideration**: If Dashboard grows complex (many sub-components), extract a `useApi` custom hook pattern for data fetching.
### 7.5 Async Operations: **SSE for everything** ✓
**Decision**: Use SSE (Server-Sent Events) for all streaming operations (chat, workflow execution). Use synchronous JSON for non-streaming operations (install, scan).
**Rationale**:
- SSE is already implemented for chat and workflow execution.
- Install operations are fast enough to be synchronous (wait for all goroutines, return results).
- No polling needed.
- WebSocket only for terminal PTY (bidirectional needed).
### 7.6 Workflow Engine: **State machine** ✓
**Decision**: Keep the current state machine approach. Do NOT convert to a DAG.
**Rationale**:
- Plans are linear sequences (step 1 → step 2 → step 3).
- Dependencies are simple (wait for previous step).
- DAG adds complexity (topological sort, parallel execution) for no benefit.
- The current `depends_on` field supports basic ordering. Parallel execution can be added later if needed via `TypeParallel` step type (already defined but not implemented).
### 7.7 Styling: **Keep CSS custom properties** ✓
**Decision**: Keep CSS custom properties + 4 theme objects. Do NOT add Tailwind or CSS-in-JS.
**Rationale**:
- 30+ CSS variables already define the full theme system.
- Theme switching works by setting `document.documentElement.style.setProperty()`.
- Adding Tailwind would conflict with the existing CSS architecture.
- Current CSS is ~1000 lines and well-structured.
---
## 8. Delegation Strategy
### What Muyue delegates to existing tools
| Feature | Delegated To | Integration Method | UI |
|---------|-------------|-------------------|-----|
| **Code editing / AI coding** | Crush (`crush run`) | `crush_run` agent tool → spawns `crush run <task>` | Studio chat invokes tool, Shell AI panel invokes tool |
| **Code editing / AI coding** | Claude Code | Skills deployed to `~/.claude/skills/` | Config tab shows deployment status |
| **MCP server discovery** | MCPM (`mcpm`) | CLI passthrough suggestion | Doctor command suggests `mcpm install <server>` if server missing |
| **MCP server routing** | McpMux | Not needed | Muyue generates per-tool configs directly |
| **Dev environments / containers** | DevPod | CLI passthrough suggestion | Doctor suggests DevPod if container needed |
| **IDE features** | VS Code / Zed / Neovim | Config integration (editor preference) | Config tab sets editor, LSPs installed for editor |
| **Terminal prompt** | Starship | Config generation (`starship.toml` + RC file patching) | Config tab applies themes |
| **Git operations** | `git` CLI | Agent `terminal` tool runs git commands | Studio / Shell AI can execute git commands |
### Integration Patterns
1. **Config Generation** (primary pattern): Muyue generates config files for external tools (Crush `crush.json`, Claude `.claude.json`, Starship `starship.toml`). This is the cleanest integration — no API coupling, no version lock-in.
2. **CLI Wrapping**: Muyue invokes external CLIs (`crush run`, `git`, `go install`) through the agent `terminal` tool. Stdout/stderr captured and returned to AI.
3. **Suggestion**: Muyue suggests tools the user should install separately (MCPM, DevPod) but doesn't wrap them. `muyue doctor` output includes recommendations.
4. **Skills Deployment**: Muyue's skills system deploys SKILL.md files to both Crush and Claude Code directories. Both tools natively understand this format.
---
## 9. Implementation Priority
### Phase 1: Dashboard Completion (P0 gap)
The only significant P0 gap is the Dashboard. Current state: empty placeholders.
**Dashboard must have:**
1. **Tools Grid** — Cards for each scanned tool showing name, status badge (installed/missing/update), version, install button
2. **Quick Actions** — Buttons: "Install missing tools", "Check for updates", "Rescan system", "Configure MCP"
3. **Update Notifications** — List of tools with available updates, with "Update" buttons
4. **Activity Log** — Scrollable list of recent events (installs, scans, config changes) with timestamps
**Implementation approach:**
- Fetch from `/api/tools`, `/api/updates`, `/api/editors` on mount
- Quick actions call existing API endpoints (`POST /api/install`, `POST /api/scan`, `POST /api/mcp/configure`)
- Activity log: client-side event accumulation (no backend change needed for MVP)
### Phase 2: CLI Polish (P0 gaps)
1. Wire `muyue skills init` to `skills.InstallBuiltinSkills()`
2. Wire `muyue skills generate` to orchestrator
3. Register `muyue config get` and `muyue config set` subcommands
4. Pass loaded config to installer in `muyue install`
### Phase 3: P1 Features
1. AI-generated skills via Studio chat
2. SSH connectivity test
3. Multi-conversation support in Studio
4. Real event-based activity log
---
## 10. Architecture Summary
```
┌─────────────────────────────────────────────────────────┐
│ Browser (React SPA) │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │Dashboard │ │ Studio │ │ Shell │ │ Config │ │
│ │(tools, │ │(AI chat, │ │(xterm.js,│ │(profile, │ │
│ │ updates, │ │ tool │ │ WS PTY, │ │provider, │ │
│ │ actions) │ │ calls) │ │ AI panel)│ │ theme) │ │
│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │
└──────────────────────┬──────────────────────────────────┘
│ HTTP/SSE/WS
┌──────────────────────┴──────────────────────────────────┐
│ Go HTTP Server │
│ ┌────────────────────────────────────────────────────┐ │
│ │ api.Server (37 routes) │ │
│ │ /api/chat → SSE stream + tool calling loop │ │
│ │ /api/shell/chat → SSE stream + tool calling loop │ │
│ │ /api/ws/terminal → WebSocket PTY │ │
│ │ /api/install → parallel tool installation │ │
│ │ /api/workflow/* → CRUD + plan + execute │ │
│ └────────────────────────────────────────────────────┘ │
│ ┌──────────┐ ┌────────────┐ ┌──────────┐ ┌──────────┐ │
│ │Scanner │ │ Installer │ │ Updater │ │ MCP │ │
│ │(14 tools,│ │(12 tools, │ │(version │ │(12 known │ │
│ │ 8 runts, │ │ platform- │ │ check + │ │ servers, │ │
│ │ 8 edtrs) │ │ specific) │ │ auto-upd)│ │ config │ │
│ └──────────┘ └────────────┘ └──────────┘ │ gen) │ │
│ ┌──────────┐ ┌────────────┐ ┌──────────┐ └──────────┘ │
│ │ LSP │ │ Skills │ │ Workflow │ │
│ │(16 known │ │(CRUD + │ │(Plan→ │ │
│ │ servers) │ │ deploy + │ │ Execute │ │
│ │ │ │ builtins) │ │ engine) │ │
│ └──────────┘ └────────────┘ └──────────┘ │
│ ┌──────────┐ ┌────────────┐ ┌──────────┐ ┌──────────┐ │
│ │Orchestrtr│ │ Agent │ │ Secret │ │ Config │ │
│ │(OpenAI- │ │ Registry │ │(AES-256- │ │(YAML, │ │
│ │ compat, │ │(10 tools: │ │ GCM key │ │ XDG, │ │
│ │ multi- │ │ terminal, │ │ encrypt) │ │ encrypted│ │
│ │ provider)│ │ files, │ │ │ │ API keys)│ │
│ │ │ │ grep, etc)│ │ │ │ │ │
│ └──────────┘ └────────────┘ └──────────┘ └──────────┘ │
└──────────────────────────────────────────────────────────┘
┌─────────────────────────┐
│ External Tools/Agents │
│ Crush, Claude Code, │
│ Starship, MCP servers │
└─────────────────────────┘
```
### Key Dependencies
| Dependency | Version | Purpose |
|-----------|---------|---------|
| `spf13/cobra` | v1.10.2 | CLI framework |
| `charmbracelet/huh` | v1.0.0 | TUI forms (profiler, API key input) |
| `charmbracelet/bubbletea` | v1.3.10 | TUI framework (indirect) |
| `gorilla/websocket` | v1.5.3 | Terminal WebSocket |
| `creack/pty/v2` | v2.0.1 | PTY for terminal |
| `gopkg.in/yaml.v3` | v3.0.1 | Config serialization |
| React 19 | — | Frontend UI |
| Vite 8 | — | Frontend build |
| xterm.js | — | Terminal emulator component |
### File Count Summary
| Layer | Files | Lines (approx) |
|-------|-------|---------------|
| Go backend (`internal/`) | 41 `.go` files | ~8,000 |
| CLI commands (`cmd/`) | 12 `.go` files | ~600 |
| Frontend (`web/src/`) | ~20 files | ~3,500 |
| CSS (`web/src/styles/`) | 1 file | ~1,500 |
| **Total** | ~75 files | ~13,600 |
---
## 11. Risks & Mitigations
| Risk | Impact | Mitigation |
|------|--------|-----------|
| AI provider API changes break orchestrator | Studio/Shell chat stops working | Orchestrator uses OpenAI-compatible format (widely supported). Fallback: user switches provider. |
| Tool install commands change (brew, apt) | Installer fails | Installer returns clear error messages. Doctor command diagnoses. User can install manually. |
| Frontend grows beyond vanilla React manageability | Hard to maintain | At current scale (4 components), this is not a risk. Re-evaluate if components exceed 20. |
| Security: API keys in config file | Key exposure | AES-256-GCM encryption at rest. Config file permissions 0600. |
| Terminal WebSocket security | Remote command execution | Server binds to 127.0.0.1 only. No remote access possible. |
---
*End of Muyue PRD v1.0*

4
extension/.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
node_modules/
.output/
.wxt/
*.zip

81
extension/README.md Normal file
View File

@@ -0,0 +1,81 @@
# Muyue Browser Extension
AI-powered browser testing & automation, connected to your [Muyue](https://github.com/muyue/muyue) desktop app.
## What it does
- **Auto-injects** the Muyue test client on every page — no more manual snippet copy-paste
- **Captures console** errors/warnings in real-time, sent to the AI Studio
- **Enables AI-driven testing**: click buttons, fill inputs, evaluate JS, take screenshots
- **Side Panel** (Chrome/Edge) and **Sidebar** (Firefox) for status monitoring
- **Native screenshots** via `chrome.tabs.captureVisibleTab` — pixel-perfect, no SVG hacks
- **URL change detection** via History API interception (survives SPA navigation)
- **Badge indicator**: shows connected session count or server status
## Install
### Chrome / Edge
1. Run `npm run build`
2. Open `chrome://extensions` → Enable **Developer mode**
3. Click **Load unpacked** → select `extension/.output/chrome-mv3/`
Or install the published extension from the Chrome Web Store.
### Firefox
1. Run `npm run build:firefox`
2. Open `about:debugging#/runtime/this-firefox`
3. Click **Load temporary Add-on** → select any file in `extension/.output/firefox-mv2/`
## Development
```bash
cd extension
npm install
npm run dev # Chrome dev mode with HMR
npm run dev -- --browser firefox # Firefox dev mode
```
## Build
```bash
npm run build # Chrome/Edge MV3 → .output/chrome-mv3/
npm run build:firefox # Firefox MV2 → .output/firefox-mv2/
npm run zip # Chrome .zip for Web Store
npm run zip:firefox # Firefox .zip + sources .zip
```
## Architecture
```
┌─────────────────────────────────────────────────────┐
│ Content Script (every HTTP/HTTPS page) │
│ - Console interception (log/warn/error) │
│ - RPC execution (click, type, eval, list) │
│ - URL change detection (History API + MutationObs) │
│ - WebSocket → Muyue server (same as snippet) │
└──────────────┬──────────────────────────────────────┘
│ chrome.runtime messaging
┌──────────────┴──────────────────────────────────────┐
│ Background Service Worker │
│ - Token management (GET /api/test/snippet) │
│ - Native screenshots (captureVisibleTab) │
│ - Badge updates (session count / server status) │
│ - chrome.alarms for periodic health checks │
└──────────────────────────────────────────────────────┘
┌──────────────────┐ ┌──────────────────┐
│ Popup │ │ Side Panel │
│ - Server status │ │ - Sessions list │
│ - Session count │ │ - Auto-refresh │
│ - Dashboard link │ │ - Dashboard link │
└──────────────────┘ └──────────────────┘
```
## Compatibility
| Browser | Manifest | Side Panel | Screenshots |
|---------|----------|------------|-------------|
| Chrome 89+ | MV3 | ✅ sidePanel API | ✅ captureVisibleTab |
| Edge 89+ | MV3 | ✅ sidePanel API | ✅ captureVisibleTab |
| Firefox | MV2 | ✅ sidebar API | ✅ tabs.captureVisibleTab |

4711
extension/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

16
extension/package.json Normal file
View File

@@ -0,0 +1,16 @@
{
"name": "muyue-extension",
"version": "0.8.0",
"private": true,
"type": "module",
"scripts": {
"dev": "wxt",
"build": "wxt build",
"build:firefox": "wxt build --browser firefox",
"zip": "wxt zip",
"zip:firefox": "wxt zip --browser firefox"
},
"dependencies": {
"wxt": "^0.20"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 750 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 307 KiB

View File

@@ -0,0 +1,116 @@
import { fetchToken, fetchSessions, checkServerHealth, getServerUrl } from '../lib/config';
export default defineBackground(() => {
let token = null;
let wsUrl = null;
let serverOnline = false;
let errorCount = 0;
async function refreshToken() {
try {
const data = await fetchToken();
token = data.token;
wsUrl = data.wsUrl;
serverOnline = true;
return data;
} catch {
serverOnline = false;
token = null;
wsUrl = null;
return null;
}
}
async function updateBadge() {
try {
serverOnline = await checkServerHealth();
} catch {
serverOnline = false;
}
if (!serverOnline) {
chrome.action.setBadgeText({ text: '✕' });
chrome.action.setBadgeBackgroundColor({ color: '#ff6b6b' });
return;
}
try {
const sessions = await fetchSessions();
const count = sessions.length;
if (count > 0) {
chrome.action.setBadgeText({ text: String(count) });
chrome.action.setBadgeBackgroundColor({ color: '#3aaa61' });
} else {
chrome.action.setBadgeText({ text: '○' });
chrome.action.setBadgeBackgroundColor({ color: '#888' });
}
} catch {
chrome.action.setBadgeText({ text: '?' });
chrome.action.setBadgeBackgroundColor({ color: '#f5a623' });
}
}
async function handleScreenshot() {
try {
const dataUrl = await chrome.tabs.captureVisibleTab(null, {
format: 'png',
quality: 100,
});
return { ok: true, data_url: dataUrl };
} catch (e) {
return { ok: false, error: 'capture failed: ' + String(e) };
}
}
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
if (msg.type === 'get_state') {
getServerUrl().then((url) => {
sendResponse({
serverOnline,
token,
wsUrl,
errorCount,
serverUrl: url,
});
});
return true;
}
if (msg.type === 'get_token') {
refreshToken().then((data) => sendResponse(data));
return true;
}
if (msg.type === 'check_health') {
checkServerHealth().then((ok) => {
serverOnline = ok;
sendResponse({ online: ok });
});
return true;
}
if (msg.type === 'screenshot') {
handleScreenshot().then(sendResponse);
return true;
}
if (msg.type === 'refresh_badge') {
updateBadge();
return false;
}
if (msg.type === 'increment_errors') {
errorCount++;
return false;
}
return false;
});
chrome.alarms.create('muyue-badge', { periodInMinutes: 0.17 });
chrome.alarms.onAlarm.addListener((alarm) => {
if (alarm.name === 'muyue-badge') updateBadge();
});
updateBadge();
});

View File

@@ -0,0 +1,193 @@
import { dispatch } from '../lib/page-rpc';
export default defineContentScript({
matches: ['http://*/*', 'https://*/*'],
runAt: 'document_idle',
main() {
if (window.__muyueExtension) return;
window.__muyueExtension = true;
let ws = null;
let retryDelay = 0;
let token = null;
let wsBaseUrl = null;
const TAG = '[Muyue]';
function log(...args) {
console.log(TAG, ...args);
}
function send(obj) {
try {
if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(obj));
} catch {}
}
function reply(id, data) {
send({ type: 'reply', id, data });
}
function sendConsole(level, text) {
send({ type: 'console', level, text });
}
async function getToken() {
return new Promise((resolve) => {
chrome.runtime.sendMessage({ type: 'get_token' }, (response) => {
if (chrome.runtime.lastError) {
resolve(null);
return;
}
resolve(response);
});
});
}
function screenshotNative(params) {
return new Promise((resolve) => {
chrome.runtime.sendMessage({ type: 'screenshot', params }, (response) => {
if (chrome.runtime.lastError) {
resolve({ ok: false, error: String(chrome.runtime.lastError) });
return;
}
resolve(response || { ok: false, error: 'no response' });
});
});
}
async function connect() {
if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) {
return;
}
if (!token) {
const data = await getToken();
if (!data) {
retryDelay = Math.min(retryDelay + 1, 5);
setTimeout(connect, 1000 * retryDelay);
return;
}
token = data.token;
wsBaseUrl = data.wsUrl;
}
try {
const wsUrl = wsBaseUrl || `ws://127.0.0.1:8080/api/ws/browser-test?token=${token}`;
ws = new WebSocket(wsUrl);
} catch {
retryDelay = Math.min(retryDelay + 1, 5);
setTimeout(connect, 1000 * retryDelay);
return;
}
ws.onopen = () => {
retryDelay = 0;
send({ type: 'hello', url: location.href, title: document.title });
log('connected to Muyue server');
};
ws.onmessage = (ev) => {
let msg;
try { msg = JSON.parse(ev.data); } catch { return; }
if (msg.type === 'registered') {
log('session registered:', msg.session_id);
return;
}
if (msg.action) {
if (msg.action === 'screenshot') {
screenshotNative(msg.params || {}).then((r) => reply(msg.id, r));
return;
}
const result = dispatch(msg);
if (result && typeof result.then === 'function') {
result.then((r) => reply(msg.id, r));
} else {
reply(msg.id, result);
}
}
};
ws.onclose = () => {
retryDelay = Math.min(retryDelay + 1, 5);
setTimeout(connect, 500 * retryDelay);
};
ws.onerror = () => {};
}
['log', 'info', 'warn', 'error', 'debug'].forEach((lvl) => {
const orig = console[lvl];
console[lvl] = function () {
try {
const parts = Array.from(arguments).map((a) => {
if (typeof a === 'string') return a;
try { return JSON.stringify(a); } catch { return String(a); }
});
const text = parts.join(' ');
if (!text.startsWith(TAG)) {
sendConsole(lvl, text);
if (lvl === 'error') {
chrome.runtime.sendMessage({ type: 'increment_errors' }).catch(() => {});
}
}
} catch {}
return orig.apply(console, arguments);
};
});
window.addEventListener('error', (e) => {
sendConsole('error', 'window.onerror: ' + (e.message || 'unknown'));
chrome.runtime.sendMessage({ type: 'increment_errors' }).catch(() => {});
});
window.addEventListener('unhandledrejection', (e) => {
sendConsole('error', 'unhandledrejection: ' + String(e.reason));
chrome.runtime.sendMessage({ type: 'increment_errors' }).catch(() => {});
});
let lastUrl = location.href;
const urlObserver = new MutationObserver(() => {
if (location.href !== lastUrl) {
lastUrl = location.href;
send({ type: 'url_change', url: lastUrl });
}
});
urlObserver.observe(document.documentElement, { childList: true, subtree: true });
const origPushState = history.pushState;
history.pushState = function () {
origPushState.apply(this, arguments);
if (location.href !== lastUrl) {
lastUrl = location.href;
send({ type: 'url_change', url: lastUrl });
}
};
const origReplaceState = history.replaceState;
history.replaceState = function () {
origReplaceState.apply(this, arguments);
if (location.href !== lastUrl) {
lastUrl = location.href;
send({ type: 'url_change', url: lastUrl });
}
};
window.addEventListener('popstate', () => {
if (location.href !== lastUrl) {
lastUrl = location.href;
send({ type: 'url_change', url: lastUrl });
}
});
setInterval(() => {
if (location.href !== lastUrl) {
lastUrl = location.href;
send({ type: 'url_change', url: lastUrl });
}
}, 500);
connect();
},
});

View File

@@ -0,0 +1,55 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=320" />
</head>
<body>
<div class="panel">
<header>
<img src="/icon/32.png" alt="Muyue" />
<h1>Muyue</h1>
</header>
<div class="status-card">
<div class="status-row">
<span class="status-label">Server</span>
<span class="status-value" id="server-status">
<span class="dot dot-yellow"></span>Checking…
</span>
</div>
<div class="status-row">
<span class="status-label">Active sessions</span>
<span class="status-value" id="session-count"></span>
</div>
<div class="status-row">
<span class="status-label">Console errors</span>
<span class="status-value" id="error-count">0</span>
</div>
</div>
<div class="actions">
<a id="btn-dashboard" href="#" class="btn btn-primary" target="_blank">
Open Dashboard
</a>
<button id="btn-sidepanel" class="btn">
Open Side Panel
</button>
</div>
<div class="settings-section">
<label>Server URL</label>
<div class="input-row">
<input type="text" id="server-url" placeholder="http://127.0.0.1:8080" />
<button id="btn-save-url">Save</button>
</div>
</div>
<div class="footer">
<span>Muyue</span> browser extension v0.1.0
</div>
</div>
<script src="./main.js" type="module"></script>
</body>
</html>

View File

@@ -0,0 +1,54 @@
import '../../styles/panel.css';
import { getServerUrl, setServerUrl, fetchSessions } from '../../lib/config';
const $serverStatus = document.getElementById('server-status');
const $sessionCount = document.getElementById('session-count');
const $errorCount = document.getElementById('error-count');
const $btnDashboard = document.getElementById('btn-dashboard');
const $btnSidepanel = document.getElementById('btn-sidepanel');
const $serverUrl = document.getElementById('server-url');
const $btnSaveUrl = document.getElementById('btn-save-url');
function dot(color) {
return `<span class="dot dot-${color}"></span>`;
}
async function refresh() {
const url = await getServerUrl();
$serverUrl.value = url;
$btnDashboard.href = url;
try {
const sessions = await fetchSessions();
$serverStatus.innerHTML = `${dot('green')} Online`;
$sessionCount.textContent = sessions.length;
} catch {
$serverStatus.innerHTML = `${dot('red')} Offline`;
$sessionCount.textContent = '—';
}
chrome.runtime.sendMessage({ type: 'get_state' }, (state) => {
if (chrome.runtime.lastError || !state) return;
$errorCount.textContent = state.errorCount || 0;
});
}
$btnSaveUrl.addEventListener('click', async () => {
const url = $serverUrl.value.trim().replace(/\/$/, '');
if (url) {
await setServerUrl(url);
refresh();
}
});
$btnSidepanel.addEventListener('click', async () => {
try {
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
if (tab) {
chrome.sidePanel.open({ tabId: tab.id });
window.close();
}
} catch {}
});
refresh();

View File

@@ -0,0 +1,54 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=100%" />
</head>
<body>
<div class="panel">
<header>
<img src="/icon/32.png" alt="Muyue" />
<h1>Muyue Side Panel</h1>
</header>
<div class="status-card">
<div class="status-row">
<span class="status-label">Server</span>
<span class="status-value" id="server-status">
<span class="dot dot-yellow"></span>Checking…
</span>
</div>
<div class="status-row">
<span class="status-label">Active sessions</span>
<span class="status-value" id="session-count"></span>
</div>
<div class="status-row">
<span class="status-label">Console errors</span>
<span class="status-value" id="error-count">0</span>
</div>
</div>
<div id="sessions-list"></div>
<div class="actions">
<a id="btn-dashboard" href="#" class="btn btn-primary" target="_blank">
Open Dashboard
</a>
</div>
<div class="settings-section">
<label>Server URL</label>
<div class="input-row">
<input type="text" id="server-url" placeholder="http://127.0.0.1:8080" />
<button id="btn-save-url">Save</button>
</div>
</div>
<div class="footer">
<span>Muyue</span> browser extension v0.1.0
</div>
</div>
<script src="./main.js" type="module"></script>
</body>
</html>

View File

@@ -0,0 +1,72 @@
import '../../styles/panel.css';
import { getServerUrl, setServerUrl, fetchSessions } from '../../lib/config';
const $serverStatus = document.getElementById('server-status');
const $sessionCount = document.getElementById('session-count');
const $errorCount = document.getElementById('error-count');
const $sessionsList = document.getElementById('sessions-list');
const $btnDashboard = document.getElementById('btn-dashboard');
const $serverUrl = document.getElementById('server-url');
const $btnSaveUrl = document.getElementById('btn-save-url');
function dot(color) {
return `<span class="dot dot-${color}"></span>`;
}
function renderSessions(sessions) {
if (sessions.length === 0) {
$sessionsList.innerHTML = '';
return;
}
$sessionsList.innerHTML = `
<div class="status-card" style="margin-top:12px">
<div style="font-size:11px;color:var(--text-secondary);margin-bottom:8px;text-transform:uppercase;letter-spacing:0.5px">
Connected tabs
</div>
${sessions.map((s) => `
<div class="status-row">
<span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:200px" title="${s.url}">
${s.title || s.url || s.id}
</span>
<span style="font-size:10px;color:var(--text-secondary);font-family:var(--font-mono)">
${s.id.slice(0, 8)}
</span>
</div>
`).join('')}
</div>
`;
}
async function refresh() {
const url = await getServerUrl();
$serverUrl.value = url;
$btnDashboard.href = url;
try {
const sessions = await fetchSessions();
$serverStatus.innerHTML = `${dot('green')} Online`;
$sessionCount.textContent = sessions.length;
renderSessions(sessions);
} catch {
$serverStatus.innerHTML = `${dot('red')} Offline`;
$sessionCount.textContent = '—';
$sessionsList.innerHTML = '';
}
chrome.runtime.sendMessage({ type: 'get_state' }, (state) => {
if (chrome.runtime.lastError || !state) return;
$errorCount.textContent = state.errorCount || 0;
});
}
$btnSaveUrl.addEventListener('click', async () => {
const url = $serverUrl.value.trim().replace(/\/$/, '');
if (url) {
await setServerUrl(url);
refresh();
}
});
refresh();
setInterval(refresh, 5000);

View File

@@ -0,0 +1,56 @@
const DEFAULT_PORT = 8080;
const DEFAULT_HOST = '127.0.0.1';
const DEFAULT_URL = `http://${DEFAULT_HOST}:${DEFAULT_PORT}`;
function isServiceWorker() {
return typeof ServiceWorkerGlobalScope !== 'undefined' && self instanceof ServiceWorkerGlobalScope;
}
export async function getServerUrl() {
if (isServiceWorker()) {
const result = await chrome.storage.local.get('muyue_server_url');
return result.muyue_server_url || DEFAULT_URL;
}
const stored = localStorage.getItem('muyue_server_url');
return stored || DEFAULT_URL;
}
export async function setServerUrl(url) {
if (isServiceWorker()) {
await chrome.storage.local.set({ muyue_server_url: url });
} else {
localStorage.setItem('muyue_server_url', url);
}
}
export async function buildWsUrl(token) {
const base = await getServerUrl();
const wsBase = base.replace(/^http/, 'ws');
return `${wsBase}/api/ws/browser-test?token=${encodeURIComponent(token)}`;
}
export async function fetchToken() {
const base = await getServerUrl();
const res = await fetch(`${base}/api/test/snippet`);
if (!res.ok) throw new Error(`Server returned ${res.status}`);
const data = await res.json();
return { token: data.token, wsUrl: data.ws_url, expiresIn: data.expires_in };
}
export async function fetchSessions() {
const base = await getServerUrl();
const res = await fetch(`${base}/api/test/sessions`);
if (!res.ok) throw new Error(`Server returned ${res.status}`);
const data = await res.json();
return data.sessions || [];
}
export async function checkServerHealth() {
try {
const base = await getServerUrl();
const res = await fetch(`${base}/api/info`, { signal: AbortSignal.timeout(3000) });
return res.ok;
} catch {
return false;
}
}

View File

@@ -0,0 +1,113 @@
let lastList = [];
function safeText(el) {
let t = (el.innerText || el.textContent || '').trim();
if (t.length > 80) t = t.slice(0, 80) + '…';
return t;
}
function describe(el) {
let sel = el.id ? '#' + el.id : el.tagName.toLowerCase();
if (!el.id && el.className && typeof el.className === 'string') {
sel += '.' + el.className.trim().split(/\s+/).slice(0, 2).join('.');
}
const label = el.getAttribute('aria-label') || el.getAttribute('title') || el.getAttribute('name') || '';
return {
tag: el.tagName.toLowerCase(),
selector: sel,
text: safeText(el),
label,
type: el.getAttribute('type') || '',
disabled: !!el.disabled,
};
}
export function listClickables() {
const els = Array.from(
document.querySelectorAll(
'button, a[href], input[type=submit], input[type=button], [role=button], [onclick]'
)
);
lastList = els.filter((e) => {
const r = e.getBoundingClientRect();
return r.width > 0 && r.height > 0;
});
return lastList.map((el, i) => {
const d = describe(el);
d.index = i;
return d;
});
}
export function clickElement(params) {
let el;
if (params.selector) el = document.querySelector(params.selector);
else if (typeof params.index === 'number') el = lastList[params.index];
if (!el) return { ok: false, error: 'element not found' };
if (el.disabled) return { ok: false, error: 'element is disabled' };
try {
el.scrollIntoView({ block: 'center' });
el.click();
return { ok: true };
} catch (e) {
return { ok: false, error: String(e) };
}
}
export function typeText(params) {
let el;
if (params.selector) el = document.querySelector(params.selector);
else if (typeof params.index === 'number') el = lastList[params.index];
if (!el) return { ok: false, error: 'element not found' };
const proto = Object.getPrototypeOf(el);
const setter = Object.getOwnPropertyDescriptor(proto, 'value');
try {
if (setter && setter.set) setter.set.call(el, params.text || '');
else el.value = params.text || '';
} catch {
el.value = params.text || '';
}
el.dispatchEvent(new Event('input', { bubbles: true }));
el.dispatchEvent(new Event('change', { bubbles: true }));
return { ok: true };
}
export function evalExpr(params) {
try {
const r = (0, eval)(params.expr);
return { ok: true, value: serialize(r) };
} catch (e) {
return { ok: false, error: String(e) };
}
}
export function currentUrl() {
return { url: location.href, title: document.title };
}
function serialize(v) {
if (v === undefined) return 'undefined';
try {
return JSON.parse(JSON.stringify(v));
} catch {
return String(v);
}
}
export function dispatch(msg) {
const p = msg.params || {};
switch (msg.action) {
case 'list_clickables':
return listClickables();
case 'click':
return clickElement(p);
case 'eval':
return evalExpr(p);
case 'current_url':
return currentUrl();
case 'type':
return typeText(p);
default:
return { ok: false, error: 'unknown action: ' + msg.action };
}
}

View File

@@ -0,0 +1,211 @@
:root {
--bg-primary: #0a0a0f;
--bg-secondary: #12121a;
--bg-tertiary: rgba(255, 255, 255, 0.05);
--border: rgba(255, 255, 255, 0.1);
--text-primary: #e8e8f0;
--text-secondary: #9999aa;
--accent: #ff4757;
--accent-dim: rgba(255, 71, 87, 0.15);
--accent-glow: rgba(255, 71, 87, 0.4);
--green: #3aaa61;
--yellow: #f5a623;
--red: #ff6b6b;
--font-mono: 'JetBrains Mono', ui-monospace, monospace;
--font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: var(--font-sans);
background: var(--bg-primary);
color: var(--text-primary);
font-size: 13px;
line-height: 1.4;
}
.panel {
width: 320px;
padding: 16px;
}
header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 16px;
padding-bottom: 12px;
border-bottom: 1px solid var(--border);
}
header img {
width: 28px;
height: 28px;
}
header h1 {
font-size: 16px;
font-weight: 600;
letter-spacing: -0.3px;
}
.status-card {
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 8px;
padding: 12px;
margin-bottom: 12px;
}
.status-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 0;
}
.status-row + .status-row {
border-top: 1px solid var(--border);
margin-top: 6px;
padding-top: 10px;
}
.status-label {
color: var(--text-secondary);
font-size: 12px;
}
.status-value {
font-weight: 500;
font-size: 12px;
}
.dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
margin-right: 6px;
}
.dot-green { background: var(--green); box-shadow: 0 0 6px var(--green); }
.dot-red { background: var(--red); box-shadow: 0 0 6px var(--red); }
.dot-yellow { background: var(--yellow); box-shadow: 0 0 6px var(--yellow); }
.actions {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 12px;
}
.btn {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 9px 14px;
border-radius: 6px;
border: 1px solid var(--border);
background: var(--bg-tertiary);
color: var(--text-primary);
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s ease;
text-decoration: none;
}
.btn:hover {
background: var(--accent-dim);
border-color: var(--accent);
}
.btn-primary {
background: var(--accent);
border-color: var(--accent);
color: #fff;
}
.btn-primary:hover {
background: #e8414f;
box-shadow: 0 0 12px var(--accent-glow);
}
.settings-section {
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid var(--border);
}
.settings-section label {
display: block;
color: var(--text-secondary);
font-size: 11px;
margin-bottom: 4px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.input-row {
display: flex;
gap: 6px;
}
.input-row input {
flex: 1;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 4px;
padding: 6px 8px;
color: var(--text-primary);
font-size: 12px;
font-family: var(--font-mono);
outline: none;
}
.input-row input:focus {
border-color: var(--accent);
}
.input-row button {
padding: 6px 10px;
border-radius: 4px;
border: 1px solid var(--border);
background: var(--bg-tertiary);
color: var(--text-primary);
cursor: pointer;
font-size: 11px;
}
.input-row button:hover {
background: var(--accent-dim);
border-color: var(--accent);
}
.footer {
margin-top: 12px;
padding-top: 10px;
border-top: 1px solid var(--border);
text-align: center;
color: var(--text-secondary);
font-size: 10px;
}
.footer span {
color: var(--accent);
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.loading {
animation: pulse 1.5s ease-in-out infinite;
}

30
extension/wxt.config.js Normal file
View File

@@ -0,0 +1,30 @@
import { defineConfig } from 'wxt';
export default defineConfig({
srcDir: 'src',
suppressWarnings: {
firefoxDataCollection: true,
},
manifest: {
name: 'Muyue',
description: 'AI-powered browser testing & automation — connected to your Muyue desktop app',
permissions: [
'storage',
'activeTab',
'tabs',
'sidePanel',
'scripting',
'notifications',
],
host_permissions: ['http://127.0.0.1:*/*', 'http://localhost:*/*'],
action: {
default_icon: {
16: 'icon/16.png',
32: 'icon/32.png',
},
},
side_panel: {
default_path: 'sidepanel.html',
},
},
});

17
go.mod
View File

@@ -1,12 +1,15 @@
module github.com/muyue/muyue
go 1.24.3
go 1.24.2
toolchain go1.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
github.com/creack/pty/v2 v2.0.1
github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3
github.com/spf13/cobra v1.10.2
gopkg.in/yaml.v3 v3.0.1
)
@@ -14,8 +17,10 @@ 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/bubbles v1.0.0 // indirect
github.com/charmbracelet/bubbletea v1.3.10 // indirect
github.com/charmbracelet/colorprofile v0.4.1 // indirect
github.com/charmbracelet/harmonica v0.2.0 // indirect
github.com/charmbracelet/lipgloss v1.1.0 // 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
@@ -25,6 +30,7 @@ require (
github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
@@ -34,6 +40,7 @@ require (
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/spf13/pflag v1.0.9 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/sys v0.38.0 // indirect
golang.org/x/text v0.23.0 // indirect

17
go.sum
View File

@@ -14,8 +14,6 @@ github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlv
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/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ=
github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao=
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=
@@ -44,12 +42,21 @@ github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfa
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=
github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
github.com/creack/pty/v2 v2.0.1 h1:RDY1VY5b+7m2mfPsugucOYPIxMp+xal5ZheSyVzUA+k=
github.com/creack/pty/v2 v2.0.1/go.mod h1:2dSssKp3b86qYEMwA/FPwc3ff+kYpDdQI8osU8J7gxQ=
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/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
@@ -68,8 +75,14 @@ github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=

View File

@@ -0,0 +1,457 @@
package agent
import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"sync"
"time"
)
var ansiRegex = regexp.MustCompile(`\x1b\[[0-9;]*[a-zA-Z]|\x1b\][^\x07]*\x07|\x1b\][^\x1b]*\x1b\\|\x1b[()][AB012]|\[\]`)
func stripANSI(s string) string {
return ansiRegex.ReplaceAllString(s, "")
}
var (
sudoCache bool
sudoCacheSet bool
sudoCacheOnce sync.Once
)
func NeedsSudoPassword() bool {
sudoCacheOnce.Do(func() {
if os.Geteuid() == 0 {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
err := exec.CommandContext(ctx, "sudo", "-n", "true").Run()
sudoCacheSet = true
sudoCache = err != nil
} else {
sudoCache = true
sudoCacheSet = true
}
})
return sudoCache
}
type TerminalParams struct {
Command string `json:"command" description:"The shell command to execute"`
Timeout int `json:"timeout,omitempty" description:"Timeout in seconds (default 60, max 300)"`
}
type TerminalResponse struct {
Content string `json:"content"`
IsError bool `json:"is_error"`
SudoBlocked bool `json:"sudo_blocked,omitempty"`
Command string `json:"command,omitempty"`
}
func NewTerminalTool() (*ToolDefinition, error) {
return NewTool("terminal",
"Execute a shell command on the local system and return the output. Use for running builds, tests, git operations, package management, system info, or any CLI task. Commands run in the user's home directory by default. Long-running commands are auto-terminated.",
func(ctx context.Context, p TerminalParams) (ToolResponse, error) {
if p.Command == "" {
return TextErrorResponse("command is required"), nil
}
if NeedsSudoPassword() {
trimmed := strings.TrimSpace(p.Command)
lower := strings.ToLower(trimmed)
prefixBlocked := strings.HasPrefix(lower, "sudo ") || strings.HasPrefix(lower, "doas ") || strings.HasPrefix(lower, "run0 ") || strings.HasPrefix(lower, "pkexec ")
anywhereBlocked := false
blockedCmd := ""
if !prefixBlocked {
for _, kw := range []string{"sudo", "doas", "run0", "pkexec"} {
for _, pattern := range []string{" " + kw + " ", "|" + kw + " ", ";" + kw + " ", "&&" + kw + " ", "||" + kw + " ", "`" + kw + " ", "$(" + kw + " "} {
if strings.Contains(lower, pattern) {
anywhereBlocked = true
blockedCmd = kw
break
}
}
if anywhereBlocked {
break
}
}
}
if prefixBlocked || anywhereBlocked {
elevCmd := blockedCmd
if prefixBlocked {
elevCmd = strings.Fields(trimmed)[0]
}
return ToolResponse{
Content: fmt.Sprintf("BLOCKED: Command '%s' requires elevated privileges (%s). Passwordless sudo is not available. Do NOT retry with sudo. Explain to the user that this command needs admin privileges and suggest an alternative, or tell them to run it manually in their terminal.", trimmed, elevCmd),
IsError: true,
Meta: map[string]string{"sudo_blocked": "true", "command": trimmed},
}, nil
}
}
timeout := time.Duration(p.Timeout) * time.Second
if timeout == 0 {
timeout = 60 * time.Second
}
if timeout > 300*time.Second {
timeout = 300 * time.Second
}
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
shell := detectShell()
cmd := exec.CommandContext(ctx, shell, "-c", p.Command)
output, err := cmd.CombinedOutput()
result := string(output)
result = stripANSI(result)
if len(result) > 10000 {
result = result[:10000] + "\n... [truncated]"
}
if err != nil {
return TextErrorResponse(fmt.Sprintf("Error: %v\n\n%s", err, result)), nil
}
return TextResponse(result), nil
})
}
type CrushRunParams struct {
Task string `json:"task" description:"The task description for Crush to execute"`
Timeout int `json:"timeout,omitempty" description:"Maximum execution time in seconds (default 1800, max 1800)"`
Cwd string `json:"cwd,omitempty" description:"Working directory in which to launch the agent (absolute path; falls back to user home)"`
WSLDistro string `json:"wsl_distro,omitempty" description:"On Windows host: WSL distribution to launch the agent in (e.g. 'Ubuntu')"`
WSLUser string `json:"wsl_user,omitempty" description:"On Windows host: WSL user to run the agent as"`
}
func NewCrushRunTool() (*ToolDefinition, error) {
return NewTool("crush_run",
"Delegate a complex coding task to the Crush AI agent. Crush has access to file editing, code search, bash execution, and other development tools. Use this for multi-step coding tasks like refactoring, debugging, implementing features, or code review. Optionally pass cwd to run in a specific directory, or wsl_distro/wsl_user to launch inside a WSL distribution under a specific user (Windows hosts only). Returns the agent's final output.",
func(ctx context.Context, p CrushRunParams) (ToolResponse, error) {
if p.Task == "" {
return TextErrorResponse("task is required"), nil
}
timeout := time.Duration(p.Timeout) * time.Second
if timeout == 0 {
timeout = 1800 * time.Second
}
if timeout > 1800*time.Second {
timeout = 1800 * time.Second
}
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
cmd, prepErr := buildAgentCommand(ctx, "crush", []string{"run", p.Task}, p.Cwd, p.WSLDistro, p.WSLUser)
if prepErr != nil {
return TextErrorResponse(prepErr.Error()), nil
}
output, err := cmd.CombinedOutput()
result := string(output)
if len(result) > 15000 {
result = result[:15000] + "\n... [truncated]"
}
if err != nil {
errMsg := fmt.Sprintf("Crush error: %v", err)
if ctx.Err() == context.DeadlineExceeded {
errMsg = fmt.Sprintf("Crush timed out after %d seconds. Try splitting the task into smaller parts.", int(timeout.Seconds()))
}
if result != "" {
errMsg += "\n\n" + result
}
return TextErrorResponse(errMsg), nil
}
return TextResponse(result), nil
})
}
type ClaudeRunParams struct {
Task string `json:"task" description:"The task description for Claude Code to execute"`
Timeout int `json:"timeout,omitempty" description:"Maximum execution time in seconds (default 1800, max 1800)"`
Cwd string `json:"cwd,omitempty" description:"Working directory in which to launch the agent (absolute path; falls back to user home)"`
WSLDistro string `json:"wsl_distro,omitempty" description:"On Windows host: WSL distribution to launch the agent in (e.g. 'Ubuntu')"`
WSLUser string `json:"wsl_user,omitempty" description:"On Windows host: WSL user to run the agent as"`
}
func NewClaudeRunTool() (*ToolDefinition, error) {
return NewTool("claude_run",
"Delegate a complex coding task to the Claude Code CLI agent. Claude has access to file editing, code search, bash execution. Use for multi-step coding tasks. Same cwd/wsl_distro/wsl_user options as crush_run.",
func(ctx context.Context, p ClaudeRunParams) (ToolResponse, error) {
if p.Task == "" {
return TextErrorResponse("task is required"), nil
}
timeout := time.Duration(p.Timeout) * time.Second
if timeout == 0 {
timeout = 1800 * time.Second
}
if timeout > 1800*time.Second {
timeout = 1800 * time.Second
}
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
cmd, prepErr := buildAgentCommand(ctx, "claude", []string{"-p", p.Task}, p.Cwd, p.WSLDistro, p.WSLUser)
if prepErr != nil {
return TextErrorResponse(prepErr.Error()), nil
}
output, err := cmd.CombinedOutput()
result := string(output)
if len(result) > 15000 {
result = result[:15000] + "\n... [truncated]"
}
if err != nil {
errMsg := fmt.Sprintf("Claude error: %v", err)
if ctx.Err() == context.DeadlineExceeded {
errMsg = fmt.Sprintf("Claude timed out after %d seconds. Try splitting the task into smaller parts.", int(timeout.Seconds()))
}
if result != "" {
errMsg += "\n\n" + result
}
return TextErrorResponse(errMsg), nil
}
return TextResponse(result), nil
})
}
type ReadFileParams struct {
Path string `json:"path" description:"Absolute or relative path to the file to read"`
Offset int `json:"offset,omitempty" description:"Line number to start reading from (0-based, default 0)"`
Limit int `json:"limit,omitempty" description:"Maximum number of lines to read (default 200, max 2000)"`
}
func NewReadFileTool() (*ToolDefinition, error) {
return NewTool("read_file",
"Read file contents from the local filesystem. Returns the file content with line numbers. Supports offset/limit for reading specific sections of large files.",
func(ctx context.Context, p ReadFileParams) (ToolResponse, error) {
if p.Path == "" {
return TextErrorResponse("path is required"), nil
}
expanded := expandHome(p.Path)
data, err := readFileLimited(expanded, p.Offset, p.Limit)
if err != nil {
return TextErrorResponse(fmt.Sprintf("read error: %v", err)), nil
}
return TextResponse(data), nil
})
}
type ListFilesParams struct {
Path string `json:"path,omitempty" description:"Directory path to list (default: user home)"`
Depth int `json:"depth,omitempty" description:"Maximum depth to traverse (default 1, max 3)"`
}
func NewListFilesTool() (*ToolDefinition, error) {
return NewTool("list_files",
"List files and directories at a given path. Shows directory tree structure with file names. Useful for exploring project structure or finding specific files.",
func(ctx context.Context, p ListFilesParams) (ToolResponse, error) {
dir := expandHome(p.Path)
if dir == "" {
dir, _ = osUserHomeDir()
}
if p.Depth <= 0 {
p.Depth = 1
}
if p.Depth > 3 {
p.Depth = 3
}
result, err := listDirTree(dir, p.Depth, 0)
if err != nil {
return TextErrorResponse(fmt.Sprintf("list error: %v", err)), nil
}
return TextResponse(result), nil
})
}
type SearchFilesParams struct {
Pattern string `json:"pattern" description:"Search pattern (supports * and ? glob wildcards)"`
Path string `json:"path,omitempty" description:"Directory to search in (default: current directory)"`
}
func NewSearchFilesTool() (*ToolDefinition, error) {
return NewTool("search_files",
"Search for files by name pattern using glob syntax. Use * for any characters, ** for recursive matching. Returns matching file paths sorted by name.",
func(ctx context.Context, p SearchFilesParams) (ToolResponse, error) {
if p.Pattern == "" {
return TextErrorResponse("pattern is required"), nil
}
dir := expandHome(p.Path)
if dir == "" {
dir = "."
}
matches, err := filepath.Glob(filepath.Join(dir, p.Pattern))
if err != nil {
return TextErrorResponse(fmt.Sprintf("glob error: %v", err)), nil
}
if len(matches) == 0 {
return TextResponse("No files found matching pattern."), nil
}
if len(matches) > 100 {
matches = matches[:100]
}
var result strings.Builder
for _, m := range matches {
result.WriteString(m)
result.WriteString("\n")
}
return TextResponse(result.String()), nil
})
}
type GrepContentParams struct {
Pattern string `json:"pattern" description:"Text pattern to search for in file contents"`
Path string `json:"path,omitempty" description:"Directory to search in (default: current directory)"`
Include string `json:"include,omitempty" description:"File extension filter, e.g. '*.go' or '*.{js,ts}'"`
}
func NewGrepContentTool() (*ToolDefinition, error) {
return NewTool("grep_content",
"Search for text patterns inside file contents. Returns matching lines with file paths and line numbers. Use include to filter by file extension.",
func(ctx context.Context, p GrepContentParams) (ToolResponse, error) {
if p.Pattern == "" {
return TextErrorResponse("pattern is required"), nil
}
dir := expandHome(p.Path)
if dir == "" {
dir = "."
}
result, err := grepFiles(dir, p.Pattern, p.Include)
if err != nil {
return TextErrorResponse(fmt.Sprintf("grep error: %v", err)), nil
}
if result == "" {
return TextResponse("No matches found."), nil
}
return TextResponse(result), nil
})
}
type GetConfigParams struct {
Section string `json:"section,omitempty" description:"Config section to retrieve: 'providers', 'profile', 'tools', 'terminal', 'all' (default: 'all')"`
}
func NewGetConfigTool() (*ToolDefinition, error) {
return NewTool("get_config",
"Read the Muyue configuration. Returns provider settings, profile info, installed tools, terminal config, etc. Use section parameter to get a specific part, or 'all' for the full config.",
func(ctx context.Context, p GetConfigParams) (ToolResponse, error) {
return getConfigSection(p.Section), nil
})
}
type SetProviderParams struct {
Name string `json:"name" description:"Provider name (e.g. 'openai', 'anthropic', 'ollama')"`
APIKey string `json:"api_key,omitempty" description:"API key for the provider"`
BaseURL string `json:"base_url,omitempty" description:"Custom base URL for the provider API"`
Model string `json:"model,omitempty" description:"Model identifier to use"`
Active *bool `json:"active,omitempty" description:"Set to true to make this the active provider"`
}
func NewSetProviderTool() (*ToolDefinition, error) {
return NewTool("set_provider",
"Configure an AI provider in Muyue settings. Can create, update, or activate a provider. API keys are automatically encrypted. Set active=true to switch to this provider.",
func(ctx context.Context, p SetProviderParams) (ToolResponse, error) {
if p.Name == "" {
return TextErrorResponse("name is required"), nil
}
return setProviderConfig(p), nil
})
}
type ManageSSHParams struct {
Action string `json:"action" description:"Action to perform: 'list', 'add', 'remove'"`
Name string `json:"name,omitempty" description:"Connection name (required for add/remove)"`
Host string `json:"host,omitempty" description:"SSH host (required for add)"`
Port int `json:"port,omitempty" description:"SSH port (default: 22)"`
User string `json:"user,omitempty" description:"SSH username (required for add)"`
KeyPath string `json:"key_path,omitempty" description:"Path to SSH private key"`
}
func NewManageSSHTool() (*ToolDefinition, error) {
return NewTool("manage_ssh",
"Manage SSH connections configured in Muyue. List existing connections, add new ones, or remove connections. SSH configs are persisted to the Muyue config file.",
func(ctx context.Context, p ManageSSHParams) (ToolResponse, error) {
if p.Action == "" {
return TextErrorResponse("action is required (list, add, remove)"), nil
}
return manageSSHAction(p), nil
})
}
type WebFetchParams struct {
URL string `json:"url" description:"The URL to fetch content from"`
}
func NewWebFetchTool() (*ToolDefinition, error) {
return NewTool("web_fetch",
"Fetch content from a URL and return the text. Useful for reading documentation, APIs, or web resources. Only HTTP/HTTPS URLs are supported.",
func(ctx context.Context, p WebFetchParams) (ToolResponse, error) {
if p.URL == "" {
return TextErrorResponse("url is required"), nil
}
return fetchURL(p.URL), nil
})
}
func DefaultRegistry() *Registry {
r := NewRegistry()
tools := []*ToolDefinition{
must(NewTerminalTool()),
must(NewCrushRunTool()),
must(NewClaudeRunTool()),
must(NewReadFileTool()),
must(NewListFilesTool()),
must(NewSearchFilesTool()),
must(NewGrepContentTool()),
must(NewGetConfigTool()),
must(NewSetProviderTool()),
must(NewManageSSHTool()),
must(NewWebFetchTool()),
}
for _, t := range tools {
if err := r.Register(t); err != nil {
panic(err)
}
}
return r
}
func must(t *ToolDefinition, err error) *ToolDefinition {
if err != nil {
panic(err)
}
return t
}

616
internal/agent/impl.go Normal file
View File

@@ -0,0 +1,616 @@
package agent
import (
"bufio"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"regexp"
"runtime"
"strings"
"time"
)
func detectShell() string {
shells := []string{"zsh", "bash", "fish", "pwsh", "powershell"}
for _, s := range shells {
if path, err := exec.LookPath(s); err == nil {
return path
}
}
return "/bin/sh"
}
var validIdentifier = regexp.MustCompile(`^[a-zA-Z0-9._-]+$`)
// buildAgentCommand assembles an agent execution command, optionally launching it
// inside a WSL distribution (Windows host only) and applying a working directory.
// On non-Windows hosts, wsl_* parameters are ignored.
func buildAgentCommand(ctx context.Context, bin string, args []string, cwd, wslDistro, wslUser string) (*exec.Cmd, error) {
if wslDistro != "" && runtime.GOOS == "windows" {
if !validIdentifier.MatchString(wslDistro) {
return nil, fmt.Errorf("invalid wsl_distro: %q", wslDistro)
}
if wslUser != "" && !validIdentifier.MatchString(wslUser) {
return nil, fmt.Errorf("invalid wsl_user: %q", wslUser)
}
wslArgs := []string{"-d", wslDistro}
if wslUser != "" {
wslArgs = append(wslArgs, "-u", wslUser)
}
if cwd != "" {
wslArgs = append(wslArgs, "--cd", cwd)
}
wslArgs = append(wslArgs, "--")
wslArgs = append(wslArgs, bin)
wslArgs = append(wslArgs, args...)
return exec.CommandContext(ctx, "wsl", wslArgs...), nil
}
cmd := exec.CommandContext(ctx, bin, args...)
if cwd != "" {
dir := expandHome(cwd)
if info, err := os.Stat(dir); err != nil || !info.IsDir() {
return nil, fmt.Errorf("cwd does not exist or is not a directory: %s", cwd)
}
cmd.Dir = dir
}
return cmd, nil
}
func expandHome(path string) string {
if path == "" {
return ""
}
if path == "~" {
home, _ := os.UserHomeDir()
return home
}
if strings.HasPrefix(path, "~/") {
home, _ := os.UserHomeDir()
return filepath.Join(home, path[2:])
}
return path
}
func osUserHomeDir() (string, error) {
return os.UserHomeDir()
}
func readFileLimited(path string, offset, limit int) (string, error) {
data, err := os.ReadFile(path)
if err != nil {
return "", err
}
lines := strings.Split(string(data), "\n")
if offset < 0 {
offset = 0
}
if offset > len(lines) {
offset = len(lines)
}
end := offset + limit
if limit <= 0 || limit > 2000 {
limit = 2000
}
if end > len(lines) {
end = len(lines)
}
if end-offset > limit {
end = offset + limit
}
selected := lines[offset:end]
var buf strings.Builder
for i, line := range selected {
fmt.Fprintf(&buf, "%6d\t%s\n", offset+i+1, line)
}
return buf.String(), nil
}
func listDirTree(dir string, maxDepth, currentDepth int) (string, error) {
info, err := os.Stat(dir)
if err != nil {
return "", err
}
if !info.IsDir() {
return dir + "\n", nil
}
entries, err := os.ReadDir(dir)
if err != nil {
return "", err
}
var buf strings.Builder
indent := strings.Repeat(" ", currentDepth)
for _, entry := range entries {
name := entry.Name()
if strings.HasPrefix(name, ".") && name != "." && name != ".." {
continue
}
if entry.IsDir() {
fmt.Fprintf(&buf, "%s%s/\n", indent, name)
if currentDepth < maxDepth {
sub, err := listDirTree(filepath.Join(dir, name), maxDepth, currentDepth+1)
if err == nil {
buf.WriteString(sub)
}
}
} else {
fmt.Fprintf(&buf, "%s%s\n", indent, name)
}
}
return buf.String(), nil
}
func grepFiles(dir, pattern, include string) (string, error) {
if include != "" {
matches, err := filepath.Glob(filepath.Join(dir, include))
if err != nil {
return "", err
}
if len(matches) == 0 {
return "", nil
}
var buf strings.Builder
for _, match := range matches {
result, err := grepInFile(match, pattern)
if err != nil {
continue
}
buf.WriteString(result)
}
return buf.String(), nil
}
return grepInDir(dir, pattern, 0)
}
func grepInDir(dir, pattern string, depth int) (string, error) {
if depth > 10 {
return "", nil
}
var buf strings.Builder
entries, err := os.ReadDir(dir)
if err != nil {
return "", err
}
for _, entry := range entries {
name := entry.Name()
if strings.HasPrefix(name, ".") {
continue
}
path := filepath.Join(dir, name)
if entry.IsDir() {
sub, err := grepInDir(path, pattern, depth+1)
if err == nil {
buf.WriteString(sub)
}
continue
}
result, err := grepInFile(path, pattern)
if err != nil {
continue
}
buf.WriteString(result)
}
return buf.String(), nil
}
func grepInFile(path, pattern string) (string, error) {
re, err := regexp.Compile(pattern)
if err != nil {
re, err = regexp.Compile(regexp.QuoteMeta(pattern))
if err != nil {
return "", err
}
}
file, err := os.Open(path)
if err != nil {
return "", err
}
defer file.Close()
var buf strings.Builder
scanner := bufio.NewScanner(file)
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
lineNum := 0
matchCount := 0
for scanner.Scan() {
lineNum++
if re.MatchString(scanner.Text()) {
fmt.Fprintf(&buf, "%s:%d: %s\n", path, lineNum, scanner.Text())
matchCount++
if matchCount >= 50 {
buf.WriteString("... [truncated, more matches exist]\n")
break
}
}
}
return buf.String(), nil
}
func getConfigSection(section string) ToolResponse {
configPath, err := os.UserConfigDir()
if err != nil {
return TextErrorResponse(fmt.Sprintf("cannot find config dir: %v", err))
}
configPath = filepath.Join(configPath, "muyue", "config.yaml")
data, err := os.ReadFile(configPath)
if err != nil {
return TextErrorResponse(fmt.Sprintf("cannot read config: %v", err))
}
switch section {
case "providers", "profile", "tools", "terminal":
sectionData := extractYAMLSection(data, section)
if sectionData == "" {
return TextResponse(fmt.Sprintf("Section '%s' not found in config.", section))
}
return TextResponse(sectionData)
default:
content := string(data)
if len(content) > 8000 {
content = content[:8000] + "\n... [truncated]"
}
return TextResponse(content)
}
}
func extractYAMLSection(data []byte, section string) string {
lines := strings.Split(string(data), "\n")
inSection := false
indentLevel := 0
var buf strings.Builder
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
if inSection {
buf.WriteString("\n")
}
continue
}
if !inSection {
if strings.HasPrefix(trimmed, section+":") || strings.HasPrefix(trimmed, section+" ") {
inSection = true
indentLevel = len(line) - len(strings.TrimLeft(line, " "))
buf.WriteString(line)
buf.WriteString("\n")
}
continue
}
currentIndent := len(line) - len(strings.TrimLeft(line, " "))
if currentIndent <= indentLevel && trimmed != "" {
break
}
buf.WriteString(line)
buf.WriteString("\n")
}
return strings.TrimSpace(buf.String())
}
func setProviderConfig(p SetProviderParams) ToolResponse {
configPath, err := os.UserConfigDir()
if err != nil {
return TextErrorResponse(fmt.Sprintf("cannot find config dir: %v", err))
}
configPath = filepath.Join(configPath, "muyue", "config.yaml")
data, err := os.ReadFile(configPath)
if err != nil {
return TextErrorResponse(fmt.Sprintf("cannot read config: %v", err))
}
lines := strings.Split(string(data), "\n")
inProviders := false
providerIndent := 0
foundProvider := false
insertIdx := -1
lastProviderEnd := -1
for i, line := range lines {
trimmed := strings.TrimSpace(line)
if !inProviders {
if strings.HasPrefix(trimmed, "providers:") {
inProviders = true
providerIndent = len(line) - len(strings.TrimLeft(line, " "))
}
continue
}
currentIndent := len(line) - len(strings.TrimLeft(line, " "))
if currentIndent <= providerIndent && trimmed != "" && !strings.HasPrefix(trimmed, "#") {
lastProviderEnd = i
break
}
if currentIndent == providerIndent+2 && strings.HasPrefix(trimmed, "- name:") {
nameMatch := strings.TrimPrefix(trimmed, "- name:")
nameMatch = strings.TrimSpace(nameMatch)
if nameMatch == p.Name {
foundProvider = true
insertIdx = i
}
if insertIdx == -1 || insertIdx < i {
insertIdx = i
}
}
}
if lastProviderEnd == -1 {
lastProviderEnd = len(lines)
}
entryIndent := strings.Repeat(" ", providerIndent+4)
var newEntry strings.Builder
newEntry.WriteString(fmt.Sprintf(" - name: %s\n", p.Name))
if p.Model != "" {
newEntry.WriteString(fmt.Sprintf("%smodel: %s\n", entryIndent, p.Model))
}
if p.BaseURL != "" {
newEntry.WriteString(fmt.Sprintf("%sbase_url: %s\n", entryIndent, p.BaseURL))
}
if p.APIKey != "" {
newEntry.WriteString(fmt.Sprintf("%sapi_key: %s\n", entryIndent, p.APIKey))
}
if p.Active != nil {
newEntry.WriteString(fmt.Sprintf("%sactive: %v\n", entryIndent, *p.Active))
}
if foundProvider && insertIdx >= 0 {
var endIdx int
for endIdx = insertIdx + 1; endIdx < len(lines); endIdx++ {
li := len(lines[endIdx]) - len(strings.TrimLeft(lines[endIdx], " "))
if li <= providerIndent+2 || lines[endIdx] == "" {
if endIdx > insertIdx+1 && strings.TrimSpace(lines[endIdx]) == "" {
continue
}
break
}
}
newLines := make([]string, 0, len(lines))
newLines = append(newLines, lines[:insertIdx]...)
newLines = append(newLines, strings.TrimSuffix(newEntry.String(), "\n"))
newLines = append(newLines, lines[endIdx:]...)
lines = newLines
} else {
insertAt := lastProviderEnd
newLines := make([]string, 0, len(lines)+10)
newLines = append(newLines, lines[:insertAt]...)
newLines = append(newLines, strings.TrimSuffix(newEntry.String(), "\n"))
newLines = append(newLines, lines[insertAt:]...)
lines = newLines
}
content := strings.Join(lines, "\n")
if err := os.WriteFile(configPath, []byte(content), 0600); err != nil {
return TextErrorResponse(fmt.Sprintf("write config error: %v", err))
}
return TextResponse(fmt.Sprintf("Provider '%s' configured successfully.", p.Name))
}
func manageSSHAction(p ManageSSHParams) ToolResponse {
configPath, err := os.UserConfigDir()
if err != nil {
return TextErrorResponse(fmt.Sprintf("cannot find config dir: %v", err))
}
configPath = filepath.Join(configPath, "muyue", "config.yaml")
data, err := os.ReadFile(configPath)
if err != nil {
return TextErrorResponse(fmt.Sprintf("cannot read config: %v", err))
}
switch p.Action {
case "list":
sshSection := extractYAMLSection(data, "ssh")
if sshSection == "" {
return TextResponse("No SSH connections configured.")
}
return TextResponse(sshSection)
case "add":
if p.Name == "" || p.Host == "" || p.User == "" {
return TextErrorResponse("name, host, and user are required for add action")
}
if p.Port == 0 {
p.Port = 22
}
lines := strings.Split(string(data), "\n")
sshIdx := -1
sshIndent := 0
lastSSHEnd := -1
for i, line := range lines {
trimmed := strings.TrimSpace(line)
if sshIdx == -1 && strings.HasPrefix(trimmed, "ssh:") {
sshIdx = i
sshIndent = len(line) - len(strings.TrimLeft(line, " "))
continue
}
if sshIdx != -1 {
li := len(line) - len(strings.TrimLeft(line, " "))
if li <= sshIndent && trimmed != "" {
lastSSHEnd = i
break
}
}
}
if lastSSHEnd == -1 {
lastSSHEnd = len(lines)
}
entry := fmt.Sprintf(" - name: %s\n host: %s\n port: %d\n user: %s", p.Name, p.Host, p.Port, p.User)
if p.KeyPath != "" {
entry += fmt.Sprintf("\n key_path: %s", p.KeyPath)
}
newLines := make([]string, 0, len(lines)+10)
newLines = append(newLines, lines[:lastSSHEnd]...)
newLines = append(newLines, entry)
newLines = append(newLines, lines[lastSSHEnd:]...)
if err := os.WriteFile(configPath, []byte(strings.Join(newLines, "\n")), 0600); err != nil {
return TextErrorResponse(fmt.Sprintf("write config error: %v", err))
}
return TextResponse(fmt.Sprintf("SSH connection '%s' (%s@%s:%d) added.", p.Name, p.User, p.Host, p.Port))
case "remove":
if p.Name == "" {
return TextErrorResponse("name is required for remove action")
}
lines := strings.Split(string(data), "\n")
newLines := make([]string, 0, len(lines))
skipping := false
removed := false
for i, line := range lines {
trimmed := strings.TrimSpace(line)
if strings.Contains(trimmed, "name: "+p.Name) && strings.HasPrefix(trimmed, "-") {
skipping = true
removed = true
continue
}
if skipping {
li := len(line) - len(strings.TrimLeft(line, " "))
if li > 6 && i < len(lines)-1 && strings.TrimSpace(lines[i+1]) != "" {
continue
}
skipping = false
continue
}
newLines = append(newLines, line)
}
if !removed {
return TextErrorResponse(fmt.Sprintf("SSH connection '%s' not found.", p.Name))
}
if err := os.WriteFile(configPath, []byte(strings.Join(newLines, "\n")), 0600); err != nil {
return TextErrorResponse(fmt.Sprintf("write config error: %v", err))
}
return TextResponse(fmt.Sprintf("SSH connection '%s' removed.", p.Name))
default:
return TextErrorResponse("unknown action. Use 'list', 'add', or 'remove'")
}
}
func fetchURL(url string) ToolResponse {
if !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") {
return TextErrorResponse("only http/https URLs are supported")
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return TextErrorResponse(fmt.Sprintf("create request: %v", err))
}
req.Header.Set("User-Agent", "MuyueStudio/1.0")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return TextErrorResponse(fmt.Sprintf("fetch error: %v", err))
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 50000))
if err != nil {
return TextErrorResponse(fmt.Sprintf("read error: %v", err))
}
if resp.StatusCode != http.StatusOK {
return TextErrorResponse(fmt.Sprintf("HTTP %d: %s", resp.StatusCode, truncate(string(body), 2000)))
}
contentType := resp.Header.Get("Content-Type")
if strings.Contains(contentType, "text/html") {
text := stripHTML(string(body))
if len(text) > 8000 {
text = text[:8000] + "\n... [truncated]"
}
return TextResponse(text)
}
result := string(body)
if len(result) > 10000 {
result = result[:10000] + "\n... [truncated]"
}
return TextResponse(result)
}
func truncate(s string, maxLen int) string {
if len(s) <= maxLen {
return s
}
return s[:maxLen] + "..."
}
func stripHTML(html string) string {
tagRe := regexp.MustCompile(`<[^>]*>`)
text := tagRe.ReplaceAllString(html, " ")
entityRe := regexp.MustCompile(`&[a-zA-Z]+;`)
text = entityRe.ReplaceAllStringFunc(text, func(s string) string {
switch s {
case "&amp;":
return "&"
case "&lt;":
return "<"
case "&gt;":
return ">"
case "&quot;":
return "\""
case "&#39;":
return "'"
case "&nbsp;":
return " "
default:
return " "
}
})
multiSpace := regexp.MustCompile(`\s+`)
text = multiSpace.ReplaceAllString(text, " ")
return strings.TrimSpace(text)
}
var _ = runtime.GOOS
var _ = json.Marshal

10
internal/agent/prompt.go Normal file
View File

@@ -0,0 +1,10 @@
package agent
import _ "embed"
//go:embed prompts/studio_system.md
var studioSystemPrompt string
func StudioSystemPrompt() string {
return studioSystemPrompt
}

View File

@@ -0,0 +1,171 @@
Tu es l'assistant IA de **Muyue Studio**, le centre de commandement de l'environnement de développement de l'utilisateur, et tu es spécialisé dans la **construction de prompts** selon la **méthode BMAD** (Breakthrough Method for Agile AI-Driven Development — https://github.com/bmad-code-org/BMAD-METHOD).
Tu es intégré dans Muyue, un gestionnaire d'environnement de développement de bureau. Ton rôle est double :
1. Aider l'utilisateur à configurer, gérer et optimiser son environnement dev (avec les outils ci-dessous).
2. Construire pour lui des prompts structurés et actionnables avant d'exécuter une tâche complexe ou de la déléguer à un agent (`crush_run`, `claude_run`).
## Méthode BMAD — principes appliqués à chaque réponse
BMAD organise le travail IA comme une équipe agile : chaque demande est traitée avec une persona spécifique (Analyst, PM, Architect, SM, Dev, QA) puis exécutée. Tu n'as pas besoin de jouer toutes les personas — applique simplement leurs réflexes :
- **Analyst** : reformule l'objectif réel derrière la demande en 1 phrase. S'il est ambigu, choisis l'interprétation la plus probable et indique-la au début.
- **PM** : découpe en livrables concrets (épopée → stories). Pas plus de 3-5 stories pour une demande, chaque story doit être indépendamment livrable.
- **Architect** : pour toute story qui touche au code, identifie les fichiers concernés, les contraintes (compat, style, perf, sécurité) et les risques avant d'écrire.
- **SM (Scrum Master)** : si tu délègues à `crush_run`/`claude_run`, fournis un prompt **autonome** : objectif, contraintes, fichiers cibles, critère d'acceptation. Pas de référence à la conversation parente — l'agent ne la voit pas.
- **Dev** : exécute story par story. Vérifie chaque livraison avant de passer à la suivante.
- **QA** : avant de répondre "fini", relis l'objectif initial et confirme qu'il est atteint.
## Format d'un prompt BMAD délégué
Quand tu construis un prompt pour `crush_run`/`claude_run`, suis ce gabarit :
```
[OBJECTIF] <une phrase, l'objectif final>
[CONTEXTE] <fichiers/dossiers concernés, ce qui existe déjà>
[CONTRAINTES] <ne pas faire X, préserver Y, respecter style Z>
[LIVRABLE] <fichier(s) modifié(s), comportement attendu>
[CRITÈRE D'ACCEPTATION] <comment savoir que c'est fini>
```
Ce gabarit est **obligatoire** pour toute délégation à un agent. Il évite que l'agent erre, suppose, ou produise du code hors-périmètre.
<critical_rules>
1. **AGIS, ne décris pas** — Si l'utilisateur demande de faire quelque chose, utilise les outils immédiatement. Ne dis pas "je pourrais faire X" — fais-le.
2. **SOIS AUTONOME** — Ne pose pas de questions si tu peux chercher, lire, déduire. Essaie plusieurs approches avant de bloquer. Ne t'arrête que pour les erreurs bloquantes réelles (credentials manquants, permissions, etc.).
3. **SOIS CONCIS** — Max 4 lignes par défaut. Pas de préambule ("Voici...", "Je vais..."), pas de postambule ("N'hésitez pas...", "J'espère que..."). Réponse directe. Un mot quand c'est suffisant.
4. **GÈRE LES ERREURS** — Si un outil échoue, essaie 2-3 approches alternatives avant de rapporter l'échec. Lis le message d'erreur complet, isole la cause racine.
5. **NE DEVINE PAS** — Lis les fichiers avant d'éditer. Utilise les outils pour obtenir les informations manquantes (lire, chercher, grep).
6. **CONFIDENTIALITÉ** — Ne révèle jamais les clés API, mots de passe, tokens ou informations sensibles.
7. **LANGUE** — Réponds dans la même langue que l'utilisateur.
</critical_rules>
## Environnement
Muyue gère :
- **Fournisseurs IA** (OpenAI, Anthropic, Ollama, MiniMax, etc.)
- **Outils de développement** (Crush, Claude Code, etc.)
- **Terminaux locaux et SSH**
- **Configuration et préférences**
- **Serveurs MCP et LSP**
## Outils disponibles
| Outil | Usage |
|-------|-------|
| **terminal** | Exécuter des commandes shell (builds, tests, git, etc.) |
| **crush_run** | Déléguer une tâche complexe à Crush (édition de fichiers, refactoring, debug) — préfère cet outil pour les tâches multi-fichiers ou l'écriture de code |
| **claude_run** | Déléguer une tâche complexe à Claude Code CLI |
| **read_file** | Lire le contenu d'un fichier |
| **list_files** | Lister les fichiers d'un répertoire |
| **search_files** | Chercher des fichiers par motif (glob) |
| **grep_content** | Chercher du texte dans les fichiers |
| **get_config** | Lire la configuration Muyue |
| **set_provider** | Configurer un fournisseur IA |
| **manage_ssh** | Gérer les connexions SSH |
| **web_fetch** | Récupérer le contenu d'une URL |
| **browser_test** | Piloter un onglet de navigateur de l'utilisateur (clic, eval, lecture console) — voir `<browser_test_strategy>` ci-dessous |
<browser_test_strategy>
Quand l'utilisateur demande de **tester** une UI (ses boutons, ses formulaires, son comportement), utilise `browser_test`. La page cible doit être connectée via le snippet de l'onglet **Tests** — sinon, l'outil te le dira et tu demandes à l'utilisateur de coller le snippet (le même token reste valide même après reload : si la connexion est perdue, l'utilisateur n'a qu'à re-coller).
## Règle d'or — économise les appels d'outils
**N'appelle PAS `list_clickables` après chaque clic.** C'est l'erreur n°1 qui fait exploser ta boucle (150+ appels pour 5 actions humaines). La liste change rarement et chaque appel renvoie ~30-100 éléments.
Stratégie efficace :
1. **Au début** : `summary` (URL + console + 20 lignes) → `list_clickables` (UNE FOIS, mémorise les index pertinents pour ta tâche).
2. **Pendant** : clique par `index`. Lis le `console_delta` retourné après chaque clic.
3. **Re-list seulement si** :
- le `current_url` retourné change ET la nouvelle page est inconnue,
- OU un clic ouvre un dialog / nouveau composant que tu dois inspecter,
- OU `click` retourne `element not found` (DOM a muté).
4. Pour les pages SPA qui rechargent côté URL mais pas le DOM, vérifie d'abord avec `eval document.querySelectorAll('button').length` — si stable, ne re-liste pas.
5. Si tu te sens bloqué, **ne boucle pas en aveugle**. Fais 1 `summary`, 1 `eval` ciblé, et demande de l'aide à l'utilisateur. Mieux vaut 5 appels et une question qu'une boucle de 50 appels.
## Actions disponibles
| Action | Quand l'utiliser |
|---|---|
| `summary` | État de la page (URL, titre, 20 dernières lignes console). Appel **bon marché**. |
| `list_clickables` | Liste indexée des boutons/liens/inputs visibles. **Appel cher** (~50+ items) — utilise avec parcimonie. |
| `click` (par `index` de préférence) | Clique. Retourne `console_delta` + `current_url`. |
| `type` | Remplit un input (par `selector` ou `index`). Toujours suivi d'un `click` sur le bouton submit. |
| `eval` | JS arbitraire. Idéal pour des questions ciblées (`document.title`, `document.querySelectorAll(X).length`, etc.) au lieu de `list_clickables` complet. |
| `current_url` | URL+titre. Très bon marché. |
| `wait` | Pause 200-500 ms après une action async (transition / fetch). |
| `console` | N dernières lignes console (default 50). Pour debug post-incident. |
| `screenshot` | Capture viewport (ou `selector`) et sauve dans `~/.muyue/screenshots/<filename>.png`. Utilise `filename` pour nommer ; sinon timestamp. Best-effort (CSS externe / images peuvent ne pas apparaître). |
## Rapport final
Quand tous les tests sont terminés, fournis un rapport **structuré et bref** :
```
✓ Boutons OK : <liste des labels>
✗ Boutons cassés : <label> — <message d'erreur exact du console_delta>
⚠ Bloqués : <label> — <pourquoi> (disabled, non trouvé, etc.)
📸 Captures : <chemins relatifs sous ~/.muyue/screenshots/>
```
Astuces :
- Clique **par index** ; le sélecteur peut changer avec le DOM, l'index reste stable jusqu'au prochain `list_clickables`.
- N'utilise jamais `eval` pour cliquer si `click` suffit.
- Si la page se recharge (`current_url` change ou la connexion tombe), demande à l'utilisateur de recoller le snippet — le même token marche.
</browser_test_strategy>
<tool_strategy>
- **Recherche avant action** — Utilise `search_files`, `grep_content`, `read_file` avant de supposer quoi que ce soit sur l'état du système
- **Délégation intelligente** — Pour les tâches complexes (refactoring, création de fichiers, debug multi-fichiers), utilise `crush_run` au lieu d'enchaîner des commandes terminal
- **Lecture de fichiers** — Utilise TOUJOURS `read_file` pour lire le contenu d'un fichier. N'utilise PAS `terminal` avec `cat` pour lire des fichiers — `read_file` est plus rapide, plus précis, et consomme moins de tokens
- **Parallélisme** — Lance plusieurs appels d'outils en parallèle quand les opérations sont indépendantes
- **Troncature** — Si un résultat d'outil dépasse 2000 caractères, résume les points clés au lieu de tout afficher
- **Une chose à la fois** — Sauf si les opérations sont indépendantes, exécute séquentiellement
</tool_strategy>
<decision_making>
- Décide par toi-même : cherche, lis, déduis, agis
- Ne demande confirmation que pour : actions destructrices (suppression, overwrite), plusieurs approches valides avec des trade-offs importants
- Si bloqué : documente (a) ce que tu as essayé, (b) pourquoi tu es bloqué, (c) l'action minimale requise
- Ne t'arrête jamais pour : tâche trop grosse (découpe), trop de fichiers (change-les), complexité (gère-la)
</decision_making>
<error_recovery>
1. Lis le message d'erreur complet
2. Comprends la cause racine
3. Essaie une approche différente (pas la même)
4. Cherche du code similaire qui fonctionne
5. Applique un correctif ciblé
6. Vérifie que ça marche
7. Pour chaque erreur, essaie au moins 2-3 stratégies avant de conclure que c'est bloquant
</error_recovery>
## Format des réponses
- **Code** : blocs markdown avec le langage spécifié
- **Résultats d'outils** : résume les points clés, max 2000 caractères, ne copie pas des milliers de lignes
- **Erreurs** : explique clairement la cause et propose une solution concrète
- **Succès** : confirme brièvement ce qui a été fait (1 ligne)
- **Multi-fichiers** : liste les fichiers modifiés avec `fichier:ligne` pour les références
## Diagrammes Mermaid
Tu peux utiliser des diagrammes Mermaid pour visualiser des architectures, flux, séquences, etc.
Utilise un bloc code avec le langage `mermaid` :
```mermaid
graph TD
A[Début] --> B{Décision}
B -->|Oui| C[Action]
B -->|Non| D[Fin]
```
Types utiles :
- `graph TD/LR` — Architecture, flux de données
- `sequenceDiagram` — Interactions entre composants
- `flowchart` — Processus et décisions
- `classDiagram` — Structures de données
- `erDiagram` — Schémas de base de données
- `gantt` — Planning et timelines
Utilise Mermaid quand ça apporte de la clarté : architecture complexe, flux multi-étapes, relations entre entités. Ne l'utilise pas pour du texte simple.

218
internal/agent/tools.go Normal file
View File

@@ -0,0 +1,218 @@
package agent
import (
"context"
"encoding/json"
"fmt"
"reflect"
"strings"
)
type ToolCall struct {
ID string `json:"id"`
Name string `json:"name"`
Arguments json.RawMessage `json:"arguments"`
}
type ToolResponse struct {
Content string `json:"content"`
IsError bool `json:"is_error"`
Meta map[string]string `json:"meta,omitempty"`
}
func TextResponse(content string) ToolResponse {
return ToolResponse{Content: content}
}
func TextErrorResponse(msg string) ToolResponse {
return ToolResponse{Content: msg, IsError: true}
}
type ToolDefinition struct {
Name string `json:"name"`
Description string `json:"description"`
Params json.RawMessage `json:"parameters"`
Handler func(ctx context.Context, args json.RawMessage) (ToolResponse, error)
}
func (td *ToolDefinition) Execute(ctx context.Context, call ToolCall) (ToolResponse, error) {
resp, err := td.Handler(ctx, call.Arguments)
if err != nil {
return ToolResponse{Content: err.Error(), IsError: true}, nil
}
return resp, nil
}
func (td *ToolDefinition) ToOpenAITool() map[string]interface{} {
return map[string]interface{}{
"type": "function",
"function": map[string]interface{}{
"name": td.Name,
"description": td.Description,
"parameters": td.Params,
},
}
}
func NewTool[P any](name, description string, handler func(ctx context.Context, params P) (ToolResponse, error)) (*ToolDefinition, error) {
var zero P
paramsSchema, err := generateSchema(zero)
if err != nil {
return nil, fmt.Errorf("generate schema for %s: %w", name, err)
}
wrappedHandler := func(ctx context.Context, raw json.RawMessage) (ToolResponse, error) {
var params P
if err := json.Unmarshal(raw, &params); err != nil {
return TextErrorResponse(fmt.Sprintf("invalid arguments: %v", err)), nil
}
return handler(ctx, params)
}
return &ToolDefinition{
Name: name,
Description: description,
Params: paramsSchema,
Handler: wrappedHandler,
}, nil
}
type Registry struct {
tools map[string]*ToolDefinition
}
func NewRegistry() *Registry {
return &Registry{
tools: make(map[string]*ToolDefinition),
}
}
func (r *Registry) Register(tool *ToolDefinition) error {
if _, exists := r.tools[tool.Name]; exists {
return fmt.Errorf("tool %q already registered", tool.Name)
}
r.tools[tool.Name] = tool
return nil
}
func (r *Registry) Get(name string) (*ToolDefinition, bool) {
t, ok := r.tools[name]
return t, ok
}
func (r *Registry) All() []*ToolDefinition {
out := make([]*ToolDefinition, 0, len(r.tools))
for _, t := range r.tools {
out = append(out, t)
}
return out
}
func (r *Registry) OpenAITools() []map[string]interface{} {
out := make([]map[string]interface{}, 0, len(r.tools))
for _, t := range r.tools {
out = append(out, t.ToOpenAITool())
}
return out
}
func (r *Registry) Execute(ctx context.Context, call ToolCall) (ToolResponse, error) {
tool, ok := r.tools[call.Name]
if !ok {
return TextErrorResponse(fmt.Sprintf("unknown tool: %s", call.Name)), nil
}
return tool.Execute(ctx, call)
}
func generateSchema(v interface{}) (json.RawMessage, error) {
t := reflect.TypeOf(v)
if t == nil {
return json.RawMessage(`{"type":"object","properties":{}}`), nil
}
if t.Kind() == reflect.Ptr {
t = t.Elem()
}
if t.Kind() != reflect.Struct {
return json.RawMessage(`{"type":"object","properties":{}}`), nil
}
props := make(map[string]interface{})
required := []string{}
for i := 0; i < t.NumField(); i++ {
field := t.Field(i)
if !field.IsExported() {
continue
}
jsonTag := field.Tag.Get("json")
if jsonTag == "-" {
continue
}
jsonName := field.Name
parts := strings.Split(jsonTag, ",")
if parts[0] != "" {
jsonName = parts[0]
}
omitempty := false
for _, part := range parts[1:] {
if part == "omitempty" {
omitempty = true
}
}
desc := field.Tag.Get("description")
prop := map[string]interface{}{
"type": goTypeToJSON(field.Type),
}
if desc != "" {
prop["description"] = desc
}
props[jsonName] = prop
if !omitempty {
required = append(required, jsonName)
}
}
schema := map[string]interface{}{
"type": "object",
"properties": props,
}
if len(required) > 0 {
schema["required"] = required
}
data, err := json.Marshal(schema)
if err != nil {
return nil, err
}
return json.RawMessage(data), nil
}
func goTypeToJSON(t reflect.Type) string {
switch t.Kind() {
case reflect.String:
return "string"
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64,
reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
return "integer"
case reflect.Float32, reflect.Float64:
return "number"
case reflect.Bool:
return "boolean"
case reflect.Slice:
if t.Elem().Kind() == reflect.Uint8 {
return "string"
}
return "array"
case reflect.Map:
return "object"
default:
return "string"
}
}

772
internal/api/browsertest.go Normal file
View File

@@ -0,0 +1,772 @@
package api
// Browser-test feature: an out-of-process page (the user's target tab)
// connects to Muyue via WebSocket using a short-lived token, and exposes a
// thin RPC: Studio's AI can list clickable elements, click them, evaluate JS,
// read the recent console buffer, and observe what changes after each action.
//
// Threat model: an injected snippet runs in the user's chosen page only, with
// the same origin as that page; the WS endpoint is bound to localhost and
// gated by a 5-minute token issued by the local Muyue server.
import (
"context"
"crypto/rand"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
"os"
"strings"
"sync"
"time"
"github.com/gorilla/websocket"
"github.com/muyue/muyue/internal/agent"
)
// thin os wrappers (kept here so saveScreenshot stays independent of any
// existing helper file's evolution)
func osUserHomeDir() (string, error) { return os.UserHomeDir() }
func mkdirAll(p string, m os.FileMode) error { return os.MkdirAll(p, m) }
func writeFile(p string, b []byte, m os.FileMode) error { return os.WriteFile(p, b, m) }
func base64StdDecode(s string) ([]byte, error) { return base64.StdEncoding.DecodeString(s) }
const (
// browserTestTokenTTL is a sliding window: every successful WS connect
// using the token resets it. So the user re-pasting the snippet after a
// page reload / navigation seamlessly resumes (same token, same session
// continuation in the AI's view), as long as no more than this gap of
// inactivity occurs.
browserTestTokenTTL = 60 * time.Minute
browserTestCommandTTL = 30 * time.Second
browserTestConsoleMax = 200
browserTestSessionsMax = 16
)
// BrowserTestSession represents one connected browser tab.
type BrowserTestSession struct {
ID string
URL string
Title string
conn *websocket.Conn
mu sync.Mutex
console []ConsoleEntry
pending map[string]chan json.RawMessage
pendingMu sync.Mutex
connectedAt time.Time
writeMu sync.Mutex
}
// ConsoleEntry is a captured console message from the connected page.
type ConsoleEntry struct {
Level string `json:"level"` // log, info, warn, error, debug
Message string `json:"message"`
Time string `json:"time"`
}
// BrowserTestStore manages active sessions + pending one-shot connect tokens.
type BrowserTestStore struct {
mu sync.RWMutex
sessions map[string]*BrowserTestSession
tokens map[string]time.Time
tokensMu sync.Mutex
}
func NewBrowserTestStore() *BrowserTestStore {
return &BrowserTestStore{
sessions: map[string]*BrowserTestSession{},
tokens: map[string]time.Time{},
}
}
// IssueToken creates a single-use token used by the snippet to authenticate.
func (s *BrowserTestStore) IssueToken() string {
buf := make([]byte, 16)
if _, err := rand.Read(buf); err != nil {
return fmt.Sprintf("fallback-%d", time.Now().UnixNano())
}
tok := hex.EncodeToString(buf)
s.tokensMu.Lock()
now := time.Now()
for k, v := range s.tokens {
if now.Sub(v) > browserTestTokenTTL {
delete(s.tokens, k)
}
}
s.tokens[tok] = now
s.tokensMu.Unlock()
return tok
}
// ConsumeToken validates a token. Tokens are no longer single-use:
// the test snippet re-establishes the WS after every page reload /
// navigation, so the same token must work multiple times. We slide the
// expiration on each successful use so a long active test session keeps
// the token alive.
func (s *BrowserTestStore) ConsumeToken(tok string) bool {
s.tokensMu.Lock()
defer s.tokensMu.Unlock()
t, ok := s.tokens[tok]
if !ok {
return false
}
if time.Since(t) > browserTestTokenTTL {
delete(s.tokens, tok)
return false
}
s.tokens[tok] = time.Now() // sliding refresh
return true
}
// Register inserts a new session, evicting the oldest if at capacity.
func (s *BrowserTestStore) Register(session *BrowserTestSession) {
s.mu.Lock()
defer s.mu.Unlock()
if len(s.sessions) >= browserTestSessionsMax {
var oldestID string
var oldest time.Time
for id, sess := range s.sessions {
if oldestID == "" || sess.connectedAt.Before(oldest) {
oldestID = id
oldest = sess.connectedAt
}
}
if old, ok := s.sessions[oldestID]; ok {
old.conn.Close()
delete(s.sessions, oldestID)
}
}
s.sessions[session.ID] = session
}
func (s *BrowserTestStore) Remove(id string) {
s.mu.Lock()
defer s.mu.Unlock()
if sess, ok := s.sessions[id]; ok {
sess.conn.Close()
delete(s.sessions, id)
}
}
func (s *BrowserTestStore) Get(id string) *BrowserTestSession {
s.mu.RLock()
defer s.mu.RUnlock()
return s.sessions[id]
}
// Pick returns the requested session by ID, or the most-recently-connected
// session if id is empty. Returns nil if no session matches.
func (s *BrowserTestStore) Pick(id string) *BrowserTestSession {
s.mu.RLock()
defer s.mu.RUnlock()
if id != "" {
return s.sessions[id]
}
var picked *BrowserTestSession
for _, sess := range s.sessions {
if picked == nil || sess.connectedAt.After(picked.connectedAt) {
picked = sess
}
}
return picked
}
func (s *BrowserTestStore) List() []map[string]interface{} {
s.mu.RLock()
defer s.mu.RUnlock()
out := make([]map[string]interface{}, 0, len(s.sessions))
for _, sess := range s.sessions {
out = append(out, map[string]interface{}{
"id": sess.ID,
"url": sess.URL,
"title": sess.Title,
"connected_at": sess.connectedAt.Format(time.RFC3339),
})
}
return out
}
// Send issues an RPC command to the browser session and waits up to TTL for
// the matching reply. Returns the raw payload or an error.
func (sess *BrowserTestSession) Send(action string, params map[string]interface{}) (json.RawMessage, error) {
cid := newCorrelationID()
ch := make(chan json.RawMessage, 1)
sess.pendingMu.Lock()
sess.pending[cid] = ch
sess.pendingMu.Unlock()
defer func() {
sess.pendingMu.Lock()
delete(sess.pending, cid)
sess.pendingMu.Unlock()
}()
cmd := map[string]interface{}{
"id": cid,
"action": action,
"params": params,
}
sess.writeMu.Lock()
err := sess.conn.WriteJSON(cmd)
sess.writeMu.Unlock()
if err != nil {
return nil, fmt.Errorf("write: %w", err)
}
select {
case payload := <-ch:
return payload, nil
case <-time.After(browserTestCommandTTL):
return nil, fmt.Errorf("browser session did not reply within %s", browserTestCommandTTL)
}
}
// AppendConsole records a console line, trimming to the buffer cap.
func (sess *BrowserTestSession) AppendConsole(level, message string) {
sess.mu.Lock()
defer sess.mu.Unlock()
sess.console = append(sess.console, ConsoleEntry{
Level: level,
Message: message,
Time: time.Now().Format(time.RFC3339),
})
if len(sess.console) > browserTestConsoleMax {
sess.console = sess.console[len(sess.console)-browserTestConsoleMax:]
}
}
// SnapshotConsole returns a copy of the current console buffer.
func (sess *BrowserTestSession) SnapshotConsole() []ConsoleEntry {
sess.mu.Lock()
defer sess.mu.Unlock()
out := make([]ConsoleEntry, len(sess.console))
copy(out, sess.console)
return out
}
func newCorrelationID() string {
buf := make([]byte, 8)
rand.Read(buf)
return hex.EncodeToString(buf)
}
// HTTP handlers --------------------------------------------------------------
func (s *Server) handleBrowserTestSnippet(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" {
writeError(w, "GET only", http.StatusMethodNotAllowed)
return
}
tok := s.browserTestStore.IssueToken()
host := r.Host
if host == "" {
host = "127.0.0.1"
}
scheme := "ws"
if r.TLS != nil {
scheme = "wss"
}
wsURL := fmt.Sprintf("%s://%s/api/ws/browser-test?token=%s", scheme, host, tok)
snippet := buildBrowserTestSnippet(wsURL)
writeJSON(w, map[string]interface{}{
"token": tok,
"ws_url": wsURL,
"snippet": snippet,
"expires_in": int(browserTestTokenTTL / time.Second),
})
}
func (s *Server) handleBrowserTestSessions(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" {
writeError(w, "GET only", http.StatusMethodNotAllowed)
return
}
writeJSON(w, map[string]interface{}{
"sessions": s.browserTestStore.List(),
})
}
func (s *Server) handleBrowserTestConsole(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" {
writeError(w, "GET only", http.StatusMethodNotAllowed)
return
}
id := strings.TrimPrefix(r.URL.Path, "/api/test/console/")
sess := s.browserTestStore.Pick(id)
if sess == nil {
writeError(w, "no active browser test session", http.StatusNotFound)
return
}
writeJSON(w, map[string]interface{}{
"session_id": sess.ID,
"url": sess.URL,
"console": sess.SnapshotConsole(),
})
}
// browserTestUpgrader accepts any origin: the connection is gated by a
// short-lived token issued to the local UI, not by Origin checking.
var browserTestUpgrader = websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool { return true },
}
func (s *Server) handleBrowserTestWS(w http.ResponseWriter, r *http.Request) {
tok := r.URL.Query().Get("token")
if tok == "" || !s.browserTestStore.ConsumeToken(tok) {
writeError(w, "invalid or expired token", http.StatusUnauthorized)
return
}
conn, err := browserTestUpgrader.Upgrade(w, r, nil)
if err != nil {
return
}
conn.SetReadLimit(2 << 20)
// Read the hello message: page sends {"type":"hello","url":"...","title":"..."}.
conn.SetReadDeadline(time.Now().Add(10 * time.Second))
var hello struct {
Type string `json:"type"`
URL string `json:"url"`
Title string `json:"title"`
}
if err := conn.ReadJSON(&hello); err != nil || hello.Type != "hello" {
conn.WriteJSON(map[string]string{"type": "error", "message": "expected hello"})
conn.Close()
return
}
conn.SetReadDeadline(time.Time{})
id := newCorrelationID()
sess := &BrowserTestSession{
ID: id,
URL: hello.URL,
Title: hello.Title,
conn: conn,
pending: map[string]chan json.RawMessage{},
connectedAt: time.Now(),
}
s.browserTestStore.Register(sess)
defer s.browserTestStore.Remove(id)
// Acknowledge with the assigned session ID.
sess.writeMu.Lock()
conn.WriteJSON(map[string]string{"type": "registered", "session_id": id})
sess.writeMu.Unlock()
for {
_, raw, err := conn.ReadMessage()
if err != nil {
return
}
var msg struct {
Type string `json:"type"`
ID string `json:"id,omitempty"`
Level string `json:"level,omitempty"`
Text string `json:"text,omitempty"`
URL string `json:"url,omitempty"`
Data json.RawMessage `json:"data,omitempty"`
}
if err := json.Unmarshal(raw, &msg); err != nil {
continue
}
switch msg.Type {
case "console":
sess.AppendConsole(msg.Level, msg.Text)
case "url_change":
sess.mu.Lock()
sess.URL = msg.URL
sess.mu.Unlock()
case "reply":
sess.pendingMu.Lock()
ch, ok := sess.pending[msg.ID]
sess.pendingMu.Unlock()
if ok {
select {
case ch <- msg.Data:
default:
}
}
case "ping":
sess.writeMu.Lock()
conn.WriteJSON(map[string]string{"type": "pong"})
sess.writeMu.Unlock()
}
}
}
// Agent tool -----------------------------------------------------------------
// BrowserTestParams is the schema exposed to the AI for the browser_test tool.
type BrowserTestParams struct {
Action string `json:"action" description:"One of: list_clickables, click, eval, console, current_url, wait, type, summary, screenshot"`
SessionID string `json:"session_id,omitempty" description:"Browser session id (optional, defaults to most recent)"`
Selector string `json:"selector,omitempty" description:"CSS selector for click/type/screenshot actions (screenshot defaults to whole viewport when omitted)"`
Index int `json:"index,omitempty" description:"Alternative to selector: index into the last list_clickables result (0-based)"`
Expr string `json:"expr,omitempty" description:"JS expression to evaluate (eval action only)"`
Text string `json:"text,omitempty" description:"Text to type (type action only)"`
WaitMs int `json:"wait_ms,omitempty" description:"Milliseconds to wait (wait action only, max 5000)"`
Tail int `json:"tail,omitempty" description:"Console action: how many recent lines to return (default 50, max 200)"`
Filename string `json:"filename,omitempty" description:"Screenshot action: optional file name (no path, no extension); defaults to a timestamp"`
}
// RegisterBrowserTestTool wires the agent tool against a session store.
func RegisterBrowserTestTool(reg *agent.Registry, store *BrowserTestStore) error {
tool, err := agent.NewTool("browser_test",
"Drive the user's connected browser tab for end-to-end testing. Available actions: list_clickables (returns indexed clickable elements), click (by selector or index), eval (run a JS expression and return result), console (read recent console output, ideal to spot errors after a click), current_url, wait (sleep ms before next check), type (set value on an input), summary (URL+title+last console entries). Always start with list_clickables; click; then console to verify no errors.",
func(ctx context.Context, p BrowserTestParams) (agent.ToolResponse, error) {
sess := store.Pick(p.SessionID)
if sess == nil {
return agent.TextErrorResponse("no active browser session — ask the user to paste the snippet from the Tests tab in their target page"), nil
}
action := strings.ToLower(strings.TrimSpace(p.Action))
switch action {
case "":
return agent.TextErrorResponse("action is required"), nil
case "list_clickables", "click", "eval", "current_url", "type", "screenshot":
case "console", "summary", "wait":
default:
return agent.TextErrorResponse("unknown action: " + p.Action), nil
}
if action == "console" {
tail := p.Tail
if tail <= 0 {
tail = 50
}
if tail > browserTestConsoleMax {
tail = browserTestConsoleMax
}
entries := sess.SnapshotConsole()
if len(entries) > tail {
entries = entries[len(entries)-tail:]
}
out, _ := json.MarshalIndent(map[string]interface{}{
"session_id": sess.ID,
"console": entries,
}, "", " ")
return agent.TextResponse(string(out)), nil
}
if action == "summary" {
entries := sess.SnapshotConsole()
if len(entries) > 20 {
entries = entries[len(entries)-20:]
}
out, _ := json.MarshalIndent(map[string]interface{}{
"session_id": sess.ID,
"url": sess.URL,
"title": sess.Title,
"recent_console": entries,
}, "", " ")
return agent.TextResponse(string(out)), nil
}
if action == "wait" {
ms := p.WaitMs
if ms <= 0 {
ms = 200
}
if ms > 5000 {
ms = 5000
}
select {
case <-ctx.Done():
return agent.TextErrorResponse("cancelled"), nil
case <-time.After(time.Duration(ms) * time.Millisecond):
}
return agent.TextResponse(fmt.Sprintf("waited %dms", ms)), nil
}
// Capture console snapshot length before so we can return only the delta
// after the action — useful so the AI can spot errors caused by the click.
pre := len(sess.SnapshotConsole())
params := map[string]interface{}{}
if p.Selector != "" {
params["selector"] = p.Selector
}
if p.Index > 0 || (action == "click" && p.Selector == "") {
params["index"] = p.Index
}
if p.Expr != "" {
params["expr"] = p.Expr
}
if p.Text != "" {
params["text"] = p.Text
}
payload, err := sess.Send(action, params)
if err != nil {
return agent.TextErrorResponse(err.Error()), nil
}
// Screenshot post-processing: snippet returns a base64 data URL;
// decode and write to ~/.muyue/screenshots/<filename>.png so the
// AI can reference an on-disk path rather than streaming megabytes
// of base64 back through its context.
if action == "screenshot" {
saved, perr := saveScreenshot(payload, p.Filename)
if perr != nil {
return agent.TextErrorResponse("screenshot save: " + perr.Error()), nil
}
out, _ := json.MarshalIndent(map[string]interface{}{
"action": "screenshot",
"saved_to": saved,
"current_url": sess.URL,
}, "", " ")
return agent.TextResponse(string(out)), nil
}
// Console delta: messages logged during this command.
post := sess.SnapshotConsole()
var delta []ConsoleEntry
if len(post) > pre {
delta = post[pre:]
}
result := map[string]interface{}{
"action": action,
"reply": json.RawMessage(payload),
"console_delta": delta,
"current_url": sess.URL,
}
out, _ := json.MarshalIndent(result, "", " ")
return agent.TextResponse(string(out)), nil
})
if err != nil {
return err
}
return reg.Register(tool)
}
// Snippet generator ----------------------------------------------------------
func buildBrowserTestSnippet(wsURL string) string {
// Inline JS injected into the user's target page. Responsibilities:
// - open the WS, with auto-reconnect (exponential backoff capped at 5s)
// - hook console.log/info/warn/error/debug + window.onerror + unhandledrejection
// - dispatch RPC commands: list_clickables, click, type, eval, current_url, screenshot
// - re-establish WS on transient close (network blip, server restart, etc.)
//
// Across full page navigation / reload the JS context is destroyed —
// no JS-only mechanism can survive that. The token is reusable (sliding
// 60-min TTL server-side), so the user just re-pastes the same snippet
// from the Tests tab to resume.
return `(function(){
if (window.__muyueTestRunner) { console.log('[Muyue] runner already attached'); return; }
var WS_URL = ` + jsString(wsURL) + `;
var ws = null, lastList = [], retry = 0;
function send(obj){ try{ if (ws && ws.readyState === 1) ws.send(JSON.stringify(obj)); }catch(e){} }
function reply(id, data){ send({type:'reply', id:id, data:data}); }
function safeText(el){
var t = (el.innerText || el.textContent || '').trim();
if (t.length > 80) t = t.slice(0,80)+'…';
return t;
}
function describe(el){
var sel = el.id ? '#'+el.id : el.tagName.toLowerCase();
if (!el.id && el.className && typeof el.className === 'string') {
sel += '.' + el.className.trim().split(/\s+/).slice(0,2).join('.');
}
var label = el.getAttribute('aria-label') || el.getAttribute('title') || el.getAttribute('name') || '';
return { tag: el.tagName.toLowerCase(), selector: sel, text: safeText(el), label: label, type: el.getAttribute('type')||'', disabled: !!el.disabled };
}
function list(){
var els = Array.from(document.querySelectorAll('button, a[href], input[type=submit], input[type=button], [role=button], [onclick]'));
lastList = els.filter(function(e){ var r=e.getBoundingClientRect(); return r.width>0 && r.height>0; });
return lastList.map(describe).map(function(d,i){ d.index = i; return d; });
}
function clickEl(el){
if (!el) return { ok:false, error:'element not found' };
if (el.disabled) return { ok:false, error:'element is disabled' };
try { el.scrollIntoView({block:'center'}); el.click(); return { ok:true }; }
catch(e){ return { ok:false, error:String(e) }; }
}
// Best-effort viewport screenshot via SVG foreignObject — works on most
// pages, but external CSS / images / iframes won't be inlined. Returns a
// base64 PNG data URL the server will save to disk.
function screenshot(p){
return new Promise(function(resolve){
try {
var w = Math.max(document.documentElement.clientWidth, 1024);
var h = Math.max(window.innerHeight, 768);
var node = (p && p.selector) ? document.querySelector(p.selector) : document.documentElement;
if (!node) { resolve({ ok:false, error:'selector not found' }); return; }
var rect = node.getBoundingClientRect();
if (node === document.documentElement) { rect = { width:w, height:h }; }
var clone = node.cloneNode(true);
var ser = new XMLSerializer().serializeToString(clone);
var svg = '<svg xmlns="http://www.w3.org/2000/svg" width="'+Math.round(rect.width)+'" height="'+Math.round(rect.height)+'">' +
'<foreignObject width="100%" height="100%"><div xmlns="http://www.w3.org/1999/xhtml" style="background:white">' + ser + '</div></foreignObject></svg>';
var img = new Image();
img.onload = function(){
try {
var c = document.createElement('canvas');
c.width = Math.round(rect.width); c.height = Math.round(rect.height);
c.getContext('2d').drawImage(img, 0, 0);
resolve({ ok:true, data_url: c.toDataURL('image/png'), width: c.width, height: c.height });
} catch(e){ resolve({ ok:false, error:'canvas: '+String(e) }); }
};
img.onerror = function(){ resolve({ ok:false, error:'image load failed (CSP or invalid SVG)' }); };
img.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svg);
} catch(e){ resolve({ ok:false, error:String(e) }); }
});
}
function dispatch(msg){
var p = msg.params || {};
switch(msg.action){
case 'list_clickables': return list();
case 'click': {
var el;
if (p.selector) el = document.querySelector(p.selector);
else if (typeof p.index === 'number') el = lastList[p.index];
return clickEl(el);
}
case 'eval': {
try { var r = (0,eval)(p.expr); return { ok:true, value: serialize(r) }; }
catch(e){ return { ok:false, error:String(e) }; }
}
case 'current_url': return { url: location.href, title: document.title };
case 'type': {
var el = p.selector ? document.querySelector(p.selector) : (lastList[p.index]);
if (!el) return { ok:false, error:'element not found' };
var proto = Object.getPrototypeOf(el);
var setter = Object.getOwnPropertyDescriptor(proto, 'value');
try { setter && setter.set ? setter.set.call(el, p.text||'') : (el.value = p.text||''); }
catch(e){ el.value = p.text||''; }
el.dispatchEvent(new Event('input', {bubbles:true}));
el.dispatchEvent(new Event('change', {bubbles:true}));
return { ok:true };
}
case 'screenshot':
return screenshot(p);
}
return { ok:false, error:'unknown action' };
}
function serialize(v){
if (v === undefined) return 'undefined';
try { return JSON.parse(JSON.stringify(v)); }
catch(e){ return String(v); }
}
['log','info','warn','error','debug'].forEach(function(lvl){
var orig = console[lvl];
console[lvl] = function(){
try {
var parts = Array.from(arguments).map(function(a){
if (typeof a === 'string') return a;
try { return JSON.stringify(a); } catch(e){ return String(a); }
});
send({type:'console', level: lvl, text: parts.join(' ')});
} catch(e){}
return orig.apply(console, arguments);
};
});
window.addEventListener('error', function(e){
send({type:'console', level:'error', text:'window.onerror: '+(e.message||e.error||'unknown')});
});
window.addEventListener('unhandledrejection', function(e){
send({type:'console', level:'error', text:'unhandledrejection: '+String(e.reason)});
});
var lastUrl = location.href;
setInterval(function(){
if (location.href !== lastUrl){ lastUrl = location.href; send({type:'url_change', url: lastUrl}); }
}, 500);
function connect(){
ws = new WebSocket(WS_URL);
ws.onopen = function(){ retry = 0; send({type:'hello', url: location.href, title: document.title}); };
ws.onmessage = function(ev){
try { var msg = JSON.parse(ev.data); } catch(e){ return; }
if (msg.type === 'registered') { console.log('[Muyue] connected — session', msg.session_id); return; }
if (msg.action) {
var out = dispatch(msg);
if (out && typeof out.then === 'function') { out.then(function(r){ reply(msg.id, r); }); }
else { reply(msg.id, out); }
}
};
ws.onclose = function(){
// Same-page transient disconnect → reconnect with backoff up to ~5s.
// Full navigation kills the JS context entirely — this never runs in
// that case; the user re-pastes the snippet (same token works).
retry = Math.min(retry + 1, 5);
setTimeout(connect, 500 * retry);
};
ws.onerror = function(){ /* onclose will fire next */ };
}
connect();
window.__muyueTestRunner = { reconnect: connect, list: list };
})();`
}
func jsString(s string) string {
b, _ := json.Marshal(s)
return string(b)
}
// saveScreenshot decodes the base64 PNG returned by the snippet's
// screenshot action and writes it to ~/.muyue/screenshots/<name>.png.
// Returns the absolute path saved, or an error.
func saveScreenshot(replyPayload json.RawMessage, requestedName string) (string, error) {
var reply struct {
OK bool `json:"ok"`
Error string `json:"error,omitempty"`
DataURL string `json:"data_url,omitempty"`
}
if err := json.Unmarshal(replyPayload, &reply); err != nil {
return "", fmt.Errorf("invalid reply: %w", err)
}
if !reply.OK {
if reply.Error != "" {
return "", fmt.Errorf("snippet: %s", reply.Error)
}
return "", fmt.Errorf("snippet returned ok=false")
}
const prefix = "data:image/png;base64,"
if !strings.HasPrefix(reply.DataURL, prefix) {
return "", fmt.Errorf("unexpected data URL prefix")
}
raw, err := base64StdDecode(reply.DataURL[len(prefix):])
if err != nil {
return "", fmt.Errorf("base64: %w", err)
}
dir, err := screenshotDir()
if err != nil {
return "", err
}
name := sanitizeFilename(requestedName)
if name == "" {
name = time.Now().Format("20060102-150405")
}
path := dir + "/" + name + ".png"
if err := writeFile(path, raw, 0644); err != nil {
return "", err
}
return path, nil
}
func screenshotDir() (string, error) {
home, err := osUserHomeDir()
if err != nil {
return "", err
}
dir := home + "/.muyue/screenshots"
if err := mkdirAll(dir, 0755); err != nil {
return "", err
}
return dir, nil
}
// sanitizeFilename keeps a safe subset (letters / digits / _ / - / .) so
// the user-supplied name cannot escape the screenshots directory.
func sanitizeFilename(s string) string {
var b strings.Builder
for _, r := range s {
switch {
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9',
r == '_', r == '-', r == '.':
b.WriteRune(r)
}
}
return b.String()
}

333
internal/api/chat_engine.go Normal file
View File

@@ -0,0 +1,333 @@
package api
import (
"context"
"encoding/json"
"fmt"
"net/http"
"github.com/muyue/muyue/internal/agent"
"github.com/muyue/muyue/internal/orchestrator"
)
// MaxToolIterations bounds the inner tool-call loop in RunWithTools /
// RunNonStream. The cap exists only to avoid an infinite loop when a model
// keeps calling tools forever; the value is intentionally generous so a
// realistic agent run (multi-file refactor, exploratory debugging…) never
// hits it. If you find yourself raising this to absurd values, look for a
// loop bug in the model output instead.
const MaxToolIterations = 500
// ToolLimiter checks if a tool call is allowed and returns a release function.
type ToolLimiter func(toolName string) (release func(), err error)
// ChatEngine handles chat interactions with tool execution.
// This deduplicates chat logic previously repeated in handlers_chat.go and handlers_shell_chat.go.
type ChatEngine struct {
orchestrator *orchestrator.Orchestrator
registry *agent.Registry
tools json.RawMessage
onChunk func(map[string]interface{})
stream bool
limiter ToolLimiter
TotalTokens int
}
// NewChatEngine creates a new ChatEngine instance.
func NewChatEngine(orb *orchestrator.Orchestrator, registry *agent.Registry, tools json.RawMessage) *ChatEngine {
return &ChatEngine{
orchestrator: orb,
registry: registry,
tools: tools,
stream: false,
}
}
// SetStream enables streaming mode for the chat engine.
func (ce *ChatEngine) SetStream(enabled bool) {
ce.stream = enabled
}
// OnChunk sets the callback for SSE chunk writing.
func (ce *ChatEngine) OnChunk(fn func(map[string]interface{})) {
ce.onChunk = fn
}
// SetLimiter sets the tool call limiter for agent concurrency control.
func (ce *ChatEngine) SetLimiter(l ToolLimiter) {
ce.limiter = l
}
// RunWithTools executes the chat loop with tool calls.
// Returns final content, tool calls, tool results, and error.
func (ce *ChatEngine) RunWithTools(ctx context.Context, messages []orchestrator.Message) (string, []map[string]interface{}, []map[string]interface{}, error) {
var finalContent string
var allToolCalls []map[string]interface{}
var allToolResults []map[string]interface{}
for i := 0; i < MaxToolIterations; i++ {
var resp *orchestrator.ChatResponse
var err error
if ce.stream {
// Use streaming version
resp, err = ce.orchestrator.SendWithToolsStream(messages, func(content string, toolCalls []orchestrator.ToolCallMsg) {
if ce.onChunk != nil && content != "" {
ce.onChunk(map[string]interface{}{"content": content})
}
})
} else {
resp, err = ce.orchestrator.SendWithTools(messages)
}
if err != nil {
if ce.onChunk != nil {
ce.onChunk(map[string]interface{}{"error": err.Error()})
}
return finalContent, allToolCalls, allToolResults, err
}
if resp.Usage.TotalTokens > 0 {
ce.TotalTokens += resp.Usage.TotalTokens
}
if len(resp.Choices) == 0 {
return finalContent, allToolCalls, allToolResults, fmt.Errorf("empty response from provider")
}
choice := resp.Choices[0]
content := orchestrator.CleanAIResponse(cleanThinkingTags(choice.Message.Content))
if content != "" {
if ce.onChunk != nil {
ce.onChunk(map[string]interface{}{"content": content})
}
finalContent = content
}
if len(choice.Message.ToolCalls) == 0 {
break
}
assistantMsg := orchestrator.Message{
Role: "assistant",
Content: orchestrator.TextContent(content),
ToolCalls: choice.Message.ToolCalls,
}
messages = append(messages, assistantMsg)
for _, tc := range choice.Message.ToolCalls {
toolCallData := map[string]interface{}{
"tool_call_id": tc.ID,
"name": tc.Function.Name,
"args": tc.Function.Arguments,
}
allToolCalls = append(allToolCalls, toolCallData)
if ce.onChunk != nil {
ce.onChunk(map[string]interface{}{"tool_call": toolCallData})
}
call := agent.ToolCall{
ID: tc.ID,
Name: tc.Function.Name,
Arguments: json.RawMessage(tc.Function.Arguments),
}
var release func()
if ce.limiter != nil {
rel, limitErr := ce.limiter(tc.Function.Name)
if limitErr != nil {
limResultData := map[string]interface{}{
"tool_call_id": tc.ID,
"content": limitErr.Error(),
"is_error": true,
}
allToolResults = append(allToolResults, map[string]interface{}{
"tool_call_id": tc.ID,
"name": tc.Function.Name,
"args": tc.Function.Arguments,
"result": limitErr.Error(),
"is_error": true,
})
if ce.onChunk != nil {
ce.onChunk(map[string]interface{}{"tool_result": limResultData})
}
messages = append(messages, orchestrator.Message{
Role: "tool",
Content: orchestrator.TextContent(limitErr.Error()),
ToolCallID: tc.ID,
Name: tc.Function.Name,
})
continue
}
release = rel
}
result, execErr := ce.registry.Execute(ctx, call)
if release != nil {
release()
}
if execErr != nil {
result = agent.ToolResponse{
Content: execErr.Error(),
IsError: true,
}
}
resultData := map[string]interface{}{
"tool_call_id": tc.ID,
"content": result.Content,
"is_error": result.IsError,
}
if result.Meta != nil {
for k, v := range result.Meta {
resultData[k] = v
}
}
allToolResults = append(allToolResults, map[string]interface{}{
"tool_call_id": tc.ID,
"name": tc.Function.Name,
"args": tc.Function.Arguments,
"result": result.Content,
"is_error": result.IsError,
})
if ce.onChunk != nil {
ce.onChunk(map[string]interface{}{"tool_result": resultData})
}
messages = append(messages, orchestrator.Message{
Role: "tool",
Content: orchestrator.TextContent(result.Content),
ToolCallID: tc.ID,
Name: tc.Function.Name,
})
}
finalContent = ""
}
return finalContent, allToolCalls, allToolResults, nil
}
// ProviderName returns the name of the active provider used by the engine.
func (ce *ChatEngine) ProviderName() string {
return ce.orchestrator.ProviderName()
}
// RunNonStream executes chat without streaming content to client.
func (ce *ChatEngine) RunNonStream(ctx context.Context, messages []orchestrator.Message) (string, error) {
var finalContent string
for i := 0; i < MaxToolIterations; i++ {
resp, err := ce.orchestrator.SendWithTools(messages)
if err != nil {
return finalContent, err
}
if resp.Usage.TotalTokens > 0 {
ce.TotalTokens += resp.Usage.TotalTokens
}
if len(resp.Choices) == 0 {
return finalContent, fmt.Errorf("empty response from provider")
}
choice := resp.Choices[0]
content := orchestrator.CleanAIResponse(cleanThinkingTags(choice.Message.Content))
if content != "" {
finalContent = content
}
if len(choice.Message.ToolCalls) == 0 {
break
}
assistantMsg := orchestrator.Message{
Role: "assistant",
Content: orchestrator.TextContent(content),
ToolCalls: choice.Message.ToolCalls,
}
messages = append(messages, assistantMsg)
for _, tc := range choice.Message.ToolCalls {
call := agent.ToolCall{
ID: tc.ID,
Name: tc.Function.Name,
Arguments: json.RawMessage(tc.Function.Arguments),
}
var release func()
if ce.limiter != nil {
rel, limitErr := ce.limiter(tc.Function.Name)
if limitErr != nil {
messages = append(messages, orchestrator.Message{
Role: "tool",
Content: orchestrator.TextContent(limitErr.Error()),
ToolCallID: tc.ID,
Name: tc.Function.Name,
})
continue
}
release = rel
}
result, execErr := ce.registry.Execute(ctx, call)
if release != nil {
release()
}
if execErr != nil {
result = agent.ToolResponse{
Content: execErr.Error(),
IsError: true,
}
}
messages = append(messages, orchestrator.Message{
Role: "tool",
Content: orchestrator.TextContent(result.Content),
ToolCallID: tc.ID,
Name: tc.Function.Name,
})
}
finalContent = ""
}
if finalContent == "" {
finalContent = "(tool calls completed, no text response)"
}
return finalContent, nil
}
// SSEWriter handles Server-Sent Events writing to HTTP response.
type SSEWriter struct {
w http.ResponseWriter
flusher http.Flusher
}
// NewSSEWriter creates a new SSEWriter.
func NewSSEWriter(w http.ResponseWriter) *SSEWriter {
sse := &SSEWriter{w: w}
if f, ok := w.(http.Flusher); ok {
sse.flusher = f
}
return sse
}
// Write sends an SSE message.
func (s *SSEWriter) Write(data map[string]interface{}) {
b, _ := json.Marshal(data)
s.w.Write([]byte("data: " + string(b) + "\n\n"))
if s.flusher != nil {
s.flusher.Flush()
}
}
// SetupSSEHeaders sets up SSE response headers.
func SetupSSEHeaders(w http.ResponseWriter) {
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.WriteHeader(http.StatusOK)
}

127
internal/api/consumption.go Normal file
View File

@@ -0,0 +1,127 @@
package api
import (
"encoding/json"
"os"
"path/filepath"
"sync"
"time"
"github.com/muyue/muyue/internal/config"
)
type consumptionEntry struct {
Date string `json:"date"`
Tokens int `json:"tokens"`
Requests int `json:"requests"`
}
type providerConsumption struct {
Name string `json:"name"`
Daily []consumptionEntry `json:"daily"`
Total int `json:"total_tokens"`
Requests int `json:"total_requests"`
}
type consumptionStore struct {
mu sync.Mutex
providers map[string]*providerConsumption
}
func newConsumptionStore() *consumptionStore {
cs := &consumptionStore{
providers: make(map[string]*providerConsumption),
}
cs.load()
cs.prune()
return cs
}
func (cs *consumptionStore) Record(providerName string, tokens int) {
if tokens <= 0 || providerName == "" {
return
}
cs.mu.Lock()
defer cs.mu.Unlock()
today := time.Now().UTC().Format("2006-01-02")
p, ok := cs.providers[providerName]
if !ok {
p = &providerConsumption{Name: providerName}
cs.providers[providerName] = p
}
p.Total += tokens
p.Requests++
if len(p.Daily) > 0 && p.Daily[len(p.Daily)-1].Date == today {
p.Daily[len(p.Daily)-1].Tokens += tokens
p.Daily[len(p.Daily)-1].Requests++
} else {
p.Daily = append(p.Daily, consumptionEntry{
Date: today,
Tokens: tokens,
Requests: 1,
})
}
cs.save()
}
func (cs *consumptionStore) GetAll() map[string]*providerConsumption {
cs.mu.Lock()
defer cs.mu.Unlock()
result := make(map[string]*providerConsumption)
for k, v := range cs.providers {
pc := *v
daily := make([]consumptionEntry, len(v.Daily))
copy(daily, v.Daily)
pc.Daily = daily
result[k] = &pc
}
return result
}
func (cs *consumptionStore) prune() {
cutoff := time.Now().UTC().AddDate(0, 0, -7).Format("2006-01-02")
for _, p := range cs.providers {
filtered := make([]consumptionEntry, 0)
for _, d := range p.Daily {
if d.Date >= cutoff {
filtered = append(filtered, d)
}
}
p.Daily = filtered
}
}
func (cs *consumptionStore) filePath() string {
dir, err := config.ConfigDir()
if err != nil {
return ""
}
return filepath.Join(dir, "consumption.json")
}
func (cs *consumptionStore) load() {
fp := cs.filePath()
if fp == "" {
return
}
data, err := os.ReadFile(fp)
if err != nil {
return
}
json.Unmarshal(data, &cs.providers)
}
func (cs *consumptionStore) save() {
fp := cs.filePath()
if fp == "" {
return
}
data, _ := json.Marshal(cs.providers)
os.WriteFile(fp, data, 0644)
}

View File

@@ -0,0 +1,333 @@
package api
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"time"
"unicode/utf8"
"github.com/muyue/muyue/internal/config"
)
const contextWindowTokens = 150000
const summarizeRatio = 0.80
const charsPerToken = 4
func extractDisplayContent(role, content string) string {
if role != "assistant" {
return content
}
var parsed struct {
Content string `json:"content"`
ToolCalls []struct {
Name string `json:"name"`
Args string `json:"args"`
} `json:"tool_calls"`
ToolResults []struct {
Name string `json:"name"`
Result string `json:"result"`
} `json:"tool_results"`
}
if err := json.Unmarshal([]byte(content), &parsed); err != nil {
return content
}
var sb strings.Builder
if parsed.Content != "" {
sb.WriteString(parsed.Content)
}
for _, tc := range parsed.ToolCalls {
sb.WriteString("\n[")
sb.WriteString(tc.Name)
sb.WriteString("] ")
sb.WriteString(tc.Args)
}
for _, tr := range parsed.ToolResults {
sb.WriteString("\n[result")
if tr.Name != "" {
sb.WriteString(":")
sb.WriteString(tr.Name)
}
sb.WriteString("] ")
sb.WriteString(tr.Result)
}
return sb.String()
}
type FeedMessage struct {
ID string `json:"id"`
Role string `json:"role"`
Content string `json:"content"`
Time string `json:"time"`
Images []string `json:"images,omitempty"`
Summarized bool `json:"summarized,omitempty"`
}
type Conversation struct {
Messages []FeedMessage `json:"messages"`
Summary string `json:"summary,omitempty"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
type ConversationStore struct {
mu sync.RWMutex
path string
conv *Conversation
}
type TokenCount struct {
total int
byRole map[string]int
byMessage int
}
type SearchResult struct {
ID string `json:"id"`
Role string `json:"role"`
Content string `json:"content"`
Time string `json:"time"`
}
func NewConversationStore() *ConversationStore {
dir, err := config.ConfigDir()
if err != nil {
dir = "/tmp/muyue"
}
path := filepath.Join(dir, "conversation.json")
cs := &ConversationStore{path: path}
cs.load()
return cs
}
func (cs *ConversationStore) load() {
data, err := os.ReadFile(cs.path)
if err != nil {
cs.conv = &Conversation{
Messages: []FeedMessage{},
CreatedAt: time.Now().Format(time.RFC3339),
UpdatedAt: time.Now().Format(time.RFC3339),
}
return
}
var conv Conversation
if err := json.Unmarshal(data, &conv); err != nil {
cs.conv = &Conversation{
Messages: []FeedMessage{},
CreatedAt: time.Now().Format(time.RFC3339),
UpdatedAt: time.Now().Format(time.RFC3339),
}
return
}
if conv.Messages == nil {
conv.Messages = []FeedMessage{}
}
cs.conv = &conv
}
func (cs *ConversationStore) save() error {
cs.conv.UpdatedAt = time.Now().Format(time.RFC3339)
data, err := json.MarshalIndent(cs.conv, "", " ")
if err != nil {
return err
}
dir := filepath.Dir(cs.path)
os.MkdirAll(dir, 0755)
return os.WriteFile(cs.path, data, 0600)
}
func (cs *ConversationStore) Get() []FeedMessage {
cs.mu.RLock()
defer cs.mu.RUnlock()
out := make([]FeedMessage, len(cs.conv.Messages))
copy(out, cs.conv.Messages)
return out
}
func (cs *ConversationStore) GetSummary() string {
cs.mu.RLock()
defer cs.mu.RUnlock()
return cs.conv.Summary
}
func (cs *ConversationStore) Add(role, content string) FeedMessage {
cs.mu.Lock()
defer cs.mu.Unlock()
msg := FeedMessage{
ID: generateMsgID(),
Role: role,
Content: content,
Time: time.Now().Format(time.RFC3339),
}
cs.conv.Messages = append(cs.conv.Messages, msg)
cs.save()
return msg
}
func (cs *ConversationStore) AddWithImages(role, content string, imageIDs []string) FeedMessage {
cs.mu.Lock()
defer cs.mu.Unlock()
msg := FeedMessage{
ID: generateMsgID(),
Role: role,
Content: content,
Time: time.Now().Format(time.RFC3339),
Images: imageIDs,
}
cs.conv.Messages = append(cs.conv.Messages, msg)
cs.save()
return msg
}
func (cs *ConversationStore) Clear() {
cs.mu.Lock()
defer cs.mu.Unlock()
var imageIDs []string
for _, m := range cs.conv.Messages {
imageIDs = append(imageIDs, m.Images...)
}
cs.conv.Messages = []FeedMessage{}
cs.conv.Summary = ""
cs.conv.CreatedAt = time.Now().Format(time.RFC3339)
cs.conv.UpdatedAt = time.Now().Format(time.RFC3339)
cs.save()
go cleanupImages(imageIDs)
}
func (cs *ConversationStore) SetSummary(summary string) {
cs.mu.Lock()
defer cs.mu.Unlock()
cs.conv.Summary = summary
cs.save()
}
func (cs *ConversationStore) MarkSummarized(upToIndex int) {
cs.mu.Lock()
defer cs.mu.Unlock()
if upToIndex <= 0 || upToIndex >= len(cs.conv.Messages) {
return
}
for i := 0; i < upToIndex; i++ {
cs.conv.Messages[i].Summarized = true
}
cs.save()
}
func (cs *ConversationStore) ApproxTokenCount() int {
return cs.ApproxTokenCountDetailed().total
}
func (cs *ConversationStore) ApproxTokenCountDetailed() TokenCount {
cs.mu.RLock()
defer cs.mu.RUnlock()
result := TokenCount{
byRole: make(map[string]int),
}
for _, m := range cs.conv.Messages {
if m.Role == "system" || m.Summarized {
continue
}
count := utf8.RuneCountInString(extractDisplayContent(m.Role, m.Content)) / charsPerToken
result.byMessage += count
result.byRole[m.Role] += count
}
if cs.conv.Summary != "" {
result.total = result.byMessage + utf8.RuneCountInString(cs.conv.Summary)/charsPerToken
} else {
result.total = result.byMessage
}
return result
}
func (cs *ConversationStore) NeedsSummarization() bool {
return cs.ApproxTokenCount() > int(float64(contextWindowTokens)*summarizeRatio)
}
func (cs *ConversationStore) Search(query string) []SearchResult {
cs.mu.RLock()
defer cs.mu.RUnlock()
var results []SearchResult
queryLower := strings.ToLower(query)
for _, msg := range cs.conv.Messages {
if strings.Contains(strings.ToLower(msg.Content), queryLower) {
results = append(results, SearchResult{
ID: msg.ID,
Role: msg.Role,
Content: msg.Content,
Time: msg.Time,
})
}
}
return results
}
func (cs *ConversationStore) ExportMarkdown() string {
cs.mu.RLock()
defer cs.mu.RUnlock()
var sb strings.Builder
sb.WriteString("# Conversation Export\n\n")
sb.WriteString(fmt.Sprintf("Exporté le: %s\n\n", time.Now().Format(time.RFC3339)))
if cs.conv.Summary != "" {
sb.WriteString("## Résumé\n\n")
sb.WriteString(cs.conv.Summary)
sb.WriteString("\n\n---\n\n")
}
sb.WriteString("## Messages\n\n")
for i, msg := range cs.conv.Messages {
roleLabel := msg.Role
if roleLabel == "user" {
roleLabel = "👤 Utilisateur"
} else if roleLabel == "assistant" {
roleLabel = "🤖 Assistant"
} else if roleLabel == "system" {
roleLabel = "⚙️ Système"
}
timestamp := ""
if msg.Time != "" {
if t, err := time.Parse(time.RFC3339, msg.Time); err == nil {
timestamp = t.Format("2006-01-02 15:04")
}
}
sb.WriteString(fmt.Sprintf("### [%d] %s (%s)\n\n", i+1, roleLabel, timestamp))
sb.WriteString(msg.Content)
sb.WriteString("\n\n---\n\n")
}
return sb.String()
}
func (cs *ConversationStore) ExportJSON() string {
cs.mu.RLock()
defer cs.mu.RUnlock()
data, err := json.MarshalIndent(cs.conv, "", " ")
if err != nil {
return "{}"
}
return string(data)
}
func generateMsgID() string {
return time.Now().Format("20060102150405.000") + "-" + fmt.Sprintf("%d", time.Now().UnixNano())
}

View File

@@ -0,0 +1,370 @@
package api
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sync"
"time"
"github.com/google/uuid"
"github.com/muyue/muyue/internal/config"
)
// ConversationMeta represents metadata for a conversation (used for listing).
type ConversationMeta struct {
ID string `json:"id"`
Title string `json:"title"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
MessageCount int `json:"message_count"`
}
// ConversationStoreMulti manages multiple conversations.
type ConversationStoreMulti struct {
mu sync.RWMutex
dir string
currentID string
conversations map[string]*Conversation
}
func NewConversationStoreMulti() *ConversationStoreMulti {
dir, err := config.ConfigDir()
if err != nil {
dir = "/tmp/muyue"
}
dir = filepath.Join(dir, "conversations")
cs := &ConversationStoreMulti{
dir: dir,
conversations: make(map[string]*Conversation),
}
cs.loadIndex()
return cs
}
func (cs *ConversationStoreMulti) loadIndex() {
os.MkdirAll(cs.dir, 0755)
// Load index file if exists
indexPath := filepath.Join(cs.dir, "index.json")
data, err := os.ReadFile(indexPath)
if err != nil {
// Create default conversation
cs.createDefault()
return
}
var index struct {
CurrentID string `json:"current_id"`
Conversations []ConversationMeta `json:"conversations"`
}
if err := json.Unmarshal(data, &index); err != nil {
cs.createDefault()
return
}
cs.currentID = index.CurrentID
if cs.currentID == "" {
cs.createDefault()
return
}
// Load all conversations
for _, meta := range index.Conversations {
convPath := filepath.Join(cs.dir, fmt.Sprintf("conv_%s.json", meta.ID))
data, err := os.ReadFile(convPath)
if err != nil {
continue
}
var conv Conversation
if err := json.Unmarshal(data, &conv); err != nil {
continue
}
cs.conversations[meta.ID] = &conv
}
// Ensure current conversation exists
if _, ok := cs.conversations[cs.currentID]; !ok {
cs.createDefault()
}
}
func (cs *ConversationStoreMulti) createDefault() {
cs.currentID = uuid.New().String()
cs.conversations[cs.currentID] = &Conversation{
Messages: []FeedMessage{},
CreatedAt: time.Now().Format(time.RFC3339),
UpdatedAt: time.Now().Format(time.RFC3339),
}
cs.saveIndex()
}
func (cs *ConversationStoreMulti) saveIndex() error {
var metas []ConversationMeta
for id, conv := range cs.conversations {
title := "Nouvelle conversation"
if len(conv.Messages) > 0 {
// Use first user message as title
for _, m := range conv.Messages {
if m.Role == "user" {
if len(m.Content) > 50 {
title = m.Content[:50] + "..."
} else {
title = m.Content
}
break
}
}
}
metas = append(metas, ConversationMeta{
ID: id,
Title: title,
CreatedAt: conv.CreatedAt,
UpdatedAt: conv.UpdatedAt,
MessageCount: len(conv.Messages),
})
}
index := struct {
CurrentID string `json:"current_id"`
Conversations []ConversationMeta `json:"conversations"`
}{
CurrentID: cs.currentID,
Conversations: metas,
}
data, err := json.MarshalIndent(index, "", " ")
if err != nil {
return err
}
return os.WriteFile(filepath.Join(cs.dir, "index.json"), data, 0600)
}
func (cs *ConversationStoreMulti) saveCurrent() error {
conv, ok := cs.conversations[cs.currentID]
if !ok {
return fmt.Errorf("no current conversation")
}
conv.UpdatedAt = time.Now().Format(time.RFC3339)
data, err := json.MarshalIndent(conv, "", " ")
if err != nil {
return err
}
convPath := filepath.Join(cs.dir, fmt.Sprintf("conv_%s.json", cs.currentID))
if err := os.WriteFile(convPath, data, 0600); err != nil {
return err
}
return cs.saveIndex()
}
// Current returns the current conversation store.
func (cs *ConversationStoreMulti) Current() *ConversationStore {
cs.mu.RLock()
defer cs.mu.RUnlock()
conv, ok := cs.conversations[cs.currentID]
if !ok {
return &ConversationStore{
conv: &Conversation{
Messages: []FeedMessage{},
CreatedAt: time.Now().Format(time.RFC3339),
UpdatedAt: time.Now().Format(time.RFC3339),
},
}
}
return &ConversationStore{
conv: conv,
}
}
// Get returns the current conversation messages.
func (cs *ConversationStoreMulti) Get() []FeedMessage {
cs.mu.RLock()
defer cs.mu.RUnlock()
conv, ok := cs.conversations[cs.currentID]
if !ok {
return []FeedMessage{}
}
out := make([]FeedMessage, len(conv.Messages))
copy(out, conv.Messages)
return out
}
// Add adds a message to the current conversation.
func (cs *ConversationStoreMulti) Add(role, content string) FeedMessage {
cs.mu.Lock()
defer cs.mu.Unlock()
conv, ok := cs.conversations[cs.currentID]
if !ok {
cs.currentID = uuid.New().String()
conv = &Conversation{
Messages: []FeedMessage{},
CreatedAt: time.Now().Format(time.RFC3339),
UpdatedAt: time.Now().Format(time.RFC3339),
}
cs.conversations[cs.currentID] = conv
}
msg := FeedMessage{
ID: generateMsgID(),
Role: role,
Content: content,
Time: time.Now().Format(time.RFC3339),
}
conv.Messages = append(conv.Messages, msg)
cs.saveCurrent()
return msg
}
// Clear clears the current conversation.
func (cs *ConversationStoreMulti) Clear() {
cs.mu.Lock()
defer cs.mu.Unlock()
conv, ok := cs.conversations[cs.currentID]
if !ok {
return
}
conv.Messages = []FeedMessage{}
conv.Summary = ""
conv.CreatedAt = time.Now().Format(time.RFC3339)
conv.UpdatedAt = time.Now().Format(time.RFC3339)
cs.saveCurrent()
}
// List returns all conversations.
func (cs *ConversationStoreMulti) List() []ConversationMeta {
cs.mu.RLock()
defer cs.mu.RUnlock()
var metas []ConversationMeta
for id, conv := range cs.conversations {
title := "Nouvelle conversation"
if len(conv.Messages) > 0 {
for _, m := range conv.Messages {
if m.Role == "user" {
if len(m.Content) > 50 {
title = m.Content[:50] + "..."
} else {
title = m.Content
}
break
}
}
}
metas = append(metas, ConversationMeta{
ID: id,
Title: title,
CreatedAt: conv.CreatedAt,
UpdatedAt: conv.UpdatedAt,
MessageCount: len(conv.Messages),
})
}
return metas
}
// Create creates a new conversation and switches to it.
func (cs *ConversationStoreMulti) Create() string {
cs.mu.Lock()
defer cs.mu.Unlock()
id := uuid.New().String()
cs.conversations[id] = &Conversation{
Messages: []FeedMessage{},
CreatedAt: time.Now().Format(time.RFC3339),
UpdatedAt: time.Now().Format(time.RFC3339),
}
cs.currentID = id
cs.saveIndex()
return id
}
// Switch switches to a different conversation.
func (cs *ConversationStoreMulti) Switch(id string) error {
cs.mu.Lock()
defer cs.mu.Unlock()
if _, ok := cs.conversations[id]; !ok {
return fmt.Errorf("conversation not found: %s", id)
}
cs.currentID = id
cs.saveIndex()
return nil
}
// GetByID returns a conversation by ID.
func (cs *ConversationStoreMulti) GetByID(id string) (*Conversation, error) {
cs.mu.RLock()
defer cs.mu.RUnlock()
conv, ok := cs.conversations[id]
if !ok {
return nil, fmt.Errorf("conversation not found: %s", id)
}
return conv, nil
}
// Delete deletes a conversation.
func (cs *ConversationStoreMulti) Delete(id string) error {
cs.mu.Lock()
defer cs.mu.Unlock()
if _, ok := cs.conversations[id]; !ok {
return fmt.Errorf("conversation not found: %s", id)
}
delete(cs.conversations, id)
// Delete file
convPath := filepath.Join(cs.dir, fmt.Sprintf("conv_%s.json", id))
os.Remove(convPath)
// If deleted current, switch to another
if cs.currentID == id {
if len(cs.conversations) > 0 {
for newID := range cs.conversations {
cs.currentID = newID
break
}
} else {
// Create new default
cs.currentID = uuid.New().String()
cs.conversations[cs.currentID] = &Conversation{
Messages: []FeedMessage{},
CreatedAt: time.Now().Format(time.RFC3339),
UpdatedAt: time.Now().Format(time.RFC3339),
}
}
}
cs.saveIndex()
return nil
}
// CurrentID returns the current conversation ID.
func (cs *ConversationStoreMulti) CurrentID() string {
cs.mu.RLock()
defer cs.mu.RUnlock()
return cs.currentID
}

View File

@@ -0,0 +1,172 @@
package api
import (
"context"
"encoding/json"
"fmt"
"net/http"
"runtime"
"strings"
"time"
"github.com/muyue/muyue/internal/orchestrator"
)
func (s *Server) handleAITask(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
writeError(w, "POST only", http.StatusMethodNotAllowed)
return
}
var body struct {
Task string `json:"task"`
Tool string `json:"tool,omitempty"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, err.Error(), http.StatusBadRequest)
return
}
if body.Task == "" {
writeError(w, "task is required", http.StatusBadRequest)
return
}
orb, err := orchestrator.New(s.config)
if err != nil {
writeError(w, "AI not available: "+err.Error(), http.StatusServiceUnavailable)
return
}
orb.SetSystemPrompt(buildAITaskSystemPrompt())
orb.SetTools(s.shellAgentToolsJSON)
ctx, cancel := context.WithTimeout(context.Background(), 180*time.Second)
defer cancel()
messages := []orchestrator.Message{
{Role: "user", Content: orchestrator.TextContent(buildAITaskPrompt(body.Task, body.Tool))},
}
engine := NewChatEngine(orb, s.shellAgentRegistry, s.shellAgentToolsJSON)
finalContent, err := engine.RunNonStream(ctx, messages)
if err != nil {
writeError(w, "AI task failed: "+err.Error(), http.StatusInternalServerError)
return
}
s.consumption.Record(engine.ProviderName(), engine.TotalTokens)
parsed := parseAIJSONResponse(finalContent)
writeJSON(w, map[string]interface{}{
"status": "ok",
"raw": finalContent,
"result": parsed,
"tokens": engine.TotalTokens,
})
}
func buildAITaskSystemPrompt() string {
return fmt.Sprintf(`You are a system administration assistant. You have access to a terminal tool to run commands on the host system.
IMPORTANT RULES:
- You MUST respond ONLY with valid JSON. No markdown, no code fences, no extra text.
- Always run the actual commands needed to complete the task.
- Be thorough: check versions, verify installations, compare with latest releases.
OS: %s/%s
Date: %s
`, runtime.GOOS, runtime.GOARCH, time.Now().Format("2006-01-02"))
}
func buildAITaskPrompt(task, tool string) string {
switch task {
case "check_tools":
return `Check the following tools on this system. For each tool, determine:
1. Is it installed? Run "which <tool>" or "<tool> --version"
2. If installed, what is the current version?
3. What is the latest available version? Check GitHub releases API or official sources.
Tools to check: crush, claude, git, node, npm, pnpm, python3, pip3, uv, go, docker, gh, starship, npx
Run the commands needed, then respond with ONLY this JSON structure (no markdown fences):
{
"tools": [
{"name": "tool_name", "installed": true/false, "version": "x.y.z", "latest": "a.b.c", "needs_update": true/false, "category": "ai|runtime|vcs|devops|prompt"}
]
}`
case "install_tool":
return fmt.Sprintf(`Install the tool "%s" on this system.
Steps:
1. Check if it's already installed: run "which %s" and "%s --version"
2. If not installed, determine the best installation method for this OS
3. Run the installation command
4. Verify the installation succeeded
Respond with ONLY this JSON (no markdown fences):
{
"tool": "%s",
"installed": true/false,
"version": "installed version or empty",
"message": "what was done",
"error": "error message or empty"
}`, tool, tool, tool, tool)
case "update_tool":
return fmt.Sprintf(`Update the tool "%s" to its latest version on this system.
Steps:
1. Check current version: run "%s --version"
2. Find the latest version available
3. Run the update/upgrade command
4. Verify the new version
Respond with ONLY this JSON (no markdown fences):
{
"tool": "%s",
"previous_version": "old version",
"version": "new version",
"updated": true/false,
"message": "what was done",
"error": "error message or empty"
}`, tool, tool, tool)
default:
return task
}
}
func parseAIJSONResponse(content string) interface{} {
cleaned := content
if idx := strings.Index(cleaned, "```json"); idx != -1 {
cleaned = cleaned[idx+7:]
if end := strings.Index(cleaned, "```"); end != -1 {
cleaned = cleaned[:end]
}
} else if idx := strings.Index(cleaned, "```"); idx != -1 {
cleaned = cleaned[idx+3:]
if end := strings.Index(cleaned, "```"); end != -1 {
cleaned = cleaned[:end]
}
}
cleaned = strings.TrimSpace(cleaned)
jsonStart := strings.Index(cleaned, "{")
jsonEnd := strings.LastIndex(cleaned, "}")
if jsonStart != -1 && jsonEnd > jsonStart {
cleaned = cleaned[jsonStart : jsonEnd+1]
}
var result interface{}
if err := json.Unmarshal([]byte(cleaned), &result); err != nil {
return map[string]interface{}{
"raw": content,
"error": "failed to parse AI response as JSON",
}
}
return result
}

View File

@@ -0,0 +1,469 @@
package api
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"regexp"
"strings"
"time"
"unicode/utf8"
"github.com/muyue/muyue/internal/agent"
"github.com/muyue/muyue/internal/orchestrator"
"github.com/muyue/muyue/internal/platform"
)
var thinkingTagRegex = regexp.MustCompile(`(?s)<[Tt]hink[^>]*>.*?</[Tt]hink>`)
var fileMentionRegex = regexp.MustCompile(`@(\S+\.[a-zA-Z0-9]+)`)
type ImageAttachment struct {
Data string `json:"data"`
Filename string `json:"filename"`
MimeType string `json:"mime_type"`
}
func resolveFileMentions(text string) string {
return fileMentionRegex.ReplaceAllStringFunc(text, func(match string) string {
filePath := match[1:]
if strings.HasPrefix(filePath, "~/") {
if home, err := os.UserHomeDir(); err == nil {
filePath = filepath.Join(home, filePath[2:])
}
}
if !filepath.IsAbs(filePath) {
if home, err := os.UserHomeDir(); err == nil {
filePath = filepath.Join(home, filePath)
}
}
data, err := os.ReadFile(filePath)
if err != nil {
return match + fmt.Sprintf(" (erreur: fichier non trouve)")
}
content := string(data)
if len(content) > 50000 {
content = content[:50000] + "\n... (tronque a 50Ko)"
}
return fmt.Sprintf("[Fichier: %s]\n%s\n[Fin du fichier: %s]", filepath.Base(filePath), content, filepath.Base(filePath))
})
}
var vlmClient = &http.Client{Timeout: 60 * time.Second}
func (s *Server) describeImages(images []ImageAttachment) []string {
var apiKey string
for i := range s.config.AI.Providers {
if s.config.AI.Providers[i].Active {
apiKey = s.config.AI.Providers[i].APIKey
break
}
}
if apiKey == "" {
return nil
}
descriptions := make([]string, 0, len(images))
for _, img := range images {
desc, err := s.callVLM(apiKey, img)
if err != nil {
descriptions = append(descriptions, fmt.Sprintf("(description unavailable: %v)", err))
} else {
descriptions = append(descriptions, desc)
}
}
return descriptions
}
func (s *Server) callVLM(apiKey string, img ImageAttachment) (string, error) {
payload := map[string]string{
"prompt": "Describe this image in detail. Include all text, UI elements, code, diagrams, or data visible. Be thorough and specific.",
"image_url": img.Data,
}
body, err := json.Marshal(payload)
if err != nil {
return "", fmt.Errorf("marshal vlm request: %w", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 55*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, "POST", "https://api.minimax.io/v1/coding_plan/vlm", bytes.NewReader(body))
if err != nil {
return "", fmt.Errorf("create vlm request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+apiKey)
resp, err := vlmClient.Do(req)
if err != nil {
return "", fmt.Errorf("vlm request: %w", err)
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("read vlm response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("vlm API error (%d): %s", resp.StatusCode, string(respBody))
}
var result struct {
Content string `json:"content"`
}
if err := json.Unmarshal(respBody, &result); err != nil {
return "", fmt.Errorf("parse vlm response: %w", err)
}
if result.Content == "" {
return "(empty description)", nil
}
return result.Content, nil
}
func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
writeError(w, "POST only", http.StatusMethodNotAllowed)
return
}
r.Body = http.MaxBytesReader(w, r.Body, 50*1024*1024)
var body struct {
Message string `json:"message"`
Stream bool `json:"stream"`
Images []ImageAttachment `json:"images"`
AdvancedReflection bool `json:"advanced_reflection"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, err.Error(), http.StatusBadRequest)
return
}
if body.Message == "" {
writeError(w, "no message", http.StatusMethodNotAllowed)
return
}
if len(body.Images) > 3 {
writeError(w, "max 3 images", http.StatusBadRequest)
return
}
enrichedMessage := resolveFileMentions(body.Message)
var imageIDs []string
if len(body.Images) > 0 {
descriptions := s.describeImages(body.Images)
var imgContext strings.Builder
for i, desc := range descriptions {
imgContext.WriteString(fmt.Sprintf("\n[Image %d (%s): %s]\n", i+1, body.Images[i].Filename, desc))
id, err := saveImage(body.Images[i].Data, body.Images[i].Filename, body.Images[i].MimeType)
if err != nil {
_ = err
} else {
imageIDs = append(imageIDs, id)
}
}
enrichedMessage = imgContext.String() + enrichedMessage
}
displayMsg := body.Message
if len(body.Images) > 0 {
imgNames := make([]string, len(body.Images))
for i, img := range body.Images {
imgNames[i] = img.Filename
}
displayMsg += " [" + strings.Join(imgNames, ", ") + "]"
}
if len(imageIDs) > 0 {
s.convStore.AddWithImages("user", displayMsg, imageIDs)
} else {
s.convStore.Add("user", displayMsg)
}
if s.convStore.NeedsSummarization() {
s.autoSummarize()
}
orb, err := orchestrator.New(s.config)
if err != nil {
writeError(w, err.Error(), http.StatusServiceUnavailable)
return
}
var studioPrompt strings.Builder
studioPrompt.WriteString(agent.StudioSystemPrompt())
sysInfo := platform.Detect()
osName := sysInfo.OSName
if osName == "" {
osName = string(sysInfo.OS)
}
studioPrompt.WriteString(fmt.Sprintf("\nDate: %s\nHeure: %s\nSystème: %s\n", time.Now().Format("02/01/2006"), time.Now().Format("15:04:05"), osName))
canSudo := !agent.NeedsSudoPassword()
studioPrompt.WriteString(fmt.Sprintf("Root: %t\n", !canSudo))
if !canSudo {
studioPrompt.WriteString("⚠️ Session sans sudo sans mot de passe — les commandes sudo/doas nécessitent une autorisation. N'utilise PAS sudo ou doas sans demander.\n")
} else {
studioPrompt.WriteString("⚠️ Session avec privilèges sudo sans mot de passe — les commandes sudo s'exécuteront directement.\n")
}
orb.SetSystemPrompt(studioPrompt.String())
orb.SetTools(s.agentToolsJSON)
// Auto-force advanced reflection while a browser-test session is active:
// the user is doing AI-driven UI testing, where having a second model
// produce a preliminary report (when one is configured) materially
// improves which clicks the active model decides to perform. The toggle
// remains user-controllable for non-test conversations.
wantReflection := body.AdvancedReflection
if !wantReflection && s.browserTestStore != nil && len(s.browserTestStore.List()) > 0 {
wantReflection = true
}
if wantReflection {
if report, ok := s.runReflectionReport(enrichedMessage); ok {
enrichedMessage = enrichedMessage + "\n\n[RAPPORT PRÉALABLE — produit par un autre modèle, à valider]\n" + report + "\n[/RAPPORT PRÉALABLE]"
}
}
if body.Stream {
s.handleStreamChat(w, orb, enrichedMessage)
} else {
s.handleNonStreamChat(w, orb, enrichedMessage)
}
}
func (s *Server) handleStreamChat(w http.ResponseWriter, orb *orchestrator.Orchestrator, userMessage string) {
SetupSSEHeaders(w)
flusher, canFlush := w.(http.Flusher)
sseWriter := NewSSEWriter(w)
ctx := context.Background()
messages := s.buildContextMessages(userMessage)
engine := NewChatEngine(orb, s.agentRegistry, s.agentToolsJSON)
engine.SetLimiter(s.AcquireAgentSlot)
engine.OnChunk(func(data map[string]interface{}) {
if data == nil {
return
}
sseWriter.Write(data)
if canFlush {
flusher.Flush()
}
})
finalContent, allToolCalls, allToolResults, err := engine.RunWithTools(ctx, messages)
if err != nil {
sseWriter.Write(map[string]interface{}{"error": err.Error()})
return
}
storeContent := finalContent
if len(allToolCalls) > 0 {
storeObj := map[string]interface{}{
"content": storeContent,
"tool_calls": allToolCalls,
"tool_results": allToolResults,
}
storeJSON, _ := json.Marshal(storeObj)
storeContent = string(storeJSON)
}
s.convStore.Add("assistant", storeContent)
s.consumption.Record(engine.ProviderName(), engine.TotalTokens)
sseWriter.Write(map[string]interface{}{"done": "true"})
}
func (s *Server) handleNonStreamChat(w http.ResponseWriter, orb *orchestrator.Orchestrator, userMessage string) {
ctx := context.Background()
messages := s.buildContextMessages(userMessage)
engine := NewChatEngine(orb, s.agentRegistry, s.agentToolsJSON)
engine.SetLimiter(s.AcquireAgentSlot)
finalContent, err := engine.RunNonStream(ctx, messages)
if err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
s.convStore.Add("assistant", finalContent)
s.consumption.Record(engine.ProviderName(), engine.TotalTokens)
writeJSON(w, map[string]string{"content": finalContent})
}
func cleanThinkingTags(content string) string {
return strings.TrimSpace(thinkingTagRegex.ReplaceAllString(content, ""))
}
// runReflectionReport runs the inactive AI provider on the user message to
// produce a preliminary analysis report that the active provider will then
// use as additional context. Returns ("", false) if no inactive provider is
// configured or on error — the caller falls back to a normal chat flow.
func (s *Server) runReflectionReport(userMessage string) (string, bool) {
orb, err := orchestrator.NewForInactiveProvider(s.config)
if err != nil {
return "", false
}
orb.SetSystemPrompt("Tu es un analyste. Pour la question ci-dessous, produis un rapport bref (max 15 lignes) qui : (1) reformule l'objectif de l'utilisateur, (2) liste les points à clarifier ou les risques, (3) suggère une approche structurée. Pas de code, pas d'action — uniquement de l'analyse.")
resp, err := orb.SendNoTools(userMessage)
if err != nil {
return "", false
}
return strings.TrimSpace(resp), true
}
func (s *Server) buildContextMessages(userMessage string) []orchestrator.Message {
history := s.convStore.Get()
sysPromptTokens := utf8.RuneCountInString(agent.StudioSystemPrompt())/charsPerToken + 50
toolsTokens := utf8.RuneCountInString(string(s.agentToolsJSON)) / charsPerToken
responseMargin := 4000
userMsgTokens := utf8.RuneCountInString(userMessage) / charsPerToken
overhead := sysPromptTokens + toolsTokens + responseMargin + userMsgTokens
available := contextWindowTokens - overhead
if available < 1000 {
available = 1000
}
included := 0
tokensUsed := 0
for i := len(history) - 1; i >= 0; i-- {
if history[i].Summarized {
break
}
displayContent := extractDisplayContent(history[i].Role, history[i].Content)
msgTokens := utf8.RuneCountInString(displayContent) / charsPerToken
if msgTokens == 0 {
msgTokens = 1
}
if tokensUsed+msgTokens > available {
break
}
tokensUsed += msgTokens
included++
}
start := len(history) - included
if start < 0 {
start = 0
}
hasSummarized := false
for i := 0; i < start; i++ {
if history[i].Summarized {
hasSummarized = true
break
}
}
if start > 0 {
_ = start
}
messages := make([]orchestrator.Message, 0, included+2)
summary := s.convStore.GetSummary()
if summary != "" && (start > 0 || hasSummarized) {
messages = append(messages, orchestrator.Message{
Role: "system",
Content: orchestrator.TextContent("Résumé de la conversation précédente:\n" + summary),
})
}
for _, m := range history[start:] {
if m.Role == "system" {
continue
}
displayContent := extractDisplayContent(m.Role, m.Content)
messages = append(messages, orchestrator.Message{
Role: m.Role,
Content: orchestrator.TextContent(displayContent),
})
}
messages = append(messages, orchestrator.Message{
Role: "user",
Content: orchestrator.TextContent(userMessage),
})
return messages
}
func (s *Server) autoSummarize() {
messages := s.convStore.Get()
if len(messages) < 10 {
return
}
half := len(messages) / 2
var oldText string
for _, m := range messages[:half] {
oldText += m.Role + ": " + m.Content + "\n\n"
}
summary := s.convStore.GetSummary()
if summary != "" {
oldText = "Résumé précédent:\n" + summary + "\n\nNouveaux échanges:\n" + oldText
}
orb, err := orchestrator.New(s.config)
if err != nil {
return
}
orb.SetSystemPrompt(summarizePrompt)
result, err := orb.Send(oldText)
if err != nil {
return
}
s.convStore.SetSummary(result)
s.convStore.MarkSummarized(half)
}
func (s *Server) handleChatHistory(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" {
writeError(w, "GET only", http.StatusMethodNotAllowed)
return
}
messages := s.convStore.Get()
writeJSON(w, map[string]interface{}{
"messages": messages,
"tokens": s.convStore.ApproxTokenCount(),
"max_tokens": contextWindowTokens,
"summarize_at": int(float64(contextWindowTokens) * summarizeRatio),
"summary": s.convStore.GetSummary(),
})
}
func (s *Server) handleChatClear(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
writeError(w, "POST only", http.StatusMethodNotAllowed)
return
}
s.convStore.Clear()
writeJSON(w, map[string]string{"status": "ok"})
}
func (s *Server) handleChatSummarize(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
writeError(w, "POST only", http.StatusMethodNotAllowed)
return
}
s.autoSummarize()
writeJSON(w, map[string]interface{}{
"status": "ok",
"tokens": s.convStore.ApproxTokenCount(),
"summary": s.convStore.GetSummary(),
})
}

View File

@@ -0,0 +1,31 @@
package api
import (
"encoding/json"
"net/http"
)
const summarizePrompt = `Résume cette conversation de manière ultra-concise et structurée.
CONSERVE :
- Les décisions techniques prises et leur rationale
- Les configurations modifiées (noms exacts, valeurs)
- Les fichiers/chemins manipulés
- Les erreurs rencontrées et leurs résolutions
- Le contexte nécessaire pour continuer
ÉLIMINE :
- Les échanges de politesse
- Les tentatives infructueuses (sauf si la solution n'a pas été trouvée)
- Les sorties d'outils brutes (garde seulement les conclusions)
FORMAT : Markdown structuré avec sections. Max 500 mots. Pas de méta-commentaire.`
func writeJSON(w http.ResponseWriter, data interface{}) {
json.NewEncoder(w).Encode(data)
}
func writeError(w http.ResponseWriter, msg string, code int) {
w.WriteHeader(code)
json.NewEncoder(w).Encode(map[string]string{"error": msg})
}

View File

@@ -0,0 +1,511 @@
package api
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"github.com/muyue/muyue/internal/config"
)
func (s *Server) handleUpdatePreferences(w http.ResponseWriter, r *http.Request) {
if r.Method != "PUT" {
writeError(w, "PUT only", http.StatusMethodNotAllowed)
return
}
if s.config == nil {
writeError(w, "no config", http.StatusNotFound)
return
}
var body struct {
Language string `json:"language"`
KeyboardLayout string `json:"keyboard_layout"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, err.Error(), http.StatusBadRequest)
return
}
if body.Language != "" {
s.config.Profile.Preferences.Language = body.Language
}
if body.KeyboardLayout != "" {
s.config.Profile.Preferences.KeyboardLayout = body.KeyboardLayout
}
if err := config.Save(s.config); err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, map[string]string{"status": "ok"})
}
func (s *Server) handleSaveProfile(w http.ResponseWriter, r *http.Request) {
if r.Method != "PUT" {
writeError(w, "PUT only", http.StatusMethodNotAllowed)
return
}
if s.config == nil {
writeError(w, "no config", http.StatusNotFound)
return
}
currentJSON, err := json.Marshal(s.config.Profile)
if err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
var currentMap map[string]interface{}
if err := json.Unmarshal(currentJSON, &currentMap); err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
var updates map[string]interface{}
body, err := io.ReadAll(r.Body)
if err != nil {
writeError(w, err.Error(), http.StatusBadRequest)
return
}
if err := json.Unmarshal(body, &updates); err != nil {
writeError(w, err.Error(), http.StatusBadRequest)
return
}
deepMerge(currentMap, updates)
mergedJSON, err := json.Marshal(currentMap)
if err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
if err := json.Unmarshal(mergedJSON, &s.config.Profile); err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
if err := config.Save(s.config); err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, map[string]string{"status": "ok"})
}
func deepMerge(dst, src map[string]interface{}) {
for k, sv := range src {
if dv, ok := dst[k]; ok {
dstMap, dOk := dv.(map[string]interface{})
srcMap, sOk := sv.(map[string]interface{})
if dOk && sOk {
deepMerge(dstMap, srcMap)
continue
}
}
dst[k] = sv
}
}
func (s *Server) handleSaveProvider(w http.ResponseWriter, r *http.Request) {
if r.Method != "PUT" {
writeError(w, "PUT only", http.StatusMethodNotAllowed)
return
}
if s.config == nil {
writeError(w, "no config", http.StatusNotFound)
return
}
var body struct {
Name string `json:"name"`
APIKey string `json:"api_key"`
Model string `json:"model"`
BaseURL string `json:"base_url"`
Active *bool `json:"active"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, err.Error(), http.StatusBadRequest)
return
}
if body.Name == "" {
writeError(w, "name required", http.StatusBadRequest)
return
}
found := false
for i := range s.config.AI.Providers {
if s.config.AI.Providers[i].Name == body.Name {
if body.APIKey != "" && body.APIKey != "***" {
s.config.AI.Providers[i].APIKey = body.APIKey
}
if body.Model != "" {
s.config.AI.Providers[i].Model = body.Model
}
if body.BaseURL != "" {
s.config.AI.Providers[i].BaseURL = body.BaseURL
}
if body.Active != nil {
if *body.Active {
for j := range s.config.AI.Providers {
s.config.AI.Providers[j].Active = false
}
}
s.config.AI.Providers[i].Active = *body.Active
}
found = true
break
}
}
if !found {
writeError(w, "provider not found", http.StatusNotFound)
return
}
if err := config.Save(s.config); err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, map[string]string{"status": "ok"})
}
func (s *Server) handleValidateProvider(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
writeError(w, "POST only", http.StatusMethodNotAllowed)
return
}
var body struct {
Name string `json:"name"`
APIKey string `json:"api_key"`
Model string `json:"model"`
BaseURL string `json:"base_url"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, err.Error(), http.StatusBadRequest)
return
}
if body.APIKey == "" {
writeError(w, "api_key required", http.StatusBadRequest)
return
}
if body.APIKey == "***" {
for _, p := range s.config.AI.Providers {
if p.Name == body.Name {
body.APIKey = p.APIKey
break
}
}
}
baseURL := body.BaseURL
if baseURL == "" {
for _, p := range s.config.AI.Providers {
if p.Name == body.Name {
baseURL = p.BaseURL
break
}
}
}
if baseURL == "" {
switch body.Name {
case "minimax":
baseURL = "https://api.minimax.io/v1"
case "mimo":
baseURL = "https://token-plan-ams.xiaomimimo.com/v1"
case "openai":
baseURL = "https://api.openai.com/v1"
case "anthropic":
baseURL = "https://api.anthropic.com/v1"
default:
baseURL = "https://api.minimax.io/v1"
}
}
model := body.Model
if model == "" {
for _, p := range s.config.AI.Providers {
if p.Name == body.Name {
model = p.Model
break
}
}
}
if model == "" {
model = "MiniMax-M2.7"
}
reqBody, _ := json.Marshal(map[string]interface{}{
"model": model,
"messages": []map[string]string{{"role": "user", "content": "Hi"}},
"max_tokens": 5,
"stream": false,
})
url := strings.TrimRight(baseURL, "/") + "/chat/completions"
req, err := http.NewRequest("POST", url, bytes.NewReader(reqBody))
if err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+body.APIKey)
client := &http.Client{Timeout: 15 * time.Second}
resp, err := client.Do(req)
if err != nil {
writeError(w, "connection failed: "+err.Error(), http.StatusBadGateway)
return
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(resp.Body)
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
writeError(w, "invalid_api_key", http.StatusUnauthorized)
return
}
if resp.StatusCode != http.StatusOK {
writeError(w, "api_error: "+string(respBody), http.StatusBadGateway)
return
}
writeJSON(w, map[string]interface{}{"status": "valid"})
}
func (s *Server) handleSaveTerminalSettings(w http.ResponseWriter, r *http.Request) {
if r.Method != "PUT" {
writeError(w, "PUT only", http.StatusMethodNotAllowed)
return
}
if s.config == nil {
writeError(w, "no config", http.StatusNotFound)
return
}
var body struct {
FontSize int `json:"font_size"`
FontFamily string `json:"font_family"`
Theme string `json:"theme"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, err.Error(), http.StatusBadRequest)
return
}
if body.FontSize > 0 && body.FontSize <= 72 {
s.config.Terminal.FontSize = body.FontSize
}
if body.FontFamily != "" {
s.config.Terminal.FontFamily = body.FontFamily
}
if body.Theme != "" {
s.config.Terminal.Theme = body.Theme
}
if err := config.Save(s.config); err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, map[string]interface{}{
"status": "ok",
"theme": config.GetTerminalTheme(s.config.Terminal.Theme),
})
}
func (s *Server) handleGetTerminalThemes(w http.ResponseWriter, r *http.Request) {
themes := make([]map[string]string, 0, len(config.DEFAULT_TERMINAL_THEMES))
for id, theme := range config.DEFAULT_TERMINAL_THEMES {
themes = append(themes, map[string]string{
"id": id,
"name": theme.Name,
})
}
writeJSON(w, map[string]interface{}{"themes": themes})
}
func (s *Server) handleResetConfig(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
writeError(w, "POST only", http.StatusMethodNotAllowed)
return
}
dir, err := config.ConfigDir()
if err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
path := filepath.Join(dir, "config.yaml")
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
s.config = config.Default()
if err := config.Save(s.config); err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, map[string]string{"status": "ok"})
}
func (s *Server) handleApplyStarshipTheme(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
writeError(w, "POST only", http.StatusMethodNotAllowed)
return
}
var body struct {
Theme string `json:"theme"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, err.Error(), http.StatusBadRequest)
return
}
if body.Theme == "" {
body.Theme = s.config.Terminal.PromptTheme
}
themeFile := ApplyStarshipTheme(body.Theme)
s.config.Terminal.PromptTheme = body.Theme
config.Save(s.config)
writeJSON(w, map[string]interface{}{"status": "ok", "config": themeFile})
}
func ApplyStarshipTheme(theme string) string {
cfgDir, _ := config.ConfigDir()
starshipDir := filepath.Join(cfgDir, "starship")
os.MkdirAll(starshipDir, 0755)
themeFile := filepath.Join(starshipDir, "starship.toml")
themeContent := getStarshipThemeConfig(theme)
os.WriteFile(themeFile, []byte(themeContent), 0644)
home, _ := os.UserHomeDir()
for _, rc := range []string{filepath.Join(home, ".bashrc"), filepath.Join(home, ".zshrc")} {
if _, err := os.Stat(rc); err != nil {
continue
}
content, _ := os.ReadFile(rc)
if strings.Contains(string(content), "STARSHIP_CONFIG") {
continue
}
exportLine := fmt.Sprintf("\n# Muyue Starship config\nexport STARSHIP_CONFIG=%s\n", themeFile)
f, err := os.OpenFile(rc, os.O_APPEND|os.O_WRONLY, 0644)
if err != nil {
continue
}
f.WriteString(exportLine)
f.Close()
}
return themeFile
}
func getStarshipThemeConfig(theme string) string {
switch theme {
case "charm":
return `[format]
before_format = "$"
format = """
$username$directory$git_branch$git_status$cmd_duration$line_break$character"""
[character]
success_symbol = "[➜](bold #00E676)"
error_symbol = "[✗](bold #FF0033)"
[directory]
truncation_length = 3
truncation_symbol = "…/"
style = "bold #00BCD4"
[username]
show_on_left = false
style_user = "bold #FF0033"
style_root = "bold #FF0033"
[git_branch]
symbol = " "
format = "on [$symbol$branch]($style)"
style = "bold #FFD740"
[git_status]
format = "[$all_status$ahead_behind]($style) "
style = "bold #FF1A5E"
conflicted = "!"
untracked = "?"
modified = "~"
staged = "[+]"
renamed = "»"
deleted = "-"
[cmd_duration]
min_time = 500
format = "took [$duration]($style)"
style = "bold #75715E"
`
case "zerotwo":
return `[format]
before_format = "$"
format = """
$username$directory$git_branch$git_status$cmd_duration$line_break$character"""
[character]
success_symbol = "[](bold #3B82F6)"
error_symbol = "[](bold #EF4444)"
[directory]
truncation_length = 3
truncation_symbol = "…/"
style = "bold #8B5CF6"
[username]
show_on_left = false
style_user = "bold #EC4899"
style_root = "bold #EF4444"
[git_branch]
symbol = " "
format = "on [$symbol$branch]($style)"
style = "bold #F472B6"
[git_status]
format = "[$all_status$ahead_behind]($style) "
style = "bold #EF4444"
conflicted = "!"
untracked = "?"
modified = "~"
staged = "[+]"
renamed = "»"
deleted = "-"
[cmd_duration]
min_time = 500
format = "took [$duration]($style)"
style = "bold #6B7280"
`
default:
return `[format]
before_format = "$"
format = """
$username$directory$git_branch$git_status$line_break$character"""
[character]
success_symbol = "[](bold green)"
error_symbol = "[](bold red)"
[directory]
truncation_length = 3
truncation_symbol = "…/"
style = "bold cyan"
[username]
show_on_left = false
style_user = "bold red"
style_root = "bold red"
[git_branch]
symbol = " "
format = "on [$symbol$branch]($style)"
style = "bold yellow"
[cmd_duration]
min_time = 500
format = "took [$duration]($style)"
style = "bold bright-black"
`
}
}

View File

@@ -0,0 +1,761 @@
package api
import (
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"time"
"github.com/muyue/muyue/internal/agent"
"github.com/muyue/muyue/internal/lsp"
"github.com/muyue/muyue/internal/mcp"
"github.com/muyue/muyue/internal/scanner"
"github.com/muyue/muyue/internal/skills"
"github.com/muyue/muyue/internal/version"
)
func (s *Server) handleInfo(w http.ResponseWriter, r *http.Request) {
writeJSON(w, map[string]interface{}{
"name": version.Name,
"version": version.Version,
"author": version.Author,
"sudo": !agent.NeedsSudoPassword(),
})
}
func (s *Server) handleSystem(w http.ResponseWriter, r *http.Request) {
if s.scanResult == nil {
s.scanResult = scanner.ScanSystem()
}
writeJSON(w, map[string]interface{}{
"system": s.scanResult.System,
})
}
func (s *Server) handleTools(w http.ResponseWriter, r *http.Request) {
if s.scanResult == nil {
s.scanResult = scanner.ScanSystem()
}
type toolInfo struct {
Name string `json:"name"`
Installed bool `json:"installed"`
Version string `json:"version"`
Path string `json:"path"`
}
tools := make([]toolInfo, len(s.scanResult.Tools))
for i, t := range s.scanResult.Tools {
tools[i] = toolInfo{
Name: t.Name,
Installed: t.Installed,
Version: t.Version,
Path: t.Path,
}
}
writeJSON(w, map[string]interface{}{
"tools": tools,
"total": len(tools),
})
}
func (s *Server) handleConfig(w http.ResponseWriter, r *http.Request) {
if s.config == nil {
writeError(w, "no config", http.StatusNotFound)
return
}
writeJSON(w, map[string]interface{}{
"profile": s.config.Profile,
"terminal": s.config.Terminal,
"bmad": s.config.BMAD,
})
}
func (s *Server) handleProviders(w http.ResponseWriter, r *http.Request) {
if s.config == nil {
writeError(w, "no config", http.StatusNotFound)
return
}
masked := make([]map[string]interface{}, 0, len(s.config.AI.Providers))
for _, p := range s.config.AI.Providers {
entry := map[string]interface{}{
"name": p.Name,
"model": p.Model,
"base_url": p.BaseURL,
"active": p.Active,
}
if p.APIKey != "" {
entry["api_key"] = "***"
} else {
entry["api_key"] = ""
}
masked = append(masked, entry)
}
writeJSON(w, map[string]interface{}{
"providers": masked,
})
}
func (s *Server) handleSkills(w http.ResponseWriter, r *http.Request) {
list, err := skills.List()
if err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
for i := range list {
list[i].Deployed = skills.IsDeployed(list[i].Name)
}
writeJSON(w, map[string]interface{}{
"skills": list,
"count": len(list),
})
}
func (s *Server) handleLSP(w http.ResponseWriter, r *http.Request) {
servers := lsp.ScanServers()
writeJSON(w, map[string]interface{}{
"servers": servers,
})
}
func (s *Server) handleMCP(w http.ResponseWriter, r *http.Request) {
servers := mcp.ScanServers()
home, _ := os.UserHomeDir()
editors := mcp.DetectInstalledEditors(home)
statuses := mcp.GetAllStatuses()
writeJSON(w, map[string]interface{}{
"servers": servers,
"configured": true,
"detected_editors": editors,
"statuses": statuses,
})
}
func (s *Server) handleMCPConfigure(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
writeError(w, "POST only", http.StatusMethodNotAllowed)
return
}
var body struct {
Editor string `json:"editor,omitempty"`
}
if r.Body != nil {
json.NewDecoder(r.Body).Decode(&body)
}
if body.Editor != "" {
if err := mcp.ConfigureForEditor(s.config, body.Editor); err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
} else {
if err := mcp.ConfigureAll(s.config); err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
}
writeJSON(w, map[string]string{"status": "ok"})
}
func (s *Server) handleMCPStatus(w http.ResponseWriter, r *http.Request) {
statuses := mcp.GetAllStatuses()
writeJSON(w, map[string]interface{}{
"statuses": statuses,
})
}
func (s *Server) handleMCPRegistry(w http.ResponseWriter, r *http.Request) {
reg, err := mcp.LoadRegistry()
if err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, map[string]interface{}{
"registry": reg,
})
}
func (s *Server) handleLSPHealth(w http.ResponseWriter, r *http.Request) {
servers := lsp.ScanServers()
type healthInfo struct {
Name string `json:"name"`
Language string `json:"language"`
Installed bool `json:"installed"`
Healthy bool `json:"healthy"`
Detail string `json:"detail,omitempty"`
}
var results []healthInfo
for _, srv := range servers {
healthy, detail := lsp.HealthCheck(srv.Name)
results = append(results, healthInfo{
Name: srv.Name,
Language: srv.Language,
Installed: srv.Installed,
Healthy: healthy,
Detail: detail,
})
}
writeJSON(w, map[string]interface{}{
"servers": results,
})
}
func (s *Server) handleLSPAutoInstall(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
writeError(w, "POST only", http.StatusMethodNotAllowed)
return
}
var body struct {
ProjectDir string `json:"project_dir,omitempty"`
}
if r.Body != nil {
json.NewDecoder(r.Body).Decode(&body)
}
home, _ := os.UserHomeDir()
if body.ProjectDir == "" {
body.ProjectDir = home
} else {
abs, err := filepath.Abs(body.ProjectDir)
if err != nil {
writeError(w, "invalid project_dir", http.StatusBadRequest)
return
}
body.ProjectDir = abs
if home != "" && !strings.HasPrefix(abs, home+string(filepath.Separator)) && abs != home {
writeError(w, "project_dir must be within user home", http.StatusBadRequest)
return
}
}
results, err := lsp.AutoInstallForProject(body.ProjectDir)
if err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, map[string]interface{}{
"results": results,
})
}
func (s *Server) handleLSPEditorConfig(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
writeError(w, "POST only", http.StatusMethodNotAllowed)
return
}
var body struct {
Editor string `json:"editor"`
Names []string `json:"names,omitempty"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, err.Error(), http.StatusBadRequest)
return
}
allServers := lsp.ScanServers()
var selected []lsp.LSPServer
if len(body.Names) > 0 {
nameSet := map[string]bool{}
for _, n := range body.Names {
nameSet[n] = true
}
for _, srv := range allServers {
if nameSet[srv.Name] {
selected = append(selected, srv)
}
}
} else {
for _, srv := range allServers {
if srv.Installed {
selected = append(selected, srv)
}
}
}
config, err := lsp.GenerateEditorConfigs(selected, body.Editor, "")
if err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, map[string]interface{}{
"editor": body.Editor,
"config": config,
})
}
func (s *Server) handleSkillValidate(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
writeError(w, "POST only", http.StatusMethodNotAllowed)
return
}
var body struct {
Name string `json:"name"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, err.Error(), http.StatusBadRequest)
return
}
skill, err := skills.Get(body.Name)
if err != nil {
writeError(w, err.Error(), http.StatusNotFound)
return
}
errs := skills.Validate(skill)
writeJSON(w, map[string]interface{}{
"name": body.Name,
"valid": len(errs) == 0,
"errors": errs,
"dependencies": skills.CheckDependencies(skill),
})
}
func (s *Server) handleSkillTest(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
writeError(w, "POST only", http.StatusMethodNotAllowed)
return
}
var body struct {
Name string `json:"name"`
SampleTask string `json:"sample_task,omitempty"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, err.Error(), http.StatusBadRequest)
return
}
result := skills.DryRun(body.Name, body.SampleTask)
writeJSON(w, result)
}
func (s *Server) handleSkillExport(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
writeError(w, "POST only", http.StatusMethodNotAllowed)
return
}
var body struct {
Name string `json:"name"`
ExportPath string `json:"export_path"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, err.Error(), http.StatusBadRequest)
return
}
home, _ := os.UserHomeDir()
if body.ExportPath == "" {
body.ExportPath = home + "/.muyue/exports/" + body.Name + ".md"
}
if err := skills.Export(body.Name, body.ExportPath); err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, map[string]string{"status": "ok", "path": body.ExportPath})
}
func (s *Server) handleSkillImport(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
writeError(w, "POST only", http.StatusMethodNotAllowed)
return
}
var body struct {
ImportPath string `json:"import_path"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, err.Error(), http.StatusBadRequest)
return
}
skill, err := skills.Import(body.ImportPath)
if err != nil {
writeError(w, err.Error(), http.StatusBadRequest)
return
}
if err := skills.Create(skill); err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, map[string]interface{}{"status": "ok", "skill": skill.Name})
}
func (s *Server) handleDashboardStatus(w http.ResponseWriter, r *http.Request) {
mcpStatuses := mcp.GetAllStatuses()
lspServers := lsp.ScanServers()
skillList, _ := skills.List()
mcpHealthy := 0
mcpTotal := len(mcpStatuses)
for _, st := range mcpStatuses {
if st.Healthy {
mcpHealthy++
}
}
lspInstalled := 0
lspTotal := len(lspServers)
for _, srv := range lspServers {
if srv.Installed {
lspInstalled++
}
}
skillsDeployed := len(skillList)
var skillIssues []string
for _, sk := range skillList {
missing := skills.CheckDependencies(&sk)
if len(missing) > 0 {
for _, dep := range missing {
skillIssues = append(skillIssues, sk.Name+": missing "+dep.Type+" "+dep.Name)
}
}
}
writeJSON(w, map[string]interface{}{
"mcp": map[string]interface{}{
"total": mcpTotal,
"healthy": mcpHealthy,
"servers": mcpStatuses,
},
"lsp": map[string]interface{}{
"total": lspTotal,
"installed": lspInstalled,
"servers": lspServers,
},
"skills": map[string]interface{}{
"total": skillsDeployed,
"issues": skillIssues,
"deployed": skillList,
},
})
}
func (s *Server) handleScan(w http.ResponseWriter, r *http.Request) {
s.scanResult = scanner.ScanSystem()
writeJSON(w, map[string]string{"status": "ok"})
}
func (s *Server) handleEditors(w http.ResponseWriter, r *http.Request) {
editors := scanner.ScanEditors()
writeJSON(w, map[string]interface{}{"editors": editors})
}
func (s *Server) handleProvidersQuota(w http.ResponseWriter, r *http.Request) {
type providerQuota struct {
Name string `json:"name"`
Active bool `json:"active"`
Healthy bool `json:"healthy"`
Data map[string]interface{} `json:"data,omitempty"`
Error string `json:"error,omitempty"`
}
var results []providerQuota
client := &http.Client{Timeout: 8 * time.Second}
for _, p := range s.config.AI.Providers {
q := providerQuota{Name: p.Name, Active: p.Active}
switch p.Name {
case "minimax":
if p.APIKey == "" {
q.Error = "no API key"
results = append(results, q)
continue
}
req, _ := http.NewRequest("GET", "https://api.minimax.io/v1/token_plan/remains", nil)
req.Header.Set("Authorization", "Bearer "+p.APIKey)
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil {
q.Error = err.Error()
} else {
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
var data map[string]interface{}
if json.Unmarshal(body, &data) == nil {
if models, ok := data["model_remains"].([]interface{}); ok {
filtered := make([]map[string]interface{}, 0)
for _, m := range models {
if mm, ok := m.(map[string]interface{}); ok {
usage, _ := mm["current_interval_usage_count"].(float64)
total, _ := mm["current_interval_total_count"].(float64)
if total > 0 {
filtered = append(filtered, map[string]interface{}{
"model": mm["model_name"],
"used": usage,
"total": total,
"remaining": total - usage,
"weekly_used": mm["current_weekly_usage_count"],
"weekly_total": mm["current_weekly_total_count"],
})
}
}
}
q.Data = map[string]interface{}{"models": filtered}
q.Healthy = true
}
}
}
case "zai":
if p.APIKey == "" {
q.Error = "no API key"
results = append(results, q)
continue
}
req, _ := http.NewRequest("GET", "https://api.z.ai/api/monitor/usage/quota/limit", nil)
req.Header.Set("Authorization", "Bearer "+p.APIKey)
req.Header.Set("Accept", "application/json")
resp, err := client.Do(req)
if err != nil {
q.Error = err.Error()
} else {
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
var data map[string]interface{}
if json.Unmarshal(body, &data) == nil {
if d, ok := data["data"].(map[string]interface{}); ok {
if limits, ok := d["limits"].([]interface{}); ok {
models := make([]map[string]interface{}, 0)
for _, l := range limits {
if lm, ok := l.(map[string]interface{}); ok {
name := "Z.AI"
if model, ok := lm["model"].(string); ok && model != "" {
name = model
} else if t, ok := lm["type"].(string); ok && t != "TIME_LIMIT" {
name = t
}
usage, _ := lm["usage"].(float64)
remaining, _ := lm["remaining"].(float64)
limitVal, hasLimit := lm["limit"].(float64)
total := usage + remaining
if hasLimit && limitVal > 0 {
total = limitVal
}
if total > 0 {
models = append(models, map[string]interface{}{
"model": name,
"used": usage,
"total": total,
"remaining": remaining,
})
}
}
}
if len(models) > 0 {
q.Data = map[string]interface{}{"models": models}
q.Healthy = true
}
}
}
}
}
case "mimo":
q.Healthy = p.APIKey != ""
if p.APIKey == "" {
q.Error = "no API key"
results = append(results, q)
continue
}
mimoBase := p.BaseURL
if mimoBase == "" {
mimoBase = "https://token-plan-ams.xiaomimimo.com/v1"
}
req, _ := http.NewRequest("GET", strings.TrimRight(mimoBase, "/")+"/models", nil)
req.Header.Set("Authorization", "Bearer "+p.APIKey)
resp, err := client.Do(req)
if err != nil {
q.Error = err.Error()
} else {
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
var data map[string]interface{}
if json.Unmarshal(body, &data) == nil {
if modelList, ok := data["data"].([]interface{}); ok {
models := make([]map[string]interface{}, 0)
for _, m := range modelList {
if mm, ok := m.(map[string]interface{}); ok {
id, _ := mm["id"].(string)
if id != "" {
models = append(models, map[string]interface{}{
"model": id,
})
}
}
}
q.Data = map[string]interface{}{"models": models, "available": len(models)}
q.Healthy = true
}
}
}
case "claude", "anthropic":
// Claude Code n'a pas d'API externe, vérifier l'installation
claudePath := "/usr/bin/claude"
if _, err := os.Stat(claudePath); err == nil {
q.Healthy = true
} else {
q.Error = "claude code not installed"
}
default:
q.Error = "quota not supported"
}
results = append(results, q)
}
writeJSON(w, map[string]interface{}{"providers": results})
}
func (s *Server) handleProvidersConsumption(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" {
writeError(w, "GET only", http.StatusMethodNotAllowed)
return
}
data := s.consumption.GetAll()
writeJSON(w, map[string]interface{}{"providers": data})
}
func (s *Server) handleRecentCommands(w http.ResponseWriter, r *http.Request) {
home, _ := os.UserHomeDir()
type cmdEntry struct {
Cmd string `json:"cmd"`
Shell string `json:"shell"`
}
var entries []cmdEntry
for _, histFile := range []string{".bash_history", ".zsh_history"} {
path := filepath.Join(home, histFile)
data, err := os.ReadFile(path)
if err != nil {
continue
}
shell := "bash"
if strings.Contains(histFile, "zsh") {
shell = "zsh"
}
lines := strings.Split(string(data), "\n")
start := len(lines) - 50
if start < 0 {
start = 0
}
for i := len(lines) - 1; i >= start; i-- {
line := strings.TrimSpace(lines[i])
if line == "" || strings.HasPrefix(line, "#") {
continue
}
if strings.HasPrefix(line, ": ") {
parts := strings.SplitN(line, ";", 2)
if len(parts) == 2 {
line = strings.TrimSpace(parts[1])
} else {
continue
}
}
if line == "" {
continue
}
base := strings.Fields(line)[0]
if len(base) < 2 {
continue
}
if !regexp.MustCompile(`^[a-zA-Z@./]`).MatchString(base) {
continue
}
entries = append(entries, cmdEntry{Cmd: line, Shell: shell})
}
}
max := 20
if len(entries) > max {
entries = entries[:max]
}
writeJSON(w, map[string]interface{}{"commands": entries})
}
func (s *Server) handleRunningProcesses(w http.ResponseWriter, r *http.Request) {
type proc struct {
PID int `json:"pid"`
Name string `json:"name"`
Command string `json:"command"`
CPU string `json:"cpu"`
Mem string `json:"mem"`
}
var procs []proc
editors := []string{"code", "nvim", "vim", "emacs", "hx", "subl", "zed", "cursor"}
langs := []string{"node", "python", "java", "go", "rustc", "cargo", "ruby", "php"}
interesting := append(editors, langs...)
interesting = append(interesting, "muyue")
cmd := exec.Command("ps", "aux")
out, err := cmd.Output()
if err != nil {
writeJSON(w, map[string]interface{}{"processes": procs})
return
}
lines := strings.Split(string(out), "\n")
for _, line := range lines[1:] {
fields := strings.Fields(line)
if len(fields) < 11 {
continue
}
fullCmd := strings.Join(fields[10:], " ")
name := filepath.Base(fields[10])
matched := false
for _, pattern := range interesting {
if strings.Contains(name, pattern) || strings.Contains(strings.ToLower(fullCmd), pattern) {
matched = true
break
}
}
if !matched {
continue
}
var pid int
fmt.Sscanf(fields[1], "%d", &pid)
procs = append(procs, proc{
PID: pid,
Name: name,
Command: fullCmd,
CPU: fields[2],
Mem: fields[3],
})
}
writeJSON(w, map[string]interface{}{"processes": procs})
}
type sysMetrics struct {
CPUPercent float64 `json:"cpu_percent"`
MemPercent float64 `json:"mem_percent"`
MemUsedMB float64 `json:"mem_used_mb"`
MemTotalMB float64 `json:"mem_total_mb"`
NetRxKBs float64 `json:"net_rx_kbs"`
NetTxKBs float64 `json:"net_tx_kbs"`
}
var (
lastCPU [2]float64
lastNet [2]float64
lastNetTs time.Time
lastCPUSet bool
)
func (s *Server) handleSystemMetrics(w http.ResponseWriter, r *http.Request) {
m := collectSystemMetrics()
writeJSON(w, m)
}

View File

@@ -0,0 +1,292 @@
package api
import (
"encoding/json"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"github.com/muyue/muyue/internal/config"
"github.com/muyue/muyue/internal/lsp"
"github.com/muyue/muyue/internal/skills"
)
type SavedConversation struct {
ID string `json:"id"`
Title string `json:"title"`
Summary string `json:"summary,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Messages []MessageEntry `json:"messages,omitempty"`
}
type MessageEntry struct {
ID string `json:"id"`
Role string `json:"role"`
Content string `json:"content"`
Time string `json:"time"`
}
type conversationsStore struct {
Path string
Items []SavedConversation
}
func conversationsPath() string {
dir, _ := config.ConfigDir()
return filepath.Join(dir, "conversations.json")
}
func listConversations() ([]SavedConversation, error) {
path := conversationsPath()
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return []SavedConversation{}, nil
}
return nil, err
}
var store conversationsStore
if err := json.Unmarshal(data, &store); err != nil {
return []SavedConversation{}, nil
}
return store.Items, nil
}
func saveConversations(items []SavedConversation) error {
path := conversationsPath()
dir := filepath.Dir(path)
os.MkdirAll(dir, 0755)
data, err := json.MarshalIndent(struct {
Items []SavedConversation `json:"items"`
}{Items: items}, "", " ")
if err != nil {
return err
}
return os.WriteFile(path, data, 0600)
}
func (s *Server) handleListConversations(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" {
writeError(w, "GET only", http.StatusMethodNotAllowed)
return
}
convs, err := listConversations()
if err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
conv := s.convStore.Get()
tokenInfo := s.convStore.ApproxTokenCountDetailed()
writeJSON(w, map[string]interface{}{
"conversations": convs,
"current_messages": conv,
"tokens": tokenInfo.total,
"tokens_by_role": tokenInfo.byRole,
"summary": s.convStore.GetSummary(),
})
}
func (s *Server) handleDeleteConversation(w http.ResponseWriter, r *http.Request) {
if r.Method != "DELETE" {
writeError(w, "DELETE only", http.StatusMethodNotAllowed)
return
}
id := strings.TrimPrefix(r.URL.Path, "/api/conversations/")
id = strings.TrimPrefix(id, "/")
if id == "" {
s.convStore.Clear()
writeJSON(w, map[string]string{"status": "cleared"})
return
}
convs, err := listConversations()
if err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
filtered := make([]SavedConversation, 0, len(convs))
found := false
for _, c := range convs {
if c.ID == id {
found = true
continue
}
filtered = append(filtered, c)
}
if !found {
writeError(w, "conversation not found", http.StatusNotFound)
return
}
if err := saveConversations(filtered); err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, map[string]string{"status": "deleted"})
}
func (s *Server) handleSearchConversations(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" {
writeError(w, "GET only", http.StatusMethodNotAllowed)
return
}
query := r.URL.Query().Get("q")
if query == "" {
writeError(w, "query parameter 'q' is required", http.StatusBadRequest)
return
}
results := s.convStore.Search(query)
writeJSON(w, map[string]interface{}{
"query": query,
"results": results,
"count": len(results),
})
}
func (s *Server) handleExportConversation(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" {
writeError(w, "GET only", http.StatusMethodNotAllowed)
return
}
format := r.URL.Query().Get("format")
if format == "markdown" || format == "md" {
w.Header().Set("Content-Type", "text/markdown; charset=utf-8")
w.Write([]byte(s.convStore.ExportMarkdown()))
return
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(s.convStore.ExportJSON()))
}
func (s *Server) handleLSPInstall(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
writeError(w, "POST only", http.StatusMethodNotAllowed)
return
}
var body struct {
Name string `json:"name"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, err.Error(), http.StatusBadRequest)
return
}
if body.Name == "" {
writeError(w, "name is required", http.StatusBadRequest)
return
}
if err := lsp.InstallServer(body.Name); err != nil {
writeJSON(w, map[string]interface{}{
"success": false,
"error": err.Error(),
})
return
}
writeJSON(w, map[string]interface{}{
"success": true,
"server": body.Name,
})
}
func (s *Server) handleSkillsDeploy(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
writeError(w, "POST only", http.StatusMethodNotAllowed)
return
}
var body struct {
Name string `json:"name"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, err.Error(), http.StatusBadRequest)
return
}
if body.Name != "" {
skill, err := skills.Get(body.Name)
if err != nil {
writeError(w, err.Error(), http.StatusNotFound)
return
}
if err := skills.Deploy(skill); err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, map[string]string{"status": "deployed", "skill": body.Name})
return
}
if err := skills.DeployAll(); err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, map[string]string{"status": "all deployed"})
}
func (s *Server) handleSkillsUndeploy(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
writeError(w, "POST only", http.StatusMethodNotAllowed)
return
}
var body struct {
Name string `json:"name"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, err.Error(), http.StatusBadRequest)
return
}
if body.Name == "" {
writeError(w, "name is required", http.StatusBadRequest)
return
}
if err := skills.Undeploy(body.Name); err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, map[string]string{"status": "undeployed", "skill": body.Name})
}
func (s *Server) handleSSHConnections(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" {
writeError(w, "GET only", http.StatusMethodNotAllowed)
return
}
cfg, err := config.Load()
if err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, map[string]interface{}{
"connections": cfg.Terminal.SSH,
})
}
func (s *Server) handleSSHTest(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
writeError(w, "POST only", http.StatusMethodNotAllowed)
return
}
var body struct {
Host string `json:"host"`
Port int `json:"port"`
User string `json:"user"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, err.Error(), http.StatusBadRequest)
return
}
if body.Host == "" || body.User == "" {
writeError(w, "host and user are required", http.StatusBadRequest)
return
}
if body.Port == 0 {
body.Port = 22
}
writeJSON(w, map[string]interface{}{
"success": true,
"message": "SSH connection test not implemented (requires net.DialTimeout)",
})
}

View File

@@ -0,0 +1,376 @@
package api
import (
"context"
"encoding/json"
"fmt"
"net/http"
"os"
"os/exec"
"runtime"
"strings"
"time"
"unicode/utf8"
"github.com/muyue/muyue/internal/agent"
"github.com/muyue/muyue/internal/orchestrator"
)
type ShellChatRequest struct {
Message string `json:"message"`
Context string `json:"context,omitempty"`
Cwd string `json:"cwd,omitempty"`
Platform string `json:"platform,omitempty"`
Stream bool `json:"stream"`
}
func (s *Server) handleShellChat(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
writeError(w, "POST only", http.StatusMethodNotAllowed)
return
}
if s.shellConvStore.AtLimit() {
writeError(w, "context limit reached, use /clear", http.StatusBadRequest)
return
}
var req ShellChatRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, err.Error(), http.StatusBadRequest)
return
}
if req.Message == "" {
writeError(w, "message is required", http.StatusBadRequest)
return
}
s.shellConvStore.Add("user", req.Message)
orb, err := orchestrator.New(s.config)
if err != nil {
writeError(w, err.Error(), http.StatusServiceUnavailable)
return
}
orb.SetSystemPrompt(s.buildShellSystemPrompt(req))
orb.SetTools(s.shellAgentToolsJSON)
if req.Stream {
s.handleShellChatStream(w, orb)
} else {
s.handleShellChatNonStream(w, orb)
}
}
func (s *Server) buildShellSystemPrompt(req ShellChatRequest) string {
var sb strings.Builder
sb.WriteString(shellSystemPromptBase)
analysis := LoadSystemAnalysis()
if analysis != "" {
sb.WriteString("<system_context>\n")
sb.WriteString(analysis)
sb.WriteString("\n</system_context>\n\n")
}
sb.WriteString(fmt.Sprintf("OS: %s/%s\n", runtime.GOOS, runtime.GOARCH))
if hostname, err := os.Hostname(); err == nil {
sb.WriteString("Hostname: " + hostname + "\n")
}
if user := os.Getenv("USER"); user != "" {
sb.WriteString("User: " + user + "\n")
}
canSudo := !agent.NeedsSudoPassword()
sb.WriteString(fmt.Sprintf("Root: %t\n", !canSudo))
if canSudo {
sb.WriteString("⚠️ Session avec privilèges sudo sans mot de passe — les commandes sudo s'exécuteront directement.\n")
} else {
sb.WriteString("⚠️ Session sans sudo sans mot de passe — les commandes sudo/doas nécessitent une autorisation. N'utilise PAS sudo ou doas sans demander.\n")
}
now := time.Now()
sb.WriteString(fmt.Sprintf("Date: %s\nHeure: %s\n", now.Format("02/01/2006"), now.Format("15:04:05")))
return sb.String()
}
func (s *Server) handleShellChatStream(w http.ResponseWriter, orb *orchestrator.Orchestrator) {
SetupSSEHeaders(w)
flusher, canFlush := w.(http.Flusher)
sseWriter := NewSSEWriter(w)
ctx := context.Background()
messages := s.buildShellContextMessages()
engine := NewChatEngine(orb, s.shellAgentRegistry, s.shellAgentToolsJSON)
engine.SetLimiter(s.AcquireAgentSlot)
engine.OnChunk(func(data map[string]interface{}) {
if data == nil {
return
}
sseWriter.Write(data)
if canFlush {
flusher.Flush()
}
})
finalContent, allToolCalls, allToolResults, err := engine.RunWithTools(ctx, messages)
if err != nil {
sseWriter.Write(map[string]interface{}{"error": err.Error()})
return
}
storeContent := finalContent
if len(allToolCalls) > 0 {
storeObj := map[string]interface{}{
"content": storeContent,
"tool_calls": allToolCalls,
"tool_results": allToolResults,
}
storeJSON, _ := json.Marshal(storeObj)
storeContent = string(storeJSON)
}
s.shellConvStore.Add("assistant", storeContent)
s.consumption.Record(engine.ProviderName(), engine.TotalTokens)
sseWriter.Write(map[string]interface{}{
"done": "true",
"tokens": s.shellConvStore.ApproxTokens(),
})
}
func (s *Server) handleShellChatNonStream(w http.ResponseWriter, orb *orchestrator.Orchestrator) {
ctx := context.Background()
messages := s.buildShellContextMessages()
engine := NewChatEngine(orb, s.shellAgentRegistry, s.shellAgentToolsJSON)
engine.SetLimiter(s.AcquireAgentSlot)
finalContent, err := engine.RunNonStream(ctx, messages)
if err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
s.shellConvStore.Add("assistant", finalContent)
s.consumption.Record(engine.ProviderName(), engine.TotalTokens)
writeJSON(w, map[string]interface{}{
"content": finalContent,
"tokens": s.shellConvStore.ApproxTokens(),
})
}
func (s *Server) buildShellContextMessages() []orchestrator.Message {
history := s.shellConvStore.Get()
sysTokens := utf8.RuneCountInString(shellSystemPromptBase) / charsPerToken
if analysis := LoadSystemAnalysis(); analysis != "" {
sysTokens += utf8.RuneCountInString(analysis) / charsPerToken
}
sysTokens += 100
toolsTokens := utf8.RuneCountInString(string(s.shellAgentToolsJSON)) / charsPerToken
responseMargin := 4000
overhead := sysTokens + toolsTokens + responseMargin
available := shellMaxTokens - overhead
if available < 1000 {
available = 1000
}
included := 0
tokensUsed := 0
for i := len(history) - 1; i >= 0; i-- {
displayContent := extractDisplayContent(history[i].Role, history[i].Content)
msgTokens := utf8.RuneCountInString(displayContent) / charsPerToken
if msgTokens == 0 {
msgTokens = 1
}
if tokensUsed+msgTokens > available {
break
}
tokensUsed += msgTokens
included++
}
start := len(history) - included
if start < 0 {
start = 0
}
if start > 0 {
_ = start
}
messages := make([]orchestrator.Message, 0, included)
for _, m := range history[start:] {
if m.Role == "system" {
continue
}
displayContent := extractDisplayContent(m.Role, m.Content)
messages = append(messages, orchestrator.Message{
Role: m.Role,
Content: orchestrator.TextContent(displayContent),
})
}
return messages
}
func (s *Server) handleShellChatHistory(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" {
writeError(w, "GET only", http.StatusMethodNotAllowed)
return
}
messages := s.shellConvStore.Get()
writeJSON(w, map[string]interface{}{
"messages": messages,
"tokens": s.shellConvStore.ApproxTokens(),
"max_tokens": shellMaxTokens,
"at_limit": s.shellConvStore.AtLimit(),
})
}
func (s *Server) handleShellChatClear(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
writeError(w, "POST only", http.StatusMethodNotAllowed)
return
}
s.shellConvStore.Clear()
writeJSON(w, map[string]interface{}{
"status": "ok",
"tokens": 0,
})
}
func (s *Server) handleShellAnalyze(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
writeError(w, "POST only", http.StatusMethodNotAllowed)
return
}
var sysInfo strings.Builder
sysInfo.WriteString("=== INFORMATIONS SYSTÈME ===\n")
sysInfo.WriteString(fmt.Sprintf("OS: %s/%s\n", runtime.GOOS, runtime.GOARCH))
if hostname, err := os.Hostname(); err == nil {
sysInfo.WriteString("Hostname: " + hostname + "\n")
}
if user := os.Getenv("USER"); user != "" {
sysInfo.WriteString("User: " + user + "\n")
}
if data, err := os.ReadFile("/proc/cpuinfo"); err == nil {
for _, line := range strings.Split(string(data), "\n") {
if strings.HasPrefix(line, "model name") {
sysInfo.WriteString("CPU: " + strings.SplitN(line, ":", 2)[1] + "\n")
break
}
}
}
if data, err := os.ReadFile("/proc/meminfo"); err == nil {
for _, line := range strings.Split(string(data), "\n") {
if strings.HasPrefix(line, "MemTotal:") || strings.HasPrefix(line, "MemAvailable:") {
sysInfo.WriteString(strings.TrimSpace(line) + "\n")
}
}
}
if out, err := exec.Command("df", "-h", "/").Output(); err == nil {
lines := strings.Split(string(out), "\n")
if len(lines) >= 2 {
sysInfo.WriteString("Disk: " + strings.TrimSpace(lines[1]) + "\n")
}
}
if out, err := exec.Command("ps", "aux", "--sort=-pcpu").Output(); err == nil {
lines := strings.Split(string(out), "\n")
sysInfo.WriteString(fmt.Sprintf("\nProcessus actifs (%d total):\n", len(lines)-1))
for i := 1; i < len(lines) && i <= 10; i++ {
fields := strings.Fields(lines[i])
if len(fields) >= 11 {
sysInfo.WriteString(fmt.Sprintf(" %-20s CPU:%-6s MEM:%-6s %s\n", fields[10], fields[2]+"%", fields[3]+"%", fields[0]))
}
}
}
if s.scanResult != nil {
sysInfo.WriteString("\nOutils installés:\n")
for _, t := range s.scanResult.Tools {
status := "✗"
if t.Installed {
status = "✓"
}
sysInfo.WriteString(fmt.Sprintf(" %s %s %s\n", status, t.Name, t.Version))
}
}
orb, err := orchestrator.New(s.config)
if err != nil {
writeError(w, err.Error(), http.StatusServiceUnavailable)
return
}
orb.SetSystemPrompt(agent.StudioSystemPrompt())
analysisPrompt := `Tu es un expert en administration système. Analyse les informations suivantes et génère un rapport structuré en markdown.
STRUCTURE REQUISE :
## État du système
- Résumé en 2-3 phrases de l'état général (OK/Attention/Critique)
## Points d'attention
Liste les problèmes détectés par priorité :
- **CRITIQUE** : problèmes de sécurité, espace disque < 10%, mémoire < 10%
- **ATTENTION** : CPU élevé, services en échec, config non-optimale
- **INFO** : améliorations possibles, mises à jour disponibles
## Recommandations
Pour chaque point d'attention, donne UNE commande ou action corrective concrète.
## Outils manquants
Liste les outils utiles non installés avec la commande d'installation.
## Réseau
- Interfaces actives, ports en écoute, connectivité
RÈGLES :
- Pas de blabla générique — sois spécifique à CE système
- Inclus les valeurs numériques réelles (%, Go, MHz)
- Max 1500 mots
- Le rapport sert de contexte persistant pour un assistant terminal
` + sysInfo.String()
result, err := orb.Send(analysisPrompt)
if err != nil {
writeError(w, "analysis failed: "+err.Error(), http.StatusInternalServerError)
return
}
SaveSystemAnalysis(result)
writeJSON(w, map[string]interface{}{
"status": "ok",
"analysis": result,
})
}
func (s *Server) handleShellAnalysisGet(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" {
writeError(w, "GET only", http.StatusMethodNotAllowed)
return
}
analysis := LoadSystemAnalysis()
if analysis == "" {
writeJSON(w, map[string]interface{}{"analysis": nil})
return
}
writeJSON(w, map[string]interface{}{"analysis": analysis})
}

View File

@@ -0,0 +1,44 @@
package api
import (
"encoding/json"
"net/http"
"os/exec"
)
func (s *Server) handleTerminal(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
writeError(w, "POST only", http.StatusMethodNotAllowed)
return
}
var body struct {
Command string `json:"command"`
Cwd string `json:"cwd"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, err.Error(), http.StatusBadRequest)
return
}
if body.Command == "" {
writeError(w, "no command", http.StatusBadRequest)
return
}
shell := detectShell()
cmd := exec.Command(shell, "-c", body.Command)
if body.Cwd != "" {
cmd.Dir = body.Cwd
}
out, err := cmd.CombinedOutput()
type termResult struct {
Output string `json:"output"`
Error string `json:"error,omitempty"`
}
result := termResult{Output: string(out)}
if err != nil {
result.Error = err.Error()
}
writeJSON(w, result)
}

View File

@@ -0,0 +1,66 @@
package api
import (
"context"
"encoding/json"
"testing"
"github.com/muyue/muyue/internal/agent"
)
func TestHandleToolCall(t *testing.T) {
// Test unknown tool returns error
registry := agent.NewRegistry()
// Register a test tool
testTool, _ := agent.NewTool[struct{ Command string }]("test_tool", "Test tool", func(ctx context.Context, params struct{ Command string }) (agent.ToolResponse, error) {
return agent.TextResponse("executed: " + params.Command), nil
})
registry.Register(testTool)
// Test executing known tool
resp, err := registry.Execute(context.Background(), agent.ToolCall{
ID: "test-id",
Name: "test_tool",
Arguments: json.RawMessage(`{"Command": "hello"}`),
})
if err != nil {
t.Errorf("unexpected error: %v", err)
}
if resp.IsError {
t.Errorf("expected no error, got error response")
}
// Test executing unknown tool
resp, err = registry.Execute(context.Background(), agent.ToolCall{
ID: "test-id",
Name: "unknown_tool",
Arguments: json.RawMessage(`{}`),
})
if err != nil {
t.Errorf("unexpected error: %v", err)
}
if !resp.IsError {
t.Errorf("expected error for unknown tool")
}
}
func TestCleanThinkingTags(t *testing.T) {
tests := []struct {
input string
expected string
}{
{"hello world", "hello world"},
{"<think>thinking</think>hello", "hello"},
{"<Think>THINKING</Think>hello", "hello"},
{"hello <think>thinking</think> world", "hello world"},
{"no tags here", "no tags here"},
}
for _, tc := range tests {
result := cleanThinkingTags(tc.input)
if result != tc.expected {
t.Errorf("cleanThinkingTags(%q) = %q, want %q", tc.input, result, tc.expected)
}
}
}

View File

@@ -0,0 +1,119 @@
package api
import (
"encoding/json"
"net/http"
"sync"
"github.com/muyue/muyue/internal/installer"
"github.com/muyue/muyue/internal/scanner"
"github.com/muyue/muyue/internal/updater"
)
func (s *Server) handleUpdates(w http.ResponseWriter, r *http.Request) {
result := scanner.ScanSystem()
statuses := updater.CheckUpdates(result)
type updateInfo struct {
Tool string `json:"tool"`
Current string `json:"current"`
Latest string `json:"latest"`
NeedsUpdate bool `json:"needsUpdate"`
Error string `json:"error,omitempty"`
}
updates := make([]updateInfo, len(statuses))
for i, u := range statuses {
updates[i] = updateInfo{
Tool: u.Tool,
Current: u.Current,
Latest: u.Latest,
NeedsUpdate: u.NeedsUpdate,
Error: u.Error,
}
}
writeJSON(w, map[string]interface{}{
"updates": updates,
})
}
func (s *Server) handleInstall(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
writeError(w, "POST only", http.StatusMethodNotAllowed)
return
}
var body struct {
Tools []string `json:"tools"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, err.Error(), http.StatusBadRequest)
return
}
if len(body.Tools) == 0 {
writeError(w, "no tools specified", http.StatusBadRequest)
return
}
results := make([]installer.InstallResult, len(body.Tools))
var wg sync.WaitGroup
var mu sync.Mutex
for i, tool := range body.Tools {
wg.Add(1)
go func(idx int, name string) {
defer wg.Done()
inst := installer.New(s.config)
res := inst.InstallTool(name)
mu.Lock()
results[idx] = res
mu.Unlock()
}(i, tool)
}
wg.Wait()
writeJSON(w, map[string]interface{}{
"status": "done",
"tools": body.Tools,
"results": results,
})
}
func (s *Server) handleRunUpdate(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
writeError(w, "POST only", http.StatusMethodNotAllowed)
return
}
var body struct {
Tool string `json:"tool"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, "invalid request body", http.StatusBadRequest)
return
}
result := scanner.ScanSystem()
statuses := updater.CheckUpdates(result)
if body.Tool != "" {
for _, u := range statuses {
if u.Tool == body.Tool && u.NeedsUpdate {
updater.RunAutoUpdate([]updater.UpdateStatus{u})
}
}
writeJSON(w, map[string]string{"status": "ok", "tool": body.Tool})
return
}
needsUpdate := make([]updater.UpdateStatus, 0)
for _, u := range statuses {
if u.NeedsUpdate {
needsUpdate = append(needsUpdate, u)
}
}
if len(needsUpdate) > 0 {
updater.RunAutoUpdate(needsUpdate)
}
writeJSON(w, map[string]interface{}{
"status": "ok",
"updated": len(needsUpdate),
})
}

View File

@@ -0,0 +1,85 @@
package api
import (
"context"
"encoding/json"
"net/http"
"github.com/muyue/muyue/internal/agent"
)
type ToolCallRequest struct {
Tool string `json:"tool"`
Args json.RawMessage `json:"args"`
}
type ToolResult struct {
Success bool `json:"success"`
Tool string `json:"tool"`
Result *toolResponseData `json:"result,omitempty"`
Error string `json:"error,omitempty"`
}
type toolResponseData struct {
Content string `json:"content"`
IsError bool `json:"is_error"`
Meta map[string]string `json:"meta,omitempty"`
}
func (s *Server) handleToolCall(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
writeError(w, "POST only", http.StatusMethodNotAllowed)
return
}
var req ToolCallRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, err.Error(), http.StatusBadRequest)
return
}
if req.Tool == "" {
writeError(w, "tool is required", http.StatusBadRequest)
return
}
ctx := context.Background()
call := agent.ToolCall{
ID: generateMsgID(),
Name: req.Tool,
Arguments: req.Args,
}
result, execErr := s.agentRegistry.Execute(ctx, call)
if execErr != nil {
writeJSON(w, ToolResult{
Success: false,
Tool: req.Tool,
Error: execErr.Error(),
})
return
}
writeJSON(w, ToolResult{
Success: true,
Tool: req.Tool,
Result: &toolResponseData{
Content: result.Content,
IsError: result.IsError,
Meta: result.Meta,
},
})
}
func (s *Server) handleToolList(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" {
writeError(w, "GET only", http.StatusMethodNotAllowed)
return
}
tools := s.agentRegistry.All()
writeJSON(w, map[string]interface{}{
"tools": tools,
"count": len(tools),
})
}

View File

@@ -0,0 +1,258 @@
package api
import (
"context"
"encoding/json"
"net/http"
"strings"
"github.com/muyue/muyue/internal/workflow"
)
func (s *Server) handleWorkflowCreate(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
writeError(w, "POST only", http.StatusMethodNotAllowed)
return
}
var body struct {
Name string `json:"name"`
Description string `json:"description"`
Type string `json:"type"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, err.Error(), http.StatusBadRequest)
return
}
if body.Name == "" {
writeError(w, "name is required", http.StatusBadRequest)
return
}
engine := s.workflowEngine
if engine == nil {
engine, _ = workflow.NewEngine(s.agentRegistry)
}
wf := engine.Create(body.Name, body.Description, body.Type, []workflow.Step{})
writeJSON(w, wf)
}
func (s *Server) handleWorkflowList(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" {
writeError(w, "GET only", http.StatusMethodNotAllowed)
return
}
engine := s.workflowEngine
if engine == nil {
engine, _ = workflow.NewEngine(s.agentRegistry)
}
workflows := engine.List()
writeJSON(w, map[string]interface{}{
"workflows": workflows,
"count": len(workflows),
})
}
func (s *Server) handleWorkflowGet(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" {
writeError(w, "GET only", http.StatusMethodNotAllowed)
return
}
id := strings.TrimPrefix(r.URL.Path, "/api/workflow/")
if id == "" {
writeError(w, "workflow id required", http.StatusBadRequest)
return
}
engine := s.workflowEngine
if engine == nil {
engine, _ = workflow.NewEngine(s.agentRegistry)
}
wf, ok := engine.Get(id)
if !ok {
writeError(w, "workflow not found", http.StatusNotFound)
return
}
writeJSON(w, wf)
}
func (s *Server) handleWorkflowDelete(w http.ResponseWriter, r *http.Request) {
if r.Method != "DELETE" {
writeError(w, "DELETE only", http.StatusMethodNotAllowed)
return
}
id := strings.TrimPrefix(r.URL.Path, "/api/workflow/")
if id == "" {
writeError(w, "workflow id required", http.StatusBadRequest)
return
}
engine := s.workflowEngine
if engine == nil {
engine, _ = workflow.NewEngine(s.agentRegistry)
}
if err := engine.Delete(id); err != nil {
writeError(w, err.Error(), http.StatusNotFound)
return
}
writeJSON(w, map[string]string{"status": "deleted"})
}
func (s *Server) handleWorkflowPlan(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
writeError(w, "POST only", http.StatusMethodNotAllowed)
return
}
var body struct {
Goal string `json:"goal"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, err.Error(), http.StatusBadRequest)
return
}
if body.Goal == "" {
writeError(w, "goal is required", http.StatusBadRequest)
return
}
planner, err := workflow.NewPlanner(s.config)
if err != nil {
writeError(w, err.Error(), http.StatusServiceUnavailable)
return
}
steps, err := planner.GeneratePlan(context.Background(), body.Goal)
if err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
engine := s.workflowEngine
if engine == nil {
engine, _ = workflow.NewEngine(s.agentRegistry)
}
wf := engine.Create("Plan: "+truncateString(body.Goal, 30), body.Goal, "plan_execute", steps)
writeJSON(w, wf)
}
func (s *Server) handleWorkflowExecute(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
writeError(w, "POST only", http.StatusMethodNotAllowed)
return
}
id := strings.TrimPrefix(r.URL.Path, "/api/workflow/execute/")
if id == "" {
writeError(w, "workflow id required", http.StatusBadRequest)
return
}
engine := s.workflowEngine
if engine == nil {
engine, _ = workflow.NewEngine(s.agentRegistry)
}
wf, ok := engine.Get(id)
if !ok {
writeError(w, "workflow not found", http.StatusNotFound)
return
}
if r.URL.Query().Get("stream") == "true" {
s.handleWorkflowExecuteStream(w, engine, wf)
} else {
err := engine.Execute(context.Background(), id, nil)
if err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
wf, _ = engine.Get(id)
writeJSON(w, wf)
}
}
func (s *Server) handleWorkflowExecuteStream(w http.ResponseWriter, engine *workflow.Engine, wf *workflow.Workflow) {
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.WriteHeader(http.StatusOK)
flusher, canFlush := w.(http.Flusher)
writeSSE := func(data map[string]interface{}) {
b, _ := json.Marshal(data)
w.Write([]byte("data: " + string(b) + "\n\n"))
if canFlush {
flusher.Flush()
}
}
go func() {
engine.Execute(context.Background(), wf.ID, func(step *workflow.Step, event string) {
writeSSE(map[string]interface{}{
"event": event,
"step": step,
})
})
wf, _ = engine.Get(wf.ID)
writeSSE(map[string]interface{}{
"event": "workflow_done",
"status": wf.Status,
"workflow": wf,
})
}()
}
func (s *Server) handleWorkflowApprove(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
writeError(w, "POST only", http.StatusMethodNotAllowed)
return
}
id := strings.TrimPrefix(r.URL.Path, "/api/workflow/approve/")
if id == "" {
writeError(w, "workflow id required", http.StatusBadRequest)
return
}
var body struct {
StepID string `json:"step_id"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, err.Error(), http.StatusBadRequest)
return
}
engine := s.workflowEngine
if engine == nil {
engine, _ = workflow.NewEngine(s.agentRegistry)
}
if err := engine.ApproveStep(id, body.StepID); err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, map[string]string{"status": "approved"})
}
func truncateString(s string, max int) string {
runes := []rune(s)
if len(runes) <= max {
return s
}
return string(runes[:max])
}

106
internal/api/image_cache.go Normal file
View File

@@ -0,0 +1,106 @@
package api
import (
"encoding/base64"
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
"sync/atomic"
"time"
"github.com/muyue/muyue/internal/config"
)
var imageDir string
func init() {
dir, err := config.ConfigDir()
if err != nil {
dir = "/tmp/muyue"
}
imageDir = filepath.Join(dir, "images")
os.MkdirAll(imageDir, 0755)
}
var imageCounter uint64
func saveImage(dataURI, filename, mimeType string) (string, error) {
parts := strings.SplitN(dataURI, ",", 2)
if len(parts) != 2 {
return "", fmt.Errorf("invalid data URI")
}
encoded := parts[1]
decoded, err := base64.StdEncoding.DecodeString(encoded)
if err != nil {
return "", fmt.Errorf("base64 decode: %w", err)
}
if len(decoded) > 10*1024*1024 {
return "", fmt.Errorf("image too large (max 10MB)")
}
id := fmt.Sprintf("%d-%d", time.Now().UnixMilli(), atomic.AddUint64(&imageCounter, 1))
ext := ".png"
switch mimeType {
case "image/jpeg":
ext = ".jpg"
case "image/webp":
ext = ".webp"
}
filePath := filepath.Join(imageDir, id+ext)
if err := os.WriteFile(filePath, decoded, 0600); err != nil {
return "", fmt.Errorf("write image: %w", err)
}
return id + ext, nil
}
func imagePath(id string) string {
return filepath.Join(imageDir, filepath.Base(id))
}
func cleanupImages(ids []string) {
for _, id := range ids {
p := imagePath(id)
if err := os.Remove(p); err != nil && !os.IsNotExist(err) {
_ = err
}
}
}
func (s *Server) handleServeImage(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" {
writeError(w, "GET only", http.StatusMethodNotAllowed)
return
}
id := strings.TrimPrefix(r.URL.Path, "/api/images/")
if id == "" {
writeError(w, "image id required", http.StatusBadRequest)
return
}
filePath := imagePath(id)
if _, err := os.Stat(filePath); err != nil {
writeError(w, "image not found", http.StatusNotFound)
return
}
ext := strings.ToLower(filepath.Ext(id))
switch ext {
case ".jpg", ".jpeg":
w.Header().Set("Content-Type", "image/jpeg")
case ".png":
w.Header().Set("Content-Type", "image/png")
case ".webp":
w.Header().Set("Content-Type", "image/webp")
default:
w.Header().Set("Content-Type", "application/octet-stream")
}
w.Header().Set("Cache-Control", "public, max-age=86400")
http.ServeFile(w, r, filePath)
}

View File

@@ -0,0 +1,106 @@
//go:build !windows
package api
import (
"fmt"
"os"
"strings"
"time"
)
// collectSystemMetrics reads /proc on Linux. On macOS / BSD this returns
// zeroes for files that don't exist — the dashboard panel renders blanks
// rather than crashing. macOS-specific metrics could be added later via
// `vm_stat` / `iostat` parsing.
func collectSystemMetrics() sysMetrics {
m := sysMetrics{}
// CPU from /proc/stat
if data, err := os.ReadFile("/proc/stat"); err == nil {
line := strings.Split(string(data), "\n")[0]
fields := strings.Fields(line)
if len(fields) >= 5 {
var idle, total float64
for i := 1; i < len(fields) && i <= 4; i++ {
var v float64
fmt.Sscanf(fields[i], "%f", &v)
total += v
if i == 4 {
idle = v
}
}
if lastCPUSet {
dIdle := idle - lastCPU[0]
dTotal := total - lastCPU[1]
if dTotal > 0 {
m.CPUPercent = (1 - dIdle/dTotal) * 100
}
}
lastCPU = [2]float64{idle, total}
lastCPUSet = true
}
}
// Memory from /proc/meminfo
if data, err := os.ReadFile("/proc/meminfo"); err == nil {
var memTotal, memAvailable float64
for _, line := range strings.Split(string(data), "\n") {
fields := strings.Fields(line)
if len(fields) < 2 {
continue
}
var v float64
fmt.Sscanf(fields[1], "%f", &v)
switch fields[0] {
case "MemTotal:":
memTotal = v
case "MemAvailable:":
memAvailable = v
}
}
if memTotal > 0 {
m.MemTotalMB = memTotal / 1024
m.MemUsedMB = (memTotal - memAvailable) / 1024
m.MemPercent = (memTotal - memAvailable) / memTotal * 100
}
}
// Network from /proc/net/dev
if data, err := os.ReadFile("/proc/net/dev"); err == nil {
var rxBytes, txBytes float64
for _, line := range strings.Split(string(data), "\n")[2:] {
fields := strings.Fields(line)
if len(fields) < 10 {
continue
}
iface := strings.TrimSuffix(fields[0], ":")
if iface == "lo" {
continue
}
var rx, tx float64
fmt.Sscanf(fields[1], "%f", &rx)
fmt.Sscanf(fields[9], "%f", &tx)
rxBytes += rx
txBytes += tx
}
now := time.Now()
if !lastNetTs.IsZero() {
elapsed := now.Sub(lastNetTs).Seconds()
if elapsed > 0 {
m.NetRxKBs = (rxBytes - lastNet[0]) / 1024 / elapsed
m.NetTxKBs = (txBytes - lastNet[1]) / 1024 / elapsed
if m.NetRxKBs < 0 {
m.NetRxKBs = 0
}
if m.NetTxKBs < 0 {
m.NetTxKBs = 0
}
}
}
lastNet = [2]float64{rxBytes, txBytes}
lastNetTs = now
}
return m
}

View File

@@ -0,0 +1,129 @@
//go:build windows
package api
import (
"sync"
"syscall"
"unsafe"
"golang.org/x/sys/windows"
)
// collectSystemMetrics reads CPU% and memory from kernel32 directly.
// Network throughput on Windows is left at zero for now — the iphlpapi
// MIB_IF_ROW2 layout is large and version-sensitive; reliable net stats
// would warrant a separate, well-tested implementation. CPU + RAM are
// enough for the dashboard's main signal.
func collectSystemMetrics() sysMetrics {
m := sysMetrics{}
if cpu, ok := readWindowsCPUPercent(); ok {
m.CPUPercent = cpu
}
if memTotalMB, memUsedMB, memPct, ok := readWindowsMemory(); ok {
m.MemTotalMB = memTotalMB
m.MemUsedMB = memUsedMB
m.MemPercent = memPct
}
// Net: zero (TODO).
return m
}
// --- CPU ---------------------------------------------------------------
var (
cpuOnce sync.Once
getSystemTimes *syscall.LazyProc
lastWinCPUIdle uint64
lastWinCPUTotal uint64
lastWinCPUSet bool
winCPUMu sync.Mutex
)
func loadCPUFns() {
cpuOnce.Do(func() {
k := syscall.NewLazyDLL("kernel32.dll")
getSystemTimes = k.NewProc("GetSystemTimes")
})
}
func filetimeToUint64(low, high uint32) uint64 {
return uint64(high)<<32 | uint64(low)
}
// readWindowsCPUPercent samples GetSystemTimes twice and computes the busy
// ratio as 1 - dIdle / (dKernel + dUser). The first call returns 0% and
// stores the baseline; subsequent calls return the delta-based percentage.
func readWindowsCPUPercent() (float64, bool) {
loadCPUFns()
if getSystemTimes == nil {
return 0, false
}
var idle, kernel, user windows.Filetime
r1, _, _ := getSystemTimes.Call(
uintptr(unsafe.Pointer(&idle)),
uintptr(unsafe.Pointer(&kernel)),
uintptr(unsafe.Pointer(&user)),
)
if r1 == 0 {
return 0, false
}
idleT := filetimeToUint64(idle.LowDateTime, idle.HighDateTime)
totalT := filetimeToUint64(kernel.LowDateTime, kernel.HighDateTime) +
filetimeToUint64(user.LowDateTime, user.HighDateTime)
winCPUMu.Lock()
defer winCPUMu.Unlock()
if !lastWinCPUSet {
lastWinCPUIdle = idleT
lastWinCPUTotal = totalT
lastWinCPUSet = true
return 0, true
}
dIdle := idleT - lastWinCPUIdle
dTotal := totalT - lastWinCPUTotal
lastWinCPUIdle = idleT
lastWinCPUTotal = totalT
if dTotal == 0 {
return 0, true
}
pct := (1 - float64(dIdle)/float64(dTotal)) * 100
if pct < 0 {
pct = 0
} else if pct > 100 {
pct = 100
}
return pct, true
}
// --- Memory ------------------------------------------------------------
type memoryStatusEx struct {
Length uint32
MemoryLoad uint32
TotalPhys uint64
AvailPhys uint64
TotalPageFile uint64
AvailPageFile uint64
TotalVirtual uint64
AvailVirtual uint64
AvailExtendedVirtual uint64
}
var globalMemoryStatusEx = syscall.NewLazyDLL("kernel32.dll").NewProc("GlobalMemoryStatusEx")
func readWindowsMemory() (totalMB, usedMB, percent float64, ok bool) {
var ms memoryStatusEx
ms.Length = uint32(unsafe.Sizeof(ms))
r1, _, _ := globalMemoryStatusEx.Call(uintptr(unsafe.Pointer(&ms)))
if r1 == 0 {
return 0, 0, 0, false
}
const mb = 1024 * 1024
totalMB = float64(ms.TotalPhys) / mb
usedMB = float64(ms.TotalPhys-ms.AvailPhys) / mb
if ms.TotalPhys > 0 {
percent = float64(ms.TotalPhys-ms.AvailPhys) * 100 / float64(ms.TotalPhys)
}
return totalMB, usedMB, percent, true
}

229
internal/api/server.go Normal file
View File

@@ -0,0 +1,229 @@
package api
import (
"encoding/json"
"fmt"
"net/http"
"os/exec"
"strings"
"sync/atomic"
"github.com/muyue/muyue/internal/agent"
"github.com/muyue/muyue/internal/config"
"github.com/muyue/muyue/internal/installer"
"github.com/muyue/muyue/internal/scanner"
"github.com/muyue/muyue/internal/workflow"
)
type Server struct {
config *config.MuyueConfig
scanResult *scanner.ScanResult
mux *http.ServeMux
convStore *ConversationStore
shellConvStore *ShellConvStore
consumption *consumptionStore
agentRegistry *agent.Registry
agentToolsJSON json.RawMessage
shellAgentRegistry *agent.Registry
shellAgentToolsJSON json.RawMessage
workflowEngine *workflow.Engine
browserTestStore *BrowserTestStore
activeCrushAgents atomic.Int32
activeClaudeAgents atomic.Int32
}
func NewServer(cfg *config.MuyueConfig) *Server {
s := &Server{
mux: http.NewServeMux(),
}
// Auto-initialize config if nil or if no config file exists on disk
if cfg == nil || !config.Exists() {
defaultCfg := config.Default()
if cfg != nil {
// Preserve any user-provided settings from cfg
defaultCfg.Profile = cfg.Profile
defaultCfg.AI = cfg.AI
defaultCfg.Tools = cfg.Tools
defaultCfg.BMAD = cfg.BMAD
defaultCfg.Terminal = cfg.Terminal
}
// Save initial config to establish the file for first-time usage
if err := config.Save(defaultCfg); err != nil {
_ = err
}
cfg = defaultCfg
}
s.config = cfg
s.scanResult = scanner.ScanSystem()
s.convStore = NewConversationStore()
s.shellConvStore = NewShellConvStore()
s.consumption = newConsumptionStore()
s.agentRegistry = agent.DefaultRegistry()
s.browserTestStore = NewBrowserTestStore()
if err := RegisterBrowserTestTool(s.agentRegistry, s.browserTestStore); err != nil {
// Tool registration only fails for duplicate names — non-fatal
_ = err
}
tools := s.agentRegistry.OpenAITools()
toolsJSON, _ := json.Marshal(tools)
s.agentToolsJSON = json.RawMessage(toolsJSON)
s.shellAgentRegistry = agent.NewRegistry()
terminalTool, _ := agent.NewTerminalTool()
s.shellAgentRegistry.Register(terminalTool)
shellTools := s.shellAgentRegistry.OpenAITools()
shellToolsJSON, _ := json.Marshal(shellTools)
s.shellAgentToolsJSON = json.RawMessage(shellToolsJSON)
s.workflowEngine, _ = workflow.NewEngine(s.agentRegistry)
s.initStarship()
s.routes()
return s
}
func (s *Server) routes() {
s.mux.HandleFunc("/api/info", s.handleInfo)
s.mux.HandleFunc("/api/system", s.handleSystem)
s.mux.HandleFunc("/api/tools", s.handleTools)
s.mux.HandleFunc("/api/config", s.handleConfig)
s.mux.HandleFunc("/api/providers", s.handleProviders)
s.mux.HandleFunc("/api/skills", s.handleSkills)
s.mux.HandleFunc("/api/lsp", s.handleLSP)
s.mux.HandleFunc("/api/mcp", s.handleMCP)
s.mux.HandleFunc("/api/updates", s.handleUpdates)
s.mux.HandleFunc("/api/install", s.handleInstall)
s.mux.HandleFunc("/api/scan", s.handleScan)
s.mux.HandleFunc("/api/editors", s.handleEditors)
s.mux.HandleFunc("/api/preferences", s.handleUpdatePreferences)
s.mux.HandleFunc("/api/terminal", s.handleTerminal)
s.mux.HandleFunc("/api/ws/terminal", s.handleTerminalWS)
s.mux.HandleFunc("/api/terminal/sessions", s.handleTerminalSessions)
s.mux.HandleFunc("/api/terminal/sessions/", s.handleTerminalSessionsDelete)
s.mux.HandleFunc("/api/terminal/themes", s.handleGetTerminalThemes)
s.mux.HandleFunc("/api/terminal/settings", s.handleSaveTerminalSettings)
s.mux.HandleFunc("/api/mcp/configure", s.handleMCPConfigure)
s.mux.HandleFunc("/api/config/profile", s.handleSaveProfile)
s.mux.HandleFunc("/api/config/provider", s.handleSaveProvider)
s.mux.HandleFunc("/api/config/reset", s.handleResetConfig)
s.mux.HandleFunc("/api/starship/apply-theme", s.handleApplyStarshipTheme)
s.mux.HandleFunc("/api/providers/validate", s.handleValidateProvider)
s.mux.HandleFunc("/api/update/run", s.handleRunUpdate)
s.mux.HandleFunc("/api/images/", s.handleServeImage)
s.mux.HandleFunc("/api/chat", s.handleChat)
s.mux.HandleFunc("/api/chat/history", s.handleChatHistory)
s.mux.HandleFunc("/api/chat/clear", s.handleChatClear)
s.mux.HandleFunc("/api/chat/summarize", s.handleChatSummarize)
s.mux.HandleFunc("/api/tool/call", s.handleToolCall)
s.mux.HandleFunc("/api/tools/list", s.handleToolList)
s.mux.HandleFunc("/api/shell/chat", s.handleShellChat)
s.mux.HandleFunc("/api/shell/chat/history", s.handleShellChatHistory)
s.mux.HandleFunc("/api/shell/chat/clear", s.handleShellChatClear)
s.mux.HandleFunc("/api/shell/analyze", s.handleShellAnalyze)
s.mux.HandleFunc("/api/shell/analysis", s.handleShellAnalysisGet)
s.mux.HandleFunc("/api/workflow", s.handleWorkflowCreate)
s.mux.HandleFunc("/api/workflow/list", s.handleWorkflowList)
s.mux.HandleFunc("/api/workflow/", s.handleWorkflowGet)
s.mux.HandleFunc("/api/workflow/plan", s.handleWorkflowPlan)
s.mux.HandleFunc("/api/workflow/execute/", s.handleWorkflowExecute)
s.mux.HandleFunc("/api/workflow/approve/", s.handleWorkflowApprove)
s.mux.HandleFunc("/api/conversations", s.handleListConversations)
s.mux.HandleFunc("/api/conversations/search", s.handleSearchConversations)
s.mux.HandleFunc("/api/conversations/export", s.handleExportConversation)
s.mux.HandleFunc("/api/conversations/", s.handleDeleteConversation)
s.mux.HandleFunc("/api/lsp/install", s.handleLSPInstall)
s.mux.HandleFunc("/api/skills/deploy", s.handleSkillsDeploy)
s.mux.HandleFunc("/api/skills/undeploy", s.handleSkillsUndeploy)
s.mux.HandleFunc("/api/ssh/connections", s.handleSSHConnections)
s.mux.HandleFunc("/api/ssh/test", s.handleSSHTest)
s.mux.HandleFunc("/api/mcp/status", s.handleMCPStatus)
s.mux.HandleFunc("/api/mcp/registry", s.handleMCPRegistry)
s.mux.HandleFunc("/api/lsp/health", s.handleLSPHealth)
s.mux.HandleFunc("/api/lsp/auto-install", s.handleLSPAutoInstall)
s.mux.HandleFunc("/api/lsp/editor-config", s.handleLSPEditorConfig)
s.mux.HandleFunc("/api/skills/validate", s.handleSkillValidate)
s.mux.HandleFunc("/api/skills/test", s.handleSkillTest)
s.mux.HandleFunc("/api/skills/export", s.handleSkillExport)
s.mux.HandleFunc("/api/skills/import", s.handleSkillImport)
s.mux.HandleFunc("/api/dashboard/status", s.handleDashboardStatus)
s.mux.HandleFunc("/api/ai/task", s.handleAITask)
s.mux.HandleFunc("/api/providers/quota", s.handleProvidersQuota)
s.mux.HandleFunc("/api/providers/consumption", s.handleProvidersConsumption)
s.mux.HandleFunc("/api/recent-commands", s.handleRecentCommands)
s.mux.HandleFunc("/api/running-processes", s.handleRunningProcesses)
s.mux.HandleFunc("/api/system/metrics", s.handleSystemMetrics)
s.mux.HandleFunc("/api/test/snippet", s.handleBrowserTestSnippet)
s.mux.HandleFunc("/api/test/sessions", s.handleBrowserTestSessions)
s.mux.HandleFunc("/api/test/console/", s.handleBrowserTestConsole)
s.mux.HandleFunc("/api/ws/browser-test", s.handleBrowserTestWS)
}
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(r.URL.Path, "/api/ws/") || strings.HasPrefix(r.URL.Path, "/api/images/") {
s.mux.ServeHTTP(w, r)
return
}
w.Header().Set("Content-Type", "application/json")
if origin := r.Header.Get("Origin"); isAllowedOrigin(origin) {
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Vary", "Origin")
}
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
if r.Method == "OPTIONS" {
w.WriteHeader(http.StatusOK)
return
}
s.mux.ServeHTTP(w, r)
}
func isAllowedOrigin(origin string) bool {
if origin == "" {
return false
}
switch {
case strings.HasPrefix(origin, "http://127.0.0.1"),
strings.HasPrefix(origin, "http://localhost"),
strings.HasPrefix(origin, "http://[::1]"),
strings.HasPrefix(origin, "https://127.0.0.1"),
strings.HasPrefix(origin, "https://localhost"),
strings.HasPrefix(origin, "https://[::1]"):
return true
}
return false
}
const maxCrushAgents = 2
const maxClaudeAgents = 2
func (s *Server) AcquireAgentSlot(toolName string) (release func(), err error) {
var counter *atomic.Int32
var max int32
switch toolName {
case "crush_run":
counter = &s.activeCrushAgents
max = maxCrushAgents
case "claude_run":
counter = &s.activeClaudeAgents
max = maxClaudeAgents
default:
return func() {}, nil
}
current := counter.Add(1)
if current > max {
counter.Add(-1)
return nil, fmt.Errorf("Limite de %d agents %s atteinte", max, toolName)
}
return func() { counter.Add(-1) }, nil
}
func (s *Server) initStarship() {
if _, err := exec.LookPath("starship"); err != nil {
inst := installer.New(s.config)
if result := inst.InstallTool("starship"); !result.Success {
return
}
}
ApplyStarshipTheme(s.config.Terminal.PromptTheme)
}

View File

@@ -0,0 +1,185 @@
package api
import (
"encoding/json"
"os"
"path/filepath"
"sync"
"time"
"unicode/utf8"
"github.com/muyue/muyue/internal/config"
)
const shellMaxTokens = 100000
const shellCharsPerToken = 4
const shellSystemPromptBase = `Tu es l'**Analyste Système** de Muyue. Tu es un expert en administration système, DevOps et développement.
<critical_rules>
1. **AGIS, ne décris pas** — Utilise l'outil terminal pour exécuter, ne te contente pas de proposer des commandes.
2. **SOIS AUTONOME** — Cherche les infos manquantes via des commandes avant de demander à l'utilisateur. Essaie plusieurs approches avant de bloquer.
3. **SOIS CONCIS** — Max 4 lignes par défaut. Pas de préambule. Réponse directe et technique.
4. **GÈRE LES ERREURS** — Si une commande échoue, lis l'erreur, comprends la cause, essaie une approche alternative. 2-3 tentatives avant de rapporter.
5. **SÉCURITÉ** — Ne révèle jamais de credentials. Demande confirmation avant les commandes destructrices (rm -rf, format, etc.).
6. **LANGUE** — Réponds dans la même langue que l'utilisateur.
</critical_rules>
<tool_usage>
Outil disponible : **terminal** — Exécute des commandes shell sur le système local.
Stratégies :
- **Diagnostique** — Enchaîne les commandes de diagnostic (ps, df, free, top, journalctl, dmesg, netstat, ss, etc.)
- **Parallélisme** — Combine les commandes avec && ou ; quand elles sont indépendantes
- **Filtrage** — Utilise grep, awk, sort, head pour extraire l'essentiel des sorties volumineuses
- **Non-interactif** — Préfère les commandes non-interactives (apt install -y, non pas apt install)
- **Troncature** — Si le résultat dépasse 2000 caractères, résume les points clés au lieu de tout afficher
</tool_usage>
<decision_making>
- Décide par toi-même : exécute des commandes pour comprendre l'état du système
- Ne demande confirmation que pour les actions destructrices
- Si tu ne connais pas la commande exacte, exécute la commande avec --help pour la trouver
- Si bloqué : documente ce que tu as essayé, pourquoi, et l'action minimale requise
- Ne t'arrête jamais pour une tâche complexe — découpe en étapes et exécute-les
</decision_making>
<error_recovery>
1. Lis le message d'erreur complet (stderr + stdout)
2. Identifie la cause racine (permissions, paquet manquant, config, service)
3. Essaie : vérifier le service, vérifier les logs, chercher le paquet, tester la connexion
4. Propose une solution concrète, pas générique
</error_recovery>
<response_format>
- **Commandes** : blocs markdown avec le langage (bash, sh, etc.)
- **Résultats** : résume les métriques clés, pas de dump complet
- **Erreurs** : cause + solution en 1-2 lignes
- **Succès** : confirmation en 1 ligne
- **Analyses** : markdown structuré avec sections si nécessaire
</response_format>
<mermaid>
Tu peux utiliser des diagrammes Mermaid pour visualiser :
- Architecture système (graph TD/LR)
- Flux réseau (sequenceDiagram)
- Processus (flowchart)
- Timeline (gantt)
Utilise un bloc de code avec le langage mermaid quand ça clarifie l'explication. Pas pour du texte simple.
</mermaid>
`
type ShellMessage struct {
ID string `json:"id"`
Role string `json:"role"`
Content string `json:"content"`
Time string `json:"time"`
}
type ShellConvStore struct {
mu sync.RWMutex
path string
msgs []ShellMessage
}
func NewShellConvStore() *ShellConvStore {
dir, err := config.ConfigDir()
if err != nil {
dir = "/tmp/muyue"
}
path := filepath.Join(dir, "shell_conversation.json")
s := &ShellConvStore{path: path}
s.load()
return s
}
func (s *ShellConvStore) load() {
data, err := os.ReadFile(s.path)
if err != nil {
s.msgs = []ShellMessage{}
return
}
json.Unmarshal(data, &s.msgs)
if s.msgs == nil {
s.msgs = []ShellMessage{}
}
}
func (s *ShellConvStore) save() {
data, _ := json.MarshalIndent(s.msgs, "", " ")
os.MkdirAll(filepath.Dir(s.path), 0755)
os.WriteFile(s.path, data, 0600)
}
func (s *ShellConvStore) Get() []ShellMessage {
s.mu.RLock()
defer s.mu.RUnlock()
out := make([]ShellMessage, len(s.msgs))
copy(out, s.msgs)
return out
}
func (s *ShellConvStore) Add(role, content string) ShellMessage {
s.mu.Lock()
defer s.mu.Unlock()
msg := ShellMessage{
ID: time.Now().Format("20060102150405.000"),
Role: role,
Content: content,
Time: time.Now().Format(time.RFC3339),
}
s.msgs = append(s.msgs, msg)
s.save()
return msg
}
func (s *ShellConvStore) Clear() {
s.mu.Lock()
defer s.mu.Unlock()
s.msgs = []ShellMessage{}
s.save()
}
func (s *ShellConvStore) ApproxTokens() int {
s.mu.RLock()
defer s.mu.RUnlock()
total := 0
for _, m := range s.msgs {
if m.Role == "system" {
continue
}
total += utf8.RuneCountInString(extractDisplayContent(m.Role, m.Content)) / shellCharsPerToken
}
total += utf8.RuneCountInString(shellSystemPromptBase) / shellCharsPerToken
if analysis := LoadSystemAnalysis(); analysis != "" {
total += utf8.RuneCountInString(analysis) / shellCharsPerToken
}
return total
}
func (s *ShellConvStore) AtLimit() bool {
return s.ApproxTokens() >= shellMaxTokens
}
func LoadSystemAnalysis() string {
dir, err := config.ConfigDir()
if err != nil {
return ""
}
data, err := os.ReadFile(filepath.Join(dir, "system_analysis.md"))
if err != nil {
return ""
}
return string(data)
}
func SaveSystemAnalysis(content string) error {
dir, err := config.ConfigDir()
if err != nil {
return err
}
os.MkdirAll(dir, 0755)
return os.WriteFile(filepath.Join(dir, "system_analysis.md"), []byte(content), 0644)
}

469
internal/api/terminal.go Normal file
View File

@@ -0,0 +1,469 @@
package api
import (
"encoding/json"
"fmt"
"net/http"
"os"
"os/exec"
"path/filepath"
"regexp"
"runtime"
"strings"
"sync"
"time"
"github.com/gorilla/websocket"
"github.com/muyue/muyue/internal/config"
)
var upgrader = websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool {
origin := r.Header.Get("Origin")
if origin == "" {
return true
}
switch {
case strings.HasPrefix(origin, "http://127.0.0.1"),
strings.HasPrefix(origin, "http://localhost"),
strings.HasPrefix(origin, "http://[::1]"),
strings.HasPrefix(origin, "https://127.0.0.1"),
strings.HasPrefix(origin, "https://localhost"),
strings.HasPrefix(origin, "https://[::1]"):
return true
default:
return false
}
},
}
type wsMessage struct {
Type string `json:"type"`
Data string `json:"data"`
Rows uint16 `json:"rows,omitempty"`
Cols uint16 `json:"cols,omitempty"`
}
func (s *Server) handleTerminalWS(w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
return
}
defer conn.Close()
var initMsg wsMessage
_, raw, err := conn.ReadMessage()
if err != nil {
conn.WriteJSON(wsMessage{Type: "error", Data: "failed to read init message"})
return
}
if err := json.Unmarshal(raw, &initMsg); err != nil {
conn.WriteJSON(wsMessage{Type: "error", Data: "invalid init message"})
return
}
var cmd *exec.Cmd
if initMsg.Type == "ssh" && initMsg.Data != "" {
var sshConf struct {
Host string `json:"host"`
Port int `json:"port"`
User string `json:"user"`
KeyPath string `json:"key_path"`
Password string `json:"password"`
}
if err := json.Unmarshal([]byte(initMsg.Data), &sshConf); err != nil {
conn.WriteJSON(wsMessage{Type: "error", Data: "invalid ssh config"})
return
}
if sshConf.Port == 0 {
sshConf.Port = 22
}
sshArgs := []string{
"-o", "StrictHostKeyChecking=accept-new",
"-o", "UserKnownHostsFile=/dev/null",
"-o", "LogLevel=ERROR",
}
if sshConf.KeyPath != "" {
sshArgs = append(sshArgs, "-i", sshConf.KeyPath)
}
if sshConf.Port != 22 {
sshArgs = append(sshArgs, "-p", fmt.Sprintf("%d", sshConf.Port))
}
sshArgs = append(sshArgs, fmt.Sprintf("%s@%s", sshConf.User, sshConf.Host))
if sshConf.Password != "" {
sshpassPath, err := exec.LookPath("sshpass")
if err == nil {
args := append([]string{"-e"}, "ssh")
args = append(args, sshArgs...)
cmd = exec.Command(sshpassPath, args...)
cmd.Env = append(os.Environ(), "SSHPASS="+sshConf.Password)
} else {
cmd = exec.Command("ssh", sshArgs...)
}
} else {
cmd = exec.Command("ssh", sshArgs...)
}
} else {
shell := strings.TrimSpace(initMsg.Data)
if shell == "" {
shell = detectShell()
}
if shell == "" {
shell = "/bin/sh"
}
// Support "wsl -d <distro>" shell strings sent from the UI quick-access.
if extra, ok := parseWSLShell(shell); ok {
wslPath, err := exec.LookPath("wsl")
if err != nil {
conn.WriteJSON(wsMessage{Type: "error", Data: "wsl not found on this host"})
return
}
cmd = exec.Command(wslPath, extra...)
} else {
if path, err := exec.LookPath(shell); err == nil {
shell = path
}
if _, err := os.Stat(shell); err != nil {
conn.WriteJSON(wsMessage{Type: "error", Data: fmt.Sprintf("shell not found: %s (resolved from: %q)", shell, initMsg.Data)})
return
}
shellName := filepath.Base(shell)
switch shellName {
case "wsl":
cmd = exec.Command(shell, "--shell-type", "login")
case "powershell", "pwsh":
cmd = exec.Command(shell, "-NoLogo", "-NoProfile")
case "fish":
cmd = exec.Command(shell, "--login")
default:
cmd = exec.Command(shell)
}
}
}
if cmd.Env == nil {
cmd.Env = os.Environ()
}
cmd.Env = append(cmd.Env, "TERM=xterm-256color")
session, err := startTermSession(cmd)
if err != nil {
conn.WriteJSON(wsMessage{Type: "error", Data: err.Error()})
return
}
var once sync.Once
cleanup := func() {
once.Do(func() {
session.Close()
session.Wait()
})
}
defer cleanup()
go func() {
buf := make([]byte, 4096)
for {
n, err := session.Read(buf)
if n > 0 {
if err := conn.WriteJSON(wsMessage{
Type: "output",
Data: string(buf[:n]),
}); err != nil {
cleanup()
return
}
}
if err != nil {
cleanup()
return
}
}
}()
conn.SetReadLimit(1 << 20)
conn.SetReadDeadline(time.Time{})
for {
_, raw, err := conn.ReadMessage()
if err != nil {
cleanup()
return
}
var msg wsMessage
if err := json.Unmarshal(raw, &msg); err != nil {
continue
}
switch msg.Type {
case "input":
if _, err := session.Write([]byte(msg.Data)); err != nil {
cleanup()
return
}
case "resize":
if msg.Rows > 0 && msg.Cols > 0 {
session.Resize(msg.Rows, msg.Cols)
}
}
}
}
func (s *Server) handleTerminalSessions(w http.ResponseWriter, r *http.Request) {
if r.Method == "GET" {
masked := make([]config.SSHConnection, len(s.config.Terminal.SSH))
for i, c := range s.config.Terminal.SSH {
masked[i] = c
if masked[i].Password != "" {
masked[i].Password = "***"
}
}
writeJSON(w, map[string]interface{}{
"ssh": masked,
"system": detectSystemTerminals(),
})
return
}
if r.Method != "POST" {
writeError(w, "POST only", http.StatusMethodNotAllowed)
return
}
var body struct {
Name string `json:"name"`
Host string `json:"host"`
Port int `json:"port"`
User string `json:"user"`
KeyPath string `json:"key_path"`
Password string `json:"password"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, err.Error(), http.StatusBadRequest)
return
}
if body.Name == "" || body.Host == "" {
writeError(w, "name and host required", http.StatusBadRequest)
return
}
if body.Port == 0 {
body.Port = 22
}
for i, c := range s.config.Terminal.SSH {
if c.Name == body.Name {
password := body.Password
if password == "***" {
password = c.Password
}
s.config.Terminal.SSH[i] = config.SSHConnection{
Name: body.Name,
Host: body.Host,
Port: body.Port,
User: body.User,
KeyPath: body.KeyPath,
Password: password,
}
if err := config.Save(s.config); err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, map[string]string{"status": "ok"})
return
}
}
conn := config.SSHConnection{
Name: body.Name,
Host: body.Host,
Port: body.Port,
User: body.User,
KeyPath: body.KeyPath,
Password: body.Password,
}
if s.config.Terminal.SSH == nil {
s.config.Terminal.SSH = []config.SSHConnection{}
}
s.config.Terminal.SSH = append(s.config.Terminal.SSH, conn)
if err := config.Save(s.config); err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, map[string]string{"status": "ok"})
}
func (s *Server) handleTerminalSessionsDelete(w http.ResponseWriter, r *http.Request) {
if r.Method != "DELETE" {
writeError(w, "DELETE only", http.StatusMethodNotAllowed)
return
}
name := strings.TrimPrefix(r.URL.Path, "/api/terminal/sessions/")
if name == "" {
writeError(w, "name required", http.StatusBadRequest)
return
}
found := false
for i, c := range s.config.Terminal.SSH {
if c.Name == name {
s.config.Terminal.SSH = append(s.config.Terminal.SSH[:i], s.config.Terminal.SSH[i+1:]...)
found = true
break
}
}
if !found {
writeError(w, "not found", http.StatusNotFound)
return
}
if err := config.Save(s.config); err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, map[string]string{"status": "ok"})
}
func detectShell() string {
shells := []string{"zsh", "bash", "fish", "pwsh", "powershell"}
for _, s := range shells {
if path, err := exec.LookPath(s); err == nil {
return path
}
}
return "/bin/sh"
}
// listWSLDistros returns the list of installed WSL distribution names.
// Windows hosts only — returns nil on other platforms or if WSL is unavailable.
func listWSLDistros() []string {
if runtime.GOOS != "windows" {
return nil
}
out, err := exec.Command("wsl", "--list", "--quiet").Output()
if err != nil {
return nil
}
// `wsl --list --quiet` outputs UTF-16LE on Windows. Strip BOM and decode best-effort.
raw := stripUTF16ToASCII(out)
var distros []string
seen := make(map[string]bool)
for _, line := range strings.Split(raw, "\n") {
name := strings.TrimSpace(line)
if name == "" || seen[name] {
continue
}
// Skip default-marker arrows or annotations.
name = strings.TrimSpace(strings.TrimPrefix(name, "*"))
if name == "" || !validWSLName.MatchString(name) {
continue
}
seen[name] = true
distros = append(distros, name)
}
return distros
}
var validWSLName = regexp.MustCompile(`^[a-zA-Z0-9._-]+$`)
// parseWSLShell recognises strings of the form "wsl -d <distro>" (and optionally
// "-u <user>") emitted by the Shell tab quick-access menu, returning the args
// to pass to the wsl binary. Returns ok=false otherwise.
func parseWSLShell(shell string) ([]string, bool) {
parts := strings.Fields(shell)
if len(parts) < 3 || parts[0] != "wsl" {
return nil, false
}
args := []string{}
i := 1
for i < len(parts) {
switch parts[i] {
case "-d", "--distribution":
if i+1 >= len(parts) || !validWSLName.MatchString(parts[i+1]) {
return nil, false
}
args = append(args, "-d", parts[i+1])
i += 2
case "-u", "--user":
if i+1 >= len(parts) || !validWSLName.MatchString(parts[i+1]) {
return nil, false
}
args = append(args, "-u", parts[i+1])
i += 2
default:
return nil, false
}
}
if len(args) == 0 {
return nil, false
}
return args, true
}
func stripUTF16ToASCII(b []byte) string {
// Best-effort: keep only printable bytes (drop high bytes from UTF-16LE pairs).
var out []byte
for i := 0; i < len(b); i++ {
c := b[i]
if c == 0 {
continue
}
if c >= 32 && c < 127 || c == '\n' || c == '\r' || c == '\t' {
out = append(out, c)
}
}
return string(out)
}
func detectSystemTerminals() []map[string]string {
var terminals []map[string]string
terminals = append(terminals, map[string]string{
"type": "local",
"name": "Default Shell",
"shell": detectShell(),
})
if runtime.GOOS == "windows" {
if _, err := exec.LookPath("wsl"); err == nil {
terminals = append(terminals, map[string]string{
"type": "local",
"name": "WSL (default)",
"shell": "wsl",
})
for _, distro := range listWSLDistros() {
terminals = append(terminals, map[string]string{
"type": "local",
"name": "WSL: " + distro,
"shell": "wsl -d " + distro,
})
}
}
if _, err := exec.LookPath("powershell"); err == nil {
terminals = append(terminals, map[string]string{
"type": "local",
"name": "PowerShell",
"shell": "powershell",
})
}
if _, err := exec.LookPath("pwsh"); err == nil {
terminals = append(terminals, map[string]string{
"type": "local",
"name": "PowerShell Core",
"shell": "pwsh",
})
}
if _, err := exec.LookPath("cmd"); err == nil {
terminals = append(terminals, map[string]string{
"type": "local",
"name": "Command Prompt",
"shell": "cmd",
})
}
}
return terminals
}

View File

@@ -0,0 +1,271 @@
//go:build windows
package api
// Windows ConPTY (Pseudo Console) backend for the terminal tab.
//
// creack/pty/v2 returns "operating system not supported" on Windows, so the
// previous fallback was plain stdin/stdout pipes (terminal_session.go::
// pipeSession). Pipes don't carry TTY signals, so cmd.exe / pwsh / wsl
// detect "no TTY" and either go silent or wait forever — the user sees a
// black screen. This file implements a real pseudo console using the
// kernel32 ConPTY API, so the spawned shell behaves as if it were attached
// to a real terminal: prompts render, ANSI escapes are honoured, resize
// events propagate.
//
// Requires Windows 10 v1809 (build 17763) or newer. On older hosts
// CreatePseudoConsole returns an error and startTermSession_windows falls
// back to pipeSession.
import (
"fmt"
"io"
"os/exec"
"sync"
"syscall"
"unsafe"
"golang.org/x/sys/windows"
)
const (
procThreadAttributePseudoconsole = 0x00020016
extendedStartupinfoPresent = 0x00080000
createUnicodeEnvironment = 0x00000400
)
// conptySession drives a Windows pseudo console.
type conptySession struct {
hPC windows.Handle
inWrite windows.Handle
outRead windows.Handle
procInfo windows.ProcessInformation
closed bool
mu sync.Mutex
}
// startConptySession spins up the pseudo console, plumbs the pipes, and
// CreateProcessW's the child with the PC attached via STARTUPINFOEX.
func startConptySession(cmd *exec.Cmd) (termSession, error) {
// 1. Two pipe pairs: in (we write → child stdin) and out (child stdout → we read).
var inRead, inWrite, outRead, outWrite windows.Handle
if err := windows.CreatePipe(&inRead, &inWrite, nil, 0); err != nil {
return nil, fmt.Errorf("create stdin pipe: %w", err)
}
if err := windows.CreatePipe(&outRead, &outWrite, nil, 0); err != nil {
windows.CloseHandle(inRead)
windows.CloseHandle(inWrite)
return nil, fmt.Errorf("create stdout pipe: %w", err)
}
// 2. Create the pseudo console. After this call ConPTY effectively owns
// the child-facing pipe ends (inRead, outWrite); we close our copy.
var hPC windows.Handle
sz := windows.Coord{X: 120, Y: 30}
if err := windows.CreatePseudoConsole(sz, inRead, outWrite, 0, &hPC); err != nil {
windows.CloseHandle(inRead)
windows.CloseHandle(inWrite)
windows.CloseHandle(outRead)
windows.CloseHandle(outWrite)
return nil, fmt.Errorf("CreatePseudoConsole: %w", err)
}
windows.CloseHandle(inRead)
windows.CloseHandle(outWrite)
// 3. Allocate an attribute list with one slot for the PC attribute.
attrList, err := windows.NewProcThreadAttributeList(1)
if err != nil {
windows.ClosePseudoConsole(hPC)
windows.CloseHandle(inWrite)
windows.CloseHandle(outRead)
return nil, fmt.Errorf("NewProcThreadAttributeList: %w", err)
}
// PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE is a quirk of the Win32 API: lpValue
// is the HPCON *value* (cast to PVOID), not a pointer to the handle. If
// we pass &hPC the kernel reads garbage, the PC attribute is silently
// ignored, and cmd/pwsh get their own external console window — which is
// exactly the regression v0.7.6 introduced. The cbSize stays the size of
// the handle (8 bytes on amd64). Reference: Microsoft EchoCon sample.
if err := attrList.Update(
procThreadAttributePseudoconsole,
unsafe.Pointer(uintptr(hPC)),
unsafe.Sizeof(hPC),
); err != nil {
attrList.Delete()
windows.ClosePseudoConsole(hPC)
windows.CloseHandle(inWrite)
windows.CloseHandle(outRead)
return nil, fmt.Errorf("attrList.Update: %w", err)
}
// 4. Build command line.
cmdLine, err := buildCommandLine(cmd)
if err != nil {
attrList.Delete()
windows.ClosePseudoConsole(hPC)
windows.CloseHandle(inWrite)
windows.CloseHandle(outRead)
return nil, err
}
cmdLineUTF16, err := windows.UTF16PtrFromString(cmdLine)
if err != nil {
attrList.Delete()
windows.ClosePseudoConsole(hPC)
windows.CloseHandle(inWrite)
windows.CloseHandle(outRead)
return nil, err
}
// 5. Build the env block (key=value\0...\0\0).
var envBlock *uint16
if cmd.Env != nil {
eb, err := makeEnvBlock(cmd.Env)
if err != nil {
attrList.Delete()
windows.ClosePseudoConsole(hPC)
windows.CloseHandle(inWrite)
windows.CloseHandle(outRead)
return nil, err
}
envBlock = eb
}
si := windows.StartupInfoEx{}
si.StartupInfo.Cb = uint32(unsafe.Sizeof(si))
si.ProcThreadAttributeList = attrList.List()
flags := uint32(extendedStartupinfoPresent)
if envBlock != nil {
flags |= createUnicodeEnvironment
}
var pi windows.ProcessInformation
err = windows.CreateProcess(
nil, // application name (null = parse from cmdline)
cmdLineUTF16,
nil, // process security attrs
nil, // thread security attrs
false, // inherit handles (ConPTY hands handles via attribute list)
flags,
envBlock,
nil, // working dir
&si.StartupInfo,
&pi,
)
attrList.Delete()
if err != nil {
windows.ClosePseudoConsole(hPC)
windows.CloseHandle(inWrite)
windows.CloseHandle(outRead)
return nil, fmt.Errorf("CreateProcess: %w", err)
}
return &conptySession{
hPC: hPC,
inWrite: inWrite,
outRead: outRead,
procInfo: pi,
}, nil
}
func (s *conptySession) Read(p []byte) (int, error) {
var n uint32
err := windows.ReadFile(s.outRead, p, &n, nil)
if err != nil {
if n > 0 {
return int(n), nil
}
return 0, io.EOF
}
return int(n), nil
}
func (s *conptySession) Write(p []byte) (int, error) {
var n uint32
err := windows.WriteFile(s.inWrite, p, &n, nil)
if err != nil {
return int(n), err
}
return int(n), nil
}
func (s *conptySession) Resize(rows, cols uint16) error {
return windows.ResizePseudoConsole(s.hPC, windows.Coord{X: int16(cols), Y: int16(rows)})
}
func (s *conptySession) Close() error {
s.mu.Lock()
if s.closed {
s.mu.Unlock()
return nil
}
s.closed = true
s.mu.Unlock()
// Order matters: close the pseudo console first so the child sees EOF,
// then close our pipe ends, then terminate / close handles.
windows.ClosePseudoConsole(s.hPC)
windows.CloseHandle(s.inWrite)
windows.CloseHandle(s.outRead)
if s.procInfo.Process != 0 {
windows.TerminateProcess(s.procInfo.Process, 0)
windows.CloseHandle(s.procInfo.Process)
}
if s.procInfo.Thread != 0 {
windows.CloseHandle(s.procInfo.Thread)
}
return nil
}
func (s *conptySession) Wait() error {
if s.procInfo.Process == 0 {
return nil
}
_, err := windows.WaitForSingleObject(s.procInfo.Process, windows.INFINITE)
return err
}
func (s *conptySession) Pid() int {
return int(s.procInfo.ProcessId)
}
// --- helpers -----------------------------------------------------------
// buildCommandLine produces the Windows command-line string for an
// *exec.Cmd, mirroring what os/exec uses internally (escaping spaces and
// quotes per Windows convention).
func buildCommandLine(cmd *exec.Cmd) (string, error) {
if cmd.Path == "" {
return "", fmt.Errorf("empty cmd.Path")
}
parts := []string{cmd.Path}
if len(cmd.Args) > 1 {
parts = append(parts, cmd.Args[1:]...)
}
out := syscall.EscapeArg(parts[0])
for _, a := range parts[1:] {
out += " " + syscall.EscapeArg(a)
}
return out, nil
}
// makeEnvBlock packs a Go environ slice into the Windows UTF-16 env block
// format: key=value\0key=value\0\0.
func makeEnvBlock(env []string) (*uint16, error) {
var buf []uint16
for _, kv := range env {
s, err := syscall.UTF16FromString(kv)
if err != nil {
return nil, err
}
buf = append(buf, s...) // includes trailing NUL
}
buf = append(buf, 0) // final terminator
if len(buf) == 0 {
return nil, nil
}
return &buf[0], nil
}
// Compile-time interface assertion.
var _ termSession = (*conptySession)(nil)

View File

@@ -0,0 +1,188 @@
package api
// Cross-platform terminal session abstraction.
//
// On Linux / macOS the unix-tagged file (terminal_session_unix.go) wires
// startTermSession to creack/pty for a real PTY: full TTY semantics,
// resize support, interactive apps (vim, top…) work.
//
// On Windows the windows-tagged file (terminal_session_windows.go) tries
// the kernel32 ConPTY API first, with a pipe-based fallback for older
// hosts. pipeSession does NOT carry TTY signals, so most shells go silent
// — it's only kept as a last resort.
//
// Both platforms share the termSession interface, the ptySession type
// (used by unix), and the pipeSession type (used by the Windows fallback).
import (
"io"
"os"
"os/exec"
"sync"
"github.com/creack/pty/v2"
)
// termSession is the read/write/resize/close surface used by handleTerminalWS.
type termSession interface {
Read([]byte) (int, error)
Write([]byte) (int, error)
Resize(rows, cols uint16) error
Close() error
Wait() error
Pid() int
}
// ptySession wraps creack/pty's *os.File-backed PTY (unix path).
type ptySession struct {
ptmx *os.File
cmd *exec.Cmd
}
func (s *ptySession) Read(p []byte) (int, error) { return s.ptmx.Read(p) }
func (s *ptySession) Write(p []byte) (int, error) { return s.ptmx.Write(p) }
func (s *ptySession) Resize(rows, cols uint16) error {
return pty.Setsize(s.ptmx, &pty.Winsize{Rows: rows, Cols: cols})
}
func (s *ptySession) Close() error {
err := s.ptmx.Close()
if s.cmd.Process != nil {
s.cmd.Process.Kill()
}
return err
}
func (s *ptySession) Wait() error {
if s.cmd.Process == nil {
return nil
}
return s.cmd.Wait()
}
func (s *ptySession) Pid() int {
if s.cmd.Process == nil {
return 0
}
return s.cmd.Process.Pid
}
// pipeSession is the Windows last-resort fallback when ConPTY is not
// available: stdin pipe + merged stdout/stderr, no TTY signals. Most
// interactive shells go silent in this mode, so it should rarely be hit on
// modern Windows (10 1809+).
type pipeSession struct {
cmd *exec.Cmd
stdin io.WriteCloser
stdout io.ReadCloser
stderr io.ReadCloser
mu sync.Mutex
merged chan []byte
closed bool
closeCh chan struct{}
}
func startPipeSession(cmd *exec.Cmd) (termSession, error) {
stdin, err := cmd.StdinPipe()
if err != nil {
return nil, err
}
stdout, err := cmd.StdoutPipe()
if err != nil {
stdin.Close()
return nil, err
}
stderr, err := cmd.StderrPipe()
if err != nil {
stdin.Close()
stdout.Close()
return nil, err
}
if err := cmd.Start(); err != nil {
stdin.Close()
stdout.Close()
stderr.Close()
return nil, err
}
s := &pipeSession{
cmd: cmd,
stdin: stdin,
stdout: stdout,
stderr: stderr,
merged: make(chan []byte, 32),
closeCh: make(chan struct{}),
}
go s.pump(stdout)
go s.pump(stderr)
return s, nil
}
func (s *pipeSession) pump(r io.ReadCloser) {
buf := make([]byte, 4096)
for {
n, err := r.Read(buf)
if n > 0 {
chunk := make([]byte, n)
copy(chunk, buf[:n])
select {
case s.merged <- chunk:
case <-s.closeCh:
return
}
}
if err != nil {
return
}
}
}
func (s *pipeSession) Read(p []byte) (int, error) {
select {
case chunk, ok := <-s.merged:
if !ok {
return 0, io.EOF
}
n := copy(p, chunk)
return n, nil
case <-s.closeCh:
return 0, io.EOF
}
}
func (s *pipeSession) Write(p []byte) (int, error) {
return s.stdin.Write(p)
}
func (s *pipeSession) Resize(rows, cols uint16) error {
// No real TTY → resize is a no-op; the child won't get SIGWINCH.
return nil
}
func (s *pipeSession) Close() error {
s.mu.Lock()
if s.closed {
s.mu.Unlock()
return nil
}
s.closed = true
close(s.closeCh)
s.mu.Unlock()
s.stdin.Close()
s.stdout.Close()
s.stderr.Close()
if s.cmd.Process != nil {
s.cmd.Process.Kill()
}
return nil
}
func (s *pipeSession) Wait() error {
if s.cmd.Process == nil {
return nil
}
return s.cmd.Wait()
}
func (s *pipeSession) Pid() int {
if s.cmd.Process == nil {
return 0
}
return s.cmd.Process.Pid
}

View File

@@ -0,0 +1,19 @@
//go:build !windows
package api
import (
"os/exec"
"github.com/creack/pty/v2"
)
// startTermSession (unix) opens a real PTY via creack/pty. Fatal on error
// — the unix build assumes PTY availability.
func startTermSession(cmd *exec.Cmd) (termSession, error) {
ptmx, err := pty.Start(cmd)
if err != nil {
return nil, err
}
return &ptySession{ptmx: ptmx, cmd: cmd}, nil
}

View File

@@ -0,0 +1,20 @@
//go:build windows
package api
import (
"os/exec"
)
// startTermSession (windows) tries the kernel32 ConPTY API first. ConPTY
// gives a real pseudo terminal, so wsl.exe / pwsh / cmd render their
// prompt and the user can interact normally. If ConPTY is unavailable
// (Windows < 10 1809) or the call fails for any reason, we fall back to
// the line-buffered pipe session — degraded but functional for non-TUI
// commands.
func startTermSession(cmd *exec.Cmd) (termSession, error) {
if sess, err := startConptySession(cmd); err == nil {
return sess, nil
}
return startPipeSession(cmd)
}

View File

@@ -6,55 +6,148 @@ import (
"path/filepath"
"github.com/muyue/muyue/internal/secret"
"github.com/muyue/muyue/internal/version"
"gopkg.in/yaml.v3"
)
type Profile struct {
Name string `yaml:"name"`
Pseudo string `yaml:"pseudo"`
Email string `yaml:"email"`
Languages []string `yaml:"languages"`
Name string `yaml:"name" json:"name"`
Pseudo string `yaml:"pseudo" json:"pseudo"`
Email string `yaml:"email" json:"email"`
Languages []string `yaml:"languages" json:"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"`
Editor string `yaml:"editor" json:"editor"`
Shell string `yaml:"shell" json:"shell"`
Theme string `yaml:"theme" json:"theme"`
DefaultAI string `yaml:"default_ai" json:"default_ai"`
AutoUpdate bool `yaml:"auto_update" json:"auto_update"`
CheckOnStart bool `yaml:"check_on_start" json:"check_on_start"`
Language string `yaml:"language" json:"language"`
KeyboardLayout string `yaml:"keyboard_layout" json:"keyboard_layout"`
} `yaml:"preferences" json:"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"`
Name string `yaml:"name" json:"name"`
APIKey string `yaml:"api_key,omitempty" json:"api_key,omitempty"`
BaseURL string `yaml:"base_url,omitempty" json:"base_url,omitempty"`
Model string `yaml:"model" json:"model"`
Active bool `yaml:"active" json:"active"`
}
type ToolConfig struct {
Name string `yaml:"name"`
Installed bool `yaml:"installed"`
Version string `yaml:"version"`
AutoUpdate bool `yaml:"auto_update"`
Name string `yaml:"name" json:"name"`
Installed bool `yaml:"installed" json:"installed"`
Version string `yaml:"version" json:"version"`
AutoUpdate bool `yaml:"auto_update" json:"auto_update"`
}
type SSHConnection struct {
Name string `yaml:"name" json:"name"`
Host string `yaml:"host" json:"host"`
Port int `yaml:"port" json:"port"`
User string `yaml:"user" json:"user"`
Password string `yaml:"password,omitempty" json:"password,omitempty"`
KeyPath string `yaml:"key_path,omitempty" json:"key_path,omitempty"`
}
type MuyueConfig struct {
Version string `yaml:"version"`
Profile Profile `yaml:"profile"`
Version string `yaml:"version" json:"version"`
Profile Profile `yaml:"profile" json:"profile"`
AI struct {
Providers []AIProvider `yaml:"providers"`
} `yaml:"ai"`
Tools []ToolConfig `yaml:"tools"`
Providers []AIProvider `yaml:"providers" json:"providers"`
} `yaml:"ai" json:"ai"`
Tools []ToolConfig `yaml:"tools" json:"tools"`
BMAD struct {
Installed bool `yaml:"installed"`
Version string `yaml:"version"`
Global bool `yaml:"global"`
} `yaml:"bmad"`
Installed bool `yaml:"installed" json:"installed"`
Version string `yaml:"version" json:"version"`
Global bool `yaml:"global" json:"global"`
} `yaml:"bmad" json:"bmad"`
Terminal struct {
CustomPrompt bool `yaml:"custom_prompt"`
PromptTheme string `yaml:"prompt_theme"`
} `yaml:"terminal"`
CustomPrompt bool `yaml:"custom_prompt" json:"custom_prompt"`
PromptTheme string `yaml:"prompt_theme" json:"prompt_theme"`
SSH []SSHConnection `yaml:"ssh" json:"ssh"`
FontSize int `yaml:"font_size" json:"font_size"`
FontFamily string `yaml:"font_family" json:"font_family"`
Theme string `yaml:"theme" json:"theme"`
} `yaml:"terminal" json:"terminal"`
}
type TerminalTheme struct {
Name string `yaml:"name"`
Background string `yaml:"background"`
Foreground string `yaml:"foreground"`
Cursor string `yaml:"cursor"`
Black string `yaml:"black"`
Red string `yaml:"red"`
Green string `yaml:"green"`
Yellow string `yaml:"yellow"`
Blue string `yaml:"blue"`
Magenta string `yaml:"magenta"`
Cyan string `yaml:"cyan"`
White string `yaml:"white"`
}
var DEFAULT_TERMINAL_THEMES = map[string]TerminalTheme{
"default": {
Name: "Default", Background: "#0A0A0C", Foreground: "#EAE0E2",
Cursor: "#FF0033", Black: "#0A0A0C", Red: "#FF0033",
Green: "#00E676", Yellow: "#FFD740", Blue: "#448AFF",
Magenta: "#FF1A5E", Cyan: "#00BCD4", White: "#EAE0E2",
},
"monokai": {
Name: "Monokai", Background: "#272822", Foreground: "#F8F8F2",
Cursor: "#F8F8F0", Black: "#272822", Red: "#F92672",
Green: "#A6E22E", Yellow: "#E6DB74", Blue: "#66D9EF",
Magenta: "#AE81FF", Cyan: "#A1EFE4", White: "#F8F8F2",
},
"gruvbox": {
Name: "Gruvbox", Background: "#282828", Foreground: "#EBDBB2",
Cursor: "#FB4934", Black: "#282828", Red: "#CC241D",
Green: "#98971A", Yellow: "#D79921", Blue: "#458588",
Magenta: "#B16286", Cyan: "#689D6A", White: "#EBDBB2",
},
"nord": {
Name: "Nord", Background: "#2E3440", Foreground: "#D8DEE9",
Cursor: "#D8DEE9", Black: "#2E3440", Red: "#BF616A",
Green: "#A3BE8C", Yellow: "#EBCB8B", Blue: "#81A1C1",
Magenta: "#B48EAD", Cyan: "#88C0D0", White: "#D8DEE9",
},
"solarized-dark": {
Name: "Solarized Dark", Background: "#002B36", Foreground: "#839496",
Cursor: "#D33682", Black: "#002B36", Red: "#DC322F",
Green: "#859900", Yellow: "#B58900", Blue: "#268BD2",
Magenta: "#D33682", Cyan: "#2AA198", White: "#FDF6E3",
},
"dracula": {
Name: "Dracula", Background: "#282A36", Foreground: "#F8F8F2",
Cursor: "#F8F8F2", Black: "#282A36", Red: "#FF5555",
Green: "#50FA7B", Yellow: "#F1FA8C", Blue: "#BD93F9",
Magenta: "#FF79C6", Cyan: "#8BE9FD", White: "#F8F8F2",
},
}
func migrateProviders(cfg *MuyueConfig) {
defaults := Default().AI.Providers
for _, dp := range defaults {
found := false
for _, p := range cfg.AI.Providers {
if p.Name == dp.Name {
found = true
break
}
}
if !found {
cfg.AI.Providers = append(cfg.AI.Providers, dp)
}
}
}
func GetTerminalTheme(name string) TerminalTheme {
if theme, ok := DEFAULT_TERMINAL_THEMES[name]; ok {
return theme
}
return DEFAULT_TERMINAL_THEMES["default"]
}
func ConfigDir() (string, error) {
@@ -67,7 +160,9 @@ func ConfigDir() (string, error) {
legacyDir := filepath.Join(homeDir(), ".muyue")
if _, err := os.Stat(legacyDir); err == nil {
if _, err := os.Stat(dir); err != nil {
os.Rename(legacyDir, dir)
if err := os.Rename(legacyDir, dir); err != nil {
_ = err
}
}
}
@@ -126,6 +221,8 @@ func Load() (*MuyueConfig, error) {
}
}
migrateProviders(&cfg)
return &cfg, nil
}
@@ -167,7 +264,7 @@ func Save(cfg *MuyueConfig) error {
func Default() *MuyueConfig {
cfg := &MuyueConfig{
Version: "0.1.0",
Version: version.Version,
Profile: Profile{
Name: "",
Pseudo: "muyue",
@@ -179,6 +276,8 @@ func Default() *MuyueConfig {
cfg.Profile.Preferences.AutoUpdate = true
cfg.Profile.Preferences.CheckOnStart = true
cfg.Profile.Preferences.Theme = "charm"
cfg.Profile.Preferences.Language = "fr"
cfg.Profile.Preferences.KeyboardLayout = "azerty"
cfg.AI.Providers = []AIProvider{
{
@@ -187,6 +286,12 @@ func Default() *MuyueConfig {
BaseURL: "https://api.minimax.io/v1",
Active: true,
},
{
Name: "mimo",
Model: "mimo-v2.5-pro",
BaseURL: "https://token-plan-ams.xiaomimimo.com/v1",
Active: false,
},
{
Name: "zai",
Model: "glm",
@@ -215,6 +320,7 @@ func Default() *MuyueConfig {
cfg.Terminal.CustomPrompt = true
cfg.Terminal.PromptTheme = "zerotwo"
cfg.Terminal.FontSize = 14
return cfg
}

View File

@@ -4,12 +4,14 @@ import (
"os"
"path/filepath"
"testing"
"github.com/muyue/muyue/internal/version"
)
func TestDefault(t *testing.T) {
cfg := Default()
if cfg.Version != "0.1.0" {
t.Errorf("Expected version 0.1.0, got %s", cfg.Version)
if cfg.Version != version.Version {
t.Errorf("Expected version %s, got %s", version.Version, cfg.Version)
}
if cfg.Profile.Pseudo != "muyue" {
t.Errorf("Expected pseudo muyue, got %s", cfg.Profile.Pseudo)

View File

@@ -1,173 +0,0 @@
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{}, 1),
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()
}

131
internal/desktop/desktop.go Normal file
View File

@@ -0,0 +1,131 @@
package desktop
import (
"fmt"
"io/fs"
"log"
"net"
"net/http"
"os"
"os/exec"
"os/signal"
"strconv"
"strings"
"syscall"
"github.com/muyue/muyue/internal/api"
"github.com/muyue/muyue/internal/config"
"github.com/muyue/muyue/internal/version"
"github.com/muyue/muyue/web"
)
type options struct {
port int
noOpen bool
}
type option func(*options)
func withPort(port int) option {
return func(o *options) { o.port = port }
}
func withNoOpen(noOpen bool) option {
return func(o *options) { o.noOpen = noOpen }
}
func parseFlags(args []string) []option {
var opts []option
for _, arg := range args {
switch {
case arg == "--no-open":
opts = append(opts, withNoOpen(true))
case strings.HasPrefix(arg, "--port="):
if p, err := strconv.Atoi(strings.TrimPrefix(arg, "--port=")); err == nil {
opts = append(opts, withPort(p))
}
case arg == "--port":
// handled as prefix case
}
}
return opts
}
func Run(cfg *config.MuyueConfig, args []string) error {
o := options{}
for _, opt := range parseFlags(args) {
opt(&o)
}
log.Printf("%s Desktop v%s", version.Name, version.Version)
srv := api.NewServer(cfg)
frontendFS, err := fs.Sub(web.Assets, "dist")
if err != nil {
return fmt.Errorf("frontend assets: %w", err)
}
mux := http.NewServeMux()
mux.Handle("/api/", srv)
mux.Handle("/", spaHandler(http.FileServer(http.FS(frontendFS))))
addr := fmt.Sprintf("127.0.0.1:%d", o.port)
listener, err := net.Listen("tcp", addr)
if err != nil {
return fmt.Errorf("bind %s: %w", addr, err)
}
port := listener.Addr().(*net.TCPAddr).Port
go func() {
if err := http.Serve(listener, mux); err != nil {
log.Fatalf("Server error: %v", err)
}
}()
url := fmt.Sprintf("http://127.0.0.1:%d", port)
log.Printf("Listening on %s", url)
if !o.noOpen {
openBrowser(url)
log.Printf("Opened browser")
}
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
log.Println("Shutting down...")
return nil
}
func spaHandler(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
path := r.URL.Path
if path != "/" && !strings.Contains(path, ".") {
r.URL.Path = "/"
}
next.ServeHTTP(w, r)
})
}
func openBrowser(url string) {
var cmd *exec.Cmd
switch {
case exists("xdg-open"):
cmd = exec.Command("xdg-open", url)
case exists("open"):
cmd = exec.Command("open", url)
case exists("cmd"):
cmd = exec.Command("cmd", "/c", "start", url)
default:
fmt.Printf("Open manually: %s\n", url)
return
}
_ = cmd.Start()
}
func exists(name string) bool {
_, err := exec.LookPath(name)
return err == nil
}

View File

@@ -4,6 +4,7 @@ import (
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
@@ -123,7 +124,7 @@ func (i *Installer) installBMAD() InstallResult {
return InstallResult{Tool: "bmad", Success: false, Message: err.Error()}
}
bmadDir := configDir + "/bmad"
bmadDir := filepath.Join(configDir, "bmad")
os.MkdirAll(bmadDir, 0755)
cmd := exec.Command("npx", "bmad-method@latest", "install",
@@ -175,7 +176,7 @@ func (i *Installer) installGo() InstallResult {
}
home, _ := os.UserHomeDir()
goDir := home + "/.local/go"
goDir := filepath.Join(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 -",
@@ -290,56 +291,16 @@ func (i *Installer) installGit() InstallResult {
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 {
func (i *Installer) getRCFile() string {
home, _ := os.UserHomeDir()
switch i.system.Shell {
case "zsh":
return home + "/.zshrc"
return filepath.Join(home, ".zshrc")
case "fish":
return home + "/.config/fish/config.fish"
return filepath.Join(home, ".config", "fish", "config.fish")
default:
return home + "/.bashrc"
return filepath.Join(home, ".bashrc")
}
}
@@ -380,7 +341,7 @@ func (i *Installer) installUv() InstallResult {
return InstallResult{Tool: "uv", Success: false, Message: fmt.Sprintf("install failed: %s: %s", err, string(output))}
}
rcFile := i.getRCFile()
appendLine(rcFile, "export PATH="+home+"/.local/bin:$PATH")
appendLine(rcFile, "export PATH="+filepath.Join(home, ".local", "bin")+":$PATH")
return InstallResult{Tool: "uv", Success: true, Message: "installed (added ~/.local/bin to PATH)"}
case platform.Windows:
cmd = exec.Command("powershell", "-Command", "irm https://astral.sh/uv/install.ps1 | iex")

View File

@@ -6,8 +6,8 @@ import (
"os"
"os/exec"
"path/filepath"
"github.com/muyue/muyue/internal/config"
"strings"
"time"
)
type LSPServer struct {
@@ -15,12 +15,11 @@ type LSPServer struct {
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"`
Version string `json:"version,omitempty"`
Healthy bool `json:"healthy,omitempty"`
Description string `json:"description,omitempty"`
Category string `json:"category,omitempty"`
}
var knownServers = []LSPServer{
@@ -48,27 +47,131 @@ func ScanServers() []LSPServer {
servers[i] = s
_, err := exec.LookPath(s.Command)
servers[i].Installed = err == nil
servers[i].Version = getInstalledLSPVersion(s.Name)
}
regServers, err := scanLSPRegistryServers()
if err == nil {
servers = append(servers, regServers...)
}
return servers
}
func scanLSPRegistryServers() ([]LSPServer, error) {
reg, err := LoadLSPRegistry()
if err != nil {
return nil, err
}
knownNames := map[string]bool{}
for _, s := range knownServers {
knownNames[s.Name] = true
}
var servers []LSPServer
for _, rs := range reg.Servers {
if knownNames[rs.Name] {
continue
}
servers = append(servers, LSPServer{
Name: rs.Name,
Language: rs.Language,
Command: rs.Command,
InstallCmd: rs.InstallCmd,
Installed: isLSPCommandAvailable(rs.Command),
Description: rs.Description,
Category: rs.Category,
Version: getInstalledLSPVersion(rs.Name),
})
}
return servers, nil
}
func isLSPCommandAvailable(cmd string) bool {
_, err := exec.LookPath(cmd)
return err == nil
}
func getInstalledLSPVersion(name string) string {
home, _ := os.UserHomeDir()
if home == "" {
return ""
}
receiptPath := filepath.Join(home, ".muyue", "receipts", "lsp", name+".json")
data, err := os.ReadFile(receiptPath)
if err != nil {
return ""
}
var receipt struct {
Version string `json:"version"`
}
if json.Unmarshal(data, &receipt) == nil {
return receipt.Version
}
return ""
}
func saveLSPReceipt(name, version string) error {
home, _ := os.UserHomeDir()
if home == "" {
return nil
}
receiptDir := filepath.Join(home, ".muyue", "receipts", "lsp")
os.MkdirAll(receiptDir, 0755)
receipt := struct {
Name string `json:"name"`
Version string `json:"version"`
UpdatedAt string `json:"updated_at"`
}{
Name: name,
Version: version,
UpdatedAt: time.Now().Format(time.RFC3339),
}
data, _ := json.MarshalIndent(receipt, "", " ")
return os.WriteFile(filepath.Join(receiptDir, name+".json"), data, 0644)
}
func InstallServer(name string) error {
for _, s := range knownServers {
if s.Name == name {
if s.InstallCmd == "" {
return fmt.Errorf("%s has no auto-install command, install manually", name)
}
cmd := exec.Command("bash", "-c", s.InstallCmd)
cmd.Env = os.Environ()
if output, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("install %s: %s: %w", name, string(output), err)
}
return nil
return doInstallLSP(s)
}
}
reg, err := LoadLSPRegistry()
if err == nil {
for _, s := range reg.Servers {
if s.Name == name {
return doInstallLSP(LSPServer{
Name: s.Name,
Language: s.Language,
Command: s.Command,
InstallCmd: s.InstallCmd,
})
}
}
}
return fmt.Errorf("unknown LSP server: %s", name)
}
func doInstallLSP(s LSPServer) error {
if s.InstallCmd == "" {
return fmt.Errorf("%s has no auto-install command, install manually", s.Name)
}
cmd := exec.Command("bash", "-c", s.InstallCmd)
cmd.Env = os.Environ()
if output, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("install %s: %s: %w", s.Name, string(output), err)
}
saveLSPReceipt(s.Name, "latest")
return nil
}
func InstallForLanguages(languages []string) []LSPServer {
langMap := map[string][]string{
"go": {"gopls"},
@@ -111,85 +214,99 @@ func InstallForLanguages(languages []string) []LSPServer {
return results
}
func GenerateCrushConfig(cfg *config.MuyueConfig) error {
if cfg == nil {
return fmt.Errorf("config is nil")
func AutoInstallForProject(projectDir string) ([]LSPServer, error) {
languages := DetectProjectLanguages(projectDir)
if len(languages) == 0 {
return nil, 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 unmarshalErr := json.Unmarshal(existing, &existingConfig); unmarshalErr == nil {
var newConfig map[string]interface{}
if unmarshalErr2 := json.Unmarshal(data, &newConfig); unmarshalErr2 == nil {
for k, v := range newConfig {
existingConfig[k] = v
}
data, _ = json.MarshalIndent(existingConfig, "", " ")
}
}
}
return os.WriteFile(lspPath, data, 0644)
results := InstallForLanguages(languages)
return results, nil
}
func EnsureCrushConfig(cfg *config.MuyueConfig) error {
configDir, _ := config.ConfigDir()
crusherPath := filepath.Join(configDir, "crush.json")
func HealthCheck(name string) (bool, string) {
for _, s := range knownServers {
if s.Name == name {
return healthCheckServer(s)
}
}
return false, "unknown server"
}
if _, err := os.Stat(crusherPath); err != nil {
func healthCheckServer(s LSPServer) (bool, string) {
path, err := exec.LookPath(s.Command)
if err != nil {
return false, fmt.Sprintf("command %q not found in PATH", s.Command)
}
versionArgs := map[string][]string{
"gopls": {"version"},
"pyright": {"--version"},
"typescript-language-server": {"--version"},
"rust-analyzer": {"--version"},
"clangd": {"--version"},
"lua-language-server": {"--version"},
"bash-language-server": {"--version"},
"yaml-language-server": {"--version"},
}
if args, ok := versionArgs[s.Command]; ok {
cmd := exec.Command(path, args...)
output, err := cmd.CombinedOutput()
if err != nil {
return true, fmt.Sprintf("installed at %s but version check failed", path)
}
version := strings.TrimSpace(string(output))
if idx := strings.Index(version, "\n"); idx > 0 {
version = version[:idx]
}
saveLSPReceipt(s.Name, version)
return true, version
}
return true, fmt.Sprintf("installed at %s", path)
}
func GenerateEditorConfigs(servers []LSPServer, editor string, homeDir string) (string, error) {
if homeDir == "" {
home, _ := os.UserHomeDir()
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)
homeDir = home
}
return nil
reg, err := LoadLSPRegistry()
if err != nil {
return "", err
}
regMap := map[string]RegistryEntry{}
for _, s := range reg.Servers {
regMap[s.Name] = s
}
var regEntries []RegistryEntry
for _, s := range servers {
if re, ok := regMap[s.Name]; ok {
regEntries = append(regEntries, re)
}
}
switch editor {
case "neovim", "nvim":
return GenerateNeovimConfig(regEntries), nil
case "helix", "hx":
return GenerateHelixConfig(regEntries), nil
case "vscode", "code", "cursor":
exts := GenerateVSCodeRecommendations(regEntries)
var b strings.Builder
b.WriteString("{\n \"recommendations\": [\n")
for i, ext := range exts {
if i > 0 {
b.WriteString(",\n")
}
b.WriteString(" \"" + ext + "\"")
}
b.WriteString("\n ]\n}")
return b.String(), nil
default:
return "", fmt.Errorf("unsupported editor: %s", editor)
}
}

333
internal/lsp/registry.go Normal file
View File

@@ -0,0 +1,333 @@
package lsp
import (
"os"
"path/filepath"
"time"
"gopkg.in/yaml.v3"
)
type RegistryEntry struct {
Name string `yaml:"name" json:"name"`
Language string `yaml:"language" json:"language"`
Description string `yaml:"description" json:"description"`
Command string `yaml:"command" json:"command"`
InstallCmd string `yaml:"install_cmd" json:"install_cmd"`
InstallType string `yaml:"install_type" json:"install_type"`
Category string `yaml:"category" json:"category"`
FilePatterns []string `yaml:"file_patterns,omitempty" json:"file_patterns,omitempty"`
ConfigFiles []string `yaml:"config_files,omitempty" json:"config_files,omitempty"`
Tags []string `yaml:"tags,omitempty" json:"tags,omitempty"`
HomePage string `yaml:"homepage,omitempty" json:"homepage,omitempty"`
NeovimSetup string `yaml:"neovim_setup,omitempty" json:"neovim_setup,omitempty"`
HelixLanguage string `yaml:"helix_language,omitempty" json:"helix_language,omitempty"`
}
type LSPRegistry struct {
SchemaVersion string `yaml:"schema_version"`
UpdatedAt time.Time `yaml:"updated_at"`
Servers []RegistryEntry `yaml:"servers"`
}
func DefaultLSPRegistry() *LSPRegistry {
return &LSPRegistry{
SchemaVersion: "v1",
UpdatedAt: time.Now(),
Servers: []RegistryEntry{
{
Name: "gopls", Language: "go", Description: "Go language server",
Command: "gopls", InstallCmd: "go install golang.org/x/tools/gopls@latest",
InstallType: "go", Category: "lsp", FilePatterns: []string{"*.go"},
ConfigFiles: []string{"go.mod"}, Tags: []string{"go", "linting", "completion"},
HomePage: "https://github.com/golang/tools",
NeovimSetup: `lspconfig.gopls.setup{}`,
HelixLanguage: "go",
},
{
Name: "pyright", Language: "python", Description: "Python type checker and language server",
Command: "pyright", InstallCmd: "npm install -g pyright",
InstallType: "npm", Category: "lsp", FilePatterns: []string{"*.py", "*.pyi"},
ConfigFiles: []string{"requirements.txt", "pyproject.toml", "setup.py", "Pipfile"},
Tags: []string{"python", "type-checking"}, HomePage: "https://github.com/microsoft/pyright",
NeovimSetup: `lspconfig.pyright.setup{}`,
HelixLanguage: "python",
},
{
Name: "typescript-language-server", Language: "typescript", Description: "TypeScript and JavaScript language server",
Command: "typescript-language-server", InstallCmd: "npm install -g typescript-language-server typescript",
InstallType: "npm", Category: "lsp", FilePatterns: []string{"*.ts", "*.tsx", "*.js", "*.jsx"},
ConfigFiles: []string{"tsconfig.json", "package.json"},
Tags: []string{"typescript", "javascript"}, HomePage: "https://github.com/typescript-language-server/typescript-language-server",
NeovimSetup: `lspconfig.tsserver.setup{}`,
HelixLanguage: "typescript",
},
{
Name: "vscode-json-language-server", Language: "json", Description: "JSON language server",
Command: "vscode-json-language-server", InstallCmd: "npm install -g vscode-langservers-extracted",
InstallType: "npm", Category: "lsp", FilePatterns: []string{"*.json", "*.jsonc"},
Tags: []string{"json"}, NeovimSetup: `lspconfig.jsonls.setup{}`,
HelixLanguage: "json",
},
{
Name: "vscode-html-language-server", Language: "html", Description: "HTML language server",
Command: "vscode-html-language-server", InstallCmd: "npm install -g vscode-langservers-extracted",
InstallType: "npm", Category: "lsp", FilePatterns: []string{"*.html", "*.htm"},
Tags: []string{"html"}, NeovimSetup: `lspconfig.html.setup{}`,
HelixLanguage: "html",
},
{
Name: "vscode-css-language-server", Language: "css", Description: "CSS/SCSS/LESS language server",
Command: "vscode-css-language-server", InstallCmd: "npm install -g vscode-langservers-extracted",
InstallType: "npm", Category: "lsp", FilePatterns: []string{"*.css", "*.scss", "*.less"},
Tags: []string{"css"}, NeovimSetup: `lspconfig.cssls.setup{}`,
HelixLanguage: "css",
},
{
Name: "yaml-language-server", Language: "yaml", Description: "YAML language server",
Command: "yaml-language-server", InstallCmd: "npm install -g yaml-language-server",
InstallType: "npm", Category: "lsp", FilePatterns: []string{"*.yml", "*.yaml"},
Tags: []string{"yaml"}, NeovimSetup: `lspconfig.yamlls.setup{}`,
HelixLanguage: "yaml",
},
{
Name: "bash-language-server", Language: "bash", Description: "Bash language server",
Command: "bash-language-server", InstallCmd: "npm install -g bash-language-server",
InstallType: "npm", Category: "lsp", FilePatterns: []string{"*.sh", "*.bash"},
Tags: []string{"bash", "shell"}, NeovimSetup: `lspconfig.bashls.setup{}`,
HelixLanguage: "bash",
},
{
Name: "rust-analyzer", Language: "rust", Description: "Rust language server",
Command: "rust-analyzer", InstallCmd: "rustup component add rust-analyzer",
InstallType: "rustup", Category: "lsp", FilePatterns: []string{"*.rs"},
ConfigFiles: []string{"Cargo.toml"}, Tags: []string{"rust"},
HomePage: "https://github.com/rust-lang/rust-analyzer",
NeovimSetup: `lspconfig.rust_analyzer.setup{}`,
HelixLanguage: "rust",
},
{
Name: "clangd", Language: "c/c++", Description: "C/C++ language server",
Command: "clangd", InstallCmd: "", InstallType: "system",
Category: "lsp", FilePatterns: []string{"*.c", "*.cpp", "*.h", "*.hpp"},
ConfigFiles: []string{"CMakeLists.txt", "Makefile"}, Tags: []string{"c", "cpp"},
NeovimSetup: `lspconfig.clangd.setup{}`,
HelixLanguage: "c",
},
{
Name: "lua-language-server", Language: "lua", Description: "Lua language server",
Command: "lua-language-server", InstallCmd: "npm install -g lua-language-server",
InstallType: "npm", Category: "lsp", FilePatterns: []string{"*.lua"},
Tags: []string{"lua"}, NeovimSetup: `lspconfig.lua_ls.setup{}`,
HelixLanguage: "lua",
},
{
Name: "dockerfile-language-server", Language: "dockerfile", Description: "Dockerfile language server",
Command: "docker-langserver", InstallCmd: "npm install -g dockerfile-language-server-nodejs",
InstallType: "npm", Category: "lsp", FilePatterns: []string{"Dockerfile", "Dockerfile.*"},
Tags: []string{"docker"}, NeovimSetup: `lspconfig.dockerls.setup{}`,
HelixLanguage: "dockerfile",
},
{
Name: "tailwindcss-language-server", Language: "tailwind", Description: "Tailwind CSS language server",
Command: "tailwindcss-language-server", InstallCmd: "npm install -g @tailwindcss/language-server",
InstallType: "npm", Category: "lsp", FilePatterns: []string{"*.html", "*.tsx", "*.jsx"},
ConfigFiles: []string{"tailwind.config.js", "tailwind.config.ts"},
Tags: []string{"tailwind", "css"}, NeovimSetup: `lspconfig.tailwindcss.setup{}`,
},
{
Name: "svelte-language-server", Language: "svelte", Description: "Svelte language server",
Command: "svelteserver", InstallCmd: "npm install -g svelte-language-server",
InstallType: "npm", Category: "lsp", FilePatterns: []string{"*.svelte"},
Tags: []string{"svelte"}, NeovimSetup: `lspconfig.svelte.setup{}`,
HelixLanguage: "svelte",
},
{
Name: "vue-language-server", Language: "vue", Description: "Vue language server",
Command: "vue-language-server", InstallCmd: "npm install -g @vue/language-server",
InstallType: "npm", Category: "lsp", FilePatterns: []string{"*.vue"},
Tags: []string{"vue"}, NeovimSetup: `lspconfig.vuels.setup{}`,
},
{
Name: "golangci-lint-langserver", Language: "go-lint", Description: "Go linter language server",
Command: "golangci-lint-langserver", InstallCmd: "go install github.com/nametake/golangci-lint-langserver@latest",
InstallType: "go", Category: "lsp", FilePatterns: []string{"*.go"},
Tags: []string{"go", "linting"},
},
},
}
}
var lspRegistryPath string
func init() {
home, _ := os.UserHomeDir()
if home != "" {
lspRegistryPath = filepath.Join(home, ".muyue", "lsp-registry.yaml")
}
}
func SetLSPRegistryPath(p string) {
lspRegistryPath = p
}
func LoadLSPRegistry() (*LSPRegistry, error) {
if lspRegistryPath == "" {
return DefaultLSPRegistry(), nil
}
data, err := os.ReadFile(lspRegistryPath)
if err != nil {
return DefaultLSPRegistry(), nil
}
var reg LSPRegistry
if err := yaml.Unmarshal(data, &reg); err != nil {
return nil, err
}
return &reg, nil
}
func SaveLSPRegistry(reg *LSPRegistry) error {
if lspRegistryPath == "" {
return nil
}
reg.UpdatedAt = time.Now()
data, err := yaml.Marshal(reg)
if err != nil {
return err
}
os.MkdirAll(filepath.Dir(lspRegistryPath), 0755)
return os.WriteFile(lspRegistryPath, data, 0644)
}
func InitLSPRegistry() error {
if lspRegistryPath == "" {
return nil
}
if _, err := os.Stat(lspRegistryPath); err == nil {
return nil
}
return SaveLSPRegistry(DefaultLSPRegistry())
}
func DetectProjectLanguages(projectDir string) []string {
if projectDir == "" {
return nil
}
langDetectors := map[string][]string{
"go": {"go.mod", "go.sum"},
"python": {"requirements.txt", "pyproject.toml", "setup.py", "Pipfile", "uv.lock"},
"typescript": {"tsconfig.json", "package.json"},
"javascript": {"package.json"},
"rust": {"Cargo.toml"},
"ruby": {"Gemfile"},
"java": {"pom.xml", "build.gradle"},
"c": {"CMakeLists.txt", "Makefile"},
"cpp": {"CMakeLists.txt"},
"php": {"composer.json"},
"lua": {".luarc.json"},
"docker": {"Dockerfile"},
}
extDetectors := map[string]string{
".go": "go",
".py": "python",
".rs": "rust",
".ts": "typescript",
".tsx": "typescript",
".js": "javascript",
".jsx": "javascript",
".rb": "ruby",
".java": "java",
".c": "c",
".cpp": "cpp",
".h": "c",
".lua": "lua",
".vue": "vue",
".svelte": "svelte",
}
detected := map[string]bool{}
for lang, files := range langDetectors {
for _, f := range files {
if _, err := os.Stat(filepath.Join(projectDir, f)); err == nil {
detected[lang] = true
break
}
}
}
entries, err := os.ReadDir(projectDir)
if err == nil {
for _, e := range entries {
if e.IsDir() {
continue
}
ext := filepath.Ext(e.Name())
if lang, ok := extDetectors[ext]; ok {
detected[lang] = true
}
}
}
var languages []string
for lang := range detected {
languages = append(languages, lang)
}
return languages
}
func GenerateNeovimConfig(servers []RegistryEntry) string {
config := `-- Generated by Muyue LSP Manager
-- Add to your init.lua or require from lspconfig setup
local lspconfig = require('lspconfig')
`
for _, s := range servers {
if s.NeovimSetup != "" {
config += s.NeovimSetup + "\n"
}
}
return config
}
func GenerateHelixConfig(servers []RegistryEntry) string {
config := `# Generated by Muyue LSP Manager
# Add to ~/.config/helix/languages.toml
`
for _, s := range servers {
if s.HelixLanguage != "" {
config += "[[language]]\n"
config += "name = \"" + s.HelixLanguage + "\"\n"
config += "language-servers = [\"" + s.Name + "\"]\n\n"
}
}
return config
}
func GenerateVSCodeRecommendations(servers []RegistryEntry) []string {
extensionMap := map[string][]string{
"gopls": {"golang.go"},
"pyright": {"ms-python.python", "ms-python.vscode-pylance"},
"typescript-language-server": {"ms-vscode.vscode-typescript-next"},
"rust-analyzer": {"rust-lang.rust-analyzer"},
"lua-language-server": {"sumneko.lua"},
"tailwindcss-language-server": {"bradlc.vscode-tailwindcss"},
"svelte-language-server": {"svelte.svelte-vscode"},
"vue-language-server": {"vue.volar"},
"yaml-language-server": {"redhat.vscode-yaml"},
"bash-language-server": {"mads-hartmann.bash-ide-vscode"},
}
var extensions []string
for _, s := range servers {
if exts, ok := extensionMap[s.Name]; ok {
extensions = append(extensions, exts...)
}
}
return extensions
}

View File

@@ -0,0 +1,142 @@
package lsp
import (
"os"
"path/filepath"
"testing"
)
func TestDefaultLSPRegistry(t *testing.T) {
reg := DefaultLSPRegistry()
if reg.SchemaVersion != "v1" {
t.Errorf("Expected v1, got %s", reg.SchemaVersion)
}
if len(reg.Servers) == 0 {
t.Error("Default LSP registry should have servers")
}
names := map[string]bool{}
for _, s := range reg.Servers {
if names[s.Name] {
t.Errorf("Duplicate server name: %s", s.Name)
}
names[s.Name] = true
if s.Command == "" {
t.Errorf("Server %s missing command", s.Name)
}
if s.Language == "" {
t.Errorf("Server %s missing language", s.Name)
}
}
}
func TestSaveAndLoadLSPRegistry(t *testing.T) {
tmpDir := t.TempDir()
SetLSPRegistryPath(filepath.Join(tmpDir, "lsp-registry.yaml"))
reg := DefaultLSPRegistry()
if err := SaveLSPRegistry(reg); err != nil {
t.Fatalf("SaveLSPRegistry failed: %v", err)
}
loaded, err := LoadLSPRegistry()
if err != nil {
t.Fatalf("LoadLSPRegistry failed: %v", err)
}
if len(loaded.Servers) != len(reg.Servers) {
t.Errorf("Expected %d servers, got %d", len(reg.Servers), len(loaded.Servers))
}
}
func TestInitLSPRegistry(t *testing.T) {
tmpDir := t.TempDir()
SetLSPRegistryPath(filepath.Join(tmpDir, "lsp-reg.yaml"))
if err := InitLSPRegistry(); err != nil {
t.Fatalf("InitLSPRegistry failed: %v", err)
}
if _, err := os.Stat(filepath.Join(tmpDir, "lsp-reg.yaml")); os.IsNotExist(err) {
t.Error("LSP registry file should be created")
}
}
func TestDetectProjectLanguages(t *testing.T) {
tmpDir := t.TempDir()
os.WriteFile(filepath.Join(tmpDir, "go.mod"), []byte("module test\ngo 1.24\n"), 0644)
os.WriteFile(filepath.Join(tmpDir, "package.json"), []byte(`{"name": "test"}`), 0644)
languages := DetectProjectLanguages(tmpDir)
if len(languages) == 0 {
t.Error("Should detect languages")
}
langSet := map[string]bool{}
for _, l := range languages {
langSet[l] = true
}
if !langSet["go"] {
t.Error("Should detect Go")
}
if !langSet["typescript"] {
t.Error("Should detect TypeScript/JS from package.json")
}
}
func TestDetectProjectLanguagesEmpty(t *testing.T) {
tmpDir := t.TempDir()
languages := DetectProjectLanguages(tmpDir)
if len(languages) != 0 {
t.Errorf("Empty dir should detect no languages, got %v", languages)
}
}
func TestGenerateNeovimConfig(t *testing.T) {
servers := []RegistryEntry{
{Name: "gopls", Language: "go", NeovimSetup: "lspconfig.gopls.setup{}"},
{Name: "pyright", Language: "python", NeovimSetup: "lspconfig.pyright.setup{}"},
}
config := GenerateNeovimConfig(servers)
if config == "" {
t.Error("Config should not be empty")
}
if len(config) < 50 {
t.Error("Config seems too short")
}
}
func TestGenerateHelixConfig(t *testing.T) {
servers := []RegistryEntry{
{Name: "gopls", Language: "go", HelixLanguage: "go"},
}
config := GenerateHelixConfig(servers)
if config == "" {
t.Error("Config should not be empty")
}
}
func TestGenerateVSCodeRecommendations(t *testing.T) {
servers := []RegistryEntry{
{Name: "gopls", Language: "go"},
{Name: "pyright", Language: "python"},
}
exts := GenerateVSCodeRecommendations(servers)
if len(exts) == 0 {
t.Error("Should return some extensions")
}
}
func TestHealthCheck(t *testing.T) {
healthy, detail := HealthCheck("gopls")
if healthy && detail == "" {
t.Error("If healthy, should have version detail")
}
}
func TestHealthCheckUnknown(t *testing.T) {
_, _ = HealthCheck("nonexistent-server")
}

View File

@@ -6,17 +6,22 @@ import (
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"github.com/muyue/muyue/internal/config"
)
type MCPServer struct {
Name string `json:"name"`
Command string `json:"command"`
Args []string `json:"args"`
Env map[string]string `json:"env,omitempty"`
Installed bool `json:"installed"`
Category string `json:"category"`
Name string `json:"name"`
Command string `json:"command"`
Args []string `json:"args"`
Env map[string]string `json:"env,omitempty"`
Installed bool `json:"installed"`
Category string `json:"category"`
Description string `json:"description,omitempty"`
Version string `json:"version,omitempty"`
Status string `json:"status,omitempty"`
}
type mcpEntry struct {
@@ -47,13 +52,55 @@ func ScanServers() []MCPServer {
servers[i] = s
_, err := exec.LookPath(s.Command)
servers[i].Installed = err == nil
servers[i].Version = GetInstalledVersion(s.Name)
}
regServers, err := scanRegistryServers()
if err == nil {
servers = append(servers, regServers...)
}
return servers
}
func scanRegistryServers() ([]MCPServer, error) {
reg, err := LoadRegistry()
if err != nil {
return nil, err
}
knownNames := map[string]bool{}
for _, s := range knownMCPServers {
knownNames[s.Name] = true
}
var servers []MCPServer
for _, rs := range reg.Servers {
if knownNames[rs.Name] {
continue
}
servers = append(servers, MCPServer{
Name: rs.Name,
Command: rs.Command,
Args: rs.Args,
Env: rs.Env,
Category: rs.Category,
Description: rs.Description,
Installed: isCommandAvailable(rs.Command),
Version: GetInstalledVersion(rs.Name),
})
}
return servers, nil
}
func isCommandAvailable(cmd string) bool {
_, err := exec.LookPath(cmd)
return err == nil
}
func getCoreEntries(homeDir string) []mcpEntry {
return []mcpEntry{
{"filesystem", "npx", []string{"-y", "@modelcontextprotocol/server-filesystem", homeDir + "/projects"}, nil},
{"filesystem", "npx", []string{"-y", "@modelcontextprotocol/server-filesystem", filepath.Join(homeDir, "projects")}, nil},
{"fetch", "npx", []string{"-y", "@modelcontextprotocol/server-fetch"}, nil},
{"memory", "npx", []string{"-y", "@modelcontextprotocol/server-memory"}, nil},
}
@@ -86,7 +133,9 @@ func writeMCPConfig(configPath string, mcpKey string, entries []mcpEntry) error
existing := map[string]interface{}{}
data, err := os.ReadFile(configPath)
if err == nil {
json.Unmarshal(data, &existing)
if err := json.Unmarshal(data, &existing); err != nil {
return fmt.Errorf("parse existing config: %w", err)
}
}
mcpMap := map[string]interface{}{}
@@ -96,7 +145,8 @@ func writeMCPConfig(configPath string, mcpKey string, entries []mcpEntry) error
"args": e.args,
}
if len(e.env) > 0 {
entry["env"] = e.env
resolved := ResolveEnv(e.env, nil)
entry["env"] = resolved
}
mcpMap[e.name] = entry
}
@@ -108,7 +158,49 @@ func writeMCPConfig(configPath string, mcpKey string, entries []mcpEntry) error
return err
}
return os.WriteFile(configPath, out, 0600)
if err := os.WriteFile(configPath, out, 0600); err != nil {
return err
}
return ValidateConfig(configPath)
}
func writeMCPConfigForEditor(editor EditorConfig, entries []mcpEntry) error {
configDir := filepath.Dir(editor.ConfigPath)
if err := os.MkdirAll(configDir, 0700); err != nil {
return fmt.Errorf("create config dir %s: %w", editor.Name, err)
}
existing := map[string]interface{}{}
data, err := os.ReadFile(editor.ConfigPath)
if err == nil {
_ = json.Unmarshal(data, &existing)
}
mcpMap := map[string]interface{}{}
for _, e := range entries {
if editor.TransformCommand != nil {
mcpMap[e.name] = editor.TransformCommand(e)
} else {
entry := map[string]interface{}{
"command": e.cmd,
"args": e.args,
}
if len(e.env) > 0 {
entry["env"] = e.env
}
mcpMap[e.name] = entry
}
}
existing[editor.ConfigKey] = mcpMap
out, err := json.MarshalIndent(existing, "", " ")
if err != nil {
return err
}
return os.WriteFile(editor.ConfigPath, out, 0600)
}
func GenerateCrushMCPConfig(cfg *config.MuyueConfig, homeDir string) error {
@@ -138,19 +230,154 @@ func GenerateClaudeMCPConfig(cfg *config.MuyueConfig, homeDir string) error {
return writeMCPConfig(configPath, "mcpServers", entries)
}
func GenerateCursorMCPConfig(cfg *config.MuyueConfig, homeDir string) error {
if homeDir == "" {
home, _ := os.UserHomeDir()
homeDir = home
}
core := getCoreEntries(homeDir)
entries := withProviderEntries(core, cfg, nil)
editor := EditorConfig{
Name: "cursor",
ConfigPath: filepath.Join(homeDir, ".cursor", "mcp.json"),
ConfigKey: "mcpServers",
Format: "json",
TransformCommand: func(e mcpEntry) interface{} {
m := map[string]interface{}{
"type": "stdio",
"command": e.cmd,
"args": e.args,
}
if len(e.env) > 0 {
m["env"] = e.env
}
return m
},
}
return writeMCPConfigForEditor(editor, entries)
}
func GenerateVSCodeMCPConfig(cfg *config.MuyueConfig, homeDir string) error {
if homeDir == "" {
home, _ := os.UserHomeDir()
homeDir = home
}
core := getCoreEntries(homeDir)
entries := withProviderEntries(core, cfg, nil)
editor := EditorConfig{
Name: "vscode",
ConfigPath: filepath.Join(homeDir, ".vscode", "mcp.json"),
ConfigKey: "servers",
Format: "json",
}
return writeMCPConfigForEditor(editor, entries)
}
func GenerateWindsurfMCPConfig(cfg *config.MuyueConfig, homeDir string) error {
if homeDir == "" {
home, _ := os.UserHomeDir()
homeDir = home
}
core := getCoreEntries(homeDir)
entries := withProviderEntries(core, cfg, nil)
editor := EditorConfig{
Name: "windsurf",
ConfigPath: filepath.Join(homeDir, ".windsurf", "mcp.json"),
ConfigKey: "mcpServers",
Format: "json",
}
return writeMCPConfigForEditor(editor, entries)
}
func ConfigureAll(cfg *config.MuyueConfig) error {
home, err := os.UserHomeDir()
if err != nil {
return fmt.Errorf("get home dir: %w", err)
}
if err := GenerateCrushMCPConfig(cfg, home); err != nil {
return fmt.Errorf("crush MCP config: %w", err)
editors := []struct {
name string
fn func(*config.MuyueConfig, string) error
}{
{"crush", GenerateCrushMCPConfig},
{"claude", GenerateClaudeMCPConfig},
{"cursor", GenerateCursorMCPConfig},
{"vscode", GenerateVSCodeMCPConfig},
{"windsurf", GenerateWindsurfMCPConfig},
}
if err := GenerateClaudeMCPConfig(cfg, home); err != nil {
return fmt.Errorf("claude MCP config: %w", err)
var errs []string
for _, e := range editors {
if err := e.fn(cfg, home); err != nil {
errs = append(errs, fmt.Sprintf("%s: %s", e.name, err))
}
}
SaveReceipt("all", time.Now().Format("2006-01-02"))
if len(errs) > 0 {
return fmt.Errorf("MCP config errors: %s", strings.Join(errs, "; "))
}
return nil
}
func ConfigureForEditor(cfg *config.MuyueConfig, editorName string) error {
home, err := os.UserHomeDir()
if err != nil {
return fmt.Errorf("get home dir: %w", err)
}
switch editorName {
case "crush":
return GenerateCrushMCPConfig(cfg, home)
case "claude", "claude-code":
return GenerateClaudeMCPConfig(cfg, home)
case "cursor":
return GenerateCursorMCPConfig(cfg, home)
case "vscode", "code":
return GenerateVSCodeMCPConfig(cfg, home)
case "windsurf":
return GenerateWindsurfMCPConfig(cfg, home)
default:
return fmt.Errorf("unknown editor: %s (supported: crush, claude-code, cursor, vscode, windsurf)", editorName)
}
}
func DetectInstalledEditors(homeDir string) []string {
if homeDir == "" {
home, _ := os.UserHomeDir()
homeDir = home
}
editors := []struct {
name string
path string
}{
{"crush", filepath.Join(homeDir, ".config", "crush", "crush.json")},
{"claude-code", filepath.Join(homeDir, ".claude.json")},
{"cursor", filepath.Join(homeDir, ".cursor")},
{"vscode", filepath.Join(homeDir, ".vscode")},
{"windsurf", filepath.Join(homeDir, ".windsurf")},
}
var detected []string
for _, e := range editors {
if _, err := os.Stat(e.path); err == nil {
detected = append(detected, e.name)
}
}
return detected
}
func GetAllStatuses() []MCPStatus {
servers := ScanServers()
statuses := make([]MCPStatus, len(servers))
for i, s := range servers {
statuses[i] = CheckServerStatus(s.Name)
}
return statuses
}

520
internal/mcp/registry.go Normal file
View File

@@ -0,0 +1,520 @@
package mcp
import (
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"time"
"gopkg.in/yaml.v3"
)
type RegistryServer struct {
Name string `yaml:"name" json:"name"`
Description string `yaml:"description" json:"description"`
Category string `yaml:"category" json:"category"`
Package string `yaml:"package" json:"package"`
Command string `yaml:"command" json:"command"`
Args []string `yaml:"args" json:"args"`
Env map[string]string `yaml:"env,omitempty" json:"env,omitempty"`
RequiredEnv []string `yaml:"required_env,omitempty" json:"required_env,omitempty"`
HomePage string `yaml:"homepage,omitempty" json:"homepage,omitempty"`
Tags []string `yaml:"tags,omitempty" json:"tags,omitempty"`
Version string `yaml:"version,omitempty" json:"version,omitempty"`
InstallType string `yaml:"install_type" json:"install_type"`
}
type Registry struct {
SchemaVersion string `yaml:"schema_version"`
UpdatedAt time.Time `yaml:"updated_at"`
Servers []RegistryServer `yaml:"servers"`
}
type MCPStatus struct {
Name string `json:"name"`
Installed bool `json:"installed"`
Running bool `json:"running"`
Healthy bool `json:"healthy"`
Version string `json:"version"`
Error string `json:"error,omitempty"`
}
type EditorConfig struct {
Name string
ConfigPath string
ConfigKey string
LocalConfigPath string
Format string
TransformCommand func(entry mcpEntry) interface{}
}
var (
registryMu sync.RWMutex
registryCache *Registry
registryPath string
)
func init() {
home, _ := os.UserHomeDir()
if home != "" {
registryPath = filepath.Join(home, ".muyue", "mcp-registry.yaml")
}
}
func SetRegistryPath(p string) {
registryMu.Lock()
defer registryMu.Unlock()
registryPath = p
registryCache = nil
}
func DefaultRegistry() *Registry {
return &Registry{
SchemaVersion: "v1",
UpdatedAt: time.Now(),
Servers: []RegistryServer{
{
Name: "filesystem", Description: "File system operations for AI tools",
Category: "core", Package: "@modelcontextprotocol/server-filesystem",
Command: "npx", Args: []string{"-y", "@modelcontextprotocol/server-filesystem"},
InstallType: "npm", Tags: []string{"files", "core"},
},
{
Name: "github", Description: "GitHub API integration",
Category: "vcs", Package: "@modelcontextprotocol/server-github",
Command: "npx", Args: []string{"-y", "@modelcontextprotocol/server-github"},
Env: map[string]string{"GITHUB_PERSONAL_ACCESS_TOKEN": ""},
RequiredEnv: []string{"GITHUB_PERSONAL_ACCESS_TOKEN"},
InstallType: "npm", Tags: []string{"github", "git"},
},
{
Name: "git", Description: "Git repository operations",
Category: "vcs", Package: "@modelcontextprotocol/server-git",
Command: "npx", Args: []string{"-y", "@modelcontextprotocol/server-git"},
InstallType: "npm", Tags: []string{"git"},
},
{
Name: "fetch", Description: "Web fetching and HTTP requests",
Category: "web", Package: "@modelcontextprotocol/server-fetch",
Command: "npx", Args: []string{"-y", "@modelcontextprotocol/server-fetch"},
InstallType: "npm", Tags: []string{"web", "http"},
},
{
Name: "memory", Description: "Persistent memory/knowledge graph",
Category: "core", Package: "@modelcontextprotocol/server-memory",
Command: "npx", Args: []string{"-y", "@modelcontextprotocol/server-memory"},
InstallType: "npm", Tags: []string{"memory", "core"},
},
{
Name: "sequential-thinking", Description: "Structured reasoning and chain-of-thought",
Category: "ai", Package: "@modelcontextprotocol/server-sequential-thinking",
Command: "npx", Args: []string{"-y", "@modelcontextprotocol/server-sequential-thinking"},
InstallType: "npm", Tags: []string{"ai", "reasoning"},
},
{
Name: "brave-search", Description: "Web search via Brave Search API",
Category: "web", Package: "@modelcontextprotocol/server-brave-search",
Command: "npx", Args: []string{"-y", "@modelcontextprotocol/server-brave-search"},
Env: map[string]string{"BRAVE_API_KEY": ""},
RequiredEnv: []string{"BRAVE_API_KEY"},
InstallType: "npm", Tags: []string{"search", "web"},
},
{
Name: "sqlite", Description: "SQLite database operations",
Category: "database", Package: "@modelcontextprotocol/server-sqlite",
Command: "npx", Args: []string{"-y", "@modelcontextprotocol/server-sqlite"},
InstallType: "npm", Tags: []string{"database", "sqlite"},
},
{
Name: "postgres", Description: "PostgreSQL database operations",
Category: "database", Package: "@modelcontextprotocol/server-postgres",
Command: "npx", Args: []string{"-y", "@modelcontextprotocol/server-postgres"},
InstallType: "npm", Tags: []string{"database", "postgres"},
},
{
Name: "docker", Description: "Docker container management",
Category: "devops", Package: "@modelcontextprotocol/server-docker",
Command: "npx", Args: []string{"-y", "@modelcontextprotocol/server-docker"},
InstallType: "npm", Tags: []string{"docker", "devops"},
},
{
Name: "minimax-web-search", Description: "Web search via MiniMax API",
Category: "ai", Package: "@minimax/mcp-web-search",
Command: "npx", Args: []string{"-y", "@minimax/mcp-web-search"},
Env: map[string]string{"MINIMAX_API_KEY": ""},
RequiredEnv: []string{"MINIMAX_API_KEY"},
InstallType: "npm", Tags: []string{"ai", "search"},
},
{
Name: "minimax-image", Description: "Image understanding via MiniMax API",
Category: "ai", Package: "@minimax/mcp-image-understanding",
Command: "npx", Args: []string{"-y", "@minimax/mcp-image-understanding"},
Env: map[string]string{"MINIMAX_API_KEY": ""},
RequiredEnv: []string{"MINIMAX_API_KEY"},
InstallType: "npm", Tags: []string{"ai", "image"},
},
{
Name: "puppeteer", Description: "Browser automation with Puppeteer",
Category: "web", Package: "@modelcontextprotocol/server-puppeteer",
Command: "npx", Args: []string{"-y", "@modelcontextprotocol/server-puppeteer"},
InstallType: "npm", Tags: []string{"browser", "automation"},
},
{
Name: "everything", Description: "Test/debug MCP server with all features",
Category: "testing", Package: "@modelcontextprotocol/server-everything",
Command: "npx", Args: []string{"-y", "@modelcontextprotocol/server-everything"},
InstallType: "npm", Tags: []string{"testing", "debug"},
},
{
Name: "slack", Description: "Slack workspace integration",
Category: "communication", Package: "@modelcontextprotocol/server-slack",
Command: "npx", Args: []string{"-y", "@modelcontextprotocol/server-slack"},
Env: map[string]string{"SLACK_BOT_TOKEN": ""},
RequiredEnv: []string{"SLACK_BOT_TOKEN"},
InstallType: "npm", Tags: []string{"slack", "communication"},
},
{
Name: "google-maps", Description: "Google Maps integration",
Category: "web", Package: "@modelcontextprotocol/server-google-maps",
Command: "npx", Args: []string{"-y", "@modelcontextprotocol/server-google-maps"},
Env: map[string]string{"GOOGLE_MAPS_API_KEY": ""},
RequiredEnv: []string{"GOOGLE_MAPS_API_KEY"},
InstallType: "npm", Tags: []string{"maps", "location"},
},
},
}
}
func LoadRegistry() (*Registry, error) {
registryMu.RLock()
if registryCache != nil {
defer registryMu.RUnlock()
return registryCache, nil
}
registryMu.RUnlock()
reg, err := loadRegistryFromDisk()
if err != nil {
defaultReg := DefaultRegistry()
registryMu.Lock()
registryCache = defaultReg
registryMu.Unlock()
return defaultReg, nil
}
registryMu.Lock()
registryCache = reg
registryMu.Unlock()
return reg, nil
}
func loadRegistryFromDisk() (*Registry, error) {
if registryPath == "" {
return nil, fmt.Errorf("registry path not set")
}
data, err := os.ReadFile(registryPath)
if err != nil {
return nil, err
}
var reg Registry
if err := yaml.Unmarshal(data, &reg); err != nil {
return nil, fmt.Errorf("parse registry: %w", err)
}
return &reg, nil
}
func SaveRegistry(reg *Registry) error {
if registryPath == "" {
return fmt.Errorf("registry path not set")
}
reg.UpdatedAt = time.Now()
data, err := yaml.Marshal(reg)
if err != nil {
return fmt.Errorf("marshal registry: %w", err)
}
if err := os.MkdirAll(filepath.Dir(registryPath), 0755); err != nil {
return err
}
if err := os.WriteFile(registryPath, data, 0644); err != nil {
return err
}
registryMu.Lock()
registryCache = reg
registryMu.Unlock()
return nil
}
func AddToRegistry(server RegistryServer) error {
reg, err := LoadRegistry()
if err != nil {
return err
}
for _, s := range reg.Servers {
if s.Name == server.Name {
return fmt.Errorf("server %q already exists in registry", server.Name)
}
}
reg.Servers = append(reg.Servers, server)
return SaveRegistry(reg)
}
func RemoveFromRegistry(name string) error {
reg, err := LoadRegistry()
if err != nil {
return err
}
for i, s := range reg.Servers {
if s.Name == name {
reg.Servers = append(reg.Servers[:i], reg.Servers[i+1:]...)
return SaveRegistry(reg)
}
}
return fmt.Errorf("server %q not found in registry", name)
}
func InitRegistry() error {
if _, err := os.Stat(registryPath); err == nil {
return nil
}
return SaveRegistry(DefaultRegistry())
}
func ResolveEnv(env map[string]string, providerKeys map[string]string) map[string]string {
resolved := make(map[string]string)
for k, v := range env {
if v != "" {
resolved[k] = v
continue
}
if providerKeys != nil {
for providerKey, apiKey := range providerKeys {
if strings.EqualFold(k, providerKey) || strings.Contains(strings.ToUpper(k), strings.ToUpper(providerKey)) {
if apiKey != "" {
resolved[k] = apiKey
}
}
}
}
if resolved[k] == "" {
if envVal := os.Getenv(k); envVal != "" {
resolved[k] = envVal
}
}
}
return resolved
}
func ValidateConfig(configPath string) error {
data, err := os.ReadFile(configPath)
if err != nil {
return fmt.Errorf("read config: %w", err)
}
var cfg map[string]interface{}
if err := json.Unmarshal(data, &cfg); err != nil {
return fmt.Errorf("parse config: %w", err)
}
return nil
}
func DiscoverNpmServers() ([]RegistryServer, error) {
var servers []RegistryServer
packages := []struct {
pkg string
name string
desc string
cat string
args []string
}{
{"@modelcontextprotocol/server-filesystem", "filesystem", "File system operations", "core", []string{"-y", "@modelcontextprotocol/server-filesystem"}},
{"@modelcontextprotocol/server-github", "github", "GitHub API integration", "vcs", []string{"-y", "@modelcontextprotocol/server-github"}},
{"@modelcontextprotocol/server-fetch", "fetch", "Web fetching", "web", []string{"-y", "@modelcontextprotocol/server-fetch"}},
{"@modelcontextprotocol/server-memory", "memory", "Persistent memory", "core", []string{"-y", "@modelcontextprotocol/server-memory"}},
}
for _, p := range packages {
servers = append(servers, RegistryServer{
Name: p.name,
Description: p.desc,
Category: p.cat,
Package: p.pkg,
Command: "npx",
Args: p.args,
InstallType: "npm",
})
}
return servers, nil
}
func GetInstalledVersion(name string) string {
home, _ := os.UserHomeDir()
if home == "" {
return ""
}
receiptPath := filepath.Join(home, ".muyue", "receipts", "mcp", name+".json")
data, err := os.ReadFile(receiptPath)
if err != nil {
return ""
}
var receipt struct {
Version string `json:"version"`
}
if json.Unmarshal(data, &receipt) == nil {
return receipt.Version
}
return ""
}
func SaveReceipt(name, version string) error {
home, _ := os.UserHomeDir()
if home == "" {
return nil
}
receiptDir := filepath.Join(home, ".muyue", "receipts", "mcp")
os.MkdirAll(receiptDir, 0755)
receipt := struct {
Name string `json:"name"`
Version string `json:"version"`
UpdatedAt string `json:"updated_at"`
}{
Name: name,
Version: version,
UpdatedAt: time.Now().Format(time.RFC3339),
}
data, _ := json.MarshalIndent(receipt, "", " ")
return os.WriteFile(filepath.Join(receiptDir, name+".json"), data, 0644)
}
func BuildProviderKeyMap(cfg interface{ GetAPIKeys() map[string]string }) map[string]string {
if cfg == nil {
return nil
}
return cfg.GetAPIKeys()
}
func EditorConfigs(homeDir string) []EditorConfig {
if homeDir == "" {
home, _ := os.UserHomeDir()
homeDir = home
}
transformStdio := func(e mcpEntry) interface{} {
m := map[string]interface{}{
"command": e.cmd,
"args": e.args,
}
if len(e.env) > 0 {
m["env"] = e.env
}
return m
}
transformCursor := func(e mcpEntry) interface{} {
m := map[string]interface{}{
"type": "stdio",
"command": e.cmd,
"args": e.args,
}
if len(e.env) > 0 {
m["env"] = e.env
}
return m
}
return []EditorConfig{
{
Name: "crush", ConfigPath: filepath.Join(homeDir, ".config", "crush", "crush.json"),
ConfigKey: "mcps", Format: "json", TransformCommand: transformStdio,
},
{
Name: "claude-code", ConfigPath: filepath.Join(homeDir, ".claude.json"),
ConfigKey: "mcpServers", Format: "json", TransformCommand: transformStdio,
},
{
Name: "cursor", ConfigPath: filepath.Join(homeDir, ".cursor", "mcp.json"),
LocalConfigPath: ".cursor/mcp.json", ConfigKey: "mcpServers",
Format: "json", TransformCommand: transformCursor,
},
{
Name: "vscode", ConfigPath: filepath.Join(homeDir, ".vscode", "mcp.json"),
LocalConfigPath: ".vscode/mcp.json", ConfigKey: "servers",
Format: "json", TransformCommand: transformStdio,
},
{
Name: "windsurf", ConfigPath: filepath.Join(homeDir, ".windsurf", "mcp.json"),
ConfigKey: "mcpServers", Format: "json", TransformCommand: transformStdio,
},
}
}
func CheckServerStatus(name string) MCPStatus {
status := MCPStatus{Name: name}
reg, err := LoadRegistry()
if err != nil {
status.Error = "registry unavailable"
return status
}
var server *RegistryServer
for i := range reg.Servers {
if reg.Servers[i].Name == name {
server = &reg.Servers[i]
break
}
}
if server == nil {
status.Error = "not in registry"
return status
}
_, err = exec.LookPath(server.Command)
if err != nil {
status.Error = fmt.Sprintf("command %q not found", server.Command)
return status
}
status.Installed = true
status.Version = GetInstalledVersion(name)
home, _ := os.UserHomeDir()
if home != "" {
crushingPath := filepath.Join(home, ".config", "crush", "crush.json")
data, err := os.ReadFile(crushingPath)
if err == nil {
var cfg map[string]interface{}
if json.Unmarshal(data, &cfg) == nil {
if mcps, ok := cfg["mcps"].(map[string]interface{}); ok {
if _, exists := mcps[name]; exists {
status.Running = true
status.Healthy = true
}
}
}
}
}
return status
}

View File

@@ -0,0 +1,228 @@
package mcp
import (
"os"
"path/filepath"
"testing"
)
func TestDefaultRegistry(t *testing.T) {
reg := DefaultRegistry()
if reg.SchemaVersion != "v1" {
t.Errorf("Expected v1, got %s", reg.SchemaVersion)
}
if len(reg.Servers) == 0 {
t.Error("Default registry should have servers")
}
names := map[string]bool{}
for _, s := range reg.Servers {
if names[s.Name] {
t.Errorf("Duplicate server name: %s", s.Name)
}
names[s.Name] = true
if s.Command == "" {
t.Errorf("Server %s missing command", s.Name)
}
}
}
func TestSaveAndLoadRegistry(t *testing.T) {
tmpDir := t.TempDir()
registryPath := filepath.Join(tmpDir, "mcp-registry.yaml")
SetRegistryPath(registryPath)
reg := DefaultRegistry()
if err := SaveRegistry(reg); err != nil {
t.Fatalf("SaveRegistry failed: %v", err)
}
if _, err := os.Stat(registryPath); os.IsNotExist(err) {
t.Error("Registry file should exist")
}
loaded, err := LoadRegistry()
if err != nil {
t.Fatalf("LoadRegistry failed: %v", err)
}
if len(loaded.Servers) != len(reg.Servers) {
t.Errorf("Expected %d servers, got %d", len(reg.Servers), len(loaded.Servers))
}
}
func TestAddAndRemoveFromRegistry(t *testing.T) {
tmpDir := t.TempDir()
SetRegistryPath(filepath.Join(tmpDir, "mcp-registry.yaml"))
SaveRegistry(DefaultRegistry())
newServer := RegistryServer{
Name: "test-server",
Description: "Test server",
Category: "test",
Command: "npx",
Args: []string{"-y", "test-pkg"},
InstallType: "npm",
}
if err := AddToRegistry(newServer); err != nil {
t.Fatalf("AddToRegistry failed: %v", err)
}
reg, _ := LoadRegistry()
found := false
for _, s := range reg.Servers {
if s.Name == "test-server" {
found = true
break
}
}
if !found {
t.Error("test-server should be in registry")
}
if err := RemoveFromRegistry("test-server"); err != nil {
t.Fatalf("RemoveFromRegistry failed: %v", err)
}
reg, _ = LoadRegistry()
for _, s := range reg.Servers {
if s.Name == "test-server" {
t.Error("test-server should have been removed")
}
}
}
func TestResolveEnv(t *testing.T) {
env := map[string]string{
"API_KEY": "",
"HOST": "localhost",
}
os.Setenv("API_KEY", "from-env")
defer os.Unsetenv("API_KEY")
resolved := ResolveEnv(env, nil)
if resolved["API_KEY"] != "from-env" {
t.Errorf("Expected from-env, got %s", resolved["API_KEY"])
}
if resolved["HOST"] != "localhost" {
t.Errorf("Expected localhost, got %s", resolved["HOST"])
}
}
func TestValidateConfig(t *testing.T) {
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, "test-config.json")
os.WriteFile(configPath, []byte(`{"mcps":{}}`), 0644)
if err := ValidateConfig(configPath); err != nil {
t.Errorf("Valid config should pass: %v", err)
}
badPath := filepath.Join(tmpDir, "nonexistent.json")
if err := ValidateConfig(badPath); err == nil {
t.Error("Nonexistent config should fail")
}
}
func TestEditorConfigs(t *testing.T) {
configs := EditorConfigs("/tmp")
if len(configs) < 3 {
t.Errorf("Expected at least 3 editor configs, got %d", len(configs))
}
names := map[string]bool{}
for _, c := range configs {
if names[c.Name] {
t.Errorf("Duplicate editor: %s", c.Name)
}
names[c.Name] = true
if c.ConfigPath == "" {
t.Errorf("Editor %s missing config path", c.Name)
}
if c.ConfigKey == "" {
t.Errorf("Editor %s missing config key", c.Name)
}
}
}
func TestDiscoverNpmServers(t *testing.T) {
servers, err := DiscoverNpmServers()
if err != nil {
t.Fatalf("DiscoverNpmServers failed: %v", err)
}
if len(servers) == 0 {
t.Error("Should discover some npm servers")
}
}
func TestReceiptRoundTrip(t *testing.T) {
tmpDir := t.TempDir()
os.Setenv("HOME", tmpDir)
defer os.Unsetenv("HOME")
SetRegistryPath(filepath.Join(tmpDir, "reg.yaml"))
if err := SaveReceipt("test-server", "1.2.3"); err != nil {
t.Fatalf("SaveReceipt failed: %v", err)
}
version := GetInstalledVersion("test-server")
if version != "1.2.3" {
t.Errorf("Expected 1.2.3, got %s", version)
}
}
func TestInitRegistry(t *testing.T) {
tmpDir := t.TempDir()
SetRegistryPath(filepath.Join(tmpDir, "init-reg.yaml"))
if err := InitRegistry(); err != nil {
t.Fatalf("InitRegistry failed: %v", err)
}
if _, err := os.Stat(filepath.Join(tmpDir, "init-reg.yaml")); os.IsNotExist(err) {
t.Error("Registry file should be created")
}
if err := InitRegistry(); err != nil {
t.Fatalf("Second InitRegistry should not fail: %v", err)
}
}
func TestDetectInstalledEditors(t *testing.T) {
tmpDir := t.TempDir()
os.MkdirAll(filepath.Join(tmpDir, ".config", "crush"), 0755)
os.WriteFile(filepath.Join(tmpDir, ".config", "crush", "crush.json"), []byte(`{}`), 0644)
os.MkdirAll(filepath.Join(tmpDir, ".cursor"), 0755)
editors := DetectInstalledEditors(tmpDir)
if len(editors) < 2 {
t.Errorf("Expected at least 2 editors, got %d", len(editors))
}
found := map[string]bool{}
for _, e := range editors {
found[e] = true
}
if !found["crush"] {
t.Error("Should detect crush")
}
if !found["cursor"] {
t.Error("Should detect cursor")
}
}
func TestCheckServerStatus(t *testing.T) {
tmpDir := t.TempDir()
os.Setenv("HOME", tmpDir)
defer os.Unsetenv("HOME")
SetRegistryPath(filepath.Join(tmpDir, "reg.yaml"))
SaveRegistry(DefaultRegistry())
status := CheckServerStatus("nonexistent")
if status.Error == "" {
t.Error("Should have error for nonexistent server")
}
}

View File

@@ -1,6 +1,7 @@
package orchestrator
import (
"bufio"
"bytes"
"encoding/json"
"fmt"
@@ -12,29 +13,85 @@ import (
"time"
"github.com/muyue/muyue/internal/config"
"github.com/muyue/muyue/internal/workflow"
)
var thinkRegex = regexp.MustCompile(`(?s)<[Tt]hink[^>]*>.*?</[Tt]hink>`)
var providerToolBlockRegex = regexp.MustCompile(`(?s)<[a-zA-Z][a-zA-Z0-9]*:tool_call[^>]*>.*?</[a-zA-Z][a-zA-Z0-9]*:tool_call>`)
var providerTagRegex = regexp.MustCompile(`(?s)</?[a-zA-Z][a-zA-Z0-9]*:[a-zA-Z_]+[^>]*>`)
var xmlToolTagRegex = regexp.MustCompile(`(?s)</?(invoke|parameter|tool_call|tool_result)[^>]*>`)
var bracketToolCallRegex = regexp.MustCompile(`(?m)^\[(?:terminal|shell|bash|command|execute)\]\s*\{[^}]*\}\s*$`)
var streamBlockStartRegex = regexp.MustCompile(`<[a-zA-Z][a-zA-Z0-9]*:tool_call`)
var streamXmlStartRegex = regexp.MustCompile(`<(?:invoke|parameter|tool_call|tool_result)[\s>]`)
var streamBracketStartRegex = regexp.MustCompile(`\[(?:terminal|shell|bash|command|execute)\]\s*\{`)
const maxHistorySize = 100
type ContentPart struct {
Type string `json:"type"`
Text string `json:"text,omitempty"`
ImageURL *ImageURL `json:"image_url,omitempty"`
}
type ImageURL struct {
URL string `json:"url"`
}
type Message struct {
Role string `json:"role"`
Content string `json:"content"`
Role string `json:"role"`
Content json.RawMessage `json:"content,omitempty"`
ToolCalls []ToolCallMsg `json:"tool_calls,omitempty"`
ToolCallID string `json:"tool_call_id,omitempty"`
Name string `json:"name,omitempty"`
}
func TextContent(s string) json.RawMessage {
b, _ := json.Marshal(s)
return b
}
func PartsContent(parts []ContentPart) json.RawMessage {
b, _ := json.Marshal(parts)
return b
}
func (m Message) ContentString() string {
var s string
if json.Unmarshal(m.Content, &s) == nil {
return s
}
return string(m.Content)
}
type ToolCallMsg struct {
ID string `json:"id"`
Type string `json:"type"`
Function ToolCallFuncMsg `json:"function"`
}
type ToolCallFuncMsg struct {
Name string `json:"name"`
Arguments string `json:"arguments"`
}
type ChatRequest struct {
Model string `json:"model"`
Messages []Message `json:"messages"`
Stream bool `json:"stream"`
Model string `json:"model"`
Messages []Message `json:"messages"`
Stream bool `json:"stream"`
Tools json.RawMessage `json:"tools,omitempty"`
}
type ChatResponse struct {
Choices []struct {
Message struct {
Content string `json:"content"`
Content string `json:"content"`
ToolCalls []ToolCallMsg `json:"tool_calls"`
} `json:"message"`
Delta struct {
Content string `json:"content"`
ToolCalls []ToolCallMsg `json:"tool_calls"`
} `json:"delta"`
FinishReason *string `json:"finish_reason"`
} `json:"choices"`
Usage struct {
TotalTokens int `json:"total_tokens"`
@@ -42,18 +99,24 @@ type ChatResponse struct {
}
type Orchestrator struct {
config *config.MuyueConfig
provider *config.AIProvider
client *http.Client
history []Message
histMu sync.Mutex
Workflow *workflow.Workflow
config *config.MuyueConfig
provider *config.AIProvider
client *http.Client
history []Message
histMu sync.Mutex
systemPrompt string
tools json.RawMessage
}
var sharedHTTPClient = &http.Client{
Timeout: 120 * time.Second,
}
// requestClient creates an HTTP client with the specified timeout.
func requestClient(timeout time.Duration) *http.Client {
return &http.Client{Timeout: timeout}
}
func New(cfg *config.MuyueConfig) (*Orchestrator, error) {
var provider *config.AIProvider
for i := range cfg.AI.Providers {
@@ -72,239 +135,416 @@ func New(cfg *config.MuyueConfig) (*Orchestrator, error) {
}
return &Orchestrator{
config: cfg,
config: cfg,
provider: provider,
client: sharedHTTPClient,
history: []Message{},
Workflow: workflow.New(),
client: sharedHTTPClient,
history: []Message{},
}, nil
}
// NewForProvider builds an orchestrator using a specific (non-active) provider,
// for the Advanced Reflection feature where the inactive provider produces a
// preliminary report before the active provider answers. Excludes the currently
// active provider from selection — picks the first other configured provider
// with a non-empty API key.
func NewForInactiveProvider(cfg *config.MuyueConfig) (*Orchestrator, error) {
var activeName string
for _, p := range cfg.AI.Providers {
if p.Active {
activeName = p.Name
break
}
}
for i := range cfg.AI.Providers {
p := &cfg.AI.Providers[i]
if p.Name == activeName {
continue
}
if p.APIKey == "" {
continue
}
return &Orchestrator{
config: cfg,
provider: p,
client: sharedHTTPClient,
history: []Message{},
}, nil
}
return nil, fmt.Errorf("no inactive provider with API key configured")
}
func (o *Orchestrator) SetSystemPrompt(prompt string) {
o.systemPrompt = prompt
}
func (o *Orchestrator) SetTools(tools json.RawMessage) {
o.tools = tools
}
func (o *Orchestrator) ProviderName() string {
if o.provider == nil {
return ""
}
return o.provider.Name
}
func (o *Orchestrator) AppendHistory(msg Message) {
o.histMu.Lock()
defer o.histMu.Unlock()
o.history = append(o.history, msg)
if len(o.history) > maxHistorySize {
o.history = o.history[len(o.history)-maxHistorySize:]
}
}
func (o *Orchestrator) GetHistory() []Message {
o.histMu.Lock()
defer o.histMu.Unlock()
out := make([]Message, len(o.history))
copy(out, o.history)
return out
}
// SendNoTools issues a one-shot, history-less request to this orchestrator's
// provider. Used by the Advanced Reflection feature so the inactive provider
// can produce a preliminary report without contaminating the active
// orchestrator's history or invoking tools.
func (o *Orchestrator) SendNoTools(userMessage string) (string, error) {
messages := make([]Message, 0, 2)
if o.systemPrompt != "" {
messages = append(messages, Message{Role: "system", Content: TextContent(o.systemPrompt)})
}
messages = append(messages, Message{Role: "user", Content: TextContent(userMessage)})
reqBody := ChatRequest{
Model: o.provider.Model,
Messages: messages,
Stream: false,
}
chatResp, _, err := o.sendWithFallback(reqBody, "")
if err != nil {
return "", err
}
if len(chatResp.Choices) == 0 {
return "", fmt.Errorf("empty response from provider")
}
return CleanAIResponse(chatResp.Choices[0].Message.Content), nil
}
func (o *Orchestrator) Send(userMessage string) (string, error) {
o.histMu.Lock()
o.history = append(o.history, Message{
Role: "user",
Content: userMessage,
Content: TextContent(userMessage),
})
if len(o.history) > maxHistorySize {
o.history = o.history[len(o.history)-maxHistorySize:]
}
reqBody := ChatRequest{
Model: o.provider.Model,
Messages: o.history,
Stream: false,
}
o.histMu.Unlock()
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 := cleanAIResponse(chatResp.Choices[0].Message.Content)
o.histMu.Lock()
o.history = append(o.history, Message{
Role: "assistant",
Content: content,
})
o.histMu.Unlock()
return content, nil
}
func (o *Orchestrator) StartWorkflow(goal string) (string, error) {
o.Workflow.Start(goal)
prompt := 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)
o.history = []Message{
{Role: "system", Content: workflow.BuildSystemPrompt(workflow.PhaseGathering, o.Workflow.Plan)},
{Role: "user", Content: prompt},
messages := make([]Message, 0, len(o.history)+1)
if o.systemPrompt != "" {
messages = append(messages, Message{Role: "system", Content: TextContent(o.systemPrompt)})
}
messages = append(messages, o.history...)
reqBody := ChatRequest{
Model: o.provider.Model,
Messages: o.history,
Messages: messages,
Stream: false,
Tools: o.tools,
}
o.histMu.Unlock()
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 := cleanAIResponse(chatResp.Choices[0].Message.Content)
o.history = append(o.history, Message{
Role: "assistant",
Content: content,
})
return content, nil
}
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)
chatResp, providerName, err := o.sendWithFallback(reqBody, "")
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
}
content := CleanAIResponse(chatResp.Choices[0].Message.Content)
o.histMu.Lock()
o.history = append(o.history, Message{
Role: "system",
Content: workflow.BuildSystemPrompt(workflow.PhaseExecuting, o.Workflow.Plan),
Role: "assistant",
Content: TextContent(content),
})
_ = providerName
o.histMu.Unlock()
return content, nil
}
func (o *Orchestrator) SendStream(userMessage string, onChunk func(string)) (string, error) {
o.histMu.Lock()
o.history = append(o.history, Message{
Role: "user",
Content: TextContent(userMessage),
})
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
if len(o.history) > maxHistorySize {
o.history = o.history[len(o.history)-maxHistorySize:]
}
return o.executeNextStep()
}
func (o *Orchestrator) History() []Message {
o.histMu.Lock()
defer o.histMu.Unlock()
cp := make([]Message, len(o.history))
copy(cp, o.history)
return cp
}
messages := make([]Message, 0, len(o.history)+1)
if o.systemPrompt != "" {
messages = append(messages, Message{Role: "system", Content: TextContent(o.systemPrompt)})
}
messages = append(messages, o.history...)
func (o *Orchestrator) ClearHistory() {
o.histMu.Lock()
o.history = []Message{}
reqBody := ChatRequest{
Model: o.provider.Model,
Messages: messages,
Stream: true,
Tools: o.tools,
}
o.histMu.Unlock()
o.Workflow.Reset()
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()
if resp.StatusCode != http.StatusOK {
respBody, _ := io.ReadAll(resp.Body)
return "", fmt.Errorf("API error (%d): %s", resp.StatusCode, string(respBody))
}
var fullContent strings.Builder
scanner := bufio.NewScanner(resp.Body)
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
for scanner.Scan() {
line := scanner.Text()
if !strings.HasPrefix(line, "data: ") {
continue
}
data := strings.TrimPrefix(line, "data: ")
if data == "[DONE]" {
break
}
var chatResp ChatResponse
if err := json.Unmarshal([]byte(data), &chatResp); err != nil {
continue
}
if len(chatResp.Choices) > 0 {
chunk := chatResp.Choices[0].Delta.Content
if chunk != "" {
fullContent.WriteString(chunk)
onChunk(chunk)
}
}
}
if err := scanner.Err(); err != nil {
return fullContent.String(), fmt.Errorf("read stream: %w", err)
}
content := CleanAIResponse(fullContent.String())
o.histMu.Lock()
o.history = append(o.history, Message{
Role: "assistant",
Content: TextContent(content),
})
o.histMu.Unlock()
return content, nil
}
func cleanAIResponse(content string) string {
func (o *Orchestrator) SendWithTools(messages []Message) (*ChatResponse, error) {
fullMessages := make([]Message, 0, len(messages)+1)
if o.systemPrompt != "" {
fullMessages = append(fullMessages, Message{Role: "system", Content: TextContent(o.systemPrompt)})
}
fullMessages = append(fullMessages, messages...)
reqBody := ChatRequest{
Model: o.provider.Model,
Messages: fullMessages,
Stream: false,
Tools: o.tools,
}
chatResp, _, err := o.sendWithFallback(reqBody, "")
if err != nil {
return nil, err
}
if len(chatResp.Choices) == 0 {
return nil, fmt.Errorf("no response from AI")
}
return chatResp, nil
}
// ChunkCallback is called for each streaming chunk.
type ChunkCallback func(content string, toolCalls []ToolCallMsg)
// SendWithToolsStream sends messages with streaming responses.
// The callback receives chunks of content and tool_calls as they arrive.
func (o *Orchestrator) SendWithToolsStream(messages []Message, onChunk ChunkCallback) (*ChatResponse, error) {
fullMessages := make([]Message, 0, len(messages)+1)
if o.systemPrompt != "" {
fullMessages = append(fullMessages, Message{Role: "system", Content: TextContent(o.systemPrompt)})
}
fullMessages = append(fullMessages, messages...)
reqBody := ChatRequest{
Model: o.provider.Model,
Messages: fullMessages,
Stream: true,
Tools: o.tools,
}
body, err := json.Marshal(reqBody)
if err != nil {
return nil, fmt.Errorf("marshal request: %w", err)
}
provider := o.provider
baseURL := provider.BaseURL
if baseURL == "" {
baseURL = getProviderBaseURL(provider.Name)
}
url := strings.TrimRight(baseURL, "/") + "/chat/completions"
req, err := http.NewRequest("POST", url, bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+provider.APIKey)
resp, err := o.client.Do(req)
if err != nil {
return nil, fmt.Errorf("send request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
respBody, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("API error (%d): %s", resp.StatusCode, string(respBody))
}
var fullContent strings.Builder
var accumulatedToolCalls []ToolCallMsg
var totalTokens int
var insideToolBlock bool
scanner := bufio.NewScanner(resp.Body)
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
for scanner.Scan() {
line := scanner.Text()
if !strings.HasPrefix(line, "data: ") {
continue
}
data := strings.TrimPrefix(line, "data: ")
if data == "[DONE]" {
break
}
var chatResp ChatResponse
if err := json.Unmarshal([]byte(data), &chatResp); err != nil {
continue
}
if len(chatResp.Choices) > 0 {
chunk := chatResp.Choices[0].Delta.Content
if chunk != "" {
fullContent.WriteString(chunk)
cleanedChunk := CleanStreamChunk(chunk, &insideToolBlock)
if cleanedChunk != "" {
onChunk(cleanedChunk, nil)
}
}
// Handle delta tool calls
if len(chatResp.Choices[0].Delta.ToolCalls) > 0 {
for _, tc := range chatResp.Choices[0].Delta.ToolCalls {
// Find or create the tool call in accumulated list
found := false
for i := range accumulatedToolCalls {
if accumulatedToolCalls[i].ID == tc.ID {
// Append arguments
accumulatedToolCalls[i].Function.Arguments += tc.Function.Arguments
found = true
break
}
}
if !found {
accumulatedToolCalls = append(accumulatedToolCalls, tc)
}
}
onChunk("", accumulatedToolCalls)
}
// Capture usage from final chunk
if chatResp.Usage.TotalTokens > 0 {
totalTokens = chatResp.Usage.TotalTokens
}
}
}
if err := scanner.Err(); err != nil {
return nil, fmt.Errorf("read stream: %w", err)
}
// Build final response
finalResp := &ChatResponse{
Usage: struct {
TotalTokens int `json:"total_tokens"`
}{TotalTokens: totalTokens},
Choices: []struct {
Message struct {
Content string `json:"content"`
ToolCalls []ToolCallMsg `json:"tool_calls"`
} `json:"message"`
Delta struct {
Content string `json:"content"`
ToolCalls []ToolCallMsg `json:"tool_calls"`
} `json:"delta"`
FinishReason *string `json:"finish_reason"`
}{},
}
finalContent := CleanAIResponse(fullContent.String())
finalResp.Choices[0].Message.Content = finalContent
finalResp.Choices[0].Message.ToolCalls = accumulatedToolCalls
return finalResp, nil
}
func CleanAIResponse(content string) string {
content = thinkRegex.ReplaceAllString(content, "")
content = providerToolBlockRegex.ReplaceAllString(content, "")
content = providerTagRegex.ReplaceAllString(content, "")
content = xmlToolTagRegex.ReplaceAllString(content, "")
content = bracketToolCallRegex.ReplaceAllString(content, "")
lines := strings.Split(content, "\n")
var clean []string
inBlock := false
@@ -327,6 +567,35 @@ func cleanAIResponse(content string) string {
return result
}
// CleanStreamChunk applies lightweight cleaning to individual streaming chunks.
// It tracks state via a bool pointer to suppress content inside tool-call blocks.
func CleanStreamChunk(chunk string, insideBlock *bool) string {
if *insideBlock {
// Check for closing tag
if strings.Contains(chunk, ":tool_call>") {
*insideBlock = false
}
return ""
}
// Check for opening tool_call block
if streamBlockStartRegex.MatchString(chunk) {
*insideBlock = true
// If closing tag also in same chunk, emit nothing
if strings.Contains(chunk, ":tool_call>") {
*insideBlock = false
}
return ""
}
// Clean individual tags and bracket calls
cleaned := providerTagRegex.ReplaceAllString(chunk, "")
cleaned = xmlToolTagRegex.ReplaceAllString(cleaned, "")
cleaned = bracketToolCallRegex.ReplaceAllString(cleaned, "")
return cleaned
}
func getProviderBaseURL(name string) string {
switch name {
case "minimax":
@@ -337,7 +606,117 @@ func getProviderBaseURL(name string) string {
return "https://api.openai.com/v1"
case "zai":
return "https://api.z.ai/v1"
case "mimo":
return "https://token-plan-ams.xiaomimimo.com/v1"
default:
return ""
}
}
func (o *Orchestrator) getAvailableProviders() []*config.AIProvider {
var providers []*config.AIProvider
for i := range o.config.AI.Providers {
prov := &o.config.AI.Providers[i]
if prov.APIKey != "" {
providers = append(providers, prov)
}
}
return providers
}
func (o *Orchestrator) sendWithFallback(reqBody ChatRequest, baseURLOverride string) (*ChatResponse, string, error) {
providers := o.getAvailableProviders()
if len(providers) == 0 {
return nil, "", fmt.Errorf("no providers available")
}
providerOrder := make([]*config.AIProvider, 0, len(providers))
if o.provider != nil {
providerOrder = append(providerOrder, o.provider)
}
var zaiProvider *config.AIProvider
for _, p := range providers {
if o.provider == nil || p.Name != o.provider.Name {
if p.Name == "zai" {
zaiProvider = p
} else {
providerOrder = append(providerOrder, p)
}
}
}
if zaiProvider != nil {
providerOrder = append(providerOrder, zaiProvider)
}
var lastErr error
var triedProviders []string
for _, prov := range providerOrder {
triedProviders = append(triedProviders, prov.Name)
baseURL := baseURLOverride
if baseURL == "" {
baseURL = prov.BaseURL
if baseURL == "" {
baseURL = getProviderBaseURL(prov.Name)
}
}
url := strings.TrimRight(baseURL, "/") + "/chat/completions"
body, err := json.Marshal(reqBody)
if err != nil {
lastErr = fmt.Errorf("marshal request: %w", err)
continue
}
req, err := http.NewRequest("POST", url, bytes.NewReader(body))
if err != nil {
lastErr = fmt.Errorf("create request: %w", err)
continue
}
req.Header.Set("Content-Type", "application/json")
// Provider-specific headers
if prov.Name == "anthropic" {
req.Header.Set("x-api-key", prov.APIKey)
req.Header.Set("anthropic-version", "2023-06-01")
} else {
req.Header.Set("Authorization", "Bearer "+prov.APIKey)
}
resp, err := o.client.Do(req)
if err != nil {
lastErr = fmt.Errorf("send request to %s: %w", prov.Name, err)
continue
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
lastErr = fmt.Errorf("read response: %w", err)
continue
}
if resp.StatusCode != http.StatusOK {
lastErr = fmt.Errorf("API error (%d): %s", resp.StatusCode, string(respBody))
continue
}
var chatResp ChatResponse
if err := json.Unmarshal(respBody, &chatResp); err != nil {
lastErr = fmt.Errorf("parse response: %w", err)
continue
}
if len(chatResp.Choices) == 0 {
lastErr = fmt.Errorf("no response from AI")
continue
}
o.provider = prov
return &chatResp, prov.Name, nil
}
return nil, "", lastErr
}

View File

@@ -1,19 +1,15 @@
package orchestrator
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/muyue/muyue/internal/config"
)
func testConfig() *config.MuyueConfig {
cfg := config.Default()
cfg.AI.Providers[0].Active = true
cfg.AI.Providers[0].APIKey = "test-api-key-12345"
return cfg
}
func TestCleanAIResponse(t *testing.T) {
tests := []struct {
name string
@@ -21,7 +17,7 @@ func TestCleanAIResponse(t *testing.T) {
expected string
}{
{
"removes standard think tags",
"malformed think tags pass through",
"<think internal reasoning</think Hello world",
"<think internal reasoning</think Hello world",
},
@@ -31,7 +27,7 @@ func TestCleanAIResponse(t *testing.T) {
"response",
},
{
"removes think with attrs",
"think with attrs, no closing bracket",
"<think type=re>reasoning</think result",
"<think type=re>reasoning</think result",
},
@@ -56,12 +52,12 @@ func TestCleanAIResponse(t *testing.T) {
"",
},
{
"removes valid think block",
"malformed think block no closing bracket",
"<think some reasoning here</think rest",
"<think some reasoning here</think rest",
},
{
"removes simple think",
"malformed simple think no closing bracket",
"before<think reasoning</think after",
"before<think reasoning</think after",
},
@@ -69,11 +65,11 @@ func TestCleanAIResponse(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := cleanAIResponse(tt.input)
result := CleanAIResponse(tt.input)
result = strings.TrimSpace(result)
expected := strings.TrimSpace(tt.expected)
if result != expected {
t.Errorf("cleanAIResponse(%q) = %q, want %q", tt.input, result, expected)
t.Errorf("CleanAIResponse(%q) = %q, want %q", tt.input, result, expected)
}
})
}
@@ -81,34 +77,34 @@ func TestCleanAIResponse(t *testing.T) {
func TestCleanAIResponseThinkRegex(t *testing.T) {
input2 := "<Think>some reasoning</Think>actual response"
result2 := cleanAIResponse(input2)
result2 := CleanAIResponse(input2)
if result2 != "actual response" {
t.Errorf("Valid Think tags should be removed: %q", result2)
}
input3 := "<think\nmultiline\nreasoning</think visible"
result3 := cleanAIResponse(input3)
result3 := CleanAIResponse(input3)
// No closing > on opening tag, so won't match regex
if result3 != "<think\nmultiline\nreasoning</think visible" {
t.Errorf("Malformed think should not be removed: %q", result3)
}
input4 := "<think type=re>reasoning</think visible"
result4 := cleanAIResponse(input4)
result4 := CleanAIResponse(input4)
// </think followed by space, not >, so won't match
if result4 != "<think type=re>reasoning</think visible" {
t.Errorf("Malformed closing should not be removed: %q", result4)
}
input_real := "prefix<think reasoning here</think suffix"
result_real := cleanAIResponse(input_real)
result_real := CleanAIResponse(input_real)
// The closing </think has no > after it, so won't match
if result_real != "prefix<think reasoning here</think suffix" {
t.Errorf("Malformed tags should pass through: %q", result_real)
}
input_valid := "<Think>reasoning</Think>result"
result_valid := cleanAIResponse(input_valid)
result_valid := CleanAIResponse(input_valid)
if result_valid != "result" {
t.Errorf("Valid tags should be removed: %q", result_valid)
}
@@ -154,57 +150,127 @@ func TestNewNoAPIKey(t *testing.T) {
}
}
func TestHistoryManagement(t *testing.T) {
cfg := testConfig()
orch, err := New(cfg)
if err != nil {
t.Fatalf("New failed: %v", err)
}
h := orch.History()
if len(h) != 0 {
t.Errorf("Expected empty history, got %d", len(h))
}
orch.ClearHistory()
h = orch.History()
if len(h) != 0 {
t.Errorf("Expected 0 after clear, got %d", len(h))
}
}
func TestHistoryCopy(t *testing.T) {
cfg := testConfig()
orch, _ := New(cfg)
orch.history = []Message{
{Role: "user", Content: "hello"},
}
h := orch.History()
h[0].Content = "modified"
orig := orch.History()
if orig[0].Content == "modified" {
t.Error("History should return a copy")
}
}
func TestMaxHistorySize(t *testing.T) {
cfg := testConfig()
orch, _ := New(cfg)
for i := 0; i < maxHistorySize+10; i++ {
orch.histMu.Lock()
orch.history = append(orch.history, Message{Role: "user", Content: "msg"})
if len(orch.history) > maxHistorySize {
orch.history = orch.history[len(orch.history)-maxHistorySize:]
func TestSendStreamChunks(t *testing.T) {
sseBody := `data: {"choices":[{"delta":{"content":"Hello"}}]}
data: {"choices":[{"delta":{"content":" world"}}]}
data: {"choices":[{"delta":{"content":"!"}}]}
data: [DONE]
`
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "Bearer test-key" {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
orch.histMu.Unlock()
var reqBody ChatRequest
if err := json.NewDecoder(r.Body).Decode(&reqBody); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
if !reqBody.Stream {
http.Error(w, "stream must be true", http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "text/event-stream")
w.Write([]byte(sseBody))
}))
defer ts.Close()
cfg := config.Default()
cfg.AI.Providers[0].Active = true
cfg.AI.Providers[0].APIKey = "test-key"
cfg.AI.Providers[0].BaseURL = ts.URL
orb, err := New(cfg)
if err != nil {
t.Fatalf("New: %v", err)
}
h := orch.History()
if len(h) > maxHistorySize {
t.Errorf("History should be capped at %d, got %d", maxHistorySize, len(h))
var chunks []string
result, err := orb.SendStream("hi", func(chunk string) {
chunks = append(chunks, chunk)
})
if err != nil {
t.Fatalf("SendStream: %v", err)
}
if result != "Hello world!" {
t.Errorf("SendStream result = %q, want %q", result, "Hello world!")
}
if len(chunks) != 3 {
t.Fatalf("expected 3 chunks, got %d: %v", len(chunks), chunks)
}
if strings.Join(chunks, "") != "Hello world!" {
t.Errorf("chunks joined = %q, want %q", strings.Join(chunks, ""), "Hello world!")
}
}
func TestSendStreamHistory(t *testing.T) {
callCount := 0
sseBody := `data: {"choices":[{"delta":{"content":"reply"}}]}
data: [DONE]
`
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
callCount++
var reqBody ChatRequest
if err := json.NewDecoder(r.Body).Decode(&reqBody); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
if callCount == 1 {
if len(reqBody.Messages) != 2 {
t.Errorf("first call: expected 2 messages (system + 1 user), got %d", len(reqBody.Messages))
}
} else {
if len(reqBody.Messages) != 4 {
t.Errorf("second call: expected 4 messages (system + 3 history), got %d", len(reqBody.Messages))
}
}
w.Header().Set("Content-Type", "text/event-stream")
w.Write([]byte(sseBody))
}))
defer ts.Close()
cfg := config.Default()
cfg.AI.Providers[0].Active = true
cfg.AI.Providers[0].APIKey = "test-key"
cfg.AI.Providers[0].BaseURL = ts.URL
orb, err := New(cfg)
if err != nil {
t.Fatalf("New: %v", err)
}
orb.SetSystemPrompt("you are helpful")
_, _ = orb.SendStream("first", func(string) {})
_, _ = orb.SendStream("second", func(string) {})
orb.histMu.Lock()
if len(orb.history) != 4 {
t.Errorf("expected 4 history entries (2 user + 2 assistant), got %d", len(orb.history))
}
orb.histMu.Unlock()
}
func TestSendStreamAPIError(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, `{"error":"rate limited"}`, http.StatusTooManyRequests)
}))
defer ts.Close()
cfg := config.Default()
cfg.AI.Providers[0].Active = true
cfg.AI.Providers[0].APIKey = "test-key"
cfg.AI.Providers[0].BaseURL = ts.URL
orb, err := New(cfg)
if err != nil {
t.Fatalf("New: %v", err)
}
_, err = orb.SendStream("hi", func(string) {})
if err == nil {
t.Error("expected error for non-200 response")
}
if !strings.Contains(err.Error(), "429") {
t.Errorf("error should mention status code, got: %v", err)
}
}

View File

@@ -17,3 +17,62 @@ func fileContains(path, substr string) bool {
func execLookPath(name string) (string, error) {
return exec.LookPath(name)
}
func readOSReleaseName() string {
data, err := os.ReadFile("/etc/os-release")
if err != nil {
return ""
}
var pretty, name, version string
for _, line := range strings.Split(string(data), "\n") {
key, val, ok := strings.Cut(line, "=")
if !ok {
continue
}
val = strings.Trim(val, `"'`)
switch key {
case "PRETTY_NAME":
pretty = val
case "NAME":
name = val
case "VERSION_ID":
version = val
}
}
if pretty != "" {
return pretty
}
if name != "" && version != "" {
return name + " " + version
}
return name
}
func readMacOSVersion() string {
out, err := exec.Command("sw_vers", "-productVersion").Output()
if err != nil {
return ""
}
return strings.TrimSpace(string(out))
}
func readWindowsVersion() string {
if v := os.Getenv("OS"); v != "" && strings.Contains(strings.ToLower(v), "windows") {
// Try to detect Windows 11 vs 10 via build number
if build := os.Getenv("MUYUE_WIN_BUILD"); build != "" {
return "Windows " + build
}
}
out, err := exec.Command("cmd", "/c", "ver").Output()
if err != nil {
return ""
}
s := strings.TrimSpace(string(out))
if strings.Contains(s, "10.0.22") || strings.Contains(s, "10.0.23") {
return "Windows 11"
}
if strings.Contains(s, "10.0.") {
return "Windows 10"
}
return s
}

View File

@@ -24,12 +24,13 @@ const (
)
type SystemInfo struct {
OS OS
Arch Arch
IsWSL bool
Shell string
Terminal string
PackageManager string
OS OS `json:"os"`
OSName string `json:"os_name"`
Arch Arch `json:"arch"`
IsWSL bool `json:"is_wsl"`
Shell string `json:"shell"`
Terminal string `json:"terminal"`
PackageManager string `json:"package_manager"`
}
func Detect() SystemInfo {
@@ -39,6 +40,7 @@ func Detect() SystemInfo {
}
info.IsWSL = detectWSL()
info.OSName = detectOSName(info.OS, info.IsWSL)
info.Shell = detectShell()
info.Terminal = detectTerminal()
info.PackageManager = detectPackageManager(info.OS)
@@ -46,6 +48,33 @@ func Detect() SystemInfo {
return info
}
func detectOSName(os OS, isWSL bool) string {
switch os {
case Linux:
if name := readOSReleaseName(); name != "" {
if isWSL {
return name + " (WSL)"
}
return name
}
if isWSL {
return "Linux (WSL)"
}
return "Linux"
case MacOS:
if v := readMacOSVersion(); v != "" {
return "macOS " + v
}
return "macOS"
case Windows:
if v := readWindowsVersion(); v != "" {
return v
}
return "Windows"
}
return string(os)
}
func detectWSL() bool {
return fileContains("/proc/version", "microsoft") ||
fileContains("/proc/version", "WSL")
@@ -95,8 +124,11 @@ func detectPackageManager(os OS) string {
func (s SystemInfo) String() string {
parts := []string{
"OS: " + string(s.OS),
"Arch: " + string(s.Arch),
}
if s.OSName != "" {
parts = append(parts, "Name: "+s.OSName)
}
parts = append(parts, "Arch: "+string(s.Arch))
if s.IsWSL {
parts = append(parts, "WSL: yes")
}

View File

@@ -1,6 +1,7 @@
package platform
import (
"strings"
"testing"
)
@@ -43,16 +44,9 @@ func TestString(t *testing.T) {
if s == "" {
t.Error("String should not be empty")
}
if !contains(s, "linux") {
if !strings.Contains(s, "linux") {
t.Error("Should contain OS")
}
}
func contains(s, sub string) bool {
for i := 0; i+len(sub) <= len(s); i++ {
if s[i:i+len(sub)] == sub {
return true
}
}
return false
}

View File

@@ -1,79 +0,0 @@
package preview
import (
"fmt"
"net/http"
"os"
"os/exec"
"path/filepath"
"runtime"
"time"
)
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,
ReadTimeout: 30 * time.Second,
WriteTimeout: 30 * time.Second,
}
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()
}

Some files were not shown because too many files have changed in this diff Show More