Compare commits

..

No commits in common. "f8eb0d3449ab6d00b81df49bbe7d825cc256e69d" and "9a52b5dce1bbab451534bd0d13dab05cde097942" have entirely different histories.

9 changed files with 46 additions and 497 deletions

View file

@ -8,6 +8,8 @@ import (
"os" "os"
"sort" "sort"
"strings" "strings"
"forge.lclr.dev/AI/mcp-framework/secretstore"
) )
const ( const (
@ -166,6 +168,14 @@ func Run(ctx context.Context, opts Options) error {
} }
_, err := fmt.Fprintln(normalized.Stdout, normalized.Version) _, err := fmt.Fprintln(normalized.Stdout, normalized.Version)
return err return err
case CommandLogin:
_, err := secretstore.LoginBitwarden(secretstore.BitwardenLoginOptions{
ServiceName: normalized.BinaryName,
Stdin: normalized.Stdin,
Stdout: normalized.Stdout,
Stderr: normalized.Stderr,
})
return err
default: default:
return fmt.Errorf("%w: %s", ErrCommandNotConfigured, command) return fmt.Errorf("%w: %s", ErrCommandNotConfigured, command)
} }

View file

@ -1,56 +0,0 @@
package bootstrap
import (
"context"
"fmt"
fwcli "forge.lclr.dev/AI/mcp-framework/cli"
"forge.lclr.dev/AI/mcp-framework/secretstore"
)
// StandardConfigTestOptions configure le handler de config test standard.
// Aucun champ n'est obligatoire — omettez ceux qui ne s'appliquent pas à l'application.
type StandardConfigTestOptions struct {
// ConfigCheck vérifie que le fichier de configuration est lisible.
// Construire avec cli.NewConfigCheck(store).
ConfigCheck fwcli.DoctorCheck
// OpenStore ouvre le secret store pour vérifier sa disponibilité.
// Si fourni, un SecretStoreAvailabilityCheck est automatiquement inclus.
OpenStore func() (secretstore.Store, error)
// ConnectivityCheck vérifie la connectivité applicative (IMAP, HTTP, etc.).
ConnectivityCheck fwcli.DoctorCheck
// ExtraChecks contient des vérifications supplémentaires spécifiques à l'application.
ExtraChecks []fwcli.DoctorCheck
}
// StandardConfigTestHandler retourne un Handler pour la commande config test.
// Il inclut : config (si fourni), secret store (si fourni), connectivité (si fournie),
// checks supplémentaires. Le ManifestCheck est intentionnellement absent : le manifest
// est un artefact de build, pas une contrainte runtime.
func StandardConfigTestHandler(opts StandardConfigTestOptions) Handler {
return func(ctx context.Context, inv Invocation) error {
doctorOpts := fwcli.DoctorOptions{
ConfigCheck: opts.ConfigCheck,
ConnectivityCheck: opts.ConnectivityCheck,
ExtraChecks: opts.ExtraChecks,
}
if opts.OpenStore != nil {
doctorOpts.SecretStoreCheck = fwcli.SecretStoreAvailabilityCheck(opts.OpenStore)
}
report := fwcli.RunDoctor(ctx, doctorOpts)
if err := fwcli.RenderDoctorReport(inv.Stdout, report); err != nil {
return err
}
if report.HasFailures() {
return fmt.Errorf("config checks failed")
}
return nil
}
}

View file

