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.
- Marcus installs
sidecar-githuband configures a profile whose allowlist includesmarcus(and laterhaplab). The API token lives inGITHUB_TOKEN, never in a file. - Sidecar is configured with one provider instance,
passEnv: ["GITHUB_TOKEN"], andclaimHosts: ["github.com"]. - An agent prints
https://github.com/marcus/sidecar/pull/88. Sidecar underlines it. Recognition is local. No GitHub call happens yet. - A plain click in Sidecar opens or focuses a Resource pane. Sidecar invokes
sidecar-githubwith the provider instance, matcher ID, and the URL as the locator. Loading, then the PR title, open/draft/merged, author, and body. oopens the GitHub page (sourceUrl).rrefreshes. Same pane keys as Jira.- The same PR printed as
marcus/sidecar#88opens the same tab. Identity ismarcus/sidecar#88even when the locator was a URL, so the two forms merge. - Cmd-click in Ghostty still goes to the browser. That path is the emulator's.
- A URL for some other GitHub org stays a normal browser link.
- An agent can open the pane without a mouse:
sidecar open --provider github https://github.com/marcus/sidecar/pull/88The 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
describematches the entire URL string - the matcher's pattern still comes from the provider allowlist, so
https://github.com/random-org/foo/issues/1is 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
claimHoststo a terminal-resource provider instance. - After
scanURLs, for each URL span whose host is in some instance'sclaimHosts, test that instance's compiled matchers against the full URL value. On a whole-string match, rewrite the span toKindResourcewith that instance and matcher ID. - Greedy matchers still cannot claim a URL whose host is not in
claimHosts. KeepTestScanWithBuiltinsKeepPrecedenceOverExternalMatchers. - Add tests: claimed
github.comPR 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:
| Package | Owns |
|---|---|
internal/protocol | Wire types. Transport-free. Same v1 shapes as sidecar-jira. |
internal/config | Provider-owned profiles. No credentials on disk. Matcher generation from the allowlist. |
internal/github | Narrow REST client and issue/PR → resource mapping. |
internal/cli | issue 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-url—https?://(?:www\.)?github\.com/(?:allow)/.../(?:issues|pull)/[1-9][0-9]*github-ref—(?:allow)#(?:[1-9][0-9]*)where each allow entry is eitherowner/repoor, 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 field | Source |
|---|---|
| identity | owner/repo#number |
| title | title |
| subtitle | Issue / Pull request |
| status | open / closed / merged / draft, with protocol tones |
| fields | Author, plus Assignees / Labels / Reviewers when present |
| body | issue body as markdown; GitHub markdown is already text |
| sourceUrl | html_url |
| updatedAt | updated_at (already RFC 3339) |
| freshForSeconds | 60, 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.
| GitHub | Protocol |
|---|---|
| 200 | success |
| 401 | unauthorized |
| 403 (rate limit or forbidden) | rate_limited if secondary-rate / Retry-After / X-RateLimit-Remaining: 0, else forbidden |
| 404 | not_found (GitHub also 404s private repos the token cannot see) |
| 5xx | unavailable |
| timeout / transport | unavailable |
| missing config / empty token | invalid_config |
| bad protocol / unknown matcher / empty locator | invalid_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
baseUrlon the profile) - commits, Actions runs, checks, discussions, projects
- bare
#123/ implicit current-repo context - write actions (comment, review, merge)
- GraphQL
ghas a runtime dependency (documentgh auth tokenas a way to populateGITHUB_TOKENif useful)
Work sequence
- Sidecar URL yield in
~/code/sidecar:claimHosts, reclassify tests, OSC-8 decision, existing URL tests still green. - This repo, steel thread: config + matcher generation + GitHub issue
fetch +
issue showagainsthttptestfor one PR fixture. sidecar-provider: describe, resolve, deadline budget, typed errors, process-contract test (one JSON in, one JSON out, exit 0).doctorand README (install, config, Sidecar wiring, both locators).- Proof: real Sidecar,
claimHostsset, clickhttps://github.com/marcus/sidecar/pull/N, pane theno. Same PR asmarcus/sidecar#Nfocuses that tab. A non-allowlisted GitHub URL still opens the browser. - 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.
oon that pane opens the GitHub html URL.marcus/sidecar#Nand the matching URL are one tab.- Unallowlisted
github.comURLs, and every non-GitHub URL, still open the browser. - Cmd-click in Ghostty is unchanged (browser).
describedoes 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.