2026-04-10 07:34:33 +00:00
|
|
|
package cli
|
|
|
|
|
|
2026-04-10 07:45:35 +00:00
|
|
|
import (
|
2026-04-10 08:08:21 +00:00
|
|
|
"context"
|
2026-04-10 08:47:52 +00:00
|
|
|
"errors"
|
2026-04-10 07:45:35 +00:00
|
|
|
"fmt"
|
2026-04-10 08:08:21 +00:00
|
|
|
"io"
|
|
|
|
|
"os"
|
2026-04-10 07:34:33 +00:00
|
|
|
|
2026-04-10 07:45:35 +00:00
|
|
|
"email-mcp/internal/secretstore"
|
2026-04-10 08:47:52 +00:00
|
|
|
"email-mcp/internal/secretstore/kwallet"
|
2026-04-10 07:45:35 +00:00
|
|
|
)
|
2026-04-10 07:34:33 +00:00
|
|
|
|
2026-04-10 08:08:21 +00:00
|
|
|
type MCPRunner interface {
|
|
|
|
|
Run(ctx context.Context) error
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-10 07:45:35 +00:00
|
|
|
type App struct {
|
2026-04-10 08:08:21 +00:00
|
|
|
prompter SetupPrompter
|
|
|
|
|
store secretstore.Store
|
|
|
|
|
runner MCPRunner
|
|
|
|
|
stderr io.Writer
|
2026-04-10 07:45:35 +00:00
|
|
|
}
|
|
|
|
|
|
2026-04-10 07:54:38 +00:00
|
|
|
func NewApp() *App {
|
2026-04-10 08:08:21 +00:00
|
|
|
return NewAppWithDependencies(nil, nil, nil, os.Stderr)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func NewAppWithDependencies(prompter SetupPrompter, store secretstore.Store, runner MCPRunner, stderr io.Writer) *App {
|
|
|
|
|
if stderr == nil {
|
|
|
|
|
stderr = io.Discard
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return &App{
|
|
|
|
|
prompter: prompter,
|
|
|
|
|
store: store,
|
|
|
|
|
runner: runner,
|
|
|
|
|
stderr: stderr,
|
|
|
|
|
}
|
2026-04-10 07:34:33 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (a *App) Run(args []string) error {
|
|
|
|
|
if len(args) == 0 {
|
|
|
|
|
return fmt.Errorf("usage: email-mcp <setup|mcp>")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
switch args[0] {
|
2026-04-10 08:08:21 +00:00
|
|
|
case "setup":
|
|
|
|
|
return a.runSetup(context.Background())
|
|
|
|
|
case "mcp":
|
|
|
|
|
return a.runMCP(context.Background())
|
2026-04-10 07:34:33 +00:00
|
|
|
default:
|
|
|
|
|
return fmt.Errorf("unknown command: %s", args[0])
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-04-10 08:08:21 +00:00
|
|
|
|
|
|
|
|
func (a *App) runSetup(ctx context.Context) error {
|
|
|
|
|
if a.prompter == nil {
|
|
|
|
|
return fmt.Errorf("setup prompter is not configured")
|
|
|
|
|
}
|
|
|
|
|
if a.store == nil {
|
|
|
|
|
return fmt.Errorf("secret store is not configured")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
cred, err := a.prompter.PromptSetup(ctx)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
if err := cred.Validate(); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
if err := a.store.Save(ctx, secretstore.DefaultAccountKey, cred); err != nil {
|
2026-04-10 08:47:52 +00:00
|
|
|
return mapAppError(err)
|
2026-04-10 08:08:21 +00:00
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (a *App) runMCP(ctx context.Context) error {
|
|
|
|
|
if a.runner == nil {
|
|
|
|
|
return fmt.Errorf("mcp runner is not configured")
|
|
|
|
|
}
|
2026-04-10 08:47:52 +00:00
|
|
|
return mapAppError(a.runner.Run(ctx))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func mapAppError(err error) error {
|
|
|
|
|
if err == nil {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
switch {
|
|
|
|
|
case errors.Is(err, kwallet.ErrKWalletUnavailable):
|
2026-04-10 08:58:51 +00:00
|
|
|
return newUserFacingError("kwallet is not available; make sure KDE Wallet is installed and your session D-Bus is running", err)
|
2026-04-10 08:47:52 +00:00
|
|
|
case errors.Is(err, kwallet.ErrKWalletDisabled):
|
2026-04-10 08:58:51 +00:00
|
|
|
return newUserFacingError("kwallet is disabled in this KDE session", err)
|
2026-04-10 08:47:52 +00:00
|
|
|
case errors.Is(err, kwallet.ErrKWalletOpenFailed):
|
2026-04-10 08:58:51 +00:00
|
|
|
return newUserFacingError("kwallet could not be opened; unlock the wallet and try again", err)
|
2026-04-10 08:47:52 +00:00
|
|
|
case errors.Is(err, kwallet.ErrCredentialNotFound):
|
2026-04-10 08:58:51 +00:00
|
|
|
return newUserFacingError("credentials not configured; run `email-mcp setup`", err)
|
2026-04-10 08:47:52 +00:00
|
|
|
default:
|
|
|
|
|
return err
|
|
|
|
|
}
|
2026-04-10 08:08:21 +00:00
|
|
|
}
|
2026-04-10 08:58:51 +00:00
|
|
|
|
|
|
|
|
type userFacingError struct {
|
|
|
|
|
message string
|
|
|
|
|
err error
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (e *userFacingError) Error() string {
|
|
|
|
|
return e.message
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (e *userFacingError) Unwrap() error {
|
|
|
|
|
return e.err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func newUserFacingError(message string, err error) error {
|
|
|
|
|
return &userFacingError{
|
|
|
|
|
message: message,
|
|
|
|
|
err: err,
|
|
|
|
|
}
|
|
|
|
|
}
|