@giovannijecha/jecode 0.1.9 → 0.2.1

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 (42) hide show
  1. package/README.md +36 -18
  2. package/dist/account-lock.js +112 -0
  3. package/dist/accounts.js +100 -0
  4. package/dist/cli-info.js +2 -2
  5. package/dist/commands.js +1 -1
  6. package/dist/controller.js +46 -11
  7. package/dist/credential-commands.js +32 -4
  8. package/dist/credential-safety.js +10 -1
  9. package/dist/effort.js +25 -0
  10. package/dist/external-browser.js +53 -0
  11. package/dist/oauth-http.js +114 -0
  12. package/dist/openai-account-command.js +124 -0
  13. package/dist/openai-account.js +94 -0
  14. package/dist/openai-oauth-callback.js +186 -0
  15. package/dist/openai-oauth-tokens.js +65 -0
  16. package/dist/openai-oauth.js +203 -0
  17. package/dist/provider-commands.js +79 -38
  18. package/dist/provider-label.js +10 -0
  19. package/dist/providers/anthropic-stream.js +8 -2
  20. package/dist/providers/anthropic.js +22 -6
  21. package/dist/providers/index.js +2 -1
  22. package/dist/providers/ollama-stream.js +3 -0
  23. package/dist/providers/ollama.js +4 -1
  24. package/dist/providers/openai-codex.js +157 -0
  25. package/dist/providers/openai-stream.js +13 -9
  26. package/dist/providers/openai-wire.js +4 -9
  27. package/dist/providers/openai.js +36 -15
  28. package/dist/providers/sse.js +1 -1
  29. package/dist/settings-command.js +64 -9
  30. package/dist/settings.js +2 -1
  31. package/dist/tools/args.js +4 -3
  32. package/dist/tools/fs.js +8 -3
  33. package/dist/tools/index.js +2 -2
  34. package/dist/tools/shell.js +2 -0
  35. package/dist/tui/app-workflows.js +5 -0
  36. package/dist/tui/components/messages.js +26 -5
  37. package/dist/tui/feedback.js +10 -6
  38. package/dist/tui/session-view.js +10 -4
  39. package/dist/tui/turn.js +4 -1
  40. package/dist/version.js +14 -0
  41. package/docs/assets/brand/jeco-256.png +0 -0
  42. package/package.json +5 -1
package/README.md CHANGED
@@ -25,7 +25,7 @@
25
25
  <a href="https://github.com/giovannijecha/jecode/releases">Releases</a>
26
26
  </p>
27
27
 
28
- > Jecode is an early 0.1.x release. The core loop is usable today; commands and
28
+ > Jecode is an early 0.2.x release. The core loop is usable today; commands and
29
29
  > terminal interactions may still evolve before 1.0.
30
30
 
31
31
  ## Why Jecode
@@ -36,8 +36,8 @@
36
36
  diffs, approvals, reasoning, and status all share one full-screen TUI.
37
37
  - **Permission-aware.** Reads stay transparent; dangerous actions ask first.
38
38
  Session approvals can be reviewed and revoked.
39
- - **Provider-neutral.** Use Anthropic, OpenAI, or a local/remote Ollama server
40
- without changing the workflow.
39
+ - **Provider-neutral.** Use Anthropic or OpenAI API keys, a ChatGPT account, or
40
+ a local/remote Ollama server without changing the workflow.
41
41
  - **Lean by construction.** Jecode installs as plain JavaScript, runs on
42
42
  Node.js 22.18+ (22.x) or Node.js 24+, executes no installation scripts, and
43
43
  has zero third-party runtime dependencies.
@@ -145,15 +145,27 @@ published package runs no compilation or installation scripts.
145
145
  ## First session
146
146
 
147
147
  Jecode opens on an empty composer instead of forcing a setup wizard. Type
148
- **/settings** when you are ready to choose a provider, select a model, and add a
149
- credential. A credential can remain in memory for the current session or be
150
- saved explicitly under **~/.jecode**; it is never stored in the workspace.
148
+ **/settings** when you are ready to choose a provider, select a model, and
149
+ configure authentication. An API key can remain in memory for the current
150
+ session or be saved explicitly under **~/.jecode**; it is never stored in the
151
+ workspace.
151
152
 