@ -1,155 +0,0 @@
package bootstrap
import (
"bytes"
"context"
"errors"
"strings"
"testing"
fwcli "forge.lclr.dev/AI/mcp-framework/cli"
"forge.lclr.dev/AI/mcp-framework/secretstore"
)
func TestStandardConfigTestHandlerRendersChecks(t *testing.T) {
var stdout bytes.Buffer
handler := StandardConfigTestHandler(StandardConfigTestOptions{
ConfigCheck: func(context.Context) fwcli.DoctorResult {
return fwcli.DoctorResult{Name: "config", Status: fwcli.DoctorStatusOK, Summary: "config ok"}
},
ConnectivityCheck: func(context.Context) fwcli.DoctorResult {
return fwcli.DoctorResult{Name: "connectivity", Status: fwcli.DoctorStatusOK, Summary: "reachable"}
},
})
err := handler(context.Background(), Invocation{Command: CommandConfig, Stdout: &stdout})
if err != nil {
t.Fatalf("handler returned error: %v", err)
}
out := stdout.String()
if !strings.Contains(out, "[OK] config") {
t.Fatalf("stdout = %q, want [OK] config", out)
}
if !strings.Contains(out, "[OK] connectivity") {
t.Fatalf("stdout = %q, want [OK] connectivity", out)
}
if strings.Contains(out, "manifest") {
t.Fatalf("stdout should not contain manifest check, got:\n%s", out)
}
}
func TestStandardConfigTestHandlerIncludesSecretStoreCheck(t *testing.T) {
var stdout bytes.Buffer
handler := StandardConfigTestHandler(StandardConfigTestOptions{
OpenStore: func() (secretstore.Store, error) {
return secretstore.Open(secretstore.Options{
BackendPolicy: secretstore.BackendEnvOnly,
LookupEnv: func(string) (string, bool) { return "", false },
})
},
})
err := handler(context.Background(), Invocation{Command: CommandConfig, Stdout: &stdout})
if err != nil {
t.Fatalf("handler returned error: %v", err)
}
if !strings.Contains(stdout.String(), "secret-store") {
t.Fatalf("stdout = %q, want secret-store check", stdout.String())
}
}
func TestStandardConfigTestHandlerReturnsErrorOnFailure(t *testing.T) {
var stdout bytes.Buffer
handler := StandardConfigTestHandler(StandardConfigTestOptions{
ConnectivityCheck: func(context.Context) fwcli.DoctorResult {
return fwcli.DoctorResult{Name: "connectivity", Status: fwcli.DoctorStatusFail, Summary: "unreachable"}
},
})
err := handler(context.Background(), Invocation{Command: CommandConfig, Stdout: &stdout})
if err == nil {
t.Fatal("handler should return error when checks fail")
}
if !strings.Contains(err.Error(), "config checks failed") {
t.Fatalf("err = %q, want 'config checks failed'", err.Error())
}
if !strings.Contains(stdout.String(), "[FAIL] connectivity") {
t.Fatalf("stdout = %q, want [FAIL] connectivity", stdout.String())
}
}
func TestStandardConfigTestHandlerOmitsManifestCheck(t *testing.T) {
var stdout bytes.Buffer
handler := StandardConfigTestHandler(StandardConfigTestOptions{
ExtraChecks: []fwcli.DoctorCheck{
func(context.Context) fwcli.DoctorResult {
return fwcli.DoctorResult{Name: "custom", Status: fwcli.DoctorStatusOK, Summary: "ok"}
},
},
})
err := handler(context.Background(), Invocation{Command: CommandConfig, Stdout: &stdout})
if err != nil {
t.Fatalf("handler returned error: %v", err)
}
if strings.Contains(stdout.String(), "manifest") {
t.Fatalf("manifest check should not appear, got:\n%s", stdout.String())
}
}
func TestStandardConfigTestHandlerRunsViaBootstrap(t *testing.T) {
var stdout bytes.Buffer
openCalled := false
err := Run(context.Background(), Options{
BinaryName: "my-mcp",
Args: []string{"config", "test"},
Stdout: &stdout,
Hooks: Hooks{
ConfigTest: StandardConfigTestHandler(StandardConfigTestOptions{
OpenStore: func() (secretstore.Store, error) {
openCalled = true
return secretstore.Open(secretstore.Options{
BackendPolicy: secretstore.BackendEnvOnly,
LookupEnv: func(string) (string, bool) { return "", false },
})
},
}),
},
})
if err != nil {
t.Fatalf("Run error = %v", err)
}
if !openCalled {
t.Fatal("OpenStore should have been called")
}
if !strings.Contains(stdout.String(), "secret-store") {
t.Fatalf("stdout = %q, want secret-store in output", stdout.String())
}
}
func TestStandardConfigTestHandlerSecretStoreFailurePropagates(t *testing.T) {
var stdout bytes.Buffer
storeErr := errors.New("bitwarden unavailable")
handler := StandardConfigTestHandler(StandardConfigTestOptions{
OpenStore: func() (secretstore.Store, error) {
return nil, storeErr
},
})
err := handler(context.Background(), Invocation{Command: CommandConfig, Stdout: &stdout})
if err == nil {
t.Fatal("handler should return error when store fails")
}
if !strings.Contains(stdout.String(), "[FAIL] secret-store") {
t.Fatalf("stdout = %q, want [FAIL] secret-store", stdout.String())
}
}

