codeply-cli 0.2.0 → 0.3.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 (43) hide show
  1. package/THIRD_PARTY_NOTICES.md +63 -0
  2. package/bin/codeply.js +558 -370
  3. package/lib/agent.mjs +1627 -160
  4. package/lib/ai.js +1107 -387
  5. package/lib/apply-limit.js +33 -51
  6. package/lib/apply-patch.js +217 -0
  7. package/lib/arity-table.json +138 -0
  8. package/lib/arity.js +70 -0
  9. package/lib/auth.js +118 -112
  10. package/lib/commands.js +103 -0
  11. package/lib/config.js +392 -136
  12. package/lib/design-library/artifact-template.html +500 -0
  13. package/lib/design-library/assemble-artifact.mjs +21 -0
  14. package/lib/design-library/build.mjs +226 -0
  15. package/lib/design-library/cleanup.mjs +45 -0
  16. package/lib/design-library/index.json +22380 -0
  17. package/lib/design-library/make-table-data.mjs +39 -0
  18. package/lib/design-library/query.mjs +104 -0
  19. package/lib/diagnostics.js +192 -0
  20. package/lib/edit-engine.js +171 -14
  21. package/lib/github-agent.mjs +307 -0
  22. package/lib/history.js +75 -0
  23. package/lib/mcp.js +258 -0
  24. package/lib/native-tools.mjs +189 -0
  25. package/lib/oauth-connectors.js +478 -0
  26. package/lib/permissions.js +109 -0
  27. package/lib/plugins.js +353 -0
  28. package/lib/render-md.mjs +617 -617
  29. package/lib/server.mjs +457 -0
  30. package/lib/session-db.js +220 -0
  31. package/lib/share.js +168 -0
  32. package/lib/skills.js +80 -22
  33. package/lib/snapshot.js +146 -0
  34. package/lib/subagents.js +164 -0
  35. package/lib/terminal-hints.js +154 -0
  36. package/lib/textarea.mjs +258 -258
  37. package/lib/tools.mjs +1658 -79
  38. package/lib/tui.js +7 -7
  39. package/lib/tui.mjs +1086 -992
  40. package/lib/typecheck-worker.js +147 -0
  41. package/lib/web-tools.js +158 -0
  42. package/package.json +4 -3
  43. package/skills/premium-web-design/SKILL.md +154 -0
