500 lines
13 KiB
Go
500 lines
13 KiB
Go
package cli
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"flag"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
frameworkbootstrap "gitea.lclr.dev/AI/mcp-framework/bootstrap"
|
|
frameworkcli "gitea.lclr.dev/AI/mcp-framework/cli"
|
|
frameworkconfig "gitea.lclr.dev/AI/mcp-framework/config"
|
|
frameworkmanifest "gitea.lclr.dev/AI/mcp-framework/manifest"
|
|
frameworksecretstore "gitea.lclr.dev/AI/mcp-framework/secretstore"
|
|
frameworkupdate "gitea.lclr.dev/AI/mcp-framework/update"
|
|
|
|
"email-mcp/internal/mcpserver"
|
|
"email-mcp/internal/secretstore"
|
|
)
|
|
|
|
const (
|
|
binaryName = "email-mcp"
|
|
defaultProfileEnv = "EMAIL_MCP_PROFILE"
|
|
binaryDescription = "Local MCP server to read an IMAP mailbox."
|
|
)
|
|
|
|
type MCPRunner interface {
|
|
Run(ctx context.Context) error
|
|
}
|
|
|
|
type ConfigPrompter interface {
|
|
PromptCredential(ctx context.Context, existing secretstore.Credential, hasStoredPassword bool) (secretstore.Credential, error)
|
|
}
|
|
|
|
type profileConfigStore interface {
|
|
LoadDefault() (frameworkconfig.FileConfig[ProfileConfig], string, error)
|
|
SaveDefault(frameworkconfig.FileConfig[ProfileConfig]) (string, error)
|
|
}
|
|
|
|
type secretStore interface {
|
|
SetSecret(name, label, secret string) error
|
|
GetSecret(name string) (string, error)
|
|
DeleteSecret(name string) error
|
|
}
|
|
|
|
type manifestLoader func(startDir string) (frameworkmanifest.File, string, error)
|
|
type executableResolver func() (string, error)
|
|
|
|
type ProfileConfig struct {
|
|
Host string `json:"host"`
|
|
Username string `json:"username"`
|
|
}
|
|
|
|
type App struct {
|
|
prompter ConfigPrompter
|
|
configStore profileConfigStore
|
|
openSecretStore func() (secretStore, error)
|
|
newMailService func() mcpserver.MailService
|
|
newRunner func(secretstore.Credential, mcpserver.MailService, io.Reader, io.Writer, io.Writer) MCPRunner
|
|
loadManifest manifestLoader
|
|
resolveExecutable executableResolver
|
|
stdin io.Reader
|
|
stdout io.Writer
|
|
stderr io.Writer
|
|
version string
|
|
}
|
|
|
|
func NewApp() *App {
|
|
return BuildApp("dev")
|
|
}
|
|
|
|
func NewAppWithDependencies(
|
|
prompter ConfigPrompter,
|
|
configStore profileConfigStore,
|
|
openSecretStore func() (secretStore, error),
|
|
newMailService func() mcpserver.MailService,
|
|
newRunner func(secretstore.Credential, mcpserver.MailService, io.Reader, io.Writer, io.Writer) MCPRunner,
|
|
loadManifest manifestLoader,
|
|
resolveExecutable executableResolver,
|
|
stdin io.Reader,
|
|
stdout io.Writer,
|
|
stderr io.Writer,
|
|
version string,
|
|
) *App {
|
|
if stdin == nil {
|
|
stdin = strings.NewReader("")
|
|
}
|
|
if stdout == nil {
|
|
stdout = io.Discard
|
|
}
|
|
if stderr == nil {
|
|
stderr = io.Discard
|
|
}
|
|
if version == "" {
|
|
version = "dev"
|
|
}
|
|
|
|
return &App{
|
|
prompter: prompter,
|
|
configStore: configStore,
|
|
openSecretStore: openSecretStore,
|
|
newMailService: newMailService,
|
|
newRunner: newRunner,
|
|
loadManifest: loadManifest,
|
|
resolveExecutable: resolveExecutable,
|
|
stdin: stdin,
|
|
stdout: stdout,
|
|
stderr: stderr,
|
|
version: version,
|
|
}
|
|
}
|
|
|
|
func (a *App) Run(args []string) error {
|
|
if isDoctorHelpCommand(args) {
|
|
return a.printDoctorHelp()
|
|
}
|
|
|
|
if len(args) > 0 && strings.TrimSpace(args[0]) == "doctor" {
|
|
return a.runDoctor(context.Background(), args[1:])
|
|
}
|
|
|
|
if isGlobalHelpCommand(args) {
|
|
return a.printGlobalHelp()
|
|
}
|
|
|
|
return a.runBootstrap(context.Background(), args)
|
|
}
|
|
|
|
func (a *App) runBootstrap(ctx context.Context, args []string) error {
|
|
return frameworkbootstrap.Run(ctx, frameworkbootstrap.Options{
|
|
BinaryName: binaryName,
|
|
Description: binaryDescription,
|
|
Version: a.version,
|
|
Args: args,
|
|
Stdin: a.stdin,
|
|
Stdout: a.stdout,
|
|
Stderr: a.stderr,
|
|
Hooks: frameworkbootstrap.Hooks{
|
|
Setup: func(ctx context.Context, inv frameworkbootstrap.Invocation) error {
|
|
return a.runConfig(ctx, frameworkbootstrap.CommandSetup, inv.Args)
|
|
},
|
|
MCP: func(ctx context.Context, inv frameworkbootstrap.Invocation) error {
|
|
return a.runMCP(ctx, inv.Args)
|
|
},
|
|
Config: func(ctx context.Context, inv frameworkbootstrap.Invocation) error {
|
|
return a.runConfig(ctx, frameworkbootstrap.CommandConfig, inv.Args)
|
|
},
|
|
Update: func(ctx context.Context, inv frameworkbootstrap.Invocation) error {
|
|
return a.runUpdate(ctx, inv.Args)
|
|
},
|
|
},
|
|
})
|
|
}
|
|
|
|
func isGlobalHelpCommand(args []string) bool {
|
|
if len(args) == 0 {
|
|
return true
|
|
}
|
|
if len(args) != 1 {
|
|
return false
|
|
}
|
|
|
|
switch strings.TrimSpace(args[0]) {
|
|
case "help", "-h", "--help":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func isDoctorHelpCommand(args []string) bool {
|
|
if len(args) != 2 {
|
|
return false
|
|
}
|
|
|
|
first := strings.TrimSpace(args[0])
|
|
second := strings.TrimSpace(args[1])
|
|
|
|
if first == "help" && second == "doctor" {
|
|
return true
|
|
}
|
|
if first == "doctor" && (second == "-h" || second == "--help") {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (a *App) printGlobalHelp() error {
|
|
if _, err := fmt.Fprintf(a.stdout, "%s\n\n", binaryDescription); err != nil {
|
|
return err
|
|
}
|
|
if _, err := fmt.Fprintf(a.stdout, "Usage:\n %s <command> [args]\n\n", binaryName); err != nil {
|
|
return err
|
|
}
|
|
if _, err := fmt.Fprintln(a.stdout, "Common commands:"); err != nil {
|
|
return err
|
|
}
|
|
|
|
commands := []struct {
|
|
name string
|
|
description string
|
|
}{
|
|
{name: "setup", description: "Initialize or update local configuration."},
|
|
{name: "mcp", description: "Run the MCP server over stdio."},
|
|
{name: "config", description: "Inspect or update configuration."},
|
|
{name: "doctor", description: "Run local diagnostics."},
|
|
{name: "update", description: "Run the self-update flow."},
|
|
{name: "version", description: "Print the binary version."},
|
|
}
|
|
for _, command := range commands {
|
|
if _, err := fmt.Fprintf(a.stdout, " %-7s %s\n", command.name, command.description); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
_, err := fmt.Fprintf(a.stdout, "\nDetailed help: %s help <command>\n", binaryName)
|
|
return err
|
|
}
|
|
|
|
func (a *App) printDoctorHelp() error {
|
|
_, err := fmt.Fprintf(
|
|
a.stdout,
|
|
"Usage:\n %s doctor [--profile NAME]\n\nRun local diagnostics for config, wallet, manifest, and IMAP connectivity.\n",
|
|
binaryName,
|
|
)
|
|
return err
|
|
}
|
|
|
|
func (a *App) runConfig(ctx context.Context, command string, args []string) error {
|
|
if a.prompter == nil {
|
|
return fmt.Errorf("config prompter is not configured")
|
|
}
|
|
if a.configStore == nil {
|
|
return fmt.Errorf("config store is not configured")
|
|
}
|
|
if a.openSecretStore == nil {
|
|
return fmt.Errorf("secret store is not configured")
|
|
}
|
|
|
|
profileFlag, err := parseProfileArgs(command, args)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
cfg, _, err := a.configStore.LoadDefault()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
profileName := frameworkcli.ResolveProfileName(profileFlag, os.Getenv(defaultProfileEnv), cfg.CurrentProfile)
|
|
profile := cfg.Profiles[profileName]
|
|
|
|
secrets, err := a.openSecretStore()
|
|
if err != nil {
|
|
return mapAppError(err)
|
|
}
|
|
|
|
storedPassword, hasStoredPassword, err := loadStoredPassword(secrets, profileName)
|
|
if err != nil {
|
|
return mapAppError(err)
|
|
}
|
|
|
|
cred, err := a.prompter.PromptCredential(ctx, secretstore.Credential{
|
|
Host: profile.Host,
|
|
Username: profile.Username,
|
|
Password: storedPassword,
|
|
}, hasStoredPassword)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := cred.Validate(); err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := secrets.SetSecret(passwordSecretName(profileName), "IMAP password", cred.Password); err != nil {
|
|
return mapAppError(err)
|
|
}
|
|
|
|
if cfg.Profiles == nil {
|
|
cfg.Profiles = map[string]ProfileConfig{}
|
|
}
|
|
cfg.CurrentProfile = profileName
|
|
cfg.Profiles[profileName] = ProfileConfig{
|
|
Host: cred.Host,
|
|
Username: cred.Username,
|
|
}
|
|
configPath, err := a.configStore.SaveDefault(cfg)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
fmt.Fprintf(a.stdout, "profile %q saved to %s\n", profileName, configPath)
|
|
return nil
|
|
}
|
|
|
|
func (a *App) runMCP(ctx context.Context, args []string) error {
|
|
if a.newRunner == nil {
|
|
return fmt.Errorf("mcp runner is not configured")
|
|
}
|
|
if a.newMailService == nil {
|
|
return fmt.Errorf("mail service is not configured")
|
|
}
|
|
|
|
profileFlag, err := parseProfileArgs("mcp", args)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
cred, err := a.loadCredential(profileFlag)
|
|
if err != nil {
|
|
return mapAppError(err)
|
|
}
|
|
|
|
runner := a.newRunner(cred, a.newMailService(), a.stdin, a.stdout, a.stderr)
|
|
if runner == nil {
|
|
return fmt.Errorf("mcp runner is not configured")
|
|
}
|
|
|
|
return mapAppError(runner.Run(ctx))
|
|
}
|
|
|
|
func (a *App) runUpdate(ctx context.Context, args []string) error {
|
|
if a.loadManifest == nil {
|
|
return fmt.Errorf("manifest loader is not configured")
|
|
}
|
|
if a.resolveExecutable == nil {
|
|
return fmt.Errorf("executable resolver is not configured")
|
|
}
|
|
|
|
if err := parseUpdateArgs(args); err != nil {
|
|
return err
|
|
}
|
|
|
|
executablePath, err := a.resolveExecutable()
|
|
if err != nil {
|
|
return fmt.Errorf("resolve executable path: %w", err)
|
|
}
|
|
|
|
manifestFile, err := a.loadManifestForExecutable(executablePath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return frameworkupdate.Run(ctx, frameworkupdate.Options{
|
|
CurrentVersion: a.version,
|
|
ExecutablePath: executablePath,
|
|
BinaryName: binaryName,
|
|
ReleaseSource: manifestFile.Update.ReleaseSource(),
|
|
Stdout: a.stdout,
|
|
})
|
|
}
|
|
|
|
func (a *App) loadManifestForExecutable(executablePath string) (frameworkmanifest.File, error) {
|
|
searchDirs := []string{filepath.Dir(executablePath), "."}
|
|
var firstErr error
|
|
|
|
for _, dir := range searchDirs {
|
|
file, _, err := a.loadManifest(dir)
|
|
if err == nil {
|
|
return file, nil
|
|
}
|
|
if firstErr == nil {
|
|
firstErr = err
|
|
}
|
|
}
|
|
|
|
return frameworkmanifest.File{}, fmt.Errorf("load manifest: %w", firstErr)
|
|
}
|
|
|
|
func (a *App) loadCredential(profileFlag string) (secretstore.Credential, error) {
|
|
if a.configStore == nil {
|
|
return secretstore.Credential{}, fmt.Errorf("config store is not configured")
|
|
}
|
|
if a.openSecretStore == nil {
|
|
return secretstore.Credential{}, fmt.Errorf("secret store is not configured")
|
|
}
|
|
|
|
cfg, _, err := a.configStore.LoadDefault()
|
|
if err != nil {
|
|
return secretstore.Credential{}, err
|
|
}
|
|
|
|
profileName := frameworkcli.ResolveProfileName(profileFlag, os.Getenv(defaultProfileEnv), cfg.CurrentProfile)
|
|
profile, ok := cfg.Profiles[profileName]
|
|
if !ok {
|
|
return secretstore.Credential{}, fmt.Errorf("%w: profile %q", mcpserver.ErrCredentialsNotConfigured, profileName)
|
|
}
|
|
|
|
secrets, err := a.openSecretStore()
|
|
if err != nil {
|
|
return secretstore.Credential{}, err
|
|
}
|
|
|
|
password, _, err := loadStoredPassword(secrets, profileName)
|
|
if err != nil {
|
|
if errors.Is(err, frameworksecretstore.ErrNotFound) {
|
|
return secretstore.Credential{}, fmt.Errorf("%w: profile %q", mcpserver.ErrCredentialsNotConfigured, profileName)
|
|
}
|
|
return secretstore.Credential{}, err
|
|
}
|
|
|
|
cred := secretstore.Credential{
|
|
Host: profile.Host,
|
|
Username: profile.Username,
|
|
Password: password,
|
|
}
|
|
if err := cred.Validate(); err != nil {
|
|
return secretstore.Credential{}, fmt.Errorf("%w: profile %q is incomplete", mcpserver.ErrCredentialsNotConfigured, profileName)
|
|
}
|
|
|
|
return cred, nil
|
|
}
|
|
|
|
func loadStoredPassword(store secretStore, profileName string) (string, bool, error) {
|
|
password, err := store.GetSecret(passwordSecretName(profileName))
|
|
if err != nil {
|
|
if errors.Is(err, frameworksecretstore.ErrNotFound) {
|
|
return "", false, nil
|
|
}
|
|
return "", false, err
|
|
}
|
|
|
|
return password, true, nil
|
|
}
|
|
|
|
func passwordSecretName(profileName string) string {
|
|
return "imap-password/" + strings.TrimSpace(profileName)
|
|
}
|
|
|
|
func parseProfileArgs(command string, args []string) (string, error) {
|
|
flagSet := flag.NewFlagSet(command, flag.ContinueOnError)
|
|
flagSet.SetOutput(io.Discard)
|
|
|
|
profile := flagSet.String("profile", "", "")
|
|
if err := flagSet.Parse(args); err != nil {
|
|
return "", fmt.Errorf("usage: email-mcp %s [--profile NAME]", command)
|
|
}
|
|
if flagSet.NArg() != 0 {
|
|
return "", fmt.Errorf("usage: email-mcp %s [--profile NAME]", command)
|
|
}
|
|
|
|
return strings.TrimSpace(*profile), nil
|
|
}
|
|
|
|
func parseUpdateArgs(args []string) error {
|
|
flagSet := flag.NewFlagSet("update", flag.ContinueOnError)
|
|
flagSet.SetOutput(io.Discard)
|
|
|
|
if err := flagSet.Parse(args); err != nil {
|
|
return fmt.Errorf("usage: email-mcp update")
|
|
}
|
|
if flagSet.NArg() != 0 {
|
|
return fmt.Errorf("usage: email-mcp update")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func mapAppError(err error) error {
|
|
if err == nil {
|
|
return nil
|
|
}
|
|
|
|
switch {
|
|
case errors.Is(err, mcpserver.ErrCredentialsNotConfigured):
|
|
return newUserFacingError("credentials not configured; run `email-mcp config`", err)
|
|
case errors.Is(err, frameworksecretstore.ErrBackendUnavailable):
|
|
return newUserFacingError(
|
|
fmt.Sprintf("%s is not available; configure a supported OS wallet and retry", frameworksecretstore.BackendName()),
|
|
err,
|
|
)
|
|
case errors.Is(err, frameworksecretstore.ErrReadOnly):
|
|
return newUserFacingError("secret backend is read-only", err)
|
|
default:
|
|
return err
|
|
}
|
|
}
|
|
|
|
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,
|
|
}
|
|
}
|