feat: align CLI with mcp-framework v1.2.1 config commands

This commit is contained in:
thibaud-leclere 2026-04-14 11:21:01 +02:00
parent 8a448be942
commit 781a5985ab
4 changed files with 189 additions and 25 deletions

View file

@ -12,8 +12,9 @@ Le binaire sappuie 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 laccè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 na été configuré pour le profil résolu, le serveur renvoie lerreur :
```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

View file

@ -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 "<missing>"
}
return field.Value
}
func renderSecretField(field frameworkcli.ResolvedField) string {
if !field.Found || strings.TrimSpace(field.Value) == "" {
return "<missing>"
}
return "<set>"
}
type userFacingError struct {
message string
err error

View file

@ -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: <set> (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") {

View file

@ -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"