codeply-cli 0.4.0 → 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.
@@ -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: ['query'], p: { query: 'Gmail search syntax.' } },
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
 
@@ -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.json();
59
- if (!res.ok) throw new Error(body.error_description || body.error || `Gmail token refresh failed (HTTP ${res.status})`);
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.json();
68
- if (!res.ok) throw new Error(body.error?.message || `Gmail profile lookup failed (HTTP ${res.status})`);
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.json();
84
- if (!res.ok) throw new Error(data.error?.message || `Gmail send failed (HTTP ${res.status})`);
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
- const params = new URLSearchParams({ q: query, maxResults: String(maxResults) });
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.json();
94
- if (!res.ok) throw new Error(list.error?.message || `Gmail search failed (HTTP ${res.status})`);
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
- const refreshed = await oauth.refreshGmailToken(gmail.clientId, gmail.clientSecret, gmail.refreshToken);
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
- const token = await getValidGmailToken();
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
- if (!query) return { ok: false, output: 'gmail_search needs a <query> (Gmail search syntax, e.g. "from:x@y.com is:unread").' };
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
- const results = await oauth.gmailSearch(token, query);
743
- if (!results.length) return { ok: true, output: `No messages matched "${query}".`, meta: { label: query } };
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: query, count: results.length } };
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.4.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": {