From 831183308d4f6a04aac23f411ee3c0ba27b2c41e Mon Sep 17 00:00:00 2001 From: Jason Walker <913443@dadeschools.net> Date: Wed, 8 Jul 2026 00:23:29 -0400 Subject: [PATCH] feat: add root MCP operator shell menu (#478) Add ./mcp-menu.sh for safe, read-only onboarding and workflow prompt discovery. Includes root checkout health, role prompts, onboarding checklist, Proxmox placeholders, and run-tests fallback. Document in docs/mcp-menu.md with hermetic tests. Closes #478. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/mcp-menu.md | 67 ++++++++++ mcp-menu.sh | 232 ++++++++++++++++++++++++++++++++++ tests/test_mcp_menu_script.py | 124 ++++++++++++++++++ 3 files changed, 423 insertions(+) create mode 100644 docs/mcp-menu.md create mode 100755 mcp-menu.sh create mode 100644 tests/test_mcp_menu_script.py diff --git a/docs/mcp-menu.md b/docs/mcp-menu.md new file mode 100644 index 0000000..0b028f9 --- /dev/null +++ b/docs/mcp-menu.md @@ -0,0 +1,67 @@ +# MCP operator shell menu + +## Purpose + +`./mcp-menu.sh` is a repository-root terminal menu for onboarding and operating +the Gitea-Tools MCP/Gitea workflow without memorizing every prompt, script path, +or runbook section. + +It is intentionally **safe by default**: status checks and copy-paste workflow +prompts. It does not delete branches, force-push, edit lock files, or bypass +sanctioned MCP tools. + +## How to run + +From the repository root: + +```bash +./mcp-menu.sh +``` + +The script must be executable (`chmod +x mcp-menu.sh`). It uses bash with +`set -euo pipefail`. + +## Safety rules + +- **Read-only by default** — root checkout health is inspection only. +- **No destructive git** — no `git push --force`, branch deletion, or + `--delete` refspecs. +- **No lock-file editing** — issue locks are acquired only through + `gitea_lock_issue`. +- **No raw API bypass** — prompts direct operators to sanctioned MCP tools. +- **Remote mutations require confirmation** — any future menu action that would + mutate remote or server state must be clearly labeled and require explicit + operator confirmation before running. +- **Author work stays under `branches/`** — the root checkout is a stable + control checkout on `master` / `prgs/master`. + +## Menu options + +| Option | Description | +|--------|-------------| +| Project status / root checkout health | Shows cwd, branch, `git status --short --branch`, HEAD SHA, `prgs/master` SHA, and warnings when the root checkout is dirty or off `master`. | +| Author workflow prompts | Ready-to-copy prompts for issue work, conflict-fix sessions, and root checkout recovery. | +| Reviewer workflow prompts | PR review prompt (review-only; no merge). | +| Merger workflow prompts | PR merge prompt (merge gates and explicit approval). | +| Reconciler workflow prompts | Already-landed / closed PR reconciliation prompt. | +| Onboarding new project | Checklist prompt for adding a repository to the MCP workflow. | +| Proxmox deployment placeholder | **Not implemented** — informational message only. | +| Create Proxmox LXC placeholder | **Not implemented** — informational message only. | +| Run tests | Runs `./run-tests.sh` when present; otherwise `venv/bin/python -m pytest`; otherwise fails closed with a clear error. | +| Exit | Quit the menu. | + +## Placeholder-only entries + +**Proxmox deployment** and **Create Proxmox LXC** are placeholders until +dedicated issues implement sanctioned automation. The menu prints a clear +message and does not invoke deploy scripts. + +## Related documentation + +- [`docs/llm-workflow-runbooks.md`](llm-workflow-runbooks.md) — Gitea-specific workflow runbooks +- [`skills/llm-project-workflow/SKILL.md`](../skills/llm-project-workflow/SKILL.md) — portable workflow skill +- [`skills/llm-project-workflow/workflows/`](../skills/llm-project-workflow/workflows/) — canonical task workflows + +## Tests + +Hermetic coverage lives in `tests/test_mcp_menu_script.py`. \ No newline at end of file diff --git a/mcp-menu.sh b/mcp-menu.sh new file mode 100755 index 0000000..4d124f2 --- /dev/null +++ b/mcp-menu.sh @@ -0,0 +1,232 @@ +#!/usr/bin/env bash +# mcp-menu.sh — Repository-root operator menu for MCP/Gitea workflow onboarding. +# +# Safe by default: read-only status and copy-paste prompts unless an action is +# explicitly labeled and confirmed. No branch deletion, force-push, lock-file +# editing, or raw API bypass. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$SCRIPT_DIR" + +pause() { + read -r -p "Press Enter to return to the menu..." +} + +print_banner() { + printf '\n=== Gitea-Tools MCP Operator Menu ===\n' + printf 'Repository: %s\n' "$REPO_ROOT" + printf 'Safe by default — destructive actions require explicit confirmation.\n\n' +} + +show_root_checkout_health() { + printf '\n--- Project status / root checkout health ---\n\n' + printf 'Current directory: %s\n' "$(pwd)" + local branch head_sha prgs_master_sha dirty + branch="$(git -C "$REPO_ROOT" branch --show-current 2>/dev/null || true)" + if [[ -z "$branch" ]]; then + branch="(detached HEAD)" + fi + printf 'Current branch: %s\n' "$branch" + printf '\nGit status (short, branch):\n' + git -C "$REPO_ROOT" status --short --branch || true + head_sha="$(git -C "$REPO_ROOT" rev-parse HEAD 2>/dev/null || echo 'unknown')" + printf '\nHEAD SHA: %s\n' "$head_sha" + if git -C "$REPO_ROOT" rev-parse --verify prgs/master >/dev/null 2>&1; then + prgs_master_sha="$(git -C "$REPO_ROOT" rev-parse prgs/master)" + printf 'prgs/master SHA: %s\n' "$prgs_master_sha" + if [[ "$head_sha" != "$prgs_master_sha" ]]; then + printf '\nWARNING: root checkout HEAD does not match prgs/master.\n' + printf 'Keep the stable control checkout on master/prgs/master.\n' + fi + else + printf 'prgs/master SHA: unavailable (remote ref not fetched)\n' + fi + if [[ -n "$(git -C "$REPO_ROOT" status --porcelain 2>/dev/null || true)" ]]; then + printf '\nWARNING: root checkout has uncommitted changes (dirty).\n' + printf 'Author mutations belong in a session worktree under branches/.\n' + fi + if [[ "$branch" != "master" && "$branch" != "main" && "$branch" != "dev" ]]; then + printf '\nWARNING: root checkout is not on a stable base branch (master/main/dev).\n' + printf 'Return to master before using the control checkout.\n' + fi + pause +} + +print_prompt_block() { + local title="$1" + local body="$2" + printf '\n--- %s ---\n\n' "$title" + printf '%s\n' "$body" + printf '\n(Copy the prompt above into your LLM session.)\n' + pause +} + +show_author_prompts() { + while true; do + printf '\n--- Author workflow prompts ---\n' + printf ' 1) Work issue (author/coder)\n' + printf ' 2) Conflict-fix author session\n' + printf ' 3) Root checkout recovery session\n' + printf ' 0) Back\n' + read -r -p 'Choice: ' choice + case "$choice" in + 1) + print_prompt_block "Author — work issue" \ +"You are the AUTHOR session for /. + +Goal: implement issue # only. + +Workflow: +1. Preflight: prove identity, work_issue/create_pr capability, clean session worktree under branches/. +2. gitea_lock_issue for issue # and branch feat/issue--. +3. Implement in the locked worktree only — never mutate the root control checkout. +4. Validate, commit, push, gitea_create_pr. Final report with issue, branch, SHA, PR, tests, mutation ledger." + ;; + 2) + print_prompt_block "Author — conflict-fix session" \ +"You are the AUTHOR session for / in conflict-fix mode. + +Goal: resolve merge conflicts on PR #

/ branch only. + +Workflow: +1. Preflight: prove author identity and exact push/commit capability for the locked PR branch. +2. Confirm conflict-fix lease and stale-head protection before pushing. +3. Work only in the session-owned worktree under branches/ — never the root checkout. +4. Rebase or merge target branch, run tests, push, update PR. No force-push without explicit operator approval. +5. Final report: conflict resolution proof, new HEAD SHA, tests, mutation ledger." + ;; + 3) + print_prompt_block "Author — root checkout recovery" \ +"You are a RECOVERY session for /. + +Goal: restore the stable root control checkout to clean master/prgs/master. + +Workflow: +1. Inspect root checkout: branch, git status --short --branch, HEAD vs prgs/master. +2. Do not implement features from the root checkout. Stash or move work to branches/ first. +3. Return root to master (or main/dev per project policy) matching prgs/master with no dirty tracked files. +4. Report before/after branch, SHA, dirty state, and safe next action for author worktree creation." + ;; + 0) return ;; + *) printf 'Invalid choice.\n' ;; + esac + done +} + +show_reviewer_prompts() { + print_prompt_block "Reviewer — PR review" \ +"You are the REVIEWER session for /. + +Goal: review PR #

only — do not merge unless explicitly switched to merger mode. + +Workflow: +1. Load canonical workflow: skills/llm-project-workflow/workflows/review-merge-pr.md +2. Preflight: prove reviewer identity, review_pr capability, clean review worktree. +3. gitea_view_pr, validate scope, run required checks in the correct worktree. +4. gitea_review_pr with approve, request-changes, or comment as warranted. +5. Final report: PR head SHA, verdict, validation evidence, mutation ledger. No merge in reviewer-only runs." +} + +show_merger_prompts() { + print_prompt_block "Merger — PR merge" \ +"You are the MERGER session for /. + +Goal: merge PR #

only after every gate passes. + +Workflow: +1. Load canonical workflow: skills/llm-project-workflow/workflows/review-merge-pr.md +2. Preflight: prove merger identity and exact merge_pr capability for the current PR head SHA. +3. Confirm approval pins the current head SHA; re-validate if the branch moved. +4. gitea_merge_pr only on explicit operator approval after all gates pass. +5. Final report: merged SHA, cleanup handoff, mutation ledger." +} + +show_reconciler_prompts() { + print_prompt_block "Reconciler — already-landed / closed PR cleanup" \ +"You are the RECONCILER session for /. + +Goal: reconcile already-landed open PRs — close or comment only when exact capability is proven. + +Workflow: +1. Load canonical workflow: skills/llm-project-workflow/workflows/reconcile-landed-pr.md +2. Preflight: prove reconciler identity and gitea.pr.close (or authorized close) capability. +3. Do not review, merge, implement code, or create branches. +4. gitea_scan_already_landed_open_prs / gitea_reconcile_already_landed_pr as appropriate. +5. Final report: PR numbers handled, close proof, mutation ledger." +} + +show_onboarding_prompt() { + print_prompt_block "Onboarding — new project to MCP workflow" \ +"Onboard / into the MCP Control Plane workflow. + +Checklist: +1. Prove identity and task capability via gitea_whoami and gitea_resolve_task_capability. +2. Configure separate MCP namespaces/profiles: author, reviewer, merger/reconciler as needed. +3. Register gitea-tools (and jenkins-mcp / glitchtip-mcp if applicable) in the client MCP config. +4. Copy skills/llm-project-workflow/SKILL.md guidance into the target repo or ECC install. +5. Verify gitea_get_runtime_context, gitea_lock_issue, and worktree rules under branches/. +6. Run ./mcp-menu.sh for day-to-day prompts; use docs/mcp-menu.md and docs/llm-workflow-runbooks.md. + +Canonical router: skills/llm-project-workflow/SKILL.md" +} + +show_proxmox_placeholder() { + printf '\n--- Proxmox deployment (placeholder) ---\n\n' + printf 'Push this project to Proxmox — TODO / issue-backed\n' + printf 'Create Proxmox LXC — TODO / issue-backed\n\n' + printf 'These actions are NOT implemented yet.\n' + printf 'Track deployment automation in dedicated Gitea issues before enabling here.\n' + printf 'This menu will not run deploy scripts until sanctioned tooling exists.\n' + pause +} + +run_tests() { + printf '\n--- Run tests ---\n\n' + if [[ -x "$REPO_ROOT/run-tests.sh" ]]; then + printf 'Running ./run-tests.sh ...\n\n' + (cd "$REPO_ROOT" && ./run-tests.sh) + pause + return + fi + if [[ -x "$REPO_ROOT/venv/bin/python" ]]; then + printf 'run-tests.sh not found; falling back to venv/bin/python -m pytest\n\n' + (cd "$REPO_ROOT" && ./venv/bin/python -m pytest) + pause + return + fi + printf 'ERROR: No test runner available (fail closed).\n' >&2 + printf 'Expected ./run-tests.sh or venv/bin/python for pytest fallback.\n' >&2 + exit 1 +} + +main_menu() { + while true; do + print_banner + printf ' 1) Project status / root checkout health\n' + printf ' 2) Author workflow prompts\n' + printf ' 3) Reviewer workflow prompts\n' + printf ' 4) Merger workflow prompts\n' + printf ' 5) Reconciler workflow prompts\n' + printf ' 6) Onboarding new project to this MCP workflow\n' + printf ' 7) Proxmox deployment menu placeholder\n' + printf ' 8) Create Proxmox LXC placeholder\n' + printf ' 9) Run tests\n' + printf ' 0) Exit\n' + read -r -p 'Choice: ' choice + case "$choice" in + 1) show_root_checkout_health ;; + 2) show_author_prompts ;; + 3) show_reviewer_prompts ;; + 4) show_merger_prompts ;; + 5) show_reconciler_prompts ;; + 6) show_onboarding_prompt ;; + 7|8) show_proxmox_placeholder ;; + 9) run_tests ;; + 0) printf 'Goodbye.\n'; exit 0 ;; + *) printf 'Invalid choice.\n'; pause ;; + esac + done +} + +main_menu \ No newline at end of file diff --git a/tests/test_mcp_menu_script.py b/tests/test_mcp_menu_script.py new file mode 100644 index 0000000..a447648 --- /dev/null +++ b/tests/test_mcp_menu_script.py @@ -0,0 +1,124 @@ +"""Hermetic tests for repository-root MCP operator menu (#478).""" +import os +import stat +import unittest +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent +SCRIPT = REPO_ROOT / "mcp-menu.sh" +DOCS = REPO_ROOT / "docs" / "mcp-menu.md" + +REQUIRED_MENU_LABELS = ( + "Project status / root checkout health", + "Author workflow prompts", + "Reviewer workflow prompts", + "Merger workflow prompts", + "Reconciler workflow prompts", + "Onboarding new project to this MCP workflow", + "Proxmox deployment menu placeholder", + "Create Proxmox LXC placeholder", + "Run tests", + "Exit", +) + +DANGEROUS_PATTERNS = ( + "git push --force", + "git push -f", + "--force-with-lease", + "delete-branch", + "gitea_delete_branch", + "issue-locks", + "issue_lock_store", + "curl ", + "wget ", +) + + +class TestMcpMenuScript(unittest.TestCase): + def setUp(self): + self.assertTrue(SCRIPT.is_file(), "mcp-menu.sh must exist at repo root") + self.content = SCRIPT.read_text(encoding="utf-8") + + def test_script_exists_at_repo_root(self): + self.assertEqual(SCRIPT.name, "mcp-menu.sh") + self.assertEqual(SCRIPT.parent, REPO_ROOT) + + def test_executable_bit_is_set(self): + mode = SCRIPT.stat().st_mode + self.assertTrue(mode & stat.S_IXUSR, "mcp-menu.sh must be executable by owner") + + def test_shebang(self): + first_line = self.content.splitlines()[0] + self.assertEqual(first_line, "#!/usr/bin/env bash") + + def test_uses_set_euo_pipefail(self): + self.assertIn("set -euo pipefail", self.content) + + def test_no_dangerous_commands(self): + lowered = self.content.lower() + for pattern in DANGEROUS_PATTERNS: + with self.subTest(pattern=pattern): + self.assertNotIn(pattern.lower(), lowered) + + def test_no_branch_deletion_verbs(self): + for token in ("git branch -D", "git branch -d", "push :refs"): + with self.subTest(token=token): + self.assertNotIn(token, self.content) + + def test_contains_required_menu_labels(self): + for label in REQUIRED_MENU_LABELS: + with self.subTest(label=label): + self.assertIn(label, self.content) + + def test_run_tests_prefers_run_tests_sh(self): + self.assertIn('run-tests.sh', self.content) + run_tests_idx = self.content.index("run_tests()") + run_tests_body = self.content[run_tests_idx : run_tests_idx + 800] + pytest_idx = run_tests_body.find("pytest") + run_tests_sh_idx = run_tests_body.find("run-tests.sh") + self.assertGreater(pytest_idx, 0) + self.assertGreater(run_tests_sh_idx, 0) + self.assertLess(run_tests_sh_idx, pytest_idx) + + def test_run_tests_fail_closed_without_runner(self): + self.assertIn("fail closed", self.content.lower()) + self.assertIn("exit 1", self.content) + + def test_proxmox_entries_are_placeholders(self): + self.assertIn("TODO / issue-backed", self.content) + self.assertIn("NOT implemented", self.content) + + def test_root_health_shows_required_fields(self): + health_fn = self._extract_function("show_root_checkout_health") + for snippet in ( + "status --short --branch", + "rev-parse HEAD", + "prgs/master", + "WARNING: root checkout", + ): + with self.subTest(snippet=snippet): + self.assertIn(snippet, health_fn) + + def test_docs_mention_mcp_menu_sh(self): + self.assertTrue(DOCS.is_file(), "docs/mcp-menu.md must exist") + docs_text = DOCS.read_text(encoding="utf-8") + self.assertIn("./mcp-menu.sh", docs_text) + self.assertIn("placeholder", docs_text.lower()) + + def _extract_function(self, name: str) -> str: + marker = f"{name}() {{" + start = self.content.index(marker) + depth = 0 + for idx in range(start, len(self.content)): + char = self.content[idx] + if char == "{": + depth += 1 + elif char == "}": + depth -= 1 + if depth == 0: + return self.content[start : idx + 1] + self.fail(f"Could not parse function {name}") + + +if __name__ == "__main__": + unittest.main() \ No newline at end of file