diff --git a/cli/cmd/plugin/plugin.go b/cli/cmd/plugin/plugin.go index 604a53b..20803d9 100644 --- a/cli/cmd/plugin/plugin.go +++ b/cli/cmd/plugin/plugin.go @@ -25,5 +25,6 @@ func New() *cobra.Command { } c.AddCommand(newList()) c.AddCommand(newInstall()) + c.AddCommand(newUninstall()) return c } diff --git a/cli/cmd/plugin/uninstall.go b/cli/cmd/plugin/uninstall.go new file mode 100644 index 0000000..1d11d9a --- /dev/null +++ b/cli/cmd/plugin/uninstall.go @@ -0,0 +1,48 @@ +package plugin + +import ( + "github.com/spf13/cobra" + "evercli/internal/cmdctx" + "evercli/internal/plugin" +) + +func newUninstall() *cobra.Command { + var uninstallAll bool + + c := &cobra.Command{ + Use: "uninstall [...]", + Short: "Remove the EverMe MCP plugin configurations from local Agents", + Long: `Uninstall safely strips EverMe configurations from your local AI agents' +settings files (JSON, TOML, YAML) without touching your other plugins. + +Use the --all flag to automatically find and remove EverMe from every supported platform.`, + Example: ` evercli plugin uninstall cursor + evercli plugin uninstall claude-code codex + evercli plugin uninstall --all`, + RunE: func(cmd *cobra.Command, args []string) error { + deps, err := cmdctx.BuildDeps(cmd) + if err != nil { + return deps.Out.Err(err) + } + + svc := plugin.NewService(deps.Client, deps.Config.APIBaseURL) + + var targetPlatforms []plugin.Platform + if uninstallAll { + targetPlatforms = svc.SupportedPlatforms() + } else { + for _, a := range args { + targetPlatforms = append(targetPlatforms, plugin.Platform(a)) + } + } + + if err := svc.Uninstall(cmd.Context(), targetPlatforms); err != nil { + return deps.Out.Err(err) + } + return nil + }, + } + + c.Flags().BoolVar(&uninstallAll, "all", false, "uninstall from all supported platforms") + return c +} \ No newline at end of file diff --git a/cli/internal/plugin/codex.go b/cli/internal/plugin/codex.go index 5706d44..f9d76ee 100644 --- a/cli/internal/plugin/codex.go +++ b/cli/internal/plugin/codex.go @@ -345,6 +345,58 @@ func (*codexWriter) Commit(_ context.Context, plan *WritePlan, params WriteParam }, nil } +// Remove parses ~/.codex/config.toml and strips all EverMe-owned sections: +// the MCP server, the plugin enablement, and the marketplace registration. +// It implements the Remover interface to restore uninstall capabilities. +func (w *codexWriter) Remove(_ context.Context, configPath string) (bool, error) { + // 1. Safely read the TOML file. + cfg, exists, err := readCodexConfig(configPath) + if err != nil { + return false, err + } + if !exists { + return false, nil // File doesn't exist, nothing to remove + } + + modified := false + + // 2. Strip the active MCP Server (and its nested token) + if mcpServers, ok := cfg["mcp_servers"].(map[string]interface{}); ok { + if _, present := mcpServers[codexMcpEntryName]; present { + delete(mcpServers, codexMcpEntryName) + modified = true + } + } + + // 3. Strip the Plugin enablement + if plugins, ok := cfg["plugins"].(map[string]interface{}); ok { + if _, present := plugins[codexPluginSpec]; present { + delete(plugins, codexPluginSpec) + modified = true + } + } + + // 4. Strip the Marketplace registration + if marketplaces, ok := cfg["marketplaces"].(map[string]interface{}); ok { + if _, present := marketplaces[codexMarketplaceName]; present { + delete(marketplaces, codexMarketplaceName) + modified = true + } + } + + // If we didn't find any EverMe entries, bail out early to avoid a useless disk write + if !modified { + return false, nil + } + + // 5. Safely write the cleaned TOML back to disk + if err := writeCodexConfig(configPath, cfg); err != nil { + return false, err + } + + return true, nil +} + // Verify re-reads the on-disk config and asserts the three sections // the install workflow depends on are present: // - `[marketplaces.everme]` (written by Codex CLI in Prepare) diff --git a/cli/internal/plugin/hermes.go b/cli/internal/plugin/hermes.go index 21503a1..754c56d 100644 --- a/cli/internal/plugin/hermes.go +++ b/cli/internal/plugin/hermes.go @@ -284,6 +284,69 @@ func (*hermesWriter) Commit(_ context.Context, plan *WritePlan, params WritePara }, nil } +// Remove uninstalls the EverMe memory provider from Hermes. +// It deletes the embedded Python plugin directory, the credentials env file, +// and strips the memory.provider setting from config.yaml. +// It implements the Remover interface to restore uninstall capabilities. +func (*hermesWriter) Remove(_ context.Context, configPath string) (bool, error) { + home := filepath.Dir(configPath) + modifiedAny := false + + // 1. Delete the physical Python plugin directory + pluginDir := filepath.Join(home, "plugins", "everme") + if info, err := os.Stat(pluginDir); err == nil && info.IsDir() { + if err := os.RemoveAll(pluginDir); err != nil { + return false, output.IOErr(pluginDir, "remove-plugin-dir", err) + } + modifiedAny = true + } + + // 2. Delete the physical credentials file + envFile := filepath.Join(home, "everme.env") + if _, err := os.Stat(envFile); err == nil { + if err := os.Remove(envFile); err != nil { + return false, output.IOErr(envFile, "remove-env-file", err) + } + modifiedAny = true + } + + // 3. Safely read the YAML config + cfg, exists, err := readHermesConfig(configPath) + if err != nil { + return false, err + } + + if exists { + yamlModified := false + + // 4. Strip the provider setting if it belongs to us + if mem, ok := cfg["memory"].(map[string]interface{}); ok { + if prov, _ := mem["provider"].(string); prov == "everme" { + delete(mem, "provider") + yamlModified = true + } + } + + // 5. Clean up the legacy V1.x MCP entry just in case they upgraded + if servers, ok := cfg["mcp_servers"].(map[string]interface{}); ok { + if _, present := servers[hermesMcpEntryName]; present { + delete(servers, hermesMcpEntryName) + yamlModified = true + } + } + + // 6. Write back to disk only if we actually changed the YAML + if yamlModified { + if err := writeHermesConfig(configPath, cfg); err != nil { + return false, err + } + modifiedAny = true + } + } + + return modifiedAny, nil +} + // writeEvermeEnv writes the three EVERME_* credentials as KEY=VALUE lines // at mode 0600. The provider's config.py reads this file (priority 3). func writeEvermeEnv(path string, params WriteParams) error { diff --git a/cli/internal/plugin/mcp.go b/cli/internal/plugin/mcp.go index 11eb806..806c969 100644 --- a/cli/internal/plugin/mcp.go +++ b/cli/internal/plugin/mcp.go @@ -155,6 +155,40 @@ func (m *mcpWriter) Commit(_ context.Context, plan *WritePlan, params WriteParam }, nil } +// Remove strips the everme-memory entry from the MCP JSON config if it exists. +// It implements the Remover interface to restore uninstall capabilities. +func (m *mcpWriter) Remove(_ context.Context, configPath string) (bool, error) { + // 1. Safely read the JSON. If it's malformed, readConfig returns an error. + cfg, exists, err := readConfig(configPath) + if err != nil { + return false, err + } + if !exists { + return false, nil // File doesn't exist, nothing to remove + } + + // 2. Walk the JSON tree to find the target block (e.g., "mcpServers") + servers := walkServersMap(cfg, m.serversPath) + if servers == nil { + return false, nil // Parent object doesn't exist, nothing to remove + } + + // 3. Check if the EverMe entry is actually there + if _, present := servers[mcpEntryName]; !present { + return false, nil // EverMe is not installed here + } + + // 4. Safely delete the target key from the Go map + delete(servers, mcpEntryName) + + // 5. Use their secure, atomic writer to save the file back to disk + if err := writeConfigAtomic(configPath, cfg); err != nil { + return false, err + } + + return true, nil +} + // ---- helpers --------------------------------------------------------- // readConfig parses the JSON config at path. Returns (cfg, exists, err). diff --git a/cli/internal/plugin/service.go b/cli/internal/plugin/service.go index edf6687..34511e2 100644 --- a/cli/internal/plugin/service.go +++ b/cli/internal/plugin/service.go @@ -476,3 +476,44 @@ func (s *Service) buildRegisterReq(p Platform, displayName string) client.Regist func osTag() string { return runtimeGOOS() } + +// Uninstall orchestrates the local cleanup of EverMe configurations across the requested +// platforms. It utilizes the Remover interface to safely strip configurations. +func (s *Service) Uninstall(ctx context.Context, platforms []Platform) error { + if len(platforms) == 0 { + return output.Invalid("at least one platform is required (or use --all)", "") + } + + for _, p := range platforms { + if !s.reg.Has(p) { + fmt.Printf("✗ %s: unknown platform\n", p) + continue + } + + wr := s.reg.writer(p) + remover, ok := wr.(Remover) + if !ok { + fmt.Printf("— %s: uninstall not yet supported for this platform format\n", p) + continue + } + + det := s.reg.detector(p) + detection, err := det.Detect(ctx) + if err != nil || detection == nil || detection.ConfigPath == "" { + fmt.Printf("— %s: could not detect config path\n", p) + continue + } + + modified, err := remover.Remove(ctx, detection.ConfigPath) + if err != nil { + fmt.Printf("✗ %s failed to uninstall: %v\n", p, err) + } else if modified { + fmt.Printf("✓ %s: successfully removed EverMe configuration from %s\n", p, detection.ConfigPath) + } else { + fmt.Printf("— %s: no EverMe configuration found in %s\n", p, detection.ConfigPath) + } + } + + fmt.Println("\nNote: Local configurations removed. Please ensure you disconnect the agents from the EverMe Web UI to revoke server-side tokens.") + return nil +} \ No newline at end of file diff --git a/cli/internal/plugin/types.go b/cli/internal/plugin/types.go index 6ee4ef7..931a2bf 100644 --- a/cli/internal/plugin/types.go +++ b/cli/internal/plugin/types.go @@ -112,6 +112,18 @@ type Writer interface { Commit(ctx context.Context, plan *WritePlan, params WriteParams) (*WriteResult, error) } +// Remover is an extension to Writer that safely strips the everme-memory +// entry from the Agent's MCP config. This restores the uninstall capability +// that was temporarily retired in V1. +type Remover interface { + Writer + + // Remove parses the target ConfigPath, deletes the EverMe entry if it + // exists, and atomically rewrites the file. Returns true if the file + // was modified, false if no EverMe entry was found. + Remove(ctx context.Context, configPath string) (bool, error) +} + // Preparer is an optional Writer extension for hosts that need a // side-effecting setup step BEFORE the backend mints a fresh token. // Service.installOne calls Prepare immediately after Detect and before