Extract GitHub App lifecycle into circlesac/gh2-cli
- Status: planning (tracking issue)
- Owner: @ygpark80
- Target start: 2026-05-20
- Mirror project:
circlesac/slack2-cli
- Migration spec lives inline below ー gh2-cli implementation can refer to this issue without needing to read padawan source
Motivation
Padawan currently bundles GitHub App lifecycle commands under padawan github .... The commands are uneven:
padawan github auth login ー misnamed. It's register an existing app, not a session login. Confuses with gh auth login style.
padawan github app list ー reads only the local github.{local,prod}.json files. Not a real "list of apps I own"; redundant with a cat.
padawan github app create ー does real work (manifest flow + auto credential save).
padawan github app info ー real work (live GET /app via JWT).
Padawan is an agent consumer of these credentials. Like Slack lifecycle lives in slack2-cli, GitHub App lifecycle deserves its own home so padawan only reads the resulting JSON. Cleaner separation of concerns, plus room to grow (gh2 repo, gh2 install, gh2 workflow later).
v0 command surface
Resource-prefixed (gh2 app <verb>) so other resource trees can be added without breaking existing usage.
gh2 app login # capture github.com cookies (no Puppeteer)
gh2 app create <name> [--org <org>] [--stage <s>] # manifest flow → auto credential save
gh2 app list [--org <org>] # scrape settings/apps via cookies
gh2 app info <app-id> [--stage <s>] # live GET /app (JWT signed with PEM)
gh2 app register <app-id> --pem <path> --webhook-secret <s> [--stage <s>] # register existing app locally
gh2 app update <app-id> [--webhook-url] [--permissions] # JWT PATCH (esp. /app/hook/config)
gh2 app token <app-id> --installation <id> # POST /app/installations/.../access_tokens
gh2 app export <app-id> [--output FILE] [--stage <s>] # dump local JSON to stdout/file
Deferred (not in v0)
gh2 app rotate <app-id> --key|--secret ー needs web POST against settings page; fragile. For now: instruct user via GitHub.com UI.
gh2 app delete <app-id> ー same. GitHub UI only.
Reserved future resources
gh2 repo <verb>
gh2 install <verb> (app installations)
gh2 webhook <verb>
gh2 release <verb>
Auth strategy ー no headless browser
slack2-cli pattern: read browser cookies from the OS keystore (Chrome/Safari profile + macOS Keychain decryption). All subsequent calls are pure fetch() with Cookie: header. No Puppeteer/Playwright.
Two auth axes coexist:
| Channel |
Mechanism |
Used by |
| Web session (cookies) |
OS keystore extract → ~/.gh2/auth.json |
login, create callback, list |
| API (JWT) |
RS256 sign with app PEM |
info, update, token |
create keeps the hybrid pattern padawan uses today: open user's default browser to GitHub's manifest flow page → wait for a localhost callback → exchange code via POST /app-manifests/<code>/conversions. The "browser" is the user's real browser, not headless.
tsk-cli already handles GitHub PAT/JWT auth for Projects API ー reference that pattern for info/update/token JWT signing.
--org semantics
Only on commands where org context changes the result:
| Command |
--org? |
app login |
no (session covers all orgs the user belongs to) |
app create |
yes (personal vs org-owned app) |
app list |
yes (which scope to list) |
app info / register / update / token / export |
no (app-id is unique) |
Implementation spec (migration carrier)
This is the technical content currently inside padawan that needs to move to gh2-cli. Source paths are padawan paths (for diffing the deletion PR); the corresponding gh2 paths are suggested in parentheses.
1. JSON output contract (the line both sides honor)
The file produced by gh2 app create / gh2 app register ー and consumed by padawan ー has exactly four fields. Padawan's lib/integrations.ts:GitHubAppConfig is the read side; gh2 produces the same shape.
// Shape (TypeScript)
interface GitHubAppConfig {
appId: number; // numeric GitHub App ID
name: string; // display name as GitHub returned it
webhookSecret: string; // raw webhook signing secret (HMAC key for inbound webhook verification)
privateKey: string; // **base64-encoded PEM**, not raw PEM (decoded at runtime with Buffer.from(s, "base64").toString("utf-8"))
}
Real example (agents/yg2/github.prod.json, truncated):
{
"appId": 2864083,
"name": "circlesac-yg2",
"webhookSecret": "c0a7e2d7…",
"privateKey": "LS0tLS1CRUdJTiBSU0EgUFJJVkFURSBLRVktLS0t…"
}
Default file path: <agent-root>/github.<stage>.json (stage ∈ {"local", "prod"}). gh2 should default to writing there when cwd is inside an agent directory; allow --output <path> override; output to stdout if - passed.
2. JWT signing helper (RS256)
Used by every API command. From packages/cli/src/lib/github-api.ts:6-25.
import { createSign } from "node:crypto";
function base64url(input: string | Buffer): string {
const buf = typeof input === "string" ? Buffer.from(input) : input;
return buf.toString("base64url");
}
export function createGitHubJwt(appId: number, privateKeyPem: string): string {
const now = Math.floor(Date.now() / 1000);
const header = base64url(JSON.stringify({ alg: "RS256", typ: "JWT" }));
const payload = base64url(
JSON.stringify({ iss: appId, iat: now - 60, exp: now + 600 }),
);
const signingInput = `${header}.${payload}`;
const sign = createSign("RSA-SHA256");
sign.update(signingInput);
sign.end();
const signature = sign.sign(privateKeyPem, "base64url");
return `${signingInput}.${signature}`;
}
Notes:
iat is set 60s in the past to absorb minor clock skew (GitHub recommends this).
exp is 10 min from now; GitHub rejects JWTs older than 10 min.
- Accepts both PKCS#1 and PKCS#8 PEM (Node's
crypto.createSign handles both transparently).
iss MUST be the numeric app ID (not the slug).
3. Generic GitHub API fetch wrapper
From packages/cli/src/lib/github-api.ts:29-51. Used by every API call below.
async function githubApi(
url: string,
options: { method?: string; headers?: Record<string, string>; body?: unknown } = {},
): Promise<unknown> {
const res = await fetch(url, {
method: options.method ?? "GET",
headers: {
Accept: "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
...options.headers,
},
body: options.body ? JSON.stringify(options.body) : undefined,
});
const data = await res.json();
if (!res.ok) {
const msg = (data as { message?: string }).message ?? `HTTP ${res.status}`;
throw new GitHubApiError(`GitHub API error: ${msg}`, res.status);
}
return data;
}
Always include both Accept and X-GitHub-Api-Version headers. GitHub's API version is fixed at 2022-11-28 for current REST endpoints.
4. Manifest flow (gh2 app create)
Reference: padawan's packages/cli/src/commands/github/app/create.ts and lib/github-api.ts. 207 + ~50 lines.
4.1 Manifest shape
The JSON GitHub expects when creating an app via manifest:
function buildGitHubAppManifest(name: string, callbackUrl: string): Record<string, unknown> {
return {
name,
url: "https://github.com/circlesac/padawan", // ← change to a gh2-specific URL or accept --homepage
hook_attributes: { url: "https://example.com/webhook", active: false }, // placeholder; padawan re-patches via configureWebhook on first serve/deploy
redirect_url: callbackUrl, // http://localhost:8337/callback
callback_urls: [callbackUrl],
public: false, // private app
default_permissions: {
issues: "write",
pull_requests: "write",
contents: "read",
metadata: "read",
},
default_events: ["issues", "issue_comment", "pull_request", "push"],
};
}
Notes:
hook_attributes.url deliberately points at example.com with active: false. The real webhook URL is set later by the agent (padawan's configureWebhook at every serve/deploy). gh2 doesn't need to know the agent's tunnel hostname at create time.
default_permissions / default_events should be configurable via flags or a profile (--profile padawan-default) in a future iteration; for v0 the bot-comment-friendly defaults above are reasonable.
public: false matches the private-bot use case. Add --public flag if needed.
4.2 Auto-submit HTML
GitHub's manifest endpoint accepts a POST form, not a GET URL. So gh2 serves a tiny local page that auto-submits the form. From create.ts:18-40:
function buildAutoSubmitHtml(manifest: Record<string, unknown>, org?: string): string {
const action = org
? `https://github.com/organizations/${org}/settings/apps/new`
: "https://github.com/settings/apps/new";
const json = JSON.stringify(manifest);
const escaped = json.replace(/&/g, "&").replace(/"/g, """);
return `<!DOCTYPE html>
<html><head><title>gh2 — Creating GitHub App</title></head>
<body>
<h2>Redirecting to GitHub...</h2>
<p>If not redirected automatically, click the button below.</p>
<form method="post" action="${action}">
<input type="hidden" name="manifest" value="${escaped}">
<button type="submit">Create GitHub App</button>
</form>
<script>document.forms[0].submit();</script>
</body></html>`;
}
4.3 Local callback server
Listens on :8337 (constant), one-shot, 120s timeout. From create.ts:42-101:
const PORT = 8337;
function startCallbackServer(manifest: Record<string, unknown>, org?: string):
Promise<{ code: string; server: Server }> {
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
server.close();
reject(new Error("Timed out waiting for GitHub callback (120s)"));
}, 120_000);
const server = createServer((req, res) => {
const url = new URL(req.url ?? "/", `http://localhost:${PORT}`);
if (url.pathname === "/") {
res.writeHead(200, { "Content-Type": "text/html" });
res.end(buildAutoSubmitHtml(manifest, org));
return;
}
if (url.pathname === "/callback") {
const code = url.searchParams.get("code");
if (!code) { res.writeHead(400); res.end("Missing code"); return; }
res.writeHead(200, { "Content-Type": "text/html" });
res.end("<html><body><h2>Success!</h2><p>You can close this window.</p></body></html>");
clearTimeout(timeout);
resolve({ code, server });
return;
}
res.writeHead(404); res.end("Not found");
});
server.listen(PORT);
server.on("error", (err) => {
clearTimeout(timeout);
if ((err as NodeJS.ErrnoException).code === "EADDRINUSE") {
reject(new Error(`Port ${PORT} is already in use`));
} else { reject(err); }
});
});
}
Two routes: / (auto-submit page) and /callback?code=... (GitHub's redirect target).
4.4 Code → credentials exchange
After the callback arrives, exchange the temporary code for real credentials within 1 hour (GitHub's window). From lib/github-api.ts:67-74:
interface ManifestConversionResult {
id: number;
slug: string;
name: string;
pem: string; // raw PEM string (not base64)
webhook_secret: string;
client_id: string;
client_secret: string;
html_url: string;
owner: { login: string };
}
async function exchangeManifestCode(code: string): Promise<ManifestConversionResult> {
return await githubApi(
`https://api.github.com/app-manifests/${code}/conversions`,
{ method: "POST" },
);
}
This endpoint requires NO auth. The code itself is the credential. Single-use, ~1h TTL.
4.5 End-to-end create flow (pseudocode)
const callbackUrl = `http://localhost:8337/callback`;
const manifest = buildGitHubAppManifest(name, callbackUrl);
const serverPromise = startCallbackServer(manifest, org);
execSync(`open "http://localhost:8337/"`); // → user clicks "Create app" in their browser
const { code, server } = await serverPromise; // ← GitHub redirects with ?code=...
const result = await exchangeManifestCode(code); // POST conversions
// Persist:
await writeGitHubAppConfig({
appId: result.id,
name: result.name,
webhookSecret: result.webhook_secret,
privateKey: Buffer.from(result.pem).toString("base64"),
});
server.close();
// Open install URL so user can install on a repo:
const installUrl = `${result.html_url}/installations/new${org ? `/permissions?target_id=${org}` : ""}`;
execSync(`open "${installUrl}"`);
5. gh2 app info ー live API view
From commands/github/app/info.ts + lib/github-api.ts:91-99.
interface GitHubAppInfo {
id: number;
slug: string;
name: string;
description: string | null;
html_url: string;
created_at: string;
updated_at: string;
permissions: Record<string, string>;
events: string[];
installations_count: number;
}
async function getAppInfo(appId: number, privateKeyPem: string): Promise<GitHubAppInfo> {
const jwt = createGitHubJwt(appId, privateKeyPem);
return await githubApi("https://api.github.com/app", {
headers: { Authorization: `Bearer ${jwt}` },
});
}
Endpoint: GET https://api.github.com/app with Authorization: Bearer <JWT>. Returns the schema above.
Output formats (mirror padawan): table (default), JSON (--output json).
6. gh2 app register ー register existing
From commands/github/auth/login.ts. Three required args: <app-id> (positional), --pem <path> (file path), --webhook-secret <secret>. Optional --stage <local|prod> (default prod).
Flow:
- Read PEM file content
- Call
getAppInfo(appId, pem) to verify the PEM matches the app ID (fail-fast on mismatched credentials)
- Write
github.<stage>.json with the four fields, base64-encoding the PEM
const pem = await readFile(resolve(args.pem), "utf-8");
const info = await getAppInfo(appId, pem); // throws if PEM/appId mismatch → user sees clear error
await writeFile(`github.${stage}.json`, JSON.stringify({
appId,
name: info.name,
webhookSecret: args.webhookSecret,
privateKey: Buffer.from(pem).toString("base64"),
}, null, 2) + "\n");
7. gh2 app update --webhook-url <url>
From padawan's lib/github-api.ts:103-122. The smallest possible update operation: PATCH the webhook URL + secret in one call.
async function configureWebhook(
appId: number,
privateKeyPem: string,
webhookUrl: string,
webhookSecret: string,
): Promise<void> {
const jwt = createGitHubJwt(appId, privateKeyPem);
await githubApi("https://api.github.com/app/hook/config", {
method: "PATCH",
headers: {
Authorization: `Bearer ${jwt}`,
"Content-Type": "application/json",
},
body: {
url: webhookUrl,
content_type: "json",
secret: webhookSecret,
},
});
}
In v0, support at minimum --webhook-url. Future flags: --permissions key=write,key=read, --events comma,separated.
NOTE: this same function stays in padawan (called from padawan serve / padawan deploy). See "Padawan cleanup" §11 below ー that's the one helper padawan keeps from github-api.ts.
8. gh2 app token --installation <id>
Not currently in padawan ー new in gh2 v0 for debugging. Endpoint: POST https://api.github.com/app/installations/<installation-id>/access_tokens with JWT. Returns short-lived installation access token (1 hour). Useful for curl -H "Authorization: Bearer <token>" https://api.github.com/... ad-hoc API calls.
async function createInstallationToken(appId: number, pem: string, installationId: number) {
const jwt = createGitHubJwt(appId, pem);
return await githubApi(
`https://api.github.com/app/installations/${installationId}/access_tokens`,
{ method: "POST", headers: { Authorization: `Bearer ${jwt}` } },
);
// Returns: { token: "ghs_...", expires_at: "2026-...Z", permissions: {...}, repository_selection: "selected" }
}
9. gh2 app login ー cookie capture (slack2 pattern)
Mirror slack2-cli's mechanism. On macOS:
- Chrome cookies live at
~/Library/Application Support/Google/Chrome/<Profile>/Cookies (SQLite)
- Encrypted blob fields require macOS Keychain decryption (
security find-generic-password -a Chrome -s "Chrome Safe Storage")
- Safari cookies at
~/Library/Containers/com.apple.Safari/Data/Library/Cookies/Cookies.binarycookies (binary format, harder)
Recommend: target Chrome first (largest user base), Safari/Firefox follow-ups.
Look at slack2-cli for the exact library + decrypt pseudocode. Likely candidates: chrome-cookies-secure, or direct better-sqlite3 + keytar + crypto.pbkdf2Sync.
Store extracted cookies at ~/.gh2/auth.json:
{
"host": "github.com",
"cookies": [
{ "name": "user_session", "value": "...", "expires": 1234567890 },
{ "name": "_gh_sess", "value": "...", "expires": 1234567890 }
],
"capturedAt": "2026-05-19T17:00:00Z"
}
All subsequent web-session commands (list, create callback if needed, delete if added later) build a Cookie: header from this file.
10. gh2 app list ー scrape settings/apps
GitHub has no API to list apps you own (verified across REST + GraphQL). Pure-network alternative = HTTP fetch + HTML parse.
async function listApps(orgOrUser?: string): Promise<AppRow[]> {
const url = orgOrUser
? `https://github.com/organizations/${orgOrUser}/settings/apps`
: `https://github.com/settings/apps`;
const cookies = await loadCookies(); // ~/.gh2/auth.json
const res = await fetch(url, { headers: { Cookie: serializeCookies(cookies) } });
const html = await res.text();
return parseAppsListHtml(html); // cheerio or regex
}
HTML structure (verify before implementation ー GitHub may change anchor patterns):
- Each app card has an anchor pointing to
/apps/<slug> (public) and a settings link /settings/apps/<slug>
- App name in the card title
- "X installations" badge usually present
Output columns: name, slug, installations count, settings URL.
Fragility note: if GitHub redesigns the page, this breaks. Same risk profile as slack2's web scraping. Acceptable for v0; reduce scope if it ever rots.
11. Padawan cleanup PR
After gh2-cli ships, this PR lands on padawan:
Files to DELETE
packages/cli/src/commands/github/
├── app/
│ ├── create.ts
│ ├── index.ts
│ ├── info.ts
│ └── list.ts
├── auth/
│ ├── index.ts
│ └── login.ts
└── index.ts
Files to MODIFY
packages/cli/src/index.ts: remove githubCommand import + subCommands.github.
packages/cli/src/lib/github-api.ts: keep only configureWebhook + the JWT helper it needs (createGitHubJwt, base64url) + the minimal githubApi fetch wrapper for the PATCH. Strip everything else (getAppInfo, GitHubAppInfo, exchangeManifestCode, ManifestConversionResult, buildGitHubAppManifest). Expected size after trim: ~60 lines from ~145.
packages/cli/src/lib/integrations.ts: keep as is. GitHubAppConfig / readGitHubAppConfig stay (padawan still consumes the JSON shape). Optionally also keep writeGitHubAppConfig, but it's no longer called once github commands are gone ー safe to remove.
packages/cli/src/commands/init.ts:68-70: the "Next steps" log refers to padawan github app create. Change to gh2 app create <name> --stage local (mirroring the existing slack2 create / install lines).
README.md: every reference to padawan github ... becomes gh2 app .... Specifically:
- "Setup §3 GitHub channel" gets rewritten in slack2-style (an external CLI, install via
brew install circlesac/tap/gh2, then gh2 app create mybot --stage local).
- "CLI surface" table loses the four
padawan github ... rows.
- Any inline
padawan github app create / list / info and padawan github auth login references swap to gh2 app create / list / info / register.
CLAUDE.md: any padawan github ... reference (none currently, but verify) → gh2 app ....
Files to LEAVE ALONE
packages/cli/src/commands/serve.ts and deploy.ts: they import + call configureWebhook from lib/github-api.ts. That import path doesn't change; only the surrounding code is shrunk.
packages/temple/src/github.ts + github-jwt.ts: runtime-side webhook handler, unrelated to lifecycle CLI.
Verification after cleanup
bun test # packages/temple + packages/cli
padawan --help # github subcommand absent from output
padawan serve # still PATCHes webhook URL on start (regression check)
Open questions
Non-goals (v0)
- Web UI scraping for rotate/delete (deferred; GitHub UI instructions only).
- App marketplace operations.
- App installation management (separate
gh2 install tree, future).
- Pre-built native binaries (publish path can come later; dev install via
bun link only).
Related
12. Padawan-side removal ー additional files discovered
Files removed during the padawan cleanup PR that gh2-cli will likely need on its own side:
packages/cli/src/lib/args.ts ー commonArgs helper (--output json|table). Was only used by github app info / github app list. gh2-cli needs an equivalent for the --output flag pattern shared across info / list / export.
packages/cli/src/lib/output.ts ー getOutputFormat() + printOutput() (table renderer + JSON pass-through). Same scope. Port to gh2-cli.
packages/cli/src/lib/integrations.ts:writeGitHubAppConfig ー was the sole writer of github.<stage>.json on padawan side; unused after create / auth login removal, so dropped. gh2-cli's app create and app register must write the same JSON shape (see §1).
Padawan-side github-api.ts final size: ~65 lines (down from 145). Keeps only configureWebhook + private createGitHubJwt + private base64url. Called by padawan serve and padawan deploy only.
Post-cleanup verification: padawan --help no longer lists github, both test suites green (temple 127/0, cli 21/0).
Extract GitHub App lifecycle into
circlesac/gh2-clicirclesac/slack2-cliMotivation
Padawan currently bundles GitHub App lifecycle commands under
padawan github .... The commands are uneven:padawan github auth loginー misnamed. It's register an existing app, not a session login. Confuses withgh auth loginstyle.padawan github app listー reads only the localgithub.{local,prod}.jsonfiles. Not a real "list of apps I own"; redundant with acat.padawan github app createー does real work (manifest flow + auto credential save).padawan github app infoー real work (liveGET /appvia JWT).Padawan is an agent consumer of these credentials. Like Slack lifecycle lives in
slack2-cli, GitHub App lifecycle deserves its own home so padawan only reads the resulting JSON. Cleaner separation of concerns, plus room to grow (gh2 repo,gh2 install,gh2 workflowlater).v0 command surface
Resource-prefixed (
gh2 app <verb>) so other resource trees can be added without breaking existing usage.Deferred (not in v0)
gh2 app rotate <app-id> --key|--secretー needs web POST against settings page; fragile. For now: instruct user via GitHub.com UI.gh2 app delete <app-id>ー same. GitHub UI only.Reserved future resources
gh2 repo <verb>gh2 install <verb>(app installations)gh2 webhook <verb>gh2 release <verb>Auth strategy ー no headless browser
slack2-cli pattern: read browser cookies from the OS keystore (Chrome/Safari profile + macOS Keychain decryption). All subsequent calls are pure
fetch()withCookie:header. No Puppeteer/Playwright.Two auth axes coexist:
~/.gh2/auth.jsonlogin,createcallback,listinfo,update,tokencreatekeeps the hybrid pattern padawan uses today:openuser's default browser to GitHub's manifest flow page → wait for a localhost callback → exchange code viaPOST /app-manifests/<code>/conversions. The "browser" is the user's real browser, not headless.tsk-clialready handles GitHub PAT/JWT auth for Projects API ー reference that pattern forinfo/update/tokenJWT signing.--orgsemanticsOnly on commands where org context changes the result:
--org?app loginapp createapp listapp info/register/update/token/exportImplementation spec (migration carrier)
This is the technical content currently inside padawan that needs to move to gh2-cli. Source paths are padawan paths (for diffing the deletion PR); the corresponding gh2 paths are suggested in parentheses.
1. JSON output contract (the line both sides honor)
The file produced by
gh2 app create/gh2 app registerー and consumed by padawan ー has exactly four fields. Padawan'slib/integrations.ts:GitHubAppConfigis the read side; gh2 produces the same shape.Real example (
agents/yg2/github.prod.json, truncated):{ "appId": 2864083, "name": "circlesac-yg2", "webhookSecret": "c0a7e2d7…", "privateKey": "LS0tLS1CRUdJTiBSU0EgUFJJVkFURSBLRVktLS0t…" }Default file path:
<agent-root>/github.<stage>.json(stage ∈ {"local", "prod"}). gh2 should default to writing there when cwd is inside an agent directory; allow--output <path>override; output to stdout if-passed.2. JWT signing helper (RS256)
Used by every API command. From
packages/cli/src/lib/github-api.ts:6-25.Notes:
iatis set 60s in the past to absorb minor clock skew (GitHub recommends this).expis 10 min from now; GitHub rejects JWTs older than 10 min.crypto.createSignhandles both transparently).issMUST be the numeric app ID (not the slug).3. Generic GitHub API fetch wrapper
From
packages/cli/src/lib/github-api.ts:29-51. Used by every API call below.Always include both
AcceptandX-GitHub-Api-Versionheaders. GitHub's API version is fixed at2022-11-28for current REST endpoints.4. Manifest flow (
gh2 app create)Reference: padawan's
packages/cli/src/commands/github/app/create.tsandlib/github-api.ts. 207 + ~50 lines.4.1 Manifest shape
The JSON GitHub expects when creating an app via manifest:
Notes:
hook_attributes.urldeliberately points at example.com withactive: false. The real webhook URL is set later by the agent (padawan'sconfigureWebhookat everyserve/deploy). gh2 doesn't need to know the agent's tunnel hostname at create time.default_permissions/default_eventsshould be configurable via flags or a profile (--profile padawan-default) in a future iteration; for v0 the bot-comment-friendly defaults above are reasonable.public: falsematches the private-bot use case. Add--publicflag if needed.4.2 Auto-submit HTML
GitHub's manifest endpoint accepts a POST form, not a GET URL. So gh2 serves a tiny local page that auto-submits the form. From
create.ts:18-40:4.3 Local callback server
Listens on
:8337(constant), one-shot, 120s timeout. Fromcreate.ts:42-101:Two routes:
/(auto-submit page) and/callback?code=...(GitHub's redirect target).4.4 Code → credentials exchange
After the callback arrives, exchange the temporary
codefor real credentials within 1 hour (GitHub's window). Fromlib/github-api.ts:67-74:This endpoint requires NO auth. The code itself is the credential. Single-use, ~1h TTL.
4.5 End-to-end create flow (pseudocode)
5.
gh2 app infoー live API viewFrom
commands/github/app/info.ts+lib/github-api.ts:91-99.Endpoint:
GET https://api.github.com/appwithAuthorization: Bearer <JWT>. Returns the schema above.Output formats (mirror padawan): table (default), JSON (
--output json).6.
gh2 app registerー register existingFrom
commands/github/auth/login.ts. Three required args:<app-id>(positional),--pem <path>(file path),--webhook-secret <secret>. Optional--stage <local|prod>(defaultprod).Flow:
getAppInfo(appId, pem)to verify the PEM matches the app ID (fail-fast on mismatched credentials)github.<stage>.jsonwith the four fields, base64-encoding the PEM7.
gh2 app update --webhook-url <url>From padawan's
lib/github-api.ts:103-122. The smallest possible update operation: PATCH the webhook URL + secret in one call.In v0, support at minimum
--webhook-url. Future flags:--permissions key=write,key=read,--events comma,separated.NOTE: this same function stays in padawan (called from
padawan serve/padawan deploy). See "Padawan cleanup" §11 below ー that's the one helper padawan keeps fromgithub-api.ts.8.
gh2 app token --installation <id>Not currently in padawan ー new in gh2 v0 for debugging. Endpoint:
POST https://api.github.com/app/installations/<installation-id>/access_tokenswith JWT. Returns short-lived installation access token (1 hour). Useful forcurl -H "Authorization: Bearer <token>" https://api.github.com/...ad-hoc API calls.9.
gh2 app loginー cookie capture (slack2 pattern)Mirror slack2-cli's mechanism. On macOS:
~/Library/Application Support/Google/Chrome/<Profile>/Cookies(SQLite)security find-generic-password -a Chrome -s "Chrome Safe Storage")~/Library/Containers/com.apple.Safari/Data/Library/Cookies/Cookies.binarycookies(binary format, harder)Recommend: target Chrome first (largest user base), Safari/Firefox follow-ups.
Look at slack2-cli for the exact library + decrypt pseudocode. Likely candidates:
chrome-cookies-secure, or directbetter-sqlite3+keytar+crypto.pbkdf2Sync.Store extracted cookies at
~/.gh2/auth.json:{ "host": "github.com", "cookies": [ { "name": "user_session", "value": "...", "expires": 1234567890 }, { "name": "_gh_sess", "value": "...", "expires": 1234567890 } ], "capturedAt": "2026-05-19T17:00:00Z" }All subsequent web-session commands (
list,createcallback if needed,deleteif added later) build aCookie:header from this file.10.
gh2 app listー scrapesettings/appsGitHub has no API to list apps you own (verified across REST + GraphQL). Pure-network alternative = HTTP fetch + HTML parse.
HTML structure (verify before implementation ー GitHub may change anchor patterns):
/apps/<slug>(public) and a settings link/settings/apps/<slug>Output columns: name, slug, installations count, settings URL.
Fragility note: if GitHub redesigns the page, this breaks. Same risk profile as slack2's web scraping. Acceptable for v0; reduce scope if it ever rots.
11. Padawan cleanup PR
After gh2-cli ships, this PR lands on padawan:
Files to DELETE
Files to MODIFY
packages/cli/src/index.ts: removegithubCommandimport +subCommands.github.packages/cli/src/lib/github-api.ts: keep onlyconfigureWebhook+ the JWT helper it needs (createGitHubJwt,base64url) + the minimalgithubApifetch wrapper for the PATCH. Strip everything else (getAppInfo,GitHubAppInfo,exchangeManifestCode,ManifestConversionResult,buildGitHubAppManifest). Expected size after trim: ~60 lines from ~145.packages/cli/src/lib/integrations.ts: keep as is.GitHubAppConfig/readGitHubAppConfigstay (padawan still consumes the JSON shape). Optionally also keepwriteGitHubAppConfig, but it's no longer called oncegithubcommands are gone ー safe to remove.packages/cli/src/commands/init.ts:68-70: the "Next steps" log refers topadawan github app create. Change togh2 app create <name> --stage local(mirroring the existingslack2 create / installlines).README.md: every reference topadawan github ...becomesgh2 app .... Specifically:brew install circlesac/tap/gh2, thengh2 app create mybot --stage local).padawan github ...rows.padawan github app create / list / infoandpadawan github auth loginreferences swap togh2 app create / list / info / register.CLAUDE.md: anypadawan github ...reference (none currently, but verify) →gh2 app ....Files to LEAVE ALONE
packages/cli/src/commands/serve.tsanddeploy.ts: they import + callconfigureWebhookfromlib/github-api.ts. That import path doesn't change; only the surrounding code is shrunk.packages/temple/src/github.ts+github-jwt.ts: runtime-side webhook handler, unrelated to lifecycle CLI.Verification after cleanup
Open questions
gh2 app loginbrowser preference: explicit--browser chrome|safari|firefoxor auto-detect like slack2.gh2 app listHTML parsing: cheerio vs regex. Lean cheerio (slack2 likely uses it).gh2 app login?gh2 app create: default toagents/<name>/github.<stage>.jsonif cwd is inside an agent dir? Or always require--output+--stage?gh2 app createdefault manifest: keep padawan's permissions (issues/pr write + contents/metadata read) or start from zero and require explicit--permissionsflag?agents/<name>/via thefindAgentRootwalker padawan uses), or should it be agent-agnostic and only write to--output <path>ー leaving "where the file goes" to the caller?Non-goals (v0)
gh2 installtree, future).bun linkonly).Related
circlesac/slack2-cliー mirror project for Slack; cookie extraction + auto-submit form pattern referencecirclesac/tsk-cliー GitHub PAT/JWT auth wiring referencecirclesac/cgrokー separate tunnel CLI, similar separation-of-concerns precedent12. Padawan-side removal ー additional files discovered
Files removed during the padawan cleanup PR that gh2-cli will likely need on its own side:
packages/cli/src/lib/args.tsーcommonArgshelper (--output json|table). Was only used bygithub app info/github app list. gh2-cli needs an equivalent for the--outputflag pattern shared acrossinfo/list/export.packages/cli/src/lib/output.tsーgetOutputFormat()+printOutput()(table renderer + JSON pass-through). Same scope. Port to gh2-cli.packages/cli/src/lib/integrations.ts:writeGitHubAppConfigー was the sole writer ofgithub.<stage>.jsonon padawan side; unused aftercreate/auth loginremoval, so dropped. gh2-cli'sapp createandapp registermust write the same JSON shape (see §1).Padawan-side
github-api.tsfinal size: ~65 lines (down from 145). Keeps onlyconfigureWebhook+ privatecreateGitHubJwt+ privatebase64url. Called bypadawan serveandpadawan deployonly.Post-cleanup verification:
padawan --helpno longer listsgithub, both test suites green (temple127/0,cli21/0).