feat: align CLI with mcp-framework v1.2.1 config commands
This commit is contained in:
parent
8a448be942
commit
781a5985ab
4 changed files with 189 additions and 25 deletions
27
README.md
27
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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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") {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in a new issue