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
This commit was merged in pull request #9.
This commit is contained in:
2026-04-27 10:55:48 +00:00
5 changed files with 280 additions and 31 deletions

View File

@@ -138,11 +138,12 @@ jobs:
echo "sudo mv muyue-darwin-arm64 /usr/local/bin/muyue" echo "sudo mv muyue-darwin-arm64 /usr/local/bin/muyue"
echo "\`\`\`" echo "\`\`\`"
echo "" echo ""
echo "**Windows (x86_64)**" echo "**Windows (x86_64)** — sans privilèges admin, crée les raccourcis Bureau + Menu Démarrer :"
echo "\`\`\`powershell" echo "\`\`\`powershell"
echo "Invoke-WebRequest -Uri \"${DL_URL}/muyue-windows-amd64.zip\" -OutFile \"muyue.zip\"" echo "\$dest = \"\$env:LOCALAPPDATA\\Muyue\""
echo "Expand-Archive -Path \"muyue.zip\" -DestinationPath \".\"" echo "Invoke-WebRequest -Uri \"${DL_URL}/muyue-windows-amd64.zip\" -OutFile \"\$env:TEMP\\muyue.zip\""
echo "Move-Item muyue-windows-amd64.exe C:\\Windows\\muyue.exe" echo "Expand-Archive -Path \"\$env:TEMP\\muyue.zip\" -DestinationPath \$dest -Force"
echo "& \"\$dest\\muyue-windows-amd64.exe\" install-shortcuts"
echo "\`\`\`" echo "\`\`\`"
} > /tmp/stable_changelog.md } > /tmp/stable_changelog.md
echo "path=/tmp/stable_changelog.md" >> $GITHUB_OUTPUT echo "path=/tmp/stable_changelog.md" >> $GITHUB_OUTPUT

View File

@@ -4,6 +4,30 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## v0.7.3
### Onboarding — focus MiniMax + MiMo
- L'étape `apikey` du wizard de premier lancement propose désormais **les deux clés** (MiniMax + MiMo) côte à côte ; au moins une doit être validée pour continuer.
- Les autres fournisseurs (OpenAI, Anthropic, Z.AI, Ollama) ne sont plus proposés dans le wizard — l'utilisateur les configure ensuite via l'onglet **Configuration** s'il le souhaite. Justification : pour les nouveaux utilisateurs, deux choix simples > six choix qui ralentissent le démarrage.
- Si MiniMax est validé, il devient le provider actif. Sinon, c'est MiMo. Si les deux sont validés, MiniMax reste actif (peut être basculé via `/model change` plus tard).
### Install Windows — pas d'admin + raccourcis automatiques
- **Avant** : la 3ᵉ ligne du snippet d'install (`Move-Item ... C:\Windows\muyue.exe`) échouait avec `UnauthorizedAccessException` sur PowerShell sans élévation.
- **Maintenant** : 4 lignes, toutes exécutables sans admin :
```powershell
$dest = "$env:LOCALAPPDATA\Muyue"
Invoke-WebRequest -Uri ".../muyue-windows-amd64.zip" -OutFile "$env:TEMP\muyue.zip"
Expand-Archive -Path "$env:TEMP\muyue.zip" -DestinationPath $dest -Force
& "$dest\muyue-windows-amd64.exe" install-shortcuts
```
- Nouvelle commande `muyue install-shortcuts` (Windows uniquement) :
- crée `Muyue.lnk` sur le Bureau et dans le Menu Démarrer (résolus via `[Environment]::GetFolderPath`, robuste OneDrive / profils non-standards) ;
- utilise WScript.Shell COM via PowerShell pour générer les `.lnk` (pas de dépendance Go ajoutée) ;
- ajoute le dossier d'install au `PATH` utilisateur (scope User, pas de modif système).
- Une icône custom pourra être branchée plus tard en remplaçant la ressource embed du `.exe` ; pour l'instant, l'icône Windows par défaut du binaire est utilisée.
## v0.7.2 ## v0.7.2
### Amélioration ### Amélioration

