@giovannijecha/jecode 0.8.5 → 0.8.6
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 +22 -5
- package/dist/batch-view.js +27 -2
- package/dist/batch.js +2 -0
- package/dist/cli-info.js +0 -1
- package/dist/config.js +14 -6
- package/dist/credential-commands.js +3 -3
- package/dist/openai-account-command.js +14 -12
- package/dist/openai-account.js +7 -5
- package/dist/openai-oauth-callback.js +13 -4
- package/dist/openai-oauth-tokens.js +9 -7
- package/dist/openai-oauth.js +8 -6
- package/dist/permission-command.js +1 -1
- package/dist/provider-commands.js +1 -33
- package/dist/provider-errors.js +1 -10
- package/dist/provider-label.js +3 -10
- package/dist/providers/anthropic.js +0 -1
- package/dist/providers/index.js +1 -5
- package/dist/providers/ollama-context.js +42 -0
- package/dist/providers/ollama-endpoint.js +7 -34
- package/dist/providers/ollama.js +13 -147
- package/dist/providers/openai-codex.js +4 -4
- package/dist/providers/openai.js +0 -1
- package/dist/sessions/bucket.js +55 -0
- package/dist/sessions/catalog-io.js +162 -0
- package/dist/sessions/catalog.js +3 -1
- package/dist/sessions/codec-messages.js +122 -0
- package/dist/sessions/codec-transcript.js +93 -0
- package/dist/sessions/codec-values.js +52 -0
- package/dist/sessions/codec.js +4 -257
- package/dist/sessions/files.js +158 -0
- package/dist/sessions/load.js +90 -0
- package/dist/sessions/snapshot.js +33 -0
- package/dist/sessions/store.js +42 -461
- package/dist/settings-command.js +10 -5
- package/dist/settings.js +16 -15
- package/dist/start.js +1 -2
- package/dist/tools/file-read.js +192 -0
- package/dist/tools/file-summary.js +9 -0
- package/dist/tools/{fs.js → file-write.js} +5 -193
- package/dist/tools/glob.js +107 -0
- package/dist/tools/index.js +2 -1
- package/dist/tools/search.js +1 -105
- package/dist/tui/app-workflows.js +8 -361
- package/dist/tui/approve.js +5 -3
- package/dist/tui/blocks.js +8 -7
- package/dist/tui/command-workflow.js +106 -0
- package/dist/tui/components/command-menu.js +7 -10
- package/dist/tui/components/menu.js +74 -43
- package/dist/tui/components/messages.js +16 -9
- package/dist/tui/components/tool-evidence.js +107 -0
- package/dist/tui/components/tool-motion.js +32 -0
- package/dist/tui/components/tool.js +48 -202
- package/dist/tui/help.js +1 -1
- package/dist/tui/picker-layout.js +40 -0
- package/dist/tui/picker.js +7 -71
- package/dist/tui/tool-details.js +135 -0
- package/dist/tui/transcript-grammar.js +8 -1
- package/dist/tui/transcript-view.js +26 -112
- package/dist/tui/turn-workflow.js +264 -0
- package/dist/tui/turn.js +7 -140
- package/dist/tui/workflow-types.js +2 -0
- package/package.json +12 -12
- package/dist/ollama-settings-command.js +0 -74
- package/dist/tui/motion.js +0 -32
package/README.md
CHANGED
|
@@ -26,9 +26,9 @@
|
|
|
26
26
|
<a href="https://github.com/giovannijecha/jecode/releases">Releases</a>
|
|
27
27
|
</p>
|
|
28
28
|
|
|
29
|
-
> Jecode is
|
|
30
|
-
>
|
|
31
|
-
>
|
|
29
|
+
> Jecode is stabilizing for 1.0. New capabilities are deferred while correctness,
|
|
30
|
+
> recovery, performance, accessibility, and interface refinements continue within
|
|
31
|
+
> the [compatibility contract](https://github.com/giovannijecha/jecode/blob/main/docs/COMPATIBILITY.md).
|
|
32
32
|
|
|
33
33
|
## Why Jecode
|
|
34
34
|
|
|
@@ -37,8 +37,16 @@ follow tool calls and diffs, steer the model while it runs, and resume the same
|
|
|
37
37
|
conversation later. One controller carries each turn from prompt to result—no
|
|
38
38
|
delegated agents or hidden model workers.
|
|
39
39
|
|
|
40
|
-
|
|
41
|
-
|
|
40
|
+
Tool records keep the target, outcome, and compact evidence together. Expand a
|
|
41
|
+
record to inspect its complete output or diff while the composer stays ready
|
|
42
|
+
at the full width of the terminal.
|
|
43
|
+
|
|
44
|
+
Menus keep the selected choice and its details together. Approvals separate
|
|
45
|
+
the target from the decision and make the scope of remembered access explicit.
|
|
46
|
+
|
|
47
|
+
Choose Anthropic API, OpenAI API, OpenAI Account with eligible ChatGPT access,
|
|
48
|
+
or Ollama API without changing the workflow. Ollama uses its cloud API only.
|
|
49
|
+
API connections and account access stay separate, and
|
|
42
50
|
the footer always names the route selected for the next request. Jecode is
|
|
43
51
|
written in TypeScript and released as plain JavaScript for Node.js, with no
|
|
44
52
|
installation scripts and zero third-party runtime dependencies. The runtime
|
|
@@ -166,6 +174,15 @@ The [user guide](https://github.com/giovannijecha/jecode/blob/main/docs/USAGE.md
|
|
|
166
174
|
covers provider access, TUI controls, sessions and compaction, batch mode,
|
|
167
175
|
configuration, and safety boundaries.
|
|
168
176
|
|
|
177
|
+
The [architecture guide](https://github.com/giovannijecha/jecode/blob/main/docs/ARCHITECTURE.md)
|
|
178
|
+
explains the current implementation and module boundaries. Maintainers use the
|
|
179
|
+
[release guide](https://github.com/giovannijecha/jecode/blob/main/docs/RELEASING.md)
|
|
180
|
+
for publishing and release recovery.
|
|
181
|
+
|
|
182
|
+
The [development environments](https://github.com/giovannijecha/jecode/blob/main/dev/README.md)
|
|
183
|
+
provide the TUI lab, current interface direction, and manual performance probes
|
|
184
|
+
for working from a source checkout.
|
|
185
|
+
|
|
169
186
|
## Community
|
|
170
187
|
|
|
171
188
|
- Ask questions, share workflows, and explore early ideas in
|
package/dist/batch-view.js
CHANGED
|
@@ -1,9 +1,14 @@
|
|
|
1
1
|
// Project semantic blocks into a compact transcript for pipes.
|
|
2
|
-
import {
|
|
2
|
+
import { renderAnswer, renderReasoning, renderUser } from "./tui/components/messages.js";
|
|
3
|
+
import { renderNotice } from "./tui/components/misc.js";
|
|
4
|
+
import { renderDetail } from "./tui/components/tool-evidence.js";
|
|
5
|
+
import { stateInk, stateMark } from "./tui/components/tool-motion.js";
|
|
6
|
+
import { toolDuration } from "./duration.js";
|
|
7
|
+
import { row } from "./ui/render.js";
|
|
3
8
|
export function renderBatch(block, width, pal) {
|
|
4
9
|
const rows = [];
|
|
5
10
|
let lastWasBlank = false;
|
|
6
|
-
for (const rendered of
|
|
11
|
+
for (const rendered of batchRows(block, width, pal)) {
|
|
7
12
|
const line = rendered.trimEnd();
|
|
8
13
|
const blank = line === "";
|
|
9
14
|
if (blank && lastWasBlank)
|
|
@@ -15,3 +20,23 @@ export function renderBatch(block, width, pal) {
|
|
|
15
20
|
rows.pop();
|
|
16
21
|
return rows;
|
|
17
22
|
}
|
|
23
|
+
function batchRows(block, width, pal) {
|
|
24
|
+
switch (block.kind) {
|
|
25
|
+
case "answer": return renderAnswer(block, width, pal);
|
|
26
|
+
case "reasoning": return renderReasoning(block, width, pal);
|
|
27
|
+
case "user": return renderUser(block, width, pal);
|
|
28
|
+
case "notice": return renderNotice(block, width, pal);
|
|
29
|
+
case "tool": {
|
|
30
|
+
// Pipes cannot expand records or repaint a running connector.
|
|
31
|
+
const mark = stateMark(block);
|
|
32
|
+
const result = [block.right, block.durationMs === undefined ? "" : toolDuration(block.durationMs)]
|
|
33
|
+
.filter(Boolean).join(" · ");
|
|
34
|
+
return ["", row(width, [
|
|
35
|
+
{ text: `${mark} `, fg: stateInk(block, pal) },
|
|
36
|
+
{ text: block.name, fg: pal.ink.bright },
|
|
37
|
+
{ text: block.target === "" ? "" : ` ${block.target}`, fg: pal.technical },
|
|
38
|
+
], [{ text: result, fg: pal.ink.muted }]),
|
|
39
|
+
...(block.body ?? []).map((detail) => renderDetail(detail, block.tone, width, pal))];
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
package/dist/batch.js
CHANGED
|
@@ -68,6 +68,7 @@ export async function runBatch(session, environment = {}) {
|
|
|
68
68
|
provider: session.provider,
|
|
69
69
|
model: session.model,
|
|
70
70
|
compactionPercent: session.config.compactionPercent,
|
|
71
|
+
signal,
|
|
71
72
|
});
|
|
72
73
|
};
|
|
73
74
|
const user = { role: "user", content: [{ kind: "text", text: line }] };
|
|
@@ -106,6 +107,7 @@ export async function runBatch(session, environment = {}) {
|
|
|
106
107
|
provider: session.provider,
|
|
107
108
|
model: session.model,
|
|
108
109
|
effort: session.config.effort,
|
|
110
|
+
signal,
|
|
109
111
|
context: projected,
|
|
110
112
|
turn: checkpoint.slice(before),
|
|
111
113
|
nodeId: nodeId ?? prospectiveNodeId,
|
package/dist/cli-info.js
CHANGED
|
@@ -12,7 +12,6 @@ Options:
|
|
|
12
12
|
--root <path> workspace root (default: current directory)
|
|
13
13
|
--provider <id> anthropic, openai, openai-codex, or ollama
|
|
14
14
|
--model <id> model for the selected provider
|
|
15
|
-
--ollama-host <url> Ollama Cloud, local, or custom endpoint
|
|
16
15
|
--effort <level> low, medium, high, xhigh, or max
|
|
17
16
|
--max-tokens <number> output-token ceiling
|
|
18
17
|
--max-steps <number> optional per-turn model-request budget
|
package/dist/config.js
CHANGED
|
@@ -2,11 +2,10 @@
|
|
|
2
2
|
import * as path from "node:path";
|
|
3
3
|
import { DEFAULT_COMPACTION_PERCENT, MAX_COMPACTION_PERCENT, MIN_COMPACTION_PERCENT, } from "./context/policy.js";
|
|
4
4
|
import { EFFORTS, readSettings } from "./settings.js";
|
|
5
|
-
import {
|
|
5
|
+
import { isLegacyOllamaCloudHost } from "./providers/ollama-endpoint.js";
|
|
6
6
|
const VALUE_FLAGS = [
|
|
7
7
|
"provider",
|
|
8
8
|
"model",
|
|
9
|
-
"ollama-host",
|
|
10
9
|
"effort",
|
|
11
10
|
"max-tokens",
|
|
12
11
|
"max-steps",
|
|
@@ -21,8 +20,8 @@ const BOOLEAN_FLAGS = [
|
|
|
21
20
|
const FLAGS = [...VALUE_FLAGS, ...BOOLEAN_FLAGS];
|
|
22
21
|
export function loadConfig(argv, saved = readSettings()) {
|
|
23
22
|
const flags = parseFlags(argv);
|
|
23
|
+
assertRetiredOllamaSettings(saved);
|
|
24
24
|
const providerId = pick(flags.provider, process.env.JECODE_PROVIDER, saved.provider ?? "anthropic");
|
|
25
|
-
const ollamaHost = optional(flags["ollama-host"], process.env.OLLAMA_HOST, saved.ollamaHost);
|
|
26
25
|
const effort = pick(flags.effort, process.env.JECODE_EFFORT, saved.effort ?? "high");
|
|
27
26
|
if (!EFFORTS.includes(effort)) {
|
|
28
27
|
throw new Error(`unknown effort "${effort}" (expected one of: ${EFFORTS.join(", ")})`);
|
|
@@ -31,9 +30,6 @@ export function loadConfig(argv, saved = readSettings()) {
|
|
|
31
30
|
return {
|
|
32
31
|
providerId,
|
|
33
32
|
model: pick(flags.model, process.env.JECODE_MODEL, saved.models?.[providerId] ?? ""),
|
|
34
|
-
...(ollamaHost === undefined
|
|
35
|
-
? {}
|
|
36
|
-
: { ollamaHost: parseOllamaEndpoint(ollamaHost).baseUrl }),
|
|
37
33
|
reducedMotion: bool(flags["reduced-motion"], process.env.JECODE_REDUCED_MOTION, saved.reducedMotion ?? false),
|
|
38
34
|
effort,
|
|
39
35
|
// This is a ceiling, not a target. Request budgeting may lower it to fit
|
|
@@ -48,6 +44,15 @@ export function loadConfig(argv, saved = readSettings()) {
|
|
|
48
44
|
ephemeral: bool(flags.ephemeral, process.env.JECODE_EPHEMERAL, false),
|
|
49
45
|
};
|
|
50
46
|
}
|
|
47
|
+
function assertRetiredOllamaSettings(saved) {
|
|
48
|
+
const host = process.env.OLLAMA_HOST;
|
|
49
|
+
if (host !== undefined && host !== "" && !isLegacyOllamaCloudHost(host)) {
|
|
50
|
+
throw new Error("OLLAMA_HOST no longer selects an endpoint; remove it to use Ollama API at https://ollama.com");
|
|
51
|
+
}
|
|
52
|
+
if (saved.ollamaHost !== undefined && !isLegacyOllamaCloudHost(saved.ollamaHost)) {
|
|
53
|
+
throw new Error("settings.json has a retired Ollama endpoint; remove ollamaHost to use Ollama API at https://ollama.com");
|
|
54
|
+
}
|
|
55
|
+
}
|
|
51
56
|
function bool(flag, env, fallback) {
|
|
52
57
|
if (flag !== undefined)
|
|
53
58
|
return flag === "true" || flag === "1";
|
|
@@ -105,6 +110,9 @@ function parseFlags(argv) {
|
|
|
105
110
|
const eq = body.indexOf("=");
|
|
106
111
|
const name = eq === -1 ? body : body.slice(0, eq);
|
|
107
112
|
const inline = eq === -1 ? undefined : body.slice(eq + 1);
|
|
113
|
+
if (name === "ollama-host") {
|
|
114
|
+
throw new Error("--ollama-host is no longer supported; remove it to use Ollama API at https://ollama.com");
|
|
115
|
+
}
|
|
108
116
|
if (!FLAGS.includes(name)) {
|
|
109
117
|
throw new Error(`unknown flag --${name} (known: ${FLAGS.map((flag) => `--${flag}`).join(", ")})`);
|
|
110
118
|
}
|
|
@@ -64,13 +64,13 @@ export async function apiKeyCommand(name, label, session, host, providerId) {
|
|
|
64
64
|
if (!hasSaved(name)) {
|
|
65
65
|
host.emit({
|
|
66
66
|
kind: "notice",
|
|
67
|
-
text: `${label}
|
|
67
|
+
text: `${label} key comes from the environment · restart after changing it`,
|
|
68
68
|
tone: "info",
|
|
69
69
|
});
|
|
70
70
|
return;
|
|
71
71
|
}
|
|
72
72
|
const action = await choose({
|
|
73
|
-
title: heading(`${label}
|
|
73
|
+
title: heading(`${label} key`, `${name} · environment`, session.palette),
|
|
74
74
|
description: "The environment value is read-only. A saved copy is currently shadowed.",
|
|
75
75
|
options: [{ label: "forget saved copy", hint: storeLabel(), key: "f" }],
|
|
76
76
|
index: 0,
|
|
@@ -93,7 +93,7 @@ export async function apiKeyCommand(name, label, session, host, providerId) {
|
|
|
93
93
|
: []),
|
|
94
94
|
];
|
|
95
95
|
const index = await choose({
|
|
96
|
-
title: heading(`${label}
|
|
96
|
+
title: heading(`${label} key`, `${name} · ${source ?? "missing"}`, session.palette),
|
|
97
97
|
options: actions,
|
|
98
98
|
index: 0,
|
|
99
99
|
});
|
|
@@ -1,9 +1,11 @@
|
|
|
1
|
-
// Interactive
|
|
1
|
+
// Interactive account management for the OpenAI Codex provider.
|
|
2
2
|
import { openAICodexAccount } from "./accounts.js";
|
|
3
3
|
import { headlessEnvironment, openExternal } from "./external-browser.js";
|
|
4
4
|
import { openAIAccountHint, removeOpenAIAccount, saveOpenAIAccount, } from "./openai-account.js";
|
|
5
5
|
import { beginBrowserLogin, beginDeviceLogin, } from "./openai-oauth.js";
|
|
6
|
+
import { providerLabel } from "./provider-label.js";
|
|
6
7
|
import { heading } from "./tui/picker.js";
|
|
8
|
+
const ACCOUNT_LABEL = providerLabel("openai-codex");
|
|
7
9
|
const DEFAULT_DEPENDENCIES = {
|
|
8
10
|
beginBrowser: beginBrowserLogin,
|
|
9
11
|
beginDevice: beginDeviceLogin,
|
|
@@ -16,12 +18,12 @@ export async function openAIAccountCommand(session, host, dependencies = DEFAULT
|
|
|
16
18
|
const connected = openAICodexAccount() !== undefined;
|
|
17
19
|
const actions = connected
|
|
18
20
|
? [
|
|
19
|
-
{ label:
|
|
21
|
+
{ label: `reconnect ${ACCOUNT_LABEL}`, hint: "replace the current sign-in", key: "r" },
|
|
20
22
|
{ label: "sign out", hint: "remove the saved account", key: "s" },
|
|
21
23
|
]
|
|
22
|
-
: [{ label:
|
|
24
|
+
: [{ label: `connect ${ACCOUNT_LABEL}`, hint: "sign in with OpenAI", key: "c" }];
|
|
23
25
|
const action = await host.choose({
|
|
24
|
-
title: heading(
|
|
26
|
+
title: heading(ACCOUNT_LABEL, openAIAccountHint(), session.palette),
|
|
25
27
|
options: actions,
|
|
26
28
|
index: 0,
|
|
27
29
|
});
|
|
@@ -35,8 +37,8 @@ export async function openAIAccountCommand(session, host, dependencies = DEFAULT
|
|
|
35
37
|
host.emit({
|
|
36
38
|
kind: "notice",
|
|
37
39
|
text: result.revokeFailed
|
|
38
|
-
?
|
|
39
|
-
:
|
|
40
|
+
? `${ACCOUNT_LABEL} disconnected · remote sign-out could not be confirmed${route}`
|
|
41
|
+
: `${ACCOUNT_LABEL} disconnected${route}`,
|
|
40
42
|
tone: result.revokeFailed ? "warn" : "info",
|
|
41
43
|
});
|
|
42
44
|
return result.removed;
|
|
@@ -56,14 +58,14 @@ async function connectOpenAIAccount(session, host, dependencies) {
|
|
|
56
58
|
{ label: "sign in with device code", hint: "WSL, SSH, or headless", key: "d" },
|
|
57
59
|
];
|
|
58
60
|
const choice = await host.choose({
|
|
59
|
-
title: heading(
|
|
61
|
+
title: heading(`connect ${ACCOUNT_LABEL}`, "OpenAI OAuth", session.palette),
|
|
60
62
|
description: "Choose the flow for this terminal. Your password stays on OpenAI's website.",
|
|
61
63
|
options: methods,
|
|
62
64
|
index: dependencies.headless() ? 1 : 0,
|
|
63
65
|
});
|
|
64
66
|
if (choice === undefined)
|
|
65
67
|
return false;
|
|
66
|
-
host.status?.(
|
|
68
|
+
host.status?.(`Starting ${ACCOUNT_LABEL} sign-in`);
|
|
67
69
|
let login;
|
|
68
70
|
try {
|
|
69
71
|
login = choice === 1
|
|
@@ -97,8 +99,8 @@ async function waitForLogin(login, session, host, openUrl) {
|
|
|
97
99
|
host.emit({
|
|
98
100
|
kind: "notice",
|
|
99
101
|
text: session.provider.id === "openai-codex"
|
|
100
|
-
?
|
|
101
|
-
:
|
|
102
|
+
? `${ACCOUNT_LABEL} connected · current provider route`
|
|
103
|
+
: `${ACCOUNT_LABEL} connected · choose an ${ACCOUNT_LABEL} model in /models to use this account`,
|
|
102
104
|
tone: "info",
|
|
103
105
|
});
|
|
104
106
|
return true;
|
|
@@ -111,7 +113,7 @@ async function waitForLogin(login, session, host, openUrl) {
|
|
|
111
113
|
await openUrl(login.url);
|
|
112
114
|
continue;
|
|
113
115
|
}
|
|
114
|
-
local.abort(new Error(
|
|
116
|
+
local.abort(new Error(`${ACCOUNT_LABEL} sign-in cancelled`));
|
|
115
117
|
return false;
|
|
116
118
|
}
|
|
117
119
|
}
|
|
@@ -124,7 +126,7 @@ function waitingPicker(login, session) {
|
|
|
124
126
|
? "Finish sign-in in your browser. Jecode will continue automatically."
|
|
125
127
|
: `Enter ${login.code} on OpenAI's device page. Jecode will continue automatically.`;
|
|
126
128
|
return {
|
|
127
|
-
title: heading(
|
|
129
|
+
title: heading(`${ACCOUNT_LABEL} sign-in`, login.code ?? "waiting for browser", session.palette),
|
|
128
130
|
description: detail,
|
|
129
131
|
options: [
|
|
130
132
|
{ label: "open browser again", key: "o" },
|
package/dist/openai-account.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
|
-
// The live
|
|
1
|
+
// The live OpenAI account: persistence, proactive refresh, and logout.
|
|
2
2
|
import { openAICodexAccount, updateOpenAICodexAccount } from "./accounts.js";
|
|
3
3
|
import { refreshOpenAITokens, revokeOpenAITokens } from "./openai-oauth.js";
|
|
4
|
+
import { providerLabel } from "./provider-label.js";
|
|
5
|
+
const ACCOUNT_LABEL = providerLabel("openai-codex");
|
|
4
6
|
const REFRESH_EARLY_MS = 5 * 60_000;
|
|
5
7
|
let refreshing;
|
|
6
8
|
export function openAIAccountHint() {
|
|
@@ -33,7 +35,7 @@ export async function removeOpenAIAccount(signal) {
|
|
|
33
35
|
export async function openAIAuthorization(rejectedToken, signal, onStatus) {
|
|
34
36
|
const account = openAICodexAccount();
|
|
35
37
|
if (account === undefined)
|
|
36
|
-
throw new Error(
|
|
38
|
+
throw new Error(`${ACCOUNT_LABEL} is not connected`);
|
|
37
39
|
const mustRefresh = rejectedToken !== undefined || expiresSoon(account);
|
|
38
40
|
const ready = mustRefresh
|
|
39
41
|
? await refreshAccount(rejectedToken, signal, onStatus)
|
|
@@ -47,14 +49,14 @@ async function refreshAccount(rejectedToken, signal, onStatus) {
|
|
|
47
49
|
throw abortReason(signal);
|
|
48
50
|
const task = updateOpenAICodexAccount(async (current) => {
|
|
49
51
|
if (current === undefined)
|
|
50
|
-
throw new Error(
|
|
52
|
+
throw new Error(`${ACCOUNT_LABEL} is not connected`);
|
|
51
53
|
if (!requiresAuthorityRefresh(current, rejectedToken))
|
|
52
54
|
return current;
|
|
53
|
-
onStatus?.(
|
|
55
|
+
onStatus?.(`Refreshing ${ACCOUNT_LABEL} sign-in`);
|
|
54
56
|
return refreshOpenAITokens(current);
|
|
55
57
|
}).then((account) => {
|
|
56
58
|
if (account === undefined)
|
|
57
|
-
throw new Error(
|
|
59
|
+
throw new Error(`${ACCOUNT_LABEL} is not connected`);
|
|
58
60
|
return account;
|
|
59
61
|
});
|
|
60
62
|
refreshing = task;
|
|
@@ -3,6 +3,8 @@ import { timingSafeEqual } from "node:crypto";
|
|
|
3
3
|
import { readFileSync } from "node:fs";
|
|
4
4
|
import { createServer } from "node:http";
|
|
5
5
|
import { leadingText } from "./text-boundary.js";
|
|
6
|
+
import { providerLabel } from "./provider-label.js";
|
|
7
|
+
const ACCOUNT_LABEL = providerLabel("openai-codex");
|
|
6
8
|
const CALLBACK_PORTS = [1455, 1457];
|
|
7
9
|
export const OPENAI_CALLBACK_PATH = "/auth/callback";
|
|
8
10
|
export async function openAICallback(state) {
|
|
@@ -26,7 +28,14 @@ export async function openAICallback(state) {
|
|
|
26
28
|
outgoing.writeHead(400).end("Invalid sign-in callback.");
|
|
27
29
|
return;
|
|
28
30
|
}
|
|
29
|
-
|
|
31
|
+
let incoming;
|
|
32
|
+
try {
|
|
33
|
+
incoming = new URL(request.url, "http://localhost");
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
outgoing.writeHead(400).end("Invalid sign-in callback.");
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
30
39
|
if (incoming.pathname !== OPENAI_CALLBACK_PATH) {
|
|
31
40
|
outgoing.writeHead(404).end("Not found.");
|
|
32
41
|
return;
|
|
@@ -43,10 +52,10 @@ export async function openAICallback(state) {
|
|
|
43
52
|
const authError = incoming.searchParams.get("error_description") ?? incoming.searchParams.get("error");
|
|
44
53
|
const authorizationCode = incoming.searchParams.get("code");
|
|
45
54
|
if (authError !== null) {
|
|
46
|
-
rejectCode(new Error(
|
|
55
|
+
rejectCode(new Error(`${ACCOUNT_LABEL} sign-in was rejected · ${leadingText(authError, 300)}`));
|
|
47
56
|
}
|
|
48
57
|
else if (authorizationCode === null || authorizationCode === "") {
|
|
49
|
-
rejectCode(new Error(
|
|
58
|
+
rejectCode(new Error(`${ACCOUNT_LABEL} sign-in returned no authorization code`));
|
|
50
59
|
}
|
|
51
60
|
else {
|
|
52
61
|
resolveCode(authorizationCode);
|
|
@@ -94,7 +103,7 @@ async function firstAvailableServer(handler) {
|
|
|
94
103
|
throw error;
|
|
95
104
|
}
|
|
96
105
|
}
|
|
97
|
-
throw new Error(
|
|
106
|
+
throw new Error(`${ACCOUNT_LABEL} sign-in could not open callback ports 1455 or 1457`);
|
|
98
107
|
}
|
|
99
108
|
function listen(server, port) {
|
|
100
109
|
return new Promise((resolve, reject) => {
|
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
// Validate OAuth token responses and extract the ChatGPT account claims.
|
|
2
|
+
import { providerLabel } from "./provider-label.js";
|
|
3
|
+
const ACCOUNT_LABEL = providerLabel("openai-codex");
|
|
2
4
|
const CLAIMS = "https://api.openai.com/auth";
|
|
3
5
|
export function openAITokenReply(value, previousRefresh) {
|
|
4
6
|
if (!record(value))
|
|
5
|
-
throw new Error(
|
|
7
|
+
throw new Error(`${ACCOUNT_LABEL} sign-in returned an invalid token response`);
|
|
6
8
|
const accessToken = required(value["access_token"], "access token");
|
|
7
9
|
const refreshToken = optional(value["refresh_token"]) ?? previousRefresh;
|
|
8
10
|
const expiresIn = value["expires_in"];
|
|
9
11
|
if (refreshToken === undefined)
|
|
10
|
-
throw new Error(
|
|
12
|
+
throw new Error(`${ACCOUNT_LABEL} sign-in did not return a refresh token`);
|
|
11
13
|
if (typeof expiresIn !== "number" || !Number.isFinite(expiresIn) || expiresIn <= 0) {
|
|
12
|
-
throw new Error(
|
|
14
|
+
throw new Error(`${ACCOUNT_LABEL} sign-in did not return a valid token lifetime`);
|
|
13
15
|
}
|
|
14
16
|
const idToken = optional(value["id_token"]);
|
|
15
17
|
return {
|
|
@@ -25,7 +27,7 @@ export function openAIAccountFromTokens(token) {
|
|
|
25
27
|
const auth = record(access[CLAIMS]) ? access[CLAIMS] : {};
|
|
26
28
|
const accountId = optional(auth["chatgpt_account_id"]) ?? optional(identity?.["chatgpt_account_id"]);
|
|
27
29
|
if (accountId === undefined)
|
|
28
|
-
throw new Error(
|
|
30
|
+
throw new Error(`${ACCOUNT_LABEL} sign-in did not identify an account`);
|
|
29
31
|
const email = optional(identity?.["email"]);
|
|
30
32
|
const plan = optional(auth["chatgpt_plan_type"]) ?? optional(identity?.["chatgpt_plan_type"]);
|
|
31
33
|
return {
|
|
@@ -40,7 +42,7 @@ export function openAIAccountFromTokens(token) {
|
|
|
40
42
|
function jwt(token) {
|
|
41
43
|
const part = token.split(".")[1];
|
|
42
44
|
if (part === undefined)
|
|
43
|
-
throw new Error(
|
|
45
|
+
throw new Error(`${ACCOUNT_LABEL} sign-in returned an unreadable token`);
|
|
44
46
|
try {
|
|
45
47
|
const value = JSON.parse(Buffer.from(part, "base64url").toString("utf8"));
|
|
46
48
|
if (!record(value))
|
|
@@ -48,13 +50,13 @@ function jwt(token) {
|
|
|
48
50
|
return value;
|
|
49
51
|
}
|
|
50
52
|
catch {
|
|
51
|
-
throw new Error(
|
|
53
|
+
throw new Error(`${ACCOUNT_LABEL} sign-in returned an unreadable token`);
|
|
52
54
|
}
|
|
53
55
|
}
|
|
54
56
|
function required(value, label) {
|
|
55
57
|
const found = optional(value);
|
|
56
58
|
if (found === undefined)
|
|
57
|
-
throw new Error(
|
|
59
|
+
throw new Error(`${ACCOUNT_LABEL} sign-in returned no ${label}`);
|
|
58
60
|
return found;
|
|
59
61
|
}
|
|
60
62
|
function optional(value) {
|
package/dist/openai-oauth.js
CHANGED
|
@@ -5,8 +5,10 @@
|
|
|
5
5
|
// an account on disk.
|
|
6
6
|
import { createHash, randomBytes } from "node:crypto";
|
|
7
7
|
import { oauthRequest } from "./oauth-http.js";
|
|
8
|
+
import { providerLabel } from "./provider-label.js";
|
|
8
9
|
import { OPENAI_CALLBACK_PATH, openAICallback } from "./openai-oauth-callback.js";
|
|
9
10
|
import { openAIAccountFromTokens, openAITokenReply, } from "./openai-oauth-tokens.js";
|
|
11
|
+
const ACCOUNT_LABEL = providerLabel("openai-codex");
|
|
10
12
|
const CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann";
|
|
11
13
|
const AUTHORITY = "https://auth.openai.com";
|
|
12
14
|
const AUTHORIZE = `${AUTHORITY}/oauth/authorize`;
|
|
@@ -91,7 +93,7 @@ export async function refreshOpenAITokens(account, signal) {
|
|
|
91
93
|
const token = openAITokenReply(response.value, account.refreshToken);
|
|
92
94
|
const refreshed = openAIAccountFromTokens(token);
|
|
93
95
|
if (refreshed.accountId !== account.accountId) {
|
|
94
|
-
throw new Error(
|
|
96
|
+
throw new Error(`${ACCOUNT_LABEL} refresh returned a different account · sign in again`);
|
|
95
97
|
}
|
|
96
98
|
return {
|
|
97
99
|
...refreshed,
|
|
@@ -125,7 +127,7 @@ async function exchange(code, signal) {
|
|
|
125
127
|
async function pollDevice(deviceAuthId, userCode, interval, signal) {
|
|
126
128
|
const deadline = new AbortController();
|
|
127
129
|
const timer = setTimeout(() => {
|
|
128
|
-
deadline.abort(new Error(
|
|
130
|
+
deadline.abort(new Error(`${ACCOUNT_LABEL} device sign-in timed out after 15 minutes`));
|
|
129
131
|
}, LOGIN_LIMIT_MS);
|
|
130
132
|
const combined = signal === undefined
|
|
131
133
|
? deadline.signal
|
|
@@ -149,10 +151,10 @@ async function pollDevice(deviceAuthId, userCode, interval, signal) {
|
|
|
149
151
|
}
|
|
150
152
|
const errorCode = deviceErrorCode(response.value);
|
|
151
153
|
if (errorCode === "access_denied") {
|
|
152
|
-
throw new Error(
|
|
154
|
+
throw new Error(`${ACCOUNT_LABEL} device sign-in was denied`);
|
|
153
155
|
}
|
|
154
156
|
if (errorCode === "expired_token") {
|
|
155
|
-
throw new Error(
|
|
157
|
+
throw new Error(`${ACCOUNT_LABEL} device sign-in code expired`);
|
|
156
158
|
}
|
|
157
159
|
const pending = errorCode === "authorization_pending" ||
|
|
158
160
|
errorCode === "deviceauth_authorization_pending" ||
|
|
@@ -160,7 +162,7 @@ async function pollDevice(deviceAuthId, userCode, interval, signal) {
|
|
|
160
162
|
const slowDown = errorCode === "slow_down" ||
|
|
161
163
|
(response.status === 429 && errorCode === undefined);
|
|
162
164
|
if (!pending && !slowDown) {
|
|
163
|
-
throw new Error(
|
|
165
|
+
throw new Error(`${ACCOUNT_LABEL} device sign-in failed (${response.status})`);
|
|
164
166
|
}
|
|
165
167
|
if (slowDown)
|
|
166
168
|
intervalMs += SLOW_DOWN_INCREMENT_MS;
|
|
@@ -192,7 +194,7 @@ function intervalSeconds(value) {
|
|
|
192
194
|
function required(value, label) {
|
|
193
195
|
const found = optional(value);
|
|
194
196
|
if (found === undefined)
|
|
195
|
-
throw new Error(
|
|
197
|
+
throw new Error(`${ACCOUNT_LABEL} sign-in returned no ${label}`);
|
|
196
198
|
return found;
|
|
197
199
|
}
|
|
198
200
|
function optional(value) {
|
|
@@ -42,7 +42,7 @@ export function permissionsPicker(tools, index = 0, adjust) {
|
|
|
42
42
|
index: Math.min(Math.max(0, index), Math.max(0, tools.length - 1)),
|
|
43
43
|
};
|
|
44
44
|
}
|
|
45
|
-
function permissionControlPicker(control, host, selected) {
|
|
45
|
+
export function permissionControlPicker(control, host, selected) {
|
|
46
46
|
return permissionsPicker(control.listTools(), selected, (index, step) => {
|
|
47
47
|
const tool = control.listTools()[index];
|
|
48
48
|
if (tool === undefined)
|
|
@@ -1,16 +1,12 @@
|
|
|
1
1
|
// Provider access and connection management. Runtime selection lives in /models.
|
|
2
|
-
import { saveCommandSettings } from "./command-settings.js";
|
|
3
2
|
import { apiKeyCommand } from "./credential-commands.js";
|
|
4
3
|
import { credentialSource } from "./credentials.js";
|
|
5
|
-
import { ollamaConnectionHint, ollamaConnectionSetting, } from "./ollama-settings-command.js";
|
|
6
4
|
import { openAIAccountHint } from "./openai-account.js";
|
|
7
5
|
import { openAIAccountCommand } from "./openai-account-command.js";
|
|
8
6
|
import { providerLabel, providerRouteLabel } from "./provider-label.js";
|
|
9
7
|
import { PROVIDERS } from "./providers/index.js";
|
|
10
|
-
import { ollamaConnection } from "./providers/ollama.js";
|
|
11
8
|
import { heading } from "./tui/picker.js";
|
|
12
|
-
|
|
13
|
-
/** The single control plane for API keys, OAuth accounts, and Ollama endpoints. */
|
|
9
|
+
/** The single control plane for API keys and OAuth accounts. */
|
|
14
10
|
export async function providersCommand(session, host) {
|
|
15
11
|
const choose = chooser(host);
|
|
16
12
|
if (choose === undefined)
|
|
@@ -81,45 +77,17 @@ function providerGroupHint(group, activeId) {
|
|
|
81
77
|
return active === undefined ? count : `${count} · current ${providerRouteLabel(active)}`;
|
|
82
78
|
}
|
|
83
79
|
export function providerAccessHint(provider) {
|
|
84
|
-
if (provider.id === "ollama") {
|
|
85
|
-
const connection = ollamaConnection();
|
|
86
|
-
if (connection.loopback)
|
|
87
|
-
return `${ollamaConnectionHint()} · no key needed`;
|
|
88
|
-
return `${ollamaConnectionHint()} · API key ${credentialSource(OLLAMA_KEY) ?? "missing"}`;
|
|
89
|
-
}
|
|
90
80
|
if (provider.auth.kind === "oauth")
|
|
91
81
|
return openAIAccountHint();
|
|
92
82
|
return `API key · ${credentialSource(provider.auth.keyVar) ?? "missing"}`;
|
|
93
83
|
}
|
|
94
84
|
async function manageProvider(provider, session, host) {
|
|
95
|
-
if (provider.id === "ollama") {
|
|
96
|
-
await ollamaProviderCommand(session, host);
|
|
97
|
-
return;
|
|
98
|
-
}
|
|
99
85
|
if (provider.auth.kind === "oauth") {
|
|
100
86
|
await openAIAccountCommand(session, host);
|
|
101
87
|
return;
|
|
102
88
|
}
|
|
103
89
|
await apiKeyCommand(provider.auth.keyVar, providerLabel(provider.id), session, host, provider.id);
|
|
104
90
|
}
|
|
105
|
-
async function ollamaProviderCommand(session, host) {
|
|
106
|
-
if (host.choose === undefined)
|
|
107
|
-
return;
|
|
108
|
-
const index = await host.choose({
|
|
109
|
-
title: heading("Ollama", "access and connection", session.palette),
|
|
110
|
-
options: [
|
|
111
|
-
{ label: "connection", hint: ollamaConnectionHint() },
|
|
112
|
-
{ label: "API key", hint: credentialSource(OLLAMA_KEY) ?? "missing" },
|
|
113
|
-
],
|
|
114
|
-
index: 0,
|
|
115
|
-
});
|
|
116
|
-
if (index === 0) {
|
|
117
|
-
await ollamaConnectionSetting(session, host, (patch) => saveCommandSettings(host, patch));
|
|
118
|
-
}
|
|
119
|
-
else if (index === 1) {
|
|
120
|
-
await apiKeyCommand(OLLAMA_KEY, "Ollama", session, host, "ollama");
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
91
|
function chooser(host) {
|
|
124
92
|
if (host.choose === undefined) {
|
|
125
93
|
host.emit({ kind: "notice", text: "that command needs the screen", tone: "warn" });
|
package/dist/provider-errors.js
CHANGED
|
@@ -4,21 +4,12 @@ import { providerLabel } from "./provider-label.js";
|
|
|
4
4
|
import { leadingText } from "./text-boundary.js";
|
|
5
5
|
import { terminalText } from "./ui/terminal-text.js";
|
|
6
6
|
import { providerFailureDetails, } from "./providers/failure.js";
|
|
7
|
-
const CONNECTION_FAILURE = /network error calling|timed out waiting for response headers|response body was idle/i;
|
|
8
7
|
const MAX_MESSAGE_CHARS = 1_000;
|
|
9
8
|
const MAX_REASON_CHARS = 500;
|
|
10
9
|
const HTML = /<!doctype\s|<\/?[a-z][^>]*>/i;
|
|
11
10
|
export function providerFailure(provider, error, labelProvider = false) {
|
|
12
11
|
const details = providerFailureDetails(provider.id, error);
|
|
13
|
-
let failure;
|
|
14
|
-
if (provider.id === "ollama" && details.kind === "network" && CONNECTION_FAILURE.test(error.message)) {
|
|
15
|
-
failure = provider.location?.() === "local"
|
|
16
|
-
? "Ollama is not reachable on this computer · start Ollama or choose cloud in /providers"
|
|
17
|
-
: "Ollama is not reachable · check its connection in /providers";
|
|
18
|
-
}
|
|
19
|
-
else {
|
|
20
|
-
failure = actionableFailure(provider.id, details.kind) ?? rawFailure(error);
|
|
21
|
-
}
|
|
12
|
+
let failure = actionableFailure(provider.id, details.kind) ?? rawFailure(error);
|
|
22
13
|
if (details.requestId !== undefined)
|
|
23
14
|
failure += ` · request ${details.requestId}`;
|
|
24
15
|
const label = providerLabel(provider.id);
|
package/dist/provider-label.js
CHANGED
|
@@ -1,23 +1,16 @@
|
|
|
1
1
|
// Stable human-facing names for provider identifiers.
|
|
2
2
|
export function providerLabel(id) {
|
|
3
3
|
switch (id) {
|
|
4
|
-
case "anthropic": return "Anthropic";
|
|
4
|
+
case "anthropic": return "Anthropic API";
|
|
5
5
|
case "openai": return "OpenAI API";
|
|
6
|
-
case "openai-codex": return "
|
|
7
|
-
case "ollama": return "Ollama";
|
|
6
|
+
case "openai-codex": return "OpenAI Account";
|
|
7
|
+
case "ollama": return "Ollama API";
|
|
8
8
|
default: return id === "" ? "Provider" : `${id[0]?.toUpperCase() ?? ""}${id.slice(1)}`;
|
|
9
9
|
}
|
|
10
10
|
}
|
|
11
11
|
/** Name the actual connection a model request will use. */
|
|
12
12
|
export function providerRouteLabel(provider) {
|
|
13
|
-
if (provider.auth.kind === "oauth")
|
|
14
|
-
return `${providerLabel(provider.id)} account`;
|
|
15
13
|
if (provider.id === "openai")
|
|
16
14
|
return "OpenAI API · billed usage";
|
|
17
|
-
if (provider.id === "anthropic")
|
|
18
|
-
return "Anthropic API";
|
|
19
|
-
if (provider.id === "ollama") {
|
|
20
|
-
return provider.location?.() === "local" ? "Ollama · local" : "Ollama API";
|
|
21
|
-
}
|
|
22
15
|
return providerLabel(provider.id);
|
|
23
16
|
}
|
package/dist/providers/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { anthropic } from "./anthropic.js";
|
|
2
2
|
import { openai } from "./openai.js";
|
|
3
3
|
import { openaiCodex } from "./openai-codex.js";
|
|
4
|
-
import {
|
|
4
|
+
import { ollama } from "./ollama.js";
|
|
5
5
|
export const PROVIDERS = [anthropic, openai, openaiCodex, ollama];
|
|
6
6
|
export function providerNames() {
|
|
7
7
|
return PROVIDERS.map((provider) => provider.id);
|
|
@@ -13,7 +13,3 @@ export function selectProvider(id) {
|
|
|
13
13
|
}
|
|
14
14
|
return found;
|
|
15
15
|
}
|
|
16
|
-
/** Apply provider-specific process state after startup precedence is resolved. */
|
|
17
|
-
export function configureProviders(config) {
|
|
18
|
-
configureOllama(config.ollamaHost);
|
|
19
|
-
}
|