@giovannijecha/jecode 0.3.2 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -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.3.x release. The core loop is usable today; commands and
28
+ > Jecode is an early 0.4.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
@@ -146,10 +146,11 @@ published package runs no compilation or installation scripts.
146
146
  ## First session
147
147
 
148
148
  Jecode opens on an empty composer instead of forcing a setup wizard. Type
149
- **/settings** when you are ready to choose a provider, select a model, and
150
- configure authentication. An API key can remain in memory for the current
151
- session or be saved explicitly under **~/.jecode**; it is never stored in the
152
- workspace.
149
+ **/providers** to connect the services you use, then **/models** to choose from
150
+ their combined live catalogues. **/settings** keeps the selected model and the
151
+ remaining non-secret defaults together. An API key can remain in memory for
152
+ the current session or be saved explicitly under **~/.jecode**; it is never
153
+ stored in the workspace.
153
154
 
154
155
  | Provider ID | Authentication | Notes |
155
156
  |---|---|---|
@@ -158,7 +159,8 @@ workspace.
158
159
  | openai-codex | ChatGPT OAuth | Experimental; uses eligible ChatGPT Codex access |
159
160
  | ollama | OLLAMA_API_KEY for Cloud/remote | Cloud with a key, local without one |
160
161
 
161
- Choose **openai-codex** to sign in on OpenAI's website without pasting a key.
162
+ Choose **ChatGPT** in **/providers** to sign in on OpenAI's website without
163
+ pasting a key.
162
164
  Jecode offers a local browser callback and a device-code flow; WSL and remote
163
165
  terminals default to the device code. The connection is saved only after the
164
166
  flow completes. Availability and usage limits are determined by the ChatGPT
@@ -168,10 +170,10 @@ and is not an endorsement of Jecode by OpenAI.
168
170
  Anthropic remains API-key only. Jecode does not reuse a Claude consumer
169
171
  subscription or copy credentials from another client.
170
172
 
171
- Choose **cloud**, **local**, or a custom endpoint from the Ollama connection row
172
- in **/settings**. Existing users with an Ollama API key automatically use
173
- **https://ollama.com**; without a key, Jecode uses the local daemon at
174
- **http://127.0.0.1:11434**. Remote custom endpoints must use HTTPS.
173
+ Choose **Ollama** in **/providers** to manage its API key and select **cloud**,
174
+ **local**, or a custom endpoint. Existing users with an Ollama API key
175
+ automatically use **https://ollama.com**; without a key, Jecode uses the local
176
+ daemon at **http://127.0.0.1:11434**. Remote custom endpoints must use HTTPS.
175
177
 
176
178
  ## Use the TUI
177
179
 
@@ -179,12 +181,11 @@ Type **/** to open searchable command completion inside the composer.
179
181
 
180
182
  | Command | What it does |
181
183
  |---|---|
182
- | /settings | Manage provider, connection, model, limits, motion, and authentication |
184
+ | /settings | Manage the selected model, limits, effort, motion, and provider access |
183
185
  | /effort | Change and save reasoning effort directly |
184
- | /providers | Switch the provider for the next turn |
185
- | /models | Search the live model catalogue |
186
- | /credentials | Manage API keys and the connected ChatGPT account |
187
- | /permissions | Manage session tool access and remembered approvals |
186
+ | /providers | Manage API keys, ChatGPT sign-in, and Ollama connections |
187
+ | /models | Search models across every available provider and select one |
188
+ | /permissions | Change session tool access inline and review remembered approvals |
188
189
  | /new | Start a clean conversation and reset session tool permissions |
189
190
  | /export | Save a timestamped Markdown transcript in the launch directory |
190
191
  | /help | Open a temporary keyboard reference in the composer dock |
@@ -193,6 +194,10 @@ Type **/** to open searchable command completion inside the composer.
193
194
  Useful controls:
194
195
 
195
196
  - **Up/Down** moves through command suggestions, menus, and input history.
197
+ - **Left/Right** moves the composer cursor or changes an inline menu value;
198
+ **Ctrl+Left/Right** moves by word.
199
+ - **Backspace/Delete** removes one character; **Ctrl+Backspace/Delete** removes
200
+ one word. **Home/End** moves to the start or end of the composer.
196
201
  - **Tab** completes a slash command without running it; **Enter** sends.
197
202
  - **Alt+Enter** inserts a newline.
198
203
  - **Esc** closes a menu or interrupts the foreground operation.
@@ -229,7 +234,9 @@ Persistent preferences live in **~/.jecode/settings.json**. Explicitly saved
229
234
  API keys live in **~/.jecode/credentials.json**; the ChatGPT OAuth account lives