152
- | Provider | Credential | Notes |
153
+ | Provider ID | Authentication | Notes |
153
154
  |---|---|---|
154
- | Anthropic | ANTHROPIC_API_KEY | Cloud |
155
- | OpenAI | OPENAI_API_KEY | Cloud |
156
- | Ollama | OLLAMA_API_KEY for Cloud/remote | Cloud with a key, local without one |
155
+ | anthropic | ANTHROPIC_API_KEY | Anthropic API |
156
+ | openai | OPENAI_API_KEY | OpenAI API |
157
+ | openai-codex | ChatGPT OAuth | Experimental; uses eligible ChatGPT Codex access |
158
+ | ollama | OLLAMA_API_KEY for Cloud/remote | Cloud with a key, local without one |
159
+
160
+ Choose **openai-codex** to sign in on OpenAI's website without pasting a key.
161
+ Jecode offers a local browser callback and a device-code flow; WSL and remote
162
+ terminals default to the device code. The connection is saved only after the
163
+ flow completes. Availability and usage limits are determined by the ChatGPT
164
+ account and plan, not by OpenAI API credits. This integration is experimental
165
+ and is not an endorsement of Jecode by OpenAI.
166
+
167
+ Anthropic remains API-key only. Jecode does not reuse a Claude consumer
168
+ subscription or copy credentials from another client.
157
169
 
158
170
  Choose **cloud**, **local**, or a custom endpoint from the Ollama connection row
159
171
  in **/settings**. Existing users with an Ollama API key automatically use
@@ -166,11 +178,11 @@ Type **/** to open searchable command completion inside the composer.
166
178
 
167
179
  | Command | What it does |
168
180
  |---|---|
169
- | /settings | Manage provider, connection, model, limits, motion, and credentials |
181
+ | /settings | Manage provider, connection, model, limits, motion, and authentication |
170
182
  | /effort | Change and save reasoning effort directly |
171
183
  | /providers | Switch the provider for the next turn |
172
184
  | /models | Search the live model catalogue |
173
- | /credentials | Add, replace, inspect, or forget saved credentials |
185
+ | /credentials | Manage API keys and the connected ChatGPT account |
174
186
  | /permissions | Manage session tool access and remembered approvals |
175
187
  | /new | Start a clean conversation and reset session tool permissions |
176
188
  | /export | Save a timestamped Markdown transcript in the launch directory |
@@ -207,14 +219,16 @@ settings, built-in defaults.
207
219
  | --ollama-host | OLLAMA_HOST | Cloud with an Ollama key, local without one |
208
220
  | --root | — | Current directory |
209
221
  | --effort | JECODE_EFFORT | high |
210
- | --max-tokens | JECODE_MAX_TOKENS | 64000 |
222
+ | --max-tokens | JECODE_MAX_TOKENS | 64000; not sent by openai-codex |
211
223
  | --max-steps | JECODE_MAX_STEPS | 40 |
212
224
  | --reduced-motion | JECODE_REDUCED_MOTION=1 | Off |
213
225
  | --auto-approve | JECODE_AUTO_APPROVE=1 | Off |
214
226
 
215
227
  Persistent preferences live in **~/.jecode/settings.json**. Explicitly saved
216
- credentials live in **~/.jecode/credentials.json** with owner-only permissions
217
- where the operating system supports them. Environment credentials always win.
228
+ API keys live in **~/.jecode/credentials.json**; the ChatGPT OAuth account lives
229
+ separately in **~/.jecode/accounts.json**. Both secret stores use owner-only
230
+ permissions where the operating system supports them. Environment API keys
231
+ always win.
218
232
 
219
233
  Jecode has one interface theme: dark Steel. **NO_COLOR** is supported for
220
234
  terminals and pipelines that disable colour.
@@ -241,9 +255,13 @@ untrusted data.
241
255
  - Dangerous tools ask by default unless explicitly allowed for the session in
242
256
  **/permissions** or the process started with **--auto-approve**.
