feat(#83): offer move-to-env on args rows; explain the gate instead of an empty menu
CI / Lint (ruff) (pull_request) Successful in 19s
CI / Tests (py3.12 / windows-latest) (pull_request) Successful in 24s
CI / Tests (py3.10 / ubuntu-latest) (pull_request) Successful in 29s
CI / Tests (py3.12 / ubuntu-latest) (pull_request) Successful in 29s
CI / Tests (py3.13 / ubuntu-latest) (pull_request) Successful in 31s
CI / Catalog signature (pull_request) Successful in 22s

Two things surfaced testing the GUI:

1. Args rows showed the secret warning but no move action -- the args
   editor is a free-text widget, not a table, and was deliberately left out
   of the first cut. Wired it up: ArgsEdit gains a context menu that offers
   "Move to environment variable…" on exactly the args that look like a
   credential. New pure core: secret_arg_indices (which args are secrets,
   mirroring args_secret_warning per-index) and suggested_env_var_for_arg
   (default var name from the preceding flag -- `--api-key <secret>` ->
   API_KEY, else SECRET). The move replaces that one arg line with ${VAR}
   and copies the secret to the clipboard, same contract as the tables.

2. On a Claude Desktop profile (or any non-expanding client) the menu showed
   NOTHING, so it read as broken. Now a real stored secret always shows the
   item -- enabled on a client that expands references, or disabled with the
   reason ("unavailable for Claude Desktop -- it doesn't expand ${VAR}") so
   the gate is visible rather than silent. Applies to env, headers and args.

Not a change: after converting, env_ref_warnings still notes a variable that
isn't set in the environment. That's #82's advisory doing its job -- the user
runs the export line the dialog handed them; auto-adding a ':-default' would
bake a fallback back into the config and defeat moving the secret out.

Tests: +5 core (secret_arg_indices for token/flag-value/embedded-URL/
reference-excluded, suggested_env_var_for_arg with and without a flag).
483 passed, ruff clean. GUI wiring (context menus) remains untestable in CI.

Refs #83

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EKwBecy6N83jnqQmw8ezwE
This commit is contained in:
2026-08-04 04:03:47 +00:00
co-authored by Claude Opus 4.8
parent 8fdbe90b37
commit 4743c4a995
4 changed files with 191 additions and 5 deletions
+61 -5
View File
@@ -418,12 +418,23 @@ class KeyValueTable(QWidget):
return
row = item.row()
key, value = self._row_key_value(row)
profile = self.profile_provider() if self.profile_provider else None
if not core.can_move_value_to_env_ref(key, value, profile):
# Only a real stored secret is worth moving; nothing to offer otherwise.
if not core.should_mask_value(key, value):
return
profile = self.profile_provider() if self.profile_provider else None
menu = QMenu(self)
act = QAction("Move to environment variable…", self)
act.triggered.connect(lambda: self._move_row_to_env(row))
if core.can_move_value_to_env_ref(key, value, profile):
act.triggered.connect(lambda: self._move_row_to_env(row))
else:
# Show it disabled with the reason rather than an empty menu, so the
# feature is discoverable and Claude Desktop's gating is explained.
act.setEnabled(False)
act.setText("Move to environment variable — unavailable for Claude Desktop")
act.setToolTip(
"Claude Desktop doesn't expand ${VAR}, so a reference would reach "
"the server as literal text."
)
menu.addAction(act)
menu.exec(self.table.viewport().mapToGlobal(pos))
@@ -762,10 +773,11 @@ class ServerEditor(QFrame):
return w
def set_profile_provider(self, provider):
"""Let the env/headers tables gate "move to environment variable" on
which client the loaded profile targets (#83)."""
"""Let the env/headers tables and the args editor gate "move to
environment variable" on which client the loaded profile targets (#83)."""
self.env.profile_provider = provider
self.headers.profile_provider = provider
self.args.profile_provider = provider
# --- model <-> form -------------------------------------------------- #
def load_entry(self, entry: core.ServerEntry | None):
@@ -1044,6 +1056,50 @@ class ArgsEdit(QPlainTextEdit):
self.blockCountChanged.connect(self._update_gutter_width)
self.updateRequest.connect(self._on_update_request)
self._update_gutter_width()
# Set by ServerEditor so "move to environment variable" (#83) can gate on
# the loaded client, same as the env/headers tables.
self.profile_provider = None
def contextMenuEvent(self, event):
menu = self.createStandardContextMenu() # keep cut/copy/paste
lines = self.toPlainText().splitlines()
block = self.cursorForPosition(event.pos()).blockNumber()
if 0 <= block < len(lines) and lines[block].strip():
# This editor is one arg per line; map the clicked block to its
# index among the non-blank args the model actually sees.
cleaned = [ln for ln in lines if ln.strip() != ""]
idx = sum(1 for ln in lines[:block] if ln.strip() != "")
if idx in set(core.secret_arg_indices(cleaned)):
profile = self.profile_provider() if self.profile_provider else None
act = QAction("Move to environment variable…", self)
if profile is None or core.client_expands_env_refs(profile):
act.triggered.connect(lambda: self._move_arg_to_env(block, idx, cleaned))
else:
act.setEnabled(False)
act.setText("Move to environment variable — unavailable for Claude Desktop")
act.setToolTip(
"Claude Desktop doesn't expand ${VAR}, so a reference would "
"reach the server as literal text."
)
first = menu.actions()[0] if menu.actions() else None
menu.insertAction(first, act)
if first is not None:
menu.insertSeparator(first)
menu.exec(event.globalPos())
def _move_arg_to_env(self, block: int, idx: int, cleaned: list):
value = cleaned[idx]
default_name = core.suggested_env_var_for_arg(cleaned, idx)
dlg = MoveToEnvDialog(self.window(), default_name, value)
if not dlg.exec():
return
var_name = dlg.var_name()
QGuiApplication.clipboard().setText(value)
lines = self.toPlainText().splitlines()
if 0 <= block < len(lines):
lines[block] = f"${{{var_name}}}"
# setPlainText fires textChanged -> _emit (dirty + arg recheck).
self.setPlainText("\n".join(lines))
def gutter_width(self) -> int:
digits = max(1, len(str(self.blockCount())))