@agentex/agent 0.0.24 → 0.0.26

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 (164) hide show
  1. package/CHANGELOG.md +338 -0
  2. package/LICENSE +21 -0
  3. package/README.md +52 -0
  4. package/dist/derived.d.ts +5 -3
  5. package/dist/derived.d.ts.map +1 -1
  6. package/dist/derived.js +11 -7
  7. package/dist/derived.js.map +1 -1
  8. package/dist/index.d.ts +4 -0
  9. package/dist/index.d.ts.map +1 -1
  10. package/dist/index.js +3 -0
  11. package/dist/index.js.map +1 -1
  12. package/dist/providers/acp/index.d.ts +1 -1
  13. package/dist/providers/acp/index.d.ts.map +1 -1
  14. package/dist/providers/acp/index.js +5 -97
  15. package/dist/providers/acp/index.js.map +1 -1
  16. package/dist/providers/acp/session.d.ts +8 -1
  17. package/dist/providers/acp/session.d.ts.map +1 -1
  18. package/dist/providers/acp/session.js +94 -0
  19. package/dist/providers/acp/session.js.map +1 -1
  20. package/dist/providers/claude/attach.d.ts +8 -0
  21. package/dist/providers/claude/attach.d.ts.map +1 -0
  22. package/dist/providers/claude/attach.js +113 -0
  23. package/dist/providers/claude/attach.js.map +1 -0
  24. package/dist/providers/claude/goal-capability.d.ts +15 -0
  25. package/dist/providers/claude/goal-capability.d.ts.map +1 -0
  26. package/dist/providers/claude/goal-capability.js +20 -0
  27. package/dist/providers/claude/goal-capability.js.map +1 -0
  28. package/dist/providers/claude/index.d.ts.map +1 -1
  29. package/dist/providers/claude/index.js +8 -4
  30. package/dist/providers/claude/index.js.map +1 -1
  31. package/dist/providers/claude/session.d.ts +11 -9
  32. package/dist/providers/claude/session.d.ts.map +1 -1
  33. package/dist/providers/claude/session.js +29 -14
  34. package/dist/providers/claude/session.js.map +1 -1
  35. package/dist/providers/codex/attach.d.ts +9 -0
  36. package/dist/providers/codex/attach.d.ts.map +1 -0
  37. package/dist/providers/codex/attach.js +93 -0
  38. package/dist/providers/codex/attach.js.map +1 -0
  39. package/dist/providers/codex/goal-capability.d.ts +13 -0
  40. package/dist/providers/codex/goal-capability.d.ts.map +1 -0
  41. package/dist/providers/codex/goal-capability.js +18 -0
  42. package/dist/providers/codex/goal-capability.js.map +1 -0
  43. package/dist/providers/codex/index.d.ts +1 -0
  44. package/dist/providers/codex/index.d.ts.map +1 -1
  45. package/dist/providers/codex/index.js +9 -6
  46. package/dist/providers/codex/index.js.map +1 -1
  47. package/dist/providers/codex/session.d.ts +11 -7
  48. package/dist/providers/codex/session.d.ts.map +1 -1
  49. package/dist/providers/codex/session.js +24 -12
  50. package/dist/providers/codex/session.js.map +1 -1
  51. package/dist/providers/codex/transcript-normalize.d.ts +28 -0
  52. package/dist/providers/codex/transcript-normalize.d.ts.map +1 -0
  53. package/dist/providers/codex/transcript-normalize.js +191 -0
  54. package/dist/providers/codex/transcript-normalize.js.map +1 -0
  55. package/dist/providers/cursor/index.d.ts.map +1 -1
  56. package/dist/providers/cursor/index.js +2 -2
  57. package/dist/providers/cursor/index.js.map +1 -1
  58. package/dist/providers/openclaw/index.d.ts.map +1 -1
  59. package/dist/providers/openclaw/index.js +2 -2
  60. package/dist/providers/openclaw/index.js.map +1 -1
  61. package/dist/providers/opencode/index.d.ts.map +1 -1
  62. package/dist/providers/opencode/index.js +3 -5
  63. package/dist/providers/opencode/index.js.map +1 -1
  64. package/dist/providers/pi/index.d.ts.map +1 -1
  65. package/dist/providers/pi/index.js +3 -5
  66. package/dist/providers/pi/index.js.map +1 -1
  67. package/dist/providers/process/index.d.ts.map +1 -1
  68. package/dist/providers/process/index.js +2 -2
  69. package/dist/providers/process/index.js.map +1 -1
  70. package/dist/registry.d.ts +0 -1
  71. package/dist/registry.d.ts.map +1 -1
  72. package/dist/registry.js +0 -4
  73. package/dist/registry.js.map +1 -1
  74. package/dist/sessions/index.d.ts +3 -0
  75. package/dist/sessions/index.d.ts.map +1 -0
  76. package/dist/sessions/index.js +2 -0
  77. package/dist/sessions/index.js.map +1 -0
  78. package/dist/sessions/record.d.ts +43 -0
  79. package/dist/sessions/record.d.ts.map +1 -0
  80. package/dist/sessions/record.js +85 -0
  81. package/dist/sessions/record.js.map +1 -0
  82. package/dist/types.d.ts +119 -0
  83. package/dist/types.d.ts.map +1 -1
  84. package/dist/types.js.map +1 -1
  85. package/dist/utils/uuid.d.ts +7 -1
  86. package/dist/utils/uuid.d.ts.map +1 -1
  87. package/dist/utils/uuid.js +21 -1
  88. package/dist/utils/uuid.js.map +1 -1
  89. package/package.json +64 -7
  90. package/src/derived.ts +311 -0
  91. package/src/goals/controller.ts +442 -0
  92. package/src/goals/index.ts +21 -0
  93. package/src/goals/normalize.ts +173 -0
  94. package/src/goals/sentinel.ts +90 -0
  95. package/src/index.ts +270 -0
  96. package/src/providers/_shared/http-agent.ts +304 -0
  97. package/src/providers/acp/index.ts +103 -0
  98. package/src/providers/acp/parse.ts +131 -0
  99. package/src/providers/acp/session.ts +744 -0
  100. package/src/providers/claude/attach.ts +147 -0
  101. package/src/providers/claude/codec.ts +43 -0
  102. package/src/providers/claude/execute.ts +300 -0
  103. package/src/providers/claude/goal-capability.ts +21 -0
  104. package/src/providers/claude/index.ts +72 -0
  105. package/src/providers/claude/mcp.ts +82 -0
  106. package/src/providers/claude/parse.ts +824 -0
  107. package/src/providers/claude/session.ts +1192 -0
  108. package/src/providers/claude/transcript.ts +555 -0
  109. package/src/providers/codex/attach.ts +123 -0
  110. package/src/providers/codex/codec.ts +50 -0
  111. package/src/providers/codex/execute.ts +337 -0
  112. package/src/providers/codex/goal-capability.ts +19 -0
  113. package/src/providers/codex/index.ts +57 -0
  114. package/src/providers/codex/modes.ts +159 -0
  115. package/src/providers/codex/parse.ts +691 -0
  116. package/src/providers/codex/plan-mode.ts +49 -0
  117. package/src/providers/codex/session.ts +1287 -0
  118. package/src/providers/codex/transcript-normalize.ts +197 -0
  119. package/src/providers/codex/transcript.ts +487 -0
  120. package/src/providers/codex/usage-scanner.ts +178 -0
  121. package/src/providers/copilot/index.ts +19 -0
  122. package/src/providers/cursor/codec.ts +44 -0
  123. package/src/providers/cursor/execute.ts +271 -0
  124. package/src/providers/cursor/index.ts +25 -0
  125. package/src/providers/cursor/parse.ts +288 -0
  126. package/src/providers/gemini/index.ts +21 -0
  127. package/src/providers/openclaw/codec.ts +40 -0
  128. package/src/providers/openclaw/execute.ts +19 -0
  129. package/src/providers/openclaw/index.ts +29 -0
  130. package/src/providers/opencode/codec.ts +50 -0
  131. package/src/providers/opencode/event-parse.ts +141 -0
  132. package/src/providers/opencode/execute.ts +251 -0
  133. package/src/providers/opencode/http-session.ts +427 -0
  134. package/src/providers/opencode/index.ts +30 -0
  135. package/src/providers/opencode/parse.ts +203 -0
  136. package/src/providers/opencode/server.ts +0 -0
  137. package/src/providers/pi/codec.ts +44 -0
  138. package/src/providers/pi/execute.ts +297 -0
  139. package/src/providers/pi/index.ts +30 -0
  140. package/src/providers/pi/parse.ts +231 -0
  141. package/src/providers/pi/session.ts +381 -0
  142. package/src/providers/process/execute.ts +148 -0
  143. package/src/providers/process/index.ts +52 -0
  144. package/src/registry.ts +40 -0
  145. package/src/sessions/index.ts +8 -0
  146. package/src/sessions/record.ts +108 -0
  147. package/src/types.ts +1638 -0
  148. package/src/utils/ask-user-question.ts +57 -0
  149. package/src/utils/auth.ts +661 -0
  150. package/src/utils/binary.ts +179 -0
  151. package/src/utils/endpoint.ts +172 -0
  152. package/src/utils/env.ts +63 -0
  153. package/src/utils/execute-all.ts +68 -0
  154. package/src/utils/exit-plan-mode.ts +40 -0
  155. package/src/utils/instructions.ts +427 -0
  156. package/src/utils/process.ts +223 -0
  157. package/src/utils/runtime-config.ts +100 -0
  158. package/src/utils/runtime-homes.ts +49 -0
  159. package/src/utils/skill-commands.ts +493 -0
  160. package/src/utils/skills.ts +500 -0
  161. package/src/utils/template.ts +16 -0
  162. package/src/utils/tool-names.ts +51 -0
  163. package/src/utils/uuid.ts +21 -0
  164. package/src/utils/workspace.ts +156 -0