View file

@ -1,34 +0,0 @@
package bootstrap
import (
"context"
"fmt"
"strings"
"forge.lclr.dev/AI/mcp-framework/secretstore"
)
var loginBitwarden = secretstore.LoginBitwarden
// BitwardenLoginHandler retourne un Handler pour la commande login des MCPs
// qui utilisent le backend Bitwarden. Il authentifie et déverrouille le vault,
// persiste BW_SESSION, et confirme le résultat.
//
// N'utiliser que si le MCP déclare secret_store.backend_policy = "bitwarden-cli"
// dans son manifest. Pour les backends env-only ou keyring, ne pas définir de
// hook Login : la commande sera automatiquement masquée.
func BitwardenLoginHandler(binaryName string) Handler {
name := strings.TrimSpace(binaryName)
return func(_ context.Context, inv Invocation) error {
if _, err := loginBitwarden(secretstore.BitwardenLoginOptions{
ServiceName: name,
Stdin: inv.Stdin,
Stdout: inv.Stdout,
Stderr: inv.Stderr,
}); err != nil {
return err
}
_, err := fmt.Fprintf(inv.Stdout, "Session Bitwarden persistée pour %q.\n", name)
return err
}
}

View file

@ -1,103 +0,0 @@
package bootstrap
import (
"bytes"
"context"
"errors"
"strings"
"testing"
"forge.lclr.dev/AI/mcp-framework/secretstore"
)
func withLoginBitwarden(t *testing.T, fn func(secretstore.BitwardenLoginOptions) (string, error)) {
t.Helper()
previous := loginBitwarden
loginBitwarden = fn
t.Cleanup(func() { loginBitwarden = previous })
}
func TestBitwardenLoginHandlerPrintsConfirmation(t *testing.T) {
var stdout bytes.Buffer
withLoginBitwarden(t, func(opts secretstore.BitwardenLoginOptions) (string, error) {
if opts.ServiceName != "my-mcp" {
t.Fatalf("ServiceName = %q, want %q", opts.ServiceName, "my-mcp")
}
return "session-token", nil
})
handler := BitwardenLoginHandler("my-mcp")
err := handler(context.Background(), Invocation{
Command: CommandLogin,
Stdout: &stdout,
})
if err != nil {
t.Fatalf("handler returned error: %v", err)
}
out := stdout.String()
if !strings.Contains(out, `"my-mcp"`) {
t.Fatalf("stdout = %q, want mention of binary name", out)
}
if !strings.Contains(out, "persistée") {
t.Fatalf("stdout = %q, want confirmation message", out)
}
}
func TestBitwardenLoginHandlerPropagatesError(t *testing.T) {
var stdout bytes.Buffer
loginErr := errors.New("vault locked")
withLoginBitwarden(t, func(_ secretstore.BitwardenLoginOptions) (string, error) {
return "", loginErr
})
handler := BitwardenLoginHandler("my-mcp")
err := handler(context.Background(), Invocation{
Command: CommandLogin,
Stdout: &stdout,
})
if !errors.Is(err, loginErr) {
t.Fatalf("err = %v, want %v", err, loginErr)
}
if stdout.Len() > 0 {
t.Fatalf("stdout should be empty on error, got %q", stdout.String())
}
}
func TestRunUsesBitwardenLoginHandlerWhenHookSet(t *testing.T) {
var stdout bytes.Buffer
withLoginBitwarden(t, func(_ secretstore.BitwardenLoginOptions) (string, error) {
return "tok", nil
})
err := Run(context.Background(), Options{
BinaryName: "my-mcp",
Args: []string{"login"},
Stdout: &stdout,
Hooks: Hooks{
Login: BitwardenLoginHandler("my-mcp"),
},
})
if err != nil {
t.Fatalf("Run error = %v", err)
}
if !strings.Contains(stdout.String(), "persistée") {
t.Fatalf("stdout = %q, want confirmation", stdout.String())
}
}
func TestRunLoginAutoHiddenWithoutHook(t *testing.T) {
var stdout bytes.Buffer
err := Run(context.Background(), Options{
BinaryName: "my-mcp",
Args: []string{"login"},
Stdout: &stdout,
})
if !errors.Is(err, ErrUnknownCommand) {
t.Fatalf("err = %v, want ErrUnknownCommand", err)
}
}

