codeply-cli 0.4.1 → 0.5.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/lib/agent.mjs +12 -4
- package/lib/bots.js +590 -0
- package/lib/cloud-browser.mjs +143 -0
- package/lib/cloud.mjs +404 -95
- package/lib/native-tools.mjs +2 -1
- package/lib/oauth-connectors.js +51 -9
- package/lib/tools.mjs +40 -12
- package/package.json +1 -1
package/lib/native-tools.mjs
CHANGED
|
@@ -33,7 +33,7 @@ export const TOOL_DOCS = {
|
|
|
33
33
|
fetch_image: { d: 'Download an image into the project.', req: ['url', 'path'], p: { url: 'Image URL.', path: 'Where to save it, e.g. assets/hero.jpg.' } },
|
|
34
34
|
browser_check: { d: 'Open a page in a real browser and get errors plus a screenshot.', req: ['url'], p: { url: 'file:///absolute/path or http://localhost:port.', wait: 'Extra milliseconds to wait before capturing.', viewport: 'desktop (default), tablet, mobile, or WIDTHxHEIGHT.' } },
|
|
35
35
|
gmail_send: { d: 'Send a real email now (no drafts). Only when the user asked for it.', req: ['to', 'subject', 'body'], p: { to: 'Recipient address.', subject: 'Subject line.', body: 'Plain-text body.' } },
|
|
36
|
-
gmail_search: { d: 'Search the connected Gmail account.', req: [
|
|
36
|
+
gmail_search: { d: 'Search the connected Gmail account. Leave query empty to list the newest mail.', req: [], p: { query: 'Gmail search syntax, e.g. "is:unread" or "from:x@y.com". Empty = newest mail.' } },
|
|
37
37
|
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.' } },
|
|
38
38
|
vercel_deploy: { d: 'Deploy a folder to the connected Vercel account (live, real).', req: [], p: { path: 'Folder to deploy. Default ".".' } },
|
|
39
39
|
supabase_create_project: { d: 'Create a new Supabase project (billable, real).', req: ['name'], p: { name: 'Project name.' } },
|
|
@@ -50,6 +50,7 @@ export const TOOL_DOCS = {
|
|
|
50
50
|
lsp: { d: 'Code navigation for TypeScript/JavaScript: find the real definition or every reference of a symbol, hover for its type, outline a file, or search symbols.', req: ['operation'], p: { operation: 'definition, references, implementation, hover, documentSymbol or workspaceSymbol.', path: 'File the symbol is in (not needed for workspaceSymbol).', line: '1-based line of the symbol.', character: '1-based column of the symbol (or give symbol instead).', symbol: 'The name on that line, e.g. fetchUser.', query: 'For workspaceSymbol: the name to look for.' } },
|
|
51
51
|
plan_exit: { d: 'Plan mode only. Call after the plan file is written: asks the user whether to switch to Build mode and start implementing.', req: [], p: { path: 'The plan file, e.g. .codeply/plans/add-login.md. Default: the newest plan file.' } },
|
|
52
52
|
plan_enter: { d: 'Build mode only. Ask the user to switch to Plan mode first, for a large or ambiguous task. Use sparingly.', req: [], p: { reason: 'Why planning first would help, one sentence.' } },
|
|
53
|
+
ask_bot: { d: 'Hand one task to a teammate bot from the TEAM or BOTS list and wait for its result. Only one bot runs at a time.', req: ['bot', 'task'], p: { bot: 'The bot\'s name.', task: 'The one task, with all the context it needs (it sees nothing else).' } },
|
|
53
54
|
mcp: { d: 'Call a tool on a connected MCP server.', req: ['server', 'tool'], p: { server: 'Server name from MCP SERVERS.', tool: 'Tool name on that server.', args: 'JSON object of the tool\'s arguments.' } },
|
|
54
55
|
};
|
|
55
56
|
|
package/lib/oauth-connectors.js
CHANGED
|
@@ -55,8 +55,19 @@ async function refreshGmailToken(clientId, clientSecret, refreshToken) {
|
|
|
55
55
|
refresh_token: refreshToken, grant_type: 'refresh_token',
|
|
56
56
|
}),
|
|
57
57
|
});
|
|
58
|
-
const body = await res
|
|
59
|
-
if (!res.ok)
|
|
58
|
+
const body = await readJson(res);
|
|
59
|
+
if (!res.ok) {
|
|
60
|
+
// invalid_grant (Google's description is just "Bad Request") means the
|
|
61
|
+
// refresh token is dead: revoked, or expired because the OAuth app is
|
|
62
|
+
// still in Testing mode, where Google drops refresh tokens after 7 days.
|
|
63
|
+
// Only a fresh sign-in fixes it, so say that instead of "Bad Request".
|
|
64
|
+
if (body.error === 'invalid_grant') {
|
|
65
|
+
const e = new Error('The Gmail sign-in has expired or was revoked, so Craft disconnected it. Reconnect Gmail in Connect Apps (account menu → Connect Apps), then try again.');
|
|
66
|
+
e.code = 'invalid_grant';
|
|
67
|
+
throw e;
|
|
68
|
+
}
|
|
69
|
+
throw new Error(`Gmail token refresh failed (HTTP ${res.status}, ${body.error || 'error'}): ${body.error_description || 'no details from Google'}`);
|
|
70
|
+
}
|
|
60
71
|
return body; // { access_token, expires_in, ... } - no new refresh_token on refresh
|
|
61
72
|
}
|
|
62
73
|
|
|
@@ -64,11 +75,38 @@ async function getGmailProfile(accessToken) {
|
|
|
64
75
|
const res = await fetch('https://www.googleapis.com/gmail/v1/users/me/profile', {
|
|
65
76
|
headers: { Authorization: `Bearer ${accessToken}` },
|
|
66
77
|
});
|
|
67
|
-
const body = await res
|
|
68
|
-
if (!res.ok) throw
|
|
78
|
+
const body = await readJson(res);
|
|
79
|
+
if (!res.ok) throw gmailError(body, res.status, 'Gmail profile lookup');
|
|
69
80
|
return body.emailAddress;
|
|
70
81
|
}
|
|
71
82
|
|
|
83
|
+
/**
|
|
84
|
+
* Google's bare error.message is often just "Bad Request", which tells the
|
|
85
|
+
* agent (and the user) nothing. Pull the status, the machine reason and the
|
|
86
|
+
* per-error message so the real cause shows, with a plain-words hint for the
|
|
87
|
+
* ones that need the user to act.
|
|
88
|
+
*/
|
|
89
|
+
function gmailError(data, status, what) {
|
|
90
|
+
const err = (data && data.error) || {};
|
|
91
|
+
const first = Array.isArray(err.errors) && err.errors[0] ? err.errors[0] : {};
|
|
92
|
+
const reason = first.reason || err.status || '';
|
|
93
|
+
const detail = [err.message, first.message].filter((m, i, a) => m && a.indexOf(m) === i).join(': ');
|
|
94
|
+
let hint = '';
|
|
95
|
+
if (status === 401) hint = ' The Gmail sign-in expired or was revoked. Reconnect Gmail in Connect Apps.';
|
|
96
|
+
else if (status === 403 && /scope|insufficient/i.test(detail + reason)) hint = ' Gmail was connected without read access. Reconnect Gmail in Connect Apps and allow all requested permissions.';
|
|
97
|
+
else if (status === 403 && /accessNotConfigured|SERVICE_DISABLED|has not been used/i.test(detail + reason)) hint = ' The Gmail API is not enabled on the Google Cloud project behind this app.';
|
|
98
|
+
else if (/failedPrecondition|FAILED_PRECONDITION/i.test(reason)) hint = ' Google says this account has no usable Gmail mailbox for this app (Gmail turned off, or the account is not a test user of the app). Reconnect Gmail in Connect Apps with the right account.';
|
|
99
|
+
else if (status === 400) hint = ' Reconnecting Gmail in Connect Apps usually fixes this.';
|
|
100
|
+
const e = new Error(`${what} failed (HTTP ${status}${reason ? `, ${reason}` : ''}): ${detail || 'no details from Google'}.${hint}`);
|
|
101
|
+
e.status = status;
|
|
102
|
+
return e;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function readJson(res) {
|
|
106
|
+
const text = await res.text();
|
|
107
|
+
try { return JSON.parse(text); } catch { return { error: { message: text.slice(0, 200) } }; }
|
|
108
|
+
}
|
|
109
|
+
|
|
72
110
|
function base64UrlEncode(str) {
|
|
73
111
|
return Buffer.from(str, 'utf8').toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
74
112
|
}
|
|
@@ -80,18 +118,22 @@ async function gmailSend(accessToken, { to, subject, body }) {
|
|
|
80
118
|
headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json' },
|
|
81
119
|
body: JSON.stringify({ raw }),
|
|
82
120
|
});
|
|
83
|
-
const data = await res
|
|
84
|
-
if (!res.ok) throw
|
|
121
|
+
const data = await readJson(res);
|
|
122
|
+
if (!res.ok) throw gmailError(data, res.status, 'Gmail send');
|
|
85
123
|
return data; // { id, threadId, ... }
|
|
86
124
|
}
|
|
87
125
|
|
|
88
126
|
async function gmailSearch(accessToken, query, maxResults = 10) {
|
|
89
|
-
|
|
127
|
+
// "*" and "" mean "everything" to a person but are not Gmail syntax; an
|
|
128
|
+
// empty q simply lists the newest mail, which is what they meant.
|
|
129
|
+
const q = String(query || '').trim();
|
|
130
|
+
const params = new URLSearchParams({ maxResults: String(maxResults) });
|
|
131
|
+
if (q && q !== '*' && q !== '""' && q !== "''") params.set('q', q);
|
|
90
132
|
const res = await fetch(`https://www.googleapis.com/gmail/v1/users/me/messages?${params}`, {
|
|
91
133
|
headers: { Authorization: `Bearer ${accessToken}` },
|
|
92
134
|
});
|
|
93
|
-
const list = await res
|
|
94
|
-
if (!res.ok) throw
|
|
135
|
+
const list = await readJson(res);
|
|
136
|
+
if (!res.ok) throw gmailError(list, res.status, 'Gmail search');
|
|
95
137
|
if (!list.messages?.length) return [];
|
|
96
138
|
// The list endpoint only returns bare ids - each result needs its own
|
|
97
139
|
// fetch for subject/from/snippet, capped by maxResults so a broad query
|
package/lib/tools.mjs
CHANGED
|
@@ -695,12 +695,20 @@ async function fetch_image(args, ctx) {
|
|
|
695
695
|
* "expired 4 minutes ago" and "expires in 4 minutes" both need the same
|
|
696
696
|
* refresh anyway and there's no benefit to waiting for the failure first.
|
|
697
697
|
*/
|
|
698
|
-
async function getValidGmailToken() {
|
|
698
|
+
async function getValidGmailToken({ force = false } = {}) {
|
|
699
699
|
const gmail = config.getIntegration('gmail');
|
|
700
700
|
if (!gmail.accessToken) return null;
|
|
701
|
-
if (gmail.expiresAt && Date.now() < gmail.expiresAt - 60000) return gmail.accessToken;
|
|
701
|
+
if (!force && gmail.expiresAt && Date.now() < gmail.expiresAt - 60000) return gmail.accessToken;
|
|
702
702
|
if (!gmail.refreshToken) return gmail.accessToken; // nothing to refresh with - let the call itself fail if it's actually expired
|
|
703
|
-
|
|
703
|
+
let refreshed;
|
|
704
|
+
try {
|
|
705
|
+
refreshed = await oauth.refreshGmailToken(gmail.clientId, gmail.clientSecret, gmail.refreshToken);
|
|
706
|
+
} catch (e) {
|
|
707
|
+
// A dead refresh token never comes back: drop it so Connect Apps shows
|
|
708
|
+
// Gmail as disconnected instead of "connected" but failing every call.
|
|
709
|
+
if (e.code === 'invalid_grant') config.disconnectIntegration('gmail');
|
|
710
|
+
throw e;
|
|
711
|
+
}
|
|
704
712
|
config.saveIntegration('gmail', { accessToken: refreshed.access_token, expiresAt: Date.now() + (refreshed.expires_in || 3600) * 1000 });
|
|
705
713
|
return refreshed.access_token;
|
|
706
714
|
}
|
|
@@ -712,7 +720,8 @@ async function gmail_send(args, ctx) {
|
|
|
712
720
|
if (!to) return { ok: false, output: 'gmail_send needs a <to> address.' };
|
|
713
721
|
if (!subject) return { ok: false, output: 'gmail_send needs a <subject>.' };
|
|
714
722
|
|
|
715
|
-
|
|
723
|
+
let token;
|
|
724
|
+
try { token = await getValidGmailToken(); } catch (e) { return { ok: false, output: `Gmail send failed: ${e.message}` }; }
|
|
716
725
|
if (!token) return { ok: false, output: 'Gmail is not connected. Ask the user to connect it from Connect Apps (account menu → Connect Apps) first.' };
|
|
717
726
|
|
|
718
727
|
const verdict = await ctx.approve({
|
|
@@ -733,18 +742,26 @@ async function gmail_send(args, ctx) {
|
|
|
733
742
|
|
|
734
743
|
async function gmail_search(args, ctx) {
|
|
735
744
|
const query = (args.query || '').trim();
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
const token = await getValidGmailToken();
|
|
739
|
-
if (!token) return { ok: false, output: 'Gmail is not connected. Ask the user to connect it from Connect Apps (account menu → Connect Apps) first.' };
|
|
745
|
+
// An empty query is fine: it lists the newest mail ("check my emails").
|
|
746
|
+
const label = query || 'newest mail';
|
|
740
747
|
|
|
741
748
|
try {
|
|
742
|
-
|
|
743
|
-
if (!
|
|
749
|
+
let token = await getValidGmailToken();
|
|
750
|
+
if (!token) return { ok: false, output: 'Gmail is not connected. Ask the user to connect it from Connect Apps (account menu → Connect Apps) first.' };
|
|
751
|
+
let results;
|
|
752
|
+
try {
|
|
753
|
+
results = await oauth.gmailSearch(token, query);
|
|
754
|
+
} catch (e) {
|
|
755
|
+
// A token Google already dropped looks valid by our clock; refresh once and retry.
|
|
756
|
+
if (e.status !== 401) throw e;
|
|
757
|
+
token = await getValidGmailToken({ force: true });
|
|
758
|
+
results = await oauth.gmailSearch(token, query);
|
|
759
|
+
}
|
|
760
|
+
if (!results.length) return { ok: true, output: query ? `No messages matched "${query}".` : 'The mailbox is empty.', meta: { label } };
|
|
744
761
|
const lines = results.map((m) => `- ${m.subject || '(no subject)'} - from ${m.from} - ${m.date}\n ${m.snippet}`);
|
|
745
|
-
return { ok: true, output: `${results.length} message(s) matching "${query}":\n\n${lines.join('\n')}`, meta: { label
|
|
762
|
+
return { ok: true, output: `${results.length} message(s)${query ? ` matching "${query}"` : ', newest first'}:\n\n${lines.join('\n')}`, meta: { label, count: results.length } };
|
|
746
763
|
} catch (e) {
|
|
747
|
-
return { ok: false, output: `Gmail search failed: ${e.message}
|
|
764
|
+
return { ok: false, output: `Gmail search failed: ${e.message} Do not retry with other queries; tell the user this exact error.` };
|
|
748
765
|
}
|
|
749
766
|
}
|
|
750
767
|
|
|
@@ -1924,8 +1941,18 @@ async function plan_enter(args, ctx) {
|
|
|
1924
1941
|
return { ok: true, output: 'The user wants to keep building. Carry on in Build mode.', meta: { label: 'plan' } };
|
|
1925
1942
|
}
|
|
1926
1943
|
|
|
1944
|
+
// Hand one task to another bot (lib/bots.js). The host provides ctx.askBot,
|
|
1945
|
+
// which runs that bot to completion, one agent at a time, and returns its result.
|
|
1946
|
+
async function ask_bot(args, ctx) {
|
|
1947
|
+
if (typeof ctx.askBot !== 'function') {
|
|
1948
|
+
return { ok: false, output: 'ask_bot is not available here (no bots, or this run cannot delegate any further). Do the work yourself.' };
|
|
1949
|
+
}
|
|
1950
|
+
return ctx.askBot({ name: String(args.bot || '').trim(), task: String(args.task || '').trim(), signal: ctx.signal });
|
|
1951
|
+
}
|
|
1952
|
+
|
|
1927
1953
|
export const TOOLS = {
|
|
1928
1954
|
todo,
|
|
1955
|
+
ask_bot,
|
|
1929
1956
|
ask_user,
|
|
1930
1957
|
mcp,
|
|
1931
1958
|
web_fetch, web_search, apply_patch, plan_exit, plan_enter, lsp,
|
|
@@ -1944,6 +1971,7 @@ export const TOOL_NEEDS_APPROVAL = new Set(['write_file', 'edit_file', 'apply_pa
|
|
|
1944
1971
|
export const TOOL_DISPLAY = {
|
|
1945
1972
|
todo: { verb: 'plan', icon: '☐' },
|
|
1946
1973
|
ask_user: { verb: 'ask', icon: '?' },
|
|
1974
|
+
ask_bot: { verb: 'asked', icon: '◉' },
|
|
1947
1975
|
mcp: { verb: 'mcp', icon: '⧉' },
|
|
1948
1976
|
web_fetch: { verb: 'fetch', icon: '⇩' },
|
|
1949
1977
|
web_search: { verb: 'web', icon: '▸' },
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "codeply-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
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": {
|