jeo-code 0.1.0

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.
Files changed (93) hide show
  1. package/README.md +342 -0
  2. package/package.json +57 -0
  3. package/scripts/install.sh +322 -0
  4. package/scripts/uninstall.sh +30 -0
  5. package/src/agent/compaction.ts +75 -0
  6. package/src/agent/config-schema.ts +87 -0
  7. package/src/agent/context-files.ts +51 -0
  8. package/src/agent/engine.ts +208 -0
  9. package/src/agent/json.ts +87 -0
  10. package/src/agent/loop.ts +22 -0
  11. package/src/agent/session.ts +198 -0
  12. package/src/agent/state.ts +199 -0
  13. package/src/agent/subagents.ts +149 -0
  14. package/src/agent/tools.ts +355 -0
  15. package/src/ai/index.ts +11 -0
  16. package/src/ai/model-catalog-compat.ts +119 -0
  17. package/src/ai/model-catalog.ts +97 -0
  18. package/src/ai/model-discovery.ts +148 -0
  19. package/src/ai/model-enrich.ts +75 -0
  20. package/src/ai/model-manager.ts +178 -0
  21. package/src/ai/model-picker.ts +73 -0
  22. package/src/ai/model-registry.ts +83 -0
  23. package/src/ai/provider-status.ts +77 -0
  24. package/src/ai/providers/anthropic.ts +87 -0
  25. package/src/ai/providers/errors.ts +47 -0
  26. package/src/ai/providers/gemini.ts +77 -0
  27. package/src/ai/providers/ollama.ts +54 -0
  28. package/src/ai/providers/openai.ts +67 -0
  29. package/src/ai/sse.ts +46 -0
  30. package/src/ai/types.ts +37 -0
  31. package/src/auth/callback-server.ts +195 -0
  32. package/src/auth/flows/anthropic.ts +114 -0
  33. package/src/auth/flows/google.ts +120 -0
  34. package/src/auth/flows/index.ts +50 -0
  35. package/src/auth/flows/openai.ts +130 -0
  36. package/src/auth/index.ts +23 -0
  37. package/src/auth/oauth.ts +80 -0
  38. package/src/auth/pkce.ts +24 -0
  39. package/src/auth/refresh.ts +60 -0
  40. package/src/auth/storage.ts +113 -0
  41. package/src/auth/types.ts +26 -0
  42. package/src/cli/index.ts +1 -0
  43. package/src/cli/runner.ts +245 -0
  44. package/src/cli.ts +17 -0
  45. package/src/commands/approve.ts +63 -0
  46. package/src/commands/auth.ts +144 -0
  47. package/src/commands/chat.ts +37 -0
  48. package/src/commands/deep-interview.ts +239 -0
  49. package/src/commands/doctor.ts +250 -0
  50. package/src/commands/evolve.ts +191 -0
  51. package/src/commands/launch.ts +745 -0
  52. package/src/commands/mcp.ts +18 -0
  53. package/src/commands/models.ts +104 -0
  54. package/src/commands/ralplan.ts +86 -0
  55. package/src/commands/resume.ts +6 -0
  56. package/src/commands/setup-helpers.ts +93 -0
  57. package/src/commands/setup.ts +190 -0
  58. package/src/commands/skills.ts +38 -0
  59. package/src/commands/team.ts +337 -0
  60. package/src/commands/ultragoal.ts +102 -0
  61. package/src/index.ts +31 -0
  62. package/src/mcp/index.ts +3 -0
  63. package/src/mcp/protocol.ts +45 -0
  64. package/src/mcp/server.ts +97 -0
  65. package/src/mcp/tools.ts +156 -0
  66. package/src/skills/catalog.ts +61 -0
  67. package/src/tui/app.ts +297 -0
  68. package/src/tui/components/ascii-art.ts +340 -0
  69. package/src/tui/components/autocomplete.ts +165 -0
  70. package/src/tui/components/capability.ts +29 -0
  71. package/src/tui/components/code-view.ts +146 -0
  72. package/src/tui/components/color.ts +172 -0
  73. package/src/tui/components/config-panel.ts +193 -0
  74. package/src/tui/components/evolution.ts +305 -0
  75. package/src/tui/components/footer.ts +95 -0
  76. package/src/tui/components/forge.ts +167 -0
  77. package/src/tui/components/index.ts +7 -0
  78. package/src/tui/components/layout.ts +105 -0
  79. package/src/tui/components/meter.ts +61 -0
  80. package/src/tui/components/model-picker.ts +82 -0
  81. package/src/tui/components/provider-picker.ts +42 -0
  82. package/src/tui/components/select-list.ts +199 -0
  83. package/src/tui/components/slash.ts +34 -0
  84. package/src/tui/components/spinner.ts +49 -0
  85. package/src/tui/components/status.ts +45 -0
  86. package/src/tui/components/stream.ts +36 -0
  87. package/src/tui/components/themes.ts +86 -0
  88. package/src/tui/components/tool-list.ts +67 -0
  89. package/src/tui/index.ts +2 -0
  90. package/src/tui/renderer.ts +70 -0
  91. package/src/tui/terminal.ts +78 -0
  92. package/src/util/retry.ts +108 -0
  93. package/tsconfig.json +18 -0
@@ -0,0 +1,195 @@
1
+ /**
2
+ * Local OAuth callback server + flow base class.
3
+ *
4
+ * Pure-TS / Bun.serve reimplementation of gjc's
5
+ * `packages/ai/src/utils/oauth/callback-server.ts`. Handles:
6
+ * - preferred-port binding with random fallback (unless a fixed redirectUri is required)
7
+ * - CSRF state validation
8
+ * - browser callback OR manual paste of the redirect URL / code (whichever lands first)
9
+ */
10
+ import type { OAuthController, OAuthCredentials } from "./types";
11
+ import { generateState } from "./pkce";
12
+
13
+ const DEFAULT_TIMEOUT_MS = 300_000;
14
+ const DEFAULT_HOSTNAME = "localhost";
15
+ const DEFAULT_CALLBACK_PATH = "/callback";
16
+
17
+ export interface OAuthCallbackFlowOptions {
18
+ preferredPort: number;
19
+ callbackPath?: string;
20
+ callbackHostname?: string;
21
+ /** Exact redirect URI advertised to the provider; disables port fallback when set. */
22
+ redirectUri?: string;
23
+ }
24
+
25
+ export type CallbackResult = { code: string; state: string };
26
+
27
+ const SUCCESS_HTML = `<!doctype html><html><head><meta charset="utf-8"><title>joc — login complete</title>
28
+ <style>body{font-family:system-ui,sans-serif;background:#0d1117;color:#e6edf3;display:flex;align-items:center;justify-content:center;height:100vh;margin:0}
29
+ .card{text-align:center;padding:2rem 3rem;border:1px solid #30363d;border-radius:12px;background:#161b22}
30
+ h1{margin:0 0 .5rem;font-size:1.4rem}p{margin:0;color:#8b949e}</style></head>
31
+ <body><div class="card"><h1>__TITLE__</h1><p>__MSG__</p></div></body></html>`;
32
+
33
+ function renderHtml(ok: boolean, msg: string): string {
34
+ return SUCCESS_HTML
35
+ .replace("__TITLE__", ok ? "Login complete \u2713" : "Login failed")
36
+ .replace("__MSG__", msg);
37
+ }
38
+
39
+ export abstract class OAuthCallbackFlow {
40
+ protected ctrl: OAuthController;
41
+ protected preferredPort: number;
42
+ protected callbackPath: string;
43
+ protected callbackHostname: string;
44
+ protected fixedRedirectUri?: string;
45
+ #resolve?: (r: CallbackResult) => void;
46
+ #reject?: (e: Error) => void;
47
+
48
+ constructor(ctrl: OAuthController, opts: number | OAuthCallbackFlowOptions, callbackPath = DEFAULT_CALLBACK_PATH) {
49
+ this.ctrl = ctrl;
50
+ if (typeof opts === "number") {
51
+ this.preferredPort = opts;
52
+ this.callbackPath = callbackPath;
53
+ this.callbackHostname = DEFAULT_HOSTNAME;
54
+ return;
55
+ }
56
+ this.preferredPort = opts.preferredPort;
57
+ this.callbackPath = opts.callbackPath ?? DEFAULT_CALLBACK_PATH;
58
+ this.callbackHostname = opts.callbackHostname ?? DEFAULT_HOSTNAME;
59
+ this.fixedRedirectUri = opts.redirectUri;
60
+ }
61
+
62
+ abstract generateAuthUrl(state: string, redirectUri: string): Promise<{ url: string; instructions?: string }>;
63
+ abstract exchangeToken(code: string, state: string, redirectUri: string): Promise<OAuthCredentials>;
64
+
65
+ async login(): Promise<OAuthCredentials> {
66
+ const state = generateState();
67
+ const { server, redirectUri } = this.#startServer(state);
68
+ try {
69
+ const { url, instructions } = await this.generateAuthUrl(state, redirectUri);
70
+ this.ctrl.onAuth?.({ url, instructions });
71
+ this.ctrl.onProgress?.("Waiting for browser authentication...");
72
+ const { code, state: returnedState } = await this.#waitForCallback(state);
73
+ this.ctrl.onProgress?.("Exchanging authorization code for tokens...");
74
+ return await this.exchangeToken(code, returnedState || state, redirectUri);
75
+ } finally {
76
+ server.stop();
77
+ }
78
+ }
79
+
80
+ #startServer(expectedState: string): { server: Bun.Server<unknown>; redirectUri: string } {
81
+ const serve = (port: number) =>
82
+ Bun.serve({
83
+ hostname: this.callbackHostname,
84
+ port,
85
+ fetch: req => this.#handle(req, expectedState),
86
+ });
87
+ try {
88
+ const server = serve(this.preferredPort);
89
+ const redirectUri =
90
+ this.fixedRedirectUri ?? `http://${this.callbackHostname}:${server.port}${this.callbackPath}`;
91
+ return { server, redirectUri };
92
+ } catch {
93
+ if (this.fixedRedirectUri) {
94
+ throw new Error(
95
+ `OAuth callback port ${this.preferredPort} unavailable and this provider requires a fixed redirect URI.`
96
+ );
97
+ }
98
+ const server = serve(0);
99
+ const redirectUri = `http://${this.callbackHostname}:${server.port}${this.callbackPath}`;
100
+ this.ctrl.onProgress?.(`Port ${this.preferredPort} busy; using ${server.port}.`);
101
+ return { server, redirectUri };
102
+ }
103
+ }
104
+
105
+ #handle(req: Request, expectedState: string): Response {
106
+ const url = new URL(req.url);
107
+ if (url.pathname !== this.callbackPath) return new Response("Not Found", { status: 404 });
108
+
109
+ const code = url.searchParams.get("code");
110
+ const state = url.searchParams.get("state") || "";
111
+ const error = url.searchParams.get("error");
112
+ const errorDesc = url.searchParams.get("error_description") || error;
113
+
114
+ let ok = false;
115
+ let message: string;
116
+ if (error) message = `Authorization failed: ${errorDesc}`;
117
+ else if (!code) message = "Missing authorization code";
118
+ else if (expectedState && state !== expectedState) message = "State mismatch — possible CSRF attack";
119
+ else {
120
+ ok = true;
121
+ message = "You can close this tab and return to the terminal.";
122
+ }
123
+
124
+ const resolve = this.#resolve;
125
+ const reject = this.#reject;
126
+ queueMicrotask(() => {
127
+ if (ok && code) resolve?.({ code, state });
128
+ else reject?.(new Error(message));
129
+ });
130
+
131
+ return new Response(renderHtml(ok, message), {
132
+ status: ok ? 200 : 400,
133
+ headers: { "content-type": "text/html" },
134
+ });
135
+ }
136
+
137
+ #waitForCallback(expectedState: string): Promise<CallbackResult> {
138
+ const timeout = AbortSignal.timeout(DEFAULT_TIMEOUT_MS);
139
+ const signal = this.ctrl.signal ? AbortSignal.any([this.ctrl.signal, timeout]) : timeout;
140
+
141
+ const callbackPromise = new Promise<CallbackResult>((resolve, reject) => {
142
+ this.#resolve = resolve;
143
+ this.#reject = reject;
144
+ signal.addEventListener("abort", () => {
145
+ this.#resolve = undefined;
146
+ this.#reject = undefined;
147
+ reject(new Error(`OAuth callback cancelled: ${signal.reason}`));
148
+ });
149
+ });
150
+
151
+ if (this.ctrl.onManualCodeInput) {
152
+ const ask = this.ctrl.onManualCodeInput;
153
+ const manualPromise = (async (): Promise<CallbackResult> => {
154
+ while (true) {
155
+ const result = await Promise.race<CallbackResult | null>([
156
+ callbackPromise,
157
+ ask()
158
+ .then(input => {
159
+ const parsed = parseCallbackInput(input);
160
+ if (!parsed.code) return null;
161
+ if (expectedState && parsed.state !== expectedState) return null; // reject missing OR mismatched state
162
+ return { code: parsed.code, state: parsed.state ?? "" } as CallbackResult;
163
+ })
164
+ .catch(() => null),
165
+ ]);
166
+ if (result) return result;
167
+ }
168
+ })();
169
+ return Promise.race([callbackPromise, manualPromise]);
170
+ }
171
+
172
+ return callbackPromise;
173
+ }
174
+ }
175
+
176
+ /** Parse a pasted redirect URL or bare `code#state` into its parts. */
177
+ export function parseCallbackInput(input: string): { code?: string; state?: string } {
178
+ const value = input.trim();
179
+ if (!value) return {};
180
+ try {
181
+ const url = new URL(value);
182
+ return {
183
+ code: url.searchParams.get("code") ?? undefined,
184
+ state: url.searchParams.get("state") ?? undefined,
185
+ };
186
+ } catch {
187
+ /* not a URL */
188
+ }
189
+ if (value.includes("code=")) {
190
+ const params = new URLSearchParams(value.replace(/^[?#]/, ""));
191
+ return { code: params.get("code") ?? undefined, state: params.get("state") ?? undefined };
192
+ }
193
+ const [code, state] = value.split("#", 2);
194
+ return { code, state };
195
+ }
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Anthropic (Claude Pro/Max) OAuth — real PKCE flow.
3
+ * Faithful port of gjc's packages/ai/src/utils/oauth/anthropic.ts.
4
+ *
5
+ * The resulting access token is used as `Authorization: Bearer` with the
6
+ * `anthropic-beta: oauth-2025-04-20` header against api.anthropic.com/v1/messages.
7
+ */
8
+ import { OAuthCallbackFlow } from "../callback-server";
9
+ import { generatePKCE } from "../pkce";
10
+ import type { OAuthController, OAuthCredentials } from "../types";
11
+
12
+ const decode = (s: string) => atob(s);
13
+ const CLIENT_ID = decode("OWQxYzI1MGEtZTYxYi00NGQ5LTg4ZWQtNTk0NGQxOTYyZjVl");
14
+ const AUTHORIZE_URL = "https://claude.ai/oauth/authorize";
15
+ const TOKEN_URL = "https://api.anthropic.com/v1/oauth/token";
16
+ const CALLBACK_PORT = 54545;
17
+ const CALLBACK_PATH = "/callback";
18
+ const SCOPES = "org:create_api_key user:profile user:inference";
19
+
20
+ interface AnthropicTokenResponse {
21
+ access_token: string;
22
+ refresh_token: string;
23
+ expires_in: number;
24
+ account?: { uuid?: string; email_address?: string };
25
+ }
26
+
27
+ async function postJson(url: string, body: Record<string, string>): Promise<AnthropicTokenResponse> {
28
+ const res = await fetch(url, {
29
+ method: "POST",
30
+ headers: { "content-type": "application/json", accept: "application/json" },
31
+ body: JSON.stringify(body),
32
+ signal: AbortSignal.timeout(30_000),
33
+ });
34
+ const text = await res.text();
35
+ if (!res.ok) throw new Error(`Anthropic OAuth request failed (HTTP ${res.status}): ${text}`);
36
+ try {
37
+ return JSON.parse(text) as AnthropicTokenResponse;
38
+ } catch {
39
+ throw new Error(`Anthropic OAuth returned invalid JSON: ${text}`);
40
+ }
41
+ }
42
+
43
+ function lift(data: AnthropicTokenResponse): OAuthCredentials {
44
+ const uuid = data.account?.uuid;
45
+ const email = data.account?.email_address;
46
+ return {
47
+ access: data.access_token,
48
+ refresh: data.refresh_token,
49
+ expires: Date.now() + data.expires_in * 1000 - 5 * 60 * 1000,
50
+ accountId: typeof uuid === "string" && uuid ? uuid : undefined,
51
+ email: typeof email === "string" && email ? email : undefined,
52
+ };
53
+ }
54
+
55
+ class AnthropicOAuthFlow extends OAuthCallbackFlow {
56
+ #verifier = "";
57
+
58
+ constructor(ctrl: OAuthController) {
59
+ super(ctrl, { preferredPort: CALLBACK_PORT, callbackPath: CALLBACK_PATH });
60
+ }
61
+
62
+ async generateAuthUrl(state: string, redirectUri: string) {
63
+ const pkce = await generatePKCE();
64
+ this.#verifier = pkce.verifier;
65
+ const params = new URLSearchParams({
66
+ code: "true",
67
+ client_id: CLIENT_ID,
68
+ response_type: "code",
69
+ redirect_uri: redirectUri,
70
+ scope: SCOPES,
71
+ code_challenge: pkce.challenge,
72
+ code_challenge_method: "S256",
73
+ state,
74
+ });
75
+ return {
76
+ url: `${AUTHORIZE_URL}?${params.toString()}`,
77
+ instructions:
78
+ "Approve in your browser. If it cannot reach this machine, paste the final redirect URL or code when prompted.",
79
+ };
80
+ }
81
+
82
+ async exchangeToken(code: string, state: string, redirectUri: string): Promise<OAuthCredentials> {
83
+ let exchangeCode = code;
84
+ let exchangeState = state;
85
+ const hashIdx = code.indexOf("#");
86
+ if (hashIdx >= 0) {
87
+ exchangeCode = code.slice(0, hashIdx);
88
+ const frag = code.slice(hashIdx + 1);
89
+ if (frag) exchangeState = frag;
90
+ }
91
+ const data = await postJson(TOKEN_URL, {
92
+ grant_type: "authorization_code",
93
+ client_id: CLIENT_ID,
94
+ code: exchangeCode,
95
+ state: exchangeState,
96
+ redirect_uri: redirectUri,
97
+ code_verifier: this.#verifier,
98
+ });
99
+ return lift(data);
100
+ }
101
+ }
102
+
103
+ export async function loginAnthropic(ctrl: OAuthController): Promise<OAuthCredentials> {
104
+ return new AnthropicOAuthFlow(ctrl).login();
105
+ }
106
+
107
+ export async function refreshAnthropicToken(refreshToken: string): Promise<OAuthCredentials> {
108
+ const data = await postJson(TOKEN_URL, {
109
+ grant_type: "refresh_token",
110
+ client_id: CLIENT_ID,
111
+ refresh_token: refreshToken,
112
+ });
113
+ return { ...lift(data), refresh: data.refresh_token || refreshToken };
114
+ }
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Google (Gemini CLI / Cloud Code Assist) OAuth — standard authorization-code flow.
3
+ * Port of gjc's google-oauth-shared.ts + google-gemini-cli.ts constants.
4
+ *
5
+ * NOTE: these tokens authenticate against Google's Cloud Code Assist backend.
6
+ * joc's default `gemini` adapter targets the public generativelanguage API,
7
+ * which prefers an API key (`GEMINI_API_KEY`). The login/refresh machinery is
8
+ * real; project provisioning is best-effort (env-driven) to keep joc lean.
9
+ */
10
+ import { OAuthCallbackFlow } from "../callback-server";
11
+ import type { OAuthController, OAuthCredentials } from "../types";
12
+
13
+ const decode = (s: string) => atob(s);
14
+ const CLIENT_ID = decode(
15
+ "NjgxMjU1ODA5Mzk1LW9vOGZ0Mm9wcmRybnA5ZTNhcWY2YXYzaG1kaWIxMzVqLmFwcHMuZ29vZ2xldXNlcmNvbnRlbnQuY29t"
16
+ );
17
+ // Google's installed-app ("desktop") OAuth client secret is not a true secret —
18
+ // gemini-cli ships it publicly — but committing the literal trips secret
19
+ // scanners. Source it from env so the repo stays clean; the gemini-cli value is
20
+ // the documented default for `GEMINI_OAUTH_CLIENT_SECRET`.
21
+ const CLIENT_SECRET = process.env.GEMINI_OAUTH_CLIENT_SECRET ?? "";
22
+
23
+ function requireClientSecret(): string {
24
+ if (!CLIENT_SECRET) {
25
+ throw new Error(
26
+ "Google OAuth requires GEMINI_OAUTH_CLIENT_SECRET (the public gemini-cli desktop client secret). " +
27
+ "Set it in your environment, or use a GEMINI_API_KEY with the bundled adapter instead."
28
+ );
29
+ }
30
+ return CLIENT_SECRET;
31
+ }
32
+ const AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth";
33
+ const TOKEN_URL = "https://oauth2.googleapis.com/token";
34
+ const CALLBACK_PORT = 8085;
35
+ const CALLBACK_PATH = "/oauth2callback";
36
+ const SCOPES = [
37
+ "https://www.googleapis.com/auth/cloud-platform",
38
+ "https://www.googleapis.com/auth/userinfo.email",
39
+ "https://www.googleapis.com/auth/userinfo.profile",
40
+ ];
41
+
42
+ async function getUserEmail(access: string): Promise<string | undefined> {
43
+ try {
44
+ const res = await fetch("https://www.googleapis.com/oauth2/v1/userinfo?alt=json", {
45
+ headers: { authorization: `Bearer ${access}` },
46
+ });
47
+ if (res.ok) return ((await res.json()) as { email?: string }).email;
48
+ } catch {
49
+ /* email is optional */
50
+ }
51
+ return undefined;
52
+ }
53
+
54
+ class GoogleOAuthFlow extends OAuthCallbackFlow {
55
+ constructor(ctrl: OAuthController) {
56
+ super(ctrl, { preferredPort: CALLBACK_PORT, callbackPath: CALLBACK_PATH });
57
+ }
58
+
59
+ async generateAuthUrl(state: string, redirectUri: string) {
60
+ const params = new URLSearchParams({
61
+ client_id: CLIENT_ID,
62
+ response_type: "code",
63
+ redirect_uri: redirectUri,
64
+ scope: SCOPES.join(" "),
65
+ state,
66
+ access_type: "offline",
67
+ prompt: "consent",
68
+ });
69
+ return { url: `${AUTH_URL}?${params.toString()}`, instructions: "Complete the sign-in in your browser." };
70
+ }
71
+
72
+ async exchangeToken(code: string, _state: string, redirectUri: string): Promise<OAuthCredentials> {
73
+ const res = await fetch(TOKEN_URL, {
74
+ method: "POST",
75
+ headers: { "content-type": "application/x-www-form-urlencoded" },
76
+ body: new URLSearchParams({
77
+ client_id: CLIENT_ID,
78
+ client_secret: requireClientSecret(),
79
+ code,
80
+ grant_type: "authorization_code",
81
+ redirect_uri: redirectUri,
82
+ }),
83
+ });
84
+ if (!res.ok) throw new Error(`Google token exchange failed (HTTP ${res.status}): ${await res.text()}`);
85
+ const data = (await res.json()) as { access_token: string; refresh_token?: string; expires_in: number };
86
+ if (!data.refresh_token) throw new Error("No refresh token received from Google. Try again with prompt=consent.");
87
+ const email = await getUserEmail(data.access_token);
88
+ return {
89
+ access: data.access_token,
90
+ refresh: data.refresh_token,
91
+ expires: Date.now() + data.expires_in * 1000 - 5 * 60 * 1000,
92
+ email,
93
+ projectId: process.env.GOOGLE_CLOUD_PROJECT || process.env.GOOGLE_CLOUD_PROJECT_ID || undefined,
94
+ };
95
+ }
96
+ }
97
+
98
+ export async function loginGoogle(ctrl: OAuthController): Promise<OAuthCredentials> {
99
+ return new GoogleOAuthFlow(ctrl).login();
100
+ }
101
+
102
+ export async function refreshGoogleToken(refreshToken: string): Promise<OAuthCredentials> {
103
+ const res = await fetch(TOKEN_URL, {
104
+ method: "POST",
105
+ headers: { "content-type": "application/x-www-form-urlencoded" },
106
+ body: new URLSearchParams({
107
+ client_id: CLIENT_ID,
108
+ client_secret: requireClientSecret(),
109
+ refresh_token: refreshToken,
110
+ grant_type: "refresh_token",
111
+ }),
112
+ });
113
+ if (!res.ok) throw new Error(`Google token refresh failed (HTTP ${res.status}): ${await res.text()}`);
114
+ const data = (await res.json()) as { access_token: string; expires_in: number; refresh_token?: string };
115
+ return {
116
+ access: data.access_token,
117
+ refresh: data.refresh_token || refreshToken,
118
+ expires: Date.now() + data.expires_in * 1000 - 5 * 60 * 1000,
119
+ };
120
+ }
@@ -0,0 +1,50 @@
1
+ /** Per-provider OAuth login + refresh dispatch. */
2
+ import type { AuthProvider } from "../storage";
3
+ import type { OAuthController, OAuthCredentials } from "../types";
4
+ import { loginAnthropic, refreshAnthropicToken } from "./anthropic";
5
+ import { loginOpenAI, refreshOpenAIToken } from "./openai";
6
+ import { loginGoogle, refreshGoogleToken } from "./google";
7
+
8
+ export interface OAuthFlow {
9
+ readonly provider: AuthProvider;
10
+ readonly label: string;
11
+ /** Run the interactive browser/PKCE login. */
12
+ login(ctrl: OAuthController): Promise<OAuthCredentials>;
13
+ /** Exchange a refresh token for a fresh access token. */
14
+ refresh(refreshToken: string): Promise<OAuthCredentials>;
15
+ /** Whether the minted token works with joc's bundled adapter end-to-end. */
16
+ readonly verifiedEndToEnd: boolean;
17
+ /** Human note about adapter compatibility. */
18
+ readonly note?: string;
19
+ }
20
+
21
+ export const OAUTH_FLOW_REGISTRY: Record<AuthProvider, OAuthFlow> = {
22
+ anthropic: {
23
+ provider: "anthropic",
24
+ label: "Anthropic (Claude Pro/Max)",
25
+ login: loginAnthropic,
26
+ refresh: refreshAnthropicToken,
27
+ verifiedEndToEnd: true,
28
+ note: "Works directly with the bundled Anthropic Messages adapter.",
29
+ },
30
+ openai: {
31
+ provider: "openai",
32
+ label: "OpenAI (ChatGPT/Codex)",
33
+ login: loginOpenAI,
34
+ refresh: refreshOpenAIToken,
35
+ verifiedEndToEnd: false,
36
+ note: "Token targets the ChatGPT/Codex backend; the bundled chat adapter expects an OPENAI_API_KEY.",
37
+ },
38
+ gemini: {
39
+ provider: "gemini",
40
+ label: "Google (Gemini CLI / Cloud Code Assist)",
41
+ login: loginGoogle,
42
+ refresh: refreshGoogleToken,
43
+ verifiedEndToEnd: false,
44
+ note: "Token targets Cloud Code Assist; the bundled generativelanguage adapter prefers GEMINI_API_KEY.",
45
+ },
46
+ };
47
+
48
+ export { loginAnthropic, refreshAnthropicToken } from "./anthropic";
49
+ export { loginOpenAI, refreshOpenAIToken } from "./openai";
50
+ export { loginGoogle, refreshGoogleToken } from "./google";
@@ -0,0 +1,130 @@
1
+ /**
2
+ * OpenAI (ChatGPT / Codex) OAuth — real PKCE flow with device-code fallback.
3
+ * Faithful port of gjc's packages/ai/src/utils/oauth/openai-codex.ts.
4
+ *
5
+ * NOTE: tokens minted here authenticate against OpenAI's ChatGPT/Codex
6
+ * backend. joc's default `openai` adapter targets the Chat Completions API,
7
+ * which expects a platform API key. Use this flow with a Codex-compatible
8
+ * endpoint, or prefer an `OPENAI_API_KEY` for the bundled chat adapter.
9
+ */
10
+ import { OAuthCallbackFlow } from "../callback-server";
11
+ import { generatePKCE } from "../pkce";
12
+ import type { OAuthController, OAuthCredentials } from "../types";
13
+
14
+ const CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann";
15
+ const AUTHORIZE_URL = "https://auth.openai.com/oauth/authorize";
16
+ const TOKEN_URL = "https://auth.openai.com/oauth/token";
17
+ const CALLBACK_PORT = 1455;
18
+ const CALLBACK_PATH = "/auth/callback";
19
+ const SCOPE = "openid profile email offline_access";
20
+ const TIMEOUT_MS = 15_000;
21
+ const JWT_AUTH_CLAIM = "https://api.openai.com/auth";
22
+ const JWT_PROFILE_CLAIM = "https://api.openai.com/profile";
23
+
24
+ function decodeJwt<T = Record<string, unknown>>(token: string): T | null {
25
+ try {
26
+ const parts = token.split(".");
27
+ if (parts.length !== 3) return null;
28
+ return JSON.parse(Buffer.from(parts[1] ?? "", "base64").toString("utf-8")) as T;
29
+ } catch {
30
+ return null;
31
+ }
32
+ }
33
+
34
+ function profileFromToken(access: string): { accountId?: string; email?: string } {
35
+ const payload = decodeJwt<Record<string, any>>(access);
36
+ const accountId = payload?.[JWT_AUTH_CLAIM]?.chatgpt_account_id;
37
+ const email = payload?.[JWT_PROFILE_CLAIM]?.email?.trim?.().toLowerCase?.();
38
+ return {
39
+ accountId: typeof accountId === "string" && accountId ? accountId : undefined,
40
+ email: typeof email === "string" && email ? email : undefined,
41
+ };
42
+ }
43
+
44
+ async function exchangeCodeForToken(code: string, verifier: string, redirectUri: string): Promise<OAuthCredentials> {
45
+ const res = await fetch(TOKEN_URL, {
46
+ method: "POST",
47
+ headers: { "content-type": "application/x-www-form-urlencoded" },
48
+ body: new URLSearchParams({
49
+ grant_type: "authorization_code",
50
+ client_id: CLIENT_ID,
51
+ code,
52
+ code_verifier: verifier,
53
+ redirect_uri: redirectUri,
54
+ }),
55
+ signal: AbortSignal.timeout(TIMEOUT_MS),
56
+ });
57
+ if (!res.ok) throw new Error(`OpenAI token exchange failed (HTTP ${res.status}): ${await res.text()}`);
58
+ const data = (await res.json()) as { access_token?: string; refresh_token?: string; expires_in?: number };
59
+ if (!data.access_token || !data.refresh_token || typeof data.expires_in !== "number") {
60
+ throw new Error("OpenAI token response missing required fields");
61
+ }
62
+ const { accountId, email } = profileFromToken(data.access_token);
63
+ return {
64
+ access: data.access_token,
65
+ refresh: data.refresh_token,
66
+ expires: Date.now() + data.expires_in * 1000 - 5 * 60 * 1000,
67
+ accountId,
68
+ email,
69
+ };
70
+ }
71
+
72
+ class OpenAIOAuthFlow extends OAuthCallbackFlow {
73
+ #verifier = "";
74
+
75
+ constructor(ctrl: OAuthController) {
76
+ super(ctrl, {
77
+ preferredPort: CALLBACK_PORT,
78
+ callbackPath: CALLBACK_PATH,
79
+ redirectUri: `http://localhost:${CALLBACK_PORT}${CALLBACK_PATH}`,
80
+ });
81
+ }
82
+
83
+ async generateAuthUrl(state: string, redirectUri: string) {
84
+ const pkce = await generatePKCE();
85
+ this.#verifier = pkce.verifier;
86
+ const params = new URLSearchParams({
87
+ response_type: "code",
88
+ client_id: CLIENT_ID,
89
+ redirect_uri: redirectUri,
90
+ scope: SCOPE,
91
+ code_challenge: pkce.challenge,
92
+ code_challenge_method: "S256",
93
+ state,
94
+ id_token_add_organizations: "true",
95
+ codex_cli_simplified_flow: "true",
96
+ originator: "joc",
97
+ });
98
+ return { url: `${AUTHORIZE_URL}?${params.toString()}`, instructions: "Complete login in your browser." };
99
+ }
100
+
101
+ async exchangeToken(code: string, _state: string, redirectUri: string): Promise<OAuthCredentials> {
102
+ return exchangeCodeForToken(code, this.#verifier, redirectUri);
103
+ }
104
+ }
105
+
106
+ export async function loginOpenAI(ctrl: OAuthController): Promise<OAuthCredentials> {
107
+ return new OpenAIOAuthFlow(ctrl).login();
108
+ }
109
+
110
+ export async function refreshOpenAIToken(refreshToken: string): Promise<OAuthCredentials> {
111
+ const res = await fetch(TOKEN_URL, {
112
+ method: "POST",
113
+ headers: { "content-type": "application/x-www-form-urlencoded" },
114
+ body: new URLSearchParams({ grant_type: "refresh_token", refresh_token: refreshToken, client_id: CLIENT_ID }),
115
+ signal: AbortSignal.timeout(TIMEOUT_MS),
116
+ });
117
+ if (!res.ok) throw new Error(`OpenAI token refresh failed (HTTP ${res.status}): ${await res.text()}`);
118
+ const data = (await res.json()) as { access_token?: string; refresh_token?: string; expires_in?: number };
119
+ if (!data.access_token || typeof data.expires_in !== "number") {
120
+ throw new Error("OpenAI refresh response missing required fields");
121
+ }
122
+ const { accountId, email } = profileFromToken(data.access_token);
123
+ return {
124
+ access: data.access_token,
125
+ refresh: data.refresh_token || refreshToken,
126
+ expires: Date.now() + data.expires_in * 1000 - 5 * 60 * 1000,
127
+ accountId,
128
+ email,
129
+ };
130
+ }
@@ -0,0 +1,23 @@
1
+ export {
2
+ resolveCredential,
3
+ snapshotProvider,
4
+ getStoredOAuth,
5
+ setOauthToken,
6
+ setOauthCredential,
7
+ clearOauthToken,
8
+ setApiKey,
9
+ } from "./storage";
10
+ export type { AuthProvider, Credential, AuthSnapshot } from "./storage";
11
+ export {
12
+ OAUTH_FLOWS,
13
+ openInBrowser,
14
+ interactiveLogin,
15
+ loginOAuth,
16
+ logoutOAuth,
17
+ } from "./oauth";
18
+ export type { OauthFlowDef } from "./oauth";
19
+ export { refreshOAuthToken, rotateOAuthToken } from "./refresh";
20
+ export type { RefreshResult } from "./refresh";
21
+ export { OAUTH_FLOW_REGISTRY } from "./flows";
22
+ export type { OAuthFlow } from "./flows";
23
+ export type { OAuthController, OAuthCredentials, OAuthAuthInfo } from "./types";