feat(gh): a GitHub CLI, and the write half of the integ GitHub fake (#768)

* feat(integ/github): repo, contents, issues and compare routes

The fake served the read path the `github` resource mounts -- repo
metadata, git/trees, git/blobs -- plus the Actions dataset `github_ci`
needs. A task that *acts* on a repository had nothing to call.

Adds the routes such a task uses: GET /user; POST /user/repos; DELETE
/repos/{owner}/{repo}; POST .../forks; GET .../branches and
.../branches/{branch}; GET .../commits; GET and PUT .../contents/{path};
GET and POST .../issues; GET .../compare/{basehead}.

Writes are visible to the next read, which is the point -- an issue
filed shows up in the listing, a committed file reads back with its new
bytes and a new blob sha, and the commit is recorded so `compare`
can answer which files moved. `FakeRepo` grows `issues` and `commits`
for that; a repository with no writes yet still reports one synthetic
root commit, so "the latest commit" is answerable before the agent has
done anything.

Three behaviours worth pinning rather than leaving to chance:

- A fork deep-copies. A task that forks an archive repo per run and then
  commits to the fork must not write through to the source.
- PUT /contents enforces GitHub's sha rule in both directions: replacing
  an existing file without the current blob sha is 409, and supplying
  one for a file that does not exist is 422. A task that reads before
  writing is doing so for this reason.
- A compare against a base the repository has never seen is 404, not an
  empty file list. Answering "nothing changed" to a question about an
  unrelated commit is the shape of wrongness that reads as success.

`_commit_list` is newest-first, so `compare` collects the commits
*before* it reaches the base; walking past the base instead would report
the commits the base already contains.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(integ/github): git data API, rename, raw content and GHES paths

The write half a task that builds a repository needs, plus the two shapes
a real client insists on:

  - PATCH /repos/{owner}/{repo} renames, carrying the content with it, and
    POST /forks takes a name so a fork can be named in one step.
  - The git data API -- get commit, create tree, create commit, move ref --
    so a multi-file push lands as one commit. A staged tree is invisible
    until a ref points at it, which is what git does and what keeps an
    abandoned tree from showing up in a read.
  - GET /git/ref/{ref}, and trees by commit sha, because a client resolves
    a ref to a commit before reading anything.
  - /raw/{owner}/{repo}/{ref}/{path}, with a text content type for text, so
    a reader does not base64 the whole repository.
  - Every route is served under /api/v3 as well as at the root: a client
    pointed at a host that is not github.com talks Enterprise.
  - GET /search/repositories, and an unrouted fallback that names the
    method and path on stderr. Both exist for the same reason: a 404 is an
    answer to a real client, so an unimplemented endpoint reads as a
    negative result rather than as an error.
  - Seeding takes a per-repo default branch, since a template whose branch
    is master is graded at that ref by name.

* feat(gh): a GitHub CLI, and the write routes the fake needs behind it

The `github` mount is the read half -- a repository is a tree, so listing
and reading it is `ls` and `cat`. There was no write half at all:
GITHUB_IO carries readdir/read/stat where GDRIVE_IO carries the full set,
so nothing could commit a file, and forking and renaming are account
operations a filesystem has no shape for in any case.

`gh` is that half, spelled as cli.github.com spells it:

    gh repo view [OWNER/REPO]
    gh repo fork OWNER/REPO [--fork-name NAME]
    gh repo rename NEW-NAME -R OWNER/REPO
    gh api ENDPOINT [-X METHOD] [-f key=value] [-F key=value]

`rename` takes the new name as the operand and the repository as -R, which
is the reverse of what the shape of the line suggests and is upstream's.
`-f` sends a string and `-F` reads true/false/null/integers as their JSON
types, which is gh's own --raw-field / --field split; a call with no fields
is a GET, one with fields a POST unless -X says otherwise. Committing a
file is `gh api ... -X PUT -f content="$(base64 -w0 f)"`, which is what
real gh and real GitHub require of each other -- there is no file verb to
mimic.

The transport was GET-only, so it grows a `request`, and an empty body (204,
an empty 202) decodes to null rather than throwing on a call that worked.

Fake, in support: DELETE /contents with the same sha rule as the replace,
and GET /commits/{ref}, which is not /git/commits/{sha} -- it takes a branch
name and reports the file list. Both were found by the unrouted logger
during a run: an agent issued 35 deletes, every one 404'd, and it reported
the files as removed, because to a real client a 404 is an answer.

* feat(integ/github): repository metadata, star-sorted search and readme

A task that picks *between* repositories reads their descriptions and star
counts and never opens one, so seeding gains a second mode: --metadata takes
a JSON file keyed by owner/name and creates repositories that are metadata
only. The tree and contents endpoints answer 404 for them, which is what
GitHub says about a repository with no files.

search/repositories honours sort=stars and matches the description as well
as the name, since a repository is found by what it says it does at least as
often as by what it is called. Terms OR rather than AND, which is looser
than GitHub and errs towards showing a caller the row it wants.

GET /repos/{owner}/{repo}/readme, found by the unrouted logger during a run.

* feat(integ/github): branches

The fake kept exactly one branch, which rules out every task whose premise
is a difference between two of them. FakeRepo now holds a file map and a
commit list per branch:

  - `files` and `commits` stay bound to the default branch, so every route
    that does not name a ref reads what it always read.
  - `branch_for` resolves a ref -- a branch name, HEAD, or a commit sha
    belonging to one branch's history -- and `tree_of` returns that
    branch's files, so a bad ref is a 404 rather than a silent read of the
    default branch.
  - contents, tree, raw, readme, branches, branch, commits, commit and
    git/ref are ref-aware; PUT and DELETE /contents write to the branch
    their body names; PATCH /git/refs moves the branch it names.
  - Each branch has its own synthetic root commit, so two branches of a
    fixture do not share a head.
  - A fork copies every branch, not just the default one.
  - Seeding takes `into=<branch>`, so a two-branch fixture is two
    directories and two --repo flags naming one repository.

The default-branch binding is what keeps this small: 18 call sites read
`repo.files` and none of them had to change.

* feat(integ/github): GET /git/refs/{prefix}

The plural is a different endpoint from the singular: git/ref/<full-ref>
returns one object, git/refs/<prefix> a list of everything beneath it. A
caller picks whichever it expects, so serving only the singular made the
plural read as 'no such ref'. Found by the unrouted logger during a run.

* feat(integ/github): account repo listing, bare /contents, mirror-stable shas

Three things a task that reasons across several repositories needs.

GET /users/{login}/repos and GET /user/repos. A task that asks "is there a
repository for this on my GitHub" answers it here, and a 404 read as "the
account has none" -- a wrong answer rather than an error, which is the
failure mode the unrouted logger exists for.

GET /repos/{owner}/{repo}/contents, with no trailing slash. GitHub serves
both spellings of the root and a caller picks either; only one was routed.

A branch's root commit sha is now derived from its content rather than from
the repository's name. That is what `git clone --mirror` followed by a push
actually gives you -- the same shas -- and a grader that reads an initial
sha from an upstream repository and the latest from a local copy, then asks
what changed between them, depends on it. Two branches still differ exactly
when their trees differ.

* feat(integ/github): POST /git/refs, so a branch can be created

The last write a task can ask for that the fake had no answer to. A new
branch starts as a copy of whatever the base sha resolves to -- which is
what a branch is, another name for one commit and everything reachable from
it -- with its own empty history, so the two do not share future commits.

422 on a ref that is not under refs/heads/, on a name that already exists,
and on a base sha that resolves to nothing.

* feat(integ/github): GET /, the API root

github.com answers the root with a map of endpoint URL templates. Serving
only the endpoints beneath it meant a client probing the root got a 404,
which reads as the host not being there at all rather than as an unfamiliar
API. Found by the unrouted logger during a run.

* feat(integ/github): seed a branch's commit history, not just its tree

Seeding a directory filled a tree and nothing else, so every seeded
branch answered GET /commits with one synthetic root. That is enough for
a task that reads files and wrong for one that asks when something
arrived. `task-tracker` wants the tasks added by the *most recent*
commit on each of fifteen developer branches, and each branch has five
or six -- so a history of one does not make the task hard, it makes it
unanswerable while looking answerable.

--commits owner/name=<file> reads a manifest keyed by branch, each an
array oldest first, and is applied after every tree: a manifest names
branches, and a branch no directory was seeded into would otherwise be
created here as an empty tree with a history, which reads as a branch
whose files were all deleted.

Shas are derived from full_name, branch, date and message rather than
carried, for the same reason the root's is derived from its tree: a
fixture has to answer the same way twice.

Commits now carry commit.author/committer (name, email, date) and
author.login, which is what a client sorting or filtering by date reads,
and the synthetic root carries them too against a fixed epoch -- it
stands for everything before the fixture rather than for a moment, so
"now" would make it sort ahead of the commits it precedes.

GET /commits/{ref} now renders `files` as {filename, status} objects the
way GitHub does. The list endpoint keeps paths, which is also GitHub's
split: `files` is absent from a commit in a list and detailed when one
commit is asked for. _commit_files is shared with the compare route,
which was building the same objects inline.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor(github): back the transport with octokit

Wraps @octokit/core behind the unchanged GitHubTransport, so the github
mount and the gh CLI both gain retry and throttling. Handles three
octokit behaviours: {} in a url is a route template that silently eats
the segment, the "METHOD /path" form splits on whitespace, and 204
decodes to '' rather than null. The write throttle is github.com's own
secondary rate limit, so it is off for any other host.

* fix(gh): name the mount its writes invalidate, and parse the host segment

serves was empty, so the executor's post-write cache drop did nothing and
a committed file still read back as its pre-write bytes. parseRepo took
the first two segments of [HOST/]OWNER/REPO, so github.com/acme/tools
resolved to github.com/acme -- a different repository, reported as
success.

* fix(cache): invalidate the index instead of clearing it

A cleared index reads exactly like one that was never filled, so github --
whose index is the whole listing rather than a cache in front of one --
could not tell a cache drop from an empty repository, and ls reported the
mount root missing. IndexCacheStore grows invalidate(), which expires
entries in place, and github refetches once on an EXPIRED lookup. Also
fixes a latent bug with no CLI involved: after the 24h index TTL lapsed a
github mount answered ls with exit 0 and no output. Redis cannot express
stale (an expired key is gone), so it still clears; the comment says why.

* feat(gh): a python gh CLI, mirroring the typescript one

Same four verbs and the same grammar. The python client was GET-only, so
it grows github_request alongside github_get. Layout divergence 305 -> 303.

* test(integ): a gh CLI battery on both hosts

15 cases on a new cli-gh target, covering the write-then-read path through
the mount, the refusals, and the reversed rename grammar. The fake grows
POST /reset, because the cli facet runs both hosts against one process and
writes would otherwise leak between them; it also stops rejecting wrapped
base64, which real GitHub accepts. The unrouted-request logger is now a CI
failure rather than a log line.

* fix(github): probe the parent listing, so an update is not read back stale

The index tracks freshness per directory, never per entry, so get()
never answers EXPIRED and the refill it guarded was unreachable: after a
write invalidated the index the blob's row survived carrying the
pre-write sha, and the read served the old bytes. Create and delete went
through readdir and worked; an update did not.

Readdir on the TypeScript side refilled on any miss, which spent a full
recursive-tree call on every ENOENT and diverged from python. Both now
refill only on EXPIRED.

* test(cache): pin what invalidate keeps that clear discards

A cleared store reads exactly like one that was never filled, which is
what made a github mount report an empty repository. Assert the two
answer differently.

* fix(spec): render an operand's declared name outside the clap dialect

A named slot printed as <text>, so 'man gh api' said <text> where gh
says <endpoint>. argparse prints the dest too. gh is the only spec this
changes; ntn is clap and already rendered it.

* test(integ): cover the gh surface the battery missed

Update-then-read is the case that found the stale-read bug. Also the
help and discovery surface, a leading-slash endpoint, typed fields on a
GET, and a missing endpoint.

* docs(gh): a page per host for the gh CLI

Install, the mount-reads/CLI-acts split, the api rules, and a table of
what differs from gh 2.85.

* fix(github): keep an api field from steering the request

Octokit reads loose parameters off the same object that carries url,
method and headers, so `gh api X -f url=...` retargeted the call
instead of sending the field. The query is spelled into the url and the
body travels as `data`. The python client already separated them.

* fix(cli): let a handler say it did not write

A leaf declares `write` statically because for almost every verb it is
static, but `gh api` carries its method on the line, so a read like
`gh api /user` expired every github mount the install serves. A result
may now report what the spec cannot know.

* feat(gh): expand the owner, repo and branch placeholders

gh's own examples are written with them, so `gh api
repos/{owner}/{repo}/releases` asked for literal braces and 404'd. An
install's repo and branch stand in for the current checkout. Any other
brace pair still reaches the wire, which is gh's behavior too.

* feat(gh): render repo view the way gh renders it

A name line, a description line, then the README, with the separator
omitted when there is none. Probed against gh 2.85. The REST object is
still one `gh api repos/OWNER/REPO` away.

* test(integ): cover placeholders and the new repo view

Also carries a branch on the gh install, which is what {branch}
expands from.

* fix(gh): declare branch on the GhConfig type, and type the test trees

tsc runs in CI but not in pre-commit: GhConfig's zod schema grew
`branch` while its interface did not, and the new github tests built
tree items with a `mode` key the type has no room for.

* refactor(gh): derive GhConfig from its schema instead of declaring it twice

The zod schema is the one doing real work: it validates an install's
config and carries the secretStr marker redaction reads. A hand-written
interface beside it only adds a shape that can drift, which is how
branch reached the schema and not the type.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Zecheng Zhang
2026-08-12 21:03:53 -07:00
committed by GitHub
parent ae5760f4c9
commit 41e43274db
70 changed files with 5305 additions and 125 deletions
@@ -134,6 +134,7 @@ runs:
nohup ./python/.venv/bin/python integ/server/github_server.py \
--port 5098 --repo integ/repo-v1=github/repo-v1 \
--repo integ/repo-trunc=github/repo-v1:truncated \
--repo integ/repo-cli=github/repo-v1 \
> /tmp/ghsrv.log 2>&1 &
for i in $(seq 1 30); do
grep -q "GITHUB_ENDPOINT=" /tmp/ghsrv.log && break
+25
View File
@@ -1277,6 +1277,16 @@ jobs:
cat /tmp/discord.log
echo "DISCORD_ENDPOINT=http://127.0.0.1:5099" >> "$GITHUB_ENV"
- name: Start fake GitHub API
if: matrix.facet == 'cli'
run: |
nohup ./python/.venv/bin/python integ/server/github_server.py \
--port 5098 --repo integ/repo-cli=github/repo-v1 \
> /tmp/github.log 2>&1 &
for i in $(seq 1 30); do grep -q "GITHUB_ENDPOINT=" /tmp/github.log && break; sleep 1; done
cat /tmp/github.log
echo "GITHUB_URL=http://127.0.0.1:5098" >> "$GITHUB_ENV"
# The ntn and linear CLIs ride the notion mock (self-started by each
# runner under NOTION_ENABLED) and the fake Linear GraphQL server.
- name: Start fake Linear API and the Notion mock
@@ -1311,6 +1321,21 @@ jobs:
working-directory: integ
run: pnpm exec tsx runners/typescript/main.ts --facet ${{ matrix.facet }} --strict
# A 404 is an answer to a real client -- "no such repository", "no such
# file" -- so an endpoint the fake does not implement is indistinguishable
# from a negative result, and what comes out is a confident wrong
# conclusion rather than an error. The fake names every unrouted request
# on stderr; this is what makes that a failure rather than a log line.
- name: Fail on any unrouted GitHub request
if: matrix.facet == 'cli'
run: |
if grep -q "no route for" /tmp/github.log; then
echo "The gh battery reached endpoints the fake does not implement:"
grep "no route for" /tmp/github.log | sort -u
exit 1
fi
echo "no unrouted GitHub requests"
- name: Stop GreenMail
if: always() && (matrix.facet == 'email' || matrix.facet == 'cli')
run: docker rm -f mirage-greenmail || true
+2
View File
@@ -392,6 +392,7 @@
"python/cli/discord",
"python/cli/ntn",
"python/cli/linear",
"python/cli/gh",
"python/cli/git"
]
},
@@ -582,6 +583,7 @@
"typescript/cli/discord",
"typescript/cli/ntn",
"typescript/cli/linear",
"typescript/cli/gh",
"typescript/cli/git"
]
},
+170
View File
@@ -0,0 +1,170 @@
---
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.
+1
View File
@@ -171,6 +171,7 @@ verb for it, so an agent cannot uninstall the tools it was given.
| [discord](/python/cli/discord) | Discord | OpenClaw Discord actions (`send`, `poll`, …)|
| [ntn](/python/cli/ntn) | Notion | official Notion CLI (`pages`, `datasources`)|
| [linear](/python/cli/linear) | Linear | noun/verb (`issue create`, `team list`) |
| [gh](/python/cli/gh) | GitHub | official GitHub CLI (`repo`, `api`) |
| [git](/python/cli/git) | git repositories | git (`status`, `log`, `add`, `commit`) |
Reading stays on the mount (`cat`, `grep`, `jq` over the virtual
+170
View File
@@ -0,0 +1,170 @@
---
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](/typescript/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
```typescript
import { GH } from '@struktoai/mirage-core'
import { GitHubResource, Workspace } from '@struktoai/mirage-node'
const config = { token: process.env.GITHUB_TOKEN!, repo: 'acme/tools' }
const repo = await GitHubResource.create({
token: config.token,
owner: 'acme',
repo: 'tools',
ref: 'main',
})
const ws = new Workspace({ '/repo': repo })
ws.registerCli('gh', GH, config)
await ws.execute('gh repo view')
```
In YAML the same install rides the `clis:` section; see the
[CLI overview](/typescript/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 |
| `baseUrl` | 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 `baseUrl`. 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.
+1
View File
@@ -172,6 +172,7 @@ verb for it, so an agent cannot uninstall the tools it was given.
| [discord](/typescript/cli/discord) | Discord | OpenClaw Discord actions (`send`, `poll`, …)|
| [ntn](/typescript/cli/ntn) | Notion | official Notion CLI (`pages`, `datasources`)|
| [linear](/typescript/cli/linear) | Linear | noun/verb (`issue create`, `team list`) |
| [gh](/typescript/cli/gh) | GitHub | official GitHub CLI (`repo`, `api`) |
| [git](/typescript/cli/git) | git repositories | git (`status`, `log`, `add`, `commit`) |
Reading stays on the mount (`cat`, `grep`, `jq` over the virtual
+329
View File
@@ -0,0 +1,329 @@
{
"cases": [
{
"id": "gh_repo_view_default_repo",
"seq": 569000,
"targets": [
"cli-gh"
],
"command": "gh repo view | head -5",
"expect": {
"exit": 0,
"stdout": "name:\tinteg/repo-cli\ndescription:\t\n--\n# repo-v1\n\n",
"stderr": ""
}
},
{
"id": "gh_repo_view_operand",
"seq": 569001,
"targets": [
"cli-gh"
],
"command": "gh repo view integ/repo-cli | head -2",
"expect": {
"exit": 0,
"stdout": "name:\tinteg/repo-cli\ndescription:\t\n",
"stderr": ""
}
},
{
"id": "gh_repo_view_host_prefix_is_dropped",
"seq": 569002,
"targets": [
"cli-gh"
],
"command": "gh repo view github.com/integ/repo-cli | head -2",
"expect": {
"exit": 0,
"stdout": "name:\tinteg/repo-cli\ndescription:\t\n",
"stderr": ""
}
},
{
"id": "gh_repo_view_rejects_a_bare_name",
"seq": 569003,
"targets": [
"cli-gh"
],
"command": "gh repo view justaname",
"expect": {
"exit": 1,
"stdout": "",
"stderr": "gh repo view: expected the \"[HOST/]OWNER/REPO\" format, got \"justaname\"\n"
}
},
{
"id": "gh_api_get_is_the_default_method",
"seq": 569004,
"targets": [
"cli-gh"
],
"command": "gh api repos/integ/repo-cli | head -3",
"expect": {
"exit": 0,
"stdout": "{\n \"name\": \"repo-cli\",\n \"full_name\": \"integ/repo-cli\",\n",
"stderr": ""
}
},
{
"id": "gh_api_put_lands_in_the_mount",
"seq": 569005,
"targets": [
"cli-gh"
],
"command": "gh api repos/integ/repo-cli/contents/NOTES.md -X PUT -f message=add -f content=bmV3IGZpbGUK > /dev/null && cat /repo/NOTES.md",
"expect": {
"exit": 0,
"stdout": "new file\n",
"stderr": ""
}
},
{
"id": "gh_api_put_shows_up_in_ls",
"seq": 569006,
"targets": [
"cli-gh"
],
"command": "ls /repo | head -4",
"expect": {
"exit": 0,
"stdout": "NOTES.md\nREADME.md\ndocs\npyproject.toml\n",
"stderr": ""
}
},
{
"id": "gh_api_put_without_a_sha_is_refused",
"seq": 569007,
"targets": [
"cli-gh"
],
"command": "gh api repos/integ/repo-cli/contents/NOTES.md -X PUT -f message=again -f content=b3RoZXIK",
"expect": {
"exit": 1,
"stdout": "",
"stderr": "gh api: NOTES.md does not match\n"
}
},
{
"id": "gh_api_sha_for_a_new_file_is_refused",
"seq": 569008,
"targets": [
"cli-gh"
],
"command": "gh api repos/integ/repo-cli/contents/ABSENT.md -X PUT -f message=x -f content=eAo= -f sha=deadbeef",
"expect": {
"exit": 1,
"stdout": "",
"stderr": "gh api: Invalid request.\n\n\"sha\" wasn't supplied.\n"
}
},
{
"id": "gh_api_delete_drops_the_file_from_the_mount",
"seq": 569009,
"targets": [
"cli-gh"
],
"command": "gh api repos/integ/repo-cli/contents/NOTES.md -X DELETE -f message=rm -f sha=fa49b077972391ad58037050f2a75f74e3671e92 > /dev/null && ls /repo | head -3",
"expect": {
"exit": 0,
"stdout": "README.md\ndocs\npyproject.toml\n",
"stderr": ""
}
},
{
"id": "gh_api_404_on_a_repository_that_is_not_there",
"seq": 569010,
"targets": [
"cli-gh"
],
"command": "gh api repos/integ/nope",
"expect": {
"exit": 1,
"stdout": "",
"stderr": "gh api: Not Found\n"
}
},
{
"id": "gh_api_reads_dash_f_as_json_types",
"seq": 569011,
"targets": [
"cli-gh"
],
"command": "gh api repos/integ/repo-cli/issues -f title=hi -F draft=false | head -4",
"expect": {
"exit": 0,
"stdout": "{\n \"number\": 1,\n \"title\": \"hi\",\n \"body\": \"\",\n",
"stderr": ""
}
},
{
"id": "gh_api_refuses_a_field_that_is_not_key_value",
"seq": 569012,
"targets": [
"cli-gh"
],
"command": "gh api repos/integ/repo-cli -f nope",
"expect": {
"exit": 1,
"stdout": "",
"stderr": "gh api: expected \"key=value\", got \"nope\"\n"
}
},
{
"id": "gh_repo_fork_names_where_it_landed",
"seq": 569013,
"targets": [
"cli-gh"
],
"command": "gh repo fork integ/repo-cli --fork-name forked",
"expect": {
"exit": 0,
"stdout": "\u2713 Created fork integ-user/forked\n",
"stderr": ""
}
},
{
"id": "gh_repo_rename_takes_the_new_name_as_the_operand",
"seq": 569014,
"targets": [
"cli-gh"
],
"command": "gh repo rename renamed -R integ-user/forked",
"expect": {
"exit": 0,
"stdout": "\u2713 Renamed repository integ-user/renamed\n",
"stderr": ""
}
},
{
"id": "gh_api_update_then_cat_serves_the_new_bytes",
"seq": 569015,
"targets": [
"cli-gh"
],
"command": "gh api repos/integ/repo-cli/contents/PROBE.md -X PUT -f message=a -f content=b25lCg== > /dev/null && cat /repo/PROBE.md && gh api repos/integ/repo-cli/contents/PROBE.md -X PUT -f message=b -f content=dHdvCg== -f sha=$(gh api repos/integ/repo-cli/contents/PROBE.md | jq -r .sha) > /dev/null && cat /repo/PROBE.md",
"expect": {
"exit": 0,
"stdout": "one\ntwo\n",
"stderr": ""
}
},
{
"id": "gh_api_leading_slash_is_optional",
"seq": 569016,
"targets": [
"cli-gh"
],
"command": "gh api /repos/integ/repo-cli | head -2",
"expect": {
"exit": 0,
"stdout": "{\n \"name\": \"repo-cli\",\n",
"stderr": ""
}
},
{
"id": "gh_api_typed_fields_reach_a_get_query",
"seq": 569017,
"targets": [
"cli-gh"
],
"command": "gh api -X GET repos/integ/repo-cli/contents/README.md -F ref=main | head -2",
"expect": {
"exit": 0,
"stdout": "{\n \"type\": \"file\",\n",
"stderr": ""
}
},
{
"id": "gh_api_without_an_endpoint_is_refused",
"seq": 569018,
"targets": [
"cli-gh"
],
"command": "gh api",
"expect": {
"exit": 1,
"stdout": "",
"stderr": "gh api: an API endpoint is required\n"
}
},
{
"id": "gh_is_discoverable_as_a_cli",
"seq": 569019,
"targets": [
"cli-gh"
],
"command": "type -t gh; which gh",
"expect": {
"exit": 0,
"stdout": "cli\ngh\n",
"stderr": ""
}
},
{
"id": "man_gh_renders_the_spec",
"seq": 569020,
"targets": [
"cli-gh"
],
"command": "man gh | head -8",
"expect": {
"exit": 0,
"stdout": "gh: GitHub CLI\n\nUsage: gh [flags] <command> [<args>]\n\nCommands:\n api Make an authenticated GitHub API request\n repo Manage repositories\n\n",
"stderr": ""
}
},
{
"id": "man_gh_api_renders_one_leaf",
"seq": 569021,
"targets": [
"cli-gh"
],
"command": "man gh api | head -4",
"expect": {
"exit": 0,
"stdout": "gh api: Make an authenticated GitHub API request\n\nUsage: gh api [flags] <ENDPOINT>\n\n",
"stderr": ""
}
},
{
"id": "gh_repo_rename_answers_help",
"seq": 569022,
"targets": [
"cli-gh"
],
"command": "gh repo rename --help | head -4",
"expect": {
"exit": 0,
"stdout": "gh repo rename: Rename a repository\n\nUsage: gh repo rename [flags] <NEW-NAME>\n\n",
"stderr": ""
}
},
{
"id": "gh_api_expands_owner_and_repo",
"seq": 569023,
"targets": [
"cli-gh"
],
"command": "gh api 'repos/{owner}/{repo}' | head -2",
"expect": {
"exit": 0,
"stdout": "{\n \"name\": \"repo-cli\",\n",
"stderr": ""
}
},
{
"id": "gh_api_expands_a_placeholder_in_a_typed_field",
"seq": 569025,
"targets": [
"cli-gh"
],
"command": "gh api -X GET repos/integ/repo-cli/contents/README.md -F ref='{branch}' | head -2",
"expect": {
"exit": 0,
"stdout": "{\n \"type\": \"file\",\n",
"stderr": ""
}
}
]
}
+31 -2
View File
@@ -117,6 +117,9 @@ EMAIL_IMAP_PORT = int(os.environ.get("EMAIL_IMAP_PORT", "3143"))
EMAIL_SMTP_PORT = int(os.environ.get("EMAIL_SMTP_PORT", "3025"))
EMAIL_API_PORT = int(os.environ.get("EMAIL_API_PORT", "8080"))
EMAIL_USERNAME = "integ@example.com"
# The repository the `gh` install defaults to, standing in for the current
# git remote real gh reads. Seeded by the fake alongside the mounted one.
GH_CLI_REPO = "integ/repo-cli"
EMAIL_PASSWORD = "secret"
EMAIL_SENT_FOLDER = "Sent"
# Doubles as the workspace id on the fake notion server.
@@ -1066,6 +1069,16 @@ class GitHubService:
async def create(cls) -> "GitHubService":
return cls(os.environ["GITHUB_URL"].rstrip("/"))
async def reset(self) -> None:
"""Drop every write since startup, restoring the seeded state.
The write battery runs once per host against one shared fake, so it
starts from the seed rather than from the other host's writes.
"""
async with aiohttp.ClientSession() as session:
async with session.post(f"{self.url}/reset") as resp:
resp.raise_for_status()
async def resource(self, mount: dict) -> GitHubResource:
owner, _, repo = mount["repo"].partition("/")
return await GitHubResource.build(
@@ -1074,6 +1087,16 @@ class GitHubService:
repo=repo,
base_url=self.url))
def cli_installs(self) -> dict[str, tuple[CLISpec, dict[str, object]]]:
return {
"gh": (cli_spec_for("gh"), {
"token": "ghp-integ",
"base_url": self.url,
"repo": GH_CLI_REPO,
"branch": "main",
}),
}
async def teardown(self) -> None:
return None
@@ -2147,7 +2170,12 @@ async def make_service(target: dict, run_id: str) -> "Service | None":
if target.get("service") == "dropbox":
return await DropboxService.create(target)
if target.get("service") == "github":
return await GitHubService.create()
github = await GitHubService.create()
# The write battery runs once per host against one shared fake, so
# it starts from the seed rather than from the other host's writes.
if "gh" in (target.get("clis") or []):
await github.reset()
return github
if target.get("service") == "github_ci":
return await GitHubCIService.create()
if target.get("service") == "slack":
@@ -2221,7 +2249,8 @@ def cli_install(service: "Service | None",
if service is None:
return cli_spec_for(cli_name), None
# Widen the assert when another service grows a CLI.
assert isinstance(service, (DiscordService, EmailService, GwsService,
assert isinstance(service,
(DiscordService, EmailService, GitHubService, GwsService,
LinearService, NotionService, SlackService))
return service.cli_installs()[cli_name]
+27 -1
View File
@@ -50,6 +50,7 @@ import {
DISCORD,
GWS,
HIMALAYA,
GH,
GIT,
LINEAR,
NTN,
@@ -1367,6 +1368,10 @@ async function openSlack(target: Target): Promise<Open> {
return { ws: ws as unknown as ExecWorkspace, cleanup }
}
// The repository the `gh` install defaults to, standing in for the current
// git remote real gh reads. Seeded by the fake alongside the mounted one.
const GH_CLI_REPO = 'integ/repo-cli'
// The fake api.github.com server (integ/server/github_server.py) is external
// and shared across both hosts, mirroring the fake Slack server. It used to
// have to be out of process for the python host, whose GitHubResource
@@ -1376,8 +1381,21 @@ async function openGitHub(target: Target): Promise<Open> {
let base = process.env.GITHUB_URL ?? ''
while (base.endsWith('/')) base = base.slice(0, -1)
if (base === '') throw new Error('github target requires GITHUB_URL')
const mounts: Record<string, GitHubResource | [GitHubResource, MountMode]> = {}
// The write battery runs once per host against one shared fake, so it
// starts from the seed rather than from the other host's writes.
if (target.clis?.includes('gh') === true) {
const reset = await fetch(`${base}/reset`, { method: 'POST' })
if (!reset.ok) throw new Error(`github /reset failed: ${String(reset.status)}`)
}
const mounts: Record<
string,
GitHubResource | RAMResource | [GitHubResource, MountMode]
> = {}
for (const m of target.mounts) {
if (m.resource === 'ram') {
mounts[m.path] = new RAMResource()
continue
}
const [owner, repo] = String(m.repo).split('/')
const resource = await GitHubResource.create({
token: 'ghp-integ',
@@ -1388,6 +1406,14 @@ async function openGitHub(target: Target): Promise<Open> {
mounts[m.path] = m.mode === 'read' ? [resource, MountMode.READ] : resource
}
const ws = new Workspace(mounts, { mode: MountMode.WRITE })
if (target.clis?.includes('gh') === true) {
ws.registerCli('gh', GH, {
token: 'ghp-integ',
base_url: base,
repo: GH_CLI_REPO,
branch: 'main',
})
}
return { ws: ws as unknown as ExecWorkspace, cleanup: () => ws.close() }
}
File diff suppressed because it is too large Load Diff
+26
View File
@@ -1033,6 +1033,32 @@
}
]
},
{
"id": "cli-gh",
"hosts": [
"python",
"typescript-node"
],
"service": "github",
"facet": "cli",
"clis": [
"gh"
],
"mounts": [
{
"path": "/repo",
"resource": "github",
"backend": "github",
"repo": "integ/repo-cli",
"mode": "read"
},
{
"path": "/scratch",
"resource": "ram",
"backend": "memory"
}
]
},
{
"id": "github_ci",
"hosts": [
+3
View File
@@ -57,6 +57,9 @@ class NullIndexCacheStore(IndexCacheStore):
async def invalidate_dir(self, resource_path: str) -> None:
return None
async def invalidate(self) -> None:
return None
async def clear(self) -> None:
return None
+5
View File
@@ -99,6 +99,11 @@ class RAMIndexCacheStore(IndexCacheStore, KeyLockMixin):
self._expiry.pop(resource_path, None)
self._children.pop(resource_path, None)
async def invalidate(self) -> None:
past = datetime.now(timezone.utc) - timedelta(seconds=1)
for key in list(self._expiry):
self._expiry[key] = past
async def clear(self) -> None:
self._entries.clear()
self._children.clear()
+18
View File
@@ -201,6 +201,24 @@ class RedisIndexCacheStore(IndexCacheStore):
pipe.delete(children_key)
await pipe.execute()
async def invalidate(self) -> None:
"""Clear rather than expire, because redis cannot say "stale" here.
The RAM store marks entries expired in place, so a later lookup
answers EXPIRED and a backend whose index *is* its listing knows to
refetch. A redis key carries a real TTL and an expired one is
simply gone, so absent and stale read the same and this can only
clear. The consequence, deliberately chosen: a github mount on a
redis index answers ENOENT after a CLI write instead of refetching
(``ls`` reports the mount root missing). That is a loud failure,
not a wrong answer -- a no-op here would instead serve the
pre-write tree as if it were current, and quietly wrong is the
worse of the two. Closing this properly means an ``invalidated_at``
marker key compared against each entry's ``index_time``, which
needs no schema change and can ride the same round trip.
"""
await self.clear()
async def clear(self) -> None:
self._pending_seed = None
cursor = 0
+12
View File
@@ -56,6 +56,18 @@ class IndexCacheStore:
async def invalidate_dir(self, resource_path: str) -> None:
raise NotImplementedError
async def invalidate(self) -> None:
"""Mark every entry stale without discarding it.
The difference from ``clear`` is what a later lookup can tell.
``clear`` leaves an empty store, which reads exactly like a store
that was never filled, so a backend whose index *is* its listing
cannot tell an invalidation from an empty repository. Expiring
instead keeps that distinction: the lookup answers EXPIRED and
the backend knows to refetch.
"""
raise NotImplementedError
async def clear(self) -> None:
raise NotImplementedError
@@ -0,0 +1,111 @@
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
from mirage.commands.cli.builtin.gh.api import api
from mirage.commands.cli.builtin.gh.repo import fork, rename, view
from mirage.commands.cli.types import CLISpec
from mirage.commands.spec.types import Operand, Option
from mirage.core.github.config import GhConfig
from mirage.types import ResourceName
REPO_HELP = "Select another repository, as [HOST/]OWNER/REPO"
# The GitHub CLI, spelled as cli.github.com spells it. The `github` mount is
# the read half -- a repository is a tree, so listing and reading it is `ls`
# and `cat` -- and this is the write half plus the account-level operations a
# filesystem has no shape for: forking, renaming, and `api` for everything
# else. Install with a GhConfig; `repo` supplies the default repository that
# real gh reads off the current git remote.
GH = CLISpec(
name="gh",
description="GitHub CLI",
config_model=GhConfig,
# A write here lands on the same repository a `github` mount reads, and
# it lands by name rather than by any vfs path, so the mount cannot
# invalidate itself: without this, `gh api -X PUT .../contents/f`
# followed by `cat /repo/f` serves the pre-write bytes.
serves=(ResourceName.GITHUB, ),
subcommands=(
CLISpec(
name="repo",
description="Manage repositories",
subcommands=(
CLISpec(
name="view",
description="View a repository",
fn=view,
positional=(Operand(type="str", name="REPOSITORY"), ),
),
CLISpec(
name="fork",
description="Create a fork of a repository",
fn=fork,
write=True,
positional=(Operand(type="str", name="REPOSITORY"), ),
options=(Option(
long="--fork-name",
type="str",
description="Rename the forked repository",
), ),
),
CLISpec(
name="rename",
description="Rename a repository",
fn=rename,
write=True,
positional=(Operand(
type="str",
name="NEW-NAME",
required=True,
), ),
options=(Option(
short="-R",
long="--repo",
type="str",
description=REPO_HELP,
), ),
),
),
),
CLISpec(
name="api",
description="Make an authenticated GitHub API request",
fn=api,
write=True,
positional=(Operand(type="str", name="ENDPOINT", required=True), ),
options=(
Option(
short="-X",
long="--method",
type="str",
description="The HTTP method for the request",
),
Option(
short="-f",
long="--raw-field",
type="str",
multiple=True,
description="Add a string parameter in key=value format",
),
Option(
short="-F",
long="--field",
type="str",
multiple=True,
description="Add a typed parameter in key=value format",
),
),
),
),
)
@@ -0,0 +1,56 @@
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
import json
from mirage.core.github.config import GhConfig
from mirage.core.github.repo import RepoRef, parse_repo
from mirage.io.stream import yield_bytes
from mirage.io.types import ByteSource, IOResult
from mirage.types import JsonValue
def gh_repo(config: GhConfig, spec: str | None) -> RepoRef:
"""The repository a line is about.
The operand when it named one, the install's own otherwise. Real gh
resolves this from the current git remote, which a workspace has no
equivalent of, so the config carries it.
Args:
config (GhConfig): the install's configuration.
spec (str | None): the repository the line named, if any.
Returns:
RepoRef: the owner and repository names.
Raises:
ValueError: neither the line nor the install named one.
"""
named = spec if spec else config.repo
if not named:
raise ValueError(
"no repository given; pass one or set `repo` on the install")
return parse_repo(named)
def json_out(
value: JsonValue,
mutated: bool | None = None) -> tuple[ByteSource | None, IOResult]:
text = "" if value is None else f"{json.dumps(value, indent=2)}\n"
return yield_bytes(text.encode()), IOResult(mutated=mutated)
def text_out(text: str) -> tuple[ByteSource | None, IOResult]:
return yield_bytes(text.encode()), IOResult()
@@ -0,0 +1,131 @@
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
import re
from mirage.commands.cli.builtin.gh.accessor import json_out
from mirage.commands.cli.types import CLIInvocation
from mirage.commands.spec.types import FlagView
from mirage.core.github._client import github_request
from mirage.core.github.config import GhConfig
from mirage.core.github.placeholder import expand
from mirage.io.types import ByteSource, IOResult
from mirage.types import JsonValue
INT_RE = re.compile(r"^-?\d+$")
READ_METHODS = frozenset({"GET", "HEAD", "OPTIONS"})
def typed(value: str) -> JsonValue:
"""Read a `-F` value as the JSON type it spells.
`-f` is always a string; `-F` reads `true`, `false`, `null` and integers
as their JSON types, which is gh's own split between `--raw-field` and
`--field`.
Args:
value (str): the value as typed.
Returns:
JsonValue: the value as its JSON type.
"""
if value == "true":
return True
if value == "false":
return False
if value == "null":
return None
if INT_RE.match(value):
return int(value)
return value
def split(pair: str) -> tuple[str, str]:
"""Split one `key=value`, keeping every `=` after the first.
Args:
pair (str): the field as typed.
Returns:
tuple[str, str]: the key and the value.
Raises:
ValueError: the field holds no `=`.
"""
key, sep, value = pair.partition("=")
if not sep:
raise ValueError(f'expected "key=value", got "{pair}"')
return key, value
async def api(
inv: CLIInvocation[GhConfig]) -> tuple[ByteSource | None, IOResult]:
fl = FlagView(inv.flags)
endpoint = inv.texts[0] if inv.texts else ""
if not endpoint:
raise ValueError("an API endpoint is required")
fields: dict[str, JsonValue] = {}
for pair in fl.as_list("raw_field"):
key, value = split(pair)
fields[key] = value
for pair in fl.as_list("field"):
key, value = split(pair)
# gh expands a placeholder in a `-F` value but not in a `-f` one,
# which its own --help spells out and a live 2.85 confirms.
fields[key] = typed(expand(value, inv.config))
# gh sends a body-bearing call as POST unless --method says otherwise,
# and a bare one as GET.
method = fl.as_str("method") or ("POST" if fields else "GET")
endpoint = expand(endpoint, inv.config)
path = endpoint if endpoint.startswith("/") else f"/{endpoint}"
upper = method.upper()
# `api` is one leaf for both halves of the API, so whether it wrote is
# on the line rather than in the spec: a GET must not expire every
# github mount the install serves.
mutated = upper not in READ_METHODS
# A GET carries its fields in the query string, everything else in a
# JSON body; with no fields there is neither, so a bare DELETE has no
# body.
if upper == "GET":
params = {k: _query(v) for k, v in fields.items()}
result = await github_request(inv.config.token,
upper,
path,
params=params or None,
base_url=inv.config.base_url)
else:
result = await github_request(inv.config.token,
upper,
path,
fields or None,
base_url=inv.config.base_url)
return json_out(result, mutated)
def _query(value: JsonValue) -> str:
"""One field as a query-string value.
Args:
value (JsonValue): the field value.
Returns:
str: its query-string spelling.
"""
if value is True:
return "true"
if value is False:
return "false"
if value is None:
return "null"
return str(value)
@@ -0,0 +1,84 @@
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
from mirage.commands.cli.builtin.gh.accessor import gh_repo, text_out
from mirage.commands.cli.types import CLIInvocation
from mirage.commands.spec.types import FlagView
from mirage.core.github.config import GhConfig
from mirage.core.github.repo import (fork_repo, login, read_readme,
rename_repo, view_repo)
from mirage.io.types import ByteSource, IOResult
from mirage.types import JsonValue
def summary(repo: JsonValue, readme: str | None) -> str:
"""gh's own text view of a repository.
Two tab-separated header lines and then the README verbatim, with the
`--` separator omitted entirely when there is no README. Probed
against gh 2.85, whose description line is present and empty for a
repository that has none.
Args:
repo (JsonValue): the REST repository object.
readme (str | None): the decoded README, None when absent.
Returns:
str: what gh prints.
"""
fields = repo if isinstance(repo, dict) else {}
name = fields.get("full_name")
description = fields.get("description")
head = (f"name:\t{name if isinstance(name, str) else ''}\n"
f"description:\t"
f"{description if isinstance(description, str) else ''}\n")
if readme is None:
return head
return f"{head}--\n{readme}"
async def view(
inv: CLIInvocation[GhConfig]) -> tuple[ByteSource | None, IOResult]:
operand = inv.texts[0] if inv.texts else None
ref = gh_repo(inv.config, operand)
repo = await view_repo(inv.config, ref)
return text_out(summary(repo, await read_readme(inv.config, ref)))
async def fork(
inv: CLIInvocation[GhConfig]) -> tuple[ByteSource | None, IOResult]:
fl = FlagView(inv.flags)
operand = inv.texts[0] if inv.texts else None
source = gh_repo(inv.config, operand)
name = fl.as_str("fork_name")
forked = await fork_repo(inv.config, source, name)
landed = forked.get("full_name") if isinstance(forked, dict) else None
full = landed if isinstance(
landed, str) else (f"{await login(inv.config)}/{name or source.repo}")
return text_out(f"✓ Created fork {full}\n")
async def rename(
inv: CLIInvocation[GhConfig]) -> tuple[ByteSource | None, IOResult]:
fl = FlagView(inv.flags)
# gh takes the *new name* as the operand and the repository to rename as
# -R, which is the reverse of what the shape of the line suggests.
target = gh_repo(inv.config, fl.as_str("repo"))
name = inv.texts[0] if inv.texts else ""
if not name:
raise ValueError("a new repository name is required")
renamed = await rename_repo(inv.config, target, name)
landed = renamed.get("full_name") if isinstance(renamed, dict) else None
full = landed if isinstance(landed, str) else name
return text_out(f"✓ Renamed repository {full}\n")
+1
View File
@@ -30,6 +30,7 @@ CLI_SPECS: dict[str, CLISpec] = {}
# they must not import until the name is actually requested.
BUILTIN_CLI_SPECS: dict[str, str] = {
"discord": "mirage.commands.cli.builtin.discord:DISCORD",
"gh": "mirage.commands.cli.builtin.gh:GH",
"git": "mirage.commands.cli.builtin.git:GIT",
"gws": "mirage.commands.cli.builtin.gws:GWS",
"himalaya": "mirage.commands.cli.builtin.himalaya:HIMALAYA",
+22 -4
View File
@@ -82,6 +82,26 @@ def flag_rows(spec: CommandSpec) -> list[tuple[str, str]]:
return [(_flag_display(o), o.description or "") for o in spec.options]
def _slot(operand: Operand) -> str:
"""One operand's placeholder outside the clap dialect.
A spec that named the slot gets that name, which is what argparse
prints too (it renders the dest, not a generic word); a spec that did
not falls back to the type. Only `gh` names one outside clap today, so
this is the difference between `gh api [flags] <text>` and upstream's
`gh api <endpoint>`.
Args:
operand (Operand): the slot to render.
Returns:
str: the bracketed placeholder.
"""
if operand.name:
return f"<{operand.name}>"
return "<path>" if operand.type == "path" else "<text>"
def usage_line(name: str, spec: CommandSpec, subcommands: SubcommandRows,
style: UsageStyle) -> str:
"""The ``Usage:`` line, in the dialect the CLI declares.
@@ -107,14 +127,12 @@ def usage_line(name: str, spec: CommandSpec, subcommands: SubcommandRows,
if clap:
bits.append(operand_slot(operand))
else:
bits.append("<path>" if operand.type == "path" else "<text>")
bits.append(_slot(operand))
if spec.rest is not None:
if clap:
bits.append(operand_slot(spec.rest, ellipsis=True))
elif spec.rest.type == "path":
bits.append("[<path>...]")
else:
bits.append("[<text>...]")
bits.append(f"[{_slot(spec.rest)}...]")
return "Usage: " + " ".join(bits)
+82
View File
@@ -12,12 +12,14 @@
# limitations under the License.
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
import json
from typing import Any
import aiohttp
from pydantic import SecretStr
from mirage.resource.secrets import reveal_secret
from mirage.types import JsonValue
API_BASE = "https://api.github.com"
API_VERSION = "2022-11-28"
@@ -35,6 +37,19 @@ def github_url(path: str, base_url: str | None = None, **kwargs: str) -> str:
return (base_url or API_BASE) + path.format(**kwargs)
class GitHubApiError(Exception):
"""A GitHub call that answered with a status the caller cannot use.
Args:
message (str): what GitHub said, its own wording where it gave one.
status (int): the HTTP status.
"""
def __init__(self, message: str, status: int) -> None:
super().__init__(message)
self.status = status
async def github_get(token: SecretStr,
path: str,
params: dict[str, Any] | None = None,
@@ -47,3 +62,70 @@ async def github_get(token: SecretStr,
async with session.get(url, headers=headers, params=params) as resp:
resp.raise_for_status()
return await resp.json()
async def github_request(token: SecretStr,
method: str,
path: str,
body: "JsonValue | None" = None,
params: dict[str, str] | None = None,
*,
base_url: str | None = None) -> "JsonValue":
"""One arbitrary API call, the shape `gh api` needs.
A GET carries its fields in the query string and every other method in
a JSON body, which is gh's own rule; a call with neither sends no body
at all, so a bare DELETE stays a bare DELETE rather than an empty JSON
object. The path is used verbatim, never format-expanded, because it
arrives from a command line and may hold braces of its own.
Args:
token (SecretStr): the API token.
method (str): the HTTP method.
path (str): the endpoint path, leading slash included.
body (JsonValue | None): the JSON body, None to send none.
params (dict[str, str] | None): query parameters.
base_url (str | None): API base, defaulting to github.com's.
Returns:
JsonValue: the decoded body, None for an empty one.
Raises:
GitHubApiError: the call answered with a non-2xx status.
"""
url = (base_url or API_BASE) + path
headers = github_headers(token)
async with aiohttp.ClientSession() as session:
async with session.request(method.upper(),
url,
headers=headers,
params=params,
json=body) as resp:
text = await resp.text()
if resp.status >= 400:
raise GitHubApiError(_api_message(text, resp.reason),
resp.status)
# 204 and an empty 202 have no body to decode; the caller gets
# None rather than a parse error on a call that worked.
if not text:
return None
return json.loads(text)
def _api_message(text: str, reason: str | None) -> str:
"""GitHub's own wording for a failure, or the status reason.
Args:
text (str): the response body.
reason (str | None): the HTTP reason phrase.
Returns:
str: the message to report.
"""
try:
payload = json.loads(text)
except ValueError:
return reason or text
if isinstance(payload, dict) and isinstance(payload.get("message"), str):
return payload["message"]
return reason or text
+15
View File
@@ -21,3 +21,18 @@ class GitHubConfig(BaseModel):
repo: str | None = None
ref: str = "main"
base_url: str | None = None
class GhConfig(BaseModel):
"""What a `gh` install is configured with.
`repo` is what real gh reads off the current git remote to answer a line
that names no repository; a workspace has no remote, so the install
carries it. `branch` stands in the same way for the checked-out branch,
which is what `{branch}` expands to in an endpoint.
"""
token: SecretStr
base_url: str | None = None
repo: str | None = None
branch: str | None = None
+69
View File
@@ -0,0 +1,69 @@
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
import re
from mirage.core.github.config import GhConfig
from mirage.core.github.repo import parse_repo
PLACEHOLDER_RE = re.compile(r"\{(owner|repo|branch)\}")
EXPAND_ERROR = "unable to expand placeholder in path"
def _value(name: str, config: GhConfig) -> str:
"""One placeholder's expansion from the install.
Args:
name (str): the placeholder word, without braces.
config (GhConfig): the install's configuration.
Returns:
str: what the placeholder stands for.
Raises:
ValueError: the install carries nothing to expand it to.
"""
if name == "branch":
if not config.branch:
raise ValueError(f"{EXPAND_ERROR}: no `branch` on the install")
return config.branch
if not config.repo:
raise ValueError(f"{EXPAND_ERROR}: no `repo` on the install")
ref = parse_repo(config.repo)
return ref.owner if name == "owner" else ref.repo
def expand(text: str, config: GhConfig) -> str:
"""Expand gh's repository placeholders in an endpoint or field value.
Real gh fills `{owner}`, `{repo}` and `{branch}` from the repository of
the current directory, which is what most of its own documented
examples are written with (`gh api repos/{owner}/{repo}/releases`); an
install's `repo`/`branch` are the workspace's stand-in for that. Any
other brace pair is left exactly as typed and reaches the wire, which
is gh's behavior too: it is a path segment, not a template error.
Args:
text (str): the endpoint path or field value as typed.
config (GhConfig): the install's configuration.
Returns:
str: the text with the three known placeholders expanded.
Raises:
ValueError: a known placeholder has nothing to expand to.
"""
if "{" not in text:
return text
return PLACEHOLDER_RE.sub(lambda m: _value(m.group(1), config), text)
+14
View File
@@ -18,6 +18,7 @@ from mirage.accessor.github import GitHubAccessor
from mirage.cache.index import NULL_INDEX, IndexCacheStore, LookupStatus
from mirage.core.github._client import github_get
from mirage.core.github.config import GitHubConfig
from mirage.core.github.tree import refill_index
from mirage.types import PathSpec
from mirage.utils.errors import enoent
@@ -44,6 +45,19 @@ async def read(
path = path_spec.mount_path
key = "/" + path.strip("/")
# Freshness is tracked per directory, never per entry, so a blob's row
# is exactly as fresh as its parent's listing and `get` can never
# report staleness of its own. The parent is therefore the probe:
# after a write invalidated the index the row survives carrying the
# *pre-write* blob sha, and reading it back served the old bytes. A
# miss is not a probe either -- against a live index it is a real
# absence, and refetching the whole tree on every ENOENT costs a
# recursive-tree call per miss.
if not accessor.truncated:
cut = key.rfind("/")
parent = key[:cut] if cut > 0 else "/"
if (await index.list_dir(parent)).status == LookupStatus.EXPIRED:
await refill_index(accessor, index)
result = await index.get(key)
if result.status == LookupStatus.NOT_FOUND or result.entry is None:
raise enoent(virtual)
+8 -1
View File
@@ -16,7 +16,7 @@ import logging
from mirage.cache.index import (NULL_INDEX, IndexCacheStore, IndexEntry,
LookupStatus)
from mirage.core.github.tree import fetch_dir_tree
from mirage.core.github.tree import fetch_dir_tree, refill_index
from mirage.types import PathSpec
from mirage.utils.errors import enoent
from mirage.utils.key_prefix import mount_prefix_of
@@ -36,6 +36,13 @@ async def readdir(accessor,
path = rest or "/"
path = path.rstrip("/") or "/"
listing = await index.list_dir(path)
# The index is the whole listing here, not a cache in front of one, so
# an *expired* answer means the tree aged out, not that the path is
# gone. Refetch once and ask again. A NOT_FOUND against a live index
# is a real absence and must not cost a tree fetch.
if listing.status == LookupStatus.EXPIRED and not accessor.truncated:
if await refill_index(accessor, index):
listing = await index.list_dir(path)
if listing.entries is not None:
if prefix and listing.entries and not listing.entries[0].startswith(
prefix):
+109 -2
View File
@@ -12,8 +12,19 @@
# limitations under the License.
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
from mirage.core.github._client import github_get
from mirage.core.github.config import GitHubConfig
import base64
from dataclasses import dataclass
from mirage.core.github._client import (GitHubApiError, github_get,
github_request)
from mirage.core.github.config import GhConfig, GitHubConfig
from mirage.types import JsonValue
@dataclass(frozen=True, slots=True)
class RepoRef:
owner: str
repo: str
async def fetch_default_branch(config: GitHubConfig, owner: str,
@@ -24,3 +35,99 @@ async def fetch_default_branch(config: GitHubConfig, owner: str,
owner=owner,
repo=repo)
return data["default_branch"]
def parse_repo(spec: str) -> RepoRef:
"""Split gh's `[HOST/]OWNER/REPO`.
The host is optional and leading, so the owner and the repository are
always the last two segments. Taking the first two instead reads
`github.com/acme/tools` as owner `github.com`, repo `acme` -- a
different repository, reported as success.
Args:
spec (str): the repository as the line spelled it.
Returns:
RepoRef: the owner and repository names.
Raises:
ValueError: the spec is not one or two slashes of names.
"""
parts = spec.split("/")
# One extra segment is a host; two is not a repository any spelling
# of gh's format reaches.
if len(parts) not in (2, 3) or not all(parts[-2:]):
raise ValueError(
f'expected the "[HOST/]OWNER/REPO" format, got "{spec}"')
return RepoRef(owner=parts[-2], repo=parts[-1])
async def login(config: GhConfig) -> str:
"""The authenticated account's login name.
Args:
config (GhConfig): the install's configuration.
Returns:
str: the login, empty when the account reports none.
"""
me = await github_request(config.token,
"GET",
"/user",
base_url=config.base_url)
name = me.get("login") if isinstance(me, dict) else None
return name if isinstance(name, str) else ""
async def view_repo(config: GhConfig, ref: RepoRef) -> JsonValue:
return await github_request(config.token,
"GET",
f"/repos/{ref.owner}/{ref.repo}",
base_url=config.base_url)
async def read_readme(config: GhConfig, ref: RepoRef) -> str | None:
"""The repository's README as text, or None when it has none.
Args:
config (GhConfig): the install's configuration.
ref (RepoRef): the repository.
Returns:
str | None: the decoded README, None when the repo has none.
"""
try:
data = await github_request(config.token,
"GET",
f"/repos/{ref.owner}/{ref.repo}/readme",
base_url=config.base_url)
except GitHubApiError as exc:
if exc.status == 404:
return None
raise
if not isinstance(data, dict):
return None
content = data.get("content")
if not isinstance(content, str):
return None
return base64.b64decode(content).decode("utf-8", "replace")
async def fork_repo(config: GhConfig,
ref: RepoRef,
name: str | None = None) -> JsonValue:
body: JsonValue = {} if name is None else {"name": name}
return await github_request(config.token,
"POST",
f"/repos/{ref.owner}/{ref.repo}/forks",
body,
base_url=config.base_url)
async def rename_repo(config: GhConfig, ref: RepoRef, name: str) -> JsonValue:
return await github_request(config.token,
"PATCH",
f"/repos/{ref.owner}/{ref.repo}",
{"name": name},
base_url=config.base_url)
+79
View File
@@ -13,8 +13,11 @@
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
import logging
from collections import defaultdict
from datetime import datetime, timedelta, timezone
from typing import Any
from mirage.cache.index import NULL_INDEX, IndexCacheStore, IndexEntry
from mirage.core.github._client import github_get
from mirage.core.github.config import GitHubConfig
from mirage.core.github.tree_entry import TreeEntry
@@ -95,3 +98,79 @@ async def fetch_dir_tree(
size=item.get("size"),
))
return result
def index_rows(
tree: dict[str, TreeEntry]
) -> tuple[dict[str, IndexEntry], dict[str, list[str]]]:
"""Turn a git tree into the index's entry and children tables.
Shared so the mount's initial seed and a later refill build the same
rows; TypeScript keeps its twin in this module too (`populateIndex`).
Args:
tree (dict[str, TreeEntry]): the recursive tree, keyed by
repo-relative path.
Returns:
tuple[dict[str, IndexEntry], dict[str, list[str]]]: entries keyed
by absolute path, and each directory's sorted children.
"""
dirs: dict[str, list[tuple[str, IndexEntry]]] = defaultdict(list)
for path, entry in tree.items():
parts = path.rsplit("/", 1)
if len(parts) == 2:
parent, name = "/" + parts[0], parts[1]
else:
parent, name = "/", parts[0]
dirs[parent].append(
(name,
IndexEntry(
id=entry.sha,
name=name,
resource_type=("folder" if entry.type == "tree" else "file"),
size=entry.size,
)))
entries = {
("/" + parent.strip("/") + "/" + name).replace("//", "/"): entry
for parent, rows in dirs.items()
for name, entry in rows
}
children = {
parent:
sorted(("/" + parent.strip("/") + "/" + name).replace("//", "/")
for name, _ in rows)
for parent, rows in dirs.items()
}
return entries, children
async def refill_index(accessor, index: IndexCacheStore) -> bool:
"""Refetch the recursive tree and re-seed the index from it.
The mount fetches the whole tree once and seeds the index with it, so
the index is the listing rather than a cache in front of one. That
makes a cleared or expired index indistinguishable from an empty
repository -- `ls` reported the mount root missing after an
invalidation, and reported nothing at all once the day-long TTL
lapsed. This is the refill that makes dropping the index mean
"refetch", which is what invalidating it was always supposed to mean.
Args:
accessor (GitHubAccessor): the mount's accessor, holding the
config and the ref to refetch.
index (IndexCacheStore): the index to re-seed.
Returns:
bool: whether a refill happened; False when there is no index to
seed, so a caller does not retry a lookup that cannot change.
"""
if index is NULL_INDEX:
return False
tree, truncated = await fetch_tree(accessor.config, accessor.owner,
accessor.repo, accessor.ref)
accessor.truncated = truncated
entries, children = index_rows(tree)
index.seed(entries, children,
datetime.now(timezone.utc) + timedelta(days=365))
return True
+7
View File
@@ -93,6 +93,12 @@ class IOResult:
reads (dict[str, ByteSource]): Paths read with content or streams.
writes (dict[str, ByteSource]): Paths written with content or streams.
cache (list[str]): Paths worth caching (from reads or writes).
mutated (bool | None): whether this run changed service state,
when only the handler can tell. A CLI leaf declares
``write`` statically because for almost every verb it is
static, but ``gh api`` carries its method on the line, so a
plain ``gh api /user`` is a read through a leaf that is
declared writable. None leaves the spec's answer standing.
producer (Producer | None): provenance of this result (which
command, spanning which mounts); merge keeps the rightmost
producer, mirroring whose stream the shell shows. The
@@ -126,6 +132,7 @@ class IOResult:
writes: dict[str, ByteSource] = field(default_factory=dict)
cache: list[str] = field(default_factory=list)
producer: Producer | None = None
mutated: bool | None = None
_stream_source: "IOResult | None" = field(default=None, repr=False)
def __setattr__(self, name: str, value: object) -> None:
+5 -29
View File
@@ -12,16 +12,15 @@
# limitations under the License.
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
from collections import defaultdict
from datetime import datetime, timedelta, timezone
from typing import Any
from mirage.accessor.github import GitHubAccessor
from mirage.cache.index import IndexConfig, IndexEntry
from mirage.cache.index import IndexConfig
from mirage.core.github.config import GitHubConfig
from mirage.core.github.readdir import readdir
from mirage.core.github.repo import fetch_default_branch
from mirage.core.github.tree import fetch_tree
from mirage.core.github.tree import fetch_tree, index_rows
from mirage.core.github.tree_entry import TreeEntry
from mirage.resource.base import BaseResource
from mirage.resource.github.prompt import PROMPT
@@ -140,32 +139,9 @@ class GitHubResource(BaseResource):
truncated=truncated)
def _populate_index(self, tree: dict[str, TreeEntry]) -> None:
dirs: dict[str, list[tuple[str, IndexEntry]]] = defaultdict(list)
for path, entry in tree.items():
parts = path.rsplit("/", 1)
if len(parts) == 2:
parent, name = "/" + parts[0], parts[1]
else:
parent, name = "/", parts[0]
resource_type = "folder" if entry.type == "tree" else "file"
idx_entry = IndexEntry(
id=entry.sha,
name=name,
resource_type=resource_type,
size=entry.size,
)
dirs[parent].append((name, idx_entry))
self._github_index_entries = {
("/" + parent.strip("/") + "/" + name).replace("//", "/"): entry
for parent, entries in dirs.items()
for name, entry in entries
}
self._github_index_children = {
parent:
sorted(("/" + parent.strip("/") + "/" + name).replace("//", "/")
for name, _ in entries)
for parent, entries in dirs.items()
}
entries, children = index_rows(tree)
self._github_index_entries = entries
self._github_index_children = children
self._github_index_expiry = (datetime.now(timezone.utc) +
timedelta(days=365))
self._seed_github_index()
@@ -346,12 +346,16 @@ async def handle_cli(
return None, err_io, ExecutionNode(command=cmd_str,
exit_code=1,
stderr=err_stderr)
if leaf.write and drop_caches is not None:
await drop_caches()
if out is None:
stdout, io = None, IOResult()
else:
stdout, io = out
# The spec's `write` is the static answer, which is the only one most
# verbs have; a handler that knows better says so on its result, so a
# read-only `gh api` does not expire every github mount.
mutated = leaf.write if io.mutated is None else io.mutated
if mutated and drop_caches is not None:
await drop_caches()
io.producer = Producer(command=prog, declared=leaf.limit)
if parsed.warnings:
@@ -241,7 +241,12 @@ async def drop_service_caches(registry: MountRegistry,
for mount in registry.mounts():
if mount.resource.name not in wanted:
continue
await mount.resource.index.clear()
# Invalidate rather than clear: a cleared index reads exactly like
# one that was never filled, so a backend whose index *is* its
# listing (github seeds the whole tree once) cannot tell the drop
# from an empty repository and reports the mount as gone. Expiring
# keeps that distinction and the next read refetches.
await mount.resource.index.invalidate()
if mount.cache_manager is not None:
await mount.cache_manager.drop_prefix()
+35
View File
@@ -17,6 +17,7 @@ from datetime import datetime
import pytest
from mirage.cache.index import IndexEntry, RAMIndexCacheStore
from mirage.cache.index.config import LookupStatus
@pytest.fixture
@@ -58,6 +59,40 @@ async def test_invalidate_dir_evicts_child_entries(store):
assert "/dir" not in store._expiry
# The distinction the whole github invalidation rests on: `clear` leaves a
# store that reads exactly like one that was never filled, so a backend
# whose index *is* its listing reports an empty repository. `invalidate`
# keeps the rows and only expires them, so the lookup says EXPIRED and the
# backend knows to refetch.
@pytest.mark.asyncio
async def test_invalidate_expires_without_discarding(store):
entry = IndexEntry(id="1", name="f", resource_type="file")
await store.set_dir("/dir", [("f.txt", entry)])
await store.invalidate()
assert (await store.list_dir("/dir")).status == LookupStatus.EXPIRED
assert store._children["/dir"] == ["/dir/f.txt"]
await store.clear()
assert (await store.list_dir("/dir")).status == LookupStatus.NOT_FOUND
@pytest.mark.asyncio
async def test_invalidate_expires_every_directory(store):
entry = IndexEntry(id="1", name="f", resource_type="file")
await store.set_dir("/a", [("f.txt", entry)])
await store.set_dir("/b", [("g.txt", entry)])
await store.invalidate()
assert (await store.list_dir("/a")).status == LookupStatus.EXPIRED
assert (await store.list_dir("/b")).status == LookupStatus.EXPIRED
# An empty store has nothing to expire, and must not grow a row that makes
# a never-listed directory answer EXPIRED instead of NOT_FOUND.
@pytest.mark.asyncio
async def test_invalidate_leaves_an_unlisted_directory_absent(store):
await store.invalidate()
assert (await store.list_dir("/dir")).status == LookupStatus.NOT_FOUND
@pytest.mark.asyncio
async def test_clear_empties_all_dicts(store):
entry = IndexEntry(id="1", name="f", resource_type="file")
@@ -0,0 +1,275 @@
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
import pytest
from mirage.commands.cli.builtin.gh import GH
from mirage.commands.cli.builtin.gh.api import api
from mirage.commands.cli.builtin.gh.repo import fork, rename, summary, view
from mirage.commands.cli.specs import cli_spec_for
from mirage.commands.cli.types import CLIInvocation
from mirage.core.github.config import GhConfig
from mirage.io.types import materialize
from mirage.types import ResourceName
CONFIG = GhConfig(token="t")
CALLS: list[dict] = []
REPLY: dict = {}
README: list[str | None] = [None]
def _record(**call) -> dict:
CALLS.append(call)
return REPLY
def _reset(reply=None) -> None:
CALLS.clear()
README[0] = None
globals()["REPLY"] = {} if reply is None else reply
@pytest.fixture(autouse=True)
def _patch(monkeypatch):
_reset()
async def fake_view(config, ref):
return _record(method="GET", path=f"/repos/{ref.owner}/{ref.repo}")
async def fake_readme(config, ref):
return README[0]
async def fake_fork(config, ref, name=None):
return _record(method="POST",
path=f"/repos/{ref.owner}/{ref.repo}/forks",
body={} if name is None else {"name": name})
async def fake_rename(config, ref, name):
return _record(method="PATCH",
path=f"/repos/{ref.owner}/{ref.repo}",
body={"name": name})
async def fake_request(token,
method,
path,
body=None,
params=None,
*,
base_url=None):
call = {"method": method, "path": path}
if body is not None:
call["body"] = body
if params is not None:
call["params"] = params
return _record(**call)
monkeypatch.setitem(view.__globals__, "view_repo", fake_view)
monkeypatch.setitem(view.__globals__, "read_readme", fake_readme)
monkeypatch.setitem(fork.__globals__, "fork_repo", fake_fork)
monkeypatch.setitem(rename.__globals__, "rename_repo", fake_rename)
monkeypatch.setitem(api.__globals__, "github_request", fake_request)
def _inv(texts=(), flags=None, config=CONFIG) -> CLIInvocation:
return CLIInvocation(config, texts=tuple(texts), flags=flags or {})
def test_registers_itself_under_the_grammar_gh_uses():
assert cli_spec_for("gh") is GH
assert [c.name for c in GH.subcommands] == ["repo", "api"]
assert [c.name for c in GH.subcommands[0].subcommands
] == ["view", "fork", "rename"]
# A gh write lands on the repository a `github` mount reads, by name rather
# than by any vfs path, so the mount cannot invalidate itself. Without this
# the executor's post-write cache drop is a no-op and a committed file still
# reads back as its pre-write bytes.
def test_names_the_mounted_resource_its_writes_invalidate():
assert GH.serves == (ResourceName.GITHUB, )
@pytest.mark.asyncio
async def test_views_the_repository_the_operand_names():
await view(_inv(["o/r"]))
assert CALLS == [{"method": "GET", "path": "/repos/o/r"}]
@pytest.mark.asyncio
async def test_falls_back_to_the_install_repo():
await view(_inv(config=GhConfig(token="t", repo="cfg/repo")))
assert CALLS[0]["path"] == "/repos/cfg/repo"
@pytest.mark.asyncio
async def test_refuses_a_line_with_no_repository_anywhere():
with pytest.raises(ValueError, match="no repository given"):
await view(_inv())
@pytest.mark.asyncio
async def test_refuses_a_repository_that_is_not_owner_repo():
with pytest.raises(ValueError, match="OWNER/REPO"):
await view(_inv(["justaname"]))
# gh's format is [HOST/]OWNER/REPO, so the owner and repo are the *last* two
# segments. Reading the first two made `github.com/acme/tools` a request for
# `github.com/acme` -- a different repository, reported as success.
@pytest.mark.asyncio
async def test_drops_the_optional_host_rather_than_shifting_the_repo():
await view(_inv(["github.com/acme/tools"]))
assert CALLS[0]["path"] == "/repos/acme/tools"
@pytest.mark.asyncio
async def test_refuses_more_segments_than_a_host_and_a_repository():
with pytest.raises(ValueError, match="OWNER/REPO"):
await view(_inv(["a/b/c/d"]))
@pytest.mark.asyncio
async def test_names_the_fork_at_creation_time():
_reset({"full_name": "me/renamed"})
out, _io = await fork(_inv(["o/r"], {"fork_name": "renamed"}))
assert CALLS == [{
"method": "POST",
"path": "/repos/o/r/forks",
"body": {
"name": "renamed"
}
}]
assert b"me/renamed" in await materialize(out)
@pytest.mark.asyncio
async def test_forks_under_the_source_name_when_unnamed():
_reset({"full_name": "me/r"})
await fork(_inv(["o/r"]))
assert CALLS[0]["body"] == {}
# gh takes the new name as the operand and the repository to rename as -R,
# which is the reverse of what the shape of the line suggests.
@pytest.mark.asyncio
async def test_renames_the_dash_r_repository_to_the_operand():
_reset({"full_name": "me/after"})
await rename(_inv(["after"], {"repo": "me/before"}))
assert CALLS == [{
"method": "PATCH",
"path": "/repos/me/before",
"body": {
"name": "after"
}
}]
@pytest.mark.asyncio
async def test_api_is_a_get_with_no_fields_and_sends_them_as_query():
await api(
_inv(["repos/o/r/contents/x"], {
"raw_field": ["ref=master"],
"method": "GET"
}))
assert CALLS[0] == {
"method": "GET",
"path": "/repos/o/r/contents/x",
"params": {
"ref": "master"
},
}
@pytest.mark.asyncio
async def test_api_is_a_post_once_a_field_is_given():
await api(_inv(["repos/o/r/issues"], {"raw_field": ["title=hi"]}))
assert CALLS[0]["method"] == "POST"
@pytest.mark.asyncio
async def test_api_sends_dash_f_verbatim_and_reads_dash_f_as_json_types():
await api(
_inv(
["x"], {
"method": "PUT",
"raw_field": ["a=1"],
"field": ["b=2", "c=true", "d=null", "e=text"],
}))
assert CALLS[0]["body"] == {
"a": "1",
"b": 2,
"c": True,
"d": None,
"e": "text"
}
@pytest.mark.asyncio
async def test_api_keeps_everything_after_the_first_equals():
await api(_inv(["x"], {"raw_field": ["content=YQ==\n"]}))
assert CALLS[0]["body"] == {"content": "YQ==\n"}
@pytest.mark.asyncio
async def test_api_takes_an_endpoint_with_or_without_a_leading_slash():
await api(_inv(["/user"]))
assert CALLS[0]["path"] == "/user"
@pytest.mark.asyncio
async def test_api_refuses_a_field_that_is_not_key_value():
with pytest.raises(ValueError, match="key=value"):
await api(_inv(["x"], {"raw_field": ["nope"]}))
# Real gh sends no body for a call carrying no fields, so a bare DELETE is a
# bare DELETE rather than an empty JSON object with a content type.
@pytest.mark.asyncio
async def test_api_sends_no_body_at_all_when_no_field_was_given():
await api(_inv(["repos/o/r"], {"method": "DELETE"}))
assert CALLS[0] == {"method": "DELETE", "path": "/repos/o/r"}
# -F types a value for a JSON body; on a GET the same value has to reach the
# query string, where everything is a string.
@pytest.mark.asyncio
async def test_api_stringifies_a_typed_field_bound_for_the_query():
await api(
_inv(["search/code"], {
"method": "GET",
"field": ["per_page=5", "draft=true"]
}))
assert CALLS[0]["params"] == {"per_page": "5", "draft": "true"}
assert "body" not in CALLS[0]
# gh prints two tab-separated header lines and then the README verbatim;
# with no README there is no `--` separator at all. Probed against 2.85.
def test_summary_is_gh_s_two_headers_then_the_readme():
out = summary({"full_name": "o/r", "description": "d"}, "# Title\n")
assert out == "name:\to/r\ndescription:\td\n--\n# Title\n"
def test_summary_omits_the_separator_without_a_readme():
out = summary({"full_name": "o/r", "description": None}, None)
assert out == "name:\to/r\ndescription:\t\n"
@pytest.mark.asyncio
async def test_view_renders_text_not_the_rest_object():
_reset({"full_name": "integ/x", "description": "hi"})
README[0] = "body\n"
out, _io = await view(_inv(["integ/x"]))
assert await materialize(
out) == b"name:\tinteg/x\ndescription:\thi\n--\nbody\n"
+95 -1
View File
@@ -12,7 +12,12 @@
# limitations under the License.
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
from mirage.core.github._client import github_headers, github_url
import pytest
import pytest_asyncio
from aiohttp import web
from mirage.core.github._client import (GitHubApiError, github_headers,
github_request, github_url)
from mirage.core.github.config import GitHubConfig
@@ -56,3 +61,92 @@ def test_config_base_url_defaults_to_none():
def test_config_carries_base_url():
config = GitHubConfig(token="ghp_test", base_url="http://localhost:1234")
assert config.base_url == "http://localhost:1234"
SEEN: list[dict] = []
REPLY: dict = {"status": 200, "body": '{"ok":true}'}
async def _echo(request: web.Request) -> web.Response:
SEEN.append({
"method": request.method,
"path": request.path,
"query": dict(request.query),
"body": await request.text(),
"content_type": request.headers.get("Content-Type"),
})
return web.Response(status=REPLY["status"],
text=REPLY["body"],
content_type="application/json")
@pytest_asyncio.fixture()
async def base_url():
SEEN.clear()
REPLY.update({"status": 200, "body": '{"ok":true}'})
app = web.Application()
app.router.add_route("*", "/{tail:.*}", _echo)
runner = web.AppRunner(app)
await runner.setup()
site = web.TCPSite(runner, "127.0.0.1", 0)
await site.start()
port = site._server.sockets[0].getsockname()[1]
yield f"http://127.0.0.1:{port}"
await runner.cleanup()
@pytest.mark.asyncio
async def test_request_puts_params_on_the_query_and_no_body_on_a_get(base_url):
await github_request("t",
"GET",
"/repos/o/r/git/trees/main",
params={"recursive": "1"},
base_url=base_url)
assert SEEN[0]["query"] == {"recursive": "1"}
assert SEEN[0]["body"] == ""
@pytest.mark.asyncio
async def test_request_sends_a_body_as_json(base_url):
await github_request("t",
"PATCH",
"/repos/o/r", {"name": "after"},
base_url=base_url)
assert SEEN[0]["method"] == "PATCH"
assert SEEN[0]["body"] == '{"name": "after"}'
assert "application/json" in (SEEN[0]["content_type"] or "")
# Real gh sends nothing for a fieldless call; an empty JSON object plus a
# content type is a different request, and some endpoints read it as one.
@pytest.mark.asyncio
async def test_request_sends_no_body_when_there_is_nothing_to_send(base_url):
await github_request("t", "DELETE", "/repos/o/r", base_url=base_url)
assert SEEN[0]["body"] == ""
assert SEEN[0]["content_type"] is None
# The path arrives from a command line and is used verbatim: a brace in it
# is a brace, never a format placeholder that eats the segment it sits in.
@pytest.mark.asyncio
async def test_request_does_not_format_expand_the_path(base_url):
await github_request("t",
"GET",
"/repos/o/r/contents/{tmpl}",
base_url=base_url)
assert SEEN[0]["path"] == "/repos/o/r/contents/{tmpl}"
@pytest.mark.asyncio
async def test_request_decodes_an_empty_response_to_none(base_url):
REPLY.update({"status": 204, "body": ""})
assert await github_request("t", "DELETE", "/repos/o/r",
base_url=base_url) is None
@pytest.mark.asyncio
async def test_request_raises_with_githubs_own_wording_and_status(base_url):
REPLY.update({"status": 404, "body": '{"message":"Not Found"}'})
with pytest.raises(GitHubApiError, match="Not Found") as excinfo:
await github_request("t", "GET", "/repos/o/r", base_url=base_url)
assert excinfo.value.status == 404
@@ -0,0 +1,54 @@
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
import pytest
from mirage.core.github.config import GhConfig
from mirage.core.github.placeholder import expand
CONFIG = GhConfig(token="t", repo="acme/tools", branch="main")
def test_expands_the_three_gh_documents():
assert expand("repos/{owner}/{repo}/branches/{branch}",
CONFIG) == "repos/acme/tools/branches/main"
def test_leaves_a_path_with_no_braces_untouched():
assert expand("/user", CONFIG) == "/user"
# gh leaves an unknown placeholder alone and lets the request go out; the
# API answers 404 for the literal segment. Probed against gh 2.85, which
# returns GitHub's own Not Found rather than a client-side error.
def test_leaves_an_unknown_placeholder_on_the_wire():
assert expand("repos/{owner}/{nope}", CONFIG) == "repos/acme/{nope}"
def test_reads_the_owner_off_a_host_qualified_repo():
config = GhConfig(token="t", repo="github.com/acme/tools")
assert expand("repos/{owner}/{repo}", config) == "repos/acme/tools"
def test_refuses_owner_when_the_install_names_no_repository():
with pytest.raises(ValueError, match="unable to expand placeholder"):
expand("repos/{owner}/x", GhConfig(token="t"))
# `branch` is its own field: an install may name a repository without
# pinning a branch, and gh reports the failure rather than guessing one.
def test_refuses_branch_when_the_install_pins_none():
config = GhConfig(token="t", repo="acme/tools")
with pytest.raises(ValueError, match="no `branch` on the install"):
expand("repos/{owner}/{repo}/branches/{branch}", config)
+72 -2
View File
@@ -13,12 +13,19 @@
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
import base64
from unittest.mock import AsyncMock, patch
from datetime import datetime, timedelta, timezone
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
import mirage.core.github.read
import mirage.core.github.tree
from mirage.cache.index import IndexEntry
from mirage.cache.index.ram import RAMIndexCacheStore
from mirage.core.github.config import GitHubConfig
from mirage.core.github.read import read_bytes
from mirage.core.github.read import read, read_bytes
from mirage.core.github.tree_entry import TreeEntry
from mirage.types import PathSpec
@pytest.fixture
@@ -43,3 +50,66 @@ async def test_read_bytes_binary(mock_get, config):
mock_get.return_value = {"content": base64.b64encode(content).decode()}
result = await read_bytes(config, "acme", "proj", "sha456")
assert result == content
def _index() -> RAMIndexCacheStore:
index = RAMIndexCacheStore()
entry = IndexEntry(id="bbb", name="main.py", resource_type="file", size=3)
index._entries["/src/main.py"] = entry
index._children["/src"] = ["/src/main.py"]
index._expiry["/src"] = datetime.now(timezone.utc) + timedelta(days=365)
return index
# Same rule readdir follows: an expired index is a tree that aged out, so
# `cat` refetches once rather than reporting the file gone.
@pytest.mark.asyncio
async def test_read_refills_an_expired_index(monkeypatch):
index = _index()
await index.invalidate()
calls = []
async def fake_fetch_tree(config, owner, repo, ref):
calls.append(ref)
return {
"src":
TreeEntry(path="src", type="tree", sha="aaa", size=None),
"src/main.py":
TreeEntry(path="src/main.py", type="blob", sha="bbb", size=3),
}, False
async def fake_read_bytes(config, owner, repo, sha):
return b"hi\n"
monkeypatch.setattr(mirage.core.github.tree, "fetch_tree", fake_fetch_tree)
monkeypatch.setattr(mirage.core.github.read, "read_bytes", fake_read_bytes)
accessor = MagicMock()
accessor.truncated = False
out = await read(
accessor,
PathSpec(resource_path="src/main.py",
virtual="/src/main.py",
directory="/src"), index)
assert out == b"hi\n"
assert len(calls) == 1
@pytest.mark.asyncio
async def test_read_does_not_refill_on_a_real_miss(monkeypatch):
index = _index()
calls = []
async def fake_fetch_tree(config, owner, repo, ref):
calls.append(ref)
return {}, False
monkeypatch.setattr(mirage.core.github.tree, "fetch_tree", fake_fetch_tree)
accessor = MagicMock()
accessor.truncated = False
with pytest.raises(FileNotFoundError):
await read(
accessor,
PathSpec(resource_path="src/gone.py",
virtual="/src/gone.py",
directory="/src"), index)
assert calls == []
+50
View File
@@ -18,6 +18,7 @@ from unittest.mock import MagicMock
import pytest
import mirage.core.github.tree
from mirage.cache.index import IndexEntry
from mirage.cache.index.ram import RAMIndexCacheStore
from mirage.core.github.readdir import readdir
@@ -113,3 +114,52 @@ async def test_readdir_missing_directory(tree):
PathSpec(resource_path="nonexistent",
virtual="/nonexistent",
directory="/nonexistent"), index)
# The index *is* the listing here, seeded once from the recursive tree, so
# an expired one is a tree that aged out rather than a repository that
# emptied. Before the refill, `ls /repo` exited 0 with no output once the
# day-long TTL lapsed, and reported the mount root missing after a `gh`
# write invalidated it.
@pytest.mark.asyncio
async def test_readdir_refills_an_expired_index(tree, monkeypatch):
index = _index_from_tree(tree)
await index.invalidate()
calls = []
async def fake_fetch_tree(config, owner, repo, ref):
calls.append((owner, repo, ref))
return tree, False
monkeypatch.setattr(mirage.core.github.tree, "fetch_tree", fake_fetch_tree)
accessor = MagicMock()
accessor.truncated = False
result = await readdir(
accessor, PathSpec(resource_path="", virtual="/", directory="/"),
index)
assert result == ["/README.md", "/src"]
assert len(calls) == 1
# A miss against a live index is a real absence, so it must stay one call
# of nothing: refilling here would spend a full recursive-tree fetch on
# every ENOENT.
@pytest.mark.asyncio
async def test_readdir_does_not_refill_on_a_real_miss(tree, monkeypatch):
index = _index_from_tree(tree)
calls = []
async def fake_fetch_tree(config, owner, repo, ref):
calls.append((owner, repo, ref))
return tree, False
monkeypatch.setattr(mirage.core.github.tree, "fetch_tree", fake_fetch_tree)
accessor = MagicMock()
accessor.truncated = False
with pytest.raises(FileNotFoundError):
await readdir(
accessor,
PathSpec(resource_path="nonexistent",
virtual="/nonexistent",
directory="/nonexistent"), index)
assert calls == []
+21 -1
View File
@@ -17,7 +17,7 @@ from unittest.mock import patch
import pytest
from mirage.core.github.config import GitHubConfig
from mirage.core.github.repo import fetch_default_branch
from mirage.core.github.repo import fetch_default_branch, parse_repo
@pytest.fixture
@@ -44,3 +44,23 @@ async def test_fetch_default_branch_master(mock_get, config):
mock_get.return_value = {"default_branch": "master"}
result = await fetch_default_branch(config, "acme", "proj")
assert result == "master"
# gh's format is [HOST/]OWNER/REPO, so the owner and repository are always the
# last two segments. Taking the first two read `github.com/acme/tools` as
# owner `github.com`, repo `acme` -- a different repository, reported as
# success rather than as an error.
def test_parse_repo_takes_owner_and_repo():
ref = parse_repo("acme/tools")
assert (ref.owner, ref.repo) == ("acme", "tools")
def test_parse_repo_drops_the_optional_host():
ref = parse_repo("github.com/acme/tools")
assert (ref.owner, ref.repo) == ("acme", "tools")
@pytest.mark.parametrize("spec", ["justaname", "a/b/c/d", "acme/", "/tools"])
def test_parse_repo_refuses_a_spec_that_is_not_the_format(spec):
with pytest.raises(ValueError, match="OWNER/REPO"):
parse_repo(spec)
+1 -1
View File
@@ -1,5 +1,5 @@
{
"baseline": 305,
"baseline": 303,
"baseline_reason": "Every divergence below the excused ones predates the gate and each needs its own decision, so --strict fails on a rise rather than demanding zero. Lower this number whenever a divergence is closed; the gate fails on a drop too, so an improvement cannot be silently spent. Items 31-37 of the cleanup plan are scoped from this report. The unit is one module, never one directory: a one-sided directory counts once per module inside it, so it cannot absorb new modules without moving the number. An excused directory is the deliberate exception -- it excuses its whole subtree, because the excuse is that there is no counterpart to mirror, which makes growth inside it expected rather than drift.",
"directories": {
"python_only": {
+4
View File
@@ -61,6 +61,10 @@
"dependencies": {
"@aws-sdk/client-s3": "^3.0.0",
"@modelcontextprotocol/sdk": "^1.0.0",
"@octokit/core": "^7.0.7",
"@octokit/plugin-retry": "^8.1.1",
"@octokit/plugin-throttling": "^11.0.5",
"@octokit/request-error": "^7.1.1",
"bson": "^6.10.4",
"isomorphic-git": "^1.38.1",
"jq-wasm": "1.1.0-jq-1.8.1",
@@ -23,8 +23,10 @@ export class GitHubAccessor extends Accessor {
readonly repo: string
readonly ref: string
readonly defaultBranch: string
readonly truncated: boolean
readonly tree: Record<string, TreeEntry>
// Reseated by GitHubResource.refresh: the tree is the mount's whole
// listing, so a refetch after a write replaces it wholesale.
truncated: boolean
tree: Record<string, TreeEntry>
constructor(opts: {
transport: GitHubTransport
+24
View File
@@ -91,6 +91,30 @@ describe('RAMIndexCacheStore', () => {
expect(get.entry ?? null).toBeNull()
})
// The distinction the whole github invalidation rests on: `clear` leaves
// a store that reads exactly like one that was never filled, so a backend
// whose index *is* its listing reports an empty repository. `invalidate`
// keeps the rows and only expires them, so the lookup says EXPIRED and
// the backend knows to refetch.
it('invalidate expires every directory without discarding it', async () => {
const store = new RAMIndexCacheStore()
await store.setDir('/a', [['x', mkEntry('1', 'x')]])
await store.setDir('/b', [['y', mkEntry('2', 'y')]])
await store.invalidate()
expect((await store.listDir('/a')).status).toBe(LookupStatus.EXPIRED)
expect((await store.listDir('/b')).status).toBe(LookupStatus.EXPIRED)
await store.clear()
expect((await store.listDir('/a')).status).toBe(LookupStatus.NOT_FOUND)
})
// An empty store has nothing to expire, and must not grow a row that
// makes a never-listed directory answer EXPIRED instead of NOT_FOUND.
it('invalidate leaves an unlisted directory absent', async () => {
const store = new RAMIndexCacheStore()
await store.invalidate()
expect((await store.listDir('/dir')).status).toBe(LookupStatus.NOT_FOUND)
})
it('clear wipes everything', async () => {
const store = new RAMIndexCacheStore()
await store.put('/a', mkEntry('1', 'a'))
+6
View File
@@ -83,6 +83,12 @@ export class RAMIndexCacheStore extends IndexCacheStore {
return Promise.resolve()
}
invalidate(): Promise<void> {
const past = Date.now() - 1000
for (const key of this.expiry.keys()) this.expiry.set(key, past)
return Promise.resolve()
}
clear(): Promise<void> {
this.entries.clear()
this.children.clear()
+15
View File
@@ -170,6 +170,21 @@ export class RedisIndexCacheStore extends IndexCacheStore {
await pipe.exec()
}
// Clear rather than expire, because redis cannot say "stale" here. The RAM
// store marks entries expired in place, so a later lookup answers EXPIRED
// and a backend whose index *is* its listing knows to refetch. A redis key
// carries a real TTL and an expired one is simply gone, so absent and stale
// read the same. The consequence, deliberately chosen: a github mount on a
// redis index answers ENOENT after a CLI write instead of refetching. That
// is a loud failure, not a wrong answer -- a no-op here would instead serve
// the pre-write tree as if it were current, and quietly wrong is the worse
// of the two. Closing this properly means an `invalidatedAt` marker key
// compared against each entry's indexTime, which needs no schema change and
// can ride the same round trip.
async invalidate(): Promise<void> {
await this.clear()
}
async clear(): Promise<void> {
const c = await this.client()
for (const pattern of [`${this.entryPrefix}*`, `${this.childrenPrefix}*`]) {
+11
View File
@@ -24,6 +24,17 @@ export abstract class IndexCacheStore {
expiredAt?: Date | null,
): Promise<void>
abstract invalidateDir(resourcePath: string): Promise<void>
/**
* Mark every entry stale without discarding it.
*
* The difference from `clear` is what a later lookup can tell. `clear`
* leaves an empty store, which reads exactly like a store that was never
* filled, so a backend whose index *is* its listing cannot tell an
* invalidation from an empty repository. Expiring instead keeps that
* distinction: the lookup answers EXPIRED and the backend refetches.
*/
abstract invalidate(): Promise<void>
abstract clear(): Promise<void>
/**
@@ -49,6 +49,9 @@ function makeAccessor(searchHits: string[], calls: SearchCall[]): GitHubAccessor
}
throw new Error(`unexpected transport call: ${path}`)
},
request(method: string, path: string): Promise<unknown> {
throw new Error(`unexpected transport call: ${method} ${path}`)
},
}
return new GitHubAccessor({
transport,
@@ -0,0 +1,52 @@
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
import { HttpGitHubTransport, type GitHubTransport } from '../../../../core/github/_client.ts'
import type { GhConfig } from '../../../../core/github/config.ts'
import { parseRepo, type RepoRef } from '../../../../core/github/repo.ts'
import { IOResult, type ByteSource } from '../../../../io/types.ts'
import type { CommandFnResult } from '../../../config.ts'
const ENC = new TextEncoder()
export function ghTransport(config: unknown): GitHubTransport {
const cfg = config as GhConfig
const opts: { token: string; baseUrl?: string } = { token: cfg.token }
if (cfg.baseUrl !== undefined) opts.baseUrl = cfg.baseUrl
return new HttpGitHubTransport(opts)
}
/**
* The repository a line is about: the operand if it named one, the
* install's own otherwise. gh resolves this from the current git remote,
* which a workspace has no equivalent of, so the config carries it.
*/
export function ghRepo(config: unknown, spec: string | undefined): RepoRef {
const named = spec ?? (config as GhConfig).repo
if (named === undefined || named === '') {
throw new Error('no repository given; pass one or set `repo` on the install')
}
return parseRepo(named)
}
export function jsonOut(value: unknown, mutated?: boolean): CommandFnResult {
const text = value === null ? '' : `${JSON.stringify(value, null, 2)}\n`
const out: ByteSource = ENC.encode(text)
return [out, new IOResult(mutated === undefined ? {} : { mutated })]
}
export function textOut(text: string): CommandFnResult {
const out: ByteSource = ENC.encode(text)
return [out, new IOResult()]
}
@@ -0,0 +1,81 @@
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
import { FlagView } from '../../../spec/types.ts'
import type { CommandFnResult } from '../../../config.ts'
import type { CLIInvocation } from '../../types.ts'
import { expand } from '../../../../core/github/placeholder.ts'
import type { GhConfig } from '../../../../core/github/config.ts'
import { ghTransport, jsonOut } from './accessor.ts'
/**
* `-f key=value` is always a string; `-F key=value` reads `true`, `false`,
* `null` and integers as their JSON types, which is gh's own split between
* `--raw-field` and `--field`.
*/
function typed(value: string): string | number | boolean | null {
if (value === 'true') return true
if (value === 'false') return false
if (value === 'null') return null
if (/^-?\d+$/.test(value)) return Number(value)
return value
}
const READ_METHODS = new Set(['GET', 'HEAD', 'OPTIONS'])
function split(pair: string): [string, string] {
const at = pair.indexOf('=')
if (at < 0) throw new Error(`expected "key=value", got "${pair}"`)
return [pair.slice(0, at), pair.slice(at + 1)]
}
export async function api(inv: CLIInvocation): Promise<CommandFnResult> {
const fl = new FlagView(inv.flags)
const endpoint = inv.texts[0] ?? ''
if (endpoint === '') throw new Error('an API endpoint is required')
const fields: Record<string, string | number | boolean | null> = {}
for (const pair of fl.asList('raw_field')) {
const [key, value] = split(pair)
fields[key] = value
}
const config = inv.config as GhConfig
for (const pair of fl.asList('field')) {
const [key, value] = split(pair)
// gh expands a placeholder in a `-F` value but not in a `-f` one, which
// its own --help spells out and a live 2.85 confirms.
fields[key] = typed(expand(value, config))
}
// gh sends a body-bearing call as POST unless --method says otherwise,
// and a bare one as GET.
const method = fl.asStr('method') ?? (Object.keys(fields).length > 0 ? 'POST' : 'GET')
const expanded = expand(endpoint, config)
const path = expanded.startsWith('/') ? expanded : `/${expanded}`
const upper = method.toUpperCase()
// `api` is one leaf for both halves of the API, so whether it wrote is on
// the line rather than in the spec: a GET must not expire every github
// mount the install serves.
const mutated = !READ_METHODS.has(upper)
const empty = Object.keys(fields).length === 0
// A GET carries its fields in the query string, everything else in a JSON
// body; a call with no fields sends neither, so a bare DELETE has no body.
if (upper === 'GET') {
const params: Record<string, string> = {}
for (const [key, value] of Object.entries(fields)) params[key] = String(value)
return jsonOut(await ghTransport(inv.config).request(upper, path, undefined, params), mutated)
}
return jsonOut(
await ghTransport(inv.config).request(upper, path, empty ? undefined : fields),
mutated,
)
}
@@ -0,0 +1,238 @@
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
import { describe, expect, it, vi } from 'vitest'
import type * as AccessorModule from './accessor.ts'
import type { GitHubTransport } from '../../../../core/github/_client.ts'
import { cliSpecFor } from '../../specs.ts'
import { ResourceName } from '../../../../types.ts'
import type { CommandFnResult } from '../../../config.ts'
import type { CLIInvocation } from '../../types.ts'
import { GH } from './index.ts'
import { api } from './api.ts'
import { fork, rename, summary, view } from './repo.ts'
const DEC = new TextDecoder()
interface Call {
method: string
path: string
body?: unknown
params?: Record<string, string>
}
const CALLS: Call[] = []
let REPLY: unknown = {}
class FakeTransport implements GitHubTransport {
get(path: string, params?: Record<string, string>): Promise<unknown> {
return this.request('GET', path, undefined, params)
}
request(
method: string,
path: string,
body?: unknown,
params?: Record<string, string>,
): Promise<unknown> {
const call: Call = { method, path }
if (body !== undefined) call.body = body
if (params !== undefined) call.params = params
CALLS.push(call)
return Promise.resolve(REPLY)
}
}
vi.mock('./accessor.ts', async (importOriginal) => {
const actual = await importOriginal<typeof AccessorModule>()
return { ...actual, ghTransport: () => new FakeTransport() }
})
function inv(
texts: string[],
flags: CLIInvocation['flags'] = {},
config: unknown = { token: 't' },
): CLIInvocation {
return { config, argv: [], paths: [], texts, flags, stdin: null, env: {} }
}
function text(result: CommandFnResult): string {
if (result === null) throw new Error('expected a result tuple')
return DEC.decode(result[0] as Uint8Array)
}
function reset(reply: unknown = {}): void {
CALLS.length = 0
REPLY = reply
}
describe('gh tree', () => {
it('registers itself under the grammar gh uses', () => {
expect(cliSpecFor('gh')).toBe(GH)
expect(GH.subcommands.map((c) => c.name)).toEqual(['repo', 'api'])
const repo = GH.subcommands.find((c) => c.name === 'repo')
expect(repo?.subcommands.map((c) => c.name)).toEqual(['view', 'fork', 'rename'])
})
// A gh write lands on the repository a `github` mount reads, by name
// rather than by any vfs path, so the mount cannot invalidate itself.
// Without this the executor's post-write cache drop is a no-op and a
// committed file still reads back as its pre-write bytes.
it('names the mounted resource its writes invalidate', () => {
expect(GH.serves).toEqual([ResourceName.GITHUB])
})
})
describe('gh repo', () => {
it('views the repository the operand names', async () => {
reset({ full_name: 'o/r' })
await view(inv(['o/r']))
expect(CALLS).toEqual([
{ method: 'GET', path: '/repos/o/r' },
{ method: 'GET', path: '/repos/o/r/readme' },
])
})
it('falls back to the install repo when no operand is given', async () => {
reset({ full_name: 'cfg/repo' })
await view(inv([], {}, { token: 't', repo: 'cfg/repo' }))
expect(CALLS[0]?.path).toBe('/repos/cfg/repo')
})
it('refuses a line with no repository anywhere', async () => {
reset()
await expect(view(inv([]))).rejects.toThrow(/no repository given/)
})
it('refuses a repository that is not OWNER/REPO', async () => {
reset()
await expect(view(inv(['justaname']))).rejects.toThrow(/OWNER\/REPO/)
})
// gh's format is [HOST/]OWNER/REPO, so the owner and repo are the *last*
// two segments. Reading the first two made `github.com/acme/tools` a
// request for `github.com/acme` -- a different repository, reported as
// success rather than as an error.
it('drops the optional host segment rather than shifting the repo', async () => {
reset({ full_name: 'acme/tools' })
await view(inv(['github.com/acme/tools']))
expect(CALLS[0]?.path).toBe('/repos/acme/tools')
})
it('refuses a spec with more segments than a host and a repository', async () => {
reset()
await expect(view(inv(['a/b/c/d']))).rejects.toThrow(/OWNER\/REPO/)
})
it('names the fork at creation time when --fork-name is given', async () => {
reset({ full_name: 'me/renamed' })
const out = await fork(inv(['o/r'], { fork_name: 'renamed' }))
expect(CALLS).toEqual([{ method: 'POST', path: '/repos/o/r/forks', body: { name: 'renamed' } }])
expect(out === null ? '' : text(out)).toContain('me/renamed')
})
it('forks under the source name when it is not', async () => {
reset({ full_name: 'me/r' })
await fork(inv(['o/r']))
expect(CALLS[0]?.body).toEqual({})
})
// gh takes the new name as the operand and the repository to rename as
// -R, which is the reverse of what the shape of the line suggests.
it('renames the -R repository to the operand', async () => {
reset({ full_name: 'me/after' })
await rename(inv(['after'], { repo: 'me/before' }))
expect(CALLS).toEqual([{ method: 'PATCH', path: '/repos/me/before', body: { name: 'after' } }])
})
})
describe('gh api', () => {
it('is a GET with no fields, and sends them as query parameters', async () => {
reset({ ok: true })
await api(inv(['repos/o/r/contents/x'], { raw_field: ['ref=master'], method: 'GET' }))
expect(CALLS[0]).toEqual({
method: 'GET',
path: '/repos/o/r/contents/x',
params: { ref: 'master' },
})
})
it('is a POST once a field is given', async () => {
reset({})
await api(inv(['repos/o/r/issues'], { raw_field: ['title=hi'] }))
expect(CALLS[0]?.method).toBe('POST')
})
it('sends -f verbatim and reads -F as JSON types', async () => {
reset({})
await api(
inv(['x'], {
method: 'PUT',
raw_field: ['a=1'],
field: ['b=2', 'c=true', 'd=null', 'e=text'],
}),
)
expect(CALLS[0]?.body).toEqual({ a: '1', b: 2, c: true, d: null, e: 'text' })
})
it('keeps everything after the first = in the value', async () => {
reset({})
await api(inv(['x'], { raw_field: ['content=YQ==\n'] }))
expect(CALLS[0]?.body).toEqual({ content: 'YQ==\n' })
})
it('takes an endpoint with or without a leading slash', async () => {
reset({})
await api(inv(['/user']))
expect(CALLS[0]?.path).toBe('/user')
})
it('refuses a field that is not key=value', async () => {
reset({})
await expect(api(inv(['x'], { raw_field: ['nope'] }))).rejects.toThrow(/key=value/)
})
// Real gh sends no body for a call carrying no fields, so a bare DELETE
// is a bare DELETE rather than an empty JSON object with a content type.
it('sends no body at all when no field was given', async () => {
reset({})
await api(inv(['repos/o/r'], { method: 'DELETE' }))
expect(CALLS[0]).toEqual({ method: 'DELETE', path: '/repos/o/r' })
})
// -F types a value for a JSON body; on a GET the same value has to reach
// the query string, where everything is a string.
it('stringifies a typed field when the method puts it in the query', async () => {
reset({})
await api(inv(['search/code'], { method: 'GET', field: ['per_page=5', 'draft=true'] }))
expect(CALLS[0]?.params).toEqual({ per_page: '5', draft: 'true' })
expect(CALLS[0]?.body).toBeUndefined()
})
})
// gh prints two tab-separated header lines and then the README verbatim;
// with no README there is no `--` separator at all. Probed against 2.85.
describe('gh repo view rendering', () => {
it('is two headers then the readme', () => {
expect(summary({ full_name: 'o/r', description: 'd' }, '# Title\n')).toBe(
'name:\to/r\ndescription:\td\n--\n# Title\n',
)
})
it('omits the separator without a readme', () => {
expect(summary({ full_name: 'o/r', description: null }, null)).toBe(
'name:\to/r\ndescription:\t\n',
)
})
})
@@ -0,0 +1,112 @@
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
import { GhConfigSchema } from '../../../../core/github/config.ts'
import { ResourceName } from '../../../../types.ts'
import { Operand, Option } from '../../../spec/types.ts'
import { registerCliSpec } from '../../specs.ts'
import { CLISpec } from '../../types.ts'
import { api } from './api.ts'
import { fork, rename, view } from './repo.ts'
// The GitHub CLI, spelled as cli.github.com spells it. The `github` mount
// is the read half -- a repository is a tree, so listing and reading it is
// `ls` and `cat` -- and this is the write half plus the account-level
// operations a filesystem has no shape for: forking, renaming, and `api`
// for everything else. Install with a GhConfig; `repo` supplies the
// default repository that real gh reads off the current git remote.
export const GH = new CLISpec({
name: 'gh',
description: 'GitHub CLI',
configModel: GhConfigSchema,
// A write here lands on the same repository a `github` mount reads, and it
// lands by name rather than by any vfs path, so the mount cannot invalidate
// itself: without this, `gh api -X PUT .../contents/f` followed by
// `cat /repo/f` serves the pre-write bytes, and a delete still lists.
serves: [ResourceName.GITHUB],
subcommands: [
new CLISpec({
name: 'repo',
description: 'Manage repositories',
subcommands: [
new CLISpec({
name: 'view',
description: 'View a repository',
fn: view,
positional: [new Operand({ type: 'str', name: 'REPOSITORY' })],
}),
new CLISpec({
name: 'fork',
description: 'Create a fork of a repository',
fn: fork,
write: true,
positional: [new Operand({ type: 'str', name: 'REPOSITORY' })],
options: [
new Option({
long: '--fork-name',
type: 'str',
description: 'Rename the forked repository',
}),
],
}),
new CLISpec({
name: 'rename',
description: 'Rename a repository',
fn: rename,
write: true,
positional: [new Operand({ type: 'str', name: 'NEW-NAME', required: true })],
options: [
new Option({
short: '-R',
long: '--repo',
type: 'str',
description: 'Select another repository, as [HOST/]OWNER/REPO',
}),
],
}),
],
}),
new CLISpec({
name: 'api',
description: 'Make an authenticated GitHub API request',
fn: api,
write: true,
positional: [new Operand({ type: 'str', name: 'ENDPOINT', required: true })],
options: [
new Option({
short: '-X',
long: '--method',
type: 'str',
description: 'The HTTP method for the request',
}),
new Option({
short: '-f',
long: '--raw-field',
type: 'str',
multiple: true,
description: 'Add a string parameter in key=value format',
}),
new Option({
short: '-F',
long: '--field',
type: 'str',
multiple: true,
description: 'Add a typed parameter in key=value format',
}),
],
}),
],
})
registerCliSpec(GH)
@@ -0,0 +1,62 @@
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
import { FlagView } from '../../../spec/types.ts'
import { forkRepo, login, readReadme, renameRepo, viewRepo } from '../../../../core/github/repo.ts'
import type { CommandFnResult } from '../../../config.ts'
import type { CLIInvocation } from '../../types.ts'
import { ghRepo, ghTransport, textOut } from './accessor.ts'
/**
* gh's own text view of a repository: two tab-separated header lines and
* then the README verbatim, with the `--` separator omitted entirely when
* there is no README. Probed against gh 2.85, whose description line is
* present and empty for a repository that has none.
*/
export function summary(repo: unknown, readme: string | null): string {
const fields = (repo ?? {}) as { full_name?: unknown; description?: unknown }
const name = typeof fields.full_name === 'string' ? fields.full_name : ''
const description = typeof fields.description === 'string' ? fields.description : ''
const head = `name:\t${name}\ndescription:\t${description}\n`
return readme === null ? head : `${head}--\n${readme}`
}
export async function view(inv: CLIInvocation): Promise<CommandFnResult> {
const transport = ghTransport(inv.config)
const ref = ghRepo(inv.config, inv.texts[0])
const repo = await viewRepo(transport, ref)
return textOut(summary(repo, await readReadme(transport, ref)))
}
export async function fork(inv: CLIInvocation): Promise<CommandFnResult> {
const fl = new FlagView(inv.flags)
const transport = ghTransport(inv.config)
const source = ghRepo(inv.config, inv.texts[0])
const name = fl.asStr('fork_name') ?? undefined
const forked = (await forkRepo(transport, source, name)) as { full_name?: string }
const full = forked.full_name ?? `${await login(transport)}/${name ?? source.repo}`
return textOut(`✓ Created fork ${full}\n`)
}
export async function rename(inv: CLIInvocation): Promise<CommandFnResult> {
const fl = new FlagView(inv.flags)
const transport = ghTransport(inv.config)
// gh takes the *new name* as the operand and the repository to rename as
// -R, which is the reverse of what the shape of the line suggests.
const target = ghRepo(inv.config, fl.asStr('repo') ?? undefined)
const name = inv.texts[0] ?? ''
if (name === '') throw new Error('a new repository name is required')
const renamed = (await renameRepo(transport, target, name)) as { full_name?: string }
return textOut(`✓ Renamed repository ${renamed.full_name ?? name}\n`)
}
@@ -67,6 +67,20 @@ function flagRows(spec: CommandSpec): [string, string][] {
return spec.options.map((o) => [flagDisplay(o), o.description ?? ''])
}
/**
* One operand's placeholder outside the clap dialect.
*
* A spec that named the slot gets that name, which is what argparse prints
* too (it renders the dest, not a generic word); a spec that did not falls
* back to the type. Only `gh` names one outside clap today, so this is the
* difference between `gh api [flags] <text>` and upstream's
* `gh api <endpoint>`.
*/
function slot(operand: Operand): string {
if (operand.name !== '') return `<${operand.name}>`
return operand.type === 'path' ? '<path>' : '<text>'
}
/**
* Render one command's help; a CLI group is the same shape plus a Commands
* section. `subcommands` carries (name, one-line help) rows for a CLI
@@ -84,11 +98,11 @@ function usageLine(
if (spec.options.length > 0) bits.push(clap ? '[OPTIONS]' : '[flags]')
if (subcommands.length > 0) bits.push(clap ? '<COMMAND>' : '<command> [<args>]')
for (const op of spec.positional) {
bits.push(clap ? operandSlot(op) : op.type === 'path' ? '<path>' : '<text>')
bits.push(clap ? operandSlot(op) : slot(op))
}
if (spec.rest !== null) {
if (clap) bits.push(operandSlot(spec.rest, !spec.rest.required))
else bits.push(spec.rest.type === 'path' ? '[<path>...]' : '[<text>...]')
else bits.push(`[${slot(spec.rest)}...]`)
}
return `Usage: ${bits.join(' ')}`
}
@@ -0,0 +1,148 @@
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { GitHubApiError, HttpGitHubTransport } from './_client.ts'
interface Seen {
url: string
method: string
body: string | null
contentType: string | null
}
const SEEN: Seen[] = []
let REPLY: { status: number; body: string } = { status: 200, body: '{"ok":true}' }
const REAL_FETCH = globalThis.fetch
function transport(): HttpGitHubTransport {
return new HttpGitHubTransport({ token: 't', baseUrl: 'https://api.example.test' })
}
beforeEach(() => {
SEEN.length = 0
REPLY = { status: 200, body: '{"ok":true}' }
globalThis.fetch = vi.fn((input: RequestInfo | URL, init?: RequestInit) => {
const req = new Request(input, init)
SEEN.push({
url: req.url,
method: req.method,
body: typeof init?.body === 'string' ? init.body : null,
contentType: req.headers.get('content-type'),
})
return Promise.resolve(
new Response(REPLY.body === '' ? null : REPLY.body, {
status: REPLY.status,
headers: { 'content-type': 'application/json' },
}),
)
}) as typeof globalThis.fetch
})
afterEach(() => {
globalThis.fetch = REAL_FETCH
})
describe('HttpGitHubTransport', () => {
it('puts params on the query string and leaves the body off a GET', async () => {
await transport().get('/repos/o/r/git/trees/main', { recursive: '1' })
expect(SEEN[0]?.url).toBe('https://api.example.test/repos/o/r/git/trees/main?recursive=1')
expect(SEEN[0]?.body).toBeNull()
})
it('sends a body as JSON on a method that carries one', async () => {
await transport().request('PATCH', '/repos/o/r', { name: 'after' })
expect(SEEN[0]?.method).toBe('PATCH')
expect(SEEN[0]?.body).toBe('{"name":"after"}')
expect(SEEN[0]?.contentType).toContain('application/json')
})
// Real gh sends nothing for a fieldless call; an empty JSON object plus a
// content type is a different request, and some endpoints read it as one.
it('sends no body and no content type when there is nothing to send', async () => {
await transport().request('DELETE', '/repos/o/r')
expect(SEEN[0]?.method).toBe('DELETE')
expect(SEEN[0]?.body).toBeNull()
expect(SEEN[0]?.contentType).toBeNull()
})
// Octokit reads `{...}` in a url as a route-template placeholder and drops
// the segment when nothing fills it -- silently, with no error. `gh api`
// takes its endpoint straight from the agent, so a brace must survive as
// one rather than deleting the path segment it sits in.
it('keeps a braced path segment instead of letting it vanish', async () => {
await transport().get('/repos/o/r/contents/{tmpl}')
expect(SEEN[0]?.url).toBe('https://api.example.test/repos/o/r/contents/%7Btmpl%7D')
})
it('percent-encodes a space in a path', async () => {
await transport().get('/repos/o/r/contents/my file.txt')
expect(SEEN[0]?.url).toBe('https://api.example.test/repos/o/r/contents/my%20file.txt')
})
// 204 and an empty 202 have no body; the caller gets null on a call that
// worked, not the empty string octokit reports.
it('decodes an empty response to null', async () => {
REPLY = { status: 204, body: '' }
expect(await transport().request('DELETE', '/repos/o/r')).toBeNull()
})
// github.com asks for a second between writes and enforces it as a
// secondary rate limit; a self-hosted host or a fake imposes no such thing,
// and paying it there costs a second per written file for nothing.
it('does not hold writes a second apart against a non-github.com host', async () => {
const started = Date.now()
const t = transport()
await t.request('PUT', '/repos/o/r/contents/a')
await t.request('PUT', '/repos/o/r/contents/b')
await t.request('PUT', '/repos/o/r/contents/c')
expect(Date.now() - started).toBeLessThan(500)
expect(SEEN).toHaveLength(3)
})
// Octokit merges loose parameters into the same object that carries
// `url`, `method` and `headers`, so a field the agent typed could steer
// the request instead of riding in it: `gh api X -f url=...` retargeted
// the call. Body and query travel in their own containers.
it('does not let a field named url steer the request', async () => {
await transport().request('POST', '/repos/o/r/issues', {
url: 'https://elsewhere.test/x',
method: 'DELETE',
title: 'hi',
})
expect(SEEN[0]?.url).toBe('https://api.example.test/repos/o/r/issues')
expect(SEEN[0]?.method).toBe('POST')
expect(JSON.parse(SEEN[0]?.body ?? '{}')).toEqual({
url: 'https://elsewhere.test/x',
method: 'DELETE',
title: 'hi',
})
})
it('does not let a query field named url steer the request', async () => {
await transport().get('/search/code', { url: 'https://elsewhere.test/x', q: 'a' })
expect(SEEN[0]?.url).toBe(
'https://api.example.test/search/code?url=https%3A%2F%2Felsewhere.test%2Fx&q=a',
)
})
it('reports a failure as a GitHubApiError carrying the status', async () => {
REPLY = { status: 404, body: '{"message":"Not Found"}' }
await expect(transport().get('/repos/o/r')).rejects.toMatchObject({
constructor: GitHubApiError,
status: 404,
message: 'Not Found',
})
})
})
@@ -12,39 +12,112 @@
// limitations under the License.
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
import { Octokit } from '@octokit/core'
import { RequestError } from '@octokit/request-error'
import { retry } from '@octokit/plugin-retry'
import { throttling } from '@octokit/plugin-throttling'
export const GITHUB_API_BASE = 'https://api.github.com'
export const GITHUB_API_VERSION = '2022-11-28'
// A rate limit is a wait, not a failure, but an unbounded wait is a hang;
// three attempts is what octokit's own docs use for an unattended client.
const GITHUB_RETRIES = 3
export interface GitHubTransport {
get(path: string, params?: Record<string, string>): Promise<unknown>
request(
method: string,
path: string,
body?: unknown,
params?: Record<string, string>,
): Promise<unknown>
}
const Kit = Octokit.plugin(retry, throttling)
/**
* Octokit reads `{name}` in a url as a route-template placeholder and drops
* the segment when nothing fills it, silently and without an error. Every
* caller here passes a path that is already final -- `gh api` takes one
* straight from the agent's command line -- so the braces are escaped to
* the percent forms a server sees them as.
*
* Args:
* path (string): the request path as the caller spelled it.
*
* Returns:
* string: the path with `{` and `}` percent-encoded.
*/
function escapeBraces(path: string): string {
return path.replace(/\{/g, '%7B').replace(/\}/g, '%7D')
}
export class HttpGitHubTransport implements GitHubTransport {
readonly token: string
readonly baseUrl: string
private readonly kit: InstanceType<typeof Kit>
constructor(opts: { token: string; baseUrl?: string }) {
this.token = opts.token
this.baseUrl = opts.baseUrl ?? GITHUB_API_BASE
}
async get(path: string, params?: Record<string, string>): Promise<unknown> {
const url = new URL(this.baseUrl + path)
if (params !== undefined) {
for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v)
}
const r = await fetch(url.toString(), {
headers: {
Authorization: `Bearer ${this.token}`,
Accept: 'application/vnd.github+json',
'X-GitHub-Api-Version': GITHUB_API_VERSION,
this.kit = new Kit({
auth: opts.token,
baseUrl: this.baseUrl,
request: { retries: GITHUB_RETRIES },
throttle: {
// The write limiter holds every non-GET a second apart, which is
// github.com's own guidance and its secondary rate limit. That limit
// is github.com's, not the API's: a GHES install does not impose it
// and a fake certainly does not, so paying it there would add a
// second per write for nothing.
enabled: this.baseUrl === GITHUB_API_BASE,
onRateLimit: (_after: number, _options: unknown, _kit: unknown, count: number) =>
count < GITHUB_RETRIES,
onSecondaryRateLimit: (_after: number, _options: unknown, _kit: unknown, count: number) =>
count < GITHUB_RETRIES,
},
})
if (!r.ok) {
const body = await r.text().catch(() => '')
throw new GitHubApiError(`GitHub ${path}${String(r.status)} ${body}`, r.status)
}
return r.json()
get(path: string, params?: Record<string, string>): Promise<unknown> {
return this.request('GET', path, undefined, params)
}
async request(
method: string,
path: string,
body?: unknown,
params?: Record<string, string>,
): Promise<unknown> {
try {
// Octokit reads loose parameters off the same object that carries
// `url`, `method` and `headers`, so a field the agent typed would
// steer the request rather than ride in it: `gh api X -f url=...`
// retargeted the call. The query is spelled into the url and the body
// travels as `data`, which octokit sends verbatim, so neither can
// collide with a transport option. A call with neither sends no body
// at all, which is what a bare DELETE has to look like on the wire.
const query = new URLSearchParams(params ?? {}).toString()
const r = await this.kit.request({
method: method.toUpperCase(),
url: escapeBraces(path) + (query === '' ? '' : `?${query}`),
headers: { 'X-GitHub-Api-Version': GITHUB_API_VERSION },
...(body === undefined ? {} : { data: body }),
})
// 204 and an empty 202 decode to '' rather than a body; the caller gets
// null on a call that worked.
return r.data === '' ? null : r.data
} catch (err) {
if (err instanceof RequestError) {
// Octokit composes its message as `<message> - <documentation_url>`.
// The suffix is octokit's, not GitHub's: the service says only the
// message, real gh prints only the message, and the python client
// reports only the message. Read it off the body rather than
// trimming the composed string.
const data = err.response?.data as { message?: string } | undefined
const message = typeof data?.message === 'string' ? data.message : err.message
throw new GitHubApiError(message, err.status)
}
throw err
}
}
}
@@ -0,0 +1,43 @@
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
import { z } from 'zod'
import type { REDACTED_SECRET } from '../../resource/secrets.ts'
import { redactConfigWithSchema, secretStr } from '../../resource/secrets.ts'
import { normalizeFields } from '../../utils/normalize.ts'
export const GhConfigSchema = z.object({
token: secretStr(),
baseUrl: z.string().optional(),
repo: z.string().optional(),
branch: z.string().optional(),
})
// Derived, not declared twice. The schema is the one doing real work --
// it validates an install's config and carries the `secretStr` marker
// redaction reads -- so a hand-written twin only adds a shape that can
// drift from it, which is how `branch` reached the schema and not the type.
export type GhConfig = z.infer<typeof GhConfigSchema>
export type GhConfigRedacted = Omit<GhConfig, 'token'> & {
token: typeof REDACTED_SECRET
}
export function redactGhConfig(config: GhConfig): GhConfigRedacted {
return redactConfigWithSchema(GhConfigSchema, config) as unknown as GhConfigRedacted
}
export function normalizeGhConfig(input: Record<string, unknown>): GhConfig {
return normalizeFields(input, {}) as unknown as GhConfig
}
@@ -0,0 +1,57 @@
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
import { describe, expect, it } from 'vitest'
import { expand } from './placeholder.ts'
import type { GhConfig } from './config.ts'
const CONFIG = { token: 't', repo: 'acme/tools', branch: 'main' } as unknown as GhConfig
describe('gh placeholders', () => {
it('expands the three gh documents', () => {
expect(expand('repos/{owner}/{repo}/branches/{branch}', CONFIG)).toBe(
'repos/acme/tools/branches/main',
)
})
it('leaves a path with no braces untouched', () => {
expect(expand('/user', CONFIG)).toBe('/user')
})
// gh leaves an unknown placeholder alone and lets the request go out; the
// API answers 404 for the literal segment. Probed against gh 2.85, which
// returns GitHub's own Not Found rather than a client-side error.
it('leaves an unknown placeholder on the wire', () => {
expect(expand('repos/{owner}/{nope}', CONFIG)).toBe('repos/acme/{nope}')
})
it('reads the owner off a host-qualified repo', () => {
const config = { token: 't', repo: 'github.com/acme/tools' } as unknown as GhConfig
expect(expand('repos/{owner}/{repo}', config)).toBe('repos/acme/tools')
})
it('refuses owner when the install names no repository', () => {
const config = { token: 't' } as unknown as GhConfig
expect(() => expand('repos/{owner}/x', config)).toThrow(/unable to expand placeholder/)
})
// `branch` is its own field: an install may name a repository without
// pinning a branch, and gh reports the failure rather than guessing one.
it('refuses branch when the install pins none', () => {
const config = { token: 't', repo: 'acme/tools' } as unknown as GhConfig
expect(() => expand('repos/{owner}/{repo}/branches/{branch}', config)).toThrow(
/no `branch` on the install/,
)
})
})
@@ -0,0 +1,48 @@
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
import type { GhConfig } from './config.ts'
import { parseRepo } from './repo.ts'
const PLACEHOLDER_RE = /\{(owner|repo|branch)\}/g
const EXPAND_ERROR = 'unable to expand placeholder in path'
function value(name: string, config: GhConfig): string {
if (name === 'branch') {
if (config.branch === undefined || config.branch === '') {
throw new Error(`${EXPAND_ERROR}: no \`branch\` on the install`)
}
return config.branch
}
if (config.repo === undefined || config.repo === '') {
throw new Error(`${EXPAND_ERROR}: no \`repo\` on the install`)
}
const ref = parseRepo(config.repo)
return name === 'owner' ? ref.owner : ref.repo
}
/**
* Expand gh's repository placeholders in an endpoint or field value.
*
* Real gh fills `{owner}`, `{repo}` and `{branch}` from the repository of
* the current directory, which is what most of its own documented examples
* are written with (`gh api repos/{owner}/{repo}/releases`); an install's
* `repo`/`branch` are the workspace's stand-in for that. Any other brace
* pair is left exactly as typed and reaches the wire, which is gh's
* behavior too: it is a path segment, not a template error.
*/
export function expand(text: string, config: GhConfig): string {
if (!text.includes('{')) return text
return text.replace(PLACEHOLDER_RE, (_m, name: string) => value(name, config))
}
@@ -0,0 +1,107 @@
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
import { describe, expect, it } from 'vitest'
import { GitHubAccessor } from '../../accessor/github.ts'
import { RAMIndexCacheStore } from '../../cache/index/ram.ts'
import { PathSpec } from '../../types.ts'
import { populateIndex } from './tree.ts'
import { read } from './read.ts'
import type { GitHubTransport } from './_client.ts'
const ENC = new TextEncoder()
interface Probe {
trees: number
blobs: string[]
}
// The blob a given sha holds, so a read that used a stale row is visible as
// the wrong bytes rather than as an error.
const BLOBS: Record<string, string> = { bbb: 'one\n', ccc: 'two\n' }
function accessorFor(sha: string, probe: Probe): GitHubAccessor {
const transport = {
get: (path: string) => {
if (path.includes('/git/trees/')) {
probe.trees += 1
return Promise.resolve({
tree: [
{ path: 'src', type: 'tree' as const, sha: 'aaa' },
{ path: 'src/main.py', type: 'blob' as const, sha, size: 4 },
],
truncated: false,
})
}
const blobSha = path.slice(path.lastIndexOf('/') + 1)
probe.blobs.push(blobSha)
return Promise.resolve({
content: Buffer.from(ENC.encode(BLOBS[blobSha] ?? '')).toString('base64'),
encoding: 'base64',
})
},
} as unknown as GitHubTransport
return new GitHubAccessor({
transport,
owner: 'acme',
repo: 'proj',
ref: 'main',
defaultBranch: 'main',
})
}
async function seeded(sha: string): Promise<RAMIndexCacheStore> {
const index = new RAMIndexCacheStore()
await populateIndex(index, [
{ path: 'src', type: 'tree' as const, sha: 'aaa' },
{ path: 'src/main.py', type: 'blob' as const, sha, size: 4 },
])
return index
}
function spec(p: string): PathSpec {
return new PathSpec({ resourcePath: p.slice(1), virtual: p, directory: '/src' })
}
describe('github read freshness', () => {
// The row survives an invalidation carrying the *pre-write* blob sha, so
// trusting it served the old bytes. Freshness is tracked per directory,
// so the parent listing is what says the row aged out.
it('refetches the tree when the parent listing expired', async () => {
const index = await seeded('bbb')
await index.invalidate()
const probe: Probe = { trees: 0, blobs: [] }
const out = await read(accessorFor('ccc', probe), spec('/src/main.py'), index)
expect(new TextDecoder().decode(out)).toBe('two\n')
expect(probe.trees).toBe(1)
expect(probe.blobs).toEqual(['ccc'])
})
it('trusts a live listing without refetching', async () => {
const index = await seeded('bbb')
const probe: Probe = { trees: 0, blobs: [] }
const out = await read(accessorFor('ccc', probe), spec('/src/main.py'), index)
expect(new TextDecoder().decode(out)).toBe('one\n')
expect(probe.trees).toBe(0)
})
// A miss against a live index is a real absence. Refilling here would
// spend a full recursive-tree fetch on every ENOENT.
it('does not refetch on a real miss', async () => {
const index = await seeded('bbb')
const probe: Probe = { trees: 0, blobs: [] }
await expect(read(accessorFor('ccc', probe), spec('/src/gone.py'), index)).rejects.toThrow()
expect(probe.trees).toBe(0)
})
})
@@ -12,10 +12,12 @@
// limitations under the License.
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
import { LookupStatus } from '../../cache/index/config.ts'
import { mountPrefixOf } from '../../utils/key_prefix.ts'
import type { GitHubAccessor } from '../../accessor/github.ts'
import type { IndexCacheStore } from '../../cache/index/store.ts'
import type { PathSpec } from '../../types.ts'
import { refillIndex } from './tree.ts'
import { fetchBlob } from './_client.ts'
import { stripSlash } from '../../utils/slash.ts'
import { enoent } from '../../utils/errors.ts'
@@ -40,6 +42,11 @@ function indexKey(p: string): string {
return trimmed === '' ? '/' : `/${trimmed}`
}
function parentKey(key: string): string {
const cut = key.lastIndexOf('/')
return cut <= 0 ? '/' : key.slice(0, cut)
}
export async function read(
accessor: GitHubAccessor,
path: PathSpec,
@@ -47,7 +54,19 @@ export async function read(
): Promise<Uint8Array> {
const p = stripPrefix(path)
if (index === undefined) throw enoent(path)
const result = await index.get(indexKey(p))
const key = indexKey(p)
// Freshness is tracked per directory, never per entry, so a blob's row
// is exactly as fresh as its parent's listing and `get` can never report
// staleness of its own. The parent is therefore the probe: after a write
// invalidated the index the row survives carrying the *pre-write* blob
// sha, and reading it back served the old bytes. A miss is not a probe
// either -- against a live index it is a real absence, and refetching the
// whole tree on every ENOENT costs a recursive-tree call per miss.
if (!accessor.truncated) {
const parent = await index.listDir(parentKey(key))
if (parent.status === LookupStatus.EXPIRED) await refillIndex(accessor, index)
}
const result = await index.get(key)
if (result.entry === undefined || result.entry === null) throw enoent(path)
if (result.entry.resourceType === 'folder') throw eisdir(p)
return fetchBlob(accessor.transport, accessor.owner, accessor.repo, result.entry.id)
@@ -0,0 +1,75 @@
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
import { describe, expect, it } from 'vitest'
import { GitHubAccessor } from '../../accessor/github.ts'
import { RAMIndexCacheStore } from '../../cache/index/ram.ts'
import { PathSpec } from '../../types.ts'
import { populateIndex } from './tree.ts'
import { readdir } from './readdir.ts'
import type { GitHubTransport } from './_client.ts'
const TREE = [
{ path: 'README.md', type: 'blob' as const, sha: 'eee', size: 50 },
{ path: 'src', type: 'tree' as const, sha: 'aaa' },
{ path: 'src/main.py', type: 'blob' as const, sha: 'bbb', size: 120 },
]
function accessorFor(probe: { trees: number }): GitHubAccessor {
const transport = {
get: () => {
probe.trees += 1
return Promise.resolve({ tree: TREE, truncated: false })
},
} as unknown as GitHubTransport
return new GitHubAccessor({
transport,
owner: 'acme',
repo: 'proj',
ref: 'main',
defaultBranch: 'main',
})
}
async function seeded(): Promise<RAMIndexCacheStore> {
const index = new RAMIndexCacheStore()
await populateIndex(index, TREE)
return index
}
function spec(p: string): PathSpec {
return new PathSpec({ resourcePath: p.slice(1), virtual: p, directory: p })
}
describe('github readdir freshness', () => {
// The index *is* the listing here, seeded once from the recursive tree,
// so an expired one is a tree that aged out rather than a repository that
// emptied: before the refill `ls` exited 0 with no output once the
// day-long TTL lapsed, and reported the mount root missing after a write
// invalidated it.
it('refetches the tree when the listing expired', async () => {
const index = await seeded()
await index.invalidate()
const probe = { trees: 0 }
expect(await readdir(accessorFor(probe), spec('/'), index)).toEqual(['/README.md', '/src'])
expect(probe.trees).toBe(1)
})
it('does not refetch on a real miss', async () => {
const index = await seeded()
const probe = { trees: 0 }
await expect(readdir(accessorFor(probe), spec('/nope'), index)).rejects.toThrow()
expect(probe.trees).toBe(0)
})
})
@@ -18,6 +18,7 @@ import { LookupStatus } from '../../cache/index/config.ts'
import type { IndexCacheStore } from '../../cache/index/store.ts'
import type { PathSpec } from '../../types.ts'
import { fetchDirTree } from './_client.ts'
import { refillIndex } from './tree.ts'
import { IndexEntry } from '../../cache/index/config.ts'
import { stripSlash } from '../../utils/slash.ts'
import { enoent } from '../../utils/errors.ts'
@@ -49,7 +50,15 @@ export async function readdir(
const stripped = stripPrefix(path)
const key = normalizeKey(stripped)
const listing = await index.listDir(key)
let listing = await index.listDir(key)
// The index is the whole listing here, not a cache in front of one, so an
// *expired* answer means the tree aged out, not that the path is gone.
// Refetch once and ask again. A NOT_FOUND against a live index is a real
// absence and must not cost a tree fetch: refilling on any miss spends a
// recursive-tree call on every ENOENT.
if (listing.status === LookupStatus.EXPIRED && !accessor.truncated) {
if (await refillIndex(accessor, index)) listing = await index.listDir(key)
}
if (listing.entries !== undefined && listing.entries !== null) {
return prefix !== '' && listing.entries.length > 0 && !listing.entries[0]?.startsWith(prefix)
? listing.entries.map((e) => prefix + e)
@@ -0,0 +1,89 @@
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
import { GitHubApiError, type GitHubTransport } from './_client.ts'
import { decodeBase64 } from '../../utils/base64.ts'
export interface RepoRef {
owner: string
repo: string
}
/**
* gh's `[HOST/]OWNER/REPO`: the host is optional and leading, so the owner and
* the repository are always the last two segments. Taking the first two
* instead read `github.com/acme/tools` as owner `github.com`, repo `acme` --
* a different repository, reported as success.
*
* Args:
* spec (string): the repository as the line spelled it.
*
* Returns:
* RepoRef: the owner and repository names.
*/
export function parseRepo(spec: string): RepoRef {
const parts = spec.split('/')
const repo = parts.pop()
const owner = parts.pop()
if (owner === undefined || repo === undefined || owner === '' || repo === '') {
throw new Error(`expected the "[HOST/]OWNER/REPO" format, got "${spec}"`)
}
// One more segment is a host; two is not a repository any spelling reaches.
if (parts.length > 1) {
throw new Error(`expected the "[HOST/]OWNER/REPO" format, got "${spec}"`)
}
return { owner, repo }
}
export async function login(transport: GitHubTransport): Promise<string> {
const me = (await transport.get('/user')) as { login?: string }
return me.login ?? ''
}
export function viewRepo(transport: GitHubTransport, ref: RepoRef): Promise<unknown> {
return transport.get(`/repos/${ref.owner}/${ref.repo}`)
}
/**
* The repository's README as text, or null when it has none.
*/
export async function readReadme(transport: GitHubTransport, ref: RepoRef): Promise<string | null> {
let data: unknown
try {
data = await transport.get(`/repos/${ref.owner}/${ref.repo}/readme`)
} catch (err) {
if (err instanceof GitHubApiError && err.status === 404) return null
throw err
}
const content = (data as { content?: unknown } | null)?.content
if (typeof content !== 'string') return null
return new TextDecoder().decode(decodeBase64(content))
}
export function forkRepo(
transport: GitHubTransport,
ref: RepoRef,
name?: string,
): Promise<unknown> {
const body = name === undefined ? {} : { name }
return transport.request('POST', `/repos/${ref.owner}/${ref.repo}/forks`, body)
}
export function renameRepo(
transport: GitHubTransport,
ref: RepoRef,
name: string,
): Promise<unknown> {
return transport.request('PATCH', `/repos/${ref.owner}/${ref.repo}`, { name })
}
@@ -12,6 +12,8 @@
// limitations under the License.
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
import type { GitHubAccessor } from '../../accessor/github.ts'
import { fetchTree } from './_client.ts'
import type { IndexCacheStore } from '../../cache/index/store.ts'
import type { IndexEntry } from '../../cache/index/config.ts'
import type { GitHubTreeItem } from './_client.ts'
@@ -35,3 +37,40 @@ export async function populateIndex(index: IndexCacheStore, tree: GitHubTreeItem
}
await Promise.all([...dirs].map(([parent, entries]) => index.setDir(parent, entries)))
}
/**
* Refetch the recursive tree and re-seed the index from it.
*
* The mount fetches the whole tree once and seeds the index with it, so
* the index is the listing rather than a cache in front of one. That makes
* a cleared or expired index indistinguishable from an empty repository --
* `ls` reported the mount root missing after an invalidation, and reported
* nothing at all once the day-long TTL lapsed. This is the refill that
* makes dropping the index mean "refetch", which is what invalidating it
* was always supposed to mean.
*
* Args:
* accessor (GitHubAccessor): the mount's accessor, holding the transport
* and the ref to refetch.
* index (IndexCacheStore | undefined): the index to re-seed.
*
* Returns:
* boolean: whether a refill happened; false when there is no index to
* seed, so a caller does not retry a lookup that cannot change.
*/
export async function refillIndex(
accessor: GitHubAccessor,
index: IndexCacheStore | undefined,
): Promise<boolean> {
if (index === undefined) return false
const { tree, truncated } = await fetchTree(
accessor.transport,
accessor.owner,
accessor.repo,
accessor.ref,
)
accessor.truncated = truncated
accessor.tree = buildTreeMap(tree)
await populateIndex(index, tree)
return true
}
+15
View File
@@ -718,6 +718,7 @@ export { DISCORD } from './commands/cli/builtin/discord/index.ts'
export { NTN } from './commands/cli/builtin/ntn/index.ts'
export { LINEAR } from './commands/cli/builtin/linear/index.ts'
export { GIT } from './commands/cli/builtin/git/index.ts'
export { GH } from './commands/cli/builtin/gh/index.ts'
export { nodeHelp, walk } from './commands/cli/walk.ts'
export { CLIRegistry } from './workspace/cli/registry.ts'
export type { CLIInstall } from './workspace/cli/types.ts'
@@ -943,6 +944,20 @@ export {
export { GitHubAccessor, type GitHubResourceLike } from './accessor/github.ts'
export { GITHUB_COMMANDS } from './commands/builtin/github/index.ts'
export { GITHUB_OPS } from './ops/github/index.ts'
export {
type GhConfig,
type GhConfigRedacted,
GhConfigSchema,
normalizeGhConfig,
redactGhConfig,
} from './core/github/config.ts'
export {
forkRepo as githubForkRepo,
login as githubLogin,
parseRepo as githubParseRepo,
renameRepo as githubRenameRepo,
viewRepo as githubViewRepo,
} from './core/github/repo.ts'
export { read as githubRead, stream as githubStream } from './core/github/read.ts'
export { readdir as githubReaddir } from './core/github/readdir.ts'
export {
+8
View File
@@ -69,6 +69,7 @@ export interface IOResultInit {
writes?: Record<string, ByteSource>
cache?: string[]
producer?: Producer | null
mutated?: boolean | null
}
export class IOResult {
@@ -84,6 +85,12 @@ export class IOResult {
// policy layer as context. Facts ride the envelope, policy
// decisions never do.
producer: Producer | null
// Whether this run changed service state, when only the handler can
// tell. A CLI leaf declares `write` statically because for almost every
// verb it is static, but `gh api` carries its method on the line, so a
// plain `gh api /user` is a read through a leaf that is declared
// writable. null leaves the spec's answer standing.
mutated: boolean | null
streamSource: IOResult | null
constructor(init: IOResultInit = {}) {
@@ -94,6 +101,7 @@ export class IOResult {
this.writes = init.writes ?? {}
this.cache = init.cache ?? []
this.producer = init.producer ?? null
this.mutated = init.mutated ?? null
this.streamSource = null
}
@@ -359,7 +359,13 @@ export async function handleCli(
// matters. Dropping the service's listings is what lets the agent's next
// `ls` see what it just made, and dropping its cached bodies is what lets
// the next `cat` see an edit rather than the pre-write content.
if (leaf.write && dropCaches !== null) await dropCaches()
//
// The spec's `write` is the static answer, which is the only one most
// verbs have; a handler that knows better says so on its result, so a
// read-only `gh api` does not expire every github mount.
const mutated = io.mutated ?? leaf.write
if (mutated && dropCaches !== null) await dropCaches()
io.producer = { command: prog, prefixes: [], declared: leaf.limit ?? null }
if (warnings.length > 0) {
@@ -174,7 +174,11 @@ export async function dropServiceCaches(
const wanted = new Set<string>(serves)
for (const mount of registry.allMounts()) {
if (!wanted.has(mount.resource.kind)) continue
await mount.resource.index?.clear()
// Invalidate rather than clear: a cleared index reads exactly like one
// that was never filled, so a backend whose index *is* its listing
// (github seeds the whole tree once) cannot tell the drop from an empty
// repository. Expiring keeps that distinction and the next read refetches.
await mount.resource.index?.invalidate()
await mount.cacheManager?.dropPrefix()
}
}
+132 -1
View File
@@ -429,6 +429,18 @@ importers:
'@modelcontextprotocol/sdk':
specifier: ^1.0.0
version: 1.29.0(@cfworker/json-schema@4.1.1)(zod@4.3.6)
'@octokit/core':
specifier: ^7.0.7
version: 7.0.7
'@octokit/plugin-retry':
specifier: ^8.1.1
version: 8.1.1(@octokit/core@7.0.7)
'@octokit/plugin-throttling':
specifier: ^11.0.5
version: 11.0.5(@octokit/core@7.0.7)
'@octokit/request-error':
specifier: ^7.1.1
version: 7.1.1
bson:
specifier: ^6.10.4
version: 6.10.4
@@ -1903,6 +1915,48 @@ packages:
resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==}
engines: {node: '>= 8'}
'@octokit/auth-token@6.0.0':
resolution: {integrity: sha512-P4YJBPdPSpWTQ1NU4XYdvHvXJJDxM6YwpS0FZHRgP7YFkdVxsWcpWGy/NVqlAA7PcPCnMacXlRm1y2PFZRWL/w==}
engines: {node: '>= 20'}
'@octokit/core@7.0.7':
resolution: {integrity: sha512-DcB0M3KFgr9ECI328lhBMVsyFT2DnmNucSBTqEN3exyNKUzkkpUSCHmTRcunF41Eou2TIQKW4seewri8ON9bSA==}
engines: {node: '>= 20'}
'@octokit/endpoint@11.0.4':
resolution: {integrity: sha512-f1cOWoHPmxryJFknxbtDdjODWfV8A9tc8Aae6ermXPNgHFZ/x91AtHIz4gicEjL8hkJiip+u21QHJORfBv/qiA==}
engines: {node: '>= 20'}
'@octokit/graphql@9.0.4':
resolution: {integrity: sha512-5s15CCiY8XXQ+FG+b1YQcl6Z2FA++nwAz/tg2VUrTmnMncP+2nnGUEYANImdnxsA2Fnq+Mbl7hDjUTw7cFAwcg==}
engines: {node: '>= 20'}
'@octokit/openapi-types@28.0.0':
resolution: {integrity: sha512-0rFyLuyHvIj6uuZWuDslxkowFYdPXoNIkeAv4b27dzm2Tf4vGWXnPsMcxs7d65kLdMERgP3wc1AEPlqMz8e1cQ==}
'@octokit/plugin-retry@8.1.1':
resolution: {integrity: sha512-VCVvZ/R1+u3WuiBWpNavZ0mY4aaJNAsENrpBP9aLSR2QyOpQgd7DhM5j4AW7z4MQpnJYgwBPf0XqPQoNBRdQwg==}
engines: {node: '>= 20'}
peerDependencies:
'@octokit/core': '>=7'
'@octokit/plugin-throttling@11.0.5':
resolution: {integrity: sha512-LIdrkrUv+DWbKeg/49rGuFJ3SU0d3hUS+B4MhNZLepBoNUFXms8Ic9edJjrlx+zycqJHjrMRudVpVb/bAXM2Lw==}
engines: {node: '>= 20'}
peerDependencies:
'@octokit/core': ^7.0.0
'@octokit/request-error@7.1.1':
resolution: {integrity: sha512-+eaY7G2VVpSf2pc5Gn1+mph837V/d/TYTJAgWL9Tb0ogGYcpN3IlAVFgjL+Vv93F/sevrxkvsYCedtpLdcFLzA==}
engines: {node: '>= 20'}
'@octokit/request@10.0.13':
resolution: {integrity: sha512-v2269YxL9Yf+x3d+gRI63FP0vFQEiWgLyBzxe/Y+0yFDg2B/Tzf5dhh9VNfccVAQnfcfwQWyk/y6Bn7rUXXs7A==}
engines: {node: '>= 20'}
'@octokit/types@17.0.0':
resolution: {integrity: sha512-ByP1v7YL5SMveFPP7+sj0/ZuWCOOg/Chs4NafOMpq6WNIM/hdGY0S7C0TCGDBWu1aGmOxmUIhMx3cO+IdwYZ1Q==}
'@openai/agents-core@0.13.5':
resolution: {integrity: sha512-RI9OwHG94c6ZTLNeEB7mfIpHbncgVxu4YElAmCAhq04EqLPzsLyouWhDgli8wZL/IhN/cYTaBwHvxktFzEbeyQ==}
peerDependencies:
@@ -3554,6 +3608,9 @@ packages:
bcrypt-pbkdf@1.0.2:
resolution: {integrity: sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==}
before-after-hook@4.0.0:
resolution: {integrity: sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ==}
better-path-resolve@1.0.0:
resolution: {integrity: sha512-pbnl5XzGBdrFU/wT4jqmJVPn2B6UHPBOhzMQkY/SPUPB6QtUXtmBHBIwCbXJol93mOpGMnQyP/+BB19q04xj7g==}
engines: {node: '>=4'}
@@ -3565,6 +3622,9 @@ packages:
resolution: {integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==}
engines: {node: '>=18'}
bottleneck@2.19.5:
resolution: {integrity: sha512-VHiNCbI1lKdl44tGrhNfU3lup0Tj/ZBMJB5/2ZbNXRCPuRCO7ed2mgcK4r17y+KB2EfuYuRaVlwNbAeaWGSpbw==}
bowser@2.14.1:
resolution: {integrity: sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==}
@@ -4743,6 +4803,9 @@ packages:
json-stable-stringify-without-jsonify@1.0.1:
resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==}
json-with-bigint@3.5.10:
resolution: {integrity: sha512-Vcx+JVNEBts/xfcoCS69sKrOhOk/3TVlvlT+XzUOefVKnnrbYSCKpDCm10pohsJFtsJVYnwa/cXRZ4eElzaM6w==}
jsonfile@4.0.0:
resolution: {integrity: sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==}
@@ -6348,6 +6411,9 @@ packages:
unist-util-visit@5.1.0:
resolution: {integrity: sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==}
universal-user-agent@7.0.3:
resolution: {integrity: sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A==}
universalify@0.1.2:
resolution: {integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==}
engines: {node: '>= 4.0.0'}
@@ -8443,6 +8509,61 @@ snapshots:
'@nodelib/fs.scandir': 2.1.5
fastq: 1.20.1
'@octokit/auth-token@6.0.0': {}
'@octokit/core@7.0.7':
dependencies:
'@octokit/auth-token': 6.0.0
'@octokit/graphql': 9.0.4
'@octokit/request': 10.0.13
'@octokit/request-error': 7.1.1
'@octokit/types': 17.0.0
before-after-hook: 4.0.0
universal-user-agent: 7.0.3
'@octokit/endpoint@11.0.4':
dependencies:
'@octokit/types': 17.0.0
universal-user-agent: 7.0.3
'@octokit/graphql@9.0.4':
dependencies:
'@octokit/request': 10.0.13
'@octokit/types': 17.0.0
universal-user-agent: 7.0.3
'@octokit/openapi-types@28.0.0': {}
'@octokit/plugin-retry@8.1.1(@octokit/core@7.0.7)':
dependencies:
'@octokit/core': 7.0.7
'@octokit/request-error': 7.1.1
'@octokit/types': 17.0.0
bottleneck: 2.19.5
'@octokit/plugin-throttling@11.0.5(@octokit/core@7.0.7)':
dependencies:
'@octokit/core': 7.0.7
'@octokit/types': 17.0.0
bottleneck: 2.19.5
'@octokit/request-error@7.1.1':
dependencies:
'@octokit/types': 17.0.0
'@octokit/request@10.0.13':
dependencies:
'@octokit/endpoint': 11.0.4
'@octokit/request-error': 7.1.1
'@octokit/types': 17.0.0
content-type: 2.0.0
json-with-bigint: 3.5.10
universal-user-agent: 7.0.3
'@octokit/types@17.0.0':
dependencies:
'@octokit/openapi-types': 28.0.0
'@openai/agents-core@0.13.5(@aws-sdk/credential-provider-node@3.972.70)(@cfworker/json-schema@4.1.1)(@smithy/signature-v4@5.6.6)(ws@8.21.0)(zod@4.3.6)':
dependencies:
debug: 4.4.3
@@ -10204,7 +10325,7 @@ snapshots:
axios@1.18.1:
dependencies:
follow-redirects: 1.16.0(debug@4.4.3)
follow-redirects: 1.16.0
form-data: 4.0.6
https-proxy-agent: 5.0.1
proxy-from-env: 2.1.0
@@ -10258,6 +10379,8 @@ snapshots:
dependencies:
tweetnacl: 0.14.5
before-after-hook@4.0.0: {}
better-path-resolve@1.0.0:
dependencies:
is-windows: 1.0.2
@@ -10278,6 +10401,8 @@ snapshots:
transitivePeerDependencies:
- supports-color
bottleneck@2.19.5: {}
bowser@2.14.1: {}
brace-expansion@1.1.16:
@@ -11098,6 +11223,8 @@ snapshots:
flatted@3.4.2: {}
follow-redirects@1.16.0: {}
follow-redirects@1.16.0(debug@4.4.3):
optionalDependencies:
debug: 4.4.3
@@ -11518,6 +11645,8 @@ snapshots:
json-stable-stringify-without-jsonify@1.0.1: {}
json-with-bigint@3.5.10: {}
jsonfile@4.0.0:
optionalDependencies:
graceful-fs: 4.2.11
@@ -13372,6 +13501,8 @@ snapshots:
unist-util-is: 6.0.1
unist-util-visit-parents: 6.0.2
universal-user-agent@7.0.3: {}
universalify@0.1.2: {}
unpipe@1.0.0: {}