@simplysf/simply-gitlab 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -13,12 +13,34 @@ human-readable by default and raw JSON with `--json`, and every command that cha
13
13
  The same capabilities are available to AI agents through
14
14
  [`@simplysf/simply-gitlab-mcp`](https://github.com/SimplySF/simply-gitlab/tree/main/packages/simply-gitlab-mcp).
15
15
 
16
+ ## Documentation
17
+
18
+ Guides and the full command reference: **https://simplysf.github.io/simply-cli/gitlab/**
19
+
20
+ The guides also ship inside this package, under [`docs/`](docs).
21
+
16
22
  ## Install
17
23
 
18
- ```bash
19
- npm install -g @simplysf/simply-gitlab
24
+ `@simplysf/simply-gitlab` is a plugin of the `simply` CLI, not a command of its own. Install the host; this
25
+ plugin installs itself the first time you run one of its commands.
26
+
27
+ ```sh
28
+ npm install -g @simplysf/simply-cli
29
+
30
+ simply gitlab --help
20
31
  ```
21
32
 
33
+ To install it ahead of time — in CI, a container image, or offline:
34
+
35
+ ```sh
36
+ simply plugins install @simplysf/simply-gitlab
37
+ ```
38
+
39
+ > **Upgrading from a standalone install?** This package used to own the `simply` command itself,
40
+ > which meant it could not be installed alongside SimplySF's other CLIs. It is now a plugin.
41
+ > Run `npm uninstall -g @simplysf/simply-gitlab` and install `@simplysf/simply-cli` instead.
42
+ > **Your commands do not change** — `simply gitlab …` is exactly what it was.
43
+
22
44
  ## Issues
23
45
 
24
46
  Please report any issues at https://github.com/SimplySF/simply-gitlab/issues
@@ -0,0 +1,86 @@
1
+ ---
2
+ title: Get Started
3
+ description: Requirements, installation, and a first command for the Simply GitLab CLI.
4
+ ---
5
+
6
+ ## Requirements
7
+
8
+ - Node.js 22 or later
9
+ - A GitLab account on gitlab.com or a self-managed instance, and an access token
10
+
11
+ ## Install
12
+
13
+ ```sh
14
+ npm install -g @simplysf/simply-cli
15
+ ```
16
+
17
+ ## Verify it worked
18
+
19
+ ```sh
20
+ simply gitlab --help
21
+ ```
22
+
23
+ ## Get a token
24
+
25
+ Create a personal access token under **Preferences › Access tokens** in GitLab, or a project or
26
+ group access token from that project's or group's settings. Two scopes matter:
27
+
28
+ | Scope | What it allows |
29
+ | ---------- | -------------------------------------------------------- |
30
+ | `read_api` | Everything on this site that reads. Nothing that writes. |
31
+ | `api` | Reads and writes. |
32
+
33
+ Start with `read_api`. It is not just a smaller permission — it is the only thing that makes a
34
+ write structurally impossible, which matters as soon as a script or an agent is running these
35
+ commands. See [Write safety](/guides/write-safety/).
36
+
37
+ ## Connect to your instance
38
+
39
+ Every command reads its connection settings from environment variables, from flags, or from a
40
+ `.env` file passed with `-e/--env-file`:
41
+
42
+ ```
43
+ GITLAB_TOKEN=glpat-...
44
+ GITLAB_URL=https://gitlab.example.com # omit entirely for gitlab.com
45
+ ```
46
+
47
+ `GITLAB_URL` defaults to `https://gitlab.com`, so a gitlab.com user needs only the token. See
48
+ [Credentials](/guides/credentials/) for precedence between flags, environment, and file, and for
49
+ trusting an internal certificate authority.
50
+
51
+ ## First commands
52
+
53
+ Find a project you can reach, then look at its open merge requests:
54
+
55
+ ```sh
56
+ simply gitlab project list --membership
57
+ simply gitlab mr list --project group/project
58
+ ```
59
+
60
+ Add `--json` to either to get the raw API payload instead of the formatted table.
61
+
62
+ ## Naming a project
63
+
64
+ Every project-scoped command takes `-p/--project`, which accepts a numeric id or the full path.
65
+ Both of these are the same project:
66
+
67
+ ```sh
68
+ simply gitlab mr list --project 1234
69
+ simply gitlab mr list --project group/subgroup/project
70
+ ```
71
+
72
+ The path is URL-encoded for you — do not encode it yourself. If you work in one project most of
73
+ the time, set `GITLAB_PROJECT` and drop the flag.
74
+
75
+ ## Where to go next
76
+
77
+ - The [Command Reference](/reference/) lists every command by topic, with its flags and examples.
78
+ - [Credentials](/guides/credentials/) covers tokens, precedence, and certificates.
79
+ - [Write safety](/guides/write-safety/) explains `--dry-run`, `GITLAB_READ_ONLY`, and why a
80
+ `read_api` token is the only one of those that actually binds.
81
+ - [Scripts and agents](/guides/scripting/) covers the `--json` contract and exit codes.
82
+ - [MCP server](/guides/mcp-server/) gives an AI agent the same capabilities as tools, read-only by
83
+ default.
84
+ - Want to contribute? See
85
+ [CONTRIBUTING.md](https://github.com/SimplySF/simply-gitlab/blob/main/CONTRIBUTING.md) in the
86
+ repo.
@@ -0,0 +1,186 @@
1
+ ---
2
+ title: Configuration baselines
3
+ description: Hold many GitLab projects to one standard - export a baseline from a project you like, see what differs across the rest, and converge them.
4
+ ---
5
+
6
+ Fifty projects drift. One was created before you settled on squash merges, someone unprotected
7
+ `main` to fix something and never put it back, and two never had an approval rule at all. There is
8
+ no answer to "are these the same?" short of opening fifty tabs.
9
+
10
+ A baseline is a JSON file saying what those projects should have in common, and three commands that
11
+ read it:
12
+
13
+ | Command | Writes? | What it does |
14
+ | ----------------------------- | ------- | ---------------------------------------------------- |
15
+ | `simply gitlab config export` | no | Emits a config from a project you already like |
16
+ | `simply gitlab config plan` | no | Reports, per project, what differs from the baseline |
17
+ | `simply gitlab config apply` | yes | Converges each project onto the baseline |
18
+
19
+ ## Start by exporting
20
+
21
+ Do not write the file by hand. Export the project that already looks right and delete what you do
22
+ not care about:
23
+
24
+ ```sh
25
+ simply gitlab config export --project group/api > baseline.json
26
+ ```
27
+
28
+ The output is deliberately verbose — every supported key, not a curated subset — because deleting a
29
+ line is easier than discovering that a key exists. Variables come out as metadata only: keys,
30
+ scopes, and flags, never values.
31
+
32
+ ## Point it at the rest
33
+
34
+ The exported file targets only the project it came from. Widen it on purpose:
35
+
36
+ ```json
37
+ {
38
+ "version": 1,
39
+ "targets": {
40
+ "groups": ["platform"],
41
+ "exclude": ["platform/sandbox", "platform/archived-*"]
42
+ },
43
+ "project": {
44
+ "settings": {
45
+ "merge_method": "ff",
46
+ "squash_option": "default_on",
47
+ "only_allow_merge_if_pipeline_succeeds": true
48
+ },
49
+ "protectedBranches": [{ "name": "main", "allow_force_push": false }],
50
+ "approvalRules": [{ "name": "Two maintainers", "approvals_required": 2, "usernames": ["alice", "bob"] }]
51
+ }
52
+ }
53
+ ```
54
+
55
+ `targets.groups` expands through subgroups. `exclude` takes a trailing `*` to drop a whole subtree.
56
+ A project the token cannot see is a hard error, not a silent omission — a baseline that quietly
57
+ skipped six of fifty would leave those six wrong with nobody knowing to look.
58
+
59
+ ## Read the plan
60
+
61
+ ```sh
62
+ simply gitlab config plan --config ./baseline.json
63
+ ```
64
+
65
+ ```
66
+ Targets: 50 project(s), 1 group(s).
67
+
68
+ platform/api
69
+ settings
70
+ ~ merge_method merge → ff
71
+ ~ only_allow_merge_if_pipeline_succeeds false → true
72
+ protectedBranches
73
+ ~ main.allow_force_push true → false
74
+ - legacy (not pruned; set "prune": true on this section to remove it)
75
+
76
+ platform/worker
77
+ ✓ matches the baseline
78
+
79
+ 50 target(s): 31 match, 19 differ.
80
+ ```
81
+
82
+ Three things in that output are worth understanding.
83
+
84
+ **Only what you declared is compared.** A project with forty settings the config never mentions
85
+ shows nothing for them. That is what makes a baseline safe to point at projects that already exist:
86
+ the blast radius is the set of keys you wrote down.
87
+
88
+ **Drift you are not fixing is still shown.** `- legacy` is a protected branch that exists but is not
89
+ in the config. By default nothing is deleted, and the line says so. Seeing what you are choosing not
90
+ to correct is most of the value of running this across fifty projects.
91
+
92
+ **A section can come back unsupported.** Approval rules, approval settings, and push rules are
93
+ Premium/Ultimate. On an instance without them the section reports that and the rest still converges.
94
+
95
+ ## Apply it
96
+
97
+ ```sh
98
+ simply gitlab config apply --config ./baseline.json --dry-run # prints the plan, sends nothing
99
+ simply gitlab config apply --config ./baseline.json
100
+ ```
101
+
102
+ Apply executes exactly what the plan showed — the two are computed in one pass, so a preview is a
103
+ guarantee rather than an approximation. A second run against a converged project sends no requests
104
+ at all.
105
+
106
+ Sections are applied in a fixed order: settings, protected branches, protected tags, approval
107
+ settings, approval rules, push rules. That order exists because an approval rule scoped to `main`
108
+ needs `main` protected before it can reference it.
109
+
110
+ One project failing does not abandon the other forty-nine. There is no transaction across GitLab
111
+ endpoints, so a project that fails part-way keeps the sections already applied; the summary says
112
+ where it stopped, and `plan` afterwards shows what is left.
113
+
114
+ ## Per-project exceptions
115
+
116
+ `overrides` is keyed by project path:
117
+
118
+ ```json
119
+ "overrides": {
120
+ "platform/legacy-api": {
121
+ "project": {
122
+ "settings": { "merge_method": "merge" },
123
+ "approvalRules": [{ "name": "Two maintainers", "approvals_required": 1 }]
124
+ }
125
+ }
126
+ }
127
+ ```
128
+
129
+ Lists merge by a natural key — `name` for branches, tags, and rules; `key` plus `environment_scope`
130
+ for variables — so an override adjusts one entry rather than restating the array. The rule above
131
+ changes one number and inherits the usernames from the baseline.
132
+
133
+ Set `"$replace": true` on a section to swap the list wholesale instead, for the project that needs
134
+ genuinely different rules rather than adjusted ones.
135
+
136
+ ## Turning on pruning
137
+
138
+ By default the config adds and corrects; it never deletes. When you are ready for a section to be
139
+ authoritative, opt in:
140
+
141
+ ```json
142
+ "protectedBranches": {
143
+ "prune": true,
144
+ "entries": [{ "name": "main", "allow_force_push": false }]
145
+ }
146
+ ```
147
+
148
+ Now an undeclared protected branch is removed. Adopt this section by section, after `plan` has shown
149
+ you what it would take away — a first run against fifty existing projects with everything pruning
150
+ could remove protections that predate the config.
151
+
152
+ ## Checking for drift in CI
153
+
154
+ ```sh
155
+ simply gitlab config plan --config ./baseline.json --fail-on-drift
156
+ ```
157
+
158
+ Exits `1` when anything differs, so a scheduled job tells you something drifted without anyone
159
+ reading the output. `--json` returns the whole plan as structured data if you would rather track it
160
+ over time:
161
+
162
+ ```sh
163
+ simply gitlab config plan --config ./baseline.json --json | jq '.summary'
164
+ ```
165
+
166
+ ## CI/CD variables are reported, not written
167
+
168
+ The config can declare which variables must exist and what flags they carry. `plan` reports the ones
169
+ that are missing or wrong; `apply` does not touch them.
170
+
171
+ That is the API, not caution. Creating a variable requires a value, and so does updating one — even
172
+ to change `masked` from false to true. A variable created as `masked_and_hidden` will not return its
173
+ value at all. There is no honest way to reconcile secrets from a file that, by design, holds none.
174
+
175
+ Knowing that six projects are missing `DEPLOY_ENV` is still the question people actually ask; setting
176
+ it is a person's job.
177
+
178
+ ## From an agent
179
+
180
+ The MCP server exposes `gitlab_config_plan` and `gitlab_config_export`, so you can ask an agent
181
+ "which of our projects are missing the two-approval rule?" and get an answer.
182
+
183
+ There is deliberately no tool that applies a baseline. Changing settings across fifty projects is a
184
+ deployment, not a tool call, and belongs to a person with the plan in front of them. An agent that
185
+ should apply one can run the CLI, where it is a shell command you can see and approve. See the
186
+ [MCP server guide](/guides/mcp-server/).
@@ -0,0 +1,100 @@
1
+ ---
2
+ title: Credentials
3
+ description: How the Simply GitLab CLI finds its connection settings, which token scope to use, and how flags, environment variables, and an env file take precedence.
4
+ ---
5
+
6
+ Connection settings come from environment variables, from flags, or from a `.env` file named with
7
+ `-e/--env-file`. Only GitLab connection variables are read from that file; anything else in it is
8
+ ignored.
9
+
10
+ ```
11
+ GITLAB_TOKEN=glpat-...
12
+ GITLAB_URL=https://gitlab.example.com # omit entirely for gitlab.com
13
+ ```
14
+
15
+ There are only two settings, and one of them has a default — `GITLAB_URL` is `https://gitlab.com`
16
+ unless you say otherwise. A gitlab.com user needs the token and nothing else.
17
+
18
+ ## Tokens and scopes
19
+
20
+ The token is a personal access token (**Preferences › Access tokens**), a project access token, or
21
+ a group access token. All three work the same way; the CLI sends whichever you give it as GitLab's
22
+ `PRIVATE-TOKEN` header.
23
+
24
+ | Scope | Covers |
25
+ | ---------- | ------------------------------------------- |
26
+ | `read_api` | Every read command on this site. No writes. |
27
+ | `api` | Reads and writes. |
28
+
29
+ Prefer `read_api` and reach for `api` only when you mean to change something — see
30
+ [Write safety](/guides/write-safety/) for why that distinction carries more weight than it looks
31
+ like it should.
32
+
33
+ OAuth 2 tokens are not supported: GitLab accepts those only as `Authorization: Bearer`, and this
34
+ CLI runs no OAuth flow.
35
+
36
+ ## Precedence
37
+
38
+ Explicit flags beat the environment, which beats the file's contents. That ordering is what makes
39
+ the two-file arrangement in [Write safety](/guides/write-safety/) work: a `read_api` file can be
40
+ the default, and a write-capable one is named explicitly for the one command that needs it.
41
+
42
+ ```sh
43
+ simply gitlab mr list -e ~/gitlab.env --project group/project
44
+ simply gitlab mr update -e ~/gitlab-write.env --project group/project --mr 42 --state close
45
+ ```
46
+
47
+ A variable exported as an empty string does not outrank the file. `export GITLAB_TOKEN=` in a
48
+ wrapper script would otherwise discard the real token and fail as though nothing were configured.
49
+
50
+ ## Naming a project
51
+
52
+ Not a credential, but it belongs in the same shell profile. `GITLAB_PROJECT` sets the default for
53
+ `-p/--project`:
54
+
55
+ ```sh
56
+ export GITLAB_PROJECT=group/subgroup/project
57
+ simply gitlab mr list
58
+ ```
59
+
60
+ A project is named by numeric id or full path, and the path is URL-encoded for you.
61
+
62
+ ## Self-managed instances
63
+
64
+ Set `GITLAB_URL` to the instance origin. A subpath is kept, because self-managed instances are
65
+ routinely served from one:
66
+
67
+ ```
68
+ GITLAB_URL=https://example.com/gitlab
69
+ ```
70
+
71
+ One thing to watch: pasting a **project** URL from gitlab.com — `https://gitlab.com/group/project`
72
+ — sets the instance, not the project. The CLI keeps only the origin in that case, since gitlab.com
73
+ has no subpath. On a self-managed host it cannot tell a subpath from a project path, so give it the
74
+ instance root and name the project with `--project`.
75
+
76
+ ## Certificate verification
77
+
78
+ Certificate verification is always on. For an instance behind an internal or agency certificate
79
+ authority, trust that CA rather than disabling verification:
80
+
81
+ ```sh
82
+ NODE_EXTRA_CA_CERTS=/path/to/ca.pem simply gitlab project list
83
+ ```
84
+
85
+ A `GITLAB_SSL_VERIFY=false` carried over from other GitLab tooling is rejected with an error rather
86
+ than silently ignored, so nobody ends up believing verification is off when it is on, or the
87
+ reverse.
88
+
89
+ ## Checking what you're connected as
90
+
91
+ There is no `whoami` command. The cheapest check is a list you expect to be non-empty:
92
+
93
+ ```sh
94
+ simply gitlab project list --membership --limit 5
95
+ ```
96
+
97
+ A wrong or expired token comes back as an authentication error with exit code `3`. A `403` on a
98
+ command that changes data says the token may be `read_api`-scoped, or that your role on the project
99
+ does not permit the change — so a refused write is distinguishable from a genuine permissions
100
+ problem.
@@ -0,0 +1,122 @@
1
+ ---
2
+ title: Scripts and agents
3
+ description: The output contract that makes the Simply GitLab CLI safe to drive from a shell script or an AI agent - raw JSON, stable exit codes, honest truncation, no prompts.
4
+ ---
5
+
6
+ The CLI is designed for two callers at once: a person at a terminal, and a script or AI agent
7
+ running it as shell commands. The defaults serve the person; a small, stable contract serves the
8
+ script.
9
+
10
+ ## `--json` returns the raw API payload
11
+
12
+ Every command accepts `--json`, without exception. The output is the unmodified response from the
13
+ GitLab API — the full object, not the curated subset the table shows — so anything the API exposes
14
+ is reachable with `jq`, and the payload shape is GitLab's contract rather than this CLI's.
15
+
16
+ ```sh
17
+ simply gitlab mr view --project group/project --mr 42 --json | jq -r '.detailed_merge_status'
18
+ ```
19
+
20
+ ## Lists tell you when they were cut short
21
+
22
+ Every list command follows pages up to `--limit`, so there is no single response to hand back.
23
+ They return an envelope instead:
24
+
25
+ ```json
26
+ { "items": [...], "total": 500, "pages": 3, "complete": false }
27
+ ```
28
+
29
+ The elements inside `items` are still raw API objects. **`complete: false` means `--limit` cut the
30
+ results short** and more exist, so a caller can detect truncation without counting rows. `total` is
31
+ present only when GitLab reports one — keyset-paginated endpoints and very large result sets omit
32
+ it, so treat a missing `total` as unknown rather than zero.
33
+
34
+ ```sh
35
+ out=$(simply gitlab mr list --project group/project --state all --limit 100 --json)
36
+ if [ "$(jq -r .complete <<<"$out")" = "false" ]; then
37
+ echo "more than 100 merge requests; raise --limit" >&2
38
+ fi
39
+ ```
40
+
41
+ ## Exit codes and errors
42
+
43
+ Errors carry no stack trace and map to stable exit codes:
44
+
45
+ | Exit code | Meaning |
46
+ | --------- | ---------------------------------------------------------- |
47
+ | `0` | Success |
48
+ | `2` | Configuration or usage problem, e.g. a missing token |
49
+ | `3` | Authentication or authorization failure |
50
+ | `1` | Anything else, including an API error or a network failure |
51
+
52
+ Under `--json`, a failure is written as one JSON object on **stderr** and stdout stays empty, so a
53
+ caller that captures stdout never has to disambiguate an error from a payload. The object nests
54
+ under an `error` key:
55
+
56
+ ```json
57
+ { "error": { "name": "HttpError", "message": "...", "exitCode": 1, "status": 404, "body": {} } }
58
+ ```
59
+
60
+ `body` carries the API's own response when there was one. Every string in the object — the message
61
+ and everything inside `body` — is scrubbed of any known credential value and of characters that
62
+ could rewrite the terminal or hide text, so a response that quotes your token back at you does not
63
+ put it on the stream.
64
+
65
+ ```sh
66
+ if ! out=$(simply gitlab mr view --project group/project --mr 999 --json 2>err.json); then
67
+ case $? in
68
+ 3) echo "credentials rejected";;
69
+ *) jq -r .error.message err.json;;
70
+ esac
71
+ fi
72
+ ```
73
+
74
+ ## Address merge requests by `iid`
75
+
76
+ A merge request has both an `id` and an `iid`. Both are bare integers, both appear in every
77
+ payload, and the endpoints take the **`iid`** — the number in the web URL. Passing the wrong one
78
+ does not fail: it addresses a different merge request in a different project that happens to share
79
+ the number.
80
+
81
+ The CLI validates the shape and refuses anything that is not a positive whole number, but it cannot
82
+ catch a valid-looking wrong number. When scripting, read `iid` from the list output, never `id`:
83
+
84
+ ```sh
85
+ simply gitlab mr list --project group/project --json | jq -r '.items[].iid'
86
+ ```
87
+
88
+ ## Keep payloads small
89
+
90
+ `--simple` on `project list` returns a much smaller object. `--stat` on `commit diff` lists the
91
+ changed files without the hunks. `--tail` on `ci job log` defaults to the last 200 lines, because a
92
+ trace is regularly tens of megabytes and an agent pays for every token it reads:
93
+
94
+ ```sh
95
+ simply gitlab ci job list --project group/project --scope failed --limit 5 --json \
96
+ | jq -r '.items[0].id' \
97
+ | xargs -I{} simply gitlab ci job log --project group/project --job {} --tail 50
98
+ ```
99
+
100
+ ## Retries are handled for you
101
+
102
+ 429 and 5xx responses retry with exponential backoff, honouring a capped `Retry-After`. DNS
103
+ failures and certificate problems do not retry, because another attempt cannot fix them. A script
104
+ does not need its own retry loop around these commands.
105
+
106
+ ## Never interactive
107
+
108
+ No command prompts or blocks on stdin, and there is no `--confirm` flag to hang on. Every write
109
+ takes `--dry-run` instead. See [Write safety](/guides/write-safety/) for the full set of layers and
110
+ the two-credential-file arrangement that keeps an agent's normal loop read-only.
111
+
112
+ ## Help is discovery
113
+
114
+ `--help` output is generated from the command classes and written to be self-sufficient, so an
115
+ agent can discover flags and examples the same way a person does:
116
+
117
+ ```sh
118
+ simply gitlab mr --help
119
+ simply gitlab mr create --help
120
+ ```
121
+
122
+ The same text, for every command, is on the [Command Reference](/reference/) pages.
@@ -0,0 +1,121 @@
1
+ ---
2
+ title: Write safety
3
+ description: The layers between a Simply GitLab command and a change to your repository, which of them is a real boundary, and why CI variable values are hidden by default.
4
+ ---
5
+
6
+ Six commands change data — `branch create`, `file create`, `file update`, `commit create`,
7
+ `mr create`, and `mr update`. Nothing in this CLI deletes anything. Those six sit behind several
8
+ layers, and only the last is a real boundary.
9
+
10
+ ## No `--confirm`, deliberately
11
+
12
+ Every write here either creates something or edits a merge request, and both are recoverable: a
13
+ branch can be deleted, a file can be committed over, a closed merge request can be reopened.
14
+ Requiring a confirmation for actions like those would train you to pass it always, at which point
15
+ it protects nothing while still implying that it does.
16
+
17
+ So there is no `--confirm` flag anywhere in this CLI. The first command that irreversibly destroys
18
+ something is the one that brings it back.
19
+
20
+ ## `--dry-run`
21
+
22
+ Accepted by all six. It prints the exact request body that would be sent and sends nothing, which
23
+ is the cheapest way to see what is about to happen:
24
+
25
+ ```sh
26
+ simply gitlab mr create --project group/project \
27
+ --source-branch feature/thing --target-branch main --title "feat: the thing" --dry-run
28
+ ```
29
+
30
+ ```
31
+ Dry run — not sent. Request body:
32
+ {
33
+ "source_branch": "feature/thing",
34
+ "target_branch": "main",
35
+ "title": "feat: the thing"
36
+ }
37
+ ```
38
+
39
+ ## `--last-commit-id` on a file update
40
+
41
+ `file update` replaces the whole file; there is no partial edit. That makes a lost-update race real
42
+ whenever two people or two runs touch the same file. `file view` reports the file's last commit,
43
+ and passing it back makes GitLab reject the write if anything changed meanwhile:
44
+
45
+ ```sh
46
+ simply gitlab file view --project group/project --path README.md --ref main
47
+ # Last commit: 9a1b2c3
48
+
49
+ simply gitlab file update --project group/project --path README.md --branch main \
50
+ --content-file ./README.md --message "docs: refresh" --last-commit-id 9a1b2c3
51
+ ```
52
+
53
+ Without it, the last writer silently wins.
54
+
55
+ ## `GITLAB_READ_ONLY`
56
+
57
+ Set it to `1`, `true`, `yes`, or `on` and every write command refuses before making any request.
58
+ Reads are unaffected. This guards against misconfiguration: the wrong credential file, the wrong
59
+ project, the wrong context. It is not a security boundary, because anything that can run commands
60
+ can also unset an environment variable.
61
+
62
+ ## A `read_api` token
63
+
64
+ This is the only layer that actually binds. A `read_api`-scoped token cannot create, edit, or push
65
+ anything — GitLab refuses server-side, regardless of what this CLI sends or what any caller is
66
+ persuaded to attempt.
67
+
68
+ That matters most when an AI agent drives the CLI, because merge request descriptions, commit
69
+ messages, and file contents are written by anyone who can push, and an agent reading that text
70
+ cannot reliably tell instructions from content. The arrangement worth adopting is two credential
71
+ files:
72
+
73
+ ```
74
+ ~/gitlab.env # read_api token — what the agent uses by default
75
+ ~/gitlab-write.env # api token — passed explicitly, by a person
76
+ ```
77
+
78
+ ```sh
79
+ simply gitlab mr list -e ~/gitlab.env --project group/project
80
+ simply gitlab mr update -e ~/gitlab-write.env --project group/project --mr 42 --state close
81
+ ```
82
+
83
+ The agent's normal loop is then structurally incapable of changing anything, and a write becomes a
84
+ deliberate act.
85
+
86
+ ## CI variable values are hidden
87
+
88
+ `simply gitlab ci variable list` replaces every value with `<hidden>`, in the table and under
89
+ `--json` alike:
90
+
91
+ ```sh
92
+ simply gitlab ci variable list --project group/project
93
+ ```
94
+
95
+ ```
96
+ KEY SCOPE TYPE PROTECTED MASKED VALUE
97
+ ────────── ───── ─────── ───────── ────── ────────
98
+ DEPLOY_KEY * env_var yes yes <hidden>
99
+
100
+ Showing 1 of 1 variable(s).
101
+ Values are hidden. Pass --reveal to print them.
102
+ ```
103
+
104
+ This is worth explaining, because GitLab's own API does not do this. `GET /projects/:id/variables`
105
+ returns **every** value in clear text — including the ones marked "masked", since masking only
106
+ hides a value in job logs, not from the API. Those values are deploy keys, signing certificates,
107
+ and production credentials, and the usual reason to list them is to see _which_ exist, not to read
108
+ them.
109
+
110
+ `--reveal` prints them. It exists on the CLI and has no equivalent in the
111
+ [MCP server](/guides/mcp-server/), deliberately: a value revealed to a person is on their screen,
112
+ and a value revealed to an agent is in a context window, a transcript, and whatever that agent
113
+ writes next.
114
+
115
+ ## Untrusted text is stripped
116
+
117
+ Merge request titles and descriptions, commit messages, repository file contents, and CI job traces
118
+ are all written by whoever can push to a project or open a merge request. Every one of them is
119
+ stripped of control characters before it is printed, so an escape sequence cannot make your
120
+ terminal show something other than what the API returned — and your token is redacted from every
121
+ error message and response body.