Extra commands for the GitLab CLI (glab), built for coding agents that wait on CI and review, and just as usable in a terminal or a script:
glab-watchwatches a merge request, a pipeline, all merge requests of a project, or a branch, and reports every change as one JSON event per line.glab-pipeline inspectdumps a pipeline's state and job logs and prints a problem-driven summary.glab-discussionreads a merge request's discussion threads into one file per thread, and writes comments, replies, resolves and reviews.glab-mr-statusreads merge requests once and reports what changed since the last read, with a one-line scorecard each.glab-search-coderuns a GitLab code search and downloads every file that matched, so you can read and grep them locally.glab-cli-extras-cc-hookis a Claude Code hook that stops an agent from inspecting pipelines or reading and writing MR discussions with rawglab, and names the command to use instead.
This package supersedes the standalone glab-pipeline and glab-discussion packages: their commands live here, with the same options and output.
Those two packages are deprecated. When one of them is still installed next to glab-cli-extras, its command prints one line on stderr before it runs, with the commands that replace it: uv tool uninstall glab-pipeline && uv tool install glab-cli-extras. Stdout and the exit code stay the same.
glab-search-code replaces the standalone glab-search-code tool, which has no deprecation wrapper. Both install a glab-search-code executable, so uninstall the old one first: uv tool uninstall glab-search-code && uv tool install glab-cli-extras. See glab-search-code for what changed.
Every GitLab request goes through glab api, so glab handles authentication, hosts, OAuth refresh, proxies and certificates. No command reads or stores a token.
You need Python 3.12 or later, uv, and the glab CLI installed and logged in to your GitLab host (glab auth status).
uv tool install glab-cli-extrasFrom git, for the latest unreleased changes:
uv tool install git+https://github.com/fprochazka/glab-cli-extrasFrom a local checkout:
uv tool install --editable /path/to/glab-cli-extrasThe repository is also a Claude Code plugin marketplace with one plugin, glab, which teaches an agent these commands and glab itself:
uv tool install glab-cli-extras
claude plugin marketplace add fprochazka/glab-cli-extras --scope user
claude plugin install glab@fprochazka-glab-cli-extras --scope userThe plugin carries the version of the package it ships with. It replaces the older glab, glab-pipeline and glab-discussion plugins; uninstall those first. See its README.
All the commands write into the same tree, one dir per resource, so a file one command wrote is current for the others. The base dir is:
<session scratchpad>/glab-cli-extras/when the command runs under a Claude Code session, which Claude Code removes after a few months;- else
<temp dir>/glab-cli-extras-<uid>/, private to the user, which the OS cleans; - or
--base-dirforglab-watch, and--output-dirforglab-pipeline inspectandglab-search-code, which names the resource's dir itself.
The resource dirs mirror GitLab's URLs: <base>/<host>/<group>/<project>/-/pipelines/<id>/, .../-/merge_requests/<iid>/, .../-/merge_requests/ for glab-watch mrs and .../-/branches/<branch>/ for glab-watch branch. Each one has a README.txt that names its files:
state.json: the latest state of the pipeline or MR, written on every change.events.jsonl: the changelog, every event a watcher reported, andbaseline.json, the last state this watcher saw.job-logs/<stage>-<name>-<job id>.login a pipeline's dir: one file for every job, either the log or a placeholder. See Job logs.threads/<created>-<author>-<id>.txtin an MR's dir: one file per discussion thread. A header gives the full discussion id, the type, the file and line of a diff note,Resolved:on resolvable threads, and the URL. Then comes one[<created>] @<username> (note:<id>):block per note. Bot authors get abot-file name prefix and a[BOT]tag, service accounts their display name. A file is rewritten only when the thread changed: a new reply, an edit, a resolve..meta.jsonmaps each discussion id to its file.status-baseline.json,status-events.log,mr.jsonandexternal-statuses.jsonin an MR's dir: whatglab-mr-statusread. See glab-mr-status.- The pipeline dump in a pipeline's dir:
pipeline.json,jobs.json,bridges.json, the lint, test report and downstream files when they apply, andsummary.jsonandsummary.txt. See glab-pipeline.
A code search belongs to no project, so each glab-search-code run gets a new dir of its own: <base>/<host>/-/search/<UTC time>-<query>-<hash>/. See glab-search-code.
GitLab Community Edition has no MergeRequest.approvalsLeft, and a query that asks for it fails as a whole. So before the first MR snapshot of a run, a command asks the host once for metadata { version enterprise } and leaves the field out on Community Edition. The answer is kept for a day in <cache dir>/<host>/capabilities.json, so this costs one extra request per host per day. The cache dir is:
$GLAB_CLI_EXTRAS_CACHE_DIRwhen it is set;- else
$XDG_CACHE_HOME/glab-cli-extras/, by default~/.cache/glab-cli-extras/, on macOS too; - on Windows
%LOCALAPPDATA%\glab-cli-extras\cache\.
The file holds the GitLab version, the edition flag, the time of the check, and the MergeRequest fields GitLab rejected. If GitLab still rejects approvalsLeft, the command asks again without it and adds the field to the file. A probe that gets no answer (network failure, 5xx, 429, 401) is not written down; the command then asks for the field and relies on that rejection. GitLab before 15.6 has no edition flag and works the same way. A missing, broken or day-old file is ignored and written again. Several commands may write the file at once: each one replaces it whole, and the last one wins. To start over, delete the dir.
GitLab's schema introspection (__type) cannot replace the probe: GitLab answers any introspection query with a static dump of its whole schema, 8 MB on gitlab.com.
glab-watch reports every change to a GitLab resource as a JSON event. A shared per-user daemon does the polling, so ten watchers cost about as much API budget as one.
glab-watch mr group/project!123 # a merge request; also !123 or 123 with the repo from the cwd, or the MR URL
glab-watch pipeline group/project!4567 # a pipeline and its downstream pipelines; also !4567, 4567, or the pipeline URL
glab-watch mrs group/project # every merge request of a project
glab-watch branch group/project main # a branch; the repo is optional: glab-watch branch main<repo> means the same as it does to glab: group/project, group/sub/project, host/group/project, a project URL, or nothing for the repository of the current directory. glab-watch asks glab repo view to resolve it, which costs one request at start. A URL costs nothing.
A watcher runs until its resource ends (an MR is merged or closed, a pipeline finishes), until --until, --timeout or --idle-after fires, or until it gets SIGTERM or Ctrl-C. mrs and branch run until stopped. The last event is always watch_ended with a reason.
The exit code is 0 when the watch ends normally, 1 when the resource is not found or the daemon fails, and 2 for a usage error.
All four resource commands take:
--events=<kinds or groups>: report only these. Comma-separated, repeatable. The default is every event the resource tracks.--exclude-events=<kinds or groups>: do not report these.--until=<kind>: stop after the first event of this kind, for example--until=finished.--timeout=<duration>: stop after90s,30m,2hor1h30m, counted from the start.--idle-after=<duration>: stop when nothing was reported for this long. Every reported event starts the count again, exceptquietandpipeline_stuck, which report that nothing happened. The watch ends withwatch_endedreasonidle.--events-stream=auto|stdout|claude-message: where events go. See Output streams.--base-dir=<path>: where the resource dirs go. See Where files go.--message-priority=now|next|later: forclaude-messageonly. The default isnext.--no-daemon: poll inside this process, with a private budget, without the shared daemon.
pipeline also takes --logs=failed|all|none: which finished jobs get their log downloaded. The default is failed. mr does not take --logs; follow the watch_command of a pipeline event for the jobs.
Two thresholds report what did not happen. The watcher computes them from what it already receives, so they cost no request:
--quiet-after=<duration>onmr, repeatable: aquietevent when the head pipeline succeeded on the current head and nothing else was reported for that long. It comes once per head SHA and threshold. A push, a new green pipeline and every other reported event start the count again.--quiet-after=2m --quiet-after=15msays "green and quiet for 2 minutes", then again for 15.--stuck-after=<duration>onmrandpipeline: apipeline_stuckevent when the pipeline is stillrunningorpendingthat long after the watcher first saw it in one of those statuses. It comes once per pipeline id.
Without its option, neither event is reported, and asking for it with --events is an error. With its option, the event must pass the filter: --quiet-after with --exclude-events=status is an error too.
On start, every watcher prints a header. Each line starts with glab-watch: :
glab-watch: resource: mr gitlab.example.com/group/project!123
glab-watch: stream: stdout (one JSON event per line)
glab-watch: dir: /tmp/glab-cli-extras-1000/gitlab.example.com/group/project/-/merge_requests/123
glab-watch: state: /tmp/glab-cli-extras-1000/gitlab.example.com/group/project/-/merge_requests/123/state.json
glab-watch: changelog: /tmp/glab-cli-extras-1000/gitlab.example.com/group/project/-/merge_requests/123/events.jsonl
glab-watch: events: state_changed,draft_changed,pushed,… (always: watch_started,watch_degraded,watch_ended)
glab-watch: ignored: thread_created,thread_updated,thread_resolved,thread_unresolved
glab-watch: daemon: pid 4242, endpoint /run/user/1000/glab-cli-extras/daemon-v4.sock, log /run/user/1000/glab-cli-extras/daemon-v4.log
glab-watch: restart with: glab-watch mr https://gitlab.example.com/group/project/-/merge_requests/123 --events=<kinds|groups> --exclude-events=<kinds|groups> (groups: status, comments, push, pipeline)
The state: line appears for mr and pipeline only. A watch with --quiet-after or --stuck-after adds a thresholds: line, a pipeline watch adds a logs: line, a watch with --until, --timeout or --idle-after adds an ends: line, and a stream fallback: line says why auto did not message the session.
The header and the other status lines (ended: …, errors) always go to stderr, so stdout carries only events. Claude Code puts a background task's stderr into the task output next to its stdout, so an agent sees both. A status line never starts with {: in merged output, every line that starts with { is an event.
Every event has kind, resource, seq, observed_at and data, and artifacts when it points at files:
{"kind":"job_finished","resource":"pipeline gitlab.example.com/group/project!4567","seq":7,"observed_at":"2026-11-09T10:05:03.120Z","data":{"pipeline_id":4567,"job_id":901,"name":"unit-tests","stage":"test","status":"failed","log_truncated":false},"artifacts":["/tmp/glab-cli-extras-1000/gitlab.example.com/group/project/-/pipelines/4567/job-logs/test-unit-tests-901.log"]}seq numbers the events a watcher emits: it starts after the last seq in the changelog and only goes up, also when the daemon restarts in between.
Lifecycle events come with every resource and cannot be filtered out:
watch_started: the current state, indata.summary.watch_degraded: polling has trouble:rate_limited,auth,not_foundorerror, with a message. It is sent once per kind of trouble, not on every failed poll. The reasondaemon_mismatchmeans the running daemon does not know a request thisglab-watchsends, because it was started from other code of the same protocol version (an editable install that changed since). The watch keeps reporting events, but the files that need the daemon (job logs, thread files, the pipeline dump) stay out of date until you runglab-watch daemon stop; the running watchers then start a current daemon and subscribe again.watch_ended: with areason:merged,closed,finished,not_found,until,timeout,idle,session_gone, or the signal. When the MR or the pipeline had already ended when the watch started,data.already_endedistrueand the status line on stderr says so, for exampleended: finished (already finished when the watch started). The exit code is 0.
mr:
state_changed(from,to, andreopened: truefor closed to opened),draft_changed,title_changed,description_changedpushed:from,to,forced. A force-push is detected with onemerge_baserequest per push.merge_status_changed: GitLab's transientchecking,uncheckedandpreparingare skipped, and so is every change once the MR is merged or closed.target_branch_changed(from,to): the MR was re-targeted, for example onto another MR's branch or back onto the default branch.approvals_changed,reviewers_changed, andassignees_changedandlabels_changed(the new list,added,removed).blocking_threads_resolved_changed(resolved): GitLab's "all threads must be resolved" check flipped. It istruewhenever no unresolved thread blocks the merge, also in a project that does not require resolved threads.head_pipeline_changed,pipeline_status_changed(from,to) andpipeline_finished(withfailed_jobswhen it failed) for the head pipeline. These are pipeline-level only: an MR watch reports no job events and downloads no logs. Each one has the pipeline'sweb_urlandwatch_command, theglab-watch pipeline <url>command that watches it job by job. The head pipeline's status comes with the MR's own snapshot query, so it costs no extra request.external_status_changed(name,from,to,allow_failure,description,target_url): a commit status on the head that no job of the head pipeline set, such as a quality gate outside the CI.fromisnullfor a new status. The statuses are the sameglab-mr-statusprints asfailed external status:. They cost one request per 100 statuses of the head commit, made when the head, the head pipeline or an unfinished external status moved, and otherwise about every five minutes. A status that disappears, as on a push, is not reported.thread_created,thread_updated,thread_resolved,thread_unresolved, each with the thread's file inartifacts. GitLab can show a thread unresolved for a poll or two after a force-push; athread_unresolvedwithin two polls of apushedhasafter_push: true.quiet(after,head_sha,pipeline_id) with--quiet-after, andpipeline_stuck(after,pipeline_id,status,web_url,watch_command) with--stuck-after.
One glab-discussion drafts publish changes the MR in up to three ways at once: a review state, reviewers_changed when it added you as a reviewer, and approvals_changed when it revoked your approval. A watcher of your own MR sees these as events like any other.
pipeline: status_changed, job_started, job_finished (with the log in artifacts when --logs asks for it), downstream_created, finished (with summary.txt in artifacts, and the pipeline's dir in data.dir), and pipeline_stuck (after, pipeline_id, status, web_url, running_jobs) with --stuck-after. When the pipeline finishes, the watcher writes the dump glab-pipeline inspect writes, and the final watch_ended also carries summary.txt in artifacts. A later glab-pipeline inspect of the same pipeline in the same session finds the files current and makes one request, the ETag probe.
mrs: mr_opened, mr_updated, mr_merged, mr_closed, mr_reopened. When the list shows what moved, the event has changes: {field: [old, new]} for title, draft, target_branch, sha, detailed_merge_status, labels, assignees and reviewers, for example {"draft": [true, false]} when a draft becomes ready. These come from the same list request, so they cost nothing extra.
branch: pushed, force_pushed, created, deleted, pipeline_created, pipeline_finished. A push whose head commit is a merge commit has merge_commit: true and, when GitLab knows it, merged_mr_iid.
--events and --exclude-events take kinds and these groups:
status:state_changed,draft_changed,merge_status_changed,approvals_changed,reviewers_changed,assignees_changed,labels_changed,blocking_threads_resolved_changed,title_changed,description_changed,target_branch_changed,quiet, and allmr_*.comments:thread_*.push:pushed,force_pushed,created,deleted.pipeline:head_pipeline_changed,external_status_changed,pipeline_status_changed,status_changed,job_*,downstream_created,finished,pipeline_created,pipeline_finished,pipeline_stuck.
A group brings quiet and pipeline_stuck only when their option is given.
lifecycle:watch_started,watch_degraded,watch_ended; always on.
The filter is checked at start. An unknown kind or group, or one the resource never produces, is an error, so a typo does not silently mean "no events". The filter also saves API work: an MR watch without comments never fetches threads, and one without pipeline leaves the head pipeline out of its query and fetches no commit statuses.
--events-stream=stdout prints one JSON event per line on stdout. Whatever the stream, the changelog events.jsonl in the resource dir keeps every reported event, also when nobody read the stream.
--events-stream=claude-message sends the events to a Claude Code session as messages, through the session's messaging socket (CLAUDE_CODE_MESSAGING_SOCKET). It fails at start when no session accepts messages. The default, --events-stream=auto, picks claude-message when the watcher runs under a Claude Code session that accepts messages, and stdout otherwise. To use it, start the watcher as a background Bash task from the session:
glab-watch mr group/project!123 --exclude-events=commentsThe session sees each batch as a message from glab-watch mr group/project!123:
<glab-watcher resource="mr gitlab.example.com/group/project!123" seq="18-19">
{"kind":"head_pipeline_changed","seq":18,"data":{"pipeline_id":456,"web_url":"https://gitlab.example.com/group/project/-/pipelines/456","watch_command":"glab-watch pipeline https://gitlab.example.com/group/project/-/pipelines/456","status":"running","previous_pipeline_id":455}}
{"kind":"thread_created","seq":19,"data":{"discussion_id":"6a1f…","author":"reviewer-one","resolvable":true,"resolved":false,"notes":1},"artifacts":["/tmp/claude-1000/…/scratchpad/glab-cli-extras/…/merge_requests/123/threads/2026-11-09T09-30-reviewer-one-6a1f….txt"]}
</glab-watcher>
- Paths. The first message, the one with
watch_started, also carriesdir="…"andchangelog="…": the resource dir and itsevents.jsonl. Itswatch_startedgives the threads dir relative to that dir, and stdout andevents.jsonlkeep the full paths. Later messages leave the paths out, also after a daemon restart; each event'sartifactskeep their absolute paths, so a file opens without the first message. - Batching. Events that arrive close together go out as one message: the first event of a burst waits up to 3 s for the rest. A batch above 64 KB is sent as a count per kind; the changelog has the full events.
- Priority.
--message-priority=next(the default) delivers into the session's running turn, at its next tool round.nowalso delivers into the running turn, ahead of queued work.laterwaits until the session is idle and then starts a new turn. - Rate limits. Each watcher sends as
glab-watch:<pid>, and keeps below the session's per-sender limit; messages it cannot send yet are merged into the next one. - Files. Under a Claude Code session every file goes to
<session scratchpad>/glab-cli-extras/, in both stream modes. - Lifetime. A
claude-messagewatcher exits when its session is gone: the socket refuses the message, or the session's record is no longer live. Watchers never outlive their session. - Subagents. A watcher started from a subagent's Bash messages the main session. Claude Code has no route to a subagent.
- Best effort. The session may drop a message under its own rate limit, or hold it until its user approves it, which a session in bypass-permissions mode does. The changelog always has every event.
When the session's messaging does not match what the cc-blackbox SDK speaks, for example after a Claude Code update, auto uses stdout, and the header says why on a glab-watch: stream fallback: line. If that happens while the watcher runs, it prints the same line and sends the rest of the events to stdout.
The first watcher starts the daemon, python -m glab_cli_extras.daemon, as a detached process. It exits 30 s after its last watcher is gone. You never have to start it. If it cannot start, the watcher exits and suggests --no-daemon.
glab-watch daemon status # subscriptions, resources, request counts and budget per host; --json for everything
glab-watch daemon logs -n 100 # the end of the daemon's log, 50 lines by default
glab-watch daemon stop # running watchers start a new daemon on their next poll and carry on- One daemon per user. It holds an OS lock on
daemon-v4.lockin its state dir ($XDG_RUNTIME_DIR/glab-cli-extras/on Linux,$TMPDIR/glab-cli-extras/on macOS,%LOCALAPPDATA%\glab-cli-extras\run\on Windows). Watchers that start at the same moment start one daemon, not one each. - Leases. Each watcher polls the daemon every 5 s, which renews its subscriptions. A watcher that dies stops costing anything 20 s later.
- Restarts. If the daemon goes away, watchers start a new one and subscribe again. The new daemon compares GitLab's state with the snapshot each watcher kept, so a change made in between is still reported, and nothing is reported twice.
- Budget. The daemon paces each host on GitLab's
RateLimit-RemainingandRateLimit-Resetheaders and uses at most half of what is left. A host without those headers gets 60 requests a minute. When the budget is short, every poll interval grows by the same factor. A 429 pauses the whole host for itsRetry-After. - Cheap probes. Pipelines are polled through GitLab's GraphQL ETag cache: an unchanged pipeline answers with a 304 before any query runs. Base intervals: pipeline 5 s while running, MR 30 s (15 s on hosts with a high rate limit), branch pipelines 10 s, branch pushes 30 s,
mrs60 s. - Sharing. Watchers of the same resource share one poller. All watched branches of a project share one push-event request.
- No files. The daemon writes no files. It fetches each log and each changed thread once, keeps it in memory for a while, and hands it to every watcher that asks, so ten watchers of one pipeline cost one download. The watchers write the files. The daemon keeps the capabilities of each host in memory for a day instead of in the cache dir.
glab-pipeline inspect # the head pipeline of the current branch's MR
glab-pipeline inspect --pipeline-url <url> # an explicit pipeline
glab-pipeline inspect --pipeline-id 1234567 --project group/project --hostname gitlab.example.com
glab-pipeline inspect --mr-url <url> # an MR's head pipeline, also from a /diffs or #note_ URL; or --mr-iid with --project and --hostname
glab-pipeline inspect --logs=all # every job's log, not only the failed jobs' logs
glab-pipeline inspect --with-merged-ci-config # two-step lint that resolves include: against the source branch
glab-pipeline inspect --with-test-report # the test report even without a failed test job
glab-pipeline inspect --with-downstream-pipelines # downstream detail for every bridge, not only failed ones
glab-pipeline inspect --with-artefacts # download and unpack each job's artifacts archive
glab-pipeline inspect --json | jq # the summary as JSON instead of text
glab-pipeline inspect --output-dir /path/to/dir # write the dump there instead of the pipeline's dir
glab-pipeline inspect --full # ignore what is on disk and fetch everything againinspect writes the dump into the pipeline's dir (see Where files go), the same dir glab-watch pipeline writes, and prints a problem-driven summary:
pipeline.json,jobs.json(retried jobs included) andbridges.json(when there are bridges): the GitLab API objects.job-logs/<stage>-<name>-<id>.log: the log of every failed job, and a placeholder for every other job.--logs=alldownloads every log,--logs=nonenone.lint.jsonandmerged.ymlwhen the pipeline has YAML errors, no jobs, or a job failed for a config reason;test-report.jsonwhen a test job failed;downstream/<bridge>-<id>.jsonfor a failed bridge;artifacts/<stage>-<name>-<id>/with--with-artefacts.summary.jsonandsummary.txt: the summary, also printed on stdout.summary.jsonis written also without--json.
A rerun reuses what is on disk. The pipeline is probed through GitLab's GraphQL ETag cache with the ETag of the last dump; an unchanged pipeline answers with a 304 and the rerun makes no other request. A finished job's log is never downloaded twice.
The context flags are a URL, or --pipeline-id/--mr-iid with --project and --hostname, or nothing for the current branch's MR. A partial set, such as --project without --hostname, is an error instead of being silently ignored. --pipeline-id alone takes the project of the current branch's MR.
inspect and glab-watch pipeline write the same job-logs/ dir, one file for every job, and take the same --logs=failed|all|none, default failed. A log is downloaded once; glab-watch downloads it a few seconds after the job ends. The log of a downstream job goes to its own pipeline's dir.
A job whose log is not downloaded, because it passed and --logs is failed, or because it is still running, pending, skipped or manual, has a short placeholder in its place. The placeholder starts with [glab-cli-extras: log not downloaded], says why, and gives the glab-pipeline inspect … --logs=all command that downloads it. state.json records log_state for each job: downloaded, tail or placeholder.
On GitLab 19.0 and later, a log above 500 KB is read as its last 500 KB only, and the file starts with a [glab-cli-extras: last …] line.
glab-discussion read # the current branch's MR: one file per thread, then a summary of what changed
glab-discussion read --mr-url <url> # an explicit MR, also from a /diffs or #note_ URL; or --mr-iid with --project and --hostname
glab-discussion read --no-dump # print every thread instead (the default in a terminal)
glab-discussion read --full # delete the thread files and write them all again
glab-discussion write --body - < reply.md --reply-to <discussion id> # reply to a thread
glab-discussion write --body "…" --file src/app.py --new-line 42 # a diff note on a line of the newest diff version
glab-discussion write --body "…" --draft # a pending draft only you see
glab-discussion write --body "…" --note # a plain comment, not a resolvable thread
glab-discussion write --body "…" --attach screen.png # upload a file and link it in the comment
glab-discussion resolve <discussion id> # also --unresolve
glab-discussion edit note:123 --body "…" # or draft:123, as read prints the ids
glab-discussion edit note:123 --attach screen.png # add a file to the current text
glab-discussion delete note:123
glab-discussion diff --file src/app.py # the diff with old and new line numbers; --version for an older diff version
glab-discussion drafts publish --body - < summary.md --verdict reviewed # publish your drafts as one review
glab-discussion drafts delete --force # without --force, list your draftswrite also takes --old-line and --commit for a diff note, and --resolve with --reply-to and --draft to resolve the thread when the draft is published. --no-draft publishes at once. --note posts a plain comment, which nobody can resolve, at once and never as a draft; it takes none of the thread, diff note and draft options.
--attach <file>, repeatable, uploads a file to the MR's project and adds the Markdown GitLab returns for it to the body: after the text and a blank line, one file per line, in the order given. GitLab decides how the file shows: an image, a video, an audio file or an SVG renders inline, and any other file is a download link. GitLab refuses a file over the instance's maximum attachment size, and the command then posts nothing. A signature line such as <!-- plugin:command --> at the end of the body stays the last line, so the files go before it. It works for every kind of write, drafts included: the file is uploaded when the draft is written and stays in the project if you delete the draft. A file that is missing or that you cannot read stops the command before any request. The files are uploaded after every other check, right before the comment is posted. The upload sends the file with glab api --form, which older glab versions lack.
edit takes --attach too, with the same checks and placement. With --body, the links go into the new text. Without --body, edit reads the note's or draft's current text from GitLab and adds the links to it; --body is optional only then, and an edit with neither option is a usage error, exit 2. The files are uploaded after the current text is read and checked, so a failed read or a refused text leaves no upload behind, and a failed upload leaves the comment unchanged.
read --dump writes the threads into the threads/ dir of the MR's dir (see Where files go), the same dir glab-watch mr writes, and prints the dir and the files that changed:
Discussions: /tmp/glab-cli-extras-1000/gitlab.example.com/group/project/-/merge_requests/123/threads/
updated: 2026-11-09T09-30-reviewer-one-6a1f2b3c4d5e.txt
(11 discussions up to date)
A thread file is fetched again only when its notes changed since the file was written, or when your draft replies in it changed. An unchanged MR costs one GraphQL request per 100 threads and one request for your drafts. Your drafts appear as [DRAFT] blocks in their thread, and a draft that starts a thread as draft-<id>.txt. Drafts belong to the login that wrote them, so only read shows them: a glab-watch mr rewrites a changed thread without your draft replies, and the next read puts them back.
The context flags are all or nothing: --mr-url, or --mr-iid with --project and --hostname, or none of them for the current branch's MR. A partial set is a usage error instead of silently using the current branch's MR.
The write commands send every body as JSON on stdin, byte for byte: a trailing signature line such as <!-- plugin:command --> and the final newline reach GitLab unchanged. A write is never retried, because GitLab may have applied a write whose answer was lost, and a retry would post it twice. Each write command prints the URL of the MR it acted on. GLAB_DISCUSSION_WRITE_AS_DRAFT=true makes write save drafts unless --no-draft is given.
Before a write, the commands check what agents most often got wrong, and stop with exit 1 and a message that says what to do instead:
- An empty or blank body, as from
--body "$(cat <missing file>)", is refused. Prefer--body - < file, which fails in the shell when the file is missing. write --fileanchors the note on the diff: on an unchanged line it adds the other side's line number, which GitLab requires there (--new-linealone is enough); on a renamed file it uses the old path. A line outside every hunk is refused, since GitLab takes diff notes only on changed lines and their context. If GitLab still creates a general comment, the command reports it.--reply-toandresolvetake the 12-character id prefix from a thread file's name too, resolved against the MR's files without a request. The full id from theDiscussion:line is never ambiguous.resolvereads the thread first and refuses one without a resolvable note, which GitLab answers with 403 or 404. A reply or resolve that gets 404 says whether the thread is gone (deleted, or rewritten by a force-push) or the error was transient.
GitLab keeps one draft reply per user and thread, and refuses a second one with 400 author_id has already been taken. write --draft --reply-to turns that into an error that names your existing draft, to edit it or to delete it and write the reply again.
drafts publish submits the review with GitLab's /submit_review quick action. It publishes your drafts and sets the verdict on every GitLab since 16.7, where the bulk_publish endpoint ignores the verdict before 19.2. Before it submits, it adds you as a reviewer if you are not one and checks that GitLab did. It also removes your earlier approval or request for changes that the new verdict replaces, because GitLab before 19.2 does not replace one verdict with another. Without --verdict it sets reviewed, but only when you have given the MR no verdict yet; otherwise --verdict is required. Your earlier verdict comes from the MR's system notes, because a draft resets your review state. Afterwards it reads the drafts, your review state and the approvals back, and exits with 1 when the verdict was not applied.
edit draft:<id> sends an inline draft's position back with the new text, because GitLab since 16.3 turns an inline draft into a general one on an edit without it.
glab-mr-status # the current branch's MR
glab-mr-status group/project!123 !124 # any glab-watch MR ref, or an MR URL; several at once
glab-mr-status --mr-url <url> --mr-url <url> # the same, as URLs
glab-mr-status --comments # only the threads; --pipeline only the pipeline dump; --all both (the default)
glab-mr-status --reset # forget the last read and start a new baseline
glab-mr-status --base-dir <path> # as glab-watchglab-mr-status answers "what is this MR like now, and what changed since I last looked". To be told when something changes, run glab-watch mr instead of reading in a loop.
The first read of an MR establishes a baseline and prints its state. Every later read prints one <ID>: <detail> line per change since the read before, then the threads, the pipeline dump and a scorecard:
== gitlab.example.com/group/project!123
HEAD_CHANGED: 3f2a6c0d9b1e4f7a8c2d5e6f7a8b9c0d1e2fe91c -> 8b7d04aa5c3e2f1a0b9c8d7e6f5a4b3c2d1e04aa (new head)
PIPELINE_CHANGED: new pipeline 4568 (running) on 8b7d04aa, was 4567 (failed)
NOTES_CHANGED: new note by @reviewer-one at 2026-11-09T10:12:00Z
discussions: /tmp/glab-cli-extras-1000/gitlab.example.com/group/project/-/merge_requests/123/threads/ (one file per thread, …)
new: 2026-11-09T10-12-reviewer-one-6a1f2b3c4d5e.txt
pipeline dump: /tmp/glab-cli-extras-1000/gitlab.example.com/group/project/-/pipelines/4568/ (summary.json, jobs.json, job-logs/) · summary text: …/summary.txt
scorecard: gitlab.example.com/group/project!123 · opened · ready · head 8b7d04aa · pipeline running · behind 0 · conflicts no · notes 4 · approved by - · updated 2026-11-09T10:12:00Z
state: /tmp/glab-cli-extras-1000/gitlab.example.com/group/project/-/merge_requests/123
The change ids are STATE_CHANGED, DRAFT_CHANGED, HEAD_CHANGED (full SHAs, with a rebase hint when the behind count dropped to 0), TARGET_CHANGED, TITLE_CHANGED, PIPELINE_CHANGED (noting a pipeline that ran on a superseded base), BEHIND_CHANGED, MERGE_STATUS_CHANGED, NOTES_CHANGED, DISCUSSIONS_RESOLVED_CHANGED, REVIEWERS_CHANGED, ASSIGNEES_CHANGED, LABELS_CHANGED, APPROVALS_CHANGED, and READ_FAILED when an MR could not be read. In the scorecard, approved by ? means GitLab did not return the approvals, and (on an older head) means the pipeline ran on a commit that is no longer the head.
Each read costs the REST merge request, its newest note, its approvals when the MR moved since the last read, and the project's default branch once. The threads cost what glab-discussion read costs and the pipeline dump what glab-pipeline inspect costs, which is two requests and one request when nothing changed. --comments or --pipeline leaves the other out.
The read is printed and flushed before it is stored, so a read killed in between reports the same changes again. It stores into the MR's dir:
status-baseline.json: the last state this command read. A runningglab-watchkeeps its ownbaseline.json, so neither swallows a change the other has not reported.status-events.log: every change it printed, one tab-separated line each: UTC time, MR, id, detail.mr.json: the REST merge request;external-statuses.json: the head commit's statuses its pipeline did not produce, such as an outside quality gate.- the
threads/dir and, in the pipeline's dir, the pipeline dump, asglab-discussion readandglab-pipeline inspectwrite them.
The exit code is 0 for a baseline or a read, 5 when no MR could be read, 1 for an error such as no MR on the current branch, and 2 for a usage error. When some MRs of a set fail and others are read, the exit code is 0 and each failed MR has a READ_FAILED line.
glab-search-code 'GeneratedValue' # the whole instance of the current directory's GitLab host
glab-search-code 'GeneratedValue' --hostname gitlab.example.com # another host, or outside a repository
glab-search-code 'class MyService' --group group # one group and its subgroups
glab-search-code 'class MyService' --project group/project # one project
glab-search-code 'GeneratedValue' --workers 20 --max-retries 8 # more requests at once, more attempts per request
glab-search-code 'GeneratedValue' --output-dir /path/to/dir # write the run there instead of a new dir
glab-search-code 'GeneratedValue' -v # print every glab call and its HTTP status to stderrglab-search-code runs GitLab's code search (scope=blobs) and downloads every file that matched, whole, from the ref the search ran on, usually the default branch. A search across a group or the whole instance needs Advanced search or Exact code search on the GitLab instance; without it GitLab answers 400, and the command says to pass --project. --project and --group exclude each other.
Each run writes a new dir (see Where files go):
files/<project path>/<file path>: each matching file once, also when it matched on several lines. The tree keeps the repository paths, sorgoverfiles/prints paths you can open in a clone. A path component above 255 bytes is shortened and ends with a hash, and characters Windows rejects become_.metadata.json: one entry per downloaded file, withproject_id,project_path,file_path,ref,output_file(relative to the run's dir),url(the file on GitLab) andhits(the start lines of its matches).hits.json: every match as GitLab returned it, withproject_path,path,ref,startlineanddata, the matched lines.download.log: one line per file,OK,SKIP(another file maps to the same output file) orFAILwith the reason.
Progress goes to stderr. Stdout carries the summary, and its last two lines are always Output directory: <dir> and Metadata file: <file>, also when nothing matched:
Search query: GeneratedValue
Found 42 hits in 17 files of 5 projects
Total files downloaded: 17
Hits file: /tmp/glab-cli-extras-1000/gitlab.example.com/-/search/20261110T091500Z-GeneratedValue-3f2a6c0d/hits.json
Output directory: /tmp/glab-cli-extras-1000/gitlab.example.com/-/search/20261110T091500Z-GeneratedValue-3f2a6c0d
Metadata file: /tmp/glab-cli-extras-1000/gitlab.example.com/-/search/20261110T091500Z-GeneratedValue-3f2a6c0d/metadata.json
The search costs one request per 100 hits, the project paths one GraphQL request per 100 projects, and each file one request. --workers (1 to 50, default 10) caps the requests at once. --max-retries (default 5) is the number of attempts per request, the first one included: a rate limit waits for its Retry-After, and a 5xx or a network error waits longer after each attempt. 401, 403 and 404 are not retried. A file that still fails is logged and counted, and the run goes on.
Without --hostname, the host comes from the current directory's repository, as glab repo view finds it; outside a repository --hostname is required. The exit code is 0 when the search ran, also with no hits or with failed downloads, 1 when the search failed, and 2 for a usage error.
Compared with the standalone tool, a run writes into a new dir under the base dir instead of <temp dir>/glab-code-search-<time>/, files keep their repository paths instead of a flat project__path__file name, --max-retries defaults to 5 and also retries 5xx and network errors, --hostname is optional and no longer checked against glab's config.yml, and --project and --group are new. The query and the ref are URL-encoded, so &, #, + and spaces work.
glab-cli-extras-cc-hook is a PreToolUse hook for the Bash tool. It denies a command that inspects pipelines, jobs or job logs with glab ci view|get|trace or the raw API (glab api projects/…/pipelines/…, …/jobs/…/trace, a GET of …/ci/lint), or that reads or writes MR discussions with glab api …/notes|discussions|draft_notes, glab mr view --comments or glab mr note. The deny reason names the rule that matched, the command to use instead and the skill that teaches it. Linting content you send stays allowed: glab ci lint <file>, and glab api …/ci/lint with -X/--method, --input or a field option (-f, -F, --field, --raw-field). Actions stay allowed: glab ci list, glab ci status, glab ci run, glab ci retry, the pipelines list endpoint, award emoji. The rules ship in the package, in glab_cli_extras/cc_hook/rules/.
The glab plugin registers it, through a wrapper that never blocks a Bash call when the command is missing or crashes. Until glab-cli-extras is installed, the plugin's guard therefore allows every Bash call, and its session check says so. To register it without the plugin, add it to a Claude Code settings file:
{"hooks": {"PreToolUse": [{"matcher": "Bash", "hooks": [{"type": "command", "command": "glab-cli-extras-cc-hook", "timeout": 10}]}]}}bash-classify parses the command, so a blocked command inside a wrapper (sudo, timeout, bash -c, xargs, eval) or behind glab's pipe/pipeline aliases is caught, and text that only names one (a heredoc, an echo, a commit message) is not. When bash-classify cannot fully parse a command, the hook falls back to text patterns, which also fire on such text, and the deny reason says so.
Claude Code starts the hook on every Bash call. A command that does not contain glab is answered without parsing, in about 10 ms over the interpreter's own start; one that does takes about 55 ms over it, most of it bash-classify's import and its YAML command definitions. A hook that crashes prints nothing and exits 1, which Claude Code shows as a hook error and treats as no opinion.
A few settings restrict what the commands may do, for a machine that runs coding agents unattended. They are not command-line options, so an agent cannot turn them off on its own. Without any configuration nothing is restricted.
[discussion]
quick_actions = "reject" # "allow" (default) or "reject"
drafts = "disabled" # "enabled" (default) or "disabled"discussion.quick_actions = "reject":glab-discussion write,editanddrafts publish --bodyrefuse a body with a line that GitLab may run as a quick action, with exit 1 and before any request. Such a line starts with/and a word, such as/approveor/label ~bug, also after spaces, outside a closed fenced code block (```or~~~). This is stricter than GitLab, which runs only the commands it knows: any word counts, and an unclosed fence protects nothing. Put text such as/approvein backticks or a fenced code block to post it. The quick action thatdrafts publishsends itself to submit the review is not affected.discussion.drafts = "disabled":glab-discussionhas nodraftscommand and nowrite --draft,--no-draftor--resolveoption, andeditanddeletetake onlynote:<id>. They are missing from--help, and using one is a usage error, exit 2. Everywriteposts at once, so it never writes a draft or submits a review.GLAB_DISCUSSION_WRITE_AS_DRAFT=truethen stopswritewith exit 1 instead of posting a comment that was meant to wait as a draft.readstill shows the drafts you have.
The settings come from these sources, and each one is read:
- the system file:
/etc/glab-cli-extras/config.toml, on Windows%PROGRAMDATA%\glab-cli-extras\config.toml; - the user file:
$XDG_CONFIG_HOME/glab-cli-extras/config.toml, by default~/.config/glab-cli-extras/config.toml, also on macOS; on Windows%APPDATA%\glab-cli-extras\config.toml; - the file that
GLAB_CLI_EXTRAS_CONFIGnames, which must exist; - one environment variable per setting:
GLAB_CLI_EXTRAS_DISCUSSION_QUICK_ACTIONSandGLAB_CLI_EXTRAS_DISCUSSION_DRAFTS. An empty variable counts as unset.
A restriction applies when any source sets it. A less restrictive value in another source does not loosen it, whatever the order: quick_actions = "reject" in /etc holds even when the user file or the environment says "allow". To lift a restriction, remove it from every source that sets it. The error message of a refused command names the source.
The files are strict. A file that is not valid TOML, or has an unknown table, key or value, stops every command with exit 1 and a message that names the file, so that a typo never leaves a restriction off. An unknown value in an environment variable does the same.
- Linux and macOS: everything. The daemon listens on a Unix socket.
- Windows: the daemon and the stdout stream. The daemon listens on a loopback TCP port, and every request carries a random token from a file only the user can read. The
claude-messagestream is not available.
- No
claude-messageon Windows. The SDK has no Windows transport yet;autopicks stdout there. - One glab process per request. Starting
glabcosts tens of milliseconds. At these request rates, a few per second at most, that does not matter. - The free ETag 304 is not documented by GitLab. If GitLab starts to count those requests, pipeline watching gets slower under the budget, not more expensive.
- Branch pipeline probes are cheap only on quiet projects. GitLab touches the ETag key of a project's pipelines on any pipeline change in the project. In a busy monorepo nearly every 10 s probe is a full request.
mr_updatedis best effort. GitLab does not move an MR'supdated_atwhen it was touched less than a minute before, so a second change within that minute shows up only with the next change.- Job logs of large jobs need GitLab 19.0 for the tail-only download. Older versions download the whole log.
- Deeper downstream pipelines (a child of a child) are not watched. Their changes do reach the watched pipeline's ETag, but their jobs are not reported.
- Code search across a group or an instance needs Advanced search or Exact code search. Without it, only
glab-search-code --projectworks. - A code search stops after 10,000 hits (100 pages). The summary says so; narrow the query, or search a group or a project.
- A code search can miss a few hits. GitLab pages its search results by offset, and the order of the hits can shift between two page requests. Then two pages overlap: a few hits come twice and as many are never returned. The command downloads each file once, so a repeated hit costs nothing, but the missed ones are gone. In one test, 3 of 356 hits were missed this way. Searching a project or a group returns fewer pages, so fewer page boundaries.
- SSH host aliases in git remotes are resolved by glab, as glab resolves them. A remote glab cannot map to a configured host fails at start.
uv sync
uv run ruff check
uv run ruff format --check
uv run mypy
uv run pytestTo run against a local checkout of cc-blackbox instead of its release, for example while changing both, layer it over the locked version for one command. This leaves pyproject.toml and uv.lock alone:
uv run --with-editable ../cc-blackbox pytestThe tests need no network and no glab login. A fake glab (tests/fakeglab.py) answers with the response shapes recorded from a GitLab 19.5 instance, in process for the pollers and as a real executable on PATH for the daemon tests, which start real daemons in temporary dirs.
glab-cli-extras is developed against real GitLab instances, so host names, project paths and user names from them end up in terminals, scratch files and test runs, and from there they can slip into a commit. scripts/hooks holds a pre-commit and a commit-msg hook that refuse a commit when a staged line or the commit message matches a private identifier. The identifiers live in a file outside the repository, because listing them here would publish the very strings the hooks exist to keep out. Set it up once per clone:
git config core.hooksPath scripts/hooks
git config hooks.forbiddenPatternsFile ~/.config/git/forbidden-patterns.txt # any path outside the repoThe file holds one extended regex per line, matched case-insensitively; blank lines and # comments are ignored. Until hooks.forbiddenPatternsFile points at a file with at least one pattern, both hooks refuse every commit and print how to fix it. Tests and docs use placeholder names such as gitlab.example.com and group/project.
See CHANGELOG.md.
MIT