package/lib/config.js CHANGED
@@ -1,136 +1,392 @@
1
- /**
2
- * Codeply CLI — model provider configuration.
3
- *
4
- * Lives in ~/.codeply/config.json, next to auth.json, deliberately OUTSIDE the
5
- * repo: it holds API keys and must never be committable. Environment
6
- * variables win over the file so a key can be supplied per-shell without ever
7
- * touching disk.
8
- *
9
- * Providers:
10
- * codeply the hosted ai-proxy (default; needs `codeply login`), shares
11
- * a daily cap with the desktop app
12
- * ollama Ollama's OpenAI-compatible API — cloud (ollama.com, needs a
13
- * key) or a local daemon (http://localhost:11434, no key)
14
- * openrouter bring your own OpenRouter key, no cap
15
- * groq bring your own Groq key, no cap
16
- * anthropic bring your own Anthropic key, no cap
17
- * openai bring your own OpenAI key, no cap
18
- *
19
- * The last four are "BYOK": once a key is set, the CLI talks to that
20
- * provider directly and never touches the shared Codeply account limit.
21
- */
22
- const fs = require('fs');
23
- const path = require('path');
24
- const os = require('os');
25
-
26
- const configDir = path.join(os.homedir(), '.codeply');
27
- const configPath = path.join(configDir, 'config.json');
28
-
29
- const PROVIDERS = ['codeply', 'ollama', 'openrouter', 'groq', 'anthropic', 'openai'];
30
- const BYOK_PROVIDERS = ['openrouter', 'groq', 'anthropic', 'openai'];
31
-
32
- const DEFAULTS = {
33
- provider: 'codeply',
34
- ollama: { host: 'https://ollama.com', model: 'gemma4:31b', apiKey: '' },
35
- openrouter: { model: 'openrouter/auto', apiKey: '' },
36
- groq: { model: 'openai/gpt-oss-120b', apiKey: '' },
37
- anthropic: { model: 'claude-sonnet-5', apiKey: '' },
38
- openai: { model: 'gpt-4o-mini', apiKey: '' },
39
- };
40
-
41
- function readFile() {
42
- try {
43
- return JSON.parse(fs.readFileSync(configPath, 'utf8'));
44
- } catch {
45
- return {};
46
- }
47
- }
48
-
49
- /** Effective config: defaults ← file ← environment. */
50
- function getConfig() {
51
- const file = readFile();
52
- const provider = process.env.CODEPLY_PROVIDER || file.provider || DEFAULTS.provider;
53
- const fileOllama = file.ollama || {};
54
-
55
- const byok = {};
56
- for (const name of BYOK_PROVIDERS) {
57
- const fileEntry = file[name] || {};
58
- const envPrefix = name.toUpperCase();
59
- byok[name] = {
60
- model: process.env[`${envPrefix}_MODEL`] || fileEntry.model || DEFAULTS[name].model,
61
- apiKey: process.env[`${envPrefix}_API_KEY`] || fileEntry.apiKey || '',
62
- };
63
- }
64
-
65
- return {
66
- provider,
67
- ollama: {
68
- host: (process.env.OLLAMA_HOST || fileOllama.host || DEFAULTS.ollama.host).replace(/\/+$/, ''),
69
- model: process.env.OLLAMA_MODEL || fileOllama.model || DEFAULTS.ollama.model,
70
- apiKey: process.env.OLLAMA_API_KEY || fileOllama.apiKey || '',
71
- },
72
- ...byok,
73
- };
74
- }
75
-
76
- /** Merge a patch into the stored config. Written user-only where supported. */
77
- function saveConfig(patch) {
78
- const current = readFile();
79
- const next = { ...current, ...patch };
80
- for (const name of ['ollama', ...BYOK_PROVIDERS]) {
81
- if (current[name] || patch[name]) {
82
- next[name] = { ...(current[name] || {}), ...(patch[name] || {}) };
83
- }
84
- }
85
- try {
86
- if (!fs.existsSync(configDir)) fs.mkdirSync(configDir, { recursive: true });
87
- fs.writeFileSync(configPath, JSON.stringify(next, null, 2), { mode: 0o600 });
88
- // mkdir/writeFile modes are advisory on Windows; chmod is best-effort.
89
- try { fs.chmodSync(configPath, 0o600); } catch {}
90
- return { ok: true, path: configPath };
91
- } catch (e) {
92
- return { ok: false, error: e.message };
93
- }
94
- }
95
-
96
- /** Never render a key in full — this is what goes on screen and in logs. */
97
- function maskKey(key) {
98
- if (!key) return '(none)';
99
- if (key.length <= 8) return '••••';
100
- return key.slice(0, 4) + '…' + key.slice(-4);
101
- }
102
-
103
- /** One-line description of where completions are coming from, model included (for `codeply provider`). */
104
- function describeProvider(cfg = getConfig()) {
105
- if (cfg.provider === 'ollama') {
106
- const where = /localhost|127\.0\.0\.1/.test(cfg.ollama.host) ? 'local' : 'cloud';
107
- return `ollama ${where} · ${cfg.ollama.model}`;
108
- }
109
- if (BYOK_PROVIDERS.includes(cfg.provider)) {
110
- return `${cfg.provider} (own key) · ${cfg[cfg.provider].model}`;
111
- }
112
- return 'codeply proxy';
113
- }
114
-
115
- /** Same, but without naming the specific model — for passive UI chrome (the TUI status line). */
116
- function describeProviderShort(cfg = getConfig()) {
117
- if (cfg.provider === 'ollama') {
118
- const where = /localhost|127\.0\.0\.1/.test(cfg.ollama.host) ? 'local' : 'cloud';
119
- return `ollama ${where}`;
120
- }
121
- if (BYOK_PROVIDERS.includes(cfg.provider)) {
122
- return `${cfg.provider} (own key)`;
123
- }
124
- return 'codeply proxy';
125
- }
126
-
127
- /** Suggested next step when the shared codeply account hits its daily cap. */
128
- function byokHint() {
129
- return 'Have your own API key? Bring your own model instead, no shared daily cap: ' +
130
- 'codeply provider openrouter --key <key> (or groq, anthropic, openai).';
131
- }
132
-
133
- module.exports = {
134
- getConfig, saveConfig, maskKey, describeProvider, describeProviderShort, byokHint,
135
- configPath, PROVIDERS, BYOK_PROVIDERS,
136
- };
1
+ /**
2
+ * Codeply CLI - model provider configuration.
3
+ *
4
+ * Lives in ~/.codeply/config.json, next to auth.json, deliberately OUTSIDE the
5
+ * repo: it holds API keys and must never be committable. Environment
6
+ * variables win over the file so a key can be supplied per-shell without ever
7
+ * touching disk.
8
+ *
9
+ * Providers:
10
+ * codeply the hosted ai-proxy (default; needs `codeply login`), shares
11
+ * a daily cap with the desktop app
12
+ * ollama Ollama's OpenAI-compatible API - cloud (ollama.com, needs a
13
+ * key) or a local daemon (http://localhost:11434, no key)
14
+ * openrouter bring your own OpenRouter key, no cap
15
+ * groq bring your own Groq key, no cap
16
+ * anthropic bring your own Anthropic key, no cap
17
+ * openai bring your own OpenAI key, no cap
18
+ * google bring your own Google AI Studio (Gemini) key, no cap - talks
19
+ * to Gemini's OpenAI-compatible endpoint, see lib/ai.js
20
+ * qwen bring your own Alibaba Cloud Model Studio (Qwen) key, no cap -
21
+ * talks to its OpenAI-compatible endpoint. Unlike the other BYOK
22
+ * providers the base URL isn't fixed (Alibaba issues a
23
+ * deployment-specific host per account/region), so it's stored
24
+ * alongside the key rather than hardcoded - see baseUrl below.
25
+ *
26
+ * The last six are "BYOK": once a key is set, the CLI talks to that
27
+ * provider directly and never touches the shared Codeply account limit.
28
+ */
29
+ const fs = require('fs');
30
+ const path = require('path');
31
+ const os = require('os');
32
+
33
+ const configDir = path.join(os.homedir(), '.codeply');
34
+ const configPath = path.join(configDir, 'config.json');
35
+
36
+ const PROVIDERS = ['codeply', 'ollama', 'openrouter', 'groq', 'anthropic', 'openai', 'google', 'qwen', 'deepseek'];
37
+ const BYOK_PROVIDERS = ['openrouter', 'groq', 'anthropic', 'openai', 'google', 'qwen', 'deepseek'];
38
+
39
+ const DEFAULTS = {
40
+ provider: 'codeply',
41
+ // numCtx: null means "let the server use its own default" - ollama.com's
42
+ // cloud service and most well-resourced hosts default to something
43
+ // reasonable already. It only needs setting for a constrained self-hosted
44
+ // instance whose default context window (commonly 2048 tokens on a plain
45
+ // `ollama pull` with no custom Modelfile) is too small to hold a real
46
+ // conversation - see lib/ai.js's ollamaRequest for where this actually
47
+ // gets sent, and Codeply Craft's MODEL_PRESETS for where 12B's is set.
48
+ ollama: { host: 'https://ollama.com', model: 'gemma4:31b', apiKey: '', apiKeyFallback: '', numCtx: null },
49
+ openrouter: { model: 'openrouter/auto', apiKey: '' },
50
+ groq: { model: 'openai/gpt-oss-120b', apiKey: '' },
51
+ anthropic: { model: 'claude-sonnet-5', apiKey: '' },
52
+ openai: { model: 'gpt-4o-mini', apiKey: '' },
53
+ google: { model: 'gemini-3.7-flash', apiKey: '' },
54
+ qwen: { model: 'qwen3.8-max', apiKey: '', baseUrl: '' },
55
+ // DeepSeek's own API (not via OpenRouter) - automatically caches repeated
56
+ // request prefixes server-side (cache-hit input tokens run ~15-30x cheaper
57
+ // than a cache miss, per their published pricing) with no special request
58
+ // flag needed to enable it; it just applies whenever consecutive calls
59
+ // share an identical prefix, which every step of one turn's action-block
60
+ // loop already does (same TOOL_REFERENCE + persona + project context each
61
+ // time) - this provider benefits from that automatically.
62
+ deepseek: { model: 'deepseek-v4-flash-vision-exp', apiKey: '' },
63
+ };
64
+
65
+ let lastReadFailed = false;
66
+ function readFile() {
67
+ lastReadFailed = false;
68
+ let raw;
69
+ try { raw = fs.readFileSync(configPath, 'utf8'); } catch { return {}; } // no file yet
70
+ try {
71
+ return JSON.parse(raw);
72
+ } catch {
73
+ // Unreadable (e.g. hand-edited). Callers must not write back over it,
74
+ // or every saved key and connection would be lost.
75
+ lastReadFailed = true;
76
+ return {};
77
+ }
78
+ }
79
+
80
+ /** Effective config: defaults ← file ← environment. */
81
+ function getConfig() {
82
+ const file = readFile();
83
+ const provider = process.env.CODEPLY_PROVIDER || file.provider || DEFAULTS.provider;
84
+ const fileOllama = file.ollama || {};
85
+
86
+ const byok = {};
87
+ for (const name of BYOK_PROVIDERS) {
88
+ const fileEntry = file[name] || {};
89
+ const envPrefix = name.toUpperCase();
90
+ byok[name] = {
91
+ model: process.env[`${envPrefix}_MODEL`] || fileEntry.model || DEFAULTS[name].model,
92
+ apiKey: process.env[`${envPrefix}_API_KEY`] || fileEntry.apiKey || '',
93
+ // Only a couple of providers (qwen so far) need a configurable host -
94
+ // this is a no-op empty string for the fixed-endpoint ones.
95
+ ...('baseUrl' in DEFAULTS[name]
96
+ ? { baseUrl: process.env[`${envPrefix}_BASE_URL`] || fileEntry.baseUrl || DEFAULTS[name].baseUrl }
97
+ : {}),
98
+ };
99
+ }
100
+
101
+ return {
102
+ provider,
103
+ ollama: {
104
+ host: (process.env.OLLAMA_HOST || fileOllama.host || DEFAULTS.ollama.host).replace(/\/+$/, ''),
105
+ model: process.env.OLLAMA_MODEL || fileOllama.model || DEFAULTS.ollama.model,
106
+ apiKey: process.env.OLLAMA_API_KEY || fileOllama.apiKey || '',
107
+ // Tried only when the primary key's request actually fails - a second
108
+ // Ollama cloud account/key so one hitting its quota or erroring out
109
+ // doesn't stop the whole app, see chatViaOllama in ai.js.
110
+ apiKeyFallback: process.env.OLLAMA_API_KEY_FALLBACK || fileOllama.apiKeyFallback || '',
111
+ // Explicit `0`/`null` in the file must still mean "unset", not fall
112
+ // through to a stale value - only undefined (key absent) does that.
113
+ numCtx: Number(process.env.OLLAMA_NUM_CTX) || fileOllama.numCtx || null,
114
+ },
115
+ ...byok,
116
+ };
117
+ }
118
+
119
+ /** Merge a patch into the stored config. Written user-only where supported. */
120
+ function saveConfig(patch) {
121
+ const current = readFile();
122
+ if (lastReadFailed) return { ok: false, error: `${configPath} is not valid JSON. Fix or delete it, then try again.` };
123
+ const next = { ...current, ...patch };
124
+ for (const name of ['ollama', ...BYOK_PROVIDERS]) {
125
+ if (current[name] || patch[name]) {
126
+ next[name] = { ...(current[name] || {}), ...(patch[name] || {}) };
127
+ }
128
+ }
129
+ try {
130
+ if (!fs.existsSync(configDir)) fs.mkdirSync(configDir, { recursive: true });
131
+ fs.writeFileSync(configPath, JSON.stringify(next, null, 2), { mode: 0o600 });
132
+ // mkdir/writeFile modes are advisory on Windows; chmod is best-effort.
133
+ try { fs.chmodSync(configPath, 0o600); } catch {}
134
+ return { ok: true, path: configPath };
135
+ } catch (e) {
136
+ return { ok: false, error: e.message };
137
+ }
138
+ }
139
+
140
+ // ─── Third-party integrations (Gmail, Slack) ───────────────────────────────
141
+ // A separate concept from the AI providers above - these are action
142
+ // integrations the agent's tools call (send an email, post a Slack message),
143
+ // not places a chat completion comes from. Kept in their own top-level config
144
+ // sections rather than folded into PROVIDERS/BYOK_PROVIDERS so the two
145
+ // systems never get confused with each other.
146
+ const INTEGRATIONS = ['gmail', 'slack', 'vercel', 'supabase', 'github'];
147
+
148
+ const INTEGRATION_DEFAULTS = {
149
+ gmail: { clientId: '', clientSecret: '', accessToken: '', refreshToken: '', expiresAt: 0, email: '' },
150
+ // accessToken is the bot token (posts as "Codeply Craft APP"); userAccessToken
151
+ // is the separate user token from the same OAuth exchange (posts as the
152
+ // actual signed-in person). Kept side by side rather than one overwriting
153
+ // the other - slack_post_message picks userAccessToken by default when
154
+ // present, but the bot token still exists for anything that needs the app
155
+ // identity specifically.
156
+ slack: { clientId: '', clientSecret: '', accessToken: '', userAccessToken: '', userId: '', teamId: '', teamName: '' },
157
+ // clientId/clientSecret ship empty - connecting is a no-op ("No Vercel
158
+ // client ID/secret configured") until a real OAuth app is registered and
159
+ // its credentials are placed here. The rest of the shape exists now so
160
+ // the connect/disconnect UI and the eventual deploy tool have somewhere
161
+ // real to read from the moment credentials are added.
162
+ vercel: { clientId: '', clientSecret: '', slug: '', accessToken: '', teamId: '', userName: '' },
163
+ supabase: { clientId: '', clientSecret: '', accessToken: '', refreshToken: '', expiresAt: 0, email: '' },
164
+ github: { clientId: '', clientSecret: '', accessToken: '', userName: '' },
165
+ };
166
+
167
+ /**
168
+ * Effective integration config: defaults ← file ← app-wide OAuth credentials.
169
+ * The OAuth app's client id/secret come from the environment (a dev .env, or
170
+ * the credentials baked into a release build - see main.js) whenever the
171
+ * user's own file doesn't set them. They're filled in here at read time only
172
+ * and never written back into the user's config file.
173
+ */
174
+ function getIntegration(name) {
175
+ if (!INTEGRATIONS.includes(name)) throw new Error(`Unknown integration: ${name}`);
176
+ const file = readFile();
177
+ const merged = { ...INTEGRATION_DEFAULTS[name], ...(file[name] || {}) };
178
+ const envPrefix = name.toUpperCase();
179
+ if (!merged.clientId) merged.clientId = process.env[`${envPrefix}_CLIENT_ID`] || '';
180
+ if (!merged.clientSecret) merged.clientSecret = process.env[`${envPrefix}_CLIENT_SECRET`] || '';
181
+ return merged;
182
+ }
183
+
184
+ /** True once the OAuth flow has actually completed and an access token is stored. */
185
+ function isIntegrationConnected(name) {
186
+ return !!getIntegration(name).accessToken;
187
+ }
188
+
189
+ /** Merge a patch into one integration's stored section - same merge-by-section semantics as saveConfig. */
190
+ function saveIntegration(name, patch) {
191
+ if (!INTEGRATIONS.includes(name)) throw new Error(`Unknown integration: ${name}`);
192
+ const stored = readFile()[name] || {};
193
+ return saveConfig({ [name]: { ...INTEGRATION_DEFAULTS[name], ...stored, ...patch } });
194
+ }
195
+
196
+ /** Disconnects by wiping the stored tokens - client id/secret are left in place so reconnecting doesn't need them re-entered. */
197
+ function disconnectIntegration(name) {
198
+ if (!INTEGRATIONS.includes(name)) throw new Error(`Unknown integration: ${name}`);
199
+ return saveIntegration(name, {
200
+ accessToken: '', refreshToken: '', expiresAt: 0, email: '', teamId: '', teamName: '', userAccessToken: '', userId: '', userName: '',
201
+ });
202
+ }
203
+
204
+ // ─── Models (the desktop app's model picker) ────────────────────────────────
205
+ // "Auto" is the hosted Codeply model (the ai-proxy, needs sign-in). Everything
206
+ // else is a model the user added themselves: a name, an OpenAI-compatible base
207
+ // URL, a model id and an optional API key - or an Ollama host. These live in
208
+ // this same file (~/.codeply/config.json, user-only permissions) and are only
209
+ // ever sent to the base URL the user typed in, never to Codeply.
210
+ //
211
+ // selectedModel: 'auto' | <model id>
212
+ // models: [{ id, name, kind: 'openai' | 'ollama', baseUrl, model, apiKey, createdAt }]
213
+
214
+ const AUTO_MODEL_ID = 'auto';
215
+
216
+ // Base URLs for importing the CLI's older per-provider keys (`codeply provider
217
+ // <name> --key`) as regular models the first time the models list is read.
218
+ const LEGACY_BASE_URLS = {
219
+ openrouter: 'https://openrouter.ai/api/v1',
220
+ groq: 'https://api.groq.com/openai/v1',
221
+ openai: 'https://api.openai.com/v1',
222
+ google: 'https://generativelanguage.googleapis.com/v1beta/openai',
223
+ deepseek: 'https://api.deepseek.com/v1',
224
+ anthropic: 'https://api.anthropic.com/v1',
225
+ };
226
+ const LEGACY_LABELS = {
227
+ openrouter: 'OpenRouter', groq: 'Groq', openai: 'OpenAI', google: 'Gemini',
228
+ qwen: 'Qwen', deepseek: 'DeepSeek', anthropic: 'Claude', ollama: 'Ollama',
229
+ };
230
+
231
+ function newModelId() {
232
+ return 'm_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 7);
233
+ }
234
+
235
+ function writeFile(next) {
236
+ if (lastReadFailed) return { ok: false, error: `${configPath} is not valid JSON. Fix or delete it, then try again.` };
237
+ try {
238
+ if (!fs.existsSync(configDir)) fs.mkdirSync(configDir, { recursive: true });
239
+ fs.writeFileSync(configPath, JSON.stringify(next, null, 2), { mode: 0o600 });
240
+ try { fs.chmodSync(configPath, 0o600); } catch {}
241
+ return { ok: true };
242
+ } catch (e) {
243
+ return { ok: false, error: e.message };
244
+ }
245
+ }
246
+
247
+ /** One-time import of legacy provider keys, so an existing setup keeps working. */
248
+ function migrateLegacyModels(file) {
249
+ const models = [];
250
+ let selected = AUTO_MODEL_ID;
251
+ for (const name of BYOK_PROVIDERS) {
252
+ const entry = file[name];
253
+ if (!entry || !entry.apiKey) continue;
254
+ const baseUrl = name === 'qwen' ? entry.baseUrl : LEGACY_BASE_URLS[name];
255
+ if (!baseUrl) continue;
256
+ const model = entry.model || DEFAULTS[name].model;
257
+ const m = { id: newModelId(), name: `${LEGACY_LABELS[name]} · ${model}`, kind: 'openai', baseUrl, model, apiKey: entry.apiKey, createdAt: Date.now() };
258
+ models.push(m);
259
+ if (file.provider === name) selected = m.id;
260
+ }
261
+ const ol = file.ollama;
262
+ const olLocal = ol && /localhost|127\.0\.0\.1/.test(ol.host || '');
263
+ if (ol && (ol.apiKey || olLocal) && ol.host) {
264
+ const model = ol.model || DEFAULTS.ollama.model;
265
+ const m = { id: newModelId(), name: `Ollama · ${model}`, kind: 'ollama', baseUrl: ol.host, model, apiKey: ol.apiKey || '', createdAt: Date.now() };
266
+ models.push(m);
267
+ if (file.provider === 'ollama') selected = m.id;
268
+ }
269
+ return { models, selected };
270
+ }
271
+
272
+ function readModelsState() {
273
+ const file = readFile();
274
+ if (!Array.isArray(file.models)) {
275
+ const { models, selected } = migrateLegacyModels(file);
276
+ file.models = models;
277
+ file.selectedModel = selected;
278
+ if (!lastReadFailed) writeFile(file);
279
+ }
280
+ return file;
281
+ }
282
+
283
+ function getModels() {
284
+ return readModelsState().models;
285
+ }
286
+
287
+ function getSelectedModelId() {
288
+ const file = readModelsState();
289
+ const id = file.selectedModel || AUTO_MODEL_ID;
290
+ if (id !== AUTO_MODEL_ID && !file.models.some((m) => m.id === id)) return AUTO_MODEL_ID;
291
+ return id;
292
+ }
293
+
294
+ /** The model entry a turn should use, or null for Auto (the hosted Codeply model). */
295
+ function getSelectedModel() {
296
+ const id = getSelectedModelId();
297
+ if (id === AUTO_MODEL_ID) return null;
298
+ return getModels().find((m) => m.id === id) || null;
299
+ }
300
+
301
+ function getModel(id) {
302
+ if (!id || id === AUTO_MODEL_ID) return null;
303
+ return getModels().find((m) => m.id === id) || null;
304
+ }
305
+
306
+ /**
307
+ * Create or update a model. On update an omitted/undefined apiKey keeps the
308
+ * stored one, so the UI never has to hold (or re-send) a saved key.
309
+ */
310
+ function saveModel(input) {
311
+ const file = readModelsState();
312
+ const name = String(input.name || '').trim();
313
+ const model = String(input.model || '').trim();
314
+ const baseUrl = String(input.baseUrl || '').trim().replace(/\/+$/, '');
315
+ const kind = input.kind === 'ollama' ? 'ollama' : 'openai';
316
+ if (!model) return { ok: false, error: 'Enter the model id (for example gpt-4o-mini or llama3.1).' };
317
+ if (!/^https?:\/\//i.test(baseUrl)) return { ok: false, error: 'The base URL must start with http:// or https://.' };
318
+
319
+ const existing = input.id ? file.models.find((m) => m.id === input.id) : null;
320
+ const entry = {
321
+ id: existing ? existing.id : newModelId(),
322
+ name: name || model,
323
+ kind,
324
+ baseUrl,
325
+ model,
326
+ apiKey: input.apiKey === undefined || input.apiKey === null ? (existing?.apiKey || '') : String(input.apiKey).trim(),
327
+ createdAt: existing?.createdAt || Date.now(),
328
+ };
329
+ file.models = existing
330
+ ? file.models.map((m) => (m.id === entry.id ? entry : m))
331
+ : [...file.models, entry];
332
+ const w = writeFile(file);
333
+ return w.ok ? { ok: true, model: entry } : w;
334
+ }
335
+
336
+ function deleteModel(id) {
337
+ const file = readModelsState();
338
+ file.models = file.models.filter((m) => m.id !== id);
339
+ if (file.selectedModel === id) file.selectedModel = AUTO_MODEL_ID;
340
+ return writeFile(file);
341
+ }
342
+
343
+ function selectModel(id) {
344
+ const file = readModelsState();
345
+ if (id !== AUTO_MODEL_ID && !file.models.some((m) => m.id === id)) return { ok: false, error: 'Unknown model.' };
346
+ file.selectedModel = id;
347
+ return writeFile(file);
348
+ }
349
+
350
+ /** Never render a key in full - this is what goes on screen and in logs. */
351
+ function maskKey(key) {
352
+ if (!key) return '(none)';
353
+ if (key.length <= 8) return '••••';
354
+ return key.slice(0, 4) + '…' + key.slice(-4);
355
+ }
356
+
357
+ /** One-line description of where completions are coming from, model included (for `codeply provider`). */
358
+ function describeProvider(cfg = getConfig()) {
359
+ if (cfg.provider === 'ollama') {
360
+ const where = /localhost|127\.0\.0\.1/.test(cfg.ollama.host) ? 'local' : 'cloud';
361
+ return `ollama ${where} · ${cfg.ollama.model}`;
362
+ }
363
+ if (BYOK_PROVIDERS.includes(cfg.provider)) {
364
+ return `${cfg.provider} (own key) · ${cfg[cfg.provider].model}`;
365
+ }
366
+ return 'codeply proxy';
367
+ }
368
+
369
+ /** Same, but without naming the specific model - for passive UI chrome (the TUI status line). */
370
+ function describeProviderShort(cfg = getConfig()) {
371
+ if (cfg.provider === 'ollama') {
372
+ const where = /localhost|127\.0\.0\.1/.test(cfg.ollama.host) ? 'local' : 'cloud';
373
+ return `ollama ${where}`;
374
+ }
375
+ if (BYOK_PROVIDERS.includes(cfg.provider)) {
376
+ return `${cfg.provider} (own key)`;
377
+ }
378
+ return 'codeply proxy';
379
+ }
380
+
381
+ /** Suggested next step when the hosted model is busy or unavailable. */
382
+ function byokHint() {
383
+ return 'You can also use your own model: in Codeply Craft click the model name next to Send and choose ' +
384
+ '"Add a model" or "Connect Ollama" (CLI: codeply provider openrouter --key <key>).';
385
+ }
386
+
387
+ module.exports = {
388
+ getConfig, saveConfig, maskKey, describeProvider, describeProviderShort, byokHint,
389
+ configPath, PROVIDERS, BYOK_PROVIDERS,
390
+ getIntegration, saveIntegration, disconnectIntegration, isIntegrationConnected, INTEGRATIONS,
391
+ AUTO_MODEL_ID, getModels, getModel, getSelectedModel, getSelectedModelId, saveModel, deleteModel, selectModel,
392
+ };