243
257
  - Credential fields are masked and excluded from transcripts. Approved shell
244
- commands receive no credential-like environment variables, and recognized
245
- credential values are redacted before tool output reaches the model, screen,
246
- history, or export.
258
+ commands receive no secret-bearing environment variables; `SSH_AUTH_SOCK`
259
+ is preserved so Git and SSH can use the user's agent, which means an approved
260
+ command can request that agent to authenticate. Recognized credential values
261
+ are redacted before tool output reaches the model, screen, history, or export.
262
+ - ChatGPT OAuth uses PKCE and an exact loopback callback or the OpenAI device
263
+ flow. Refresh-token rotation is serialized across Jecode processes; OAuth
264
+ tokens are withheld and redacted like API keys.
247
265
  - Terminal control characters are neutralized before rendering.
248
266
  - Remote Ollama endpoints require HTTPS. Provider HTTP redirects are rejected
249
267
  rather than followed across an implicit trust boundary.
@@ -0,0 +1,112 @@
1
+ // A tiny cross-process lock for rotating OAuth credentials.
2
+ //
3
+ // Refresh tokens may rotate after one use. Two Jecode processes refreshing
4
+ // the same account concurrently would make one of them persist a dead token,
5
+ // so account mutations serialize through an atomic lock directory.
6
+ import { mkdir, open, readFile, rename, rmdir, stat, unlink } from "node:fs/promises";
7
+ import { randomUUID } from "node:crypto";
8
+ import * as path from "node:path";
9
+ const WAIT_MS = 50;
10
+ const WAIT_LIMIT_MS = 20_000;
11
+ const STALE_MS = 60_000;
12
+ export async function withAccountLock(accountFile, body, signal) {
13
+ throwIfAborted(signal);
14
+ const directory = `${accountFile}.lock`;
15
+ const token = `${process.pid}:${randomUUID()}`;
16
+ const started = Date.now();
17
+ while (!(await acquire(directory, token))) {
18
+ if (signal?.aborted === true)
19
+ throw abortReason(signal);
20
+ if (Date.now() - started >= WAIT_LIMIT_MS) {
21
+ throw new Error("timed out waiting for the account store");
22
+ }
23
+ await recoverStale(directory);
24
+ await wait(WAIT_MS, signal);
25
+ }
26
+ try {
27
+ throwIfAborted(signal);
28
+ return await body();
29
+ }
30
+ finally {
31
+ await release(directory, token);
32
+ }
33
+ }
34
+ async function acquire(directory, token) {
35
+ let created = false;
36
+ try {
37
+ await mkdir(directory, { mode: 0o700 });
38
+ created = true;
39
+ const owner = await open(path.join(directory, "owner"), "wx", 0o600);
40
+ try {
41
+ await owner.writeFile(token, "utf8");
42
+ await owner.sync();
43
+ }
44
+ finally {
45
+ await owner.close();
46
+ }
47
+ return true;
48
+ }
49
+ catch (error) {
50
+ if (!created && error.code === "EEXIST")
51
+ return false;
52
+ if (created)
53
+ await removeLock(directory);
54
+ throw error;
55
+ }
56
+ }
57
+ async function recoverStale(directory) {
58
+ try {
59
+ const details = await stat(directory);
60
+ if (Date.now() - details.mtimeMs < STALE_MS)
61
+ return;
62
+ const quarantined = `${directory}.${randomUUID()}.stale`;
63
+ await rename(directory, quarantined);
64
+ await unlink(path.join(quarantined, "owner")).catch(() => undefined);
65
+ await rmdir(quarantined).catch(() => undefined);
66
+ }
67
+ catch (error) {
68
+ const code = error.code;
69
+ if (code !== "ENOENT" && code !== "EACCES" && code !== "EPERM")
70
+ throw error;
71
+ }
72
+ }
73
+ async function release(directory, token) {
74
+ try {
75
+ const owner = path.join(directory, "owner");
76
+ if ((await readFile(owner, "utf8")) !== token)
77
+ return;
78
+ await unlink(owner);
79
+ await rmdir(directory);
80
+ }
81
+ catch {
82
+ // A recovered stale lock or an already-removed directory is no longer ours.
83
+ }
84
+ }
85
+ async function removeLock(directory) {
86
+ await unlink(path.join(directory, "owner")).catch(() => undefined);
87
+ await rmdir(directory).catch(() => undefined);
88
+ }
89
+ function wait(ms, signal) {
90
+ return new Promise((resolve, reject) => {
91
+ if (signal?.aborted === true) {
92
+ reject(abortReason(signal));
93
+ return;
94
+ }
95
+ const timer = setTimeout(() => {
96
+ signal?.removeEventListener("abort", onAbort);
97
+ resolve();
98
+ }, ms);
99
+ const onAbort = () => {
100
+ clearTimeout(timer);
101
+ reject(signal === undefined ? new Error("cancelled") : abortReason(signal));
102
+ };
103
+ signal?.addEventListener("abort", onAbort, { once: true });
104
+ });
105
+ }
106
+ function abortReason(signal) {
107
+ return signal.reason instanceof Error ? signal.reason : new Error("cancelled");
108
+ }
109
+ function throwIfAborted(signal) {
110
+ if (signal?.aborted === true)
111
+ throw abortReason(signal);
112
+ }
@@ -0,0 +1,100 @@
1
+ // OAuth accounts persisted under ~/.jecode, apart from API keys.
2
+ import { chmod, mkdir } from "node:fs/promises";
3
+ import { readFileSync } from "node:fs";
4
+ import * as path from "node:path";
5
+ import { atomicWrite } from "./atomic.js";
6
+ import { withAccountLock } from "./account-lock.js";
7
+ import { userDataLabel, userDataPath } from "./user-data.js";
8
+ let cached;
9
+ export function openAICodexAccount() {
10
+ const account = store().accounts["openai-codex"];
11
+ return account === undefined ? undefined : { ...account };
12
+ }
13
+ export function accountValues() {
14
+ const account = store().accounts["openai-codex"];
15
+ return account === undefined ? [] : [account.accessToken, account.refreshToken];
16
+ }
17
+ export function accountsPath() {
18
+ return userDataPath("accounts.json");
19
+ }
20
+ export function accountsLabel() {
21
+ return userDataLabel("accounts.json");
22
+ }
23
+ export async function updateOpenAICodexAccount(change, signal) {
24
+ const file = accountsPath();
25
+ const directory = path.dirname(file);
26
+ await mkdir(directory, { recursive: true, mode: 0o700 });
27
+ if (process.platform !== "win32")
28
+ await chmod(directory, 0o700);
29
+ return withAccountLock(file, async () => {
30
+ const current = readStore(file);
31
+ const next = await change(current.accounts["openai-codex"]);
32
+ const accounts = { ...current.accounts };
33
+ if (next === undefined)
34
+ delete accounts["openai-codex"];
35
+ else
36
+ accounts["openai-codex"] = { ...next };
37
+ const updated = { version: 1, accounts };
38
+ await atomicWrite(file, `${JSON.stringify(updated, null, 2)}\n`, { mode: 0o600 });
39
+ cached = updated;
40
+ return next === undefined ? undefined : { ...next };
41
+ }, signal);
42
+ }
43
+ export function reloadAccounts() {
44
+ cached = undefined;
45
+ }
46
+ function store() {
47
+ if (cached === undefined)
48
+ cached = readStore(accountsPath());
49
+ return cached;
50
+ }
51
+ function readStore(file) {
52
+ try {
53
+ const parsed = JSON.parse(readFileSync(file, "utf8"));
54
+ return normalize(parsed);
55
+ }
56
+ catch {
57
+ return { version: 1, accounts: {} };
58
+ }
59
+ }
60
+ function normalize(value) {
61
+ if (!record(value) || value["version"] !== 1 || !record(value["accounts"])) {
62
+ return { version: 1, accounts: {} };
63
+ }
64
+ const account = normalizeOpenAI(value["accounts"]["openai-codex"]);
65
+ return {
66
+ version: 1,
67
+ accounts: account === undefined ? {} : { "openai-codex": account },
68
+ };
69
+ }
70
+ function normalizeOpenAI(value) {
71
+ if (!record(value))
72
+ return undefined;
73
+ const accessToken = nonempty(value["accessToken"]);
74
+ const refreshToken = nonempty(value["refreshToken"]);
75
+ const accountId = nonempty(value["accountId"]);
76
+ const expiresAt = value["expiresAt"];
77
+ if (accessToken === undefined ||
78
+ refreshToken === undefined ||
79
+ accountId === undefined ||
80
+ typeof expiresAt !== "number" ||
81
+ !Number.isSafeInteger(expiresAt) ||
82
+ expiresAt <= 0)
83
+ return undefined;
84
+ const email = nonempty(value["email"]);
85
+ const plan = nonempty(value["plan"]);
86
+ return {
87
+ accessToken,
88
+ refreshToken,
89
+ expiresAt,
90
+ accountId,
91
+ ...(email === undefined ? {} : { email }),
92
+ ...(plan === undefined ? {} : { plan }),
93
+ };
94
+ }
95
+ function nonempty(value) {
96
+ return typeof value === "string" && value.trim() !== "" ? value : undefined;
97
+ }
98
+ function record(value) {
99
+ return typeof value === "object" && value !== null && !Array.isArray(value);
100
+ }
package/dist/cli-info.js CHANGED
@@ -8,10 +8,10 @@ Usage:
8
8
 
