sidecar-github

August 20, 2026 · View on GitHub

Status: proposed Written: 2026-08-20 Related: sidecar-jira · terminal resource providers · protocol v1

Decision

Build sidecar-github as the second Sidecar addition, copied from the sidecar-jira layout. Sidecar does not import it. GitHub credentials never enter Sidecar configuration.

GitHub locators in real terminals are almost always URLs, not owner/repo#n. Sidecar's built-in URL matcher currently wins those clicks and opens the browser. A GitHub provider that only matches the short form will almost never fire. The host therefore gains a narrow URL yield: after a built-in URL span is found, a configured provider may claim that URL and open a Resource pane instead.

The click journey is: Sidecar click opens the pane; o opens GitHub in the browser; Cmd-click in Ghostty/iTerm may still open the browser. Do not steal the emulator's modifier click.

The protocol identifier stays sidecar.terminal-resource/v1. Yield is host configuration, not a protocol revision.

User journey

Steel thread: one pull request URL, one Sidecar click, one useful pane.

  1. Marcus installs sidecar-github and configures a profile whose allowlist includes marcus (and later haplab). The API token lives in GITHUB_TOKEN, never in a file.
  2. Sidecar is configured with one provider instance, passEnv: ["GITHUB_TOKEN"], and claimHosts: ["github.com"].
  3. An agent prints https://github.com/marcus/sidecar/pull/88. Sidecar underlines it. Recognition is local. No GitHub call happens yet.
  4. A plain click in Sidecar opens or focuses a Resource pane. Sidecar invokes sidecar-github with the provider instance, matcher ID, and the URL as the locator. Loading, then the PR title, open/draft/merged, author, and body.
  5. o opens the GitHub page (sourceUrl). r refreshes. Same pane keys as Jira.
  6. The same PR printed as marcus/sidecar#88 opens the same tab. Identity is marcus/sidecar#88 even when the locator was a URL, so the two forms merge.
  7. Cmd-click in Ghostty still goes to the browser. That path is the emulator's.
  8. A URL for some other GitHub org stays a normal browser link.
  9. An agent can open the pane without a mouse: sidecar open --provider github https://github.com/marcus/sidecar/pull/88 The provider CLI is still the structured path for the GitHub data itself.

The journey is the same from project Workspace and from global Workspaces.

Why URL yield

Built-in matchers (URL, file, td, git diff) keep precedence. External matchers run afterward, first-wins. A provider pattern cannot take https://github.com/... today because the URL span already claimed those columns.

That rule is load-bearing: a greedy \S+ provider must not steal every URL. Yield does not invert precedence. It reclassifies an already found URL span when all of these hold:

  • the Sidecar instance lists the URL host in claimHosts
  • a matcher from that instance's successful describe matches the entire URL string
  • the matcher's pattern still comes from the provider allowlist, so https://github.com/random-org/foo/issues/1 is not claimed

Unclaimed URLs keep PlanOpenURL (browser). Existing URL/file/td/diff tests stay green.

claimHosts is Sidecar instance config, not a protocol field. The locator handed to resolve is the URL string the scanner already had.

Host work (in ~/code/sidecar)

This is a prerequisite. Without it, GitHub URLs keep opening the browser and the provider only fires on owner/repo#n.

  • Add optional claimHosts to a terminal-resource provider instance.
  • After scanURLs, for each URL span whose host is in some instance's claimHosts, test that instance's compiled matchers against the full URL value. On a whole-string match, rewrite the span to KindResource with that instance and matcher ID.
  • Greedy matchers still cannot claim a URL whose host is not in claimHosts. Keep TestScanWithBuiltinsKeepPrecedenceOverExternalMatchers.
  • Add tests: claimed github.com PR URL becomes a resource; unlisted host stays a URL; substring match of a URL does not claim it.
  • OSC-8: claimed GitHub URLs may still carry OSC-8 pointing at the GitHub URL so Cmd-click remains a browser escape hatch. Sidecar's unmodified click follows the resource span, not OSC-8.
  • sidecar open --provider github <url> already works once the provider resolves; no new open-target kind.

Do not convert the URL built-in onto the executable protocol. Do not share project-root or "current repo" with the matcher. Bare #88 stays inert.

Provider work (this repo)

Copy sidecar-jira's package map:

PackageOwns
internal/protocolWire types. Transport-free. Same v1 shapes as sidecar-jira.
internal/configProvider-owned profiles. No credentials on disk. Matcher generation from the allowlist.
internal/githubNarrow REST client and issue/PR → resource mapping.
internal/cliissue show, doctor, sidecar-provider.

Commands: sidecar-github issue show <locator>, sidecar-github doctor, sidecar-github sidecar-provider. --json on the human/agent surface. Protocol mode: one JSON object in, one JSON object out, exit 0 for typed success and typed failure.

Config

$XDG_CONFIG_HOME/sidecar-github/config.json, else ~/.config/sidecar-github/config.json:

{
  "defaultProfile": "work",
  "profiles": {
    "work": {
      "allow": ["marcus", "marcus/sidecar"],
      "tokenEnv": "GITHUB_TOKEN"
    }
  }
}

