airgate 0.1.3 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -38,6 +38,7 @@ airgate run "get the failing tests passing" # a fresh pod; the result comes ba
38
38
  airgate skill install # teach Claude Code when and how to use airgate
39
39
  claude mcp add airgate -- airgate mcp # airgate as tools (pod_run, pod_up, pod_down…)
40
40
  airgate models connect claude --laptop # a token from `claude setup-token`, kept on this laptop
41
+ airgate models connect chatgpt --laptop # or Codex on your ChatGPT plan
41
42
  ```
42
43
 
43
44
  The last line lets this laptop sign the pods' model calls while airgate runs, so tasks run without
package/dist/airgate.mjs CHANGED
@@ -301,6 +301,72 @@ function dotenvExample(names) {
301
301
  return names.map((n2) => `${n2}=`).join("\n") + (names.length ? "\n" : "");
302
302
  }
303
303
 
304
+ // ../sandbox/src/chatgpt.ts
305
+ var CHATGPT_CODEX_BASE = "https://chatgpt.com/backend-api/codex";
306
+ var CHATGPT_TOKEN_URL = "https://auth.openai.com/oauth/token";
307
+ var CODEX_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann";
308
+ function jwtClaims(token) {
309
+ const part = token?.split(".")[1];
310
+ if (!part) return null;
311
+ try {
312
+ const b64 = part.replace(/-/g, "+").replace(/_/g, "/");
313
+ const bin = atob(b64 + "=".repeat((4 - b64.length % 4) % 4));
314
+ return JSON.parse(new TextDecoder().decode(Uint8Array.from(bin, (c) => c.charCodeAt(0))));
315
+ } catch {
316
+ return null;
317
+ }
318
+ }
319
+ function accountIdOf(t) {
320
+ if (typeof t.account_id === "string" && t.account_id) return t.account_id;
321
+ const auth = jwtClaims(typeof t.id_token === "string" ? t.id_token : void 0)?.["https://api.openai.com/auth"];
322
+ return typeof auth?.chatgpt_account_id === "string" ? auth.chatgpt_account_id : null;
323
+ }
324
+ function parseChatgptLogin(text2) {
325
+ let j;
326
+ try {
327
+ j = JSON.parse(text2);
328
+ } catch {
329
+ throw new Error("not a Codex login: expected the auth.json that `codex login` writes");
330
+ }
331
+ const t = j.tokens && typeof j.tokens === "object" ? j.tokens : j;
332
+ if (typeof t.access_token !== "string" || typeof t.refresh_token !== "string") {
333
+ throw new Error("this Codex login has no ChatGPT tokens: sign in with ChatGPT in `codex login`, not an API key");
334
+ }
335
+ const account = accountIdOf(t);
336
+ if (!account) throw new Error("this Codex login doesn't name a ChatGPT account");
337
+ return { access_token: t.access_token, refresh_token: t.refresh_token, id_token: typeof t.id_token === "string" ? t.id_token : void 0, account_id: account };
338
+ }
339
+ function chatgptNeedsRefresh(t, now = Date.now(), windowMs = 5 * 6e4) {
340
+ const exp = jwtClaims(t.access_token)?.exp;
341
+ return typeof exp !== "number" || exp * 1e3 - now < windowMs;
342
+ }
343
+ async function refreshChatgpt(t, o = {}) {
344
+ const res = await (o.fetch ?? fetch)(o.tokenUrl ?? CHATGPT_TOKEN_URL, {
345
+ method: "POST",
346
+ headers: { "content-type": "application/json" },
347
+ body: JSON.stringify({ client_id: CODEX_CLIENT_ID, grant_type: "refresh_token", refresh_token: t.refresh_token, scope: "openid profile email" }),
348
+ signal: AbortSignal.timeout(45e3)
349
+ });
350
+ if (!res.ok) {
351
+ const why = await res.text().catch(() => "");
352
+ throw new Error(
353
+ res.status === 400 || res.status === 401 ? "the ChatGPT sign-in expired or was revoked: connect it again (airgate models connect chatgpt)" : `couldn't renew the ChatGPT sign-in (${res.status}${why ? `: ${why.slice(0, 120)}` : ""})`
354
+ );
355
+ }
356
+ const r = await res.json();
357
+ if (!r.access_token) throw new Error("renewing the ChatGPT sign-in returned no access token");
358
+ return { ...t, access_token: r.access_token, refresh_token: r.refresh_token ?? t.refresh_token, id_token: r.id_token ?? t.id_token };
359
+ }
360
+ function chatgptUrl(path, base = CHATGPT_CODEX_BASE) {
361
+ return base + path.replace(/^\/?v1(?=\/|$)/, "").replace(/^(?!\/)/, "/");
362
+ }
363
+ function signChatgpt(t, headers) {
364
+ delete headers["x-api-key"];
365
+ headers.authorization = `Bearer ${t.access_token}`;
366
+ headers["chatgpt-account-id"] = t.account_id;
367
+ headers.originator ??= "codex_cli_rs";
368
+ }
369
+
304
370
  // src/api.ts