9
9
  Options:
10
10
  --root <path> workspace root (default: current directory)
11
- --provider <id> anthropic, openai, or ollama
11
+ --provider <id> anthropic, openai, openai-codex, or ollama
12
12
  --model <id> model for the selected provider
13
13
  --ollama-host <url> Ollama Cloud, local, or custom endpoint
14
- --effort <level> low, medium, high, or max
14
+ --effort <level> low, medium, high, xhigh, or max
15
15
  --max-tokens <number> output-token ceiling
16
16
  --max-steps <number> tool-loop ceiling
17
17
  --reduced-motion disable animated terminal states
package/dist/commands.js CHANGED
@@ -24,7 +24,7 @@ export const COMMANDS = [
24
24
  { name: "permissions", blurb: "manage session tool access" },
25
25
  { name: "settings", blurb: "change and save jecode defaults" },
26
26
  { name: "effort", blurb: "set the reasoning effort" },
27
- { name: "credentials", blurb: "inspect, replace, or forget API keys" },
27
+ { name: "credentials", blurb: "manage API keys and connected accounts" },
28
28
  { name: "models", blurb: "pick a model, from what the provider offers" },
29
29
  { name: "providers", blurb: "pick a provider" },
30
30
  ];
@@ -14,6 +14,7 @@ export const MAX_TOOL_CALLS_PER_STEP = 32;
14
14
  export async function runTurn(history, options, events, signal) {
15
15
  const specs = toolSpecs(options.tools);
16
16
  for (let step = 0; step < options.maxSteps; step++) {
17
+ throwIfAborted(signal);
17
18
  events.onStep?.(step + 1, options.maxSteps);
18
19
  // The message is displayed as it streams; what comes back here is the
19
20
  // assembled version, which exists to be appended to the history.
@@ -28,7 +29,11 @@ export async function runTurn(history, options, events, signal) {
28
29
  onStream: (event) => events.onStream(event),
29
30
  onStatus: (status) => events.onStatus?.(status),
30
31
  });
32
+ throwIfAborted(signal);
31
33
  const calls = assistant.content.filter(isToolCall);
34
+ if (assistant.content.length === 0) {
35
+ throw new Error(`${options.provider.id} completed without an answer or tool call`);
36
+ }
32
37
  if (calls.length > MAX_TOOL_CALLS_PER_STEP) {
33
38
  throw new Error(`provider returned ${calls.length} tool calls in one step (maximum ${MAX_TOOL_CALLS_PER_STEP})`);
34
39
  }
@@ -41,14 +46,33 @@ export async function runTurn(history, options, events, signal) {
41
46
  // but every result from this step goes back in a SINGLE message. Splitting
42
47
  // them teaches the model to stop batching its calls.
43
48
  const results = [];
44
- for (let index = 0; index < calls.length; index++) {
45
- const call = calls[index];
46
- events.onToolProgress?.(index + 1, calls.length);
47
- const preview = await look(call, options);
48
- events.onToolCall(call, preview);
49
- const { result, summary } = await settle(call, options, events, signal, preview);
50
- events.onToolResult(call, result, summary);
51
- results.push(result);
49
+ const announced = new Set();
50
+ try {
51
+ for (let index = 0; index < calls.length; index++) {
52
+ throwIfAborted(signal);
53
+ const call = calls[index];
54
+ events.onToolProgress?.(index + 1, calls.length);
55
+ const preview = await look(call, options, signal);
56
+ throwIfAborted(signal);
57
+ events.onToolCall(call, preview);
58
+ announced.add(call.id);
59
+ const { result, summary } = await settle(call, options, events, signal, preview);
60
+ events.onToolResult(call, result, summary);
61
+ results.push(result);
62
+ }
63
+ }
64
+ catch (error) {
65
+ if (signal?.aborted !== true)
66
+ throw error;
67
+ for (const call of calls.slice(results.length)) {
68
+ const interrupted = refuse(call, "interrupted before completion", "interrupted");
69
+ if (announced.has(call.id)) {
70
+ events.onToolResult(call, interrupted.result, interrupted.summary);
71
+ }
72
+ results.push(interrupted.result);
73
+ }
74
+ history.push({ role: "user", content: results });
75
+ throw abortReason(signal);
52
76
  }
53
77
  history.push({ role: "user", content: results });
54
78
  }
@@ -59,9 +83,13 @@ async function settle(call, options, events, signal, preview) {
59
83
  if (tool === undefined) {
60
84
  return refuse(call, `no such tool: ${call.name}`, "unknown tool");
61
85
  }
62
- if (tool.dangerous && !(await events.approve(call))) {
86
+ throwIfAborted(signal);
87
+ const approved = !tool.dangerous || await events.approve(call);
88
+ throwIfAborted(signal);
89
+ if (!approved) {
63
90
  return refuse(call, "the user declined this call — ask them how to proceed", "declined");
64
91
  }
92
+ throwIfAborted(signal);
65
93
  return runTool(tool, call, {
66
94
  ...options.toolContext,
67
95
  signal,
@@ -82,14 +110,21 @@ function refuse(call, reason, summary) {
82
110
  * file, a match that is not there — is simply no preview. Nothing about the
83
111
  * turn depends on it, because it exists for the user, not for the model.
84
112
  */
85
- async function look(call, options) {
113
+ async function look(call, options, signal) {
86
114
  const tool = findTool(options.tools, call.name);
87
115
  if (tool?.preview === undefined)
88
116
  return undefined;
89
117
  try {
90
- return await tool.preview(call.input, options.toolContext);
118
+ return await tool.preview(call.input, { ...options.toolContext, signal });
91
119
  }
92
120
  catch {
93
121
  return undefined;
94
122
  }
95
123
  }
124
+ function throwIfAborted(signal) {
125
+ if (signal?.aborted === true)
126
+ throw abortReason(signal);
127
+ }
128
+ function abortReason(signal) {
129
+ return signal.reason instanceof Error ? signal.reason : new Error("interrupted");
130
+ }
@@ -2,6 +2,8 @@
2
2
  import { heading } from "./tui/picker.js";
3
3
  import { EMPTY } from "./tui/editor.js";
4
4
  import { PROVIDERS } from "./providers/index.js";
5
+ import { openAIAccountHint } from "./openai-account.js";
6
+ import { ensureOpenAIAccount, openAIAccountCommand, } from "./openai-account-command.js";
5
7
  import { credentialSource, forgetSaved, hasSaved, hold, keep, storeLabel, } from "./credentials.js";
6
8
  /** Ask for a key, then ask separately whether it may be written to disk. */
7
9
  export async function askForKey(name, host, pal) {
@@ -52,10 +54,12 @@ export async function credentialsCommand(session, host) {
52
54
  if (choose === undefined)
53
55
  return;
54
56
  const index = await choose({
55
- title: heading("credential", "values are never shown", session.palette),
57
+ title: heading("authentication", "secrets are never shown", session.palette),
56
58
  options: PROVIDERS.map((provider) => ({
57
- label: provider.keyVar,
58
- hint: credentialSource(provider.keyVar) ?? "missing",
59
+ label: provider.auth.kind === "api-key" ? provider.auth.keyVar : `${provider.auth.label} account`,
60
+ hint: provider.auth.kind === "api-key"
61
+ ? credentialSource(provider.auth.keyVar) ?? "missing"
62
+ : openAIAccountHint(),
59
63
  })),
60
64
  index: Math.max(0, PROVIDERS.findIndex((provider) => provider.id === session.provider.id)),
61
65
  });
@@ -64,7 +68,11 @@ export async function credentialsCommand(session, host) {
64
68
  const provider = PROVIDERS[index];
65
69
  if (provider === undefined)
66
70
  return;
67
- const name = provider.keyVar;
71
+ if (provider.auth.kind === "oauth") {
72
+ await openAIAccountCommand(session, host);
73
+ return;
74
+ }
75
+ const name = provider.auth.keyVar;
68
76
  const source = credentialSource(name);
69
77
  if (source === "environment") {
70
78
  host.emit({
@@ -93,6 +101,26 @@ export async function credentialsCommand(session, host) {
93
101
  }
94
102
  await askForKey(name, host, session.palette);
95
103
  }
104
+ /** Offer the authentication flow owned by a provider, if that is its blocker. */
105
+ export async function ensureProviderAuthentication(provider, session, host) {
106
+ const blocked = provider.blocked();
107
+ if (blocked === undefined)
108
+ return true;
109
+ if (provider.auth.kind === "oauth") {
110
+ return provider.auth.account === "openai-codex"
111
+ ? ensureOpenAIAccount(session, host)
112
+ : false;
113
+ }
114
+ if (!blocked.startsWith(`${provider.auth.keyVar} `)) {
115
+ host.emit({ kind: "notice", text: blocked, tone: "error" });
116
+ return false;
117
+ }
118
+ await askForKey(provider.auth.keyVar, host, session.palette);
119
+ return provider.blocked() === undefined;
120
+ }
121
+ export function authenticationNeed(provider) {
122
+ return provider.auth.kind === "oauth" ? `${provider.auth.label} sign-in` : "an API key";
123
+ }
96
124
  async function offerForget(name, session, host, hint) {
97
125
  if (host.choose === undefined)
98
126
  return;
@@ -1,8 +1,15 @@
1
1
  // The shell needs a useful process environment, not the application's secrets.
2
2
  import { credentialValues } from "./credentials.js";
3
+ import { accountValues } from "./accounts.js";
3
4
  const REDACTED = "[credential redacted]";
4
5
  const SENSITIVE_ENVIRONMENT_NAME = /(?:^|_)(?:API_?KEY|ACCESS_?KEY|PRIVATE_?KEY|KEY|TOKEN|SECRET|PASSWORD|PASSWD|PASS|PWD|CREDENTIALS?|AUTH|JWT|COOKIE|PAT)(?:_|$)/i;
5
6
  const COMPACT_SENSITIVE_ENVIRONMENT_NAME = /^(?:PGPASSWORD)$/i;
7
+ const SAFE_ENVIRONMENT_NAMES = new Set([
8
+ "SSH_AUTH_SOCK",
9
+ "PWD",
10
+ "OLDPWD",
11
+ "PASSWORD_STORE_DIR",
12
+ ]);
6
13
  /** Preserve ordinary tool configuration while withholding credential-like values. */
7
14
  export function shellEnvironment(source = process.env) {
8
15
  const environment = {};
@@ -56,11 +63,13 @@ function sensitiveEnvironmentName(name) {
56
63
  .replace(/([a-z0-9])([A-Z])/g, "$1_$2")
57
64
  .replace(/[^a-z0-9]+/gi, "_")
58
65
  .replace(/^_+|_+$/g, "");
66
+ if (SAFE_ENVIRONMENT_NAMES.has(normalized.toUpperCase()))
67
+ return false;
59
68
  return (SENSITIVE_ENVIRONMENT_NAME.test(normalized) ||
60
69
  COMPACT_SENSITIVE_ENVIRONMENT_NAME.test(normalized));
61
70
  }
62
71
  function secrets(source) {
63
- const values = new Set(credentialValues());
72
+ const values = new Set([...credentialValues(), ...accountValues()]);
64
73
  for (const [name, value] of Object.entries(source)) {
65
74
  if (value !== undefined && value !== "" && sensitiveEnvironment(name, value))
66
75
  values.add(value);
package/dist/effort.js ADDED
@@ -0,0 +1,25 @@
1
+ // One ordered vocabulary for reasoning depth. Providers expose the subset a
2
+ // selected model accepts; this module only validates and reconciles that data.
3
+ export const EFFORTS = ["low", "medium", "high", "xhigh", "max"];
4
+ export function isEffort(value) {
5
+ return typeof value === "string" && EFFORTS.includes(value);
6
+ }
7
+ export function requireSupportedEffort(model, requested, supported) {
8
+ if (supported.includes(requested))
9
+ return requested;
10
+ const choices = supported.length === 0 ? "none" : supported.join(", ");
11
+ throw new Error(`${model} does not support effort "${requested}" (available: ${choices})`);
12
+ }
13
+ /** Keep the requested depth when possible, otherwise choose the nearest lower level. */
14
+ export function compatibleEffort(requested, supported) {
15
+ if (supported.length === 0)
16
+ return undefined;
17
+ if (supported.includes(requested))
18
+ return requested;
19
+ const requestedIndex = EFFORTS.indexOf(requested);
20
+ const candidates = EFFORTS.filter((value) => supported.includes(value));
21
+ if (requestedIndex < 0)
22
+ return candidates[0];
23
+ return [...candidates].reverse().find((value) => EFFORTS.indexOf(value) < requestedIndex)
24
+ ?? candidates[0];
25
+ }
@@ -0,0 +1,53 @@
1
+ // Open one HTTPS URL without involving a shell or interpolating commands.
2
+ import { spawn } from "node:child_process";
3
+ import { readFileSync } from "node:fs";
4
+ export async function openExternal(url) {
5
+ const target = new URL(url);
6
+ if (target.protocol !== "https:")
7
+ throw new Error("only HTTPS links may be opened");
8
+ const command = browserCommand(target.href);
9
+ if (command === undefined)
10
+ return false;
11
+ return new Promise((resolve) => {
12
+ const child = spawn(command.file, command.args, {
13
+ detached: true,
14
+ stdio: "ignore",
15
+ windowsHide: true,
16
+ });
17
+ child.once("error", () => resolve(false));
18
+ child.once("spawn", () => {
19
+ child.unref();
20
+ resolve(true);
21
+ });
22
+ });
23
+ }
24
+ function browserCommand(url) {
25
+ if (process.platform === "win32") {
26
+ return { file: "rundll32.exe", args: ["url.dll,FileProtocolHandler", url] };
27
+ }
28
+ if (process.platform === "darwin")
29
+ return { file: "open", args: [url] };
30
+ if (isWsl())
31
+ return { file: "explorer.exe", args: [url] };
32
+ return { file: "xdg-open", args: [url] };
33
+ }
34
+ export function headlessEnvironment() {
35
+ if (isWsl() || process.env["SSH_CONNECTION"] !== undefined || process.env["SSH_TTY"] !== undefined) {
36
+ return true;
37
+ }
38
+ return process.platform === "linux" &&
39
+ process.env["DISPLAY"] === undefined &&
40
+ process.env["WAYLAND_DISPLAY"] === undefined;
41
+ }
42
+ function isWsl() {
43
+ if (process.platform !== "linux")
44
+ return false;
45
+ if (process.env["WSL_DISTRO_NAME"] !== undefined || process.env["WSL_INTEROP"] !== undefined)
46
+ return true;
47
+ try {
48
+ return /microsoft/i.test(readFileSync("/proc/version", "utf8"));
49
+ }
50
+ catch {
51
+ return false;
52
+ }
53
+ }