allow is an allowlist of owner or owner/repo. It is the only input to the matcher patterns. A locator outside it is refused without a network call. Owner-only entries cover every repo under that owner. Token is read from the named env var, never logged, never written.

Sidecar instance:

{
  "id": "github",
  "command": ["sidecar-github", "sidecar-provider", "--profile", "work"],
  "passEnv": ["GITHUB_TOKEN"],
  "claimHosts": ["github.com"],
  "enabled": true,
  "timeout": "10s"
}

Locators and identity

describe declares two matcher IDs (stable; renaming orphans tabs):

  • github-urlhttps?://(?:www\.)?github\.com/(?:allow)/.../(?:issues|pull)/[1-9][0-9]*
  • github-ref(?:allow)#(?:[1-9][0-9]*) where each allow entry is either owner/repo or, for an owner-only entry, owner/[^#\s/]+

Also match scheme-less github.com/owner/repo/pull/N on github-url (or a third matcher if keeping URL-only on github-url is cleaner). That form is not a built-in URL span.

resolve accepts any of those as params.locator, parses owner/repo/number, re-checks the allowlist by exact owner or owner/repo, then fetches.

Canonical identity is owner/repo#N. GitHub shares one number space across issues and PRs, so a pull URL and an issue URL for the same number are the same resource. sourceUrl is the GitHub html URL from the API, not a reconstruction that might pick /issues/ vs /pull/ wrong.

Mapping

Use GET /repos/{owner}/{repo}/issues/{n}. That endpoint returns issues and PRs. If pull_request is present, subtitle is Pull request; otherwise Issue.

Resource fieldSource
identityowner/repo#number
titletitle
subtitleIssue / Pull request
statusopen / closed / merged / draft, with protocol tones
fieldsAuthor, plus Assignees / Labels / Reviewers when present
bodyissue body as markdown; GitHub markdown is already text
sourceUrlhtml_url
updatedAtupdated_at (already RFC 3339)
freshForSeconds60, same as jira

Do not include email addresses. Draft and merged require the pull-request payload (merged_at, draft); if the issues response is enough for open/closed only, fetch GET /repos/{owner}/{repo}/pulls/{n} when pull_request is set rather than guessing.

Sanitize body markdown the way the host will: links may become inactive in the pane. That is fine; o is the URL action.

Errors

Map honestly, retryable per response, setupHint one line.

GitHubProtocol
200success
401unauthorized
403 (rate limit or forbidden)rate_limited if secondary-rate / Retry-After / X-RateLimit-Remaining: 0, else forbidden
404not_found (GitHub also 404s private repos the token cannot see)
5xxunavailable
timeout / transportunavailable
missing config / empty tokeninvalid_config
bad protocol / unknown matcher / empty locatorinvalid_request

Budget resolve inside deadlineMs the way sidecar-jira does. describe is local and never touches the network.

doctor probes the credential (GET /user) then each allow entry (GET /users/{owner} or GET /repos/{owner}/{repo}).

Tests

No live GitHub, no credentials. httptest plus fixtures captured from public repos (or a throwaway) with no private data. Cover: URL locator, owner/repo#n locator, identity merge, allowlist refusal without a network call, 404, empty token, deadline too short, describe makes no HTTP call.

Scope

M0: GitHub.com REST, issues and pull requests, API-token auth, read-only.

Out of scope until something in the steel thread forces it:

  • GitHub Enterprise Server (extra baseUrl on the profile)
  • commits, Actions runs, checks, discussions, projects
  • bare #123 / implicit current-repo context
  • write actions (comment, review, merge)
  • GraphQL
  • gh as a runtime dependency (document gh auth token as a way to populate GITHUB_TOKEN if useful)

Work sequence

  1. Sidecar URL yield in ~/code/sidecar: claimHosts, reclassify tests, OSC-8 decision, existing URL tests still green.
  2. This repo, steel thread: config + matcher generation + GitHub issue fetch + issue show against httptest for one PR fixture.
  3. sidecar-provider: describe, resolve, deadline budget, typed errors, process-contract test (one JSON in, one JSON out, exit 0).
  4. doctor and README (install, config, Sidecar wiring, both locators).
  5. Proof: real Sidecar, claimHosts set, click https://github.com/marcus/sidecar/pull/N, pane then o. Same PR as marcus/sidecar#N focuses that tab. A non-allowlisted GitHub URL still opens the browser.
  6. Link the repo from Sidecar's additions table once it is public.

Acceptance

  • Plain click on an allowlisted GitHub issue/PR URL in Sidecar opens a Resource pane, not the browser.
  • o on that pane opens the GitHub html URL.
  • marcus/sidecar#N and the matching URL are one tab.
  • Unallowlisted github.com URLs, and every non-GitHub URL, still open the browser.
  • Cmd-click in Ghostty is unchanged (browser).
  • describe does no I/O. Tokens never appear in config, logs, or error messages.
  • Tests pass without network or credentials.
  • Sidecar does not import this module; this module does not import Sidecar.