codeply-cli 0.5.3 → 0.5.5
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/lib/agent.mjs +16 -4
- package/lib/ai.js +90 -9
- package/lib/cloud.mjs +77 -15
- package/lib/config.js +2 -2
- package/lib/github-agent.mjs +18 -3
- package/lib/native-tools.mjs +1 -0
- package/lib/telemetry.js +4 -1
- package/lib/tools.mjs +57 -4
- package/package.json +1 -1
package/lib/agent.mjs
CHANGED
|
@@ -40,7 +40,7 @@ const MAX_MALFORMED_RETRIES = 3;
|
|
|
40
40
|
const CONTEXT_CHAR_BUDGET = 64000;
|
|
41
41
|
function contextBudgetFor(route) {
|
|
42
42
|
if (route && route.custom) return route.custom.kind === 'ollama' ? 100000 : 200000;
|
|
43
|
-
if (route && route.auto) return 160000; //
|
|
43
|
+
if (route && route.auto) return 160000; // about 40k tokens: keeps Auto's Claude steps cheap (Haiku 5.5 costs 5x past 100k)
|
|
44
44
|
return CONTEXT_CHAR_BUDGET;
|
|
45
45
|
}
|
|
46
46
|
|
|
@@ -52,7 +52,7 @@ const FORMAT_CORRECTION =
|
|
|
52
52
|
'closing tags complete:\n\n' +
|
|
53
53
|
'<codeply:read_file>\n<path>run.html</path>\n</codeply:read_file>\n\n' +
|
|
54
54
|
'Valid names: todo, ask_user, mcp, list_dir, read_file, write_file, edit_file, search, run, use_skill, list_skills, fetch_image, ' +
|
|
55
|
-
'browser_check, gmail_send, gmail_search, gmail_draft, drafts_list, calendar_list, calendar_add, slack_post_message, vercel_deploy, supabase_create_project, ' +
|
|
55
|
+
'browser_check, gmail_send, gmail_search, gmail_draft, drafts_list, calendar_list, calendar_add, add_model, slack_post_message, vercel_deploy, supabase_create_project, ' +
|
|
56
56
|
'supabase_delete_project, github_create_repo, design_reference_search, view_images, supabase_api, supabase_sql, vercel_api, ' +
|
|
57
57
|
'web_fetch, web_search, image_search, reach_me, watch_email, apply_patch, plan_exit, plan_enter, lsp, publish_check, publish_connect, supabase_setup, supabase_schema, ' +
|
|
58
58
|
'publish_deploy, publish_github.';
|
|
@@ -329,6 +329,7 @@ const PARAMS = {
|
|
|
329
329
|
drafts_list: [],
|
|
330
330
|
calendar_list: ['from', 'to', 'max'],
|
|
331
331
|
calendar_add: ['title', 'start', 'end', 'description', 'location', 'reminders'],
|
|
332
|
+
add_model: ['name', 'base_url', 'model', 'api_key'],
|
|
332
333
|
slack_post_message: ['channel', 'text'],
|
|
333
334
|
vercel_deploy: ['path'],
|
|
334
335
|
supabase_create_project: ['name'],
|
|
@@ -865,6 +866,16 @@ drafts; gmail_send with <draft>id</draft> sends one of them.)
|
|
|
865
866
|
<reminders>30</reminders>
|
|
866
867
|
</codeply:calendar_add>
|
|
867
868
|
|
|
869
|
+
<codeply:add_model>
|
|
870
|
+
<name>GPT-5 mini</name>
|
|
871
|
+
<base_url>https://api.openai.com/v1</base_url>
|
|
872
|
+
<model>gpt-5-mini</model>
|
|
873
|
+
<api_key>the key the user gave</api_key>
|
|
874
|
+
</codeply:add_model>
|
|
875
|
+
|
|
876
|
+
(add_model puts a model in the user's model menu when they ask; it asks them
|
|
877
|
+
first, tests it, selects it and syncs it to their phone. Never repeat the key.)
|
|
878
|
+
|
|
868
879
|
(Calendar times are the user's local time: 2026-10-06T15:30 for a time,
|
|
869
880
|
2026-10-06 for a whole day; from/to also take today or tomorrow. calendar_list
|
|
870
881
|
defaults to today. calendar_add puts a real event on the user's Google
|
|
@@ -1043,7 +1054,8 @@ RULES
|
|
|
1043
1054
|
- Tool results are the only source of truth about what happened. A failed result (non-2xx status, non-zero exit code, "did not apply") means it did not happen - never describe it as done.
|
|
1044
1055
|
- Creating a remote repo, pushing code, enabling Pages, or deploying anything are exactly the kind of claim covered by "never report success you did not verify" above - and the easiest one to get wrong, because each step's own command can silently no-op or partially fail while a LATER step still appears to succeed. Concretely: run whoami equivalents (gh api user, gh auth status) to get the real signed-in username BEFORE building any URL with it - never guess a username from the OS account name, the folder name, or anything the user said earlier that could be stale; after gh repo create or git push, treat the command's own exit code and printed output as the only source of truth for whether it worked, not your prior turn's summary of what you intended to do - a command you ran two turns ago having succeeded is not evidence this turn's retry did too; and never hand the user a repository/deployment URL you have not just confirmed resolves (curl -I it, or read it back from the command's own output) - a plausible-looking URL built from a guessed username/slug is a fabrication even if the pattern is usually right.
|
|
1045
1056
|
- gmail_send and slack_post_message send a real email or a real Slack message the moment they run - there is no "preview" mode. Only use them when the user actually asked for that email/message to go out, never speculatively, never as a way to "show" them what it would say. When the user wants an email written but not sent yet, use gmail_draft instead. The user sees the email on an editable card before it goes out and may change it: the tool result says what was really sent or saved, so report that, not your original wording. calendar_add likewise puts a real event on the user's Google Calendar: only when they asked for it, with times in their local time; check calendar_list first when they ask what is on their calendar or whether a time is free. If gmail_search or a prior message makes clear Gmail/Slack isn't connected, say so plainly and stop - do not retry hoping it connects itself, and do not claim you sent something when the tool reported it wasn't connected.
|
|
1046
|
-
-
|
|
1057
|
+
- A brand-new project: when the user asks you to start, create or build a new app or project from scratch (an empty or new folder), then once the first version runs, back it up on GitHub without being asked again: call github_create_repo (it creates a private repo on their connected account and pushes; the user approves it on a card, no env vars, tokens or github.com steps). If it reports GitHub isn't connected, mention once, in one line, that connecting GitHub in Connect Apps backs projects up automatically, and carry on. Don't do this for existing projects, folders that are already git repos with a remote, or small one-off files.
|
|
1058
|
+
- vercel_deploy, supabase_create_project, supabase_delete_project, and github_create_repo are the same category as gmail_send/slack_post_message above: real, immediate action the moment they run - a live production deployment, a newly provisioned cloud database with its own bill, a brand-new repository pushed with the user's code. Only use them when the user actually asked for that outcome (or, for github_create_repo, a brand-new project as described above), never speculatively "to check if it would work." If one reports its integration isn't connected, say so plainly and stop rather than retrying or working around it. supabase_delete_project is the sharpest of these - it permanently destroys a database with no undo - so only reach for it when the user has clearly asked to delete or remove a specific project, never as cleanup for something that merely looks unused.
|
|
1047
1059
|
- Images: build without photos first. Most pages look great with type, color, layout, CSS shapes, icons and inline SVG, so do that unless the design truly needs a photo (a restaurant's food, a product shot, a portfolio). Never ask the user for images or how imagery should work; decide yourself. When a photo is genuinely needed, find a real one with image_search (it searches the whole web like Google Images), pick the result that fits that exact section, and download it with fetch_image to a path under assets/. Every image in the markup must be that local file: never point an <img> or CSS background at a remote URL, and never use loremflickr, picsum or other random-photo services. If a downloaded image turns out broken or unrelated once you look at the page, delete it and pick another result.
|
|
1048
1060
|
- If a SKILLS entry below is a clear match for the task, use_skill it before starting - its instructions take priority over your own default approach for that kind of work. Do not use_skill speculatively; only when a listed skill actually matches what you are about to do. A name under LIKELY RELEVANT TO THIS REQUEST, if that section is present, was matched against your actual request from the full library - treat it exactly the same way: use_skill it before starting unless it's obviously a false match, do not silently ignore it in favor of guessing your own approach.
|
|
1049
1061
|
- EXCEPTION - this one is not speculative: if the task is to build or restyle any page a human will look at in a browser (a landing page, a small-business site, a portfolio, a dashboard, any HTML/CSS/UI), use_skill 'premium-web-design' before writing markup, even if the brief sounds tiny or mundane ("a site for a tea shop"). A plain-sounding brief is not permission for a flat, default-Bootstrap-looking result - Codeply's bar is that every generated page reads as deliberately designed. Skip this only if the user explicitly asked for something minimal/utilitarian/no-frills.
|
|
@@ -1429,7 +1441,7 @@ const NO_NATIVE_TOOLS_REMINDER =
|
|
|
1429
1441
|
// unless their entry says toolMode: 'text', or CODEPLY_TOOLS=text is set.
|
|
1430
1442
|
// A model that rejects tools is remembered for the rest of the session.
|
|
1431
1443
|
const nativeUnsupported = new Set();
|
|
1432
|
-
const MUTATING_ACTIONS = new Set(['write_file', 'edit_file', 'apply_patch', 'fetch_image', 'gmail_send', 'gmail_draft', 'calendar_add', 'slack_post_message', 'vercel_deploy', 'supabase_create_project', 'supabase_delete_project', 'github_create_repo', 'supabase_setup', 'supabase_schema', 'publish_deploy', 'publish_github']);
|
|
1444
|
+
const MUTATING_ACTIONS = new Set(['write_file', 'edit_file', 'apply_patch', 'fetch_image', 'gmail_send', 'gmail_draft', 'calendar_add', 'add_model', 'slack_post_message', 'vercel_deploy', 'supabase_create_project', 'supabase_delete_project', 'github_create_repo', 'supabase_setup', 'supabase_schema', 'publish_deploy', 'publish_github']);
|
|
1433
1445
|
|
|
1434
1446
|
/**
|
|
1435
1447
|
* Native function names for MCP tools (mcp__server__tool, within the 64-char
|
package/lib/ai.js
CHANGED
|
@@ -289,6 +289,7 @@ async function chatViaProxy(messages, opts) {
|
|
|
289
289
|
'Content-Type': 'application/json',
|
|
290
290
|
'Authorization': `Bearer ${token}`,
|
|
291
291
|
'apikey': SUPABASE_ANON_KEY,
|
|
292
|
+
...(deviceId() ? { 'x-codeply-device': deviceId() } : {}),
|
|
292
293
|
},
|
|
293
294
|
// meta is optional, display-only context (what was asked, which file) -
|
|
294
295
|
// the proxy logs it to usage_history so CLI activity shows up in the
|
|
@@ -1223,14 +1224,88 @@ async function testModel(m) {
|
|
|
1223
1224
|
}
|
|
1224
1225
|
|
|
1225
1226
|
// ─── Auto ───────────────────────────────────────────────────────────────────
|
|
1226
|
-
// "Auto" is
|
|
1227
|
-
//
|
|
1228
|
-
//
|
|
1229
|
-
//
|
|
1230
|
-
//
|
|
1227
|
+
// "Auto" is Codeply's router (Codeply-App/supabase/functions/auto): Claude
|
|
1228
|
+
// Sonnet 5.5 for coding and hard work, Claude Haiku 5.5 for chat, images and
|
|
1229
|
+
// background jobs, gpt-oss-120b and Gemma 4 when a daily allowance is used up.
|
|
1230
|
+
// It decides per turn on the server, where the Claude key and the budgets
|
|
1231
|
+
// live. If the router can't be reached at all, Auto falls back to Gemma 4 31B
|
|
1232
|
+
// on Ollama Cloud directly (two account keys: a release build bakes
|
|
1233
|
+
// OLLAMA_API_KEY / OLLAMA_API_KEY_FALLBACK in via scripts/embed-secrets.js;
|
|
1234
|
+
// from source they come from ~/.codeply/config.json), then the hosted proxy.
|
|
1235
|
+
const AUTO_URL = `${SUPABASE_URL}/functions/v1/auto`;
|
|
1231
1236
|
const AUTO_MODEL = { host: 'https://ollama.com', model: 'gemma4:31b' };
|
|
1237
|
+
// Long Claude turns: the router streams keep-alive spaces, so allow a while
|
|
1238
|
+
const AUTO_TIMEOUT_MS = 6 * 60_000;
|
|
1239
|
+
|
|
1240
|
+
/**
|
|
1241
|
+
* This computer, as a one-way hash, so the router can give one Claude
|
|
1242
|
+
* allowance per computer however many accounts sign in on it. Built from the
|
|
1243
|
+
* OS machine id (Windows MachineGuid, the Mac's hardware UUID, Linux
|
|
1244
|
+
* /etc/machine-id), or the first network card's MAC when that can't be read.
|
|
1245
|
+
* The raw values never leave the PC.
|
|
1246
|
+
*/
|
|
1247
|
+
let deviceIdCache = null;
|
|
1248
|
+
function deviceId() {
|
|
1249
|
+
if (deviceIdCache !== null) return deviceIdCache;
|
|
1250
|
+
const os = require('os');
|
|
1251
|
+
const { execSync } = require('child_process');
|
|
1252
|
+
let machine = '';
|
|
1253
|
+
try {
|
|
1254
|
+
if (process.platform === 'win32') {
|
|
1255
|
+
const out = execSync('reg query HKLM\\SOFTWARE\\Microsoft\\Cryptography /v MachineGuid', { encoding: 'utf8', windowsHide: true, timeout: 3000 });
|
|
1256
|
+
machine = (/MachineGuid\s+REG_SZ\s+(\S+)/.exec(out) || [])[1] || '';
|
|
1257
|
+
} else if (process.platform === 'darwin') {
|
|
1258
|
+
const out = execSync('ioreg -rd1 -c IOPlatformExpertDevice', { encoding: 'utf8', timeout: 3000 });
|
|
1259
|
+
machine = (/"IOPlatformUUID" = "([^"]+)"/.exec(out) || [])[1] || '';
|
|
1260
|
+
} else {
|
|
1261
|
+
machine = require('fs').readFileSync('/etc/machine-id', 'utf8').trim();
|
|
1262
|
+
}
|
|
1263
|
+
} catch {}
|
|
1264
|
+
const macs = Object.values(os.networkInterfaces()).flat()
|
|
1265
|
+
.filter((n) => n && !n.internal && n.mac && n.mac !== '00:00:00:00:00:00')
|
|
1266
|
+
.map((n) => n.mac).sort();
|
|
1267
|
+
const raw = machine || macs[0] || '';
|
|
1268
|
+
deviceIdCache = raw ? require('crypto').createHash('sha256').update(`codeply-device|${raw}`).digest('hex') : '';
|
|
1269
|
+
return deviceIdCache;
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
async function chatViaRouter(messages, opts) {
|
|
1273
|
+
const token = await getAccessToken();
|
|
1274
|
+
// A cloud run has no sign-in: its usage key (CODEPLY_TRACK_KEY) stands in for the person
|
|
1275
|
+
const trackKey = token ? '' : String(process.env.CODEPLY_TRACK_KEY || '').trim();
|
|
1276
|
+
const device = trackKey ? '' : deviceId();
|
|
1277
|
+
if (!token && !trackKey) return { success: false, unreachable: true, error: 'Not signed in.' };
|
|
1278
|
+
return withRetries(async () => {
|
|
1279
|
+
let res, body;
|
|
1280
|
+
const { signal, isTimeout, cleanup } = withTimeout(opts.signal, AUTO_TIMEOUT_MS);
|
|
1281
|
+
try {
|
|
1282
|
+
res = await fetch(AUTO_URL, {
|
|
1283
|
+
method: 'POST',
|
|
1284
|
+
headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : { 'x-codeply-track-key': trackKey }), apikey: SUPABASE_ANON_KEY, ...(device ? { 'x-codeply-device': device } : {}) },
|
|
1285
|
+
body: JSON.stringify({
|
|
1286
|
+
messages: wireMessages(messages),
|
|
1287
|
+
...(opts.tools ? { tools: opts.tools } : {}),
|
|
1288
|
+
opts: { json: !!opts.json, maxTokens: opts.maxTokens, temperature: opts.temperature },
|
|
1289
|
+
}),
|
|
1290
|
+
signal,
|
|
1291
|
+
});
|
|
1292
|
+
body = JSON.parse((await res.text()).trim() || '{}');
|
|
1293
|
+
} catch (e) {
|
|
1294
|
+
if (isAbortError(e) && !isTimeout()) return { aborted: true };
|
|
1295
|
+
return { retryable: true, error: isTimeout() ? 'Auto took too long to answer.' : e.message };
|
|
1296
|
+
} finally {
|
|
1297
|
+
cleanup();
|
|
1298
|
+
}
|
|
1299
|
+
if (body.success) return { done: true, value: { success: true, data: body.data, modelUsed: body.modelUsed, route: body.route } };
|
|
1300
|
+
if (body.error) return { done: true, value: { success: false, error: body.error } };
|
|
1301
|
+
return { retryable: isTransientStatus(res.status), retryAfterMs: retryAfterMs(res), error: describeStatus(res.status) };
|
|
1302
|
+
}, opts.signal);
|
|
1303
|
+
}
|
|
1232
1304
|
|
|
1233
1305
|
async function chatViaAuto(messages, opts) {
|
|
1306
|
+
const routed = await chatViaRouter(messages, opts);
|
|
1307
|
+
if (routed.success || routed.aborted) return routed;
|
|
1308
|
+
// The router itself is unreachable or broken: the old direct route still works.
|
|
1234
1309
|
const cfg = getConfig();
|
|
1235
1310
|
const keys = { apiKey: cfg.ollama.apiKey, apiKeyFallback: cfg.ollama.apiKeyFallback };
|
|
1236
1311
|
if (!keys.apiKey && !keys.apiKeyFallback) return chatViaProxy(messages, opts);
|
|
@@ -1260,6 +1335,10 @@ async function chatResearch(messages, opts, rm) {
|
|
|
1260
1335
|
const rmod = researchMode();
|
|
1261
1336
|
const target = rmod.resolveTarget(rm);
|
|
1262
1337
|
if (target.error) return { success: false, error: target.error };
|
|
1338
|
+
// Online without their own key runs on Codeply's Ollama account: signed-in people only
|
|
1339
|
+
if (rm.mode !== 'local' && !String(rm.apiKey || '').trim() && !(await getAccessToken().catch(() => null))) {
|
|
1340
|
+
return { success: false, error: 'Sign in to Codeply to use Research Mode online, or add your own Ollama key in its settings.' };
|
|
1341
|
+
}
|
|
1263
1342
|
if (!rm.model) return { success: false, error: 'Pick a model in Research Mode settings.' };
|
|
1264
1343
|
const pre = rmod.preprocess({ messages, mode: rm.mode, model: rm.model, researchContext: rm.context });
|
|
1265
1344
|
return chatViaOllamaNative(rmod.withSystem(pre), opts, { baseUrl: target.host, model: rm.model, apiKey: target.apiKey, research: rm.mode === 'cloud' ? 'cloud' : 'local' });
|
|
@@ -1267,8 +1346,6 @@ async function chatResearch(messages, opts, rm) {
|
|
|
1267
1346
|
|
|
1268
1347
|
/** Which route a request takes, for the usage counts (telemetry.js). */
|
|
1269
1348
|
function providerOf(opts) {
|
|
1270
|
-
const rm = getConfig().research;
|
|
1271
|
-
if (rm && rm.enabled) return 'research';
|
|
1272
1349
|
if (opts.route && opts.route.custom) return opts.route.custom.kind === 'chatgpt' ? 'chatgpt' : opts.route.custom.kind === 'ollama' ? 'ollama' : 'byok';
|
|
1273
1350
|
if (opts.route && opts.route.auto) return 'auto';
|
|
1274
1351
|
const p = applyRoute(getConfig(), opts.route).provider;
|
|
@@ -1290,9 +1367,13 @@ async function chat(messages, opts = {}) {
|
|
|
1290
1367
|
}
|
|
1291
1368
|
|
|
1292
1369
|
async function chatRouted(messages, opts = {}) {
|
|
1293
|
-
// Research Mode
|
|
1370
|
+
// Research Mode: the user's research context goes in front, and whatever
|
|
1371
|
+
// model is selected (Auto or their own) answers as usual.
|
|
1294
1372
|
const rm = getConfig().research;
|
|
1295
|
-
if (rm && rm.enabled
|
|
1373
|
+
if (rm && rm.enabled && String(rm.context || '').trim()) {
|
|
1374
|
+
const rmod = researchMode();
|
|
1375
|
+
messages = rmod.withSystem(rmod.preprocess({ messages, researchContext: rm.context }));
|
|
1376
|
+
}
|
|
1296
1377
|
// A user-added model (desktop model picker) always wins when routed.
|
|
1297
1378
|
if (opts.route && opts.route.custom) return chatViaCustom(messages, opts, opts.route.custom);
|
|
1298
1379
|
// Auto in the desktop app = the hosted Codeply model, regardless of what
|
package/lib/cloud.mjs
CHANGED
|
@@ -104,6 +104,7 @@ jobs:
|
|
|
104
104
|
CRAFT_ENGINE: \${{ vars.CRAFT_ENGINE }}
|
|
105
105
|
CRAFT_ENV: \${{ secrets.CRAFT_ENV }}
|
|
106
106
|
CRAFT_MERGE: \${{ vars.CRAFT_MERGE }}
|
|
107
|
+
CRAFT_PERMISSIONS: \${{ vars.CRAFT_PERMISSIONS }}
|
|
107
108
|
CRAFT_BASE: \${{ github.ref_name }}
|
|
108
109
|
CRAFT_PROMPT: \${{ inputs.prompt }}
|
|
109
110
|
CRAFT_MODE: \${{ inputs.mode }}
|
|
@@ -379,14 +380,18 @@ export async function ensureMirror({ api, cwd, home = defaultHome() }) {
|
|
|
379
380
|
throw new Error(`Could not find a free name for the mirror (tried ${base} to ${base}-9).`);
|
|
380
381
|
}
|
|
381
382
|
|
|
382
|
-
/**
|
|
383
|
+
/**
|
|
384
|
+
* Which model a cloud run uses. Auto (Codeply's router, through the run's usage
|
|
385
|
+
* key, counted in the person's allowance) unless they picked one of their own
|
|
386
|
+
* models that GitHub can reach; a model on this PC (or with no key) uses Auto.
|
|
387
|
+
*/
|
|
388
|
+
export const AUTO_CLOUD = { kind: 'auto', model: 'Auto', sig: 'auto' };
|
|
383
389
|
export function cloudModelConfig(model) {
|
|
384
|
-
if (!model || !model.model) return
|
|
390
|
+
if (!model || !model.model) return AUTO_CLOUD;
|
|
385
391
|
const kind = model.kind === 'ollama' ? 'ollama' : 'openai';
|
|
386
392
|
const baseUrl = kind === 'ollama' ? String(model.baseUrl || 'https://ollama.com').replace(/\/+$/, '').replace(/\/(api|v1)$/i, '') : String(model.baseUrl || '').trim();
|
|
387
|
-
if (!baseUrl
|
|
388
|
-
if (/^https?:\/\/(localhost|127\.|0\.0\.0\.0|\[::1\]|192\.168\.|10\.|172\.(1[6-9]|2\d|3[01])\.)/i.test(baseUrl)) return
|
|
389
|
-
if (!model.apiKey) return { error: `${model.name || model.model} has no API key.` };
|
|
393
|
+
if (!baseUrl || !model.apiKey) return AUTO_CLOUD;
|
|
394
|
+
if (/^https?:\/\/(localhost|127\.|0\.0\.0\.0|\[::1\]|192\.168\.|10\.|172\.(1[6-9]|2\d|3[01])\.)/i.test(baseUrl)) return AUTO_CLOUD;
|
|
390
395
|
return { kind, baseUrl, model: model.model, apiKey: model.apiKey, sig: crypto.createHash('sha256').update([kind, baseUrl, model.model, model.apiKey].join('\n')).digest('hex').slice(0, 16) };
|
|
391
396
|
}
|
|
392
397
|
|
|
@@ -398,7 +403,13 @@ async function setVariable(api, repo, name, value) {
|
|
|
398
403
|
/** Put the model key in the mirror's encrypted secrets and the rest in variables. */
|
|
399
404
|
export async function configureModel({ api, repo, model }) {
|
|
400
405
|
const cfg = cloudModelConfig(model);
|
|
401
|
-
if (cfg.
|
|
406
|
+
if (cfg.kind === 'auto') {
|
|
407
|
+
// Auto: no model key in the repo at all; the runner reaches Codeply's router with its usage key
|
|
408
|
+
await api('DELETE', `/repos/${repo}/actions/secrets/CODEPLY_API_KEY`, null, { allow: [404] });
|
|
409
|
+
for (const name of ['CODEPLY_BASE_URL', 'CODEPLY_MODEL']) await api('DELETE', `/repos/${repo}/actions/variables/${name}`, null, { allow: [404] });
|
|
410
|
+
await setVariable(api, repo, 'CODEPLY_MODEL_KIND', 'auto');
|
|
411
|
+
return cfg;
|
|
412
|
+
}
|
|
402
413
|
const pk = (await api('GET', `/repos/${repo}/actions/secrets/public-key`)).json;
|
|
403
414
|
await api('PUT', `/repos/${repo}/actions/secrets/CODEPLY_API_KEY`, { encrypted_value: sealSecret(pk.key, cfg.apiKey), key_id: pk.key_id });
|
|
404
415
|
await setVariable(api, repo, 'CODEPLY_MODEL_KIND', cfg.kind);
|
|
@@ -467,8 +478,8 @@ export async function ensureTrackKey({ api, repo, cwd, home = defaultHome(), fet
|
|
|
467
478
|
try {
|
|
468
479
|
const p = getProject(cwd, home) || {};
|
|
469
480
|
if (p.trackKeyRepo === repo) return false;
|
|
470
|
-
|
|
471
|
-
|
|
481
|
+
// Needed whatever the usage-stats setting: the run reaches Auto and the connected
|
|
482
|
+
// apps with it (telemetry.js still sends no stats when they're switched off).
|
|
472
483
|
const auth = require('./auth.js');
|
|
473
484
|
const token = await auth.getAccessToken().catch(() => null);
|
|
474
485
|
if (!token) return false;
|
|
@@ -549,7 +560,7 @@ export async function loadCloudConnections({ env = process.env, fetchImpl = fetc
|
|
|
549
560
|
}
|
|
550
561
|
|
|
551
562
|
// Bumped when CLOUD_WORKFLOW changes, so repos set up earlier get the new file once.
|
|
552
|
-
export const WORKFLOW_REV =
|
|
563
|
+
export const WORKFLOW_REV = 3;
|
|
553
564
|
|
|
554
565
|
/**
|
|
555
566
|
* One-click setup. With `target: 'repo'` (default when the project has a GitHub
|
|
@@ -578,7 +589,7 @@ export async function setupCloud({ cwd, token, model, target, envText, home = de
|
|
|
578
589
|
}
|
|
579
590
|
await configureModel({ api, repo, model });
|
|
580
591
|
if (envText != null) await setProjectEnv({ api, repo, envText });
|
|
581
|
-
updateProject(cwd, home, (p) => ({ ...p, modelSig: cfg.sig, modelName: model.name || model.model, enabled: true, workflowRev: WORKFLOW_REV }));
|
|
592
|
+
updateProject(cwd, home, (p) => ({ ...p, modelSig: cfg.sig, modelName: cfg.kind === 'auto' ? 'Auto' : (model.name || model.model), enabled: true, workflowRev: WORKFLOW_REV }));
|
|
582
593
|
await ensureTrackKey({ api, repo, cwd, home, fetchImpl });
|
|
583
594
|
await syncCloudConnections({ home, fetchImpl });
|
|
584
595
|
return { repo, created, kind, base, url: `${(serverUrl || 'https://github.com').replace(/\/$/, '')}/${repo}`, skipped };
|
|
@@ -629,12 +640,12 @@ export async function startCloudRun({ cwd, token, prompt, mode = 'Build', sessio
|
|
|
629
640
|
if (prompt.length > MAX_PROMPT) throw new Error(`That task is too long for a cloud run (${prompt.length} characters, the limit is ${MAX_PROMPT}).`);
|
|
630
641
|
mode = ['Build', 'Plan', 'Ask'].includes(mode) ? mode : 'Build';
|
|
631
642
|
const api = githubApi({ token, apiUrl, fetchImpl });
|
|
632
|
-
|
|
643
|
+
// model: undefined leaves the repo's model as it is; null (Auto selected) or a model sets it
|
|
644
|
+
if (model !== undefined) {
|
|
633
645
|
const cfg = cloudModelConfig(model);
|
|
634
|
-
if (cfg.error) throw new Error(cfg.error);
|
|
635
646
|
if (cfg.sig !== p.modelSig) {
|
|
636
647
|
await configureModel({ api, repo: p.repo, model });
|
|
637
|
-
updateProject(cwd, home, (q) => ({ ...q, modelSig: cfg.sig, modelName: model.name || model.model }));
|
|
648
|
+
updateProject(cwd, home, (q) => ({ ...q, modelSig: cfg.sig, modelName: cfg.kind === 'auto' ? 'Auto' : (model.name || model.model) }));
|
|
638
649
|
}
|
|
639
650
|
}
|
|
640
651
|
const base = p.base || 'main';
|
|
@@ -681,6 +692,28 @@ export async function readTaskDoc(api, repo, taskId) {
|
|
|
681
692
|
}
|
|
682
693
|
|
|
683
694
|
/** Where a cloud task is: its status file first, then the workflow run (for starts and failed starts). */
|
|
695
|
+
/** The person's answer to a cloud run's question (allow or not), picked up by the runner within seconds. */
|
|
696
|
+
export async function answerCloudAsk({ cwd, token, taskId, askId, approve, home = defaultHome(), apiUrl, fetchImpl }) {
|
|
697
|
+
const task = getTask(cwd, taskId, home);
|
|
698
|
+
if (!task) throw new Error('Unknown cloud task.');
|
|
699
|
+
if (!/^[\w.-]+$/.test(String(askId || ''))) throw new Error('Unknown question.');
|
|
700
|
+
const api = githubApi({ token, apiUrl, fetchImpl });
|
|
701
|
+
const ok = await branchFiles(api, task.repo).write(SESSION_PATHS.answer(askId), { verdict: approve ? 'approve' : 'reject', at: Date.now() }, `Craft answer ${askId}`);
|
|
702
|
+
if (!ok) throw new Error('Could not send your answer to GitHub. Try again.');
|
|
703
|
+
saveTask(cwd, home, { ...task, ask: null });
|
|
704
|
+
return true;
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
/** "ask" (risky actions wait for an OK) or "full" (they just run), kept as the repo variable CRAFT_PERMISSIONS. */
|
|
708
|
+
export async function setCloudPermission({ cwd, token, permission, home = defaultHome(), apiUrl, fetchImpl }) {
|
|
709
|
+
const p = getProject(cwd, home);
|
|
710
|
+
if (!p || !p.repo) throw new Error('Set up cloud runs for this project first.');
|
|
711
|
+
const value = permission === 'full' ? 'full' : 'ask';
|
|
712
|
+
await setVariable(githubApi({ token, apiUrl, fetchImpl }), p.repo, 'CRAFT_PERMISSIONS', value);
|
|
713
|
+
updateProject(cwd, home, (q) => ({ ...q, permission: value }));
|
|
714
|
+
return value;
|
|
715
|
+
}
|
|
716
|
+
|
|
684
717
|
export async function cloudRunStatus({ cwd, taskId, token, home = defaultHome(), apiUrl, fetchImpl, retryMs = 3000 }) {
|
|
685
718
|
const task = getTask(cwd, taskId, home);
|
|
686
719
|
if (!task) throw new Error('Unknown cloud task.');
|
|
@@ -695,6 +728,8 @@ export async function cloudRunStatus({ cwd, taskId, token, home = defaultHome(),
|
|
|
695
728
|
const { id: _i, startedAt: _s, updatedAt: _u, runId: _r, ...result } = rest;
|
|
696
729
|
Object.assign(next, { status, result, error: result.error || null, finishedAt: Date.now(), queued: false });
|
|
697
730
|
} else next.status = 'running';
|
|
731
|
+
// a risky action waiting for the person's OK (answerCloudAsk)
|
|
732
|
+
next.ask = next.status === 'running' && doc.ask && doc.ask.id ? doc.ask : null;
|
|
698
733
|
saveTask(cwd, home, next);
|
|
699
734
|
return { ...next, events };
|
|
700
735
|
}
|
|
@@ -950,6 +985,8 @@ export const SESSION_PATHS = {
|
|
|
950
985
|
live: (sid) => `live/${sid}.json`,
|
|
951
986
|
queue: (sid) => `queue/${sid}`,
|
|
952
987
|
shot: (id, n) => `shots/${id}/${n}.png`,
|
|
988
|
+
// A risky action waiting for the person: Craft or the phone writes the answer here.
|
|
989
|
+
answer: (askId) => `answers/${askId}.json`,
|
|
953
990
|
// What does not fit in the workflow's inputs (a bot's prompt), written before the dispatch.
|
|
954
991
|
request: (id) => `requests/${id}.json`,
|
|
955
992
|
};
|
|
@@ -1121,7 +1158,8 @@ async function runOneTask({ task, env, cwd, api, files, repo, token, serverUrl,
|
|
|
1121
1158
|
output: { title: 'Starting', summary: 'Starting...' },
|
|
1122
1159
|
}).catch(() => ({ json: null }))).json : null;
|
|
1123
1160
|
|
|
1124
|
-
|
|
1161
|
+
let pendingAsk = null; let asks = 0;
|
|
1162
|
+
const statusDoc = (status, extra = {}) => ({ ...(pendingAsk && status === 'running' ? { ask: pendingAsk } : {}), id: taskId, status, mode, prompt: prompt.slice(0, 4000), sessionId, ...(kind === 'task' ? { kind } : {}), ...(bot ? { bot: { name: bot.name } } : {}), startedAt, updatedAt: Date.now(), runId: env.GITHUB_RUN_ID || null, ...extra });
|
|
1125
1163
|
const finish = async (ok, title, summary, result) => {
|
|
1126
1164
|
const baseLen = JSON.stringify(result).length;
|
|
1127
1165
|
const packed = packEvents(events, Math.max(4000, MAX_OUTPUT - baseLen - 200));
|
|
@@ -1169,7 +1207,31 @@ async function runOneTask({ task, env, cwd, api, files, repo, token, serverUrl,
|
|
|
1169
1207
|
const taskBase = await git(cwd, ['rev-parse', 'HEAD']).catch(() => headSha);
|
|
1170
1208
|
const hist = await loadHistory(files, sessionId);
|
|
1171
1209
|
await pushProgress(hist.messages.length ? `Continuing the chat (${hist.messages.length / 2} earlier turns).` : 'Working on it.', true);
|
|
1172
|
-
|
|
1210
|
+
// Risky actions (deploys...) ask the person unless the repo's Cloud permission is "full".
|
|
1211
|
+
const ASK_WAIT_MS = 15 * 60 * 1000;
|
|
1212
|
+
// Only a repo with the current workflow passes CRAFT_PERMISSIONS (empty when unset): its
|
|
1213
|
+
// Craft and phone can show the question. An older setup keeps the old rule (no) until updated.
|
|
1214
|
+
const rules = ciApprove(cwd, { permission: env.CRAFT_PERMISSIONS, canAsk: env.CRAFT_PERMISSIONS !== undefined });
|
|
1215
|
+
const approve = async (req) => {
|
|
1216
|
+
const v = await rules(req);
|
|
1217
|
+
if (v !== 'ask') return v;
|
|
1218
|
+
const askId = `${taskId}-${++asks}`;
|
|
1219
|
+
pendingAsk = { id: askId, tool: req.tool, title: String(req.title || req.tool).slice(0, 300), detail: String(req.detail || '').slice(0, 2000), at: Date.now() };
|
|
1220
|
+
events.push({ t: 'notice', level: 'info', text: `Waiting for your OK: ${pendingAsk.title}` });
|
|
1221
|
+
await pushProgress('Waiting for your OK.', true);
|
|
1222
|
+
const until = Date.now() + ASK_WAIT_MS;
|
|
1223
|
+
let verdict = null;
|
|
1224
|
+
while (Date.now() < until && !verdict) {
|
|
1225
|
+
await sleep(4000);
|
|
1226
|
+
const f = await files.read(SESSION_PATHS.answer(askId)).catch(() => null);
|
|
1227
|
+
if (f && f.json) { verdict = f.json.verdict === 'approve' ? 'once' : 'reject'; files.remove(SESSION_PATHS.answer(askId), f.sha).catch(() => {}); }
|
|
1228
|
+
else if (heartbeat) heartbeat(true).catch(() => {});
|
|
1229
|
+
}
|
|
1230
|
+
pendingAsk = null;
|
|
1231
|
+
events.push({ t: 'notice', level: 'info', text: verdict === 'once' ? 'Approved.' : verdict === 'reject' ? 'You said no, so it was skipped.' : 'No answer in 15 minutes, so it was skipped.' });
|
|
1232
|
+
await pushProgress('Working.', true);
|
|
1233
|
+
return verdict || 'reject';
|
|
1234
|
+
};
|
|
1173
1235
|
let answer = ''; let steps = 0; let failure = '';
|
|
1174
1236
|
const userMessage = kind === 'task'
|
|
1175
1237
|
? `${prompt}\n\n(You are running unattended in Codeply Cloud on a Linux machine, not on the user's PC: you cannot reach their local files or desktop apps. Use the shell, the web and this repository. Nobody can answer questions mid-run, so make sensible choices and say what you assumed. Your final reply is read out to the user: say plainly what you did or found, in a few short sentences, no markdown. Do not commit or push; Craft does that when you finish.)`
|
package/lib/config.js
CHANGED
|
@@ -102,9 +102,9 @@ function getConfig() {
|
|
|
102
102
|
const fr = file.research || {};
|
|
103
103
|
const research = {
|
|
104
104
|
enabled: fr.enabled === true,
|
|
105
|
-
mode: fr.mode === '
|
|
105
|
+
mode: fr.mode === 'local' ? 'local' : 'cloud', // online by default: works with no Ollama installed
|
|
106
106
|
model: String(fr.model || ''),
|
|
107
|
-
apiKey:
|
|
107
|
+
apiKey: fr.apiKey || '', // only a key the person added; without one, research-mode/ollama.js uses Codeply's
|
|
108
108
|
context: String(fr.context || ''),
|
|
109
109
|
};
|
|
110
110
|
|
package/lib/github-agent.mjs
CHANGED
|
@@ -42,6 +42,12 @@ export const PROVIDERS = {
|
|
|
42
42
|
|
|
43
43
|
/** Model route from CODEPLY_PROVIDER / CODEPLY_BASE_URL / CODEPLY_MODEL / CODEPLY_API_KEY. */
|
|
44
44
|
export function routeFromEnv(env) {
|
|
45
|
+
const wantsAuto = String(env.CODEPLY_MODEL_KIND || '').toLowerCase() === 'auto';
|
|
46
|
+
const hasKey = !!String(env.CODEPLY_API_KEY || '').trim();
|
|
47
|
+
if (wantsAuto || (!hasKey && !env.CODEPLY_BASE_URL && env.CODEPLY_TRACK_KEY)) {
|
|
48
|
+
if (!env.CODEPLY_TRACK_KEY) return { error: 'This repo has no Codeply usage key yet. Set cloud up again from Codeply.' };
|
|
49
|
+
return { route: { auto: true } };
|
|
50
|
+
}
|
|
45
51
|
const provider = String(env.CODEPLY_PROVIDER || 'openai').toLowerCase();
|
|
46
52
|
const preset = PROVIDERS[provider];
|
|
47
53
|
const baseUrl = env.CODEPLY_BASE_URL || (preset && preset.baseUrl);
|
|
@@ -121,12 +127,21 @@ export function git(cwd, args, { auth, input } = {}) {
|
|
|
121
127
|
const slug = (s) => String(s || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 30) || 'change';
|
|
122
128
|
export const clip = (s) => (s.length > MAX_COMMENT ? `${s.slice(0, MAX_COMMENT)}\n\n[... cut, the run log has the rest]` : s);
|
|
123
129
|
|
|
124
|
-
/**
|
|
125
|
-
|
|
130
|
+
/**
|
|
131
|
+
* The approver for a run nobody is watching live: the rules file first, never a
|
|
132
|
+
* git push (Craft pushes when the run ends), then the risky actions (deploys,
|
|
133
|
+
* deleting a database, risky commands). Those depend on the repo's Cloud
|
|
134
|
+
* permission (CRAFT_PERMISSIONS): "full" runs them, anything else answers
|
|
135
|
+
* 'ask' so the cloud runner can ask the person in Craft or on the phone. With
|
|
136
|
+
* no way to ask (the /codeply comment agent), 'ask' means no.
|
|
137
|
+
*/
|
|
138
|
+
export function ciApprove(cwd, { permission = '', canAsk = false } = {}) {
|
|
139
|
+
const full = String(permission || '').toLowerCase() === 'full';
|
|
126
140
|
return async (req) => {
|
|
127
141
|
const d = permissions.decide(req, cwd);
|
|
128
|
-
if (d.decision === 'deny' || req.
|
|
142
|
+
if (d.decision === 'deny' || req.tool === 'fetch_image') return 'reject';
|
|
129
143
|
if (req.tool === 'run' && /(^|[\s;&|(])(git\b[^;&|\n]*\spush|gh)(\s|$)/.test(`${req.detail || ''}\n${req.title || ''}`)) return 'reject';
|
|
144
|
+
if (req.danger) return full ? 'once' : canAsk ? 'ask' : 'reject';
|
|
130
145
|
return 'once';
|
|
131
146
|
};
|
|
132
147
|
}
|
package/lib/native-tools.mjs
CHANGED
|
@@ -38,6 +38,7 @@ export const TOOL_DOCS = {
|
|
|
38
38
|
drafts_list: { d: 'List the drafts saved in Codeply (ids, subject, recipient). Send one with gmail_send and its id as draft.', req: [], p: {} },
|
|
39
39
|
calendar_list: { d: 'List events on the user\'s Google Calendar in a time range (default: today). Times are local.', req: [], p: { from: 'Start: today, tomorrow, 2026-10-03 or 2026-10-03T14:00. Default today.', to: 'End (a date means through that whole day). Default: end of the from day.', max: 'Most events to return (1-50, default 20).' } },
|
|
40
40
|
calendar_add: { d: 'Add an event to the user\'s Google Calendar (asks the user first).', req: ['title', 'start'], p: { title: 'Event title.', start: 'Local start: 2026-10-03T14:00, or 2026-10-03 for an all-day event.', end: 'Local end. Default: one hour after start (all-day: the same day).', description: 'Notes for the event.', location: 'Where it is.', reminders: 'Popup reminders, minutes before, comma-separated, e.g. "10,60".' } },
|
|
41
|
+
add_model: { d: 'Add a model to the user\'s Codeply model menu when they ask (they give a name, base URL, model id and API key). Asks them first, tests it, saves it on this computer, selects it and syncs it to their phone. Any OpenAI-compatible API works; Ollama on this computer is http://localhost:11434 with no key.', req: ['base_url', 'model'], p: { name: 'Display name, e.g. "GPT-5 mini". Default: the model id.', base_url: 'OpenAI-compatible base URL, e.g. https://api.openai.com/v1, https://api.deepseek.com, https://openrouter.ai/api/v1.', model: 'The model id the provider expects, e.g. gpt-5-mini, deepseek-chat.', api_key: 'The API key the user gave (leave empty for a server with no key).' } },
|
|
41
42
|
slack_post_message: { d: 'Post a real Slack message now. Only when the user asked for it.', req: ['channel', 'text'], p: { channel: 'Channel name or id.', text: 'Message text.' } },
|
|
42
43
|
vercel_deploy: { d: 'Deploy a folder to the connected Vercel account (live, real).', req: [], p: { path: 'Folder to deploy. Default ".".' } },
|
|
43
44
|
supabase_create_project: { d: 'Create a new Supabase project (billable, real).', req: ['name'], p: { name: 'Project name.' } },
|
package/lib/telemetry.js
CHANGED
|
@@ -47,7 +47,10 @@ function push(ev) {
|
|
|
47
47
|
}
|
|
48
48
|
|
|
49
49
|
/** One model call: { model, provider, tokens_in, tokens_out, ms }. */
|
|
50
|
-
function recordAi(ev) {
|
|
50
|
+
function recordAi(ev) {
|
|
51
|
+
if (ev && ev.model === 'fake') return; // the engine tests' stand-in model is not real use
|
|
52
|
+
push({ kind: 'ai', ...ev });
|
|
53
|
+
}
|
|
51
54
|
|
|
52
55
|
/** The app was opened (counts active users even on a day with no AI call), once an hour at most. */
|
|
53
56
|
const lastOpen = new Map();
|
package/lib/tools.mjs
CHANGED
|
@@ -1335,13 +1335,23 @@ async function github_create_repo(args, ctx) {
|
|
|
1335
1335
|
// at least one commit - a brand-new GitHub repo (created with no
|
|
1336
1336
|
// auto_init below) starts completely empty, so pushing an empty local
|
|
1337
1337
|
// repo would just fail with "src refspec main does not match any".
|
|
1338
|
+
try { await runGit('--version', root); } catch {
|
|
1339
|
+
return { ok: false, output: 'Git is not installed on this computer, so the code can\'t be pushed. Tell the user to install it from git-scm.com (the defaults are fine), then ask again.' };
|
|
1340
|
+
}
|
|
1338
1341
|
const isRepo = fs.existsSync(path.join(root, '.git'));
|
|
1339
1342
|
if (!isRepo) await runGit('init', root);
|
|
1340
1343
|
let hasCommit = true;
|
|
1341
1344
|
try { await runGit('rev-parse HEAD', root); } catch { hasCommit = false; }
|
|
1342
1345
|
if (!hasCommit) {
|
|
1343
1346
|
await runGit('add -A', root);
|
|
1344
|
-
|
|
1347
|
+
// A computer where git was never told who you are would fail here
|
|
1348
|
+
// ("Please tell me who you are"): commit as the GitHub account, with
|
|
1349
|
+
// GitHub's private no-reply address. The git settings stay untouched.
|
|
1350
|
+
const login = String(github.userName || 'codeply').replace(/[^A-Za-z0-9-]/g, '') || 'codeply';
|
|
1351
|
+
let who = '';
|
|
1352
|
+
try { await runGit('config user.name', root); } catch { who += `-c user.name="${login}" `; }
|
|
1353
|
+
try { await runGit('config user.email', root); } catch { who += `-c user.email="${login}@users.noreply.github.com" `; }
|
|
1354
|
+
await runGit(`${who}commit -m "Initial commit"`, root);
|
|
1345
1355
|
}
|
|
1346
1356
|
|
|
1347
1357
|
const repo = await oauth.githubCreateRepo(github.accessToken, repoName, { private: true });
|
|
@@ -2237,6 +2247,48 @@ const publishTools = publishLib.makePublishTools({
|
|
|
2237
2247
|
resolveDir: (p, cwd) => resolvePath(String(p).trim(), cwd).abs,
|
|
2238
2248
|
});
|
|
2239
2249
|
|
|
2250
|
+
/**
|
|
2251
|
+
* add_model: adds a model to the user's model menu from chat ("add this model:
|
|
2252
|
+
* name, base URL, API key"). Asks first (key masked), checks the model really
|
|
2253
|
+
* answers, saves it on this computer and selects it. In Craft the desktop does
|
|
2254
|
+
* the saving (globalThis.__codeplyHost.addModel), so it also syncs to the
|
|
2255
|
+
* phone like any added model and the menu updates right away.
|
|
2256
|
+
*/
|
|
2257
|
+
async function add_model(args, ctx) {
|
|
2258
|
+
const name = String(args.name || '').trim();
|
|
2259
|
+
const baseUrl = String(args.base_url || args.baseUrl || '').trim().replace(/\/+$/, '');
|
|
2260
|
+
const model = String(args.model || '').trim();
|
|
2261
|
+
const apiKey = String(args.api_key || args.apiKey || '').trim();
|
|
2262
|
+
if (!model) return { ok: false, output: 'add_model needs <model>: the model id the provider expects (e.g. gpt-4o-mini, claude-sonnet-5-5, deepseek-chat).' };
|
|
2263
|
+
if (!/^https?:\/\//i.test(baseUrl)) return { ok: false, output: 'add_model needs <base_url> starting with http:// or https:// (e.g. https://api.openai.com/v1).' };
|
|
2264
|
+
const local = /^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/i.test(baseUrl) && /:11434$/.test(baseUrl);
|
|
2265
|
+
const shown = name || model;
|
|
2266
|
+
const masked = apiKey ? `${apiKey.slice(0, 4)}…${apiKey.slice(-4)}` : 'none';
|
|
2267
|
+
const verdict = readVerdict(await ctx.approve({
|
|
2268
|
+
tool: 'add_model',
|
|
2269
|
+
title: `Add "${shown}" to your models`,
|
|
2270
|
+
detail: `Model: ${model}\nAddress: ${baseUrl}\nKey: ${masked}\n\nIt's tested first, saved on this computer, and synced to your phone.`,
|
|
2271
|
+
danger: false,
|
|
2272
|
+
}));
|
|
2273
|
+
if (verdict.action === 'reject') return { ok: false, output: `User declined to add "${shown}".`, meta: { rejected: true } };
|
|
2274
|
+
const input = { kind: local ? 'ollama' : 'openai', name: shown, baseUrl, model, apiKey };
|
|
2275
|
+
try {
|
|
2276
|
+
const host = globalThis.__codeplyHost;
|
|
2277
|
+
let r;
|
|
2278
|
+
if (host && typeof host.addModel === 'function') r = await host.addModel(input);
|
|
2279
|
+
else {
|
|
2280
|
+
const t = await require('./ai.js').testModel(input);
|
|
2281
|
+
if (!t.ok) return { ok: false, output: `The model didn't answer, so it wasn't added: ${t.error}` };
|
|
2282
|
+
r = config.saveModel({ ...input, phone: true });
|
|
2283
|
+
if (r.ok) config.selectModel(r.model.id);
|
|
2284
|
+
}
|
|
2285
|
+
if (!r || !r.ok) return { ok: false, output: `Couldn't add it: ${(r && r.error) || 'unknown error'}` };
|
|
2286
|
+
return { ok: true, output: `Added "${shown}" (${model} at ${baseUrl}) and selected it. It answered a test request${local ? '' : ' and syncs to the phone'}. Never repeat the API key back to the user.`, meta: { label: shown } };
|
|
2287
|
+
} catch (e) {
|
|
2288
|
+
return { ok: false, output: `Couldn't add it: ${e.message}` };
|
|
2289
|
+
}
|
|
2290
|
+
}
|
|
2291
|
+
|
|
2240
2292
|
export const TOOLS = {
|
|
2241
2293
|
...publishTools,
|
|
2242
2294
|
todo,
|
|
@@ -2246,14 +2298,14 @@ export const TOOLS = {
|
|
|
2246
2298
|
web_fetch, web_search, image_search, apply_patch, plan_exit, plan_enter, lsp, reach_me, watch_email,
|
|
2247
2299
|
list_dir, read_file, write_file, edit_file, search, run, use_skill, list_skills, fetch_image, browser_check,
|
|
2248
2300
|
gmail_send, gmail_search, gmail_draft, drafts_list, calendar_list, calendar_add, slack_post_message, vercel_deploy, supabase_create_project, supabase_delete_project, github_create_repo,
|
|
2249
|
-
design_reference_search, view_images, supabase_api, supabase_sql, vercel_api,
|
|
2301
|
+
design_reference_search, view_images, supabase_api, supabase_sql, vercel_api, add_model,
|
|
2250
2302
|
};
|
|
2251
2303
|
|
|
2252
2304
|
// Tools that can change something. supabase_api/supabase_sql/vercel_api only
|
|
2253
2305
|
// actually prompt for non-read requests (see above); read-only tools
|
|
2254
2306
|
// (browser_check, gmail_search, design_reference_search, view_images, ...)
|
|
2255
2307
|
// never prompt.
|
|
2256
|
-
export const TOOL_NEEDS_APPROVAL = new Set(['supabase_setup', 'supabase_schema', 'publish_deploy', 'publish_github', 'write_file', 'edit_file', 'apply_patch', 'run', 'fetch_image', 'gmail_send', 'gmail_draft', 'calendar_add', 'slack_post_message', 'vercel_deploy', 'supabase_create_project', 'supabase_delete_project', 'github_create_repo', 'supabase_api', 'supabase_sql', 'vercel_api']);
|
|
2308
|
+
export const TOOL_NEEDS_APPROVAL = new Set(['add_model', 'supabase_setup', 'supabase_schema', 'publish_deploy', 'publish_github', 'write_file', 'edit_file', 'apply_patch', 'run', 'fetch_image', 'gmail_send', 'gmail_draft', 'calendar_add', 'slack_post_message', 'vercel_deploy', 'supabase_create_project', 'supabase_delete_project', 'github_create_repo', 'supabase_api', 'supabase_sql', 'vercel_api']);
|
|
2257
2309
|
|
|
2258
2310
|
/** Human-facing verb + colour hint for the transcript. */
|
|
2259
2311
|
export const TOOL_DISPLAY = {
|
|
@@ -2286,6 +2338,7 @@ export const TOOL_DISPLAY = {
|
|
|
2286
2338
|
drafts_list:{ verb: 'drafts', icon: '✉' },
|
|
2287
2339
|
calendar_list:{ verb: 'calendar', icon: '▦' },
|
|
2288
2340
|
calendar_add:{ verb: 'calendar', icon: '▦' },
|
|
2341
|
+
add_model:{ verb: 'model', icon: '◆' },
|
|
2289
2342
|
slack_post_message:{ verb: 'post', icon: '#' },
|
|
2290
2343
|
vercel_deploy:{ verb: 'deploy', icon: '▲' },
|
|
2291
2344
|
supabase_create_project:{ verb: 'provision', icon: '◆' },
|
|
@@ -2308,7 +2361,7 @@ export const TOOL_DISPLAY = {
|
|
|
2308
2361
|
// agent.mjs; this covers every other tool that changes something outside the
|
|
2309
2362
|
// conversation, so read-only can't be escaped through a side door.
|
|
2310
2363
|
const MUTATING_TOOLS = new Set([
|
|
2311
|
-
'write_file', 'edit_file', 'apply_patch', 'fetch_image', 'gmail_send', 'gmail_draft', 'calendar_add', 'slack_post_message',
|
|
2364
|
+
'write_file', 'edit_file', 'apply_patch', 'fetch_image', 'gmail_send', 'gmail_draft', 'calendar_add', 'slack_post_message', 'add_model',
|
|
2312
2365
|
'vercel_deploy', 'supabase_create_project', 'supabase_delete_project', 'github_create_repo',
|
|
2313
2366
|
'supabase_setup', 'supabase_schema', 'publish_deploy', 'publish_github',
|
|
2314
2367
|
]);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "codeply-cli",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.5",
|
|
4
4
|
"description": "Codeply from the terminal: an interactive AI coding agent, a local server (codeply serve), and a GitHub agent that runs in your own Actions runner.",
|
|
5
5
|
"main": "bin/codeply.js",
|
|
6
6
|
"bin": {
|