From 781a5985ab0c73f84be09a21c0419f39127ac5f8 Mon Sep 17 00:00:00 2001 From: thibaud-leclere Date: Tue, 14 Apr 2026 11:21:01 +0200 Subject: [PATCH] feat: align CLI with mcp-framework v1.2.1 config commands --- README.md | 27 +++++++--- internal/cli/app.go | 90 ++++++++++++++++++++++++++++++++-- internal/cli/app_test.go | 95 ++++++++++++++++++++++++++++++------ internal/mcpserver/server.go | 2 +- 4 files changed, 189 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index 9980797..89118bc 100644 --- a/README.md +++ b/README.md @@ -12,8 +12,9 @@ Le binaire s’appuie maintenant sur [`mcp-framework`](../mcp-framework) pour : ## Commandes -- `email-mcp config` : configure un profil IMAP -- `email-mcp setup` : alias de compatibilité vers `config` +- `email-mcp setup` : configure (ou met à jour) un profil IMAP +- `email-mcp config show` : affiche la configuration IMAP résolue et la provenance +- `email-mcp config test` : lance les checks de configuration/connectivité (équivalent de `doctor`) - `email-mcp mcp` : lance le serveur MCP sur `stdin/stdout` - `email-mcp doctor` : diagnostique la configuration locale, le wallet, le manifeste et l’accès IMAP - `email-mcp update` : met à jour le binaire courant depuis la dernière release @@ -50,13 +51,13 @@ Les credentials IMAP sont résolus ensuite via le résolveur multi-sources du fr ### Configurer un profil ```sh -./email-mcp config +./email-mcp setup ``` Pour un profil nommé : ```sh -./email-mcp config --profile work +./email-mcp setup --profile work ``` Le binaire demande ensuite : @@ -82,7 +83,21 @@ Pour un profil nommé : Si aucun credential n’a été configuré pour le profil résolu, le serveur renvoie l’erreur : ```text -credentials not configured; run `email-mcp config` +credentials not configured; run `email-mcp setup` +``` + +### Inspecter la configuration résolue + +```sh +./email-mcp config show +./email-mcp config show --profile work +``` + +### Tester la configuration résolue + +```sh +./email-mcp config test +./email-mcp config test --profile work ``` ## Auto-update @@ -130,7 +145,7 @@ Ajoute le serveur MCP en pointant vers le binaire et la sous-commande `mcp` : claude mcp add email-mcp -- /absolute/path/to/bin/email-mcp mcp ``` -La configuration se fait une fois séparément via `email-mcp config`. +La configuration se fait une fois séparément via `email-mcp setup`. ### Configuration JSON manuelle diff --git a/internal/cli/app.go b/internal/cli/app.go index 2a91b50..fec561e 100644 --- a/internal/cli/app.go +++ b/internal/cli/app.go @@ -148,8 +148,11 @@ func (a *App) runBootstrap(ctx context.Context, args []string) error { 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) + ConfigShow: func(ctx context.Context, inv frameworkbootstrap.Invocation) error { + return a.runConfigShow(ctx, inv.Args) + }, + ConfigTest: func(ctx context.Context, inv frameworkbootstrap.Invocation) error { + return a.runConfigTest(ctx, inv.Args) }, Update: func(ctx context.Context, inv frameworkbootstrap.Invocation) error { return a.runUpdate(ctx, inv.Args) @@ -208,7 +211,7 @@ func (a *App) printGlobalHelp() error { }{ {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: "config", description: "Inspect or test resolved configuration."}, {name: "doctor", description: "Run local diagnostics."}, {name: "update", description: "Run the self-update flow."}, {name: "version", description: "Print the binary version."}, @@ -299,6 +302,64 @@ func (a *App) runConfig(ctx context.Context, command string, args []string) erro return nil } +func (a *App) runConfigShow(ctx context.Context, args []string) error { + 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("config show", 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) + } + + resolution, err := resolveCredentialFields(profile, secrets, credentialFieldSpecs(profileName)) + if err != nil { + var missingErr *frameworkcli.MissingRequiredValuesError + if !errors.As(err, &missingErr) { + return mapAppError(err) + } + } + + host, _ := resolution.Get("host") + username, _ := resolution.Get("username") + password, _ := resolution.Get("password") + + if _, err := fmt.Fprintf(a.stdout, "profile: %s\n", profileName); err != nil { + return err + } + if _, err := fmt.Fprintf(a.stdout, "host: %s (%s)\n", renderVisibleField(host), renderSource(host)); err != nil { + return err + } + if _, err := fmt.Fprintf(a.stdout, "username: %s (%s)\n", renderVisibleField(username), renderSource(username)); err != nil { + return err + } + if _, err := fmt.Fprintf(a.stdout, "password: %s (%s)\n", renderSecretField(password), renderSource(password)); err != nil { + return err + } + + return nil +} + +func (a *App) runConfigTest(ctx context.Context, args []string) error { + return a.runDoctor(ctx, args) +} + func (a *App) runMCP(ctx context.Context, args []string) error { if a.newRunner == nil { return fmt.Errorf("mcp runner is not configured") @@ -594,7 +655,7 @@ func mapAppError(err error) error { switch { case errors.Is(err, mcpserver.ErrCredentialsNotConfigured): - return newUserFacingError("credentials not configured; run `email-mcp config`", err) + return newUserFacingError("credentials not configured; run `email-mcp setup`", err) case errors.Is(err, frameworksecretstore.ErrBackendUnavailable): return newUserFacingError( fmt.Sprintf("%s is not available; configure a supported OS wallet and retry", frameworksecretstore.BackendName()), @@ -607,6 +668,27 @@ func mapAppError(err error) error { } } +func renderSource(field frameworkcli.ResolvedField) string { + if !field.Found { + return "missing" + } + return string(field.Source) +} + +func renderVisibleField(field frameworkcli.ResolvedField) string { + if !field.Found || strings.TrimSpace(field.Value) == "" { + return "" + } + return field.Value +} + +func renderSecretField(field frameworkcli.ResolvedField) string { + if !field.Found || strings.TrimSpace(field.Value) == "" { + return "" + } + return "" +} + type userFacingError struct { message string err error diff --git a/internal/cli/app_test.go b/internal/cli/app_test.go index 612a75c..fe6da00 100644 --- a/internal/cli/app_test.go +++ b/internal/cli/app_test.go @@ -181,7 +181,7 @@ func TestAppRunDoctorHelp(t *testing.T) { } } -func TestAppRunConfigPromptsAndSavesProfile(t *testing.T) { +func TestAppRunSetupPromptsAndSavesProfile(t *testing.T) { prompter := &configPrompterStub{ credential: secretstore.Credential{ Host: "imap.example.com", @@ -207,7 +207,7 @@ func TestAppRunConfigPromptsAndSavesProfile(t *testing.T) { "dev", ) - if err := app.Run([]string{"config"}); err != nil { + if err := app.Run([]string{"setup"}); err != nil { t.Fatalf("Run returned error: %v", err) } @@ -234,7 +234,7 @@ func TestAppRunConfigPromptsAndSavesProfile(t *testing.T) { } } -func TestAppRunSetupAliasesConfig(t *testing.T) { +func TestAppRunConfigRequiresSubcommand(t *testing.T) { prompter := &configPrompterStub{ credential: secretstore.Credential{ Host: "imap.example.com", @@ -259,15 +259,19 @@ func TestAppRunSetupAliasesConfig(t *testing.T) { "dev", ) - if err := app.Run([]string{"setup"}); err != nil { - t.Fatalf("setup returned error: %v", err) + err := app.Run([]string{"config"}) + if err == nil { + t.Fatal("expected config without subcommand to fail") } - if !cfgStore.saveCalled { - t.Fatal("expected setup to save config via config command") + if !strings.Contains(err.Error(), "subcommand is required") { + t.Fatalf("unexpected error: %v", err) + } + if cfgStore.saveCalled { + t.Fatal("config without subcommand must not save configuration") } } -func TestAppRunConfigUsesStoredValuesAsDefaults(t *testing.T) { +func TestAppRunSetupUsesStoredValuesAsDefaults(t *testing.T) { prompter := &capturingPrompterStub{ credential: secretstore.Credential{ Host: "imap.example.com", @@ -307,8 +311,8 @@ func TestAppRunConfigUsesStoredValuesAsDefaults(t *testing.T) { "dev", ) - if err := app.Run([]string{"config"}); err != nil { - t.Fatalf("config returned error: %v", err) + if err := app.Run([]string{"setup"}); err != nil { + t.Fatalf("setup returned error: %v", err) } if !prompter.hasStored { t.Fatal("expected stored password to be reported") @@ -318,6 +322,69 @@ func TestAppRunConfigUsesStoredValuesAsDefaults(t *testing.T) { } } +func TestAppRunConfigShowPrintsResolvedConfiguration(t *testing.T) { + cfgStore := &configStoreStub{ + cfg: frameworkconfig.FileConfig[ProfileConfig]{ + Version: frameworkconfig.CurrentVersion, + CurrentProfile: "work", + Profiles: map[string]ProfileConfig{ + "work": { + Host: "imap.example.com", + Username: "alice", + }, + }, + }, + } + secrets := &secretStoreStub{ + values: map[string]string{ + "imap-password/work": "secret", + }, + } + output := &bytes.Buffer{} + + app := NewAppWithDependencies( + nil, + cfgStore, + func() (secretStore, error) { return secrets, nil }, + nil, + nil, + nil, + nil, + nil, + output, + &bytes.Buffer{}, + "dev", + ) + + if err := app.Run([]string{"config", "show"}); err != nil { + t.Fatalf("config show returned error: %v", err) + } + + text := output.String() + for _, needle := range []string{ + "profile: work", + "host: imap.example.com (config)", + "username: alice (config)", + "password: (secret)", + } { + if !strings.Contains(text, needle) { + t.Fatalf("output = %q, want substring %q", text, needle) + } + } +} + +func TestAppRunConfigTestDelegatesToDoctor(t *testing.T) { + app := NewAppWithDependencies(nil, nil, nil, nil, nil, nil, nil, nil, nil, &bytes.Buffer{}, "dev") + + err := app.Run([]string{"config", "test"}) + if err == nil { + t.Fatal("expected config test to fail without dependencies") + } + if !strings.Contains(err.Error(), "config store is not configured") { + t.Fatalf("unexpected error: %v", err) + } +} + func TestAppRunMCPDelegatesResolvedCredentialToRunner(t *testing.T) { cfgStore := &configStoreStub{ cfg: frameworkconfig.FileConfig[ProfileConfig]{ @@ -726,7 +793,7 @@ func TestAppRunReturnsClearErrorsWhenDependenciesMissing(t *testing.T) { command string want string }{ - {command: "config", want: "config prompter is not configured"}, + {command: "setup", want: "config prompter is not configured"}, {command: "mcp", want: "mcp runner is not configured"}, {command: "doctor", want: "config store is not configured"}, {command: "update", want: "manifest loader is not configured"}, @@ -750,7 +817,7 @@ func TestMapAppErrorMapsMissingCredentialError(t *testing.T) { if err == nil { t.Fatal("expected mapped error") } - if !strings.Contains(err.Error(), "run `email-mcp config`") { + if !strings.Contains(err.Error(), "run `email-mcp setup`") { t.Fatalf("expected config guidance, got %v", err) } if !errors.Is(err, mcpserver.ErrCredentialsNotConfigured) { @@ -771,7 +838,7 @@ func TestMapAppErrorMapsUnavailableWalletError(t *testing.T) { } } -func TestExecuteConfigWritesMappedErrorAndReturnsExitCodeOne(t *testing.T) { +func TestExecuteSetupWritesMappedErrorAndReturnsExitCodeOne(t *testing.T) { app := NewAppWithDependencies( &configPrompterStub{}, &configStoreStub{}, @@ -792,7 +859,7 @@ func TestExecuteConfigWritesMappedErrorAndReturnsExitCodeOne(t *testing.T) { ) stderr := &bytes.Buffer{} - if code := Execute(app, []string{"config"}, stderr); code != 1 { + if code := Execute(app, []string{"setup"}, stderr); code != 1 { t.Fatalf("expected exit code 1, got %d", code) } if got := strings.ToLower(stderr.String()); !strings.Contains(got, "wallet") { diff --git a/internal/mcpserver/server.go b/internal/mcpserver/server.go index ad6aba9..7349a45 100644 --- a/internal/mcpserver/server.go +++ b/internal/mcpserver/server.go @@ -14,7 +14,7 @@ import ( "email-mcp/internal/secretstore/kwallet" ) -var ErrCredentialsNotConfigured = errors.New("credentials not configured; run `email-mcp config`") +var ErrCredentialsNotConfigured = errors.New("credentials not configured; run `email-mcp setup`") const ( jsonRPCVersion = "2.0"