feat: add cli setup prompting and mcp dispatch

This commit is contained in:
thibaud-leclere 2026-04-10 10:08:21 +02:00
parent e63e67178a
commit f5f13c247d
4 changed files with 303 additions and 9 deletions

View file

@ -1,17 +1,40 @@
package cli
import (
"context"
"fmt"
"io"
"os"
"email-mcp/internal/secretstore"
)
type MCPRunner interface {
Run(ctx context.Context) error
}
type App struct {
store secretstore.Store
prompter SetupPrompter
store secretstore.Store
runner MCPRunner
stderr io.Writer
}
func NewApp() *App {
return &App{}
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,
}
}
func (a *App) Run(args []string) error {
@ -20,9 +43,39 @@ func (a *App) Run(args []string) error {
}
switch args[0] {
case "setup", "mcp":
return nil
case "setup":
return a.runSetup(context.Background())
case "mcp":
return a.runMCP(context.Background())
default:
return fmt.Errorf("unknown command: %s", args[0])
}
}
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 {
return err
}
return nil
}
func (a *App) runMCP(ctx context.Context) error {
if a.runner == nil {
return fmt.Errorf("mcp runner is not configured")
}
return a.runner.Run(ctx)
}

View file

@ -1,12 +1,59 @@
package cli
import (
"bytes"
"context"
"errors"
"strings"
"testing"
"email-mcp/internal/secretstore"
)
var _ func() *App = NewApp
type promptStub struct {
credential secretstore.Credential
err error
called bool
}
func (p *promptStub) PromptSetup(context.Context) (secretstore.Credential, error) {
p.called = true
if p.err != nil {
return secretstore.Credential{}, p.err
}
return p.credential, nil
}
type storeStub struct {
saved secretstore.Credential
savedKey string
saveErr error
saveCalled bool
}
func (s *storeStub) Save(_ context.Context, key string, cred secretstore.Credential) error {
s.saveCalled = true
s.savedKey = key
s.saved = cred
return s.saveErr
}
func (s *storeStub) Load(context.Context, string) (secretstore.Credential, error) {
return secretstore.Credential{}, nil
}
type runnerStub struct {
err error
called bool
}
func (r *runnerStub) Run(context.Context) error {
r.called = true
return r.err
}
func TestAppRunRejectsUnknownCommand(t *testing.T) {
app := NewApp()
@ -28,13 +75,97 @@ func TestAppRunShowsUsageWhenNoArgsProvided(t *testing.T) {
}
}
func TestAppRunAcceptsSetupAndMcp(t *testing.T) {
func TestAppRunSetupPromptsAndSavesDefaultCredential(t *testing.T) {
store := &storeStub{}
prompter := &promptStub{
credential: secretstore.Credential{
Host: "imap.example.com",
Username: "alice",
Password: "secret",
},
}
app := NewAppWithDependencies(prompter, store, nil, &bytes.Buffer{})
if err := app.Run([]string{"setup"}); err != nil {
t.Fatalf("Run returned error: %v", err)
}
if !prompter.called {
t.Fatal("expected setup prompter to be called")
}
if !store.saveCalled {
t.Fatal("expected store Save to be called")
}
if store.savedKey != secretstore.DefaultAccountKey {
t.Fatalf("expected saved key %q, got %q", secretstore.DefaultAccountKey, store.savedKey)
}
if store.saved.Host != "imap.example.com" || store.saved.Username != "alice" || store.saved.Password != "secret" {
t.Fatalf("unexpected saved credential: %#v", store.saved)
}
}
func TestAppRunSetupRejectsInvalidCredential(t *testing.T) {
store := &storeStub{}
app := NewAppWithDependencies(&promptStub{
credential: secretstore.Credential{
Host: "imap.example.com",
Username: "alice",
},
}, store, nil, &bytes.Buffer{})
err := app.Run([]string{"setup"})
if err == nil {
t.Fatal("expected setup to fail for invalid credential")
}
if !strings.Contains(err.Error(), "password is required") {
t.Fatalf("expected validation error, got %v", err)
}
if store.saveCalled {
t.Fatal("expected store Save not to be called when credential is invalid")
}
}
func TestAppRunReturnsPromptError(t *testing.T) {
expectedErr := errors.New("prompt failed")
app := NewAppWithDependencies(&promptStub{err: expectedErr}, &storeStub{}, nil, &bytes.Buffer{})
err := app.Run([]string{"setup"})
if !errors.Is(err, expectedErr) {
t.Fatalf("expected prompt error %v, got %v", expectedErr, err)
}
}
func TestAppRunMCPDelegatesToRunner(t *testing.T) {
runner := &runnerStub{}
app := NewAppWithDependencies(nil, nil, runner, &bytes.Buffer{})
if err := app.Run([]string{"mcp"}); err != nil {
t.Fatalf("expected mcp command to succeed, got %v", err)
}
if !runner.called {
t.Fatal("expected MCP runner to be called")
}
}
func TestAppRunReturnsClearErrorsWhenDependenciesMissing(t *testing.T) {
app := NewApp()
for _, command := range []string{"setup", "mcp"} {
t.Run(command, func(t *testing.T) {
if err := app.Run([]string{command}); err != nil {
t.Fatalf("expected %q to be accepted, got error: %v", command, err)
tests := []struct {
command string
want string
}{
{command: "setup", want: "setup prompter is not configured"},
{command: "mcp", want: "mcp runner is not configured"},
}
for _, tt := range tests {
t.Run(tt.command, func(t *testing.T) {
err := app.Run([]string{tt.command})
if err == nil {
t.Fatalf("expected %s to fail without dependencies", tt.command)
}
if !strings.Contains(err.Error(), tt.want) {
t.Fatalf("expected error to contain %q, got %v", tt.want, err)
}
})
}

71
internal/cli/setup.go Normal file
View file

@ -0,0 +1,71 @@
package cli
import (
"bufio"
"context"
"fmt"
"io"
"strings"
"email-mcp/internal/secretstore"
)
type SetupPrompter interface {
PromptSetup(ctx context.Context) (secretstore.Credential, error)
}
type InteractiveSetupPrompter struct {
input *bufio.Reader
output io.Writer
}
func NewInteractiveSetupPrompter(input io.Reader, output io.Writer) *InteractiveSetupPrompter {
if input == nil {
input = strings.NewReader("")
}
if output == nil {
output = io.Discard
}
return &InteractiveSetupPrompter{
input: bufio.NewReader(input),
output: output,
}
}
func (p *InteractiveSetupPrompter) PromptSetup(context.Context) (secretstore.Credential, error) {
host, err := p.prompt("IMAP host: ")
if err != nil {
return secretstore.Credential{}, err
}
username, err := p.prompt("Username: ")
if err != nil {
return secretstore.Credential{}, err
}
password, err := p.prompt("Password: ")
if err != nil {
return secretstore.Credential{}, err
}
cred := secretstore.Credential{
Host: host,
Username: username,
Password: password,
}
if err := cred.Validate(); err != nil {
return secretstore.Credential{}, err
}
return cred, nil
}
func (p *InteractiveSetupPrompter) prompt(label string) (string, error) {
if _, err := fmt.Fprint(p.output, label); err != nil {
return "", err
}
value, err := p.input.ReadString('\n')
if err != nil && err != io.EOF {
return "", err
}
return strings.TrimSpace(value), nil
}

View file

@ -0,0 +1,39 @@
package cli
import (
"bytes"
"context"
"strings"
"testing"
)
func TestInteractiveSetupPrompterPromptSetupCollectsCredential(t *testing.T) {
input := strings.NewReader(" imap.example.com \n alice \n secret \n")
output := &bytes.Buffer{}
prompter := NewInteractiveSetupPrompter(input, output)
cred, err := prompter.PromptSetup(context.Background())
if err != nil {
t.Fatalf("PromptSetup returned error: %v", err)
}
if cred.Host != "imap.example.com" || cred.Username != "alice" || cred.Password != "secret" {
t.Fatalf("unexpected credential: %#v", cred)
}
if got := output.String(); got != "IMAP host: Username: Password: " {
t.Fatalf("unexpected prompts: %q", got)
}
}
func TestInteractiveSetupPrompterPromptSetupRejectsMissingFields(t *testing.T) {
input := strings.NewReader("imap.example.com\nalice\n \n")
prompter := NewInteractiveSetupPrompter(input, &bytes.Buffer{})
_, err := prompter.PromptSetup(context.Background())
if err == nil {
t.Fatal("expected validation error")
}
if !strings.Contains(err.Error(), "password is required") {
t.Fatalf("expected password validation error, got %v", err)
}
}