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 installed and authenticated through
gh auth loginor its standard token environment variables. - 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.
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.
GhOptions accepts executable (default gh), cwd, env, stdin (string or
Uint8Array) and timeout (an Effect duration). 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 and stderr),
GhPlatformError (spawn or pipe failure), GhTimeoutError, or GhDecodeError
(invalid JSON or a schema mismatch). Command errors retain only the last 65,536
UTF-16 code units of stderr and flag truncation with 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.raw(request) returns buffered output, including empty or non-JSON responses.
Api.json(request, schema) decodes one JSON response. Every request specifies a
method, which the SDK passes explicitly to gh api.
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.raw({
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. Consumers can apply Effect
Schedule and Effect.retry to known-idempotent reads with a bounded policy and
their own transient-error classification. Decode errors, authentication failures
and check-status exits are not transient errors. Do not transparently replay a
stream after it has emitted output.
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.
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.
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 WorkflowInvalidOptions.
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.
- 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 install
mise run check
mise run buildTo 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.