230
235
  separately in **~/.jecode/accounts.json**. Both secret stores use owner-only
231
236
  permissions where the operating system supports them. Environment API keys
232
- always win.
237
+ always win. Model selection saves the provider and model as one change; the
238
+ separate startup flags remain available for automation and override that saved
239
+ choice.
233
240
 
234
241
  Jecode has one interface theme: dark Steel. **NO_COLOR** is supported for
235
242
  terminals and pipelines that disable colour.
@@ -0,0 +1,19 @@
1
+ // Shared persistence boundary for commands that change non-secret defaults.
2
+ import { updateSettings } from "./settings.js";
3
+ export async function saveCommandSettings(host, patch) {
4
+ try {
5
+ if (host.saveSettings === undefined)
6
+ await updateSettings(patch);
7
+ else
8
+ await host.saveSettings(patch);
9
+ return true;
10
+ }
11
+ catch (error) {
12
+ host.emit({
13
+ kind: "notice",
14
+ text: `could not save settings · ${error.message}`,
15
+ tone: "error",
16
+ });
17
+ return false;
18
+ }
19
+ }
package/dist/commands.js CHANGED
@@ -2,10 +2,10 @@
2
2
  //
3
3
  // One of them does reach the network — a menu of models cannot be built
4
4
  // without asking the provider what it has — but none of them ever sends a
5
- // message. Provider and credential interaction lives in provider-commands.ts;
6
- // this file keeps command discovery, dispatch, and local session operations.
7
- import { modelsCommand, providersCommand } from "./provider-commands.js";
8
- import { credentialsCommand } from "./credential-commands.js";
5
+ // message. Provider access and model selection stay in their own commands;
6
+ // this file keeps discovery, dispatch, and local session operations.
7
+ import { modelsCommand } from "./model-command.js";
8
+ import { providersCommand } from "./provider-commands.js";
9
9
  import { permissionsCommand } from "./permission-command.js";
10
10
  import { effortCommand, settingsCommand } from "./settings-command.js";
11
11
  import { emptyUsage } from "./usage.js";
@@ -13,8 +13,8 @@ import { emptyUsage } from "./usage.js";
13
13
  * The commands, declared once.
14
14
  *
15
15
  * Deliberately short. `/settings` owns persistent defaults; the narrower
16
- * provider, model, effort, and credential commands remain useful direct paths
17
- * into the same interactions.
16
+ * model, provider-access, and effort commands remain useful direct paths into
17
+ * the same interactions.
18
18
  */
19
19
  export const COMMANDS = [
20
20
  { name: "help", blurb: "show keyboard controls" },
@@ -24,9 +24,8 @@ 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: "manage API keys and connected accounts" },
28
- { name: "models", blurb: "pick a model, from what the provider offers" },
29
- { name: "providers", blurb: "pick a provider" },
27
+ { name: "models", blurb: "choose from every available provider" },
28
+ { name: "providers", blurb: "manage provider access and connections" },
30
29
  ];