View file

@ -86,7 +86,7 @@ func RunDoctor(ctx context.Context, options DoctorOptions) DoctorReport {
} }
if options.ManifestCheck != nil { if options.ManifestCheck != nil {
checks = append(checks, options.ManifestCheck) checks = append(checks, options.ManifestCheck)
} else if strings.TrimSpace(options.ManifestDir) != "" { } else {
checks = append(checks, ManifestCheck(options.ManifestDir, options.ManifestValidator)) checks = append(checks, ManifestCheck(options.ManifestDir, options.ManifestValidator))
} }
if options.ConnectivityCheck != nil { if options.ConnectivityCheck != nil {

View file

@ -198,24 +198,6 @@ func TestSecretStoreAvailabilityCheckFailsWhenFactoryFails(t *testing.T) {
} }
} }
func TestRunDoctorOmitsManifestCheckWhenDirNotSet(t *testing.T) {
report := RunDoctor(context.Background(), DoctorOptions{
ConnectivityCheck: func(context.Context) DoctorResult {
return DoctorResult{Name: "connectivity", Status: DoctorStatusOK, Summary: "ok"}
},
// ManifestDir intentionally empty, ManifestCheck intentionally nil.
})
for _, r := range report.Results {
if r.Name == "manifest" {
t.Fatalf("manifest check should not be included when ManifestDir is empty, got: %+v", r)
}
}
if len(report.Results) != 1 {
t.Fatalf("result count = %d, want 1 (connectivity only)", len(report.Results))
}
}
func TestBitwardenReadyCheckMapsTypedErrorsToActionableDiagnostics(t *testing.T) { func TestBitwardenReadyCheckMapsTypedErrorsToActionableDiagnostics(t *testing.T) {
prev := checkBitwardenReady prev := checkBitwardenReady
t.Cleanup(func() { t.Cleanup(func() {

View file

@ -1,27 +1,8 @@
# Bootstrap CLI # Bootstrap CLI
Le package `bootstrap` fournit un point d'entrée CLI uniforme pour les binaires Le package `bootstrap` reste optionnel : une application peut l'utiliser pour uniformiser le parsing CLI et l'aide, sans imposer de runtime monolithique.
MCP. Il gère le parsing des arguments, l'aide, les alias et le routage vers les
hooks fournis par l'application.
## Commandes disponibles Exemple minimal :
| Commande | Description |
|---|---|
| `setup` | Initialiser ou mettre à jour la configuration locale |
| `login` | Authentifier et déverrouiller Bitwarden pour persister `BW_SESSION` |
| `mcp` | Démarrer le serveur MCP |
| `config show` | Afficher la configuration résolue et la provenance des valeurs |
| `config test` | Vérifier la configuration et la connectivité |
| `config delete` | Supprimer un profil local |
| `update` | Auto-update du binaire |
| `version` | Afficher la version |
Les commandes sans hook correspondant sont automatiquement masquées de l'aide et
retournent une erreur `ErrUnknownCommand`. Exception : `version` affiche
`Options.Version` si fourni, sans hook.
## Utilisation
```go ```go
func main() { func main() {
@ -29,22 +10,26 @@ func main() {
BinaryName: "my-mcp", BinaryName: "my-mcp",
Description: "Client MCP", Description: "Client MCP",
Version: version, Version: version,
EnableDoctorAlias: true, EnableDoctorAlias: true, // expose `doctor` comme alias de `config test`
AliasDescriptions: map[string]string{
"doctor": "Diagnostiquer la configuration locale.",
},
Hooks: bootstrap.Hooks{ Hooks: bootstrap.Hooks{
Setup: func(ctx context.Context, inv bootstrap.Invocation) error { Setup: func(ctx context.Context, inv bootstrap.Invocation) error {
return runSetup(ctx, inv.Args) return runSetup(ctx, inv.Args)
}, },
Login: bootstrap.BitwardenLoginHandler("my-mcp"), Login: func(ctx context.Context, inv bootstrap.Invocation) error {
return runLogin(ctx, inv.Args)
},
MCP: func(ctx context.Context, inv bootstrap.Invocation) error { MCP: func(ctx context.Context, inv bootstrap.Invocation) error {
return runMCP(ctx, inv.Args) return runMCP(ctx, inv.Args)
}, },
ConfigShow: func(ctx context.Context, inv bootstrap.Invocation) error { ConfigShow: func(ctx context.Context, inv bootstrap.Invocation) error {
return runConfigShow(ctx, inv.Args) return runConfigShow(ctx, inv.Args)
}, },
ConfigTest: bootstrap.StandardConfigTestHandler(bootstrap.StandardConfigTestOptions{ ConfigTest: func(ctx context.Context, inv bootstrap.Invocation) error {
OpenStore: openStore, return runConfigTest(ctx, inv.Args)
ConnectivityCheck: connectivityCheck, },
}),
Update: func(ctx context.Context, inv bootstrap.Invocation) error { Update: func(ctx context.Context, inv bootstrap.Invocation) error {
return runUpdate(ctx, inv.Args) return runUpdate(ctx, inv.Args)
}, },
@ -56,97 +41,11 @@ func main() {
} }
``` ```
## Handlers fournis Si `Hooks.Version` n'est pas fourni, la sous-commande `version` affiche automatiquement `Options.Version`.
### `BitwardenLoginHandler` Sans arguments, `bootstrap.Run` affiche l'aide globale (pas de lancement implicite de `mcp`).
La commande `login` est optionnelle et peut être branchée pour gérer un unlock Bitwarden interactif.
```go La commande `config` impose une sous-commande (`show`, `test`, et optionnellement `delete`).
bootstrap.BitwardenLoginHandler(binaryName string) bootstrap.Handler Les alias déclarés (ou `EnableDoctorAlias`) sont affichés dans l'aide globale.
``` Le flag global `--debug` est supporté et active le debug des appels Bitwarden
Handler prêt à l'emploi pour la commande `login` des MCPs qui utilisent le
backend Bitwarden CLI. Il lance le flux interactif `bw unlock --raw`, persiste
`BW_SESSION` dans un fichier `0600` sous le répertoire de config utilisateur, et
confirme le résultat.
À n'utiliser que si le MCP déclare `secret_store.backend_policy = "bitwarden-cli"`
dans son manifest. Pour les autres backends (`env-only`, `keyring-any`), ne pas
définir de hook `Login` : la commande est automatiquement masquée.
```go
Hooks: bootstrap.Hooks{
Login: bootstrap.BitwardenLoginHandler(mcpgen.BinaryName),
}
```
### `StandardConfigTestHandler`
```go
bootstrap.StandardConfigTestHandler(opts bootstrap.StandardConfigTestOptions) bootstrap.Handler
```
Handler pour `config test` qui exécute un ensemble de checks standards et affiche
un rapport formaté. Aucun champ n'est obligatoire.
```go
type StandardConfigTestOptions struct {
ConfigCheck cli.DoctorCheck // cli.NewConfigCheck(store)
OpenStore func() (secretstore.Store, error) // check disponibilité secret store
ConnectivityCheck cli.DoctorCheck // check applicatif (HTTP, IMAP…)
ExtraChecks []cli.DoctorCheck
}
```
Checks inclus automatiquement selon les champs fournis :
| Champ | Check résultant |
|---|---|
| `ConfigCheck` | Fichier de configuration lisible |
| `OpenStore` | Secret store disponible |
| `ConnectivityCheck` | Connectivité applicative |
| `ExtraChecks` | Checks supplémentaires |
Le `ManifestCheck` n'est pas inclus : le manifest est un artefact de build, pas
une contrainte runtime.
```go
ConfigTest: bootstrap.StandardConfigTestHandler(bootstrap.StandardConfigTestOptions{
ConfigCheck: cli.NewConfigCheck(frameworkconfig.NewStore[ProfileConfig]("my-mcp")),
OpenStore: openSecretStore,
ConnectivityCheck: func(ctx context.Context) cli.DoctorResult {
if err := pingBackend(ctx); err != nil {
return cli.DoctorResult{
Name: "connectivity",
Status: cli.DoctorStatusFail,
Summary: "backend inaccessible",
Detail: err.Error(),
}
}
return cli.DoctorResult{
Name: "connectivity",
Status: cli.DoctorStatusOK,
Summary: "backend accessible",
}
},
}),
```
Pour un config test applicatif spécifique (appels API, messages ✓/✗), implémenter
un hook `ConfigTest` custom.
## Options
| Champ | Description |
|---|---|
| `BinaryName` | Nom du binaire, utilisé dans l'aide et les messages |
| `Description` | Description affichée dans l'aide globale |
| `Version` | Version affichée par `version` sans hook |
| `Args` | Arguments CLI (défaut : `os.Args[1:]`) |
| `Stdin/Stdout/Stderr` | I/O (défaut : `os.Stdin/Stdout/Stderr`) |
| `Aliases` | Alias de commandes |
| `AliasDescriptions` | Descriptions des alias dans l'aide |
| `EnableDoctorAlias` | Active `doctor` comme alias de `config test` |
| `DisabledCommands` | Commandes à masquer explicitement |
Le flag global `--debug` active le debug des appels Bitwarden
(`MCP_FRAMEWORK_BITWARDEN_DEBUG=1`). (`MCP_FRAMEWORK_BITWARDEN_DEBUG=1`).

View file

@ -126,11 +126,8 @@ if err := cli.RenderResolutionProvenance(os.Stdout, resolution); err != nil {
`ResolveOptions.Order` permet de changer la priorité globale si nécessaire, et `FieldSpec.Sources` permet de définir un ordre spécifique pour un champ. `ResolveOptions.Order` permet de changer la priorité globale si nécessaire, et `FieldSpec.Sources` permet de définir un ordre spécifique pour un champ.
Le package fournit un socle réutilisable pour une commande `doctor`. Le package fournit aussi un socle réutilisable pour une commande `doctor`.
Pour les cas standards (config, secret store, connectivité), préférer L'application cliente choisit comment exposer la commande (`flag`, `cobra`, etc.), mais peut réutiliser les checks communs et ajouter ses propres hooks :
`bootstrap.StandardConfigTestHandler` qui câble `RunDoctor` sans boilerplate.
Pour un contrôle fin ou un config test impératif, utiliser `RunDoctor` directement :
```go ```go
report := cli.RunDoctor(context.Background(), cli.DoctorOptions{ report := cli.RunDoctor(context.Background(), cli.DoctorOptions{
@ -140,19 +137,32 @@ report := cli.RunDoctor(context.Background(), cli.DoctorOptions{
ServiceName: "my-mcp", ServiceName: "my-mcp",
}) })
}), }),
SecretBackendPolicy: secretstore.BackendBitwardenCLI,
BitwardenOptions: cli.BitwardenDoctorOptions{
LookupEnv: os.LookupEnv,
},
RequiredSecrets: []cli.DoctorSecret{
{Name: "api-token", Label: "API token"},
},
SecretStoreFactory: func() (secretstore.Store, error) {
return secretstore.OpenFromManifest(secretstore.OpenFromManifestOptions{
ServiceName: "my-mcp",
})
},
ManifestDir: ".",
ConnectivityCheck: func(context.Context) cli.DoctorResult { ConnectivityCheck: func(context.Context) cli.DoctorResult {
if err := pingBackend(); err != nil { if err := pingBackend(); err != nil {
return cli.DoctorResult{ return cli.DoctorResult{
Name: "connectivity", Name: "connectivity",
Status: cli.DoctorStatusFail, Status: cli.DoctorStatusFail,
Summary: "backend inaccessible", Summary: "backend is unreachable",
Detail: err.Error(), Detail: err.Error(),
} }
} }
return cli.DoctorResult{ return cli.DoctorResult{
Name: "connectivity", Name: "connectivity",
Status: cli.DoctorStatusOK, Status: cli.DoctorStatusOK,
Summary: "backend accessible", Summary: "backend is reachable",
} }
}, },
}) })
@ -166,10 +176,6 @@ if report.HasFailures() {
} }
``` ```
`ManifestDir` est optionnel. Quand il est fourni, `RunDoctor` inclut un
`ManifestCheck` qui vérifie la présence et la validité de `mcp.toml` dans ce
répertoire. Ne l'inclure que si ce check est pertinent pour l'application.
Quand `SecretBackendPolicy` vaut `bitwarden-cli`, le check Bitwarden est ajouté automatiquement. Quand `SecretBackendPolicy` vaut `bitwarden-cli`, le check Bitwarden est ajouté automatiquement.
Pour le désactiver explicitement : Pour le désactiver explicitement :