View File

@@ -0,0 +1,151 @@
package commands
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"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(" Executable : %s\n", exe)
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, exe, 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, exe, 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 (open a new terminal to pick it up)\n", installDir)
}
fmt.Println("\nDone — double-click the Muyue icon on your Desktop to launch.")
return nil
},
}
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
}

View File

@@ -7,7 +7,7 @@ import (
const ( const (
Name = "muyue" Name = "muyue"
Version = "0.7.2" Version = "0.7.3"
Author = "La Légion de Muyue" Author = "La Légion de Muyue"
) )

View File

@@ -23,8 +23,12 @@ export default function OnboardingWizard({ api, onComplete }) {
language: 'fr', language: 'fr',
keyboard: 'azerty', keyboard: 'azerty',
apikey: '', apikey: '',
apikey_mimo: '',
editor: '', editor: '',
}) })
const [keyValidMimo, setKeyValidMimo] = useState(false)
const [errorMimo, setErrorMimo] = useState(null)
const [validatingMimo, setValidatingMimo] = useState(false)
const [editorList, setEditorList] = useState(BASE_EDITORS) const [editorList, setEditorList] = useState(BASE_EDITORS)
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [error, setError] = useState(null) const [error, setError] = useState(null)
@@ -52,7 +56,7 @@ export default function OnboardingWizard({ api, onComplete }) {
case 'name': return answers.name.trim().length > 0 case 'name': return answers.name.trim().length > 0
case 'language': return !!answers.language case 'language': return !!answers.language
case 'keyboard': return !!answers.keyboard case 'keyboard': return !!answers.keyboard
case 'apikey': return keyValid && !scanning case 'apikey': return (keyValid || keyValidMimo) && !scanning
case 'editor': return true case 'editor': return true
case 'done': return true case 'done': return true
default: return true default: return true
@@ -173,6 +177,33 @@ export default function OnboardingWizard({ api, onComplete }) {
setValidating(false) setValidating(false)
} }
const handleValidateKeyMimo = async () => {
if (!answers.apikey_mimo.trim()) return
setValidatingMimo(true)
setErrorMimo(null)
try {
await api.validateProvider({
name: 'mimo',
api_key: answers.apikey_mimo,
model: 'mimo-v2.5-pro',
base_url: 'https://token-plan-ams.xiaomimimo.com/v1',
})
setKeyValidMimo(true)
// Save MiMo. If MiniMax wasn't validated yet, MiMo becomes the active provider.
await api.saveProvider({
name: 'mimo',
api_key: answers.apikey_mimo,
model: 'mimo-v2.5-pro',
base_url: 'https://token-plan-ams.xiaomimimo.com/v1',
active: !keyValid,
})
} catch (err) {
setErrorMimo(err.message || 'Clé invalide')
setKeyValidMimo(false)
}
setValidatingMimo(false)
}
const handleSave = async () => { const handleSave = async () => {
@@ -201,6 +232,15 @@ export default function OnboardingWizard({ api, onComplete }) {
active: true, active: true,
}) })
} }
if (answers.apikey_mimo.trim()) {
await api.saveProvider({
name: 'mimo',
api_key: answers.apikey_mimo,
model: 'mimo-v2.5-pro',
base_url: 'https://token-plan-ams.xiaomimimo.com/v1',
active: !answers.apikey.trim(),
})
}
onComplete() onComplete()
} catch (err) { } catch (err) {
setError(err.message || 'Erreur lors de la sauvegarde') setError(err.message || 'Erreur lors de la sauvegarde')
@@ -283,38 +323,71 @@ export default function OnboardingWizard({ api, onComplete }) {
{current.key === 'apikey' && ( {current.key === 'apikey' && (
<div className="onboarding-step"> <div className="onboarding-step">
<div className="onboarding-title">Clé API MiniMax</div> <div className="onboarding-title">Clés API</div>
<div className="onboarding-desc"> <div className="onboarding-desc">
Entrez votre clé API MiniMax pour activer l'assistant IA. La clé est obligatoire pour continuer. Renseignez au moins l'une des deux clés pour activer l'assistant. Les autres fournisseurs (OpenAI, Anthropic, Ollama, Z.AI) se configurent plus tard depuis l'onglet Configuration.
</div> </div>
<input
className="onboarding-input" <div style={{ display: 'flex', flexDirection: 'column', gap: 6, marginTop: 4 }}>
placeholder="sk-xxxxxxxxxxxxxxxx" <label style={{ fontSize: 12, color: 'var(--text-tertiary)', fontWeight: 600 }}>MiniMax</label>
type="password" <input
value={answers.apikey} className="onboarding-input"
onChange={e => { setAnswers(a => ({ ...a, apikey: e.target.value })); setKeyValid(false); setError(null) }} placeholder="sk-xxxxxxxxxxxxxxxx (MiniMax)"
autoFocus type="password"
/> value={answers.apikey}
{error && !keyValid && <div className="onboarding-required">{error}</div>} onChange={e => { setAnswers(a => ({ ...a, apikey: e.target.value })); setKeyValid(false); setError(null) }}
{keyValid && !scanning && <div className="onboarding-valid">Clé valide ✓ — Appuyez sur Entrée pour continuer</div>} autoFocus
/>
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
<button
className="sm primary"
onClick={handleValidateKey}
disabled={validating || !answers.apikey.trim()}
>
{validating ? 'Validation...' : 'Valider MiniMax'}
</button>
{keyValid && <span className="onboarding-valid">✓ MiniMax OK</span>}
{error && !keyValid && <span className="onboarding-required">{error}</span>}
</div>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, marginTop: 12 }}>
<label style={{ fontSize: 12, color: 'var(--text-tertiary)', fontWeight: 600 }}>MiMo (Xiaomi)</label>
<input
className="onboarding-input"
placeholder="sk-xxxxxxxxxxxxxxxx (MiMo)"
type="password"
value={answers.apikey_mimo}
onChange={e => { setAnswers(a => ({ ...a, apikey_mimo: e.target.value })); setKeyValidMimo(false); setErrorMimo(null) }}
/>
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
<button
className="sm primary"
onClick={handleValidateKeyMimo}
disabled={validatingMimo || !answers.apikey_mimo.trim()}
>
{validatingMimo ? 'Validation...' : 'Valider MiMo'}
</button>
{keyValidMimo && <span className="onboarding-valid">✓ MiMo OK</span>}
{errorMimo && !keyValidMimo && <span className="onboarding-required">{errorMimo}</span>}
</div>
</div>
{scanning && ( {scanning && (
<div className="onboarding-scanning"> <div className="onboarding-scanning" style={{ marginTop: 8 }}>
<Loader size={14} className="spin-icon" /> <Loader size={14} className="spin-icon" />
<span>{scanMessage}</span> <span>{scanMessage}</span>
</div> </div>
)} )}
{requiredError && <div className="onboarding-required">Veuillez valider votre clé API pour continuer</div>} {requiredError && (
<div style={{ display: 'flex', gap: 8, marginTop: 4 }}> <div className="onboarding-required" style={{ marginTop: 8 }}>
<button Veuillez valider au moins une clé (MiniMax ou MiMo) pour continuer.
className="sm primary" </div>
onClick={handleValidateKey} )}
disabled={validating || !answers.apikey.trim()} {(keyValid || keyValidMimo) && !scanning && (
> <div className="onboarding-valid" style={{ marginTop: 8 }}>
{validating ? 'Validation...' : 'Valider la clé'} Au moins une clé est valide — appuyez sur Suivant pour continuer.
</button> </div>
</div>
{!keyValid && !error && answers.apikey.trim() && (
<div className="onboarding-hint">Entrez votre clé puis cliquez "Valider la clé"</div>
)} )}
</div> </div>
)} )}