---
title: gh
description: Act on GitHub in the official CLI's vocabulary, alongside a github mount that reads the repository as files.
icon: terminal
---

Act on GitHub in the official [CLI](https://cli.github.com)'s vocabulary.
A repository is a tree, so mirage already reads one as files: the
[`github` mount](/python/setup/github) is the read half, and `ls`, `cat`
and `grep` are how an agent explores it. `gh` is the write half, plus the
account-level operations a filesystem has no shape for.

## Install

```python
import os

from mirage import Workspace
from mirage.commands.cli.builtin.gh import GH
from mirage.core.github.config import GhConfig, GitHubConfig
from mirage.resource.github import GitHubResource

token = os.environ["GITHUB_TOKEN"]
repo = await GitHubResource.build(
    config=GitHubConfig(token=token), owner="acme", repo="tools", ref="main")

ws = Workspace({"/repo": repo})
ws.register_cli("gh", GH, GhConfig(token=token, repo="acme/tools"))

await ws.execute("gh repo view")
```

In YAML the same install rides the `clis:` section; see the
[CLI overview](/python/cli/index).

| Field      | Meaning                                                      |
| ---------- | ------------------------------------------------------------ |
| `token`    | the API token, as `GH_TOKEN` carries for real gh              |
| `repo`     | the default repository, as `[HOST/]OWNER/REPO`                |
| `branch` | the current branch, for `{branch}` in an endpoint |
| `base_url` | the API base, for GitHub Enterprise Server                    |

`repo` and `branch` are what real gh reads off the current directory's git
remote and checkout. A workspace has neither, so the install carries them:
`repo` answers a line that names no repository, and both feed the
`{owner}`/`{repo}`/`{branch}` placeholders. Two installs under different head words are two
accounts.

## Reading is the mount, acting is the CLI

```bash
ls /repo/src              # the tree, from the mount
cat /repo/README.md       # a blob, from the mount
grep -r TODO /repo        # the mount again

gh api repos/acme/tools/contents/README.md -X PUT \
  -f message='docs: fix a typo' -f content="$(base64 -w0 new.md)" -f sha=<blob-sha>
```

A write through `gh` lands on the same repository the mount reads, but it
lands **by repository name rather than by any vfs path**, so the mount has
nothing to aim a per-path invalidation at. The spec declares which
resource it serves, and the executor expires that mount's index after the
line, so the next `cat` or `ls` refetches instead of serving the pre-write
bytes. Nothing is required of the caller.

## Verbs

```
gh repo view   [<REPOSITORY>]
gh repo fork   [<REPOSITORY>]  --fork-name
gh repo rename  <NEW-NAME>     -R/--repo
gh api          <ENDPOINT>     -X/--method  -f/--raw-field  -F/--field
```

Every level answers `--help`, and `man gh`, `man gh repo` and
`man gh api` render the same text from the same spec.

### repo

```bash
gh repo view                        # the install's repository
gh repo view acme/tools
gh repo view github.com/acme/tools  # the host segment is accepted

gh repo fork acme/tools
gh repo fork acme/tools --fork-name tools-patched

gh repo rename tools-v2 -R acme/tools
```

`view` prints what gh prints: a `name:` line, a `description:` line, then
`--` and the README, with the separator omitted when the repository has
none. For the repository object as JSON, use `gh api repos/OWNER/REPO`.

`rename` takes the **new name** as the operand and the repository to
rename on `-R`, which is the reverse of what the shape of the line
suggests; that is upstream's grammar, not a mirage choice.

`[HOST/]OWNER/REPO` is parsed from the right, so the owner and the
repository are the last two segments and a leading host is dropped. The
host is accepted for compatibility with lines copied from real gh but
does **not** route: every call goes to the install's `base_url`. A second
host means a second install.

### api

`gh api` reaches every endpoint that has no typed verb, which is most of
them.

```bash
gh api repos/acme/tools
gh api /user
gh api repos/acme/tools/issues -f title='Bug' -f body='Steps...'
gh api -X GET search/code -f q='repo:acme/tools TODO'
gh api repos/acme/tools/issues -F draft=false -F milestone=3
gh api repos/acme/tools/contents/NOTES.md -X DELETE -f message=rm -f sha=<blob-sha>
gh api graphql -f query='{viewer{login}}'

gh api 'repos/{owner}/{repo}/releases'
gh api 'repos/{owner}/{repo}/branches/{branch}'
```

`{owner}`, `{repo}` and `{branch}` expand from the install, the way real
gh expands them from the current repository. Any other brace pair is left
exactly as typed and reaches the wire, which is gh's behavior too. Quote
the endpoint so the shell does not eat the braces.

The rules are gh's own:

- The method is `GET` with no fields and `POST` once a field is given,
  unless `-X` says otherwise.
- A `GET` carries its fields in the **query string**; every other method
  carries them in a **JSON body**.
- `-f/--raw-field` is always a string. `-F/--field` reads `true`, `false`,
  `null` and integers as their JSON types.
- A call with no fields sends **no body at all**, so a bare `DELETE` is a
  bare `DELETE` rather than an empty JSON object with a content type. Some
  endpoints read those differently.
- The leading slash is optional.
- A placeholder expands in an endpoint and in a `-F` value, but not in a
  `-f` one, which is the split gh's own `--help` describes.
- A read (`GET`, `HEAD`, `OPTIONS`) leaves the mount's cache alone; only a
  write expires it.

Output is JSON on stdout, so the rest of the shell composes with it:

```bash
gh api repos/acme/tools | jq -r .default_branch
gh api repos/acme/tools/issues | jq -r '.[].title'
```

## Divergences from upstream gh

`gh` is virtualized, not wrapped, so the table below is the whole of what
differs. Everything else matches `gh` 2.85.

| Divergence                                                                 | Why                                                                                            |
| -------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- |
| `gh api` pretty-prints the response; real gh prints the body verbatim       | the reader is usually an agent or `jq`, and an indented body is what a human reads              |
| `gh repo view` has no `--json`/`-q`/`-t`; the default text view matches      | upstream's field set is GraphQL's, with 75 names; `gh api repos/O/R` is the JSON route          |
| `--paginate`, `-q/--jq`, `-H`, `--input`, `--silent`, `-i` are not built    | not built yet; pipe to `jq` for selection, and paginate with `-f page=2`                        |
| `-F` reads no `@file` values, and `key[sub]=` / `key[]=` nesting is flat    | not built yet                                                                                   |
| `repo rename` has no `-y/--yes`                                             | there is no prompt to skip: nothing in a workspace is interactive                                |
| `repo fork` has no `--clone`, `--remote`, `--org`                           | those act on a host checkout and a git remote, which a workspace has neither of                  |
| a `HOST/` prefix is accepted but never routes                               | one install is one account on one host; a second host is a second install                        |

The interactive and host-side verbs (`auth`, `gist`, `codespace`,
`browse`, `repo clone`) are out of scope for a virtualized CLI, the same
way they are for the other account CLIs.