@@ -0,0 +1,661 @@
1
+ import * as fs from "node:fs/promises";
2
+ import * as path from "node:path";
3
+ import * as os from "node:os";
4
+ import type {
5
+ AuthIdentity,
6
+ AuthOption,
7
+ AuthReport,
8
+ AuthResolveContext,
9
+ BinaryStatus,
10
+ ProviderModule,
11
+ } from "../types.js";
12
+ import { buildEnv, ensurePathInEnv } from "./env.js";
13
+ import { findBinary } from "./binary.js";
14
+ import { runChildProcess } from "./process.js";
15
+
16
+ // ---------------------------------------------------------------------------
17
+ // Legacy env-only billing classifier — kept for ExecutionResult.billingType
18
+ // prediction. Do not use for new code; prefer resolveAuthForProvider.
19
+ // ---------------------------------------------------------------------------
20
+
21
+ export interface ResolvedAuth {
22
+ method: "api_key" | "bedrock" | "oauth" | "subscription";
23
+ billingType: "api" | "metered_api" | "subscription";
24
+ resolveModelId?(requestedModel: string): string;
25
+ region?: string;
26
+ }
27
+
28
+ function hasEnv(env: Record<string, string>, key: string): boolean {
29
+ const v = env[key];
30
+ return typeof v === "string" && v.trim().length > 0;
31
+ }
32
+
33
+ async function fileExists(p: string): Promise<boolean> {
34
+ try {
35
+ const stat = await fs.stat(p);
36
+ return stat.isFile();
37
+ } catch {
38
+ return false;
39
+ }
40
+ }
41
+
42
+ function providerHome(envVar: string, defaultDir: string): string {
43
+ const override = process.env[envVar];
44
+ if (typeof override === "string" && override.trim().length > 0) {
45
+ return override;
46
+ }
47
+ return path.join(os.homedir(), defaultDir);
48
+ }
49
+
50
+ function bedrockModelId(model: string, region?: string): string {
51
+ const prefix = region ?? "us";
52
+ const mapping: Record<string, string> = {
53
+ "claude-sonnet-4-6": `${prefix}.anthropic.claude-sonnet-4-6-v1`,
54
+ "claude-opus-4-6": `${prefix}.anthropic.claude-opus-4-6-v1`,
55
+ "claude-haiku-4-5": `${prefix}.anthropic.claude-haiku-4-5-v1`,
56
+ "claude-3.5-sonnet": `${prefix}.anthropic.claude-3-5-sonnet-20241022-v2:0`,
57
+ "claude-3.5-haiku": `${prefix}.anthropic.claude-3-5-haiku-20241022-v1:0`,
58
+ };
59
+ return mapping[model] ?? model;
60
+ }
61
+
62
+ export function detectAuth(providerType: string, env: Record<string, string>): ResolvedAuth {
63
+ switch (providerType) {
64
+ case "claude": {
65
+ if (hasEnv(env, "ANTHROPIC_BEDROCK_BASE_URL") || (hasEnv(env, "AWS_ACCESS_KEY_ID") && hasEnv(env, "AWS_REGION"))) {
66
+ const region = env["AWS_REGION"]?.trim();
67
+ return {
68
+ method: "bedrock",
69
+ billingType: "metered_api",
70
+ region,
71
+ resolveModelId: (model: string) => bedrockModelId(model, region),
72
+ };
73
+ }
74
+ if (hasEnv(env, "ANTHROPIC_API_KEY")) {
75
+ return { method: "api_key", billingType: "api" };
76
+ }
77
+ return { method: "subscription", billingType: "subscription" };
78
+ }
79
+ case "codex": {
80
+ if (hasEnv(env, "OPENAI_API_KEY")) {
81
+ return { method: "api_key", billingType: "api" };
82
+ }
83
+ return { method: "subscription", billingType: "subscription" };
84
+ }
85
+ case "gemini": {
86
+ if (hasEnv(env, "GEMINI_API_KEY") || hasEnv(env, "GOOGLE_API_KEY")) {
87
+ return { method: "api_key", billingType: "api" };
88
+ }
89
+ return { method: "subscription", billingType: "subscription" };
90
+ }
91
+ case "cursor": {
92
+ if (hasEnv(env, "CURSOR_API_KEY") || hasEnv(env, "OPENAI_API_KEY")) {
93
+ return { method: "api_key", billingType: "api" };
94
+ }
95
+ return { method: "subscription", billingType: "subscription" };
96
+ }
97
+ case "opencode":
98
+ case "pi":
99
+ return { method: "api_key", billingType: "api" };
100
+ default:
101
+ return { method: "subscription", billingType: "subscription" };
102
+ }
103
+ }
104
+
105
+ // ---------------------------------------------------------------------------
106
+ // Binary status probe — spawns `<cli> --version` to populate BinaryStatus.
107
+ // ---------------------------------------------------------------------------
108
+
109
+ async function checkBinary(
110
+ name: string,
111
+ configOverride: string | undefined,
112
+ env: Record<string, string>,
113
+ ): Promise<BinaryStatus & { prefixArgs: string[] }> {
114
+ let resolved;
115
+ try {
116
+ resolved = await findBinary(name, configOverride);
117
+ } catch (err) {
118
+ return {
119
+ installed: false,
120
+ prefixArgs: [],
121
+ error: err instanceof Error ? err.message : String(err),
122
+ };
123
+ }
124
+
125
+ let version: string | undefined;
126
+ try {
127
+ ensurePathInEnv(env);
128
+ const proc = await runChildProcess({
129
+ runId: "check-binary",
130
+ command: resolved.bin,
131
+ args: [...resolved.prefixArgs, "--version"],
132
+ cwd: process.cwd(),
133
+ env,
134
+ timeoutSec: 5,
135
+ });
136
+ if (proc.exitCode === 0) {
137
+ const out = (proc.stdout || proc.stderr).trim();
138
+ const match = out.match(/\b\d+\.\d+(?:\.\d+)?(?:[-+][\w.-]+)?\b/);
139
+ version = match ? match[0] : (out.split(/\s+/)[0] || undefined);
140
+ }
141
+ } catch {
142
+ // Version detection is best-effort; binary is still "installed" if findBinary succeeded.
143
+ }
144
+
145
+ return {
146
+ installed: true,
147
+ resolvedPath: resolved.bin,
148
+ prefixArgs: resolved.prefixArgs,
149
+ version,
150
+ };
151
+ }
152
+
153
+ // ---------------------------------------------------------------------------
154
+ // Claude — `claude auth status --json`
155
+ // ---------------------------------------------------------------------------
156
+
157
+ interface ClaudeAuthStatusJson {
158
+ loggedIn?: boolean;
159
+ authMethod?: string; // e.g. "claude.ai", "api_key", "bedrock"
160
+ apiProvider?: string; // e.g. "firstParty", "bedrock", "vertex", "foundry"
161
+ email?: string;
162
+ orgId?: string;
163
+ orgName?: string;
164
+ subscriptionType?: string; // e.g. "max", "pro", "team", "enterprise"
165
+ }
166
+
167
+ function classifyClaudeAuthMethod(raw: string | undefined): "subscription" | "api_key" | "bedrock" | null {
168
+ if (!raw) return null;
169
+ const m = raw.toLowerCase();
170
+ if (m.includes("bedrock") || m.includes("vertex") || m.includes("foundry")) return "bedrock";
171
+ if (m.includes("claude.ai") || m.includes("oauth") || m.includes("subscription")) return "subscription";
172
+ if (m.includes("api") || m.includes("console")) return "api_key";
173
+ return null;
174
+ }
175
+
176
+ async function resolveClaudeAuth(ctx?: AuthResolveContext): Promise<AuthReport> {
177
+ const env = buildEnv(ctx?.env);
178
+ const binary = await checkBinary("claude", ctx?.command, env);
179
+
180
+ const apiKeyOption: AuthOption = {
181
+ method: "api_key",
182
+ source: { kind: "env", var: "ANTHROPIC_API_KEY" },
183
+ present: hasEnv(env, "ANTHROPIC_API_KEY"),
184
+ };
185
+ const bedrockPresent =
186
+ hasEnv(env, "ANTHROPIC_BEDROCK_BASE_URL") ||
187
+ (hasEnv(env, "AWS_ACCESS_KEY_ID") && hasEnv(env, "AWS_REGION"));
188
+ const bedrockOption: AuthOption = {
189
+ method: "bedrock",
190
+ source: {
191
+ kind: "env_combo",
192
+ vars: ["ANTHROPIC_BEDROCK_BASE_URL", "AWS_ACCESS_KEY_ID", "AWS_REGION"],
193
+ },
194
+ present: bedrockPresent,
195
+ };
196
+
197
+ // Binary missing → fall back to filesystem/keychain heuristic.
198
+ if (!binary.installed) {
199
+ const subOption: AuthOption = process.platform === "darwin"
200
+ ? {
201
+ method: "subscription",
202
+ source: { kind: "keychain", service: "Claude Code" },
203
+ present: false, // can't verify without binary
204
+ }
205
+ : await (async () => {
206
+ const credPath = path.join(
207
+ providerHome("CLAUDE_CONFIG_DIR", ".claude"),
208
+ ".credentials.json",
209
+ );
210
+ return {
211
+ method: "subscription" as const,
212
+ source: { kind: "file" as const, path: credPath },
213
+ present: await fileExists(credPath),
214
+ };
215
+ })();
216
+ return {
217
+ providerType: "claude",
218
+ binary: stripPrefixArgs(binary),
219
+ options: [apiKeyOption, bedrockOption, subOption],
220
+ source: "filesystem",
221
+ };
222
+ }
223
+
224
+ // Binary present — try `claude auth status --json`.
225
+ try {
226
+ ensurePathInEnv(env);
227
+ const proc = await runChildProcess({
228
+ runId: "claude-auth-status",
229
+ command: binary.resolvedPath!,
230
+ args: [...binary.prefixArgs, "auth", "status", "--json"],
231
+ cwd: process.cwd(),
232
+ env,
233
+ timeoutSec: 10,
234
+ });
235
+
236
+ if (proc.exitCode === 0 && proc.stdout.trim()) {
237
+ const data: ClaudeAuthStatusJson = JSON.parse(proc.stdout);
238
+ const active = classifyClaudeAuthMethod(data.authMethod);
239
+ const loggedIn = data.loggedIn === true;
240
+
241
+ const subPresent = loggedIn && (active === "subscription" || active === null);
242
+ const subOption: AuthOption = {
243
+ method: "subscription",
244
+ source: { kind: "cli", command: "claude auth status --json" },
245
+ present: subPresent,
246
+ };
247
+
248
+ // Identity
249
+ const identity: AuthIdentity = {};
250
+ if (data.email) identity.email = data.email;
251
+ if (data.orgName) identity.orgName = data.orgName;
252
+ if (data.subscriptionType) identity.subscriptionType = data.subscriptionType;
253
+ if (data.authMethod) identity.authMethod = data.authMethod;
254
+
255
+ return {
256
+ providerType: "claude",
257
+ binary: stripPrefixArgs(binary),
258
+ options: [apiKeyOption, bedrockOption, subOption],
259
+ identity: Object.keys(identity).length > 0 ? identity : undefined,
260
+ source: "cli",
261
+ };
262
+ }
263
+ } catch {
264
+ // fall through to filesystem
265
+ }
266
+
267
+ // CLI call failed (old version, parse error, etc). Fall back to filesystem.
268
+ const subOption: AuthOption = process.platform === "darwin"
269
+ ? {
270
+ method: "subscription",
271
+ source: { kind: "keychain", service: "Claude Code" },
272
+ present: false,
273
+ }
274
+ : await (async () => {
275
+ const credPath = path.join(
276
+ providerHome("CLAUDE_CONFIG_DIR", ".claude"),
277
+ ".credentials.json",
278
+ );
279
+ return {
280
+ method: "subscription" as const,
281
+ source: { kind: "file" as const, path: credPath },
282
+ present: await fileExists(credPath),
283
+ };
284
+ })();
285
+
286
+ return {
287
+ providerType: "claude",
288
+ binary: stripPrefixArgs(binary),
289
+ options: [apiKeyOption, bedrockOption, subOption],
290
+ source: "filesystem",
291
+ };
292
+ }
293
+
294
+ // ---------------------------------------------------------------------------
295
+ // Codex — `codex login status`. Text-only output; regex with multiple
296
+ // tolerant patterns + filesystem fallback for forward-compat.
297
+ // ---------------------------------------------------------------------------
298
+
299
+ const CODEX_SUBSCRIPTION_PATTERNS: RegExp[] = [
300
+ /logged\s+in\s+using\s+chatgpt/i,
301
+ /logged\s+in\s+with\s+chatgpt/i,
302
+ /authenticated\s+via\s+chatgpt/i,
303
+ /signed\s+in.*chatgpt/i,
304
+ /using\s+subscription/i,
305
+ ];
306
+ const CODEX_API_KEY_PATTERNS: RegExp[] = [
307
+ /logged\s+in\s+using\s+(an?\s+)?api[-_ ]?key/i,
308
+ /logged\s+in\s+with\s+(an?\s+)?api[-_ ]?key/i,
309
+ /authenticated\s+via\s+api[-_ ]?key/i,
310
+ /signed\s+in.*api[-_ ]?key/i,
311
+ ];
312
+ const CODEX_NOT_LOGGED_IN_PATTERNS: RegExp[] = [
313
+ /not\s+logged\s+in/i,
314
+ /not\s+authenticated/i,
315
+ /please\s+(run\s+)?`?codex\s+login`?/i,
316
+ /no\s+authentication/i,
317
+ ];
318
+
319
+ async function resolveCodexAuth(ctx?: AuthResolveContext): Promise<AuthReport> {
320
+ const env = buildEnv(ctx?.env);
321
+ const binary = await checkBinary("codex", ctx?.command, env);
322
+
323
+ const apiKeyOption: AuthOption = {
324
+ method: "api_key",
325
+ source: { kind: "env", var: "OPENAI_API_KEY" },
326
+ present: hasEnv(env, "OPENAI_API_KEY"),
327
+ };
328
+ const authPath = path.join(providerHome("CODEX_HOME", ".codex"), "auth.json");
329
+
330
+ if (!binary.installed) {
331
+ const subPresent = await fileExists(authPath);
332
+ return {
333
+ providerType: "codex",
334
+ binary: stripPrefixArgs(binary),
335
+ options: [
336
+ apiKeyOption,
337
+ {
338
+ method: "subscription",
339
+ source: { kind: "file", path: authPath },
340
+ present: subPresent,
341
+ },
342
+ ],
343
+ source: "filesystem",
344
+ };
345
+ }
346
+
347
+ let subscriptionPresent = false;
348
+ let activeAuthMethod: string | undefined;
349
+ let usedCli = false;
350
+
351
+ try {
352
+ ensurePathInEnv(env);
353
+ const proc = await runChildProcess({
354
+ runId: "codex-login-status",
355
+ command: binary.resolvedPath!,
356
+ args: [...binary.prefixArgs, "login", "status"],
357
+ cwd: process.cwd(),
358
+ env,
359
+ timeoutSec: 10,
360
+ });
361
+
362
+ const out = `${proc.stdout}\n${proc.stderr}`.trim();
363
+
364
+ if (CODEX_SUBSCRIPTION_PATTERNS.some((r) => r.test(out))) {
365
+ subscriptionPresent = true;
366
+ activeAuthMethod = "chatgpt";
367
+ usedCli = true;
368
+ } else if (CODEX_API_KEY_PATTERNS.some((r) => r.test(out))) {
369
+ // API key is the active auth; subscription status unknown from this output alone.
370
+ // Trust the filesystem to know if a subscription is ALSO stored.
371
+ subscriptionPresent = await fileExists(authPath);
372
+ activeAuthMethod = "api_key";
373
+ usedCli = true;
374
+ } else if (CODEX_NOT_LOGGED_IN_PATTERNS.some((r) => r.test(out))) {
375
+ subscriptionPresent = false;
376
+ usedCli = true;
377
+ } else {
378
+ // Unknown wording (forward-compat) — fall back to filesystem.
379
+ subscriptionPresent = await fileExists(authPath);
380
+ }
381
+ } catch {
382
+ subscriptionPresent = await fileExists(authPath);
383
+ }
384
+
385
+ return {
386
+ providerType: "codex",
387
+ binary: stripPrefixArgs(binary),
388
+ options: [
389
+ apiKeyOption,
390
+ {
391
+ method: "subscription",
392
+ source: usedCli
393
+ ? { kind: "cli", command: "codex login status" }
394
+ : { kind: "file", path: authPath },
395
+ present: subscriptionPresent,
396
+ },
397
+ ],
398
+ identity: activeAuthMethod ? { authMethod: activeAuthMethod } : undefined,
399
+ source: usedCli ? "cli" : "filesystem",
400
+ };
401
+ }
402
+
403
+ // ---------------------------------------------------------------------------
404
+ // Gemini — filesystem-only (no `gemini auth status` subcommand exists).
405
+ // Still runs `gemini --version` to populate BinaryStatus.
406
+ // ---------------------------------------------------------------------------
407
+
408
+ async function resolveGeminiAuth(ctx?: AuthResolveContext): Promise<AuthReport> {
409
+ const env = buildEnv(ctx?.env);
410
+ const binary = await checkBinary("gemini", ctx?.command, env);
411
+
412
+ const credPath = path.join(providerHome("GEMINI_CONFIG_DIR", ".gemini"), "oauth_creds.json");
413
+ return {
414
+ providerType: "gemini",
415
+ binary: stripPrefixArgs(binary),
416
+ options: [
417
+ {
418
+ method: "api_key",
419
+ source: { kind: "env", var: "GEMINI_API_KEY" },
420
+ present: hasEnv(env, "GEMINI_API_KEY"),
421
+ },
422
+ {
423
+ method: "api_key",
424
+ source: { kind: "env", var: "GOOGLE_API_KEY" },
425
+ present: hasEnv(env, "GOOGLE_API_KEY"),
426
+ },
427
+ {
428
+ method: "subscription",
429
+ source: { kind: "file", path: credPath },
430
+ present: await fileExists(credPath),
431
+ },
432
+ ],
433
+ source: "filesystem",
434
+ };
435
+ }
436
+
437
+ // ---------------------------------------------------------------------------
438
+ // Cursor / OpenCode / Pi / OpenClaw / Process — no CLI auth commands;
439
+ // straightforward env-only reports with binary status when applicable.
440
+ // ---------------------------------------------------------------------------
441
+
442
+ async function resolveCursorAuth(ctx?: AuthResolveContext): Promise<AuthReport> {
443
+ const env = buildEnv(ctx?.env);
444
+ const binary = await checkBinary("agent", ctx?.command, env);
445
+ return {
446
+ providerType: "cursor",
447
+ binary: stripPrefixArgs(binary),
448
+ options: [
449
+ { method: "api_key", source: { kind: "env", var: "CURSOR_API_KEY" }, present: hasEnv(env, "CURSOR_API_KEY") },
450
+ { method: "api_key", source: { kind: "env", var: "OPENAI_API_KEY" }, present: hasEnv(env, "OPENAI_API_KEY") },
451
+ ],
452
+ source: "filesystem",
453
+ };
454
+ }
455
+
456
+ async function resolveOpencodeAuth(ctx?: AuthResolveContext): Promise<AuthReport> {
457
+ const env = buildEnv(ctx?.env);
458
+ const binary = await checkBinary("opencode", ctx?.command, env);
459
+ return {
460
+ providerType: "opencode",
461
+ binary: stripPrefixArgs(binary),
462
+ options: [
463
+ { method: "api_key", source: { kind: "env", var: "OPENAI_API_KEY" }, present: hasEnv(env, "OPENAI_API_KEY") },
464
+ { method: "api_key", source: { kind: "env", var: "ANTHROPIC_API_KEY" }, present: hasEnv(env, "ANTHROPIC_API_KEY") },
465
+ ],
466
+ source: "filesystem",
467
+ };
468
+ }
469
+
470
+ async function resolvePiAuth(ctx?: AuthResolveContext): Promise<AuthReport> {
471
+ const env = buildEnv(ctx?.env);
472
+ const binary = await checkBinary("pi", ctx?.command, env);
473
+ return {
474
+ providerType: "pi",
475
+ binary: stripPrefixArgs(binary),
476
+ options: [
477
+ { method: "api_key", source: { kind: "env", var: "OPENAI_API_KEY" }, present: hasEnv(env, "OPENAI_API_KEY") },
478
+ { method: "api_key", source: { kind: "env", var: "ANTHROPIC_API_KEY" }, present: hasEnv(env, "ANTHROPIC_API_KEY") },
479
+ ],
480
+ source: "filesystem",
481
+ };
482
+ }
483
+
484
+ function stripPrefixArgs(b: BinaryStatus & { prefixArgs: string[] }): BinaryStatus {
485
+ const { prefixArgs: _prefixArgs, ...rest } = b;
486
+ return rest;
487
+ }
488
+
489
+ // ---------------------------------------------------------------------------
490
+ // Dispatcher + 60s cache
491
+ // ---------------------------------------------------------------------------
492
+
493
+ const CACHE_TTL_MS = 60_000;
494
+ const cache = new Map<string, { report: AuthReport; ts: number }>();
495
+
496
+ function cacheKey(providerType: string, ctx?: AuthResolveContext): string {
497
+ const envKey = ctx?.env ? JSON.stringify(ctx.env) : "";
498
+ const cmdKey = ctx?.command ?? "";
499
+ return `${providerType}|${envKey}|${cmdKey}`;
500
+ }
501
+
502
+ async function resolveInternal(providerType: string, ctx?: AuthResolveContext): Promise<AuthReport> {
503
+ switch (providerType) {
504
+ case "codex":
505
+ return resolveCodexAuth(ctx);
506
+ case "claude":
507
+ return resolveClaudeAuth(ctx);
508
+ case "gemini":
509
+ return resolveGeminiAuth(ctx);
510
+ case "cursor":
511
+ return resolveCursorAuth(ctx);
512
+ case "opencode":
513
+ return resolveOpencodeAuth(ctx);
514
+ case "pi":
515
+ return resolvePiAuth(ctx);
516
+ default:
517
+ return {
518
+ providerType,
519
+ binary: { installed: false, error: `No auth resolver for provider "${providerType}"` },
520
+ options: [],
521
+ source: "filesystem",
522
+ };
523
+ }
524
+ }
525
+
526
+ /**
527
+ * Resolve the auth report for a provider by type. Cached for 60s per
528
+ * (providerType, env, command). Pass `{ fresh: true }` to bypass the cache.
529
+ */
530
+ export async function resolveAuthForProvider(
531
+ providerType: string,
532
+ ctx?: AuthResolveContext,
533
+ ): Promise<AuthReport> {
534
+ if (!ctx?.fresh) {
535
+ const key = cacheKey(providerType, ctx);
536
+ const hit = cache.get(key);
537
+ if (hit && Date.now() - hit.ts < CACHE_TTL_MS) {
538
+ return hit.report;
539
+ }
540
+ }
541
+
542
+ const report = await resolveInternal(providerType, ctx);
543
+
544
+ const key = cacheKey(providerType, ctx);
545
+ cache.set(key, { report, ts: Date.now() });
546
+ return report;
547
+ }
548
+
549
+ /** Clear the resolveAuth cache. Primarily for tests and explicit refresh. */
550
+ export function clearAuthCache(): void {
551
+ cache.clear();
552
+ }
553
+
554
+ // ---------------------------------------------------------------------------
555
+ // Sugar: method-specific presence checks. Each commits the caller to a
556
+ // billing mode, making the choice visible at the call site.
557
+ // ---------------------------------------------------------------------------
558
+
559
+ function anyPresent(report: AuthReport, method: AuthOption["method"]): boolean {
560
+ return report.options.some((o) => o.method === method && o.present === true);
561
+ }
562
+
563
+ /** True only if a subscription credential is confirmed present. */
564
+ export async function hasSubscription(
565
+ provider: ProviderModule,
566
+ ctx?: AuthResolveContext,
567
+ ): Promise<boolean> {
568
+ const report = await provider.resolveAuth(ctx);
569
+ return anyPresent(report, "subscription");
570
+ }
571
+
572
+ /** True only if an API key is confirmed set in the env. */
573
+ export async function hasApiKey(
574
+ provider: ProviderModule,
575
+ ctx?: AuthResolveContext,
576
+ ): Promise<boolean> {
577
+ const report = await provider.resolveAuth(ctx);
578
+ return anyPresent(report, "api_key");
579
+ }
580
+
581
+ /** True only if Bedrock credentials are confirmed present in the env. */
582
+ export async function hasBedrock(
583
+ provider: ProviderModule,
584
+ ctx?: AuthResolveContext,
585
+ ): Promise<boolean> {
586
+ const report = await provider.resolveAuth(ctx);
587
+ return anyPresent(report, "bedrock");
588
+ }
589
+
590
+ // ---------------------------------------------------------------------------
591
+ // Top-level convenience: "is the user logged in?" + "what command should I
592
+ // surface in a re-auth banner?". Pair these with the StreamEvent
593
+ // `auth_required` to drive a complete login UX from a host application.
594
+ // ---------------------------------------------------------------------------
595
+
596
+ /**
597
+ * Shell command a host should surface when an `auth_required` event fires
598
+ * for `providerType`, or when `isLoggedIn` returns false. Values are the
599
+ * provider's canonical CLI subcommand for interactive login — they spawn
600
+ * a browser flow (Claude, Codex, Gemini, Cursor) or print API-key setup
601
+ * instructions (OpenCode, Pi).
602
+ *
603
+ * Unknown provider types fall back to `${providerType} login`, which is
604
+ * intentionally guess-y: if a new provider is added without updating this
605
+ * function, the host gets a plausible string instead of a thrown error.
606
+ */
607
+ export function loginCommandFor(providerType: string): string {
608
+ switch (providerType) {
609
+ case "claude":
610
+ return "claude auth login";
611
+ case "codex":
612
+ return "codex login";
613
+ case "gemini":
614
+ // Gemini CLI uses `gemini` interactive mode for OAuth + `/auth` slash
615
+ // command; there's no top-level `gemini auth login` subcommand. The
616
+ // closest non-interactive path is launching `gemini` itself, which
617
+ // triggers an OAuth flow on first run.
618
+ return "gemini";
619
+ case "cursor":
620
+ return "cursor-agent login";
621
+ case "opencode":
622
+ return "opencode auth login";
623
+ case "pi":
624
+ // pi-coding-agent uses env-var auth (OPENAI_API_KEY / ANTHROPIC_API_KEY);
625
+ // there's no login subcommand. Tell the user to set the env var.
626
+ return "export OPENAI_API_KEY=... or ANTHROPIC_API_KEY=...";
627
+ default:
628
+ return `${providerType} login`;
629
+ }
630
+ }
631
+
632
+ /**
633
+ * Returns true when the provider has at least one credential confirmed
634
+ * present (any of api_key, bedrock, subscription). Wraps
635
+ * `resolveAuthForProvider` for callers that just want a yes/no.
636
+ *
637
+ * For Claude, this honors `claude auth status --json` (the authoritative
638
+ * source) via the existing `resolveAuth` path — the binary is consulted
639
+ * first and the keychain/file fallback is used only when the binary is
640
+ * missing or its status subcommand fails.
641
+ *
642
+ * Note: this is a snapshot of credential *presence*, not validity. A
643
+ * present but expired OAuth token still returns true here, because the
644
+ * status CLI itself reports `loggedIn: true` until the next refresh
645
+ * attempt fails. The definitive "is this credential actually accepted by
646
+ * the API?" check happens at request time and surfaces as an
647
+ * `auth_required` StreamEvent — pair the two:
648
+ *
649
+ * 1. Call `isLoggedIn` before starting a session to short-circuit
650
+ * obvious "never logged in" cases.
651
+ * 2. Listen for `auth_required` events during the session to catch
652
+ * tokens that were valid at presence-check time but expired/revoked
653
+ * in flight.
654
+ */
655
+ export async function isLoggedIn(
656
+ providerType: string,
657
+ ctx?: AuthResolveContext,
658
+ ): Promise<boolean> {
659
+ const report = await resolveAuthForProvider(providerType, ctx);
660
+ return report.options.some((o) => o.present);
661
+ }