oh-my-harness 0.17.0 → 0.18.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.
package/README.md CHANGED
@@ -125,7 +125,10 @@ your-project/
125
125
  │ • Claude CLI │──▶│ NL Processing │◀── "React + FastAPI
126
126
  │ • Claude API │ │ (describe your │ TDD enforced"
127
127
  │ • OpenAI API │ │ │
128
- │ • Gemini API │ └────────┬────────────┘
128
+ │ • Gemini API │ │ │
129
+ │ • Codex OAuth │ │ │
130
+ │ • Codex OAuth │ │ │
131
+ │ API │ └────────┬────────────┘
129
132
  └────────────────┘ │
130
133
  (global AI config) ┌────────▼────────────┐
131
134
  │ Project Detector │ ← Auto-detects language,
@@ -176,8 +179,10 @@ oh-my-harness supports multiple AI providers for natural language mode:
176
179
  |----------|-------|------------------|---------|
177
180
  | **Claude CLI** | `claude` command installed | Opus 4.6, Sonnet 4.6, Haiku 4.5 | ✓ |
178
181
  | **Claude API** | Set `ANTHROPIC_API_KEY` | Opus 4.6, Sonnet 4.6, Haiku 4.5 | Sonnet 4.6 |
179
- | **OpenAI API** | Set `OPENAI_API_KEY` | GPT-5.4, GPT-5.4-mini, GPT-5.4-nano, GPT-4.1, GPT-4.1-mini, o3, o4-mini | GPT-5.4 |
182
+ | **OpenAI API** | Set `OPENAI_API_KEY` | GPT-5.5, GPT-5.4, GPT-5.4-mini, GPT-5.4-nano, GPT-4.1, GPT-4.1-mini, o3, o4-mini | GPT-5.5 |
180
183
  | **Gemini API** | Set `GOOGLE_API_KEY` | Gemini 2.5 Pro, Gemini 2.5 Flash, Gemini 2.5 Flash Lite, Gemini 3.1 Pro Preview | Gemini 2.5 Pro |
184
+ | **Codex OAuth** | `codex` command installed + `codex login`; runs `codex exec` | GPT-5.5, GPT-5.4, GPT-5.4-mini | GPT-5.5 |
185
+ | **Codex OAuth API** | `omh config` device-code login; imports `~/.codex/auth.json` once if present, then uses `~/.omh` | GPT-5.5, GPT-5.4, GPT-5.4-mini | GPT-5.5 |
181
186
 
182
187
  Configuration is saved to `~/.omh/config.json` and selected via interactive UI on first use:
183
188
 
@@ -452,6 +457,8 @@ oh-my-harness/
452
457
  - **Node.js** >= 20
453
458
  - **Claude CLI** (optional, for default NL mode) — [Install guide](https://docs.anthropic.com/en/docs/claude-code)
454
459
  - **API Keys** (optional, for Claude/OpenAI/Gemini API modes) — set `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, or `GOOGLE_API_KEY`
460
+ - **Codex CLI OAuth** (optional, for `codex` CLI-wrapper mode) — install `codex` and run `codex login`
461
+ - **Codex OAuth API** (optional, experimental direct mode) — run `omh config` and choose Codex OAuth API to complete device-code sign-in; credentials are stored under `~/.omh`
455
462
 
456
463
  ---
457
464
 
@@ -465,7 +472,7 @@ oh-my-harness/
465
472
  - [x] `omh stats` — TUI analytics dashboard (ink)
466
473
  - [x] Stateful hook logging — events.jsonl
467
474
  - [x] TDD Guard — enforce test-first workflow
468
- - [x] Multi-provider AI support — Claude API, OpenAI, Gemini
475
+ - [x] Multi-provider AI support — Claude API, OpenAI, Gemini, Codex OAuth
469
476
  - [x] Interactive model selection per provider
470
477
  - [x] GitHub star prompt — first-time only
471
478
  - [x] Codex emitter — `AGENTS.md` + `.codex/hooks.json` + `.codex/config.toml`
@@ -9,6 +9,15 @@ function printSummary(config) {
9
9
  console.log(` model: ${chalk.cyan(config.model ?? "(default)")}`);
10
10
  console.log(` api key: ${chalk.dim(maskApiKey(config.apiKey))}`);
11
11
  }
12
+ else if (config.method === "oauth") {
13
+ console.log(` model: ${chalk.cyan(config.model ?? "(default)")}`);
14
+ console.log(` command: ${chalk.cyan(config.cliCommand ?? config.provider)}`);
15
+ console.log(` auth: ${chalk.dim("uses Codex CLI OAuth session; run `codex login` to sign in")}`);
16
+ }
17
+ else if (config.method === "oauth-api") {
18
+ console.log(` model: ${chalk.cyan(config.model ?? "(default)")}`);
19
+ console.log(` auth: ${chalk.dim("uses Codex OAuth token from ~/.omh; run `omh config` to sign in or refresh")}`);
20
+ }
12
21
  else {
13
22
  console.log(` command: ${chalk.cyan(config.cliCommand ?? config.provider)}`);
14
23
  }
@@ -191,6 +191,16 @@ async function checkProviderConfig(messages) {
191
191
  messages.push(`INFO: AI provider: ${config.provider} (api${model}). ` +
192
192
  "If your API key expired, run `omh config` to update it or switch provider.");
193
193
  }
194
+ else if (config.method === "oauth") {
195
+ const model = config.model ? `, ${config.model}` : "";
196
+ messages.push(`INFO: AI provider: ${config.provider} (oauth${model}). ` +
197
+ "Uses Codex CLI auth; run `codex login` if the session expired.");
198
+ }
199
+ else if (config.method === "oauth-api") {
200
+ const model = config.model ? `, ${config.model}` : "";
201
+ messages.push(`INFO: AI provider: ${config.provider} (oauth-api${model}). ` +
202
+ "Uses ~/.omh Codex OAuth auth store; run `omh config` if the token expired.");
203
+ }
194
204
  else {
195
205
  messages.push(`INFO: AI provider: ${config.provider} (cli). Run \`omh config\` to switch provider or model.`);
196
206
  }
