codeply-cli 0.1.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/bin/codeply.js +259 -0
- package/lib/agent.mjs +533 -0
- package/lib/ai.js +181 -0
- package/lib/ai.mjs +6 -0
- package/lib/apply-limit.js +41 -0
- package/lib/auth.js +112 -0
- package/lib/auth.mjs +6 -0
- package/lib/config.js +89 -0
- package/lib/edit-engine.js +207 -0
- package/lib/render-md.mjs +617 -0
- package/lib/textarea.mjs +258 -0
- package/lib/tools.mjs +376 -0
- package/lib/tui.js +7 -0
- package/lib/tui.mjs +899 -0
- package/package.json +45 -0
package/lib/ai.js
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Codeply CLI — AI client
|
|
3
|
+
*
|
|
4
|
+
* Two backends, selected by lib/config.js:
|
|
5
|
+
*
|
|
6
|
+
* codeply the 'ai-proxy' Supabase Edge Function the desktop app uses (see
|
|
7
|
+
* Codeply-App/supabase/functions/ai-proxy). No provider key lives in
|
|
8
|
+
* this CLI; it authenticates with the signed-in session from
|
|
9
|
+
* lib/auth.js, so usage counts against that account's daily cap.
|
|
10
|
+
*
|
|
11
|
+
* ollama Ollama's OpenAI-compatible /v1/chat/completions, either the cloud
|
|
12
|
+
* (ollama.com, bearer key) or a local daemon (no key). No Codeply
|
|
13
|
+
* sign-in required, and no daily cap.
|
|
14
|
+
*
|
|
15
|
+
* Both return the identical shape — {success, data:{choices:[{message}]}, …} —
|
|
16
|
+
* so nothing downstream (agent loop, edit engine) knows or cares which ran.
|
|
17
|
+
*/
|
|
18
|
+
const { SUPABASE_URL, SUPABASE_ANON_KEY, getAccessToken } = require('./auth');
|
|
19
|
+
const { getConfig } = require('./config');
|
|
20
|
+
|
|
21
|
+
const AI_PROXY_URL = `${SUPABASE_URL}/functions/v1/ai-proxy`;
|
|
22
|
+
|
|
23
|
+
function isRateLimitError(msg) {
|
|
24
|
+
const s = String(msg || '').toLowerCase();
|
|
25
|
+
return s.includes('rate limit') || s.includes('429') || s.includes('tokens per day')
|
|
26
|
+
|| s.includes('tokens per minute') || s.includes(' tpd') || s.includes(' tpm')
|
|
27
|
+
|| s.includes('quota') || s.includes('daily ai request limit');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const MAX_ATTEMPTS = 3;
|
|
31
|
+
const RETRY_BACKOFF_MS = [1200, 4000];
|
|
32
|
+
|
|
33
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Statuses worth retrying. 546 is Supabase's own code for an Edge Function
|
|
37
|
+
* killed for exceeding its resource limits (WORKER_LIMIT) — it fires on long
|
|
38
|
+
* generations and is usually transient, so a retry is far more useful to the
|
|
39
|
+
* user than surfacing "HTTP 546". 5xx and 429 get the same treatment.
|
|
40
|
+
*/
|
|
41
|
+
function isTransientStatus(status) {
|
|
42
|
+
return status === 429 || status >= 500;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Turn a bare status into something a user can act on. */
|
|
46
|
+
function describeStatus(status) {
|
|
47
|
+
if (status === 546) {
|
|
48
|
+
return 'The AI service hit its resource limit mid-request (Supabase 546). ' +
|
|
49
|
+
'This usually happens on very long generations — try a narrower request, ' +
|
|
50
|
+
'or ask for a targeted edit instead of a full-file rewrite.';
|
|
51
|
+
}
|
|
52
|
+
if (status === 504 || status === 408) return 'The AI service timed out. Try a smaller request.';
|
|
53
|
+
if (status === 429) return 'Rate limited by the AI service. Wait a moment and try again.';
|
|
54
|
+
if (status >= 500) return `The AI service is temporarily unavailable (HTTP ${status}).`;
|
|
55
|
+
return `HTTP ${status}`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Retry wrapper shared by both backends.
|
|
60
|
+
* `send` returns { done, value } to stop, or { retryable, error } to try again.
|
|
61
|
+
*/
|
|
62
|
+
async function withRetries(send) {
|
|
63
|
+
let lastError = 'Request failed';
|
|
64
|
+
for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
|
|
65
|
+
if (attempt > 0) await sleep(RETRY_BACKOFF_MS[attempt - 1] ?? 4000);
|
|
66
|
+
const step = await send();
|
|
67
|
+
if (step.done) return step.value;
|
|
68
|
+
lastError = step.error;
|
|
69
|
+
if (!step.retryable) return { success: false, error: lastError };
|
|
70
|
+
}
|
|
71
|
+
return { success: false, error: lastError };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function chatViaProxy(messages, opts) {
|
|
75
|
+
const token = await getAccessToken();
|
|
76
|
+
if (!token) return { success: false, error: 'Not signed in. Run `codeply login` first.' };
|
|
77
|
+
|
|
78
|
+
return withRetries(async () => {
|
|
79
|
+
let res;
|
|
80
|
+
try {
|
|
81
|
+
res = await fetch(AI_PROXY_URL, {
|
|
82
|
+
method: 'POST',
|
|
83
|
+
headers: {
|
|
84
|
+
'Content-Type': 'application/json',
|
|
85
|
+
'Authorization': `Bearer ${token}`,
|
|
86
|
+
'apikey': SUPABASE_ANON_KEY,
|
|
87
|
+
},
|
|
88
|
+
body: JSON.stringify({ messages, opts }),
|
|
89
|
+
});
|
|
90
|
+
} catch (e) {
|
|
91
|
+
// Network-level failure — also worth another go.
|
|
92
|
+
return { retryable: true, error: e.message };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const body = await res.json().catch(() => ({}));
|
|
96
|
+
if (res.ok && body.success) {
|
|
97
|
+
return { done: true, value: { success: true, data: body.data, modelUsed: body.modelUsed } };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// A structured error from the function itself is final: it means the
|
|
101
|
+
// function ran and deliberately rejected us (auth, daily cap, provider
|
|
102
|
+
// error). Retrying that just burns the user's quota.
|
|
103
|
+
if (body.error) return { done: true, value: { success: false, error: body.error } };
|
|
104
|
+
|
|
105
|
+
return { retryable: isTransientStatus(res.status), error: describeStatus(res.status) };
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function chatViaOllama(messages, opts, cfg) {
|
|
110
|
+
const { host, model, apiKey } = cfg.ollama;
|
|
111
|
+
const isLocal = /localhost|127\.0\.0\.1/.test(host);
|
|
112
|
+
if (!apiKey && !isLocal) {
|
|
113
|
+
return { success: false, error: `No Ollama API key set for ${host}. Run \`codeply provider ollama --key <key>\`.` };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const headers = { 'Content-Type': 'application/json' };
|
|
117
|
+
if (apiKey) headers.Authorization = `Bearer ${apiKey}`;
|
|
118
|
+
|
|
119
|
+
return withRetries(async () => {
|
|
120
|
+
let res;
|
|
121
|
+
try {
|
|
122
|
+
res = await fetch(`${host}/v1/chat/completions`, {
|
|
123
|
+
method: 'POST',
|
|
124
|
+
headers,
|
|
125
|
+
body: JSON.stringify({
|
|
126
|
+
model,
|
|
127
|
+
messages,
|
|
128
|
+
temperature: opts.temperature ?? 0,
|
|
129
|
+
...(opts.maxTokens ? { max_tokens: opts.maxTokens } : {}),
|
|
130
|
+
...(opts.json ? { response_format: { type: 'json_object' } } : {}),
|
|
131
|
+
stream: false,
|
|
132
|
+
}),
|
|
133
|
+
});
|
|
134
|
+
} catch (e) {
|
|
135
|
+
const hint = isLocal ? ' Is `ollama serve` running?' : '';
|
|
136
|
+
return { retryable: true, error: e.message + hint };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const body = await res.json().catch(() => ({}));
|
|
140
|
+
|
|
141
|
+
if (res.ok && body.choices?.[0]) {
|
|
142
|
+
// Already OpenAI-shaped, so it drops straight into the same call sites.
|
|
143
|
+
return { done: true, value: { success: true, data: body, modelUsed: body.model || model } };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const apiError = body?.error?.message || body?.error;
|
|
147
|
+
if (apiError) {
|
|
148
|
+
const msg = typeof apiError === 'string' ? apiError : JSON.stringify(apiError);
|
|
149
|
+
// A missing/unknown model won't fix itself on retry.
|
|
150
|
+
const fatal = /not found|does not exist|unknown model|unauthor|invalid.*key|forbidden/i.test(msg);
|
|
151
|
+
if (fatal) return { done: true, value: { success: false, error: `Ollama: ${msg}` } };
|
|
152
|
+
return { retryable: isTransientStatus(res.status), error: `Ollama: ${msg}` };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return { retryable: isTransientStatus(res.status), error: describeStatus(res.status) };
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* @param {Array<{role:string, content:string}>} messages
|
|
161
|
+
* @param {{ json?: boolean, maxTokens?: number, temperature?: number }} opts
|
|
162
|
+
* @returns {Promise<{success:boolean, data?:object, error?:string, modelUsed?:string}>}
|
|
163
|
+
*/
|
|
164
|
+
async function chat(messages, opts = {}) {
|
|
165
|
+
const cfg = getConfig();
|
|
166
|
+
return cfg.provider === 'ollama'
|
|
167
|
+
? chatViaOllama(messages, opts, cfg)
|
|
168
|
+
: chatViaProxy(messages, opts);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Chat helper that expects a JSON object back and parses it. */
|
|
172
|
+
async function chatJson(messages, opts = {}) {
|
|
173
|
+
const r = await chat(messages, { ...opts, json: true });
|
|
174
|
+
if (!r.success) return { success: false, error: r.error };
|
|
175
|
+
let parsed = null;
|
|
176
|
+
try { parsed = JSON.parse(r.data.choices[0].message.content); }
|
|
177
|
+
catch { return { success: false, error: 'AI returned an unreadable response.' }; }
|
|
178
|
+
return { success: true, json: parsed, usage: r.data.usage || {}, modelUsed: r.modelUsed };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
module.exports = { chat, chatJson, isRateLimitError };
|
package/lib/ai.mjs
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Codeply CLI — daily apply cap
|
|
3
|
+
*
|
|
4
|
+
* Reuses the EXACT same Supabase table/RPC the desktop app checks against
|
|
5
|
+
* (apply_history + get_daily_apply_count(), see Codeply-App/supabase/
|
|
6
|
+
* apply_limit.sql) — an apply from the CLI counts toward the same account's
|
|
7
|
+
* 100/day cap as an apply from the desktop app, not a separate bucket.
|
|
8
|
+
*/
|
|
9
|
+
const { getClient, getSession } = require('./auth');
|
|
10
|
+
|
|
11
|
+
const DAILY_APPLY_LIMIT = 100;
|
|
12
|
+
|
|
13
|
+
/** Returns { allowed, count, limit }. Fails OPEN on a DB hiccup, same as the desktop app. */
|
|
14
|
+
async function checkApplyLimit() {
|
|
15
|
+
const session = await getSession();
|
|
16
|
+
if (!session) return { allowed: false, count: 0, limit: DAILY_APPLY_LIMIT, error: 'Not signed in. Run `codeply login` first.' };
|
|
17
|
+
|
|
18
|
+
const supabase = getClient();
|
|
19
|
+
try {
|
|
20
|
+
const { data, error } = await supabase.rpc('get_daily_apply_count');
|
|
21
|
+
if (error || typeof data !== 'number') throw error || new Error('unexpected response');
|
|
22
|
+
return { allowed: data < DAILY_APPLY_LIMIT, count: data, limit: DAILY_APPLY_LIMIT };
|
|
23
|
+
} catch (e) {
|
|
24
|
+
console.warn('[apply-limit] check failed, allowing:', e.message);
|
|
25
|
+
return { allowed: true, count: 0, limit: DAILY_APPLY_LIMIT };
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Records one successful file write toward the daily cap. Fire-and-forget. */
|
|
30
|
+
async function recordApplyEvent(filePath) {
|
|
31
|
+
const session = await getSession();
|
|
32
|
+
if (!session) return;
|
|
33
|
+
const supabase = getClient();
|
|
34
|
+
try {
|
|
35
|
+
await supabase.from('apply_history').insert({ user_id: session.user.id, file_path: filePath || '' });
|
|
36
|
+
} catch (e) {
|
|
37
|
+
console.warn('[apply-limit] record failed:', e.message);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
module.exports = { DAILY_APPLY_LIMIT, checkApplyLimit, recordApplyEvent };
|
package/lib/auth.js
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Codeply CLI — auth
|
|
3
|
+
*
|
|
4
|
+
* Passwordless (email + 6-digit code) sign-in against the SAME Supabase
|
|
5
|
+
* project the desktop app uses — same account, same ai-proxy access, same
|
|
6
|
+
* daily request/apply caps either way. The session is cached in
|
|
7
|
+
* ~/.codeply/auth.json, mirroring the desktop app's own file-based session
|
|
8
|
+
* storage (there's no OS keychain dependency to add for a CLI).
|
|
9
|
+
*/
|
|
10
|
+
const fs = require('fs');
|
|
11
|
+
const os = require('os');
|
|
12
|
+
const path = require('path');
|
|
13
|
+
const readline = require('readline/promises');
|
|
14
|
+
const { createClient } = require('@supabase/supabase-js');
|
|
15
|
+
|
|
16
|
+
const SUPABASE_URL = 'https://zswkhfkfseclgadhvobg.supabase.co';
|
|
17
|
+
const SUPABASE_ANON_KEY = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Inpzd2toZmtmc2VjbGdhZGh2b2JnIiwicm9sZSI6ImFub24iLCJpYXQiOjE3ODAyMzYyOTgsImV4cCI6MjA5NTgxMjI5OH0.EoTQdIGQQDrN1uEqQfya3VmrQMT68jkzPLphbLwNTWg';
|
|
18
|
+
|
|
19
|
+
const configDir = path.join(os.homedir(), '.codeply');
|
|
20
|
+
const sessionPath = path.join(configDir, 'auth.json');
|
|
21
|
+
|
|
22
|
+
function ensureConfigDir() {
|
|
23
|
+
fs.mkdirSync(configDir, { recursive: true });
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// File-based storage adapter for Supabase session persistence (Node has no
|
|
27
|
+
// localStorage) — same pattern the desktop app uses in main.js.
|
|
28
|
+
const fileStorage = {
|
|
29
|
+
getItem(key) {
|
|
30
|
+
try { const d = JSON.parse(fs.readFileSync(sessionPath, 'utf8')); return d[key] || null; }
|
|
31
|
+
catch { return null; }
|
|
32
|
+
},
|
|
33
|
+
setItem(key, value) {
|
|
34
|
+
ensureConfigDir();
|
|
35
|
+
let d = {};
|
|
36
|
+
try { d = JSON.parse(fs.readFileSync(sessionPath, 'utf8')); } catch { }
|
|
37
|
+
d[key] = value;
|
|
38
|
+
try { fs.writeFileSync(sessionPath, JSON.stringify(d)); } catch { }
|
|
39
|
+
},
|
|
40
|
+
removeItem(key) {
|
|
41
|
+
let d = {};
|
|
42
|
+
try { d = JSON.parse(fs.readFileSync(sessionPath, 'utf8')); } catch { }
|
|
43
|
+
delete d[key];
|
|
44
|
+
try { fs.writeFileSync(sessionPath, JSON.stringify(d)); } catch { }
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
let client = null;
|
|
49
|
+
function getClient() {
|
|
50
|
+
if (!client) {
|
|
51
|
+
client = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, {
|
|
52
|
+
auth: {
|
|
53
|
+
storage: fileStorage,
|
|
54
|
+
autoRefreshToken: true,
|
|
55
|
+
persistSession: true,
|
|
56
|
+
detectSessionInUrl: false,
|
|
57
|
+
},
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
return client;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function prompt(question) {
|
|
64
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
65
|
+
try { return (await rl.question(question)).trim(); }
|
|
66
|
+
finally { rl.close(); }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Interactive email + 6-digit-code sign-in. Prints its own progress/errors. */
|
|
70
|
+
async function login() {
|
|
71
|
+
const supabase = getClient();
|
|
72
|
+
const email = await prompt('Email: ');
|
|
73
|
+
if (!email) { console.error('No email entered.'); return false; }
|
|
74
|
+
|
|
75
|
+
const { error: otpErr } = await supabase.auth.signInWithOtp({ email, options: { shouldCreateUser: false } });
|
|
76
|
+
if (otpErr) {
|
|
77
|
+
console.error(`Could not send a code: ${otpErr.message}`);
|
|
78
|
+
console.error('(No account with that email? Sign up in the Codeply desktop app first.)');
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
console.log(`Code sent to ${email}.`);
|
|
83
|
+
const code = await prompt('6-digit code: ');
|
|
84
|
+
const { data, error } = await supabase.auth.verifyOtp({ email, token: code.replace(/\s+/g, ''), type: 'email' });
|
|
85
|
+
if (error || !data?.session) {
|
|
86
|
+
console.error(`Sign-in failed: ${error?.message || 'invalid code'}`);
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
console.log(`Signed in as ${data.session.user.email}.`);
|
|
91
|
+
return true;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function logout() {
|
|
95
|
+
const supabase = getClient();
|
|
96
|
+
try { await supabase.auth.signOut(); } catch { }
|
|
97
|
+
console.log('Signed out.');
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Returns the current session, refreshing it if the client already has one. */
|
|
101
|
+
async function getSession() {
|
|
102
|
+
const supabase = getClient();
|
|
103
|
+
const { data: { session } } = await supabase.auth.getSession();
|
|
104
|
+
return session || null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function getAccessToken() {
|
|
108
|
+
const session = await getSession();
|
|
109
|
+
return session?.access_token || null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
module.exports = { SUPABASE_URL, SUPABASE_ANON_KEY, getClient, login, logout, getSession, getAccessToken };
|
package/lib/auth.mjs
ADDED
package/lib/config.js
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
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 an API key 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`)
|
|
11
|
+
* ollama Ollama's OpenAI-compatible API — cloud (ollama.com, needs a key)
|
|
12
|
+
* or a local daemon (http://localhost:11434, no key needed)
|
|
13
|
+
*/
|
|
14
|
+
const fs = require('fs');
|
|
15
|
+
const path = require('path');
|
|
16
|
+
const os = require('os');
|
|
17
|
+
|
|
18
|
+
const configDir = path.join(os.homedir(), '.codeply');
|
|
19
|
+
const configPath = path.join(configDir, 'config.json');
|
|
20
|
+
|
|
21
|
+
const DEFAULTS = {
|
|
22
|
+
provider: 'codeply',
|
|
23
|
+
ollama: {
|
|
24
|
+
host: 'https://ollama.com',
|
|
25
|
+
model: 'gemma4:31b',
|
|
26
|
+
apiKey: '',
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
function readFile() {
|
|
31
|
+
try {
|
|
32
|
+
return JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
|
33
|
+
} catch {
|
|
34
|
+
return {};
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Effective config: defaults ← file ← environment. */
|
|
39
|
+
function getConfig() {
|
|
40
|
+
const file = readFile();
|
|
41
|
+
const provider = process.env.CODEPLY_PROVIDER || file.provider || DEFAULTS.provider;
|
|
42
|
+
const fileOllama = file.ollama || {};
|
|
43
|
+
|
|
44
|
+
return {
|
|
45
|
+
provider,
|
|
46
|
+
ollama: {
|
|
47
|
+
host: (process.env.OLLAMA_HOST || fileOllama.host || DEFAULTS.ollama.host).replace(/\/+$/, ''),
|
|
48
|
+
model: process.env.OLLAMA_MODEL || fileOllama.model || DEFAULTS.ollama.model,
|
|
49
|
+
apiKey: process.env.OLLAMA_API_KEY || fileOllama.apiKey || '',
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Merge a patch into the stored config. Written user-only where supported. */
|
|
55
|
+
function saveConfig(patch) {
|
|
56
|
+
const current = readFile();
|
|
57
|
+
const next = {
|
|
58
|
+
...current,
|
|
59
|
+
...patch,
|
|
60
|
+
ollama: { ...(current.ollama || {}), ...(patch.ollama || {}) },
|
|
61
|
+
};
|
|
62
|
+
try {
|
|
63
|
+
if (!fs.existsSync(configDir)) fs.mkdirSync(configDir, { recursive: true });
|
|
64
|
+
fs.writeFileSync(configPath, JSON.stringify(next, null, 2), { mode: 0o600 });
|
|
65
|
+
// mkdir/writeFile modes are advisory on Windows; chmod is best-effort.
|
|
66
|
+
try { fs.chmodSync(configPath, 0o600); } catch {}
|
|
67
|
+
return { ok: true, path: configPath };
|
|
68
|
+
} catch (e) {
|
|
69
|
+
return { ok: false, error: e.message };
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Never render a key in full — this is what goes on screen and in logs. */
|
|
74
|
+
function maskKey(key) {
|
|
75
|
+
if (!key) return '(none)';
|
|
76
|
+
if (key.length <= 8) return '••••';
|
|
77
|
+
return key.slice(0, 4) + '…' + key.slice(-4);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** One-line description of where completions are coming from. */
|
|
81
|
+
function describeProvider(cfg = getConfig()) {
|
|
82
|
+
if (cfg.provider === 'ollama') {
|
|
83
|
+
const where = /localhost|127\.0\.0\.1/.test(cfg.ollama.host) ? 'local' : 'cloud';
|
|
84
|
+
return `ollama ${where} · ${cfg.ollama.model}`;
|
|
85
|
+
}
|
|
86
|
+
return 'codeply proxy';
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
module.exports = { getConfig, saveConfig, maskKey, describeProvider, configPath };
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Codeply CLI — surgical SEARCH/REPLACE edit engine
|
|
3
|
+
*
|
|
4
|
+
* Adapted from Codeply-App/main.js's computeInstructionEdits/
|
|
5
|
+
* applySearchReplace/applyEditsToContent — same approach (small verified
|
|
6
|
+
* diffs instead of a full-file rewrite), trimmed for a stateless single-shot
|
|
7
|
+
* CLI: no local usage-history file, no .codeply/ response cache, no
|
|
8
|
+
* multi-turn conversation context (each CLI invocation is its own process).
|
|
9
|
+
*/
|
|
10
|
+
const fs = require('fs');
|
|
11
|
+
const path = require('path');
|
|
12
|
+
const ai = require('./ai');
|
|
13
|
+
|
|
14
|
+
// Apply ONE search/replace edit to a string. Exact match first, then
|
|
15
|
+
// whitespace-tolerant (ignore leading/trailing space, then collapse internal
|
|
16
|
+
// runs), skipping blank lines on both sides. Re-indents the replacement to fit.
|
|
17
|
+
// Returns { ok, content } or { ok:false, error:'notfound'|'multiple'|'empty' }.
|
|
18
|
+
function applySearchReplace(fileContent, searchBlock, replaceBlock) {
|
|
19
|
+
const nl = (s) => (s || '').replace(/\r\n/g, '\n');
|
|
20
|
+
fileContent = nl(fileContent); searchBlock = nl(searchBlock); replaceBlock = nl(replaceBlock);
|
|
21
|
+
if (!searchBlock.trim()) return { ok: false, error: 'empty' };
|
|
22
|
+
|
|
23
|
+
const fileLines = fileContent.split('\n');
|
|
24
|
+
const searchLines = searchBlock.split('\n');
|
|
25
|
+
|
|
26
|
+
const lineStartOffset = (idx) => { let o = 0; for (let k = 0; k < idx; k++) o += fileLines[k].length + 1; return o; };
|
|
27
|
+
|
|
28
|
+
const reindentBlock = (block, indent) => {
|
|
29
|
+
const ls = block.split('\n');
|
|
30
|
+
const firstNonEmpty = ls.find(l => l.trim() !== '') || '';
|
|
31
|
+
const base = (firstNonEmpty.match(/^[ \t]*/) || [''])[0];
|
|
32
|
+
return ls.map(l => {
|
|
33
|
+
if (l.trim() === '') return '';
|
|
34
|
+
const stripped = l.startsWith(base) ? l.slice(base.length) : l.replace(/^[ \t]*/, '');
|
|
35
|
+
return indent + stripped;
|
|
36
|
+
}).join('\n');
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const locate = (normalize) => {
|
|
40
|
+
const s = searchLines.map(normalize).filter(x => x !== '');
|
|
41
|
+
if (!s.length) return [];
|
|
42
|
+
const found = [];
|
|
43
|
+
for (let start = 0; start < fileLines.length; start++) {
|
|
44
|
+
if (normalize(fileLines[start]) !== s[0]) continue;
|
|
45
|
+
let fi = start, si = 0, end = start;
|
|
46
|
+
while (si < s.length && fi < fileLines.length) {
|
|
47
|
+
const nf = normalize(fileLines[fi]);
|
|
48
|
+
if (nf === '') { fi++; continue; } // skip blank lines in the file
|
|
49
|
+
if (nf !== s[si]) break;
|
|
50
|
+
end = fi; fi++; si++;
|
|
51
|
+
}
|
|
52
|
+
if (si === s.length) found.push([start, end]);
|
|
53
|
+
}
|
|
54
|
+
return found;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const exact = fileContent.indexOf(searchBlock);
|
|
58
|
+
if (exact !== -1) {
|
|
59
|
+
if (exact !== fileContent.lastIndexOf(searchBlock)) return { ok: false, error: 'multiple' };
|
|
60
|
+
return { ok: true, content: fileContent.slice(0, exact) + replaceBlock + fileContent.slice(exact + searchBlock.length) };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
let matches = locate(l => l.trim());
|
|
64
|
+
if (matches.length === 0) matches = locate(l => l.trim().replace(/\s+/g, ' '));
|
|
65
|
+
|
|
66
|
+
if (matches.length === 0) {
|
|
67
|
+
// Last resort: anchor on the first AND last non-blank lines of the search
|
|
68
|
+
// block (recovers when the AI dropped/added a line in the middle). Kept
|
|
69
|
+
// conservative — both anchors must be unique and the span must be modest.
|
|
70
|
+
const sNon = searchLines.map(l => l.trim()).filter(x => x !== '');
|
|
71
|
+
if (sNon.length >= 2) {
|
|
72
|
+
const firstHits = [], lastHits = [];
|
|
73
|
+
fileLines.forEach((l, i) => {
|
|
74
|
+
const t = l.trim();
|
|
75
|
+
if (t === sNon[0]) firstHits.push(i);
|
|
76
|
+
if (t === sNon[sNon.length - 1]) lastHits.push(i);
|
|
77
|
+
});
|
|
78
|
+
if (firstHits.length === 1 && lastHits.length === 1 && lastHits[0] >= firstHits[0]) {
|
|
79
|
+
const sL = firstHits[0], eL = lastHits[0];
|
|
80
|
+
if (eL - sL + 1 <= sNon.length * 3 + 5) { // don't grab a huge region
|
|
81
|
+
const indent = (fileLines[sL].match(/^[ \t]*/) || [''])[0];
|
|
82
|
+
const reindented = reindentBlock(replaceBlock, indent);
|
|
83
|
+
return { ok: true, content: fileContent.slice(0, lineStartOffset(sL)) + reindented + fileContent.slice(lineStartOffset(eL) + fileLines[eL].length) };
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return { ok: false, error: 'notfound' };
|
|
88
|
+
}
|
|
89
|
+
if (matches.length > 1) return { ok: false, error: 'multiple' };
|
|
90
|
+
|
|
91
|
+
const [startLine, endLine] = matches[0];
|
|
92
|
+
const indent = (fileLines[startLine].match(/^[ \t]*/) || [''])[0];
|
|
93
|
+
const reindented = reindentBlock(replaceBlock, indent);
|
|
94
|
+
const startChar = lineStartOffset(startLine);
|
|
95
|
+
const endChar = lineStartOffset(endLine) + fileLines[endLine].length;
|
|
96
|
+
return { ok: true, content: fileContent.slice(0, startChar) + reindented + fileContent.slice(endChar) };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Apply an ordered list of verified {search, replace} hunks to file content,
|
|
100
|
+
// all-or-nothing (pure — no disk write).
|
|
101
|
+
function applyEditsToContent(fileContent, edits) {
|
|
102
|
+
let working = (fileContent || '').replace(/\r\n/g, '\n');
|
|
103
|
+
for (let i = 0; i < edits.length; i++) {
|
|
104
|
+
const { search, replace } = edits[i];
|
|
105
|
+
const res = applySearchReplace(working, search, replace);
|
|
106
|
+
if (!res.ok) return { ok: false, error: res.error, index: i };
|
|
107
|
+
working = res.content;
|
|
108
|
+
}
|
|
109
|
+
return { ok: true, content: working };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const SYSTEM_PROMPT = `You are a precise code editor. You receive an INSTRUCTION describing a small change, and the FULL current file. Make ONLY the specific change requested, as surgical SEARCH/REPLACE edits — do NOT reproduce the whole file.
|
|
113
|
+
|
|
114
|
+
STRICT RULES:
|
|
115
|
+
1. Return an "edits" array. Use the FEWEST edits that cleanly express the change.
|
|
116
|
+
2. "search" must be copied VERBATIM from the file below — exact existing text being changed, with just enough surrounding lines to be unique.
|
|
117
|
+
3. "replace" is that same block with ONLY the requested change applied — preserve everything else in it exactly (formatting, unrelated properties, comments, whitespace style).
|
|
118
|
+
4. If the SAME property/selector/value appears more than once in the file (e.g. "top" set in both a "from" and a "to" block), do NOT pick the first or most obvious match — use every clue in the instruction to find the ONE correct occurrence: the CURRENT value it mentions, nearby selectors, rule/keyframe names, or ordering (first/last, start/end). If more than one occurrence still fits equally well after that, return an EMPTY "edits" array with a "reason" naming the ambiguity instead of guessing.
|
|
119
|
+
5. If the instruction genuinely requires large new content that can't be expressed as a small edit (e.g. "build a whole new page/game/module from scratch"), return an EMPTY "edits" array — do not guess badly at a huge diff.
|
|
120
|
+
6. Return ONLY valid JSON, no markdown, no commentary.
|
|
121
|
+
|
|
122
|
+
Response format:
|
|
123
|
+
{
|
|
124
|
+
"edits": [ { "search": "<exact verbatim block from the file>", "replace": "<that block with the change applied>" } ],
|
|
125
|
+
"reason": "<one short sentence>",
|
|
126
|
+
"confidence": <0-100>
|
|
127
|
+
}`;
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Asks the AI for surgical edits, self-corrects up to twice against bad
|
|
131
|
+
* "search" matches, and returns only the edits that verify cleanly against
|
|
132
|
+
* the real file content.
|
|
133
|
+
* @returns {Promise<{success:boolean, edits?:Array, badCount?:number, reason?:string, confidence?:number, tokensUsed?:number, modelUsed?:string, error?:string}>}
|
|
134
|
+
*/
|
|
135
|
+
async function computeInstructionEdits(instruction, filePath) {
|
|
136
|
+
if (!fs.existsSync(filePath)) return { success: false, error: `File not found: ${filePath}` };
|
|
137
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
138
|
+
|
|
139
|
+
const probe = (search) => {
|
|
140
|
+
const res = applySearchReplace(content, search, search);
|
|
141
|
+
return res.ok ? 'ok' : res.error;
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
let totalTokens = 0;
|
|
145
|
+
let lastModelUsed = null;
|
|
146
|
+
|
|
147
|
+
const askModel = async (feedback) => {
|
|
148
|
+
const messages = [
|
|
149
|
+
{ role: 'system', content: SYSTEM_PROMPT },
|
|
150
|
+
// Large/stable content (the file) first, small/always-different content
|
|
151
|
+
// (the instruction) last — the ai-proxy's underlying model caches a
|
|
152
|
+
// repeated PREFIX, so this ordering matters for repeated calls against
|
|
153
|
+
// the same file even when the instruction text differs each time.
|
|
154
|
+
{ role: 'user', content: `FULL FILE (${path.basename(filePath)}):\n${content}\n\nINSTRUCTION:\n${instruction}` },
|
|
155
|
+
];
|
|
156
|
+
if (feedback) messages.push({ role: 'user', content: feedback });
|
|
157
|
+
|
|
158
|
+
const aiResult = await ai.chat(messages, { json: true });
|
|
159
|
+
if (!aiResult.success) throw new Error(aiResult.error);
|
|
160
|
+
if (aiResult.modelUsed) lastModelUsed = aiResult.modelUsed;
|
|
161
|
+
const data = aiResult.data;
|
|
162
|
+
totalTokens += (data.usage || {}).total_tokens || 0;
|
|
163
|
+
|
|
164
|
+
let parsed;
|
|
165
|
+
try { parsed = JSON.parse(data.choices[0].message.content); }
|
|
166
|
+
catch { return { edits: [], confidence: 0, reason: '' }; }
|
|
167
|
+
|
|
168
|
+
const edits = Array.isArray(parsed.edits)
|
|
169
|
+
? parsed.edits.filter(e => e && e.search && e.replace != null).map(e => ({ search: e.search, replace: e.replace }))
|
|
170
|
+
: [];
|
|
171
|
+
return { edits, confidence: parsed.confidence == null ? 75 : parsed.confidence, reason: parsed.reason || '' };
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
let edits, confidence, reason;
|
|
175
|
+
try {
|
|
176
|
+
({ edits, confidence, reason } = await askModel());
|
|
177
|
+
} catch (e) {
|
|
178
|
+
return { success: false, error: e.message };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const verify = (list) => list.map((e) => ({ e, status: probe(e.search) }));
|
|
182
|
+
let checked = verify(edits);
|
|
183
|
+
let bad = checked.filter(c => c.status !== 'ok');
|
|
184
|
+
|
|
185
|
+
let pass = 0;
|
|
186
|
+
while (bad.length && pass < 2) {
|
|
187
|
+
pass++;
|
|
188
|
+
const feedback =
|
|
189
|
+
`Some "search" blocks from your last answer are wrong. Return the COMPLETE corrected edits array again, fixing these:\n` +
|
|
190
|
+
bad.map(c => c.status === 'multiple'
|
|
191
|
+
? `- Matched MULTIPLE places — include more surrounding lines so it is unique:\n${c.e.search}`
|
|
192
|
+
: `- NOT found in the file — copy it EXACTLY from the FULL FILE above, character for character:\n${c.e.search}`
|
|
193
|
+
).join('\n') +
|
|
194
|
+
`\nEvery "search" must be copied verbatim from the file shown above.`;
|
|
195
|
+
let retry;
|
|
196
|
+
try { retry = await askModel(feedback); }
|
|
197
|
+
catch (e) { break; }
|
|
198
|
+
if (retry.edits.length) { edits = retry.edits; confidence = retry.confidence; reason = retry.reason || reason; }
|
|
199
|
+
checked = verify(edits);
|
|
200
|
+
bad = checked.filter(c => c.status !== 'ok');
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const good = checked.filter(c => c.status === 'ok').map(c => c.e);
|
|
204
|
+
return { success: true, edits: good, badCount: bad.length, confidence, reason, tokensUsed: totalTokens, modelUsed: lastModelUsed };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
module.exports = { applySearchReplace, applyEditsToContent, computeInstructionEdits };
|