@yektoo/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/README.md ADDED
@@ -0,0 +1,361 @@
1
+ # Yekto CLI & MCP Server
2
+
3
+ Manage your [Yektoo](https://yektoo.com) customer support helpdesk from the terminal — or let AI agents do it for you.
4
+
5
+ ```
6
+ npm install -g @yektoo/cli
7
+ yektoo login
8
+ yektoo inbox --status open
9
+ ```
10
+
11
+ ---
12
+
13
+ ## What's Included
14
+
15
+ | Package | Purpose |
16
+ |---|---|
17
+ | **`@yektoo/cli`** | Terminal commands for managing your helpdesk |
18
+ | **`@yektoo/mcp-server`** | MCP server that gives AI agents access to your helpdesk |
19
+
20
+ Both packages share the same authentication — log in once with the CLI, and the MCP server uses the same session.
21
+
22
+ ---
23
+
24
+ ## Quick Start
25
+
26
+ ### 1. Install
27
+
28
+ ```bash
29
+ # Install globally
30
+ npm install -g @yektoo/cli
31
+
32
+ # Or run without installing
33
+ npx @yektoo/cli login
34
+ ```
35
+
36
+ ### 2. Log In
37
+
38
+ ```bash
39
+ yektoo login
40
+ # Enter your Yektooo email and password when prompted
41
+
42
+ # Or non-interactive (for CI/scripts)
43
+ yektoo login -e you@company.com -p yourpassword
44
+ ```
45
+
46
+ ### 3. Start Using
47
+
48
+ ```bash
49
+ yektoo inbox # List all conversations
50
+ yektoo inbox --status open # Open tickets only
51
+ yektoo read <email-id> # Read a conversation thread
52
+ yektoo reply <email-id> "Your order has shipped!"
53
+ yektoo close <email-id> # Close the ticket
54
+ ```
55
+
56
+ ---
57
+
58
+ ## CLI Commands
59
+
60
+ ### Authentication
61
+
62
+ | Command | Description |
63
+ |---|---|
64
+ | `yektoo login` | Log in with email and password (interactive) |
65
+ | `yektoo login -e EMAIL -p PASS` | Log in non-interactively |
66
+ | `yektoo logout` | Clear stored session |
67
+ | `yektoo whoami` | Show current user and business |
68
+
69
+ ### Accounts
70
+
71
+ | Command | Description |
72
+ |---|---|
73
+ | `yektoo email-accounts` | List connected IMAP / Outlook email accounts |
74
+ | `yektoo shopify-accounts` | List Shopify stores and their linked email accounts |
75
+
76
+ ### Inbox & Conversations
77
+
78
+ | Command | Description |
79
+ |---|---|
80
+ | `yektoo inbox` | List all conversations (newest first, 20 per page) |
81
+ | `yektoo inbox --status open` | Filter by status: `open`, `resolved`, `pending` |
82
+ | `yektoo inbox --store <id>` | Filter by email account |
83
+ | `yektoo inbox --search "refund"` | Search by subject or sender |
84
+ | `yektoo inbox --tag <id>` | Filter by tag (use `list-tags` to find IDs) |
85
+ | `yektoo inbox --tag <id1> <id2>` | Filter by multiple tags (OR logic) |
86
+ | `yektoo inbox --from 2026-03-01 --to 2026-03-31` | Filter by date range |
87
+ | `yektoo inbox --page 2` | Go to page 2 |
88
+ | `yektoo inbox --limit 50` | Show 50 results per page |
89
+ | `yektoo open-tickets --store <id>` | Open tickets for a specific email account |
90
+ | `yektoo read <email-id>` | Read full conversation thread (emails + replies + notes) |
91
+
92
+ ### Actions
93
+
94
+ | Command | Description |
95
+ |---|---|
96
+ | `yektoo reply <email-id> "message"` | Send a reply (auto-routes to IMAP or Outlook) |
97
+ | `yektoo reply-and-close <email-id> "message"` | Reply and close the ticket in one step |
98
+ | `yektoo close <email-id>` | Close/resolve a ticket (entire thread) |
99
+ | `yektoo open <email-id>` | Reopen a closed ticket |
100
+ | `yektoo note <email-id> "internal note"` | Add an internal note (not visible to customer) |
101
+
102
+ ### Customer & Shopify
103
+
104
+ | Command | Description |
105
+ |---|---|
106
+ | `yektoo customer <email>` | Look up a customer in Shopify — profile, orders, tracking |
107
+
108
+ ### Tags & Templates
109
+
110
+ | Command | Description |
111
+ |---|---|
112
+ | `yektoo list-tags` | List all email tags (AI Auto Tags + custom) |
113
+ | `yektoo list-templates` | List all reply templates |
114
+ | `yektoo get-template <id>` | View full template content and detected variables |
115
+
116
+ ### Reports
117
+
118
+ | Command | Description |
119
+ |---|---|
120
+ | `yektoo csat` | CSAT survey report (last 30 days) |
121
+ | `yektoo csat --days 90` | CSAT report for last 90 days |
122
+
123
+ ### Help
124
+
125
+ | Command | Description |
126
+ |---|---|
127
+ | `yektoo guide` | Detailed usage guide with examples |
128
+ | `yektoo --help` | List all commands |
129
+ | `yektoo <command> --help` | Help for a specific command |
130
+
131
+ ---
132
+
133
+ ## JSON Output
134
+
135
+ Every command supports `--json` for machine-readable output:
136
+
137
+ ```bash
138
+ # Pipe to jq
139
+ yektoo --json inbox --status open | jq '.[].id'
140
+
141
+ # Use in scripts
142
+ OPEN_COUNT=$(yektoo --json inbox --status open | jq 'length')
143
+ echo "You have $OPEN_COUNT open tickets"
144
+
145
+ # Get customer data as JSON
146
+ yektoo --json customer john@example.com
147
+ ```
148
+
149
+ ---
150
+
151
+ ## Examples
152
+
153
+ ### Daily workflow
154
+
155
+ ```bash
156
+ # Check open tickets
157
+ yektoo inbox --status open
158
+
159
+ # Read a ticket
160
+ yektoo read abc123-def456
161
+
162
+ # Look up the customer
163
+ yektoo customer jane@example.com
164
+
165
+ # Reply and close
166
+ yektoo reply-and-close abc123-def456 "Your refund has been processed. Have a great day!"
167
+ ```
168
+
169
+ ### Filter by tag
170
+
171
+ ```bash
172
+ # List available tags
173
+ yektoo list-tags
174
+
175
+ # Show all refund-related conversations
176
+ yektoo inbox --tag aeb5a381-fea0-444f-950e-e66b54559d42
177
+
178
+ # Combine filters
179
+ yektoo inbox --status open --tag <urgent-tag-id> --store <store-id>
180
+ ```
181
+
182
+ ### Use templates
183
+
184
+ ```bash
185
+ # Browse templates
186
+ yektoo list-templates
187
+
188
+ # View a template's content
189
+ yektoo get-template 3adcdc58-7df4-4480-b19a-8b7d6f881b8a
190
+
191
+ # Use the template content in a reply
192
+ yektoo reply <email-id> "$(yektoo --json get-template <template-id> | jq -r '.content')"
193
+ ```
194
+
195
+ ### Date range queries
196
+
197
+ ```bash
198
+ # Last week's conversations
199
+ yektoo inbox --from 2026-03-25 --to 2026-03-31
200
+
201
+ # Open tickets from this month
202
+ yektoo inbox --status open --from 2026-04-01
203
+ ```
204
+
205
+ ---
206
+
207
+ ## MCP Server for AI Agents
208
+
209
+ The MCP server lets AI agents (Claude, Cursor, Windsurf, etc.) interact with your helpdesk using natural language.
210
+
211
+ ### Setup
212
+
213
+ **Prerequisite:** Log in with the CLI first (one-time):
214
+
215
+ ```bash
216
+ npx @yektoo/cli login
217
+ ```
218
+
219
+ Then add the MCP server to your AI tool:
220
+
221
+ #### Claude Desktop
222
+
223
+ Edit `~/Library/Application Support/Claude/claude_desktop_config.json`:
224
+
225
+ ```json
226
+ {
227
+ "mcpServers": {
228
+ "yektoo": {
229
+ "command": "npx",
230
+ "args": ["-y", "@yektoo/mcp-server"]
231
+ }
232
+ }
233
+ }
234
+ ```
235
+
236
+ #### Cursor
237
+
238
+ Edit `.cursor/mcp.json` in your project root:
239
+
240
+ ```json
241
+ {
242
+ "mcpServers": {
243
+ "yektoo": {
244
+ "command": "npx",
245
+ "args": ["-y", "@yektoo/mcp-server"]
246
+ }
247
+ }
248
+ }
249
+ ```
250
+
251
+ #### Claude Code (pi)
252
+
253
+ Edit `.mcp.json` in your project root:
254
+
255
+ ```json
256
+ {
257
+ "mcpServers": {
258
+ "yektoo": {
259
+ "command": "npx",
260
+ "args": ["-y", "@yektoo/mcp-server"]
261
+ }
262
+ }
263
+ }
264
+ ```
265
+
266
+ #### Windsurf
267
+
268
+ Edit `~/.codeium/windsurf/mcp_config.json`:
269
+
270
+ ```json
271
+ {
272
+ "mcpServers": {
273
+ "yektoo": {
274
+ "command": "npx",
275
+ "args": ["-y", "@yektoo/mcp-server"]
276
+ }
277
+ }
278
+ }
279
+ ```
280
+
281
+ After adding the config, restart the AI tool. The Yektoo tools will be available immediately.
282
+
283
+ ### Available MCP Tools
284
+
285
+ | Tool | Description | Parameters |
286
+ |---|---|---|
287
+ | `list_email_accounts` | Connected IMAP/Outlook accounts | — |
288
+ | `list_shopify_accounts` | Shopify stores + linked email accounts | — |
289
+ | `list_conversations` | Threaded inbox with filters | `store_id?`, `status?`, `search?`, `date_from?`, `date_to?`, `tag_ids?`, `limit?`, `page?` |
290
+ | `list_open_tickets` | Open tickets for an email account | `store_id`, `search?`, `date_from?`, `date_to?`, `tag_ids?`, `limit?`, `page?` |
291
+ | `read_thread` | Full conversation timeline | `email_id` |
292
+ | `send_reply` | Send a reply to a customer | `email_id`, `content` |
293
+ | `reply_and_close` | Reply and close in one step | `email_id`, `content` |
294
+ | `close_ticket` | Resolve/close entire thread | `email_id` |
295
+ | `reopen_ticket` | Reopen a closed ticket | `email_id` |
296
+ | `add_note` | Add internal note (invisible to customer) | `email_id`, `content` |
297
+ | `lookup_customer` | Shopify customer lookup | `customer_email` |
298
+ | `list_tags` | Email tags (AI Auto Tags + custom) | — |
299
+ | `list_templates` | Reply templates | — |
300
+ | `get_template` | Full template content + variables | `template_id` |
301
+ | `get_csat_report` | CSAT survey report | `days?` |
302
+
303
+ ### What AI Agents Can Do
304
+
305
+ Once connected, you can ask your AI agent things like:
306
+
307
+ - *"Show me all open tickets"*
308
+ - *"Read the conversation with jane@example.com"*
309
+ - *"Reply to this customer saying their order shipped with tracking number 123456"*
310
+ - *"Close all resolved tickets tagged as 'refund'"*
311
+ - *"Look up this customer's Shopify orders"*
312
+ - *"What's our CSAT score this month?"*
313
+ - *"Use the 'shipping delay' template to reply to this ticket"*
314
+
315
+ ---
316
+
317
+ ## Troubleshooting
318
+
319
+ ### "Not logged in"
320
+
321
+ Run `yektoo login` to authenticate. Your session persists across terminal sessions.
322
+
323
+ ### "Session expired"
324
+
325
+ Run `yektoo login` again to refresh your tokens.
326
+
327
+ ### "No business found"
328
+
329
+ Your Yektoo account isn't associated with a business. Log in to [app.yektoo.com](https://app.yektoo.com) and complete the setup.
330
+
331
+ ### MCP server not showing tools
332
+
333
+ 1. Make sure you ran `npx @yektoo/cli login` first
334
+ 2. Restart your AI tool after editing the config
335
+ 3. Check that the config file path is correct for your OS
336
+
337
+ ### Reply not delivered
338
+
339
+ Replies are queued and processed asynchronously (same as the web app). Check your Yektooo inbox in a few seconds — the reply will appear in the thread once delivered.
340
+
341
+ ---
342
+
343
+ ## Development
344
+
345
+ ```bash
346
+ # CLI
347
+ cd cli
348
+ npm install
349
+ npm run dev -- inbox --status open # Run in dev mode
350
+
351
+ # MCP Server
352
+ cd mcp-server
353
+ npm install
354
+ npm run dev # Run in dev mode
355
+ ```
356
+
357
+ ---
358
+
359
+ ## License
360
+
361
+ MIT
@@ -0,0 +1 @@
1
+ #!/usr/bin/env node
package/dist/index.js ADDED
@@ -0,0 +1,733 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/index.ts
4
+ import { Command } from "commander";
5
+ import { createInterface } from "readline";
6
+
7
+ // src/client.ts
8
+ import { createClient } from "@supabase/supabase-js";
9
+ import Conf from "conf";
10
+ var SUPABASE_URL = "https://vjkofswgtffzyeuiainf.supabase.co";
11
+ var SUPABASE_ANON_KEY = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InZqa29mc3dndGZmenlldWlhaW5mIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NDgwMDA3MzYsImV4cCI6MjA2MzU3NjczNn0.7SqqkSgvxF5zXz8Bdq1BzdNN0Hv_H9vty2aZsaz_T5s";
12
+ var config = new Conf({
13
+ projectName: "yektoo-cli",
14
+ encryptionKey: "yektoo-cli-v1"
15
+ // conf encrypts values at rest with this key
16
+ });
17
+ var _client = null;
18
+ function getClient() {
19
+ if (_client) return _client;
20
+ const accessToken = config.get("accessToken");
21
+ const refreshToken = config.get("refreshToken");
22
+ if (!accessToken || !refreshToken) {
23
+ throw new Error("Not logged in. Run `yektoo login` first.");
24
+ }
25
+ _client = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, {
26
+ auth: {
27
+ autoRefreshToken: true,
28
+ persistSession: false
29
+ // We manage persistence ourselves via conf
30
+ }
31
+ });
32
+ _client.auth.setSession({ access_token: accessToken, refresh_token: refreshToken });
33
+ return _client;
34
+ }
35
+ async function login(email, password) {
36
+ const client = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, {
37
+ auth: { autoRefreshToken: false, persistSession: false }
38
+ });
39
+ const { data, error } = await client.auth.signInWithPassword({ email, password });
40
+ if (error) throw new Error(`Login failed: ${error.message}`);
41
+ if (!data.session) throw new Error("Login failed: no session returned");
42
+ const { data: profile } = await client.from("user_profiles").select("business_id").eq("user_id", data.user.id).single();
43
+ config.set("accessToken", data.session.access_token);
44
+ config.set("refreshToken", data.session.refresh_token);
45
+ config.set("userId", data.user.id);
46
+ config.set("email", data.user.email || email);
47
+ config.set("businessId", profile?.business_id || void 0);
48
+ _client = null;
49
+ return { userId: data.user.id, email: data.user.email || email };
50
+ }
51
+ function logout() {
52
+ config.clear();
53
+ _client = null;
54
+ }
55
+ function getStoredUser() {
56
+ const userId = config.get("userId");
57
+ const email = config.get("email");
58
+ if (!userId || !email) return null;
59
+ return { userId, email, businessId: config.get("businessId") };
60
+ }
61
+ function getSupabaseUrl() {
62
+ return SUPABASE_URL;
63
+ }
64
+ async function getAuthHeader() {
65
+ const client = getClient();
66
+ const { data } = await client.auth.getSession();
67
+ if (!data.session?.access_token) throw new Error("Session expired. Run `yektoo login` again.");
68
+ return {
69
+ "Authorization": `Bearer ${data.session.access_token}`,
70
+ "Content-Type": "application/json",
71
+ "apikey": SUPABASE_ANON_KEY
72
+ };
73
+ }
74
+
75
+ // src/format.ts
76
+ import chalk from "chalk";
77
+ var _format = "table";
78
+ function setOutputFormat(format) {
79
+ _format = format;
80
+ }
81
+ function getOutputFormat() {
82
+ return _format;
83
+ }
84
+ function printData(data, options) {
85
+ if (_format === "json") {
86
+ console.log(JSON.stringify(data, null, 2));
87
+ return;
88
+ }
89
+ if (options?.title) {
90
+ console.log(chalk.bold(`
91
+ ${options.title}`));
92
+ console.log(chalk.gray("\u2500".repeat(60)));
93
+ }
94
+ if (Array.isArray(data)) {
95
+ if (data.length === 0) {
96
+ console.log(chalk.gray(" No results."));
97
+ return;
98
+ }
99
+ console.table(data);
100
+ } else {
101
+ for (const [key, value] of Object.entries(data)) {
102
+ console.log(` ${chalk.gray(key + ":")} ${value}`);
103
+ }
104
+ }
105
+ console.log();
106
+ }
107
+ function printSuccess(message) {
108
+ if (_format === "json") {
109
+ console.log(JSON.stringify({ success: true, message }));
110
+ } else {
111
+ console.log(chalk.green("\u2713") + " " + message);
112
+ }
113
+ }
114
+ function printError(message) {
115
+ if (_format === "json") {
116
+ console.error(JSON.stringify({ success: false, error: message }));
117
+ } else {
118
+ console.error(chalk.red("\u2717") + " " + message);
119
+ }
120
+ }
121
+ function truncate(str, maxLen = 60) {
122
+ if (!str) return "";
123
+ const clean = str.replace(/<[^>]+>/g, "").replace(/\s+/g, " ").trim();
124
+ return clean.length > maxLen ? clean.slice(0, maxLen - 1) + "\u2026" : clean;
125
+ }
126
+ function formatDate(dateStr) {
127
+ if (!dateStr) return "";
128
+ const d = new Date(dateStr);
129
+ const now = /* @__PURE__ */ new Date();
130
+ const diff = now.getTime() - d.getTime();
131
+ const mins = Math.floor(diff / 6e4);
132
+ if (mins < 1) return "just now";
133
+ if (mins < 60) return `${mins}m ago`;
134
+ const hours = Math.floor(mins / 60);
135
+ if (hours < 24) return `${hours}h ago`;
136
+ const days = Math.floor(hours / 24);
137
+ if (days < 7) return `${days}d ago`;
138
+ return d.toISOString().slice(0, 10);
139
+ }
140
+
141
+ // src/index.ts
142
+ import chalk2 from "chalk";
143
+ var program = new Command();
144
+ program.name("yektoo").description("Yektoo CLI \u2014 manage your customer support helpdesk").version("0.1.0").option("--json", "Output as JSON (for piping and AI agents)").hook("preAction", (thisCommand) => {
145
+ if (thisCommand.opts().json) setOutputFormat("json");
146
+ });
147
+ program.command("login").description("Log in with your Yektoo account").option("-e, --email <email>", "Account email").option("-p, --password <password>", "Account password").action(async (opts) => {
148
+ try {
149
+ let email = opts.email;
150
+ let password = opts.password;
151
+ if (!email || !password) {
152
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
153
+ const ask = (q, hidden = false) => new Promise((resolve) => {
154
+ if (hidden) {
155
+ process.stderr.write(q);
156
+ let input = "";
157
+ process.stdin.setRawMode?.(true);
158
+ process.stdin.resume();
159
+ process.stdin.setEncoding("utf8");
160
+ const onData = (ch) => {
161
+ if (ch === "\n" || ch === "\r") {
162
+ process.stdin.setRawMode?.(false);
163
+ process.stdin.removeListener("data", onData);
164
+ process.stderr.write("\n");
165
+ resolve(input);
166
+ } else if (ch === "\x7F" || ch === "\b") {
167
+ input = input.slice(0, -1);
168
+ } else if (ch === "") {
169
+ process.exit(0);
170
+ } else {
171
+ input += ch;
172
+ }
173
+ };
174
+ process.stdin.on("data", onData);
175
+ } else {
176
+ rl.question(q, (answer) => resolve(answer));
177
+ }
178
+ });
179
+ if (!email) email = await ask("Email: ");
180
+ if (!password) password = await ask("Password: ", true);
181
+ rl.close();
182
+ }
183
+ const result = await login(email, password);
184
+ printSuccess(`Logged in as ${result.email}`);
185
+ } catch (err) {
186
+ printError(err.message);
187
+ process.exit(1);
188
+ }
189
+ });
190
+ program.command("logout").description("Log out and clear stored session").action(() => {
191
+ logout();
192
+ printSuccess("Logged out");
193
+ });
194
+ program.command("whoami").description("Show current authenticated user").action(() => {
195
+ const user = getStoredUser();
196
+ if (!user) {
197
+ printError("Not logged in. Run `yektoo login` first.");
198
+ process.exit(1);
199
+ }
200
+ printData({ email: user.email, userId: user.userId, businessId: user.businessId || "(not set)" });
201
+ });
202
+ program.command("email-accounts").description("List connected email accounts (IMAP and Outlook)").action(async () => {
203
+ try {
204
+ const client = getClient();
205
+ const user = getStoredUser();
206
+ if (!user?.businessId) throw new Error("No business found. Please log in again.");
207
+ const { data, error } = await client.from("stores").select("id, name, email, platform, connection_method, connected").eq("business_id", user.businessId).order("name");
208
+ if (error) throw new Error(error.message);
209
+ const rows = (data || []).map((s) => ({
210
+ id: s.id,
211
+ name: s.name || "(unnamed)",
212
+ email: s.email,
213
+ type: s.connection_method === "imap" ? "IMAP" : s.platform === "imap" ? "IMAP" : "Outlook",
214
+ connected: s.connected ? "\u2713" : "\u2717"
215
+ }));
216
+ printData(rows, { title: "Email Accounts" });
217
+ } catch (err) {
218
+ printError(err.message);
219
+ process.exit(1);
220
+ }
221
+ });
222
+ program.command("shopify-accounts").description("List connected Shopify stores and their linked email accounts").action(async () => {
223
+ try {
224
+ const client = getClient();
225
+ const user = getStoredUser();
226
+ if (!user?.businessId) throw new Error("No business found. Please log in again.");
227
+ const { data: shopifyStores, error } = await client.from("shopify_stores").select(`
228
+ id,
229
+ shop_domain,
230
+ created_at,
231
+ associated_email_store_id,
232
+ store:store_id ( id, name, connected )
233
+ `).order("created_at", { ascending: false });
234
+ if (error) throw new Error(error.message);
235
+ const rows = await Promise.all(
236
+ (shopifyStores || []).map(async (ss) => {
237
+ let linkedEmail = "(none)";
238
+ if (ss.associated_email_store_id) {
239
+ const { data: emailStore } = await client.from("stores").select("name, email").eq("id", ss.associated_email_store_id).single();
240
+ if (emailStore) {
241
+ linkedEmail = `${emailStore.name || ""} (${emailStore.email})`.trim();
242
+ }
243
+ }
244
+ return {
245
+ id: ss.id,
246
+ domain: ss.shop_domain,
247
+ name: ss.store?.name || "(unnamed)",
248
+ connected: ss.store?.connected ? "\u2713" : "\u2717",
249
+ linkedEmailAccount: linkedEmail
250
+ };
251
+ })
252
+ );
253
+ printData(rows, { title: "Shopify Accounts" });
254
+ } catch (err) {
255
+ printError(err.message);
256
+ process.exit(1);
257
+ }
258
+ });
259
+ program.command("inbox").description("List conversations (threaded email view)").option("-s, --store <storeId>", "Filter by store ID").option("--status <status>", "Filter by status: open, resolved, pending", "all").option("-n, --limit <number>", "Number of results per page", "20").option("-p, --page <number>", "Page number", "1").option("--search <query>", "Search by subject or sender").option("--from <date>", "Filter from date (e.g. 2026-03-01)").option("--to <date>", "Filter to date (e.g. 2026-03-31)").option("--tag <tagIds...>", "Filter by tag ID(s) \u2014 use list-tags to find IDs").action(async (opts) => {
260
+ try {
261
+ const client = getClient();
262
+ const user = getStoredUser();
263
+ if (!user?.businessId) throw new Error("No business found.");
264
+ const page = parseInt(opts.page) || 1;
265
+ const limit = parseInt(opts.limit) || 20;
266
+ const tags = opts.tag && opts.tag.length > 0 ? opts.tag : null;
267
+ const { data, error } = await client.rpc("get_threaded_emails_v5", {
268
+ p_user_id: user.userId,
269
+ p_store_filter: opts.store || "all",
270
+ p_status: opts.status || "all",
271
+ p_search: opts.search || "",
272
+ p_date_from: opts.from || null,
273
+ p_date_to: opts.to || null,
274
+ p_page: page,
275
+ p_page_size: limit,
276
+ p_sort_by: "date",
277
+ p_sort_order: "desc",
278
+ p_assigned_to: "all",
279
+ p_read_status: "all",
280
+ p_priority: null,
281
+ p_tags: tags,
282
+ p_folder_source: "inbox"
283
+ });
284
+ if (error) throw new Error(error.message);
285
+ const rows = (data || []).map((r) => ({
286
+ id: r.latest_email_id,
287
+ from: truncate(r.latest_email_from, 30),
288
+ subject: truncate(r.thread_subject || r.latest_email_subject, 45),
289
+ status: r.latest_email_status,
290
+ date: formatDate(r.latest_email_date),
291
+ msgs: r.thread_email_count,
292
+ unread: r.thread_has_unread ? "\u25CF" : ""
293
+ }));
294
+ const total = (data || []).length > 0 ? data[0].total_threads : 0;
295
+ const totalPages = Math.ceil(total / limit);
296
+ printData(rows, { title: `Inbox (page ${page}/${totalPages}, ${total} total)` });
297
+ if (page < totalPages) {
298
+ console.log(chalk2.gray(` \u2192 Next page: yektoo inbox --page ${page + 1}${opts.store ? " --store " + opts.store : ""}${opts.status !== "all" ? " --status " + opts.status : ""}`));
299
+ console.log();
300
+ }
301
+ } catch (err) {
302
+ printError(err.message);
303
+ process.exit(1);
304
+ }
305
+ });
306
+ program.command("open-tickets").description("List open tickets for a specific email account").option("-s, --store <storeId>", "Filter by email account ID (required)").option("-n, --limit <number>", "Number of results per page", "20").option("-p, --page <number>", "Page number", "1").option("--search <query>", "Search by subject or sender").option("--from <date>", "Filter from date (e.g. 2026-03-01)").option("--to <date>", "Filter to date (e.g. 2026-03-31)").option("--tag <tagIds...>", "Filter by tag ID(s) \u2014 use list-tags to find IDs").action(async (opts) => {
307
+ try {
308
+ const client = getClient();
309
+ const user = getStoredUser();
310
+ if (!user?.businessId) throw new Error("No business found.");
311
+ if (!opts.store) {
312
+ const { data: stores } = await client.from("stores").select("id, name, email").eq("business_id", user.businessId).eq("connected", true).order("name");
313
+ printError("Please specify an email account with --store <id>");
314
+ if (stores?.length) {
315
+ printData(
316
+ stores.map((s) => ({ id: s.id, name: s.name, email: s.email })),
317
+ { title: "Available email accounts" }
318
+ );
319
+ }
320
+ process.exit(1);
321
+ }
322
+ const tags = opts.tag && opts.tag.length > 0 ? opts.tag : null;
323
+ const { data, error } = await client.rpc("get_threaded_emails_v5", {
324
+ p_user_id: user.userId,
325
+ p_store_filter: opts.store,
326
+ p_status: "open",
327
+ p_search: opts.search || "",
328
+ p_date_from: opts.from || null,
329
+ p_date_to: opts.to || null,
330
+ p_page: parseInt(opts.page) || 1,
331
+ p_page_size: parseInt(opts.limit) || 20,
332
+ p_sort_by: "date",
333
+ p_sort_order: "desc",
334
+ p_assigned_to: "all",
335
+ p_read_status: "all",
336
+ p_priority: null,
337
+ p_tags: tags,
338
+ p_folder_source: "inbox"
339
+ });
340
+ if (error) throw new Error(error.message);
341
+ const rows = (data || []).map((r) => ({
342
+ id: r.latest_email_id,
343
+ from: truncate(r.latest_email_from, 30),
344
+ subject: truncate(r.thread_subject || r.latest_email_subject, 45),
345
+ date: formatDate(r.latest_email_date),
346
+ msgs: r.thread_email_count,
347
+ unread: r.thread_has_unread ? "\u25CF" : ""
348
+ }));
349
+ const page = parseInt(opts.page) || 1;
350
+ const limit = parseInt(opts.limit) || 20;
351
+ const total = (data || []).length > 0 ? data[0].total_threads : 0;
352
+ const totalPages = Math.ceil(total / limit);
353
+ printData(rows, { title: `Open Tickets (page ${page}/${totalPages}, ${total} total)` });
354
+ if (page < totalPages) {
355
+ console.log(chalk2.gray(` \u2192 Next page: yektoo open-tickets --store ${opts.store} --page ${page + 1}`));
356
+ console.log();
357
+ }
358
+ } catch (err) {
359
+ printError(err.message);
360
+ process.exit(1);
361
+ }
362
+ });
363
+ program.command("read <emailId>").description("Read a conversation thread (emails + replies + notes)").action(async (emailId) => {
364
+ try {
365
+ const client = getClient();
366
+ const user = getStoredUser();
367
+ if (!user) throw new Error("Not logged in.");
368
+ const { data: emailData, error: emailErr } = await client.from("emails").select("id, thread_id, subject, from, recipient, status, date").eq("id", emailId).single();
369
+ if (emailErr || !emailData) throw new Error("Email not found");
370
+ const { data, error } = await client.rpc("get_email_thread_complete", {
371
+ p_email_id: emailId,
372
+ p_user_id: user.userId,
373
+ p_thread_id: emailData.thread_id
374
+ });
375
+ if (error) throw new Error(error.message);
376
+ if (!data || data.length === 0) throw new Error("Thread not found");
377
+ const result = data[0];
378
+ const threadEmails = result.thread_emails || [];
379
+ const replies = result.replies || [];
380
+ const notes = result.notes || [];
381
+ const timeline = [
382
+ ...threadEmails.map((e) => ({
383
+ type: "email",
384
+ from: e.from,
385
+ date: e.date,
386
+ content: truncate(e.content, 200),
387
+ direction: e.direction
388
+ })),
389
+ ...replies.map((r) => ({
390
+ type: "reply",
391
+ from: r.from || "(agent)",
392
+ date: r.sent_at,
393
+ content: truncate(r.content, 200),
394
+ direction: "outbound"
395
+ })),
396
+ ...notes.map((n) => ({
397
+ type: "note",
398
+ from: "(internal)",
399
+ date: n.created_at,
400
+ content: truncate(n.content, 200),
401
+ direction: "internal"
402
+ }))
403
+ ].sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime());
404
+ printData(
405
+ {
406
+ id: emailData.id,
407
+ subject: emailData.subject,
408
+ status: emailData.status,
409
+ from: emailData.from,
410
+ date: emailData.date,
411
+ messages: timeline.length
412
+ },
413
+ { title: "Thread" }
414
+ );
415
+ printData(timeline, { title: "Timeline" });
416
+ } catch (err) {
417
+ printError(err.message);
418
+ process.exit(1);
419
+ }
420
+ });
421
+ program.command("reply <emailId> <content>").description("Send a reply to a conversation").action(async (emailId, content) => {
422
+ try {
423
+ const client = getClient();
424
+ const user = getStoredUser();
425
+ if (!user) throw new Error("Not logged in.");
426
+ const { data: email, error: emailErr } = await client.from("emails").select("id, store_id, stores(id, platform, connection_method, business_id, email)").eq("id", emailId).single();
427
+ if (emailErr || !email) throw new Error("Email not found");
428
+ const store = email.stores;
429
+ if (!store) throw new Error("Store not found for this email");
430
+ if (store.business_id !== user.businessId) throw new Error("Access denied: email does not belong to your business");
431
+ const isImap = store.platform === "imap" || store.connection_method === "imap";
432
+ const functionType = isImap ? "send-email-imap" : "send-email";
433
+ const { error: queueError } = await client.from("email_send_queue").insert({
434
+ business_id: store.business_id,
435
+ store_id: store.id,
436
+ user_id: user.userId,
437
+ function_type: functionType,
438
+ payload: {
439
+ emailId,
440
+ content,
441
+ attachments: [],
442
+ isScheduledSend: true,
443
+ originalUserId: user.userId
444
+ }
445
+ });
446
+ if (queueError) throw new Error(`Failed to queue reply: ${queueError.message}`);
447
+ printSuccess(`Reply queued for delivery via ${functionType}`);
448
+ } catch (err) {
449
+ printError(err.message);
450
+ process.exit(1);
451
+ }
452
+ });
453
+ program.command("close <emailId>").description("Resolve/close a ticket (closes entire thread)").action(async (emailId) => {
454
+ try {
455
+ const client = getClient();
456
+ const { data, error } = await client.rpc("mark_thread_resolved", {
457
+ p_email_id: emailId
458
+ });
459
+ if (error) throw new Error(error.message);
460
+ const result = data?.[0];
461
+ if (!result || result.updated_count === 0) throw new Error("Email not found or already resolved");
462
+ printSuccess(`Closed thread: ${result.updated_count} email(s) marked as resolved`);
463
+ } catch (err) {
464
+ printError(err.message);
465
+ process.exit(1);
466
+ }
467
+ });
468
+ program.command("open <emailId>").description("Reopen a ticket").action(async (emailId) => {
469
+ try {
470
+ const client = getClient();
471
+ const { error } = await client.from("emails").update({ status: "open" }).eq("id", emailId);
472
+ if (error) throw new Error(error.message);
473
+ printSuccess("Ticket reopened");
474
+ } catch (err) {
475
+ printError(err.message);
476
+ process.exit(1);
477
+ }
478
+ });
479
+ program.command("reply-and-close <emailId> <content>").description("Send a reply and close the ticket in one step").action(async (emailId, content) => {
480
+ try {
481
+ const client = getClient();
482
+ const user = getStoredUser();
483
+ if (!user) throw new Error("Not logged in.");
484
+ const { data: email, error: emailErr } = await client.from("emails").select("id, store_id, stores(id, platform, connection_method, business_id, email)").eq("id", emailId).single();
485
+ if (emailErr || !email) throw new Error("Email not found");
486
+ const store = email.stores;
487
+ if (!store) throw new Error("Store not found for this email");
488
+ if (store.business_id !== user.businessId) throw new Error("Access denied: email does not belong to your business");
489
+ const isImap = store.platform === "imap" || store.connection_method === "imap";
490
+ const functionType = isImap ? "send-email-imap" : "send-email";
491
+ const { error: queueError } = await client.from("email_send_queue").insert({
492
+ business_id: store.business_id,
493
+ store_id: store.id,
494
+ user_id: user.userId,
495
+ function_type: functionType,
496
+ payload: {
497
+ emailId,
498
+ content,
499
+ attachments: [],
500
+ isScheduledSend: true,
501
+ originalUserId: user.userId
502
+ }
503
+ });
504
+ if (queueError) throw new Error(`Failed to queue reply: ${queueError.message}`);
505
+ const { data: closeData, error: closeError } = await client.rpc("mark_thread_resolved", {
506
+ p_email_id: emailId
507
+ });
508
+ if (closeError) throw new Error(`Reply queued but close failed: ${closeError.message}`);
509
+ const result = closeData?.[0];
510
+ printSuccess(`Reply queued via ${functionType} and ticket closed (${result?.updated_count || 1} email(s) resolved)`);
511
+ } catch (err) {
512
+ printError(err.message);
513
+ process.exit(1);
514
+ }
515
+ });
516
+ program.command("note <emailId> <content>").description("Add an internal note to a conversation").action(async (emailId, content) => {
517
+ try {
518
+ const client = getClient();
519
+ const user = getStoredUser();
520
+ if (!user) throw new Error("Not logged in.");
521
+ const { error } = await client.from("internal_notes").insert({
522
+ email_id: emailId,
523
+ user_id: user.userId,
524
+ content
525
+ });
526
+ if (error) throw new Error(error.message);
527
+ printSuccess("Internal note added");
528
+ } catch (err) {
529
+ printError(err.message);
530
+ process.exit(1);
531
+ }
532
+ });
533
+ program.command("list-tags").description("List all email tags (AI Auto Tags and custom tags)").action(async () => {
534
+ try {
535
+ const client = getClient();
536
+ const { data, error } = await client.from("email_tags").select("id, name, color, description, tag_type, created_at").order("name");
537
+ if (error) throw new Error(error.message);
538
+ const rows = (data || []).map((t) => ({
539
+ id: t.id,
540
+ name: t.name,
541
+ type: t.tag_type || "custom",
542
+ color: t.color,
543
+ description: t.description || ""
544
+ }));
545
+ printData(rows, { title: `Tags (${rows.length})` });
546
+ } catch (err) {
547
+ printError(err.message);
548
+ process.exit(1);
549
+ }
550
+ });
551
+ program.command("list-templates").description("List all reply templates").action(async () => {
552
+ try {
553
+ const client = getClient();
554
+ const { data, error } = await client.from("reply_templates").select("id, name, content, created_at").order("created_at", { ascending: false });
555
+ if (error) throw new Error(error.message);
556
+ const rows = (data || []).map((t) => ({
557
+ id: t.id,
558
+ name: t.name,
559
+ preview: truncate(t.content, 60),
560
+ created: formatDate(t.created_at)
561
+ }));
562
+ printData(rows, { title: `Reply Templates (${rows.length})` });
563
+ } catch (err) {
564
+ printError(err.message);
565
+ process.exit(1);
566
+ }
567
+ });
568
+ program.command("get-template <templateId>").description("Get the full content of a reply template").action(async (templateId) => {
569
+ try {
570
+ const client = getClient();
571
+ const { data, error } = await client.from("reply_templates").select("id, name, content, created_at").eq("id", templateId).single();
572
+ if (error || !data) throw new Error("Template not found");
573
+ const plainContent = data.content.replace(/<br\s*\/?>/gi, "\n").replace(/<\/p>/gi, "\n").replace(/<[^>]+>/g, "").replace(/&nbsp;/g, " ").replace(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/\n{3,}/g, "\n\n").trim();
574
+ printData({
575
+ id: data.id,
576
+ name: data.name,
577
+ created: formatDate(data.created_at),
578
+ variables: (data.content.match(/\{\{[a-z_]+\}\}/gi) || []).join(", ") || "(none)"
579
+ }, { title: "Template" });
580
+ if (getOutputFormat() === "json") {
581
+ printData({ content: plainContent, html: data.content });
582
+ } else {
583
+ console.log(chalk2.gray("\u2500".repeat(60)));
584
+ console.log(plainContent);
585
+ console.log(chalk2.gray("\u2500".repeat(60)));
586
+ console.log();
587
+ }
588
+ } catch (err) {
589
+ printError(err.message);
590
+ process.exit(1);
591
+ }
592
+ });
593
+ program.command("customer <email>").description("Look up a customer in Shopify (orders, spend, tracking)").action(async (customerEmail) => {
594
+ try {
595
+ const headers = await getAuthHeader();
596
+ const url = `${getSupabaseUrl()}/functions/v1/shopify-lookup?email=${encodeURIComponent(customerEmail)}`;
597
+ const response = await fetch(url, { headers });
598
+ if (!response.ok) {
599
+ const err = await response.json().catch(() => ({}));
600
+ throw new Error(err.error || `HTTP ${response.status}`);
601
+ }
602
+ const data = await response.json();
603
+ if (!data.found || !data.stores?.length) {
604
+ printError(`No Shopify customer found for ${customerEmail}`);
605
+ process.exit(0);
606
+ }
607
+ for (const entry of data.stores) {
608
+ printData({
609
+ store: entry.store?.name || "(unknown)",
610
+ name: `${entry.customer?.firstName || ""} ${entry.customer?.lastName || ""}`.trim(),
611
+ email: entry.customer?.email,
612
+ phone: entry.customer?.phone || "(none)",
613
+ orders: entry.customer?.ordersCount,
614
+ totalSpent: entry.customer?.totalSpent
615
+ }, { title: `Customer \u2014 ${entry.store?.name || "Shopify"}` });
616
+ if (entry.orders?.length) {
617
+ const orderRows = entry.orders.slice(0, 5).map((o) => ({
618
+ order: `#${o.number}`,
619
+ date: formatDate(o.date),
620
+ total: o.totalPrice,
621
+ status: o.fulfillmentStatus || "unfulfilled",
622
+ tracking: o.tracking?.number || ""
623
+ }));
624
+ printData(orderRows, { title: "Recent Orders" });
625
+ }
626
+ }
627
+ } catch (err) {
628
+ printError(err.message);
629
+ process.exit(1);
630
+ }
631
+ });
632
+ program.command("csat").description("View CSAT survey report").option("-d, --days <number>", "Number of days to report on", "30").action(async (opts) => {
633
+ try {
634
+ const client = getClient();
635
+ const user = getStoredUser();
636
+ if (!user?.businessId) throw new Error("No business found.");
637
+ const { data, error } = await client.rpc("get_csat_report", {
638
+ p_business_id: user.businessId,
639
+ p_days: parseInt(opts.days) || 30
640
+ });
641
+ if (error) throw new Error(error.message);
642
+ if (!data) throw new Error("No CSAT data available");
643
+ const summary = data.summary;
644
+ printData({
645
+ avgRating: summary.avg_rating ? `${summary.avg_rating} / 5` : "\u2014",
646
+ responseRate: summary.response_rate != null ? `${summary.response_rate}%` : "\u2014",
647
+ sent: summary.total_sent,
648
+ responded: summary.total_responded,
649
+ distribution: `\u26051:${summary.distribution["1"]} \u26052:${summary.distribution["2"]} \u26053:${summary.distribution["3"]} \u26054:${summary.distribution["4"]} \u26055:${summary.distribution["5"]}`
650
+ }, { title: `CSAT Report (last ${opts.days} days)` });
651
+ if (data.by_agent?.length) {
652
+ const agentRows = data.by_agent.map((a) => ({
653
+ agent: a.agent_name,
654
+ avgRating: a.avg_rating ? a.avg_rating.toFixed(1) : "\u2014",
655
+ responded: `${a.responded}/${a.total_sent}`
656
+ }));
657
+ printData(agentRows, { title: "By Agent" });
658
+ }
659
+ if (data.recent?.length) {
660
+ const recentRows = data.recent.slice(0, 10).map((r) => ({
661
+ customer: truncate(r.customer_email, 30),
662
+ rating: "\u2605".repeat(r.rating),
663
+ comment: truncate(r.comment, 40) || "\u2014",
664
+ date: formatDate(r.responded_at)
665
+ }));
666
+ printData(recentRows, { title: "Recent Responses" });
667
+ }
668
+ } catch (err) {
669
+ printError(err.message);
670
+ process.exit(1);
671
+ }
672
+ });
673
+ program.command("guide").description("Show detailed usage guide with examples").action(() => {
674
+ const guide = `
675
+ ${chalk2.bold("Yektoo CLI \u2014 Customer Support Helpdesk")}
676
+ ${chalk2.gray("Manage your inbox, tickets, and customers from the terminal.")}
677
+
678
+ ${chalk2.bold.underline("Authentication")}
679
+ ${chalk2.cyan("yektoo login")} Log in with email and password
680
+ ${chalk2.cyan("yektoo login -e you@co.com -p pass")} Non-interactive login
681
+ ${chalk2.cyan("yektoo logout")} Clear stored session
682
+ ${chalk2.cyan("yektoo whoami")} Show current user and business
683
+
684
+ ${chalk2.bold.underline("Accounts")}
685
+ ${chalk2.cyan("yektoo email-accounts")} List connected IMAP / Outlook accounts
686
+ ${chalk2.cyan("yektoo shopify-accounts")} List Shopify stores and linked email accounts
687
+
688
+ ${chalk2.bold.underline("Inbox & Conversations")}
689
+ ${chalk2.cyan("yektoo inbox")} List all conversations (newest first)
690
+ ${chalk2.cyan("yektoo inbox --status open")} Only open tickets
691
+ ${chalk2.cyan("yektoo inbox --store <id> --limit 10")} Filter by store, limit results
692
+ ${chalk2.cyan('yektoo inbox --search "refund"')} Search by subject or sender
693
+ ${chalk2.cyan("yektoo open-tickets --store <id>")} List open tickets for a specific email account
694
+ ${chalk2.cyan("yektoo read <email-id>")} Read full thread (emails + replies + notes)
695
+
696
+ ${chalk2.bold.underline("Actions")}
697
+ ${chalk2.cyan('yektoo reply <email-id> "message"')} Send a reply (auto-routes IMAP vs Outlook)
698
+ ${chalk2.cyan('yektoo reply-and-close <email-id> "msg"')} Reply and close the ticket in one step
699
+ ${chalk2.cyan("yektoo close <email-id>")} Close/resolve a ticket (entire thread)
700
+ ${chalk2.cyan("yektoo open <email-id>")} Reopen a closed ticket
701
+ ${chalk2.cyan('yektoo note <email-id> "internal note"')} Add an internal note (not visible to customer)
702
+
703
+ ${chalk2.bold.underline("Customer & Shopify")}
704
+ ${chalk2.cyan("yektoo customer john@example.com")} Look up Shopify customer, orders, tracking
705
+
706
+ ${chalk2.bold.underline("Templates")}
707
+ ${chalk2.cyan("yektoo list-templates")} List all reply templates
708
+ ${chalk2.cyan("yektoo get-template <id>")} View full template content and variables
709
+
710
+ ${chalk2.bold.underline("Reports")}
711
+ ${chalk2.cyan("yektoo csat")} CSAT survey report (last 30 days)
712
+ ${chalk2.cyan("yektoo csat --days 90")} CSAT report for last 90 days
713
+
714
+ ${chalk2.bold.underline("Options")}
715
+ ${chalk2.cyan("--json")} Output as JSON (for piping to other tools / AI agents)
716
+ ${chalk2.cyan("--help")} Show help for any command
717
+
718
+ ${chalk2.bold.underline("Examples")}
719
+ ${chalk2.gray("# View open tickets, then read and reply to one")}
720
+ ${chalk2.cyan("yektoo inbox --status open")}
721
+ ${chalk2.cyan("yektoo read abc123-def456")}
722
+ ${chalk2.cyan('yektoo reply abc123-def456 "Your order has shipped!"')}
723
+
724
+ ${chalk2.gray("# Look up a customer and close their ticket")}
725
+ ${chalk2.cyan("yektoo customer jane@example.com")}
726
+ ${chalk2.cyan('yektoo reply-and-close abc123 "Issue resolved. Your refund is on the way."')}
727
+
728
+ ${chalk2.gray("# Pipe JSON to jq for scripting")}
729
+ ${chalk2.cyan('yektoo --json inbox --status open | jq ".[].id"')}
730
+ `;
731
+ console.log(guide);
732
+ });
733
+ program.parse();
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "@yektoo/cli",
3
+ "version": "0.1.0",
4
+ "description": "Yekto CLI — manage your customer support helpdesk from the terminal",
5
+ "type": "module",
6
+ "bin": {
7
+ "yektoo": "dist/index.js"
8
+ },
9
+ "files": [
10
+ "dist"
11
+ ],
12
+ "scripts": {
13
+ "build": "tsup src/index.ts --format esm --dts --clean",
14
+ "dev": "tsx src/index.ts",
15
+ "lint": "tsc --noEmit"
16
+ },
17
+ "dependencies": {
18
+ "@supabase/supabase-js": "^2.49.0",
19
+ "commander": "^13.0.0",
20
+ "chalk": "^5.4.0",
21
+ "conf": "^13.0.0"
22
+ },
23
+ "devDependencies": {
24
+ "@types/node": "^22.0.0",
25
+ "tsup": "^8.0.0",
26
+ "tsx": "^4.0.0",
27
+ "typescript": "^5.7.0"
28
+ },
29
+ "engines": {
30
+ "node": ">=18"
31
+ },
32
+ "keywords": [
33
+ "yektoo",
34
+ "helpdesk",
35
+ "customer-support",
36
+ "cli",
37
+ "mcp"
38
+ ],
39
+ "license": "MIT"
40
+ }