feat: add cli setup prompting and mcp dispatch
This commit is contained in:
parent
e63e67178a
commit
f5f13c247d
4 changed files with 303 additions and 9 deletions
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
71
internal/cli/setup.go
Normal 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
|
||||
}
|
||||
39
internal/cli/setup_test.go
Normal file
39
internal/cli/setup_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue