Files
hyprvoice/cmd/hyprvoice/main.go
T
2026-02-01 22:04:42 +01:00

490 lines
11 KiB
Go

package main
import (
"context"
"errors"
"fmt"
"os/exec"
"path/filepath"
"sort"
"strings"
"github.com/leonardotrapani/hyprvoice/internal/bus"
"github.com/leonardotrapani/hyprvoice/internal/config"
"github.com/leonardotrapani/hyprvoice/internal/daemon"
"github.com/leonardotrapani/hyprvoice/internal/models/whisper"
"github.com/leonardotrapani/hyprvoice/internal/provider"
"github.com/leonardotrapani/hyprvoice/internal/tui"
"github.com/spf13/cobra"
)
func main() {
_ = rootCmd.Execute()
}
var rootCmd = &cobra.Command{
Use: "hyprvoice",
Short: "Voice-powered typing for Wayland/Hyprland",
}
func init() {
rootCmd.AddCommand(
serveCmd(),
toggleCmd(),
cancelCmd(),
statusCmd(),
versionCmd(),
stopCmd(),
onboardingCmd(),
configureCmd(),
modelCmd(),
)
}
func serveCmd() *cobra.Command {
return &cobra.Command{
Use: "serve",
Short: "Run the daemon",
RunE: func(cmd *cobra.Command, args []string) error {
d, err := daemon.New()
if err != nil {
return fmt.Errorf("failed to create daemon: %w", err)
}
return d.Run()
},
}
}
func toggleCmd() *cobra.Command {
return &cobra.Command{
Use: "toggle",
Short: "Toggle recording on/off",
RunE: func(cmd *cobra.Command, args []string) error {
resp, err := bus.SendCommand('t')
if err != nil {
return fmt.Errorf("failed to toggle recording: %w", err)
}
fmt.Print(resp)
return nil
},
}
}
func statusCmd() *cobra.Command {
return &cobra.Command{
Use: "status",
Short: "Get current recording status",
RunE: func(cmd *cobra.Command, args []string) error {
resp, err := bus.SendCommand('s')
if err != nil {
return fmt.Errorf("failed to get status: %w", err)
}
fmt.Print(resp)
return nil
},
}
}
func versionCmd() *cobra.Command {
return &cobra.Command{
Use: "version",
Short: "Get protocol version",
RunE: func(cmd *cobra.Command, args []string) error {
resp, err := bus.SendCommand('v')
if err != nil {
return fmt.Errorf("failed to get version: %w", err)
}
fmt.Print(resp)
return nil
},
}
}
func stopCmd() *cobra.Command {
return &cobra.Command{
Use: "stop",
Short: "Stop the daemon",
RunE: func(cmd *cobra.Command, args []string) error {
resp, err := bus.SendCommand('q')
if err != nil {
return fmt.Errorf("failed to stop daemon: %w", err)
}
fmt.Print(resp)
return nil
},
}
}
func cancelCmd() *cobra.Command {
return &cobra.Command{
Use: "cancel",
Short: "Cancel current operation",
RunE: func(cmd *cobra.Command, args []string) error {
resp, err := bus.SendCommand('c')
if err != nil {
return fmt.Errorf("failed to cancel operation: %w", err)
}
fmt.Print(resp)
return nil
},
}
}
func configureCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "configure",
Short: "Interactive configuration setup",
Long: `Interactive configuration wizard for hyprvoice.
This will guide you through setting up:
- Provider API keys (OpenAI, Groq, Mistral, ElevenLabs)
- Transcription settings
- LLM post-processing
- Text injection and notification preferences
For first-time setup, run 'hyprvoice onboarding'.`,
RunE: func(cmd *cobra.Command, args []string) error {
return runConfigure(false)
},
}
return cmd
}
func onboardingCmd() *cobra.Command {
return &cobra.Command{
Use: "onboarding",
Short: "Guided first-time setup",
Long: `Guided onboarding wizard for hyprvoice.
This will walk you through the full setup flow (excluding advanced options).`,
RunE: func(cmd *cobra.Command, args []string) error {
return runConfigure(true)
},
}
}
func runConfigure(onboarding bool) error {
var cfg *config.Config
var err error
if onboarding {
cfg, err = config.Load()
if err != nil {
if errors.Is(err, config.ErrConfigNotFound) {
cfg = config.DefaultConfig()
} else {
return fmt.Errorf("failed to load config: %w", err)
}
}
} else {
cfg, err = config.Load()
if err != nil {
return fmt.Errorf("failed to load config: %w", err)
}
}
// Run TUI wizard
result, err := tui.Run(cfg, onboarding)
if err != nil {
return fmt.Errorf("configuration wizard error: %w", err)
}
if result.Cancelled {
fmt.Println("Configuration cancelled.")
return nil
}
// Validate configuration
if err := result.Config.Validate(); err != nil {
fmt.Printf("Configuration validation failed: %v\n", err)
return err
}
// Save configuration
if err := config.Save(result.Config); err != nil {
return fmt.Errorf("failed to save config: %w", err)
}
fmt.Println()
fmt.Println("Configuration saved successfully!")
fmt.Println()
// Show next steps
showNextSteps(result.Config, onboarding)
return nil
}
func showNextSteps(cfg *config.Config, onboarding bool) {
// Check if service is running
serviceRunning := false
if _, err := exec.Command("systemctl", "--user", "is-active", "--quiet", "hyprvoice.service").CombinedOutput(); err == nil {
serviceRunning = true
}
// Check if ydotool is in backends
hasYdotool := false
for _, b := range cfg.Injection.Backends {
if b == "ydotool" {
hasYdotool = true
break
}
}
fmt.Println("Next Steps:")
step := 1
if hasYdotool {
fmt.Printf("%d. Ensure ydotoold is running\n", step)
step++
}
if serviceRunning {
fmt.Printf("%d. Restart the service to apply changes: systemctl --user restart hyprvoice.service\n", step)
step++
} else if onboarding {
fmt.Printf("%d. Start the service: systemctl --user start hyprvoice.service\n", step)
step++
} else {
fmt.Printf("%d. Start the service if it is not running\n", step)
step++
}
fmt.Printf("%d. Test voice input: hyprvoice toggle\n", step)
fmt.Println()
configPath, _ := config.GetConfigPath()
if onboarding {
configDir := filepath.Dir(configPath)
fmt.Printf("run hyprvoice configure to configure more, or check %s\n", configDir)
return
}
fmt.Printf("Config file location: %s\n", configPath)
}
func modelCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "model",
Short: "Manage transcription models",
}
cmd.AddCommand(modelListCmd())
cmd.AddCommand(modelDownloadCmd())
cmd.AddCommand(modelRemoveCmd())
return cmd
}
func modelListCmd() *cobra.Command {
var providerFilter string
var typeFilter string
cmd := &cobra.Command{
Use: "list",
Short: "List available transcription and LLM models",
RunE: func(cmd *cobra.Command, args []string) error {
return runModelList(providerFilter, typeFilter)
},
}
cmd.Flags().StringVar(&providerFilter, "provider", "", "filter by provider name")
cmd.Flags().StringVar(&typeFilter, "type", "", "filter by type: transcription, llm")
return cmd
}
func runModelList(providerFilter, typeFilter string) error {
// parse type filter
var filterType *provider.ModelType
if typeFilter != "" {
switch strings.ToLower(typeFilter) {
case "transcription":
t := provider.Transcription
filterType = &t
case "llm":
t := provider.LLM
filterType = &t
default:
return fmt.Errorf("invalid type: %s (use 'transcription' or 'llm')", typeFilter)
}
}
// get providers to iterate
providerNames := provider.ListProviders()
sort.Strings(providerNames)
// filter by provider if specified
if providerFilter != "" {
found := false
for _, name := range providerNames {
if name == providerFilter {
providerNames = []string{name}
found = true
break
}
}
if !found {
return fmt.Errorf("unknown provider: %s", providerFilter)
}
}
for _, providerName := range providerNames {
p := provider.GetProvider(providerName)
if p == nil {
continue
}
models := p.Models()
if filterType != nil {
models = provider.ModelsOfType(p, *filterType)
}
if len(models) == 0 {
continue
}
// print provider header
fmt.Printf("\n%s:\n", providerName)
for _, m := range models {
printModelLine(m)
}
}
fmt.Println()
return nil
}
func printModelLine(m provider.Model) {
// build prefix: checkmark for installed local models
prefix := " "
if m.Local {
if whisper.IsInstalled(m.ID) {
prefix = " [x]"
} else {
prefix = " [ ]"
}
}
// build suffix parts
var parts []string
// type indicator
if m.Type == provider.LLM {
parts = append(parts, "llm")
}
// mode capabilities indicator
if m.SupportsBothModes() {
parts = append(parts, "batch+streaming")
} else if m.SupportsStreaming {
parts = append(parts, "streaming")
}
// size for local models
if m.LocalInfo != nil && m.LocalInfo.Size != "" {
parts = append(parts, m.LocalInfo.Size)
}
// build line
line := fmt.Sprintf("%s %s", prefix, m.ID)
if m.Description != "" {
line += fmt.Sprintf(" - %s", m.Description)
}
if len(parts) > 0 {
line += fmt.Sprintf(" [%s]", strings.Join(parts, ", "))
}
fmt.Println(line)
}
func modelDownloadCmd() *cobra.Command {
return &cobra.Command{
Use: "download <model-name>",
Short: "Download a local model (e.g. whisper models)",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
return runModelDownload(cmd.Context(), args[0])
},
}
}
func runModelDownload(ctx context.Context, modelName string) error {
// find the model across all providers
model, _, err := provider.FindModelByID(modelName)
if err != nil {
return fmt.Errorf("unknown model: %s", modelName)
}
// check if it needs download (local model)
if !model.NeedsDownload() {
fmt.Printf("model '%s' is a cloud model and does not require download\n", modelName)
return nil
}
// check if already installed
if whisper.IsInstalled(modelName) {
path := whisper.GetModelPath(modelName)
fmt.Printf("model '%s' is already installed at %s\n", modelName, path)
return nil
}
// download with progress
fmt.Printf("downloading %s", modelName)
if model.LocalInfo != nil && model.LocalInfo.Size != "" {
fmt.Printf(" (%s)", model.LocalInfo.Size)
}
fmt.Println("...")
var lastPercent int
err = whisper.Download(ctx, modelName, func(downloaded, total int64) {
if total > 0 {
percent := int(downloaded * 100 / total)
if percent >= lastPercent+10 {
fmt.Printf("%d%% ", percent)
lastPercent = percent
}
}
})
if err != nil {
return fmt.Errorf("download failed: %w", err)
}
path := whisper.GetModelPath(modelName)
fmt.Printf("\ndownload complete: %s\n", path)
return nil
}
func modelRemoveCmd() *cobra.Command {
return &cobra.Command{
Use: "remove <model-name>",
Short: "Remove a downloaded local model",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
return runModelRemove(args[0])
},
}
}
func runModelRemove(modelName string) error {
// find the model across all providers
model, _, err := provider.FindModelByID(modelName)
if err != nil {
return fmt.Errorf("unknown model: %s", modelName)
}
// check if it's a cloud model (nothing to remove)
if !model.NeedsDownload() {
fmt.Printf("model '%s' is a cloud model, nothing to remove\n", modelName)
return nil
}
// check if installed
if !whisper.IsInstalled(modelName) {
return fmt.Errorf("model '%s' is not installed", modelName)
}
// remove the model
if err := whisper.Remove(modelName); err != nil {
return fmt.Errorf("failed to remove model: %w", err)
}
fmt.Printf("model '%s' removed successfully\n", modelName)
return nil
}