305
371
  var UPLOAD_PART = 6 * 1024 * 1024;
306
372
  var ApiError = class extends Error {
@@ -518,8 +584,8 @@ var Api = class {
518
584
  modelsAuth() {
519
585
  return this.request("GET", "/v1/models/auth");
520
586
  }
521
- connectModel(vendor, value) {
522
- return this.request("PUT", `/v1/models/auth/${enc(vendor)}`, { body: { value } });
587
+ connectModel(vendor, value, kind) {
588
+ return this.request("PUT", `/v1/models/auth/${enc(vendor)}`, { body: { value, ...kind ? { kind } : {} } });
523
589
  }
524
590
  disconnectModel(vendor) {
525
591
  return this.request("DELETE", `/v1/models/auth/${enc(vendor)}`);
@@ -4388,8 +4454,8 @@ function linked(s2, j, b2, ascii) {
4388
4454
  }
4389
4455
  return m;
4390
4456
  }
4391
- function place3(s2, j, c, k, join7, a = ARMS.get(c), ascii = false, round = false) {
4392
- if (join7 && a !== void 0) {
4457
+ function place3(s2, j, c, k, join8, a = ARMS.get(c), ascii = false, round = false) {
4458
+ if (join8 && a !== void 0) {
4393
4459
  const old = s2.chars[j], b2 = ascii ? asciiArms(old) : ARMS.get(old);
4394
4460
  if (b2 !== void 0) {
4395
4461
  const kept = linked(s2, j, b2, ascii);
@@ -4555,7 +4621,7 @@ function rect(s2, x, y, w, h, o) {
4555
4621
  if (title)
4556
4622
  for (const ch of title)
4557
4623
  code(ch);
4558
- const join7 = yes(o?.join, "join", true);
4624
+ const join8 = yes(o?.join, "join", true);
4559
4625
  const k = paint(s2, color), fk = fillColor === void 0 ? k : paint(s2, fillColor);
4560
4626
  if (!finite3(x, y, w, h))
4561
4627
  return { x: 0, y: 0, cols: 0, rows: 0 };
@@ -4578,7 +4644,7 @@ function rect(s2, x, y, w, h, o) {
4578
4644
  const put = (cx, cy, e, reach = 255) => {
4579
4645
  const j = s2.index(cx, cy), a = ascii ? EDGE_ARMS[e] : ARMS.get(ed[e]);
4580
4646
  if (j >= 0)
4581
- place3(s2, j, ed[e], k, join7, a === void 0 ? a : a & reach, ascii, round);
4647
+ place3(s2, j, ed[e], k, join8, a === void 0 ? a : a & reach, ascii, round);
4582
4648
  };
4583
4649
  const end = (i, n2, first, last) => n2 < 2 ? 255 : i === 0 ? 3 << first : i === n2 - 1 ? 3 << last : 255;
4584
4650
  const from = (o2, lo) => Math.max(lo, -o2), to = (o2, hi, n2) => Math.min(hi, n2 - o2);
@@ -4714,11 +4780,11 @@ function shape(s2, cx, cy, rx, ry, o, what, from = 0, span = 1) {
4714
4780
  const ch = char === "auto" ? "auto" : brush(char, `${/^[aeiou]/.test(what) ? "an" : "a"} ${what}'s char`);
4715
4781
  const f = fill === void 0 || fill === false ? null : brush(fill, "fill");
4716
4782
  const k = paint(s2, color), fk = fillColor === void 0 ? k : paint(s2, fillColor);
4717
- const join7 = yes(o?.join, "join", true);
4783
+ const join8 = yes(o?.join, "join", true);
4718
4784
  ellipseCells(s2.cols, s2.rows, cx, cy, rx, ry, f !== null, (x, y, edge, slope) => {
4719
4785
  const j = y * s2.cols + x;
4720
4786
  if (edge)
4721
- place3(s2, j, ch === "auto" ? slope : ch, k, join7 && ch !== "auto");
4787
+ place3(s2, j, ch === "auto" ? slope : ch, k, join8 && ch !== "auto");
4722
4788
  else
4723
4789
  s2.put(j, f, f === EMPTY ? NONE : fk);
4724
4790
  }, ch === "auto" ? s2.aspect : 0, from, span);
@@ -7662,7 +7728,7 @@ var log = {
7662
7728
  import { createInterface } from "node:readline";
7663
7729
 
7664
7730
  // src/version.ts
7665
- var VERSION = "0.1.3";
7731
+ var VERSION = "0.1.4";
7666
7732
 
7667
7733
  // src/commands/updown.ts
7668
7734
  import { appendFileSync, existsSync as existsSync3, mkdirSync as mkdirSync3, readFileSync as readFileSync5, realpathSync as realpathSync2, statSync as statSync2, writeFileSync as writeFileSync3 } from "node:fs";
@@ -7671,7 +7737,7 @@ import { basename, dirname as dirname2, isAbsolute, join as join4, relative as r
7671
7737
 
7672
7738
  // src/pack.ts
7673
7739
  import { spawn, spawnSync } from "node:child_process";
7674
- import { existsSync, lstatSync } from "node:fs";
7740
+ import { existsSync, lstatSync, readdirSync } from "node:fs";
7675
7741
  import { join as join2 } from "node:path";
7676
7742
  var UPLOAD_LIMIT = 32 * 6 * 1024 * 1024;
7677
7743
  var TooLargeError = class extends Error {
@@ -7681,6 +7747,11 @@ var TooLargeError = class extends Error {
7681
7747
  this.limit = limit;
7682
7748
  }
7683
7749
  };
7750
+ function isEnvFile(path) {
7751
+ const name = path.split("/").pop() ?? "";
7752
+ return /^\.env($|\.)/.test(name) && !/\.(example|sample|template|dist|defaults?|schema)$/.test(name);
7753
+ }
7754
+ var ENV_EXCLUDES = [".env", ".env.local", ".env.*.local", ".env.development", ".env.production", ".env.test", ".env.staging", ".envrc"].map((p) => `--exclude=${p}`);
7684
7755
  function git(cwd, args, input) {
7685
7756
  const r = spawnSync("git", args, { cwd, input, maxBuffer: 256 * 1024 * 1024 });
7686
7757
  return { status: r.status ?? (r.error ? 127 : 1), stdout: r.stdout?.toString("utf8") ?? "", stderr: r.stderr?.toString("utf8") ?? "", error: r.error };
@@ -7737,13 +7808,17 @@ function runTar(cwd, args, input, limit = UPLOAD_LIMIT) {
7737
7808
  }
7738
7809
  async function packProject(dir, limit = UPLOAD_LIMIT) {
7739
7810
  if (isGitRepo(dir)) {
7740
- const files = gitFiles(dir).filter((f) => !f.startsWith(".airgate/"));
7811
+ const tracked = new Set(git(dir, ["ls-files", "-c", "-z"]).stdout.split("\0"));
7812
+ const all = gitFiles(dir).filter((f) => !f.startsWith(".airgate/"));
7813
+ const skipped2 = all.filter((f) => (isEnvFile(f) || f.split("/").pop() === ".envrc") && !tracked.has(f));
7814
+ const files = all.filter((f) => !skipped2.includes(f));
7741
7815
  if (!files.length) throw new Error("no files to send (git sees nothing tracked or untracked here)");
7742
7816
  const archive2 = await runTar(dir, ["--null", "-T", "-", "-czf", "-"], files.join("\0") + "\0", limit);
7743
- return { archive: archive2, files: files.length, git: true };
7817
+ return { archive: archive2, files: files.length, git: true, skipped: skipped2 };
7744
7818
  }
7745
- const archive = await runTar(dir, ["--exclude=node_modules", "--exclude=.git", "--exclude=.airgate", "-czf", "-", "."], null, limit);
7746
- return { archive, files: -1, git: false };
7819
+ const skipped = readdirSync(dir).filter((f) => isEnvFile(f) || f === ".envrc");
7820
+ const archive = await runTar(dir, ["--exclude=node_modules", "--exclude=.git", "--exclude=.airgate", ...ENV_EXCLUDES, "-czf", "-", "."], null, limit);
7821
+ return { archive, files: -1, git: false, skipped };
7747
7822
  }
7748
7823
  var CLAUDE_CONFIG = ["settings.json", "CLAUDE.md", "skills", "agents", "commands"];
7749
7824
  async function packClaudeConfig(home, limit = UPLOAD_LIMIT) {
@@ -7765,7 +7840,7 @@ import { gunzipSync } from "node:zlib";
7765
7840
 
7766
7841
  // src/sessions.ts
7767
7842
  import { createHash, randomUUID } from "node:crypto";
7768
- import { closeSync, existsSync as existsSync2, mkdirSync as mkdirSync2, mkdtempSync, openSync, readdirSync, readFileSync as readFileSync4, readSync, realpathSync, renameSync as renameSync2, rmSync as rmSync2, statSync, writeFileSync as writeFileSync2 } from "node:fs";
7843
+ import { closeSync, existsSync as existsSync2, mkdirSync as mkdirSync2, mkdtempSync, openSync, readdirSync as readdirSync2, readFileSync as readFileSync4, readSync, realpathSync, renameSync as renameSync2, rmSync as rmSync2, statSync, writeFileSync as writeFileSync2 } from "node:fs";
7769
7844
  import { tmpdir } from "node:os";
7770
7845
  import { dirname, join as join3, relative } from "node:path";
7771
7846
  var POD_HOME = "/home/sprite";
@@ -7793,7 +7868,7 @@ function findClaudeSession(home, projectDir, id, env = {}) {
7793
7868
  const dir = join3(claudeHome(home, env), "projects", claudeKey(p));
7794
7869
  let names = [];
7795
7870
  try {
7796
- names = readdirSync(dir).filter((n2) => n2.endsWith(".jsonl"));
7871
+ names = readdirSync2(dir).filter((n2) => n2.endsWith(".jsonl"));
7797
7872
  } catch {
7798
7873
  continue;
7799
7874
  }
@@ -7824,7 +7899,7 @@ function codexRollouts(root, limit = 3e3) {
7824
7899
  const walk = (d, depth) => {
7825
7900
  let list2 = [];
7826
7901
  try {
7827
- list2 = readdirSync(d, { withFileTypes: true });
7902
+ list2 = readdirSync2(d, { withFileTypes: true });
7828
7903
  } catch {
7829
7904
  return;
7830
7905
  }
@@ -7879,7 +7954,7 @@ async function packSession(s2, projectDir, podPath, limit = UPLOAD_LIMIT) {
7879
7954
  names.push(`${s2.id}.jsonl`);
7880
7955
  if (s2.sidecar) {
7881
7956
  const copy2 = (src, dst) => {
7882
- for (const x of readdirSync(src, { withFileTypes: true })) {
7957
+ for (const x of readdirSync2(src, { withFileTypes: true })) {
7883
7958
  const a = join3(src, x.name);
7884
7959
  const b2 = join3(dst, x.name);
7885
7960
  if (x.isDirectory()) {
@@ -8293,10 +8368,11 @@ var up = {
8293
8368
  const state = { gateway: ctx.cfg.gateway, podId: pod.id, podName: pod.spec.name, dir: podDir, at: Date.now(), ...sent.length ? { sessions: sent } : {} };
8294
8369
  writeState(projectDir, state);
8295
8370
  if (ctx.json) {
8296
- return ui2.json({ pod, dir: podDir, bytes: packed.archive.length, files: packed.files, config: config?.parts ?? [], baseSet: based, sessions: resumed, secrets: envSent });
8371
+ return ui2.json({ pod, dir: podDir, bytes: packed.archive.length, files: packed.files, skipped: packed.skipped, config: config?.parts ?? [], baseSet: based, sessions: resumed, secrets: envSent });
8297
8372
  }
8298
8373
  ui2.out(`${ui2.c("ok", "up")}: ${name} \u2192 ${pod.spec.name}:~/${podDir} \xB7 ${packed.files >= 0 ? `${packed.files} files, ` : ""}${bytes(packed.archive.length)}`);
8299
8374
  if (config) ui2.out(`config: ~/.claude/{${config.parts.join(",")}}`);
8375
+ if (packed.skipped.length) ui2.out(ui2.c("dim", `kept here: ${packed.skipped.join(", ")} (env files go to a pod only as secrets: --env${packed.skipped.includes(".env") ? "" : " <file>"})`));
8300
8376
  if (!based) ui2.err(ui2.ce("warn", "couldn't set the down base; the first down will include the files you sent"));
8301
8377
  if (envSent) {
8302
8378
  ui2.out(`secrets for ${pod.spec.name}: ${envSent.sent.length ? envSent.sent.join(", ") : "none"}${envSent.skipped.length ? ui2.c("dim", ` (skipped: ${envSent.skipped.join(", ")})`) : ""}`);
@@ -8655,7 +8731,7 @@ async function runCommand(ctx, cmd, argv, cwd) {
8655
8731
  }
8656
8732
 
8657
8733
  // src/commands/run.ts
8658
- import { existsSync as existsSync5, readFileSync as readFileSync7, rmSync as rmSync3, statSync as statSync3, writeFileSync as writeFileSync5 } from "node:fs";
8734
+ import { existsSync as existsSync5, readFileSync as readFileSync8, rmSync as rmSync4, statSync as statSync3, writeFileSync as writeFileSync5 } from "node:fs";
8659
8735
  import { basename as basename2, resolve as resolve5 } from "node:path";
8660
8736
  import { randomBytes } from "node:crypto";
8661
8737
 
@@ -8678,9 +8754,9 @@ function readSaved(env) {
8678
8754
  return {};
8679
8755
  }
8680
8756
  }
8681
- function saveLocal(env, vendor, value) {
8757
+ function saveLocal(env, vendor, value, kind = value ? kindOf(vendor, value) : "api_key") {
8682
8758
  const all = readSaved(env);
8683
- if (value) all[vendor] = { kind: kindOf(vendor, value), value };
8759
+ if (value) all[vendor] = { kind, value };
8684
8760
  else delete all[vendor];
8685
8761
  const dir = configDir(env);
8686
8762
  mkdirSync4(dir, { recursive: true, mode: 448 });
@@ -8721,6 +8797,14 @@ function sign(vendor, auth, headers) {
8721
8797
  }
8722
8798
  }
8723
8799
  var UPSTREAM = { anthropic: "https://api.anthropic.com", openai: "https://api.openai.com" };
8800
+ async function chatgptReady(io, auth, f) {
8801
+ const t = JSON.parse(auth.value);
8802
+ if (!chatgptNeedsRefresh(t)) return t;
8803
+ const fresh = await refreshChatgpt(t, { fetch: f, tokenUrl: io.env.AIRGATE_RELAY_CHATGPT_TOKEN_URL || void 0 });
8804
+ auth.value = JSON.stringify(fresh);
8805
+ if (auth.from === "this laptop") saveLocal(io.env, "openai", auth.value, "chatgpt");
8806
+ return fresh;
8807
+ }
8724
8808
  function startRelay(o) {
8725
8809
  const vendors = Object.keys(o.auth).filter((v) => o.auth[v]);
8726
8810
  const inflight = /* @__PURE__ */ new Map();
@@ -8736,11 +8820,15 @@ function startRelay(o) {
8736
8820
  const auth = o.auth[m.vendor];
8737
8821
  if (!auth) return send({ t: "error", id: m.id, message: `this laptop has no ${vendorLabel(m.vendor)} sign-in` });
8738
8822
  const headers = { ...m.headers };
8739
- sign(m.vendor, auth, headers);
8740
8823
  const ctl = new AbortController();
8741
8824
  inflight.set(m.id, ctl);
8742
8825
  try {
8743
- const res = await o.fetch(`${base(m.vendor)}${m.path}`, {
8826
+ let url = `${base(m.vendor)}${m.path}`;
8827
+ if (auth.kind === "chatgpt") {
8828
+ signChatgpt(await chatgptReady(o.io, auth, o.fetch), headers);
8829
+ url = chatgptUrl(m.path, o.io.env.AIRGATE_RELAY_CHATGPT_URL || CHATGPT_CODEX_BASE);
8830
+ } else sign(m.vendor, auth, headers);
8831
+ const res = await o.fetch(url, {
8744
8832
  method: m.method,
8745
8833
  headers,
8746
8834
  body: m.body ? Buffer.from(m.body, "base64") : void 0,
@@ -8809,9 +8897,13 @@ function startRelay(o) {
8809
8897
  }
8810
8898
 
8811
8899
  // src/commands/models.ts
8900
+ import { spawnSync as spawnSync2 } from "node:child_process";
8901
+ import { mkdtempSync as mkdtempSync2, readFileSync as readFileSync7, rmSync as rmSync3 } from "node:fs";
8902
+ import { tmpdir as tmpdir2 } from "node:os";
8903
+ import { join as join6 } from "node:path";
8812
8904
  var HOW = {
8813
8905
  anthropic: "a Claude subscription token (run `claude setup-token` and paste what it prints) or an Anthropic API key",
8814
- openai: "an OpenAI API key"
8906
+ openai: "an OpenAI API key (for a ChatGPT plan: airgate models connect chatgpt)"
8815
8907
  };
8816
8908
  function relayIfSignedIn(ctx, say) {
8817
8909
  const auth = localAuth(ctx.io.env);
@@ -8822,19 +8914,66 @@ function relayIfSignedIn(ctx, say) {
8822
8914
  return null;
8823
8915
  }
8824
8916
  }
8917
+ var KIND = { oauth_token: "subscription token", api_key: "API key", chatgpt: "ChatGPT plan" };
8918
+ async function connectChatgpt(ctx, laptop) {
8919
+ const { ui: ui2 } = ctx;
8920
+ let text2;
8921
+ if (!ctx.io.stdin.isTTY) {
8922
+ text2 = await readValue(ctx, "a Codex auth.json", "cat auth.json | airgate models connect chatgpt");
8923
+ } else {
8924
+ if (spawnSync2("codex", ["--version"], { stdio: "ignore" }).status !== 0) {
8925
+ throw new CliError("Codex is not installed on this laptop", "npm i -g @openai/codex, then: airgate models connect chatgpt");
8926
+ }
8927
+ const home = mkdtempSync2(join6(tmpdir2(), "airgate-codex-"));
8928
+ try {
8929
+ ui2.err(ui2.ce("dim", "signing in to ChatGPT for airgate: a separate Codex login, so the one on this laptop stays as it is."));
8930
+ const args = ["login", ...bool(ctx.args, "device") ? ["--device-auth"] : [], "-c", 'cli_auth_credentials_store="file"'];
8931
+ const r2 = spawnSync2("codex", args, { stdio: "inherit", env: { ...process.env, ...ctx.io.env, CODEX_HOME: home } });
8932
+ if (r2.status !== 0) throw new CliError("the ChatGPT sign-in did not finish", "try again, or with --device");
8933
+ try {
8934
+ text2 = readFileSync7(join6(home, "auth.json"), "utf8");
8935
+ } catch {
8936
+ throw new CliError("Codex didn't write a login file", "update Codex (npm i -g @openai/codex) and try again");
8937
+ }
8938
+ } finally {
8939
+ rmSync3(home, { recursive: true, force: true });
8940
+ }
8941
+ }
8942
+ let tokens2;
8943
+ try {
8944
+ tokens2 = parseChatgptLogin(text2);
8945
+ } catch (e) {
8946
+ throw new CliError(e.message);
8947
+ }
8948
+ const value = JSON.stringify(tokens2);
8949
+ if (laptop) {
8950
+ saveLocal(ctx.io.env, "openai", value, "chatgpt");
8951
+ if (ctx.json) return ui2.json({ ok: true, vendor: "openai", kind: "chatgpt", where: "laptop" });
8952
+ ui2.out(`${ui2.c("ok", "saved")} on this laptop only (ChatGPT plan). airgate doesn't get it.`);
8953
+ return void ui2.out(ui2.c("dim", "pods' Codex uses it while this laptop is connected: airgate relay (airgate run and airgate mcp do this on their own)."));
8954
+ }
8955
+ const r = await ctx.api.connectModel("openai", value, "chatgpt");
8956
+ if (ctx.json) return ui2.json({ ...r, where: "airgate" });
8957
+ ui2.out(`${ui2.c("ok", "connected")}: ChatGPT plan for Codex, kept encrypted on airgate (renewed there) and never sent to a pod.`);
8958
+ ui2.out(ui2.c("dim", "undo: airgate models disconnect chatgpt"));
8959
+ }
8825
8960
  var models = {
8826
8961
  name: "models",
8827
8962
  group: "account",
8828
- usage: "[connect|disconnect <claude|openai> [--laptop]]",
8963
+ usage: "[connect|disconnect <claude|openai|chatgpt> [--laptop]]",
8829
8964
  summary: "how pods' agents sign in to their model: in the pod, through this laptop, or connected to airgate",
8830
8965
  example: "airgate models connect claude --laptop",
8831
8966
  notes: [
8832
8967
  "three ways, pick per your comfort: sign in inside the pod (nothing stored with airgate); this laptop signs (airgate relay: airgate holds nothing, works while the laptop is connected); or connect a sign-in to airgate (encrypted on the gateway, never sent to a pod).",
8833
- "connect <claude|openai>: saves it on airgate. with --laptop: saves it only on this laptop (~/.config/airgate/models.json), for airgate relay.",
8968
+ "connect <claude|openai|chatgpt>: saves it on airgate. with --laptop: saves it only on this laptop (~/.config/airgate/models.json), for airgate relay.",
8834
8969
  "claude takes a subscription token (claude setup-token) or an API key; openai takes an API key. the value is read from a hidden prompt or stdin, never from the command line.",
8970
+ "chatgpt (Codex on a ChatGPT plan) runs `codex login` into a fresh folder, so airgate gets its own Codex login and never the one on this laptop (sharing one would sign you out here). --device uses the device-code login. or pipe a Codex auth.json in.",
8835
8971
  "this laptop also uses CLAUDE_CODE_OAUTH_TOKEN, ANTHROPIC_API_KEY or OPENAI_API_KEY from its environment."
8836
8972
  ],
8837
- flags: { laptop: { type: "boolean", help: "keep the sign-in on this laptop only (for airgate relay)" } },
8973
+ flags: {
8974
+ laptop: { type: "boolean", help: "keep the sign-in on this laptop only (for airgate relay)" },
8975
+ device: { type: "boolean", help: "chatgpt: sign in with a device code instead of this browser" }
8976
+ },
8838
8977
  async run(ctx) {
8839
8978
  const { ui: ui2 } = ctx;
8840
8979
  const [action, who, extra] = ctx.args.positionals;
@@ -8853,6 +8992,7 @@ var models = {
8853
8992
  if (ctx.json) return ui2.json({ ...r2, vendor, where: "airgate" });
8854
8993
  return void ui2.out(r2.ok ? `${vendorLabel(vendor)}: disconnected from airgate. airgate no longer holds it.` : `${vendorLabel(vendor)} wasn't connected to airgate.`);
8855
8994
  }
8995
+ if (who?.toLowerCase() === "chatgpt") return connectChatgpt(ctx, laptop);
8856
8996
  if (!ctx.json && ctx.io.stdin.isTTY) ui2.err(ui2.ce("dim", `paste ${HOW[vendor]}.`));
8857
8997
  const value = (await readValue(ctx, vendor === "anthropic" ? "your Claude sign-in" : "your OpenAI key", `printf %s "$KEY" | airgate models connect ${who}${laptop ? " --laptop" : ""}`)).trim();
8858
8998
  if (value.length < 20 || /\s/.test(value)) throw new CliError("that doesn't look like a key or token", HOW[vendor]);
@@ -8864,7 +9004,7 @@ var models = {
8864
9004
  }
8865
9005
  const r = await ctx.api.connectModel(vendor, value);
8866
9006
  if (ctx.json) return ui2.json({ ...r, where: "airgate" });
8867
- ui2.out(`${ui2.c("ok", "connected")}: ${vendorLabel(vendor)} (${r.kind === "oauth_token" ? "subscription token" : "API key"}), kept encrypted on airgate and never sent to a pod.`);
9007
+ ui2.out(`${ui2.c("ok", "connected")}: ${vendorLabel(vendor)} (${KIND[r.kind] ?? r.kind}), kept encrypted on airgate and never sent to a pod.`);
8868
9008
  return void ui2.out(ui2.c("dim", `every pod's ${vendor === "anthropic" ? "Claude Code" : "Codex"} can use it now, unless signed in inside the pod. undo: airgate models disconnect ${who}`));
8869
9009
  }
8870
9010
  if (action) throw new UsageError(`unknown "${action}"`, "airgate models \xB7 airgate models connect claude [--laptop] \xB7 airgate models disconnect claude [--laptop]");
@@ -8876,8 +9016,8 @@ var models = {
8876
9016
  const l = local[v];
8877
9017
  return [
8878
9018
  vendorLabel(v),
8879
- c ? ui2.c("ok", `connected to airgate (${c.kind === "oauth_token" ? "subscription" : "API key"})`) : ui2.c("dim", "not on airgate"),
8880
- l ? `this laptop: ${l.from}${st.laptop.connected && st.laptop.vendors.includes(v) ? ui2.c("ok", " \xB7 relaying") : ""}` : ui2.c("dim", "not on this laptop")
9019
+ c ? ui2.c("ok", `connected to airgate (${KIND[c.kind] ?? c.kind})`) : ui2.c("dim", "not on airgate"),
9020
+ l ? `this laptop: ${l.kind === "chatgpt" ? "ChatGPT plan" : l.from}${st.laptop.connected && st.laptop.vendors.includes(v) ? ui2.c("ok", " \xB7 relaying") : ""}` : ui2.c("dim", "not on this laptop")
8881
9021
  ];
8882
9022
  });
8883
9023
  ui2.table(rows2, { head: ["model", "airgate", "laptop"] });
@@ -8935,7 +9075,7 @@ async function signInWarning(ctx, agent, relaying) {
8935
9075
  const list2 = await ctx.api.secrets().catch(() => null);
8936
9076
  if (list2?.account.some((s2) => m.secrets.includes(s2.name))) return null;
8937
9077
  if (!st && !list2) return null;
8938
- const who = m.vendor === "anthropic" ? "claude" : "openai";
9078
+ const who = m.vendor === "anthropic" ? "claude" : "chatgpt";
8939
9079
  return `${AGENTS[agent].label} has no sign-in for a fresh pod. pick one: airgate models connect ${who} --laptop (this laptop signs; airgate holds nothing), or airgate models connect ${who} (kept on airgate)`;
8940
9080
  }
8941
9081
  async function finish(ctx, pod, keep) {
@@ -9029,11 +9169,11 @@ var runTask = {
9029
9169
  throw new CliError(`${projectDir} isn't a git repo, so the result can't come back as a patch`, "git init && git add -A && git commit -m base, or use --no-apply");
9030
9170
  }
9031
9171
  const sp = statePath(projectDir);
9032
- const before = existsSync5(sp) ? readFileSync7(sp, "utf8") : null;
9172
+ const before = existsSync5(sp) ? readFileSync8(sp, "utf8") : null;
9033
9173
  const restoreState = () => {
9034
9174
  try {
9035
9175
  if (before !== null) writeFileSync5(sp, before);
9036
- else rmSync3(sp, { force: true });
9176
+ else rmSync4(sp, { force: true });
9037
9177
  } catch {
9038
9178
  }
9039
9179
  };
@@ -9493,7 +9633,7 @@ var mcp = {
9493
9633
  // src/commands/misc.ts
9494
9634
  import { mkdirSync as mkdirSync5, writeFileSync as writeFileSync6 } from "node:fs";
9495
9635
  import { homedir as homedir3 } from "node:os";
9496
- import { join as join6 } from "node:path";
9636
+ import { join as join7 } from "node:path";
9497
9637
  var PAGES = ["pods", "airlock", "usage", "settings", "mask", "chat", "notes", "data", "status"];
9498
9638
  var open = {
9499
9639
  name: "open",
@@ -9569,12 +9709,12 @@ var skill = {
9569
9709
  if (!text2.startsWith("---")) throw new CliError(`${url} isn't a skill file`);
9570
9710
  if (action === "show") return void ctx.io.stdout.write(text2);
9571
9711
  if (action !== "install") throw new UsageError(`unknown action "${action}"`, "airgate skill install [--project] | airgate skill show");
9572
- const base = bool(ctx.args, "project") ? join6(ctx.io.cwd, ".claude") : join6(ctx.io.env.HOME ?? homedir3(), ".claude");
9573
- const dir = join6(base, "skills", "airgate");
9712
+ const base = bool(ctx.args, "project") ? join7(ctx.io.cwd, ".claude") : join7(ctx.io.env.HOME ?? homedir3(), ".claude");
9713
+ const dir = join7(base, "skills", "airgate");
9574
9714
  mkdirSync5(dir, { recursive: true });
9575
- writeFileSync6(join6(dir, "SKILL.md"), text2);
9576
- if (ctx.json) return ctx.ui.json({ installed: join6(dir, "SKILL.md") });
9577
- ctx.ui.out(`${ctx.ui.c("ok", "installed")} the airgate skill: ${join6(dir, "SKILL.md")}`);
9715
+ writeFileSync6(join7(dir, "SKILL.md"), text2);
9716
+ if (ctx.json) return ctx.ui.json({ installed: join7(dir, "SKILL.md") });
9717
+ ctx.ui.out(`${ctx.ui.c("ok", "installed")} the airgate skill: ${join7(dir, "SKILL.md")}`);
9578
9718
  ctx.ui.out(ctx.ui.c("dim", 'claude code picks it up in its next session. ask it to "move this to an airgate pod".'));
9579
9719
  }
9580
9720
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "airgate",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "description": "Give your AI its own computer: the airgate command line.",
5
5
  "type": "module",
6
6
  "bin": {