CLI Comparison

What this answers: which client covers what, for someone — human or agent — picking a forge CLI. It compares madtea against the four relevant clients, feature by feature:

  • gh — the GitHub CLI, the Microsoft-backed benchmark everyone knows. gh cannot talk to a Forgejo/Gitea server at all; its column reads “gh offers this on its own forge” — the capability bar to clear, not an alternative client. gh is also madtea’s naming yardstick: ADR 0016 makes gh-then-tea vocabulary a recorded, test-enforced rule (internal/parity/), so gh muscle-memory lands on the right verb.
  • glab — GitLab’s official CLI. Like gh, it cannot talk to a Forgejo/Gitea server; its column reads “glab offers this on its own forge (GitLab)”. GitLab concepts are mapped to their forge equivalents (MR ≈ PR, pipeline ≈ Actions run, group ≈ org).
  • tea — Gitea’s official CLI. Same backend as madtea.
  • fj — forgejo-cli, the Forgejo-native community CLI. Same backend as madtea.

Server-side compatibility is a different question. “Will this feature work against my Forgejo/Gitea server?” lives in COMPAT.md, which owns the platform axis. A row below carries at most a terse pointer there.

This is a matrix, not a manual: Notes cells name surfaces (subcommands, flags, MCP tools/actions) in a clause or two. Semantics live in docs/reference/ and madt_help; a size test caps row length to keep it that way.

Legend: ✅ covered · ⚠️ partial — see notes · ❌ not covered. Each cell describes what that client implements for its own forge (gh: GitHub; glab: GitLab). Sources of truth. madtea: internal/cmd/. gh: github.com/cli/cli (b300f2e, 2026-07-02). glab: gitlab.com/gitlab-org/cli (53a036e, 2026-07-03). tea: gitea.com/gitea/tea (HEAD as of 2026-05-26). fj: codeberg.org/Cyborus/forgejo-cli (db218ec, 2026-07-03). Coverage current as of: 2026-07-10.

Coverage

Of the 81 features below, madtea fully covers 80 — gh fully covers 28, glab 29, tea 26, and fj 18. Every feature any of the four covers, madtea covers too; madtea’s one partial (project boards) is a forge-API limit, and it is the row gh and glab lead. The numbers are counted over the tables below, and internal/docs/ tests recompute them and the superset property — this paragraph cannot silently rot.


Commands

get is the canonical fetch-single verb on both surfaces; view (gh’s verb) is accepted everywhere as a working synonym (ADR 0016 amendment, legacy tracker 1874). Rows below list canonical names only.

Workflows (madtea-unique)

FeaturemadteaghglabteafjNotes
finishAtomic commit + push + PR + merge + return to default. --branch/branch= finishes a named branch without checkout (linked-worktree flow; skips commit); --files/files= stage named paths in the same call (no blanket -A/all=true mode, legacy tracker 1964); mutually exclusive with --no-commit/no_commit. --owner-repo/owner_repo= completes a forge-only branch with no local checkout (API-plane PR + merge + remote-branch delete; requires --branch). See workflows.
init⚠️Create new repo via API + push initial commit. gh/fj: repo create --push; glab project create inits/remotes but doesn’t push.
orchestrateBatch PR merge with verification gates and bisect failure isolation (no --style → honors repo default_merge_style), plus orchestrate reconcile <pr> (--continue/--abort/--owner-repo; MCP reconcile/reconcile_continue/reconcile_abort/dir/owner_repo) — force-push-free recovery when a PR’s base moved (foreign PR: reconcile its worktree via dir=, never the primary). See workflows.
pruneBranch cleanup: deletes merged local branches; --remote also deletes their remote twins and merged remote orphans (cleaning the now-stale remote-tracking refs). Default is a dry run listing what would go; --delete-branches (was --yes/-y, still accepted as deprecated alias) also deletes squash-merged (content-verified) branches (and their remote twins under --remote); --count prints the prunable count and exits (dry run; used by scripts/release.sh post-release report); worktree-held branches skipped not errored. glab: project prune.
secret-execExec a child process with MADTEA_TOKEN injected. See secret-exec.
adr new / adr checkADR records: adr new <slug> (--title, --tracking, --offline) numbers from origin’s default branch; adr check guards number/index/link drift (exit 1). MCP madt_adr actions new/check. See workflows.

Issues, PRs, releases, labels, milestones

FeaturemadteaghglabteafjNotes
issue⚠️Full CRUD + close, reopen, cross-repo search, comments, deps, blocks, lock, pin, deadline, time, reactions, subscribe, develop, timeline, rank, issue/comment attachments. Verb edit (gh/tea) not update, batch edit (per-item partial-success), as do lock/unlock/subscribe/unsubscribe/deadline/reactions add
pr / pulls⚠️Full CRUD + diff/patch/files/commits, comments (add/list/edit/delete), reviews + review comments, resolve/unresolve, reviewers, conflicts, checks, is-merged, checkout, revert, close, ready, pinned, update-branch. Verb edit not update; update-branch distinct. gh-canonical pr merge --admin/--match-head-commit (no strategy flag → honors repo default_merge_style), pr create -B=--base. pr close (merge via madt_finish), pr ready/--undo batch. pr diff --file; pr review-comment-add --line/--old-line. Anchored `pr body patch
pr cancel-merge⚠️Cancel a pending scheduled auto-merge. gh: pr merge --disable-auto; glab: mr merge --auto-merge=false. Gitea-only (COMPAT).
releaseList, get, create, edit, delete (by ID or tag), latest, by-tag, upload, download, delete-asset, and an assets subgroup (list/get/edit); verbs/nouns match gh and tea. download rejects asset names with path separators or traversal segments to prevent writes outside the target directory. The irreversible delete/delete-by-tag are refusal stubs on both surfaces (omitted, ADR 0029) — delete a release in the forge web UI; recoverable delete-asset stays on madt_releases.
labelsList, get, create, update, delete, add, remove, set.
org-labelsOrg-level label templates (/orgs/{org}/labels). glab: label -g; fj: org label.
milestoneList, get, create, update, delete, close, reopen. gh has no milestone command.

Repository content

FeaturemadteaghglabteafjNotes
repo⚠️Create, list, get, edit, delete, fork, transfer, archive download, generate from template, sync-fork/merge-upstream, search. list --check-metadata / search --check-metadata (check_metadata MCP) audits repos for missing description/topics/website. gh: no transfer; glab: no sync-fork; fj: no transfer/search/sync-fork. The irreversible repo delete is a refusal stub on both surfaces (omitted, ADR 0029) — delete a repository in the forge web UI.
repo collaborators⚠️Add/remove/list collaborators (/collaborators). glab: list only (project members).
repo teamsList/check/add/remove teams with repo access (/repos/{r}/teams). fj: org team repo.
repo hooks✅ (webhooks)Webhook CRUD.
repo protect⚠️Branch protection rule CRUD. gh ruleset: read-only.
repo tag-protectTag protection rule CRUD.
repo push-mirrors⚠️Manage push mirrors. glab: project mirror; fj: via repo migrate --mirror.
repo git-hooksServer-side git hooks (admin-scope); MCP madt_repos actions git_hooks_{list,get,edit,delete}.
repo flagsForgejo-only, config-gated (COMPAT).
file / dir⚠️Read/create/update/delete files, list dirs. Bulk file contents and file get-ext are Gitea-only (COMPAT). file create/update take commit-identity overrides (--author-name/-email, --committer-name/-email, --date-author, --date-committer), CLI + MCP. gh: read-only (repo read-file/read-dir).
commitsList, get, diff, compare, pr-for-commit; commit CI status via commits status list/create and commits statuses combined.
branches-remote✅ (branches)Remote branch CRUD + rename; MCP madt_branches_remote. Renamed from tea’s branches for clarity vs the local branch wrapper (legacy tracker 1958).
tagsRemote tag CRUD; MCP madt_tags (gated: madt_enable(domains=["tags"])) for list/get/create; the irreversible delete is a refusal stub on both surfaces (omitted, ADR 0029) — delete a tag in the forge web UI. tea lists tags only via releases; fj: tag. (Local tag ops = separate tag wrapper.)
wiki✅ (added 2026-05-26 in tea issue 998)⚠️List, get, create, update, delete, revisions. fj: read/clone only. The irreversible page delete is a refusal stub on both surfaces (omitted, ADR 0029) — delete a wiki page in the forge web UI.

Actions / CI

Columns here compare client coverage only (gh: GitHub Actions; glab: GitLab pipelines). Much of the forge Actions REST surface is Gitea-only — COMPAT.md has the per-platform picture.

FeaturemadteaghglabteafjNotes
actions runs (list, get, watch)⚠️/actions/runs family. watch client-side polls runs get, bounded by --interval/--timeout (MCP action="run_watch"); see actions reference. glab: ci list/get/status; fj actions tasks: list only.
actions run-deleteGitea-only (COMPAT). glab: ci delete.
actions jobs / job / job-logsMCP madt_actions actions jobs, runners_jobs. Gitea-only (COMPAT). glab: ci trace/job artifact.
actions logsGitea-only (COMPAT). glab: ci trace.
actions rerun / rerun-jobGitea-only (COMPAT). glab: ci retry.
actions artifacts / run-artifactsRepo-wide group artifacts list/get/delete/download; per-run listing run-artifacts <run-id> (MCP madt_actions run_artifacts). Gitea-only (COMPAT). gh: run download; glab: ci artifact.
actions workflows (list/get/enable/disable)⚠️Gitea-only (COMPAT). glab: ci config/lint, no enable/disable.
actions dispatchPOST /actions/workflows/{wf}/dispatches. gh: workflow run; glab: ci run/trigger.
actions runnersRepo/org/user/admin runner scopes. actions runners --org / madt_actions org= reach an org’s runners ungated (no orgs-domain enable). runners_token response flagged as a credential (“treat as secret”). glab: runner + runnercontroller.
actions secrets, variables, tasks⚠️⚠️❌ (tea secrets)tea exposes top-level tea secrets, not nested; gh: secret/variable, no tasks; glab: variable (+securefile), no tasks; MCP madt_actions secrets_*/variables_* actions. tasks: --limit/--page (MCP limit/page); MCP include_payload opt-in for runs and tasks.

Organizations, users, notifications, packages

FeaturemadteaghglabteafjNotes
org⚠️✅ (organizations)Create, list, edit, rename, activity, permissions, repos, avatar, teams, members, public-members, blocks, hooks, secrets, variables, runners. No org delete (instance-breaking; removed #120). Membership checks (blocks check, public-members check, teams member-check, teams repo-check) → madt_orgs blocks_check/public_members_check/teams_member_check/teams_repo_check; also hooks_get/hooks_edit/runners_get/runners_jobs/variables_get/variables_create. Org secrets are write-only on the API (create/update only, no retrieval). gh org: list only; glab: group flags only, no group command.
user⚠️⚠️Self + others: info, repos, followers/following, starred, subscriptions, keys, GPG, OAuth2, emails, tokens, settings, activity, heatmap, times, runners, secrets, variables. gh: keys only (ssh-key/gpg-key); glab: keys/tokens/events only.
gpg-keygpg-key add [<file>], list, delete. Reads from file or stdin; validates PGP PUBLIC KEY block markers; scope preflight on add. gh: gpg-key add/list/delete. madt_users (gated) gpg_keys_list/add/delete.
user quotaForgejo-only, config-gated (COMPAT).
notificationsList, mark read, mark all read, repo-scoped, thread CRUD. glab: todo.
packagesList, get, delete, files, versions, link/unlink. glab: packages + container_registry.

Admin

FeaturemadteaghglabteafjNotes
adminServer administration; requires admin token. Subcommands: cron, emails, hooks, jobs, orgs, runners, runs, unadopted, users. (Badges live under admin users, not a top-level subcommand.) madt_admin (gated) mirrors the full CLI including user keys/orgs/emails/repos and hooks get/edit. No user delete (instance-breaking; removed #120).

Cross-cutting / metadata

FeaturemadteaghglabteafjNotes
search⚠️⚠️Subcommands: repos, users, issues, prs. gh adds code search; glab: semantic code search only; fj: users only cross-repo.
commits status / commits statuses (CI checks)⚠️⚠️⚠️commits status list/create (per-SHA list/post), commits statuses combined (rollup); no top-level CI-status command. gh/glab/fj: PR/branch-scoped only.
stats⚠️Repo/org/issue/contributor stats; aggregates other endpoints (no /stats paths). stats usage — local-only command analytics (CLI-only; --since, --limit, --html). MCP madt_stats. glab: project contributors only.
activitypubForgejo-only, config-gated (COMPAT). MCP madt_activitypub (gated): actor/user/repo actions.
project⚠️Project-board settings only (has_projects, projects_mode) — board CRUD is web-UI-only on both forges (COMPAT). gh (Projects-v2 CRUD) and glab (issue board) lead this row. MCP madt_project (gated via madt_enable(domains=["project"])).
openOpen repo/issue/PR URL in browser; local-only. gh: browse; glab: --web; fj: per-noun browse.
api⚠️Raw API passthrough: --jq, --template, --paginate, {owner}/{repo} expansion; exits non-zero on non-2xx (API error <code>: <body>; jq/template not applied to error responses) — flag-for-flag the gh api pattern. glab api: no --jq/--template. See raw API passthrough.
server-info✅ (tea -V)Detected platform + version (/version + /nodeinfo). fj version reports the client only.
templates⚠️Gitignore, label, license templates (/gitignore, /label/templates, /licenses). gh: gitignore/license only.
renderServer-side markdown/markup rendering (/markdown, /markup).
topicsSearch topics.
signing-keyInstance GPG signing key (/signing-key.gpg).
settingsRead-only instance config introspection (/settings/*).

Local git wrappers

FeaturemadteaghglabteafjNotes
cloneClone via API + auto-configure per-repo credentials; token injected so the URL stays credential-free.‡
addStage files by declared path (no blanket -A/--all/all=true, removed legacy tracker 1995 — a directory counts as a deliberate declaration); MCP madt_add returns the resulting staged list.†
statusWorking-tree status: branch, HEAD short sha, upstream ahead/behind, staged/unstaged/untracked/conflicted files, plus local-only release cadence latestTag/commitsSinceTag (and mainlineCommitsSinceTag on a feature branch); MCP madt_status. Remote-aware by default: wraps a short-timeout (~2s) git fetch so ahead/behind + mainline-behind reflect fresh refs, falling back to cached refs with a remoteStale/remoteWarning staleness note on timeout (no flag; never prints the remote URL). Opt-in file inventory via --glob/glob (comma-separated filepath.Match patterns; /-patterns path-scoped, else base-name; capped with filesTruncated), and opt-in branch-ahead commit list via --branch-commits/branch_commits (branchCommits, <mainlineRef>..HEAD). LOCAL checkout, distinct from remote file/dir.†
pushPush a branch (or --tags, CLI-only) to origin.‡ MCP madt_push — the publish primitive before madt_prs action=create (head branch must exist on the remote).†
pullFetch + integrate (git pull; --rebase).‡ MCP madt_pull. Renamed-default auto-repair: when a pull fails because the remote’s default branch was renamed, madtea confirms the new default via the API, re-tracks the local branch, and retries the fast-forward; unsafe cases (diverged, name taken, API unreachable) change nothing and print the hand-fix.†
fetchUpdate remote-tracking refs (git fetch); --all/--prune/--tags.‡ MCP madt_fetch — read-side primitive: freshens origin/<default> refs without touching the working tree or current branch; explicitly allowed on foreign primary dir= targets (unlike madt_pull; ADR 0019 legacy tracker 1914).†
branchLocal branch list / create-and-checkout (-b, --base start-point; MCP base) / SAFE merged-only delete (-d).‡ NEVER force-deletes — --force-delete/-D REMOVED, replaced by an actionable redirect. MCP madt_branch_create(branch=) + madt_branch_delete(branch=) (merged-only, refuses unmerged); MCP param renamed namebranch (#58). Remote branch CRUD is the separate branches-remote/madt_branches_remote.†
commitRecord a commit (git commit; staged-only by default). MCP madt_commit — checkpoint primitive: stages files= (or commits already-staged; no blanket all=true, legacy tracker 1964), commits, and confirms branch @ short hash + stats in ONE call. Refuses committing on the repo’s default branch (empty-repo bootstrap exempt); override per call with --allow-default-branch/allow_default_branch. Contrast madt_finish, which completes a branch.†
logCommit history (git log) with message search --grep (-i/--regexp-ignore-case), plus -n/--max-count, --oneline, --graph, a revision range, and --unreleased (logs <latest-tag>..HEAD). MCP madt_log (action=list; grep/sha/unreleased/limit/oneline/graph); a single ref with -n 1/limit=1 also resolves it. Message search lives ONLY here — the forge commits API has no message filter.†
tagLocal tag list (version-sorted) / create (<name> [<commit>], -a/-m, --push) / delete (-d).‡ MCP madt_tag_list — read-only version-sorted listing (name @ short sha; annotated tags peeled to their commit); create/delete stay CLI-only. Remote tag CRUD is the separate tags/madt_tags.†
worktreeManage linked worktrees (list/status/add/remove/rebase/prune; wraps git worktree). MCP madt_worktrees, action-dispatched. add <path> [<commit-ish>] follows git’s signature — the positional checks out an existing ref (never a silent create); -b <branch> is the explicit create form (MCP branch= keeps its create-or-checkout convenience). status: batch commit-clean report (HEAD, clean/dirty, ahead/behind). rebase <path> --onto <base> (--onto optional, defaults to forge default branch; conflict aborted clean). MCP rebase also accepts dir=<wt> direct or dir=<primary> path=<wt>. Remove refuses a dirty/locked worktree with NO force override on either surface — force=/--force REMOVED (unrecoverable), refusal names raw git worktree remove --force for the user; never escalates privileges, never rm -rf; prune the only metadata escape. add keeps -f/--force (path/branch reuse — destroys nothing).†§ See madt_help(topic="worktrees").
checkout, cherry-pick, diff, merge, rebase, remote (incl. remote add), reset, revert, stashThin git wrappers (recovery ops, CLI-only).‡§ NEVER runs an irreversible working-tree-destroying op — four modes REMOVED, each with an actionable redirect: reset --hard (--soft/--mixed kept, preserve edits), checkout [<ref>] -- <files> file-restore (branch-switching unaffected), stash drop/clear (pop + reconcile; save/pop/apply/list kept), commit --amend (make a new commit). Recoverable tag --force is kept — see its row (worktree remove --force is REMOVED, see the worktree row).

† MCP tool takes an optional dir targeting any git repo on disk, not confined to a workspace root (legacy tracker 1862); a FOREIGN target (a differing git-common-dir) needs the per-tool intent contract instead of a refusal — see ADR 0007’s amendment. ‡ Credentials routed through GIT_ASKPASS (never on argv/env/shell history); host-scoped to the configured forge, so a foreign-host origin fails auth closed instead of leaking the token. § API-sourced refs/paths guarded against --prefixed flag injection via a -- terminator or leading-dash rejection.

Setup, config, and self-management

FeaturemadteaghglabteafjNotes
auth login✅ (tea login add)Interactive credential setup, per-repo or global. Secure-backend storage by default (OS keychain, pass, or systemd-creds, auto-detected in that order); --insecure-storage forces plaintext git config. --web opens a browser OAuth (PKCE) login via a per-forge registered OAuth app (stored config key) and creates the API token automatically (gh/glab: auth login --web; fj: auth login); falls back to token paste. gh/glab/fj: auth login. Login origin-derived; --global deprecated (ADR 0025). MCP madt_auth_login runs the same browser OAuth in-session (secure-backend only, no --insecure-storage); the progress notification carries the authorize URL, whose loopback redirect must be reachable from your browser (run on the serve host, or ssh -L <port>:127.0.0.1:<port>). See setup.
auth logout✅ (tea logout)Clear madtea-stored credentials for a scope. madtea removes only madtea.* git-config keys + the secure-backend entry (OS keychain/pass/systemd-creds); never touches user.* or credential.helper. Requires interactive TTY confirmation — no bypass flag. gh: auth logout; glab: auth clear; fj: auth logout.
auth use-ssh✅ (auth use-ssh)Flip a git remote’s URL between HTTPS and SSH forms (toggles on each call). SSH = user-managed auth outside the token model. MCP madt_auth_use_ssh. fj: auth use-ssh.
auth whoami⚠️⚠️Authenticated identity, confirmed by a live /user read (not just local credential resolution), plus an on-demand token-scope check, a repo-level probe (GET /repos/<slug>), forge/origin host cross-check (split-brain warning), credential resolution chain (four rungs with winner marked), and forge-URL resolution chain (five steps including origin-remote rung, winner marked). MCP madt_auth_whoami, whose dir= reports a target checkout’s local-resolution identity (no server call, unverified). gh auth status reports scopes; glab/tea/fj have no scope check; fj identity only.
mcp-configPrint an MCP server config snippet for an AI agent via --client {claude,codex,antigravity} (auto-detects the installed agent when omitted); prints only — all wiring lives under madtea install. The retired gemini value is hidden and refuses (Gemini CLI shut down 2026-06-18) but still prints the snippet for enterprise licenses. --install is a hidden deprecated flag that always refuses and points at madtea install / madtea install --mcp-client. See clients.
serveStart the MCP server over stdio. glab: mcp serve.
completion✅ (tea autocomplete)Generate shell completion scripts.
installCopy the running binary to the standard OS location (/usr/local/bin on Unix, %LOCALAPPDATA%\Programs\madtea on Windows); installs bash/zsh/fish completion scripts for detected shells; registers the MCP server with Claude Code when the claude CLI is on PATH; --mcp-client codex,antigravity also wires those agents (repeatable/comma-separated, each its own step; retired gemini refuses); the one setup command, with --skip-binary/--skip-completions/--skip-mcp to re-run a single feature; recommends the plugin for the Claude Code guardrail hooks (the binary no longer materializes them); always reinstalls (no version-comparison skip) and warns about processes still running the replaced binary; re-running repairs completions and MCP registration; prints the full plan (source, destination + why, PATH state, stale copies) before acting, then narrates each step and, on a TTY, confirms the sudo elevation (declining falls back to ~/.local/bin) and the stale-copy sweep before deleting; --yes-sudo pre-answers the sudo step, --yes-delete-stale pre-answers the sweep; non-TTY without its flag takes the decline path (plan always printed).
update⚠️✅ (tea update)Self-update to the latest release; GPG-verifies the signed checksums.txt against the embedded release key, then the archive’s SHA-256, then atomically replaces the binary. Root-owned installs: a plain update auto-elevates only the atomic swap via sudo madtea update --apply (network verify + stage run unprivileged as you first), falling back to the manual --stage/--apply/--sha256 two-step when sudo is unavailable. update --verify checks the installed binary against its matching release (same verify pipeline) and repairs a mismatch via the normal swap — best-effort self-check, nothing to compare for dev/unreleased builds. Self-contained (no third-party self-update library). glab check-update and fj version --check only notify. See ADR 0012.

CLI ergonomics

FeaturemadteaghglabteafjNotes
Suggestions for mistyped top-level commandBuilt into Cobra/gh’s CLI library; clap gives fj this free.
--no-color / color controlDisable colored output; also honors NO_COLOR (https://no-color.org); CLICOLOR_FORCE=1 forces color even in pipes. gh: --no-color; git: color.ui=never.
Suggestions for mistyped nested subcommandmadtea ports cli/cli’s help.go pattern: a two-level typo (release crate) yields “unknown subcommand … Did you mean: create?” and exits non-zero. glab carries the same custom nested-suggest fix. Stock Cobra silently dumps parent help.

Canonical source: docs/COMPARISON.md in the madtea repo.