codeply-cli 0.2.0 → 0.3.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/THIRD_PARTY_NOTICES.md +63 -0
- package/bin/codeply.js +562 -370
- package/lib/agent.mjs +1627 -160
- package/lib/ai.js +1107 -387
- package/lib/apply-limit.js +33 -51
- package/lib/apply-patch.js +217 -0
- package/lib/arity-table.json +138 -0
- package/lib/arity.js +70 -0
- package/lib/auth.js +118 -112
- package/lib/commands.js +103 -0
- package/lib/config.js +392 -136
- package/lib/design-library/artifact-template.html +500 -0
- package/lib/design-library/assemble-artifact.mjs +21 -0
- package/lib/design-library/build.mjs +226 -0
- package/lib/design-library/cleanup.mjs +45 -0
- package/lib/design-library/index.json +22380 -0
- package/lib/design-library/make-table-data.mjs +39 -0
- package/lib/design-library/query.mjs +104 -0
- package/lib/diagnostics.js +192 -0
- package/lib/edit-engine.js +171 -14
- package/lib/github-agent.mjs +290 -0
- package/lib/history.js +75 -0
- package/lib/mcp.js +258 -0
- package/lib/native-tools.mjs +189 -0
- package/lib/oauth-connectors.js +478 -0
- package/lib/permissions.js +109 -0
- package/lib/plugins.js +353 -0
- package/lib/render-md.mjs +617 -617
- package/lib/server.mjs +457 -0
- package/lib/session-db.js +220 -0
- package/lib/share.js +168 -0
- package/lib/skills.js +80 -22
- package/lib/snapshot.js +146 -0
- package/lib/subagents.js +164 -0
- package/lib/terminal-hints.js +154 -0
- package/lib/textarea.mjs +258 -258
- package/lib/tools.mjs +1658 -79
- package/lib/tui.js +7 -7
- package/lib/tui.mjs +1086 -992
- package/lib/typecheck-worker.js +147 -0
- package/lib/web-tools.js +158 -0
- package/package.json +4 -3
- package/skills/premium-web-design/SKILL.md +154 -0
package/lib/config.js
CHANGED
|
@@ -1,136 +1,392 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Codeply CLI
|
|
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
|
|
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
|
-
*
|
|
20
|
-
*
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
const
|
|
30
|
-
const
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
+
};
|