31
30
  export async function handleCommand(line, session, host) {
32
31
  const [name] = line.slice(1).trim().split(/\s+/);
@@ -69,9 +68,6 @@ export async function handleCommand(line, session, host) {
69
68
  case "effort":
70
69
  await effortCommand(session, host);
71
70
  return "handled";
72
- case "credentials":
73
- await credentialsCommand(session, host);
74
- return "handled";
75
71
  case "models":
76
72
  await modelsCommand(session, host);
77
73
  return "handled";
@@ -1,10 +1,7 @@
1
- // Credential command flows shared by settings and provider selection.
2
- import { heading } from "./tui/picker.js";
1
+ // Masked API-key interaction shared by provider connection flows.
2
+ import { credentialSource, forgetSaved, forgetSession, hasSaved, hold, keep, storeLabel, } from "./credentials.js";
3
3
  import { EMPTY } from "./tui/editor.js";
4
- import { PROVIDERS } from "./providers/index.js";
5
- import { openAIAccountHint } from "./openai-account.js";
6
- import { ensureOpenAIAccount, openAIAccountCommand, } from "./openai-account-command.js";
7
- import { credentialSource, forgetSaved, hasSaved, hold, keep, storeLabel, } from "./credentials.js";
4
+ import { heading } from "./tui/picker.js";
8
5
  /** Ask for a key, then ask separately whether it may be written to disk. */
9
6
  export async function askForKey(name, host, pal) {
10
7
  if (host.type === undefined || host.choose === undefined)
@@ -43,109 +40,95 @@ export async function askForKey(name, host, pal) {
43
40
  return true;
44
41
  }
45
42
  catch (error) {
46
- host.emit({ kind: "notice", text: `could not save API key · ${error.message}`, tone: "error" });
43
+ host.emit({
44
+ kind: "notice",
45
+ text: `could not save API key · ${error.message}`,
46
+ tone: "error",
47
+ });
47
48
  return false;
48
49
  }
49
50
  }
50
51
  return false;
51
52
  }
52
- export async function credentialsCommand(session, host) {
53
+ /** Manage one provider key without ever placing its value on screen. */
54
+ export async function apiKeyCommand(name, label, session, host) {
53
55
  const choose = chooser(host);
54
56
  if (choose === undefined)
55
57
  return;
56
- const index = await choose({
57
- title: heading("authentication", "secrets are never shown", session.palette),
58
- options: PROVIDERS.map((provider) => ({
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(),
63
- })),
64
- index: Math.max(0, PROVIDERS.findIndex((provider) => provider.id === session.provider.id)),
65
- });
66
- if (index === undefined)
67
- return;
68
- const provider = PROVIDERS[index];
69
- if (provider === undefined)
70
- return;
71
- if (provider.auth.kind === "oauth") {
72
- await openAIAccountCommand(session, host);
73
- return;
74
- }
75
- const name = provider.auth.keyVar;
76
58
  const source = credentialSource(name);
77
59
  if (source === "environment") {
78
- host.emit({
79
- kind: "notice",
80
- text: `${name} comes from the environment · restart after changing it`,
81
- tone: "info",
60
+ if (!hasSaved(name)) {
61
+ host.emit({
62
+ kind: "notice",
63
+ text: `${label} API key comes from the environment · restart after changing it`,
64
+ tone: "info",
65
+ });
66
+ return;
67
+ }
68
+ const action = await choose({
69
+ title: heading(`${label} API key`, `${name} · environment`, session.palette),
70
+ description: "The environment value is read-only. A saved copy is currently shadowed.",
71
+ options: [{ label: "forget saved copy", hint: storeLabel(), key: "f" }],
72
+ index: 0,
82
73
  });
83
- if (hasSaved(name))
84
- await offerForget(name, session, host, "a saved copy is currently shadowed");
74
+ if (action === 0)
75
+ await forgetSavedKey(name, host);
85
76
  return;
86
77
  }
87
78
  const actions = [
88
- { label: source === undefined ? "add credential" : "replace credential", key: "r" },
89
- ...(hasSaved(name) ? [{ label: "forget saved copy", hint: storeLabel(), key: "f" }] : []),
79
+ {
80
+ label: source === undefined ? "add API key" : "replace API key",
81
+ hint: source === undefined ? "session or owner-only file" : `currently ${source}`,
82
+ key: "r",
83
+ },
84
+ ...(source === "session"
85
+ ? [{ label: "clear session key", hint: "this process only", key: "c" }]
86
+ : []),
87
+ ...(hasSaved(name)
88
+ ? [{ label: "forget saved copy", hint: storeLabel(), key: "f" }]
89
+ : []),
90
90
  ];
91
- const action = await choose({
92
- title: heading(name, source ?? "missing", session.palette),
91
+ const index = await choose({
92
+ title: heading(`${label} API key`, `${name} · ${source ?? "missing"}`, session.palette),
93
93
  options: actions,
94
94
  index: 0,
95
95
  });
96
- if (action === undefined)
97
- return;
98
- if (actions[action]?.key === "f") {
99
- await forget(name, host);
100
- return;
101
- }
102
- await askForKey(name, host, session.palette);
96
+ const action = index === undefined ? undefined : actions[index]?.key;
97
+ if (action === "r")
98
+ await askForKey(name, host, session.palette);
99
+ else if (action === "c")
100
+ clearSessionKey(name, host);
101
+ else if (action === "f")
102
+ await forgetSavedKey(name, host);
103
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
- }
124
- async function offerForget(name, session, host, hint) {
125
- if (host.choose === undefined)
126
- return;
127
- const index = await host.choose({
128
- title: heading(name, hint, session.palette),
129
- options: [
130
- { label: "keep saved copy", key: "k" },
131
- { label: "forget saved copy", hint: storeLabel(), key: "f" },
132
- ],
133
- index: 0,
104
+ function clearSessionKey(name, host) {
105
+ const removed = forgetSession(name);
106
+ const fallback = credentialSource(name);
107
+ host.emit({
108
+ kind: "notice",
109
+ text: removed
110
+ ? fallback === undefined
111
+ ? "session API key removed"
112
+ : `session API key removed · ${fallback} copy now active`
113
+ : "no session API key",
114
+ tone: removed ? "info" : "warn",
134
115
  });
135
- if (index === 1)
136
- await forget(name, host);
137
116
  }
138
- async function forget(name, host) {
117
+ async function forgetSavedKey(name, host) {
139
118
  try {
140
119
  const removed = await forgetSaved(name);
141
120
  host.emit({
142
121
  kind: "notice",
143
- text: removed ? "API key removed" : "no saved API key",
122
+ text: removed ? "saved API key removed" : "no saved API key",
144
123
  tone: removed ? "info" : "warn",
145
124
  });
146
125
  }
147
126
  catch (error) {
148
- host.emit({ kind: "notice", text: `could not remove API key · ${error.message}`, tone: "error" });
127
+ host.emit({
128
+ kind: "notice",
129
+ text: `could not remove API key · ${error.message}`,
130
+ tone: "error",
131
+ });
149
132
  }
150
133
  }
151
134
  function chooser(host) {
@@ -60,6 +60,10 @@ export function hasSaved(name) {
60
60
  export function hold(name, value) {
61
61
  held.set(name, value);
62
62
  }
63
+ /** Remove only the value held by this process. Saved and environment values remain. */
64
+ export function forgetSession(name) {
65
+ return held.delete(name);
66
+ }
63
67
  /**
64
68
  * Take a key and write it down, returning the path it went to.
65
69
  *
@@ -74,8 +78,10 @@ export async function keep(name, value) {
74
78
  return withStoreLock(file, async () => {
75
79
  const all = { ...readSavedStore(), [name]: value };
76
80
  await persist(file, all);
77
- hold(name, value);
78
81
  saved = all;
82
+ // A newly saved replacement must become active immediately. Otherwise an
83
+ // older session-only value would keep shadowing the value just written.
84
+ held.delete(name);
79
85
  return file;
80
86
  });
81
87
  }
@@ -0,0 +1,171 @@
1
+ // Model-first runtime selection across every provider that can answer now.
2
+ import { saveCommandSettings } from "./command-settings.js";
3
+ import { compatibleEffort } from "./effort.js";
4
+ import { providerFailure } from "./provider-errors.js";
5
+ import { providerLabel } from "./provider-label.js";
6
+ import { PROVIDERS } from "./providers/index.js";
7
+ import { readSettings } from "./settings.js";
8
+ /** Build one searchable catalogue from every provider that is usable now. */
9
+ export async function modelsCommand(session, host, behavior = {}, providers = PROVIDERS) {
10
+ const choose = chooser(host);
11
+ if (choose === undefined)
12
+ return false;
13
+ const availability = providers.map((provider) => ({
14
+ provider,
15
+ blocked: provider.blocked(),
16
+ }));
17
+ const connected = availability
18
+ .filter((entry) => entry.blocked === undefined)
19
+ .map((entry) => entry.provider);
20
+ const disconnected = availability
21
+ .filter((entry) => entry.blocked !== undefined)
22
+ .map((entry) => entry.provider);
23
+ if (connected.length === 0) {
24
+ host.emit({
25
+ kind: "notice",
26
+ text: "no providers are connected · use /providers",
27
+ tone: "warn",
28
+ });
29
+ return false;
30
+ }
31
+ throwIfAborted(host.signal);
32
+ host.status?.("Loading model catalogs");
33
+ let settled;
34
+ try {
35
+ settled = await Promise.allSettled(connected.map(async (provider) => ({
36
+ provider,
37
+ models: await provider.models(host.signal, (status) => host.status?.(`${providerLabel(provider.id)} · ${status}`)),
38
+ })));
39
+ // allSettled deliberately preserves partial provider failures, but an
40
+ // explicit command cancellation is not a catalogue failure.
41
+ throwIfAborted(host.signal);
42
+ }
43
+ finally {
44
+ host.status?.(undefined);
45
+ }
46
+ const choices = [];
47
+ const failed = [];
48
+ for (let index = 0; index < settled.length; index++) {
49
+ const result = settled[index];
50
+ const provider = connected[index];
51
+ if (result === undefined || provider === undefined)
52
+ continue;
53
+ if (result.status === "rejected") {
54
+ failed.push({
55
+ provider,
56
+ error: result.reason instanceof Error ? result.reason : new Error(String(result.reason)),
57
+ });
58
+ continue;
59
+ }
60
+ for (const model of result.value.models)
61
+ choices.push({ provider, model });
62
+ }
63
+ if (choices.length === 0) {
64
+ host.emit({
65
+ kind: "notice",
66
+ text: emptyCatalogMessage(connected, failed),
67
+ tone: failed.length === connected.length ? "error" : "warn",
68
+ });
69
+ return false;
70
+ }
71
+ const description = catalogDescription(disconnected, failed);
72
+ const index = await choose({
73
+ title: [],
74
+ ...(description === undefined ? {} : { description }),
75
+ options: choices.map((choice) => ({
76
+ label: choice.model,
77
+ // Provider identity is part of the choice, not optional help: keep it
78
+ // visible even when the terminal is only at the supported minimum.
79
+ value: providerLabel(choice.provider.id),
80
+ })),
81
+ searchable: true,
82
+ query: "",
83
+ index: Math.max(0, choices.findIndex((choice) => choice.provider.id === session.provider.id && choice.model === session.model)),
84
+ });
85
+ throwIfAborted(host.signal);
86
+ const chosen = index === undefined ? undefined : choices[index];
87
+ if (chosen === undefined)
88
+ return false;
89
+ const beforeEffort = session.config.effort;
90
+ const alignment = await alignEffort(chosen.provider, chosen.model, beforeEffort, host);
91
+ if (!alignment.ok)
92
+ return false;
93
+ if (behavior.save !== false) {
94
+ const saved = readSettings();
95
+ const patch = {
96
+ provider: chosen.provider.id,
97
+ models: { ...saved.models, [chosen.provider.id]: chosen.model },
98
+ ...(alignment.effort === beforeEffort ? {} : { effort: alignment.effort }),
99
+ };
100
+ if (!(await saveCommandSettings(host, patch)))
101
+ return false;
102
+ }
103
+ // Commit the runtime selection only after both provider validation and
104
+ // persistence succeed, so neither the footer nor a later turn can observe a
105
+ // half-applied provider/model pair.
106
+ session.provider = chosen.provider;
107
+ session.model = chosen.model;
108
+ session.config.providerId = chosen.provider.id;
109
+ session.config.model = chosen.model;
110
+ session.config.effort = alignment.effort;
111
+ if (behavior.announce !== false) {
112
+ host.emit({
113
+ kind: "notice",
114
+ text: `model · ${providerLabel(chosen.provider.id)} · ${chosen.model}`,
115
+ tone: "info",
116
+ });
117
+ }
118
+ return true;
119
+ }
120
+ async function alignEffort(provider, model, effort, host) {
121
+ if (provider.efforts === undefined)
122
+ return { ok: true, effort };
123
+ try {
124
+ throwIfAborted(host.signal);
125
+ const supported = await provider.efforts(model, host.signal, (status) => host.status?.(status));
126
+ throwIfAborted(host.signal);
127
+ return { ok: true, effort: compatibleEffort(effort, supported) ?? effort };
128
+ }
129
+ catch (error) {
130
+ throwIfAborted(host.signal);
131
+ host.emit({
132
+ kind: "notice",
133
+ text: providerFailure(provider, error, true),
134
+ tone: "error",
135
+ });
136
+ return { ok: false };
137
+ }
138
+ }
139
+ function catalogDescription(disconnected, failed) {
140
+ const parts = [
141
+ disconnected.length === 0
142
+ ? undefined
143
+ : `Not connected: ${disconnected.map((provider) => providerLabel(provider.id)).join(", ")}`,
144
+ failed.length === 0
145
+ ? undefined
146
+ : `Unavailable: ${failed.map((entry) => providerLabel(entry.provider.id)).join(", ")}`,
147
+ ].filter((part) => part !== undefined);
148
+ return parts.length === 0 ? undefined : `${parts.join(" · ")} · manage in /providers`;
149
+ }
150
+ function emptyCatalogMessage(connected, failed) {
151
+ if (failed.length === 1 && connected.length === 1) {
152
+ const one = failed[0];
153
+ if (one !== undefined)
154
+ return providerFailure(one.provider, one.error, true);
155
+ }
156
+ if (failed.length > 0) {
157
+ return `model catalogs unavailable: ${failed.map((entry) => providerLabel(entry.provider.id)).join(", ")} · /providers`;
158
+ }
159
+ return "connected providers offer no models · check /providers";
160
+ }
161
+ function chooser(host) {
162
+ if (host.choose === undefined) {
163
+ host.emit({ kind: "notice", text: "that command needs the screen", tone: "warn" });
164
+ }
165
+ return host.choose;
166
+ }
167
+ function throwIfAborted(signal) {
168
+ if (signal?.aborted !== true)
169
+ return;
170
+ throw signal.reason instanceof Error ? signal.reason : new Error("interrupted");
171
+ }