An Effect v4 SDK for the GitHub CLI (gh).
Inspired by the Effect-native API in dmmulroy/herdr-ts-sdk.
- Effect matching
peerDependencies.effectin package.json, with a compatible consumer-chosen platform adapter. - GitHub CLI 2.97.0 or later, installed and authenticated through
gh auth loginor its standard token environment variables.Api.raw,Api.text,Api.emptyandPullRequest.diffpass--allow-escape-sequences, which older releases reject. - An ESM consumer. Bun runs the TypeScript source directly; Node uses the compiled JavaScript in a built package. The platform adapter stays a consumer dependency.
The commands below read Effect and Node adapter versions from the installed SDK's
package.json. The Node adapter also works in Bun.
Run from your application directory:
bun add github:timmo001/effect-gh
bun add "effect@$(bun -p 'require("@timmo001/effect-gh/package.json").peerDependencies.effect')" \
"@effect/platform-node@$(bun -p 'require("@timmo001/effect-gh/package.json").devDependencies["@effect/platform-node"]')"Commit package.json and bun.lock in your application to preserve the resolved
GitHub revision and dependency versions. The bun export resolves to
src/index.ts, and types resolve to the same source. GitHub installs need no
build step or consumer patch.
Run from your application directory to build and install a package from source with the mise-pinned tools:
git clone https://github.com/timmo001/effect-gh.git .effect-gh
(
cd .effect-gh &&
mise install &&
mise run install &&
mise exec -- npm pack --pack-destination ..
)
bun add ./timmo001-effect-gh-*.tgz
bun add "effect@$(bun -p 'require("@timmo001/effect-gh/package.json").peerDependencies.effect')" \
"@effect/platform-node@$(bun -p 'require("@timmo001/effect-gh/package.json").devDependencies["@effect/platform-node"]')"Keep the archive with your application for repeatable installs. The .effect-gh
checkout is only needed to build it.
The first npm publication is pending. After publication, either runtime can install directly from npm:
bun add @timmo001/effect-gh
bun add "effect@$(bun -p 'require("@timmo001/effect-gh/package.json").peerDependencies.effect')" \
"@effect/platform-node@$(bun -p 'require("@timmo001/effect-gh/package.json").devDependencies["@effect/platform-node"]')"Install GitHub CLI separately, then authenticate:
gh auth login
gh auth statusFor automation, supply GH_TOKEN or another gh-supported authentication
configuration through the environment. The SDK uses it without a separate login.
The effect-gh skill guides Effect integrations towards the package and its current documentation. It follows the Agent Skills specification. Install it with:
npx skills add timmo001/effect-ghOr import the complete skills/effect-gh/ directory unchanged into your skill
collection; it has no client-specific configuration or local path dependencies.
Gh is the service tag. layer(options?) captures the consumer-provided
ChildProcessSpawner and starts no processes until an operation runs. Consumers
choose their platform layer and runtime. For example, with @effect/platform-node:
import { NodeServices } from "@effect/platform-node";
import { Effect, Layer, Schema } from "effect";
import { Gh, layer } from "@timmo001/effect-gh";
const ghLayer = layer({ timeout: "30 seconds" }).pipe(
Layer.provide(NodeServices.layer),
);
const program = Effect.gen(function* () {
const gh = yield* Gh;
return yield* gh.json(
["api", "user"],
Schema.Struct({ login: Schema.String }),
);
}).pipe(Effect.provide(ghLayer));execute(args, options?)returns{ stdout, stderr, exitCode }on exit zero. It buffers both UTF-8 outputs in memory, so usestreamfor large output.json(args, schema, options?)decodes stdout as JSON through the supplied Schema, preserving its decoding service requirements. Supply any--jsonfields required by the selectedghcommand yourself.stream(args, options?)emits{ _tag: "Stdout" | "Stderr", text }chunks. It drains both pipes concurrently with backpressure and preserves order within each pipe, with no ordering guarantee between pipes. Chunk boundaries are not line boundaries. Completion waits for both pipes and a successful exit.interactive(args, options?)runs on the terminal's stdin, stdout and stderr, without the SDK's prompt and colour overrides, so gh and git can show progress and ask for credentials. Output is not captured; a nonzero exit fails withGhCommandError.
Prefer the typed operations below. Reach for the raw methods only for a command the SDK does not cover yet, and add a typed operation for it when it sticks.
GhOptions accepts executable (default gh), cwd, env, stdin (string or
Uint8Array), timeout (an Effect duration) and maxOutputBytes, which stops a
command with GhOutputLimitError once stdout passes that size. Call options override layer
defaults; environment entries merge. timeout: null disables a layer timeout.
Timeouts cover spawning, execution and output draining, rather than idle time.
There is no timeout by default. Schema decoding runs after the subprocess timeout.
Commands pass literal argv without a shell. The platform inherits the environment,
including existing gh authentication and configuration. Supplied environment
entries override inherited values; undefined removes a value. The SDK always
disables prompts, colour, forced TTY and spinners, and selects cat as the pager.
Stdin is closed unless supplied explicitly. No operation is automatically retried.
Errors are tagged GhCommandError (nonzero exit with exit code, stdout and
stderr), GhPlatformError (spawn or pipe failure), GhTimeoutError,
GhOutputLimitError, GhDecodeError (invalid JSON or a schema mismatch), or
GhInvalidOptionsError (operation options rejected before spawning). Command errors retain only
the last 65,536 UTF-16 code units of each output and flag truncation with
stdoutTruncated and stderrTruncated.
The stream emits trailing output before reporting a nonzero exit.
Interruption, timeout and early stream termination close the child scope. The platform terminates the child, escalating after one second if needed. An early consumer stop does not validate the final exit code. Interruptions remain Effect interruptions rather than being converted into SDK errors.
Api.json(request, schema) decodes one JSON response. Api.empty(request) runs
a request whose body is not needed, such as a 204 mutation. Api.text(request)
returns the body as text, for raw file contents and other non-JSON media types.
Api.headers(request) returns the response headers with lower-cased names; use
method HEAD to skip the body. Api.raw(request) returns the buffered output
and exit code. Every request specifies a method, which the SDK passes
explicitly to gh api. raw, text and empty return non-JSON bodies
verbatim, including terminal escape sequences that piped gh would otherwise
refuse; gh sanitises JSON responses either way.
import { Api } from "@timmo001/effect-gh";
import { Schema } from "effect";
const viewer = Api.json(
{ endpoint: "user", method: "GET" },
Schema.Struct({ login: Schema.String }),
);
const subscription = Api.empty({
endpoint: "notifications/threads/123/subscription",
method: "PUT",
body: { ignored: true },
});Requests accept hostname, headers, query, a JSON body, and core overrides
under options. Query values are URL-encoded; arrays repeat the key. JSON bodies
go through stdin rather than CLI field interpolation. Bodyless calls clear any
layer-level stdin.
Api.pages(request, pageSchema) runs a bodyless REST GET with
--paginate --slurp. The result is an array of decoded pages, not flattened
items: use an array schema for array endpoints, or an object schema for envelopes
such as { workflow_runs: [...] }. It buffers all pages and does not provide a
snapshot-consistency guarantee. GraphQL cursor pagination remains available
through the raw Gh interface.
No SDK operation retries automatically. Mutations can have taken effect even when the CLI times out or loses the connection, so only retry known-idempotent reads, and do not transparently replay a stream after it has emitted output.
Gh.retryTransient(options?) retries an effect failing with GhError while
isTransient holds: timeouts, rate limits, HTTP 408, 429 and 5xx gateway
statuses, and network failures. It defaults to three retries with jittered
exponential backoff from 250 milliseconds, capped at 10 seconds
(defaultRetrySchedule); pass schedule or times to change either. Decode
errors, authentication failures and check-status exits are not transient.
import { Api, Gh } from "@timmo001/effect-gh";
import { Schema } from "effect";
const viewer = Api.json(
{ endpoint: "user", method: "GET" },
Schema.Struct({ login: Schema.String }),
).pipe(Gh.retryTransient({ times: 2 }));isRateLimited(error) and httpStatus(error) expose the same classification
for consumers mapping GhError into their own errors.
RateLimit.get(resource?, options?) reads the core (default), graphql or
search quota from gh api rate_limit, which does not count against it.
RateLimit.cached(timeToLive, options?) builds an Effect Cache of those reads
keyed by resource. Failed reads are not kept, and Cache.invalidate forces a
fresh read after a rate-limited failure. RateLimit.guard(cache, options?)
checks that cache before a request: once remaining requests drop to
minRemaining (default 0) it sleeps until the reset when that is within
maxWait (default 60 seconds), and otherwise fails with RateLimitExhausted.
import { Issue, Repository } from "@timmo001/effect-gh";
const repository = Repository.view("owner/repo");
const issues = Issue.list({ repo: "owner/repo", state: "open", limit: 50 });
const issue = Issue.view(42, { repo: "owner/repo" });Repository.view(repo?, options?) returns the repository name, URL, privacy and
nullable default branch. Omitting the selector uses gh's working-directory
context. Issue list/view also accept an optional repo, plus core execution
options as their final argument. Lists default to 30 open issues, and support
state, limit, labels, assignee and search filters. Issue.view includes the body.
Invalid numeric limits and selectors fail with IssueInvalidInput before spawning.
Issue.get and Issue.query take a fields tuple, such as
["number", "body", "labels", "comments"], and decode exactly those fields.
Issue.create returns the new issue's URL; edit, comment and close cover
the common mutations.
Repository.clone captures output, so pass non-interactive git settings in
env. Repository.cloneInteractive clones on the terminal. Both accept extra
gitArgs. Repository.browse opens a repository or one of its sections.
import { PullRequest } from "@timmo001/effect-gh";
const pullRequests = PullRequest.list({ repository: "owner/repo" });
const pullRequest = PullRequest.view(42, { repository: "owner/repo" });
const checks = PullRequest.checks(42, {
repository: "owner/repo",
required: true,
});Pull request options require repository and accept core execution overrides.
Lists default to 30 open PRs, with optional state, limit, base and head filters.
List/view return number, title, URL, state, draft status, head branch and head SHA.
PullRequest.checks returns { checks, status, exitCode }. Valid check output
from exits 0, 1 and 8 is data with status success, failure or pending.
Genuine command failures retain their typed error; no command is re-executed to
retrieve its output.
PullRequest.get and PullRequest.query take a fields tuple and decode
exactly those fields, including body, author, merge state, review decision,
comments, reviews and labels. get defaults to the working directory's current
branch and repository. create returns the URL; edit, merge (now or
auto), disableAutoMerge and diff cover the rest. diff returns the patch
verbatim, escape sequences included.
import { Workflow } from "@timmo001/effect-gh";
const runs = Workflow.list({ repo: "owner/repo", branch: "main", limit: 20 });
const run = Workflow.view({ repo: "owner/repo", runId: 123, attempt: 2 });
const logs = Workflow.logs({
repo: "owner/repo",
runId: 123,
attempt: 2,
failedOnly: true,
});
const progress = Workflow.watch(
{ repo: "owner/repo", runId: 123, interval: 3 },
{ timeout: null },
);Workflow operations require repo and accept core options as a final argument.
Lists default to at most 20 runs, with branch, commit, workflow and status filters.
Run data uses gh's camelCase JSON fields, including databaseId and attempt.
Log retrieval supports an explicit attempt and job, and returns buffered text.
Invalid selectors and numeric options fail with GhInvalidOptionsError.
Workflow.watch emits core stdout/stderr chunks using
gh run watch --compact --exit-status. It defaults to a three-second refresh
interval, fails when the run fails and inherits the core timeout. It requires a
gh version with --compact support; gh watch does not support fine-grained PATs.
Use REST page schemas through Api when a consumer needs snake_case fields,
page envelopes or attempt-specific jobs.
Workflow.jobs returns a run's jobs and steps. Workflow.dispatch starts a
workflow_dispatch run with an optional ref and string inputs, and
Workflow.open opens a run in the browser.
Auth.status(hostname?)andAuth.hasToken(hostname?)return booleans.hasTokendrains the token without keeping it.Auth.scopes(hostname?)returns the classic OAuth scopes from thex-oauth-scopesheader, orNonefor fine-grained tokens.Extension.listparsesgh extension listinto name, repository and version.Extension.installandExtension.upgradeAllaccept{ interactive: true }.Label.create(withforceto update) andLabel.remove.Release.viewdecodes selected fields,Release.existschecks a tag, andcreate,edit,uploadanddownloadcover publishing.Search.codereturns matching paths with their repositories.Cli.versionreturns the installed gh version.
- Notifications: nullable notification fields, single-page counts, mark-read/done and subscription requests with empty responses, plus consumer-side NodeServices composition.
- Workflow runs: REST page envelopes, branch and SHA filtering, retained run attempts, attempt-specific jobs and failed logs.
These examples are typechecked and tested against fixtures matching the dotfiles and Herdr Workflow Watch contracts. They preserve the data needed by those consumers; filtering policy, reconciliation, completeness checks and runtime ownership stay with the application. They are migration references, not drop-in replacements for either existing service.
Use the tool versions pinned in mise.toml and Bun for dependencies.
mise run check ::: buildtooling.yml records the gh release the SDK was last reviewed
against, and tooling/gh holds a snapshot of the help and --json
fields of every wrapped command at that release. The daily Tooling drift
workflow runs mise run tooling:publish, the
tooling-drift tool pinned in
mise.toml. It compares the reviewed release with the latest
cli/cli release and, when it moves, runs
mise run tooling:refresh <tag> to regenerate the snapshot from that release,
then opens one pull request: [Pin-only] Update cli/cli to <tag> with squash
auto-merge when the snapshot is unchanged, or Update cli/cli to <tag> for
review when a flag or field changed. Review that diff against the wrappers in
src/ before merging; a change here may also need one in the repositories that
use the SDK.
Run the same update by hand with mise run tooling:update <tag>, which
downloads that gh release through
scripts/gh-contract.ts, regenerates the snapshot and
records the tag; mise run tooling:status prints whether a newer release
exists. Add a command to the list in that script when the SDK starts wrapping
it.
To inspect the package locally, run mise exec -- npm pack --dry-run; the pack
hook builds dist/.
The package includes only the ESM build, declarations, README, licence and package
metadata.
Before the first automated publication, configure npm trusted publishing for
timmo001/effect-gh and workflow filename release.yml. An initial package
publication may be needed before that configuration is available. The release
workflow uses the shared npm publisher and resolves Bun and Node versions from
mise.toml through mise.
For a release, set the package version, keep bun.lock in sync, and pass
mise run check and mise run build. Commit and push the reviewed version, then
publish a GitHub release with a tag matching the version exactly, such as 0.1.0.
The publisher verifies the tag against the package version, validates and builds
the package, then publishes to npm with provenance.