feat: interactive setup menu for canonical Gitea MCP profiles (#31)
Add an interactive utility so users create/edit/validate canonical runtime profiles and generate safe LLM launcher snippets without hand-editing JSON or pasting tokens into Claude/Gemini/Codex configs. Run: `python gitea_config.py menu` (or `python gitea_config_menu.py`). gitea_config.py — pure, testable authoring helpers: - is_valid_profile_name, build_profile, keychain_auth/env_auth, empty_config - validate_config (reports missing base_url/auth, inline token/password — never echoing the secret value) - add_profile (preserves existing, rejects dup/invalid name/missing base_url), upsert_profile, remove_profile - save_config: mkdir parents + atomic temp-then-os.replace, pretty JSON - launcher_entry: thin MCP entry (command/args + GITEA_MCP_CONFIG/PROFILE only) - keychain_set: store a token via `security add-generic-password` (token passed as an arg, never returned/printed/logged; injectable runner) - `menu` __main__ dispatch gitea_config_menu.py — interactive loop with fully injectable IO/secret/HTTP/ keychain so it is testable without a real terminal, keychain, or network: - list / add / edit / remove / validate profiles - test authentication + show authenticated user (calls /user only on request) - reviewer-eligibility helper (authenticated user vs PR author, open state) — read-only, never approves/merges - launcher snippets for Claude / Gemini / Codex (no secrets) Security: tokens are never written to profiles.json, launcher snippets, logs, or errors — only keychain ids / env var names are stored. Backwards compatible: menu is optional; env-only mode and MCP server startup are unchanged. Tests: tests/test_config_menu.py (21 cases) — name validation, preserve-on-add, dup/invalid/missing-field rejection, atomic write (+ replace-failure leaves the original intact, no temp debris), keychain_set stores-without-printing, launcher snippets secret-free, eligibility eligible/self-author/closed, and a full menu add→list→quit flow proving the token value never reaches disk or stdout. Stacked on #30 (canonical profiles); base branch feat/json-runtime-profiles. Refs #10, #19. Closes #31. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+213
@@ -44,7 +44,10 @@ Design constraints:
|
||||
tokens, or passwords.
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import json
|
||||
import tempfile
|
||||
import subprocess
|
||||
|
||||
ENV_CONFIG_PATH = "GITEA_MCP_CONFIG"
|
||||
@@ -53,6 +56,14 @@ ENV_PROFILE = "GITEA_MCP_PROFILE"
|
||||
SUPPORTED_VERSION = 1
|
||||
_AUTH_TYPES = ("keychain", "env")
|
||||
|
||||
# Profile names go into env vars, keychain ids, and JSON keys — keep them tame.
|
||||
_PROFILE_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
|
||||
|
||||
# Default canonical config location (one file shared by all LLM launchers).
|
||||
DEFAULT_CONFIG_PATH = os.path.join(
|
||||
os.path.expanduser("~"), ".config", "gitea-tools", "profiles.json"
|
||||
)
|
||||
|
||||
|
||||
class ConfigError(Exception):
|
||||
"""Raised for a bad config file, profile selection, or secret reference.
|
||||
@@ -234,3 +245,205 @@ def resolve_token(profile, keychain_lookup=_keychain_token):
|
||||
)
|
||||
return value
|
||||
raise ConfigError(f"unsupported auth type {atype!r} in the selected profile")
|
||||
|
||||
|
||||
# ── Config authoring helpers (used by the interactive menu; pure + testable) ────
|
||||
|
||||
def is_valid_profile_name(name):
|
||||
"""True if *name* is a safe profile key (alnum, dot, dash, underscore)."""
|
||||
return bool(name) and bool(_PROFILE_NAME_RE.match(name))
|
||||
|
||||
|
||||
def keychain_auth(item_id):
|
||||
"""Build a keychain auth reference."""
|
||||
return {"type": "keychain", "id": item_id}
|
||||
|
||||
|
||||
def env_auth(var_name):
|
||||
"""Build an env auth reference."""
|
||||
return {"type": "env", "name": var_name}
|
||||
|
||||
|
||||
def build_profile(*, base_url, auth, username=None, default_owner=None,
|
||||
default_repo=None, execution_profile=None):
|
||||
"""Assemble a profile dict, omitting empty optional fields. No secrets."""
|
||||
profile = {"base_url": base_url, "auth": auth}
|
||||
if username:
|
||||
profile["username"] = username
|
||||
if default_owner:
|
||||
profile["default_owner"] = default_owner
|
||||
if default_repo:
|
||||
profile["default_repo"] = default_repo
|
||||
if execution_profile:
|
||||
profile["execution_profile"] = execution_profile
|
||||
return profile
|
||||
|
||||
|
||||
def empty_config():
|
||||
"""Return a fresh, valid canonical config with no profiles."""
|
||||
return {"version": SUPPORTED_VERSION, "profiles": {}}
|
||||
|
||||
|
||||
def validate_config(config):
|
||||
"""Return a list of human-readable problems with *config* (empty = valid).
|
||||
|
||||
Never includes secret material — profiles carry only auth *references*.
|
||||
"""
|
||||
problems = []
|
||||
if not isinstance(config, dict):
|
||||
return ["config is not a JSON object"]
|
||||
if config.get("version", SUPPORTED_VERSION) != SUPPORTED_VERSION:
|
||||
problems.append(
|
||||
f"unsupported version {config.get('version')!r} (expected {SUPPORTED_VERSION})"
|
||||
)
|
||||
profiles = config.get("profiles")
|
||||
if not isinstance(profiles, dict):
|
||||
problems.append("missing 'profiles' object")
|
||||
return problems
|
||||
for name, profile in profiles.items():
|
||||
if not is_valid_profile_name(name):
|
||||
problems.append(f"invalid profile name {name!r}")
|
||||
if not isinstance(profile, dict):
|
||||
problems.append(f"profile '{name}' is not an object")
|
||||
continue
|
||||
if not profile.get("base_url"):
|
||||
problems.append(f"profile '{name}' is missing 'base_url'")
|
||||
for secret_key in ("token", "password"):
|
||||
if secret_key in profile:
|
||||
problems.append(
|
||||
f"profile '{name}' has an inline '{secret_key}' (use an auth reference)"
|
||||
)
|
||||
try:
|
||||
_validate_auth(name, profile.get("auth"))
|
||||
except ConfigError as exc:
|
||||
problems.append(str(exc))
|
||||
else:
|
||||
if profile.get("auth") is None:
|
||||
problems.append(f"profile '{name}' is missing an 'auth' reference")
|
||||
return problems
|
||||
|
||||
|
||||
def add_profile(config, name, profile):
|
||||
"""Return a copy of *config* with *profile* added under *name*.
|
||||
|
||||
Preserves existing profiles. Raises :class:`ConfigError` on an invalid name,
|
||||
a duplicate name, or an invalid profile.
|
||||
"""
|
||||
if not is_valid_profile_name(name):
|
||||
raise ConfigError(
|
||||
f"invalid profile name {name!r}; use letters, digits, '.', '-', '_'"
|
||||
)
|
||||
profiles = dict(config.get("profiles") or {})
|
||||
if name in profiles:
|
||||
raise ConfigError(f"profile '{name}' already exists; edit or remove it first")
|
||||
candidate = {"version": config.get("version", SUPPORTED_VERSION),
|
||||
"profiles": {**profiles, name: profile}}
|
||||
# Validate just this profile (reuse select_profile's checks).
|
||||
select_profile(candidate, name)
|
||||
if not profile.get("base_url"):
|
||||
raise ConfigError(f"profile '{name}' is missing 'base_url'")
|
||||
return candidate
|
||||
|
||||
|
||||
def upsert_profile(config, name, profile):
|
||||
"""Like :func:`add_profile` but replaces an existing profile (for edits)."""
|
||||
if not is_valid_profile_name(name):
|
||||
raise ConfigError(f"invalid profile name {name!r}")
|
||||
profiles = dict(config.get("profiles") or {})
|
||||
profiles[name] = profile
|
||||
candidate = {"version": config.get("version", SUPPORTED_VERSION),
|
||||
"profiles": profiles}
|
||||
select_profile(candidate, name)
|
||||
return candidate
|
||||
|
||||
|
||||
def remove_profile(config, name):
|
||||
"""Return a copy of *config* without profile *name*."""
|
||||
profiles = dict(config.get("profiles") or {})
|
||||
if name not in profiles:
|
||||
raise ConfigError(f"profile '{name}' not found")
|
||||
del profiles[name]
|
||||
return {"version": config.get("version", SUPPORTED_VERSION), "profiles": profiles}
|
||||
|
||||
|
||||
def save_config(config, path=None):
|
||||
"""Atomically write *config* to *path* as pretty JSON, creating parent dirs.
|
||||
|
||||
Writes a temp file in the same directory then ``os.replace``s it into place,
|
||||
so a crash mid-write never truncates an existing config.
|
||||
"""
|
||||
path = path or config_path() or DEFAULT_CONFIG_PATH
|
||||
directory = os.path.dirname(os.path.abspath(path))
|
||||
os.makedirs(directory, exist_ok=True)
|
||||
fd, tmp = tempfile.mkstemp(dir=directory, prefix=".profiles-", suffix=".json")
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
||||
json.dump(config, fh, indent=2, sort_keys=True)
|
||||
fh.write("\n")
|
||||
os.replace(tmp, path)
|
||||
except BaseException:
|
||||
try:
|
||||
os.unlink(tmp)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
return path
|
||||
|
||||
|
||||
def server_command():
|
||||
"""Return (command, args) that launch this repo's MCP server."""
|
||||
root = os.path.dirname(os.path.abspath(__file__))
|
||||
python = os.path.join(root, "venv", "bin", "python3")
|
||||
if not os.path.exists(python):
|
||||
python = sys.executable
|
||||
return python, [os.path.join(root, "mcp_server.py")]
|
||||
|
||||
|
||||
def launcher_entry(profile_name, config_path=None):
|
||||
"""Return a thin MCP launcher entry for *profile_name*.
|
||||
|
||||
Contains only command/args and the two GITEA_MCP_* env vars — never a token
|
||||
or password. Suitable for Claude / Gemini / Codex ``mcpServers`` blocks.
|
||||
"""
|
||||
command, args = server_command()
|
||||
return {
|
||||
"gitea-tools": {
|
||||
"command": command,
|
||||
"args": args,
|
||||
"env": {
|
||||
"GITEA_MCP_CONFIG": config_path or DEFAULT_CONFIG_PATH,
|
||||
"GITEA_MCP_PROFILE": profile_name,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def keychain_set(item_id, token, account=None, runner=subprocess.run):
|
||||
"""Store *token* in the macOS keychain under service *item_id*.
|
||||
|
||||
The token is passed to ``security`` as an argument only; it is never
|
||||
returned, printed, or logged here. *runner* is injectable for testing.
|
||||
Raises :class:`ConfigError` on failure (without echoing the token).
|
||||
"""
|
||||
if not item_id:
|
||||
raise ConfigError("keychain item id is required")
|
||||
if not token:
|
||||
raise ConfigError("refusing to store an empty token")
|
||||
account = account or os.environ.get("USER") or "gitea-tools"
|
||||
cmd = ["security", "add-generic-password", "-U",
|
||||
"-s", item_id, "-a", account, "-w", token]
|
||||
try:
|
||||
proc = runner(cmd, capture_output=True, text=True)
|
||||
except (OSError, subprocess.SubprocessError) as exc:
|
||||
raise ConfigError(f"could not run keychain store for '{item_id}'") from exc
|
||||
if getattr(proc, "returncode", 1) != 0:
|
||||
raise ConfigError(f"keychain store failed for item '{item_id}'")
|
||||
return item_id
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover - thin CLI dispatch
|
||||
if len(sys.argv) > 1 and sys.argv[1] == "menu":
|
||||
import gitea_config_menu
|
||||
raise SystemExit(gitea_config_menu.main(sys.argv[2:]))
|
||||
print("usage: python gitea_config.py menu", file=sys.stderr)
|
||||
raise SystemExit(2)
|
||||
|
||||
Reference in New Issue
Block a user