Skip to content

Extract GitHub App lifecycle into circlesac/gh2-cli #1

Description

@ygpark80

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, "&amp;").replace(/"/g, "&quot;");
  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:

  1. Read PEM file content
  2. Call getAppInfo(appId, pem) to verify the PEM matches the app ID (fail-fast on mismatched credentials)
  3. 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

  • Cookie extraction lib: same as slack2-cli? Verify before scaffolding.
  • gh2 app login browser preference: explicit --browser chrome|safari|firefox or auto-detect like slack2.
  • gh2 app list HTML parsing: cheerio vs regex. Lean cheerio (slack2 likely uses it).
  • PAT fallback: if cookies expired and no API alternative, should commands prompt for PAT? Or always require fresh gh2 app login?
  • Output destination for gh2 app create: default to agents/<name>/github.<stage>.json if cwd is inside an agent dir? Or always require --output + --stage?
  • gh2 app create default manifest: keep padawan's permissions (issues/pr write + contents/metadata read) or start from zero and require explicit --permissions flag?
  • Should gh2 know about agent dirs (look up agents/<name>/ via the findAgentRoot walker 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)

  • 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.tscommonArgs 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.tsgetOutputFormat() + 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).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions