feat(desktop): disconnect external (CLI-managed) providers

External providers (Claude Code) store creds outside Hermes, so the
disconnect API refuses them. The backend now hands the GUI a per-OS
`disconnect_command` that clears the credential the same way the CLI's
logout does (macOS Keychain entry + ~/.claude/.credentials.json), and
the misleading "use claude setup-token" hint is corrected.

Settings → Providers offers a Disconnect button for these: it confirms,
leaves Settings, and runs the removal command in the embedded terminal
via a new runInTerminal() (queues onto $terminalInjection; the terminal
pane flushes and clears it once its session is live). The expanded list
also gets its own "Other providers" header so it no longer reads as
grouped under "Connected". API-managed providers keep the one-click
(trash) disconnect.
This commit is contained in:
Brooklyn Nicholson
2026-06-16 00:08:21 -05:00
parent 0e81d2fb71
commit a0ec4f52b9
13 changed files with 178 additions and 21 deletions
+33 -1
View File
@@ -5228,10 +5228,39 @@ def _resolve_provider_status(provider_id: str, status_fn) -> Dict[str, Any]:
return {"logged_in": False}
def _oauth_provider_disconnect_command(provider: Dict[str, Any]) -> Optional[str]:
"""Shell command that clears an external provider's credentials.
External providers store their credentials outside Hermes, so the disconnect
API deliberately refuses them (we never delete files another CLI owns on the
user's behalf via a silent API call). For the ones we know how to clear we
instead hand the GUI a command it can *run in the embedded terminal* the
user sees exactly what executes, and Hermes then stops resolving the token.
Claude Code has no scriptable logout (only the interactive ``/logout``), so
we remove the credential the same way logout does: the macOS Keychain entry
(``Claude Code-credentials``) and/or the ``~/.claude/.credentials.json``
file the two sources ``read_claude_code_credentials()`` consults. Returns
None for providers we can't safely clear (the GUI shows a manual hint).
"""
if provider.get("flow") != "external":
return None
if provider.get("id") == "claude-code":
rm_file = "rm -f ~/.claude/.credentials.json"
if sys.platform == "darwin":
return f'security delete-generic-password -s "Claude Code-credentials" 2>/dev/null; {rm_file}'
return rm_file
return None
def _oauth_provider_disconnect_hint(provider: Dict[str, Any], status: Dict[str, Any]) -> Optional[str]:
"""Return the manual disconnect path when the API cannot clear this provider."""
if provider.get("flow") == "external":
return f"Use `{provider['cli_command']}` or that provider's CLI to remove it."
if _oauth_provider_disconnect_command(provider):
# The GUI offers a one-click "run in terminal" path; this hint is the
# fallback wording for surfaces that only show text.
return "Managed outside Hermes — run the disconnect command to remove it."
return "Managed by that provider's CLI; remove it there."
if status.get("source") == "env_var":
return "Remove the API key from Settings → Keys instead."
return None
@@ -5246,6 +5275,8 @@ async def list_oauth_providers(profile: Optional[str] = None):
name human label
flow "pkce" | "device_code" | "external" | "loopback"
cli_command fallback CLI command for users to run manually
disconnect_command shell command that clears an external provider's
creds (run in the embedded terminal), else null
docs_url external docs/portal link for the "Learn more" link
status:
logged_in bool currently has usable creds
@@ -5267,6 +5298,7 @@ async def list_oauth_providers(profile: Optional[str] = None):
"cli_command": p["cli_command"],
"docs_url": p["docs_url"],
"disconnect_hint": disconnect_hint,
"disconnect_command": _oauth_provider_disconnect_command(p),
"disconnectable": disconnect_hint is None,
"status": status,
})