@@ -1,5 +1,6 @@
1
1
  import * as p from "@clack/prompts";
2
2
  import { getAvailableProviders, getProviderDefinition, } from "../../nl/provider-registry.js";
3
+ import { ensureCodexOauthApiAuth } from "../../nl/providers/codex-oauth-api.js";
3
4
  import { saveProviderConfig, } from "../../nl/config-store.js";
4
5
  export async function runProviderSetup() {
5
6
  p.intro("AI Provider Setup");
@@ -17,15 +18,18 @@ export async function runProviderSetup() {
17
18
  return undefined;
18
19
  }
19
20
  const def = getProviderDefinition(providerName);
20
- // Step 2: Select method (CLI or API)
21
+ // Step 2: Select method (CLI, API, or OAuth)
21
22
  let method;
22
- if (def.supportsCli && def.supportsApi) {
23
+ const methodOptions = [
24
+ def.supportsCli ? { value: "cli", label: `CLI tool (${def.cliCommand ?? def.name})` } : undefined,
25
+ def.supportsApi ? { value: "api", label: "API Key" } : undefined,
26
+ def.supportsOAuth ? { value: "oauth", label: `Codex OAuth (${def.cliCommand ?? def.name} login)` } : undefined,
27
+ def.supportsOAuthApi ? { value: "oauth-api", label: "Codex OAuth API (~/.omh auth store)" } : undefined,
28
+ ].filter((option) => option !== undefined);
29
+ if (methodOptions.length > 1) {
23
30
  const selected = await p.select({
24
31
  message: "How would you like to connect?",
25
- options: [
26
- { value: "cli", label: `CLI tool (${def.cliCommand ?? def.name})` },
27
- { value: "api", label: "API Key" },
28
- ],
32
+ options: methodOptions,
29
33
  });
30
34
  if (p.isCancel(selected)) {
31
35
  p.cancel("Provider setup cancelled.");
@@ -33,11 +37,11 @@ export async function runProviderSetup() {
33
37
  }
34
38
  method = selected;
35
39
  }
36
- else if (def.supportsCli) {
37
- method = "cli";
40
+ else if (methodOptions[0]) {
41
+ method = methodOptions[0].value;
38
42
  }
39
43
  else {
40
- method = "api";
44
+ throw new Error(`Provider "${def.name}" has no supported authentication method`);
41
45
  }
42
46
  const config = {
43
47
  provider: providerName,
@@ -75,6 +79,33 @@ export async function runProviderSetup() {
75
79
  }
76
80
  config.model = selectedModel;
77
81
  }
82
+ else if (method === "oauth" || method === "oauth-api") {
83
+ if (method === "oauth") {
84
+ config.cliCommand = def.cliCommand ?? def.name;
85
+ }
86
+ const selectedModel = await p.select({
87
+ message: "Select model:",
88
+ options: def.availableModels.map((m) => ({
89
+ value: m.id,
90
+ label: m.label,
91
+ hint: m.id === def.defaultModel ? "default" : undefined,
92
+ })),
93
+ initialValue: def.defaultModel,
94
+ });
95
+ if (p.isCancel(selectedModel)) {
96
+ p.cancel("Provider setup cancelled.");
97
+ return undefined;
98
+ }
99
+ config.model = selectedModel;
100
+ if (method === "oauth-api") {
101
+ await ensureCodexOauthApiAuth({
102
+ onDeviceCode: ({ url, code }) => {
103
+ p.note(`Open ${url} and enter code: ${code}`, "Codex OAuth API sign-in");
104
+ },
105
+ });
106
+ p.log.success("Codex OAuth API session saved under ~/.omh.");
107
+ }
108
+ }
78
109
  else {
79
110
  config.cliCommand = def.cliCommand ?? def.name;
80
111
  }
@@ -1,6 +1,6 @@
1
1
  export interface ProviderConfig {
2
- provider: "claude" | "openai" | "gemini";
3
- method: "cli" | "api";
2
+ provider: "claude" | "openai" | "gemini" | "codex" | "codex-oauth-api";
3
+ method: "cli" | "api" | "oauth" | "oauth-api";
4
4
  apiKey?: string;
5
5
  model?: string;
6
6
  cliCommand?: string;
@@ -12,6 +12,8 @@ export interface ProviderDefinition {
12
12
  displayName: string;
13
13
  supportsCli: boolean;
14
14
  supportsApi: boolean;
15
+ supportsOAuth: boolean;
16
+ supportsOAuthApi: boolean;
15
17
  defaultModel: string;
16
18
  availableModels: ModelEntry[];
17
19
  cliCommand?: string;
@@ -2,12 +2,16 @@ import { createClaudeCliProvider } from "./providers/claude-cli.js";
2
2
  import { createClaudeApiProvider } from "./providers/claude-api.js";
3
3
  import { createOpenaiApiProvider } from "./providers/openai-api.js";
4
4
  import { createGeminiApiProvider } from "./providers/gemini-api.js";
5
+ import { createCodexOauthProvider } from "./providers/codex-oauth.js";
6
+ import { createCodexOauthApiProvider } from "./providers/codex-oauth-api.js";
5
7
  const providers = [
6
8
  {
7
9
  name: "claude",
8
10
  displayName: "Claude (Anthropic)",
9
11
  supportsCli: true,
10
12
  supportsApi: true,
13
+ supportsOAuth: false,
14
+ supportsOAuthApi: false,
11
15
  defaultModel: "claude-sonnet-4-6",
12
16
  availableModels: [
13
17
  { id: "claude-opus-4-6", label: "Claude Opus 4.6 — most capable, 1M context" },
@@ -18,12 +22,15 @@ const providers = [
18
22
  },
19
23
  {
20
24
  name: "openai",
21
- displayName: "OpenAI (GPT-5.4)",
25
+ displayName: "OpenAI (GPT-5.5)",
22
26
  supportsCli: false,
23
27
  supportsApi: true,
24
- defaultModel: "gpt-5.4",
28
+ supportsOAuth: false,
29
+ supportsOAuthApi: false,
30
+ defaultModel: "gpt-5.5",
25
31
  availableModels: [
26
- { id: "gpt-5.4", label: "GPT-5.4flagship, agentic & coding" },
32
+ { id: "gpt-5.5", label: "GPT-5.5newest frontier, complex reasoning & coding" },
33
+ { id: "gpt-5.4", label: "GPT-5.4 — previous flagship, agentic & coding" },
27
34
  { id: "gpt-5.4-mini", label: "GPT-5.4 Mini — strongest mini model" },
28
35
  { id: "gpt-5.4-nano", label: "GPT-5.4 Nano — cheapest GPT-5.4 class" },
29
36
  { id: "gpt-4.1", label: "GPT-4.1 — best non-reasoning, coding" },
@@ -37,6 +44,8 @@ const providers = [
37
44
  displayName: "Gemini (Google)",
38
45
  supportsCli: false,
39
46
  supportsApi: true,
47
+ supportsOAuth: false,
48
+ supportsOAuthApi: false,
40
49
  defaultModel: "gemini-2.5-pro",
41
50
  availableModels: [
42
51
  { id: "gemini-2.5-pro", label: "Gemini 2.5 Pro — most advanced stable" },
@@ -46,6 +55,35 @@ const providers = [
46
55
  { id: "gemini-3-flash-preview", label: "Gemini 3 Flash Preview — frontier performance (preview)" },
47
56
  ],
48
57
  },
58
+ {
59
+ name: "codex",
60
+ displayName: "Codex OAuth (OpenAI ChatGPT login)",
61
+ supportsCli: false,
62
+ supportsApi: false,
63
+ supportsOAuth: true,
64
+ supportsOAuthApi: false,
65
+ defaultModel: "gpt-5.5",
66
+ availableModels: [
67
+ { id: "gpt-5.5", label: "GPT-5.5 — frontier Codex reasoning when available" },
68
+ { id: "gpt-5.4", label: "GPT-5.4 — previous Codex default-capable flagship" },
69
+ { id: "gpt-5.4-mini", label: "GPT-5.4 Mini — faster Codex runs" },
70
+ ],
71
+ cliCommand: "codex",
72
+ },
73
+ {
74
+ name: "codex-oauth-api",
75
+ displayName: "Codex OAuth API (ChatGPT token direct)",
76
+ supportsCli: false,
77
+ supportsApi: false,
78
+ supportsOAuth: false,
79
+ supportsOAuthApi: true,
80
+ defaultModel: "gpt-5.5",
81
+ availableModels: [
82
+ { id: "gpt-5.5", label: "GPT-5.5 — direct Codex OAuth Responses endpoint" },
83
+ { id: "gpt-5.4", label: "GPT-5.4 — previous direct Codex OAuth model" },
84
+ { id: "gpt-5.4-mini", label: "GPT-5.4 Mini — faster direct Codex OAuth runs" },
85
+ ],
86
+ },
49
87
  ];
50
88
  export function getAvailableProviders() {
51
89
  return [...providers];
@@ -62,15 +100,41 @@ export function createProvider(config) {
62
100
  if (!def) {
63
101
  throw new Error(`Unknown AI provider: "${config.provider}". Available: ${providers.map((p) => p.name).join(", ")}`);
64
102
  }
65
- if (config.method !== "cli" && config.method !== "api") {
103
+ if (config.method !== "cli" && config.method !== "api" && config.method !== "oauth" && config.method !== "oauth-api") {
66
104
  throw new Error(`Unsupported provider method: "${String(config.method)}"`);
67
105
  }
106
+ if (config.method === "cli" && !def.supportsCli) {
107
+ throw new Error(`Provider "${config.provider}" does not support CLI mode`);
108
+ }
109
+ if (config.method === "api" && !def.supportsApi) {
110
+ throw new Error(`Provider "${config.provider}" does not support API mode`);
111
+ }
112
+ if (config.method === "oauth" && !def.supportsOAuth) {
113
+ throw new Error(`Provider "${config.provider}" does not support OAuth mode`);
114
+ }
115
+ if (config.method === "oauth-api" && !def.supportsOAuthApi) {
116
+ throw new Error(`Provider "${config.provider}" does not support OAuth API mode`);
117
+ }
68
118
  if (config.method === "cli") {
69
119
  if (config.provider === "claude") {
70
120
  return createClaudeCliProvider(config.cliCommand ?? "claude");
71
121
  }
72
122
  throw new Error(`Provider "${config.provider}" does not support CLI mode`);
73
123
  }
124
+ if (config.method === "oauth") {
125
+ if (config.provider === "codex") {
126
+ const model = config.model?.trim() || def.defaultModel;
127
+ return createCodexOauthProvider(config.cliCommand ?? "codex", model);
128
+ }
129
+ throw new Error(`Provider "${config.provider}" does not support OAuth mode`);
130
+ }
131
+ if (config.method === "oauth-api") {
132
+ if (config.provider === "codex-oauth-api") {
133
+ const model = config.model?.trim() || def.defaultModel;
134
+ return createCodexOauthApiProvider({ model });
135
+ }
136
+ throw new Error(`Provider "${config.provider}" does not support OAuth API mode`);
137
+ }
74
138
  // API mode
75
139
  const apiKey = config.apiKey?.trim();
76
140
  if (!apiKey) {
@@ -0,0 +1,48 @@
1
+ import type { LLMProvider } from "../provider-registry.js";
2
+ export interface CodexOauthApiProviderOptions {
3
+ model?: string;
4
+ authPath?: string;
5
+ codexCliAuthPath?: string;
6
+ responsesUrl?: string;
7
+ timeoutMs?: number;
8
+ }
9
+ export interface CodexOauthApiLoginOptions {
10
+ authPath?: string;
11
+ issuer?: string;
12
+ tokenUrl?: string;
13
+ timeoutMs?: number;
14
+ pollIntervalMs?: number;
15
+ maxWaitMs?: number;
16
+ onDeviceCode?: (info: {
17
+ url: string;
18
+ code: string;
19
+ }) => void | Promise<void>;
20
+ }
21
+ interface CodexAuthFile {
22
+ provider?: string;
23
+ source?: string;
24
+ OPENAI_API_KEY?: string | null;
25
+ tokens?: {
26
+ access_token?: string;
27
+ refresh_token?: string;
28
+ id_token?: string;
29
+ account_id?: string;
30
+ };
31
+ last_refresh?: string;
32
+ auth_mode?: string;
33
+ }
34
+ interface AuthState {
35
+ accessToken: string;
36
+ refreshToken?: string;
37
+ idToken?: string;
38
+ accountId?: string;
39
+ authPath?: string;
40
+ authFile?: CodexAuthFile;
41
+ }
42
+ export declare function getCodexOauthApiAuthPath(): string;
43
+ export declare function loginCodexOauthApi(options?: CodexOauthApiLoginOptions): Promise<AuthState>;
44
+ export declare function ensureCodexOauthApiAuth(options?: CodexOauthApiLoginOptions & {
45
+ codexCliAuthPath?: string;
46
+ }): Promise<AuthState>;
47
+ export declare function createCodexOauthApiProvider(options?: CodexOauthApiProviderOptions): LLMProvider;
48
+ export {};
@@ -0,0 +1,416 @@
1
+ import fs from "node:fs/promises";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { getConfigDir } from "../config-store.js";
5
+ const DEFAULT_MODEL = "gpt-5.5";
6
+ const ISSUER = "https://auth.openai.com";
7
+ const RESPONSES_URL = "https://chatgpt.com/backend-api/codex/responses";
8
+ const TOKEN_URL = `${ISSUER}/oauth/token`;
9
+ const CODEX_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann";
10
+ const REQUEST_TIMEOUT_MS = 120_000;
11
+ const MAX_ATTEMPTS = 3;
12
+ const AUTH_FILENAME = "codex-oauth-api-auth.json";
13
+ export function getCodexOauthApiAuthPath() {
14
+ return path.join(getConfigDir(), AUTH_FILENAME);
15
+ }
16
+ function getCodexCliAuthPath() {
17
+ const codexHome = process.env.CODEX_HOME?.trim() || path.join(process.env.HOME ?? os.homedir(), ".codex");
18
+ return path.join(codexHome, "auth.json");
19
+ }
20
+ function decodeJwtPayload(token) {
21
+ const payload = token?.split(".")[1];
22
+ if (!payload)
23
+ return undefined;
24
+ try {
25
+ const padded = `${payload}${"=".repeat((4 - (payload.length % 4)) % 4)}`;
26
+ return JSON.parse(Buffer.from(padded.replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf-8"));
27
+ }
28
+ catch {
29
+ return undefined;
30
+ }
31
+ }
32
+ function readNestedAccountId(payload) {
33
+ if (!payload)
34
+ return undefined;
35
+ const authClaim = payload["https://api.openai.com/auth"];
36
+ const nested = authClaim && typeof authClaim === "object"
37
+ ? authClaim.chatgpt_account_id
38
+ : undefined;
39
+ const dotted = payload["https://api.openai.com/auth.chatgpt_account_id"];
40
+ const direct = payload.chatgpt_account_id;
41
+ const orgs = payload.organizations;
42
+ const firstOrg = Array.isArray(orgs) ? orgs[0]?.id : undefined;
43
+ const candidate = nested ?? dotted ?? direct ?? firstOrg;
44
+ return typeof candidate === "string" && candidate.trim() ? candidate : undefined;
45
+ }
46
+ function extractAccountId(tokens) {
47
+ return tokens?.account_id
48
+ ?? readNestedAccountId(decodeJwtPayload(tokens?.id_token))
49
+ ?? readNestedAccountId(decodeJwtPayload(tokens?.access_token));
50
+ }
51
+ async function readAuthFile(authPath) {
52
+ try {
53
+ return JSON.parse(await fs.readFile(authPath, "utf-8"));
54
+ }
55
+ catch {
56
+ return undefined;
57
+ }
58
+ }
59
+ async function writeAuthFile(authPath, authFile) {
60
+ await fs.mkdir(path.dirname(authPath), { recursive: true });
61
+ await fs.writeFile(authPath, `${JSON.stringify(authFile, null, 2)}\n`, { encoding: "utf-8", mode: 0o600 });
62
+ await fs.chmod(authPath, 0o600);
63
+ }
64
+ function authStateFromFile(authFile, authPath) {
65
+ const accessToken = authFile.tokens?.access_token?.trim();
66
+ if (!accessToken)
67
+ return undefined;
68
+ return {
69
+ accessToken,
70
+ refreshToken: authFile.tokens?.refresh_token,
71
+ idToken: authFile.tokens?.id_token,
72
+ accountId: extractAccountId(authFile.tokens),
73
+ authPath,
74
+ authFile,
75
+ };
76
+ }
77
+ async function importCodexCliAuth(cliAuthPath, authPath) {
78
+ const cliAuth = await readAuthFile(cliAuthPath);
79
+ const state = cliAuth ? authStateFromFile(cliAuth, authPath) : undefined;
80
+ if (!state || !cliAuth?.tokens)
81
+ return undefined;
82
+ const nextAuth = {
83
+ provider: "codex-oauth-api",
84
+ source: "codex-cli-import",
85
+ auth_mode: "chatgpt",
86
+ tokens: {
87
+ ...cliAuth.tokens,
88
+ account_id: extractAccountId(cliAuth.tokens),
89
+ },
90
+ last_refresh: new Date().toISOString(),
91
+ };
92
+ await writeAuthFile(authPath, nextAuth);
93
+ return authStateFromFile(nextAuth, authPath);
94
+ }
95
+ async function loadAuthState(authPath, cliAuthPath) {
96
+ const envToken = process.env.CODEX_ACCESS_TOKEN?.trim() || process.env.CODEX_API_KEY?.trim();
97
+ if (envToken) {
98
+ return {
99
+ accessToken: envToken,
100
+ accountId: readNestedAccountId(decodeJwtPayload(envToken)),
101
+ };
102
+ }
103
+ const omhAuth = await readAuthFile(authPath);
104
+ const omhState = omhAuth ? authStateFromFile(omhAuth, authPath) : undefined;
105
+ if (omhState)
106
+ return omhState;
107
+ const imported = await importCodexCliAuth(cliAuthPath, authPath);
108
+ if (imported)
109
+ return imported;
110
+ throw new Error(`Codex OAuth API credentials not found. Run \`omh config\` and choose Codex OAuth API to sign in, ` +
111
+ `or set CODEX_ACCESS_TOKEN.`);
112
+ }
113
+ async function persistRefreshedAuth(state, tokenResponse) {
114
+ if (!state.authPath || !state.authFile || !tokenResponse.access_token) {
115
+ return state;
116
+ }
117
+ const nextTokens = {
118
+ ...state.authFile.tokens,
119
+ access_token: tokenResponse.access_token,
120
+ refresh_token: tokenResponse.refresh_token ?? state.refreshToken,
121
+ id_token: tokenResponse.id_token ?? state.idToken,
122
+ };
123
+ const accountId = extractAccountId(nextTokens) ?? state.accountId;
124
+ if (accountId)
125
+ nextTokens.account_id = accountId;
126
+ const nextAuth = {
127
+ ...state.authFile,
128
+ provider: "codex-oauth-api",
129
+ auth_mode: "chatgpt",
130
+ tokens: nextTokens,
131
+ last_refresh: new Date().toISOString(),
132
+ };
133
+ await writeAuthFile(state.authPath, nextAuth);
134
+ return {
135
+ accessToken: tokenResponse.access_token,
136
+ refreshToken: nextTokens.refresh_token,
137
+ idToken: nextTokens.id_token,
138
+ accountId,
139
+ authPath: state.authPath,
140
+ authFile: nextAuth,
141
+ };
142
+ }
143
+ async function refreshAuth(state, timeoutMs) {
144
+ if (!state.refreshToken) {
145
+ throw new Error("Codex OAuth API token expired and no refresh token is available. Run `omh config` again.");
146
+ }
147
+ const response = await fetchWithTimeout(TOKEN_URL, {
148
+ method: "POST",
149
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
150
+ body: new URLSearchParams({
151
+ client_id: CODEX_CLIENT_ID,
152
+ grant_type: "refresh_token",
153
+ refresh_token: state.refreshToken,
154
+ }).toString(),
155
+ }, timeoutMs);
156
+ if (!response.ok) {
157
+ throw new Error(`Codex OAuth token refresh failed (${response.status}): ${await response.text()}`);
158
+ }
159
+ const tokenResponse = (await response.json());
160
+ if (!tokenResponse.access_token) {
161
+ throw new Error("Codex OAuth token refresh returned no access token");
162
+ }
163
+ return persistRefreshedAuth(state, tokenResponse);
164
+ }
165
+ async function fetchWithTimeout(url, init, timeoutMs) {
166
+ const controller = new AbortController();
167
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
168
+ try {
169
+ return await fetch(url, { ...init, signal: controller.signal });
170
+ }
171
+ catch (err) {
172
+ if (err.name === "AbortError") {
173
+ throw new Error(`Codex OAuth API request timed out after ${Math.ceil(timeoutMs / 1000)} seconds`);
174
+ }
175
+ throw err;
176
+ }
177
+ finally {
178
+ clearTimeout(timeout);
179
+ }
180
+ }
181
+ function buildHeaders(state) {
182
+ const headers = {
183
+ "Authorization": `Bearer ${state.accessToken}`,
184
+ "Content-Type": "application/json",
185
+ "Accept": "text/event-stream, application/json",
186
+ "originator": "codex_cli_rs",
187
+ "User-Agent": "codex_cli_rs/0.0.1",
188
+ };
189
+ if (state.accountId) {
190
+ headers["ChatGPT-Account-ID"] = state.accountId;
191
+ }
192
+ return headers;
193
+ }
194
+ function buildBody(prompt, model) {
195
+ return JSON.stringify({
196
+ model,
197
+ instructions: "You are a concise assistant. Return only the requested answer.",
198
+ input: [
199
+ {
200
+ role: "user",
201
+ content: [{ type: "input_text", text: prompt }],
202
+ },
203
+ ],
204
+ store: false,
205
+ stream: true,
206
+ });
207
+ }
208
+ function extractTextFromJson(data) {
209
+ if (typeof data !== "object" || data === null)
210
+ return "";
211
+ const root = data;
212
+ if (typeof root.output_text === "string")
213
+ return root.output_text;
214
+ if (typeof root.delta === "string")
215
+ return root.delta;
216
+ if (typeof root.text === "string")
217
+ return root.text;
218
+ if (root.response) {
219
+ const nested = extractTextFromJson(root.response);
220
+ if (nested)
221
+ return nested;
222
+ }
223
+ const output = Array.isArray(root.output) ? root.output : [];
224
+ const parts = [];
225
+ for (const item of output) {
226
+ if (typeof item !== "object" || item === null)
227
+ continue;
228
+ const outputItem = item;
229
+ if (typeof outputItem.text === "string")
230
+ parts.push(outputItem.text);
231
+ const content = Array.isArray(outputItem.content) ? outputItem.content : [];
232
+ for (const contentItem of content) {
233
+ if (typeof contentItem !== "object" || contentItem === null)
234
+ continue;
235
+ const maybeText = contentItem.text
236
+ ?? contentItem.output_text;
237
+ if (typeof maybeText === "string")
238
+ parts.push(maybeText);
239
+ }
240
+ }
241
+ return parts.join("");
242
+ }
243
+ function parseResponseText(raw) {
244
+ const trimmed = raw.trim();
245
+ if (!trimmed)
246
+ return "";
247
+ if (!trimmed.includes("data:")) {
248
+ return extractTextFromJson(JSON.parse(trimmed));
249
+ }
250
+ const deltas = [];
251
+ let completedText = "";
252
+ for (const line of trimmed.split(/\r?\n/)) {
253
+ if (!line.startsWith("data:"))
254
+ continue;
255
+ const payload = line.slice("data:".length).trim();
256
+ if (!payload || payload === "[DONE]")
257
+ continue;
258
+ const event = JSON.parse(payload);
259
+ if (event.type === "response.output_text.delta" && typeof event.delta === "string") {
260
+ deltas.push(event.delta);
261
+ continue;
262
+ }
263
+ if (event.type === "response.completed" && event.response) {
264
+ completedText = extractTextFromJson(event.response);
265
+ }
266
+ }
267
+ return deltas.join("") || completedText;
268
+ }
269
+ function isRetryableStatus(status) {
270
+ return status === 429 || status >= 500;
271
+ }
272
+ function sleep(ms) {
273
+ return new Promise((resolve) => setTimeout(resolve, ms));
274
+ }
275
+ async function parseJsonResponse(response, label) {
276
+ try {
277
+ return await response.json();
278
+ }
279
+ catch (err) {
280
+ throw new Error(`${label} returned invalid JSON: ${err.message}`);
281
+ }
282
+ }
283
+ export async function loginCodexOauthApi(options = {}) {
284
+ const authPath = options.authPath ?? getCodexOauthApiAuthPath();
285
+ const issuer = (options.issuer ?? ISSUER).replace(/\/$/, "");
286
+ const tokenUrl = options.tokenUrl ?? `${issuer}/oauth/token`;
287
+ const timeoutMs = options.timeoutMs ?? REQUEST_TIMEOUT_MS;
288
+ const maxWaitMs = options.maxWaitMs ?? 15 * 60 * 1000;
289
+ const deviceResponse = await fetchWithTimeout(`${issuer}/api/accounts/deviceauth/usercode`, {
290
+ method: "POST",
291
+ headers: { "Content-Type": "application/json" },
292
+ body: JSON.stringify({ client_id: CODEX_CLIENT_ID }),
293
+ }, timeoutMs);
294
+ if (!deviceResponse.ok) {
295
+ throw new Error(`Codex device authorization failed (${deviceResponse.status}): ${await deviceResponse.text()}`);
296
+ }
297
+ const device = await parseJsonResponse(deviceResponse, "Codex device authorization");
298
+ const deviceAuthId = device.device_auth_id?.trim();
299
+ const userCode = device.user_code?.trim();
300
+ if (!deviceAuthId || !userCode) {
301
+ throw new Error("Codex device authorization response was missing device_auth_id or user_code");
302
+ }
303
+ const deviceUrl = `${issuer}/codex/device`;
304
+ await options.onDeviceCode?.({ url: deviceUrl, code: userCode });
305
+ const parsedInterval = typeof device.interval === "number" ? device.interval : Number.parseInt(String(device.interval ?? "5"), 10);
306
+ const pollIntervalMs = options.pollIntervalMs ?? Math.max(Number.isFinite(parsedInterval) ? parsedInterval : 5, 1) * 1000;
307
+ const startedAt = Date.now();
308
+ let authorizationCode = "";
309
+ let codeVerifier = "";
310
+ while (Date.now() - startedAt < maxWaitMs) {
311
+ const pollResponse = await fetchWithTimeout(`${issuer}/api/accounts/deviceauth/token`, {
312
+ method: "POST",
313
+ headers: { "Content-Type": "application/json" },
314
+ body: JSON.stringify({ device_auth_id: deviceAuthId, user_code: userCode }),
315
+ }, timeoutMs);
316
+ if (pollResponse.ok) {
317
+ const poll = await parseJsonResponse(pollResponse, "Codex device authorization poll");
318
+ authorizationCode = poll.authorization_code?.trim() ?? "";
319
+ codeVerifier = poll.code_verifier?.trim() ?? "";
320
+ break;
321
+ }
322
+ if (pollResponse.status !== 403 && pollResponse.status !== 404) {
323
+ throw new Error(`Codex device authorization poll failed (${pollResponse.status}): ${await pollResponse.text()}`);
324
+ }
325
+ await sleep(pollIntervalMs);
326
+ }
327
+ if (!authorizationCode || !codeVerifier) {
328
+ throw new Error("Codex device authorization timed out before sign-in completed");
329
+ }
330
+ const tokenResponse = await fetchWithTimeout(tokenUrl, {
331
+ method: "POST",
332
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
333
+ body: new URLSearchParams({
334
+ grant_type: "authorization_code",
335
+ code: authorizationCode,
336
+ redirect_uri: `${issuer}/deviceauth/callback`,
337
+ client_id: CODEX_CLIENT_ID,
338
+ code_verifier: codeVerifier,
339
+ }).toString(),
340
+ }, timeoutMs);
341
+ if (!tokenResponse.ok) {
342
+ throw new Error(`Codex OAuth token exchange failed (${tokenResponse.status}): ${await tokenResponse.text()}`);
343
+ }
344
+ const tokens = await parseJsonResponse(tokenResponse, "Codex OAuth token exchange");
345
+ if (!tokens.access_token) {
346
+ throw new Error("Codex OAuth token exchange returned no access token");
347
+ }
348
+ const nextAuth = {
349
+ provider: "codex-oauth-api",
350
+ source: "device-code",
351
+ auth_mode: "chatgpt",
352
+ tokens: {
353
+ access_token: tokens.access_token,
354
+ refresh_token: tokens.refresh_token,
355
+ id_token: tokens.id_token,
356
+ },
357
+ last_refresh: new Date().toISOString(),
358
+ };
359
+ const accountId = extractAccountId(nextAuth.tokens);
360
+ if (accountId && nextAuth.tokens)
361
+ nextAuth.tokens.account_id = accountId;
362
+ await writeAuthFile(authPath, nextAuth);
363
+ const state = authStateFromFile(nextAuth, authPath);
364
+ if (!state)
365
+ throw new Error("Codex OAuth API login did not persist a usable access token");
366
+ return state;
367
+ }
368
+ export async function ensureCodexOauthApiAuth(options = {}) {
369
+ const authPath = options.authPath ?? getCodexOauthApiAuthPath();
370
+ try {
371
+ return await loadAuthState(authPath, options.codexCliAuthPath ?? getCodexCliAuthPath());
372
+ }
373
+ catch {
374
+ return loginCodexOauthApi({ ...options, authPath });
375
+ }
376
+ }
377
+ export function createCodexOauthApiProvider(options = {}) {
378
+ const model = options.model?.trim() || DEFAULT_MODEL;
379
+ const responsesUrl = options.responsesUrl ?? RESPONSES_URL;
380
+ const authPath = options.authPath ?? getCodexOauthApiAuthPath();
381
+ const codexCliAuthPath = options.codexCliAuthPath ?? getCodexCliAuthPath();
382
+ const timeoutMs = options.timeoutMs ?? REQUEST_TIMEOUT_MS;
383
+ return {
384
+ name: "codex-oauth-api",
385
+ run: async (prompt) => {
386
+ let authState = await loadAuthState(authPath, codexCliAuthPath);
387
+ let lastError;
388
+ for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
389
+ const response = await fetchWithTimeout(responsesUrl, {
390
+ method: "POST",
391
+ headers: buildHeaders(authState),
392
+ body: buildBody(prompt, model),
393
+ }, timeoutMs);
394
+ if (response.status === 401 && attempt === 1) {
395
+ authState = await refreshAuth(authState, timeoutMs);
396
+ continue;
397
+ }
398
+ if (!response.ok) {
399
+ const body = await response.text();
400
+ if (isRetryableStatus(response.status) && attempt < MAX_ATTEMPTS) {
401
+ lastError = new Error(`Codex OAuth API error (${response.status}): ${body}`);
402
+ await sleep(Math.pow(2, attempt - 1) * 1000);
403
+ continue;
404
+ }
405
+ throw new Error(`Codex OAuth API error (${response.status}): ${body}`);
406
+ }
407
+ const text = parseResponseText(await response.text());
408
+ if (!text) {
409
+ throw new Error("Codex OAuth API returned no text content");
410
+ }
411
+ return text;
412
+ }
413
+ throw lastError ?? new Error("Codex OAuth API request failed after retries");
414
+ },
415
+ };
416
+ }
@@ -0,0 +1,5 @@
1
+ import type { LLMProvider } from "../provider-registry.js";
2
+ export interface CodexOauthProviderOptions {
3
+ timeoutMs?: number;
4
+ }
5
+ export declare function createCodexOauthProvider(command?: string, model?: string, options?: CodexOauthProviderOptions): LLMProvider;
@@ -0,0 +1,75 @@
1
+ import { spawn } from "node:child_process";
2
+ const DEFAULT_COMMAND = "codex";
3
+ const DEFAULT_MODEL = "gpt-5.5";
4
+ const DEFAULT_TIMEOUT_MS = 120_000;
5
+ export function createCodexOauthProvider(command = DEFAULT_COMMAND, model = DEFAULT_MODEL, options = {}) {
6
+ return {
7
+ name: "codex",
8
+ run: async (prompt) => {
9
+ return new Promise((resolve, reject) => {
10
+ let settled = false;
11
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
12
+ let timeout;
13
+ const finalize = (fn) => {
14
+ if (settled)
15
+ return;
16
+ settled = true;
17
+ if (timeout)
18
+ clearTimeout(timeout);
19
+ fn();
20
+ };
21
+ const args = [
22
+ "--ask-for-approval",
23
+ "never",
24
+ "exec",
25
+ "--ephemeral",
26
+ "--skip-git-repo-check",
27
+ "--sandbox",
28
+ "read-only",
29
+ "--color",
30
+ "never",
31
+ "-m",
32
+ model,
33
+ "-",
34
+ ];
35
+ const proc = spawn(command, args, {
36
+ stdio: ["pipe", "pipe", "pipe"],
37
+ env: { ...process.env },
38
+ });
39
+ timeout = setTimeout(() => {
40
+ proc.kill("SIGTERM");
41
+ finalize(() => {
42
+ reject(new Error(`${command} timed out after ${Math.ceil(timeoutMs / 1000)} seconds`));
43
+ });
44
+ }, timeoutMs);
45
+ let stdout = "";
46
+ let stderr = "";
47
+ proc.stdout.on("data", (data) => {
48
+ stdout += data.toString();
49
+ });
50
+ proc.stderr.on("data", (data) => {
51
+ stderr += data.toString();
52
+ });
53
+ proc.on("error", (err) => {
54
+ if (err.code === "ENOENT") {
55
+ finalize(() => reject(new Error(`${command} Codex CLI not found. Install Codex and sign in with ChatGPT using: codex login`)));
56
+ }
57
+ else {
58
+ finalize(() => reject(err));
59
+ }
60
+ });
61
+ proc.on("close", (code) => {
62
+ if (code === 0) {
63
+ finalize(() => resolve(stdout.trim()));
64
+ return;
65
+ }
66
+ const details = (stderr || stdout).trim();
67
+ finalize(() => reject(new Error(`${command} exited with code ${code}: ${details || "no output"}\n` +
68
+ "Codex OAuth mode uses your Codex CLI ChatGPT login. Run `codex login` and retry.")));
69
+ });
70
+ proc.stdin.write(prompt);
71
+ proc.stdin.end();
72
+ });
73
+ },
74
+ };
75
+ }
@@ -1,4 +1,4 @@
1
- const DEFAULT_MODEL = "gpt-5.4";
1
+ const DEFAULT_MODEL = "gpt-5.5";
2
2
  const API_URL = "https://api.openai.com/v1/chat/completions";
3
3
  const REQUEST_TIMEOUT_MS = 60_000;
4
4
  const MAX_ATTEMPTS = 3;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "oh-my-harness",
3
- "version": "0.17.0",
3
+ "version": "0.18.0",
4
4
  "description": "Tame your AI coding agents with natural language",
5
5
  "type": "module",
6
6
  "bin": {