lystbot 0.2.3 โ†’ 0.3.1

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 CHANGED
@@ -1,3 +1,73 @@
1
- # @lystbot/cli
1
+ # LystBot CLI & MCP Server
2
2
 
3
- LystBot command-line interface. See the full documentation at [docs/cli/](../docs/cli/).
3
+ Smart lists that your AI can actually use.
4
+
5
+ ## CLI
6
+
7
+ ```bash
8
+ npx lystbot login <your-api-key>
9
+ npx lystbot lists
10
+ npx lystbot add "Groceries" "Milk, Eggs, Butter"
11
+ npx lystbot check "Groceries" "Milk"
12
+ ```
13
+
14
+ ## MCP Server (Claude Desktop, Cursor, Windsurf)
15
+
16
+ LystBot includes a built-in MCP server. Add it to your AI tool:
17
+
18
+ ### Claude Desktop
19
+
20
+ Add to `~/Library/Application Support/Claude/claude_desktop_config.json`:
21
+
22
+ ```json
23
+ {
24
+ "mcpServers": {
25
+ "lystbot": {
26
+ "command": "npx",
27
+ "args": ["lystbot", "mcp"]
28
+ }
29
+ }
30
+ }
31
+ ```
32
+
33
+ ### Cursor / Windsurf
34
+
35
+ Add to `.cursor/mcp.json` or `.windsurf/mcp.json`:
36
+
37
+ ```json
38
+ {
39
+ "mcpServers": {
40
+ "lystbot": {
41
+ "command": "npx",
42
+ "args": ["lystbot", "mcp"]
43
+ }
44
+ }
45
+ }
46
+ ```
47
+
48
+ ### Setup
49
+
50
+ 1. Install the app: [lystbot.com](https://lystbot.com)
51
+ 2. Copy your API key from Settings
52
+ 3. Run `npx lystbot login <your-api-key>`
53
+ 4. Add the MCP config above
54
+ 5. Ask Claude: "What's on my grocery list?"
55
+
56
+ ### Available Tools
57
+
58
+ | Tool | Description |
59
+ |------|-------------|
60
+ | `list_lists` | Get all your lists |
61
+ | `get_list` | Get a list with all items |
62
+ | `create_list` | Create a new list |
63
+ | `delete_list` | Delete a list |
64
+ | `add_items` | Add items (comma-separated) |
65
+ | `check_item` | Check off an item |
66
+ | `uncheck_item` | Reopen a checked item |
67
+ | `remove_item` | Delete an item |
68
+ | `share_list` | Generate a share code |
69
+ | `join_list` | Join a shared list |
70
+
71
+ ## Documentation
72
+
73
+ Full docs at [lystbot.com](https://lystbot.com) and [docs/](../docs/).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lystbot",
3
- "version": "0.2.3",
3
+ "version": "0.3.1",
4
4
  "description": "LystBot CLI - Manage your lists from the terminal",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -21,6 +21,7 @@
21
21
  "author": "TourAround UG",
22
22
  "license": "MIT",
23
23
  "dependencies": {
24
+ "@modelcontextprotocol/sdk": "^1.27.1",
24
25
  "commander": "^12.0.0"
25
26
  },
26
27
  "engines": {
package/src/index.js CHANGED
@@ -323,6 +323,38 @@ program
323
323
  console.log(`๐Ÿ—‘๏ธ Removed: ${item.text}`);
324
324
  });
325
325
 
326
+ // โ”€โ”€ clear โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
327
+ program
328
+ .command('clear <list>')
329
+ .description('Remove all checked (completed) items from a list')
330
+ .option('--force', 'Skip confirmation')
331
+ .action(async (listQuery, options) => {
332
+ config.getApiKey();
333
+ const { list, detail } = await api.resolveList(listQuery, { withItems: true });
334
+ const checked = (detail.items || []).filter(i => i.checked);
335
+
336
+ if (checked.length === 0) {
337
+ console.log(`โœจ No checked items in ${list.emoji || '๐Ÿ“‹'} ${list.title || list.name}`);
338
+ return;
339
+ }
340
+
341
+ if (!options.force) {
342
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
343
+ const answer = await new Promise(resolve =>
344
+ rl.question(`๐Ÿงน Remove ${checked.length} checked item(s) from '${list.emoji || ''} ${list.title || list.name}'? (y/N) `, resolve)
345
+ );
346
+ rl.close();
347
+
348
+ if (answer.toLowerCase() !== 'y') {
349
+ console.log('Cancelled.');
350
+ process.exit(0);
351
+ }
352
+ }
353
+
354
+ const result = await api.request('DELETE', `/lists/${list.id}/items/checked`);
355
+ console.log(`๐Ÿงน Cleared ${result.deleted_count} checked item(s) from ${list.emoji || '๐Ÿ“‹'} ${list.title || list.name}`);
356
+ });
357
+
326
358
  // โ”€โ”€ create โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
327
359
  program
328
360
  .command('create <name>')
@@ -391,4 +423,13 @@ program
391
423
  console.log(`๐Ÿค Joined: ${list.emoji || '๐Ÿ“‹'} ${list.title || list.name} (${list.item_count || 0} items)`);
392
424
  });
393
425
 
426
+ // โ”€โ”€ mcp โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
427
+ program
428
+ .command('mcp')
429
+ .description('Start MCP (Model Context Protocol) server for Claude Desktop, Cursor, etc.')
430
+ .action(async () => {
431
+ const { startMcpServer } = require('./mcp');
432
+ await startMcpServer();
433
+ });
434
+
394
435
  program.parse();
package/src/mcp.js ADDED
@@ -0,0 +1,240 @@
1
+ const { McpServer } = require('@modelcontextprotocol/sdk/server/mcp.js');
2
+ const { StdioServerTransport } = require('@modelcontextprotocol/sdk/server/stdio.js');
3
+ const config = require('./config');
4
+
5
+ // --- API helper (non-exiting version for MCP) ---
6
+ async function api(method, path, body = null) {
7
+ const baseUrl = config.getBaseUrl();
8
+ const url = `${baseUrl}${path}`;
9
+ const cfg = config.read();
10
+
11
+ const headers = { 'Content-Type': 'application/json' };
12
+ if (cfg?.apiKey) headers['Authorization'] = `Bearer ${cfg.apiKey}`;
13
+
14
+ const opts = { method, headers };
15
+ if (body) opts.body = JSON.stringify(body);
16
+
17
+ const res = await fetch(url, opts);
18
+ if (res.status === 204) return null;
19
+
20
+ const data = await res.json().catch(() => null);
21
+ if (!res.ok) {
22
+ const msg = data?.error?.message || data?.message || `HTTP ${res.status}`;
23
+ throw new Error(msg);
24
+ }
25
+ return data;
26
+ }
27
+
28
+ function findList(lists, query) {
29
+ const lower = query.toLowerCase();
30
+ return lists.find(l => l.id === query)
31
+ || lists.find(l => (l.title || '').toLowerCase() === lower)
32
+ || lists.find(l => (l.title || '').toLowerCase().startsWith(lower))
33
+ || lists.find(l => (l.title || '').toLowerCase().includes(lower))
34
+ || null;
35
+ }
36
+
37
+ function uuid() {
38
+ return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {
39
+ const r = Math.random() * 16 | 0;
40
+ return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16);
41
+ });
42
+ }
43
+
44
+ // --- MCP Server ---
45
+ async function startMcpServer() {
46
+ const cfg = config.read();
47
+ if (!cfg?.apiKey) {
48
+ console.error('Not logged in. Run: lystbot login <api-key>');
49
+ process.exit(1);
50
+ }
51
+
52
+ const server = new McpServer({
53
+ name: 'lystbot',
54
+ version: require('../package.json').version,
55
+ });
56
+
57
+ // --- Tools ---
58
+
59
+ server.tool('list_lists', 'Get all lists', {}, async () => {
60
+ const data = await api('GET', '/lists');
61
+ const lists = data.lists || data;
62
+ const text = lists.length === 0
63
+ ? 'No lists found.'
64
+ : lists.map(l => {
65
+ const emoji = l.emoji || '๐Ÿ“‹';
66
+ const count = l.item_count ?? '?';
67
+ return `${emoji} ${l.title} (${count} items) [id: ${l.id}]`;
68
+ }).join('\n');
69
+ return { content: [{ type: 'text', text }] };
70
+ });
71
+
72
+ server.tool('get_list', 'Get a list with all its items', {
73
+ list: { type: 'string', description: 'List name or ID' },
74
+ }, async ({ list: query }) => {
75
+ const all = await api('GET', '/lists');
76
+ const match = findList(all.lists || all, query);
77
+ if (!match) throw new Error(`List "${query}" not found`);
78
+
79
+ const detail = await api('GET', `/lists/${match.id}`);
80
+ const items = detail.items || [];
81
+
82
+ let text = `${match.emoji || '๐Ÿ“‹'} ${match.title}\n`;
83
+ text += `Type: ${match.type || 'generic'} | ${items.length} items\n\n`;
84
+
85
+ if (items.length === 0) {
86
+ text += '(empty)';
87
+ } else {
88
+ text += items.map(i => {
89
+ const check = i.checked ? 'โœ…' : 'โฌœ';
90
+ const qty = (i.quantity || 1) > 1 ? ` (${i.quantity}x)` : '';
91
+ const unit = i.unit ? ` ${i.unit}` : '';
92
+ return `${check} ${i.text}${qty}${unit}`;
93
+ }).join('\n');
94
+ }
95
+
96
+ return { content: [{ type: 'text', text }] };
97
+ });
98
+
99
+ server.tool('create_list', 'Create a new list', {
100
+ title: { type: 'string', description: 'List title' },
101
+ type: { type: 'string', description: 'List type: shopping, todo, packing, or generic (default: generic)' },
102
+ emoji: { type: 'string', description: 'List emoji (optional, auto-picked if omitted)' },
103
+ }, async ({ title, type, emoji }) => {
104
+ const id = uuid();
105
+ if (!emoji) {
106
+ const lower = title.toLowerCase();
107
+ const map = {
108
+ grocer: '๐Ÿ›’', shopping: '๐Ÿ›๏ธ', food: '๐Ÿ•', todo: 'โœ…', task: '๐Ÿ“',
109
+ travel: 'โœˆ๏ธ', trip: '๐Ÿงณ', pack: '๐Ÿงณ', movie: '๐ŸŽฌ', book: '๐Ÿ“š',
110
+ gift: '๐ŸŽ', wish: 'โญ', fitness: '๐Ÿ’ช', recipe: '๐Ÿ‘จโ€๐Ÿณ', clean: '๐Ÿงน',
111
+ };
112
+ emoji = Object.entries(map).find(([k]) => lower.includes(k))?.[1] || '๐Ÿ“‹';
113
+ }
114
+ await api('POST', '/lists', { id, title, type: type || 'generic', emoji });
115
+ return { content: [{ type: 'text', text: `Created: ${emoji} ${title} [id: ${id}]` }] };
116
+ });
117
+
118
+ server.tool('delete_list', 'Delete a list', {
119
+ list: { type: 'string', description: 'List name or ID' },
120
+ }, async ({ list: query }) => {
121
+ const all = await api('GET', '/lists');
122
+ const match = findList(all.lists || all, query);
123
+ if (!match) throw new Error(`List "${query}" not found`);
124
+ await api('DELETE', `/lists/${match.id}`);
125
+ return { content: [{ type: 'text', text: `Deleted: ${match.emoji || '๐Ÿ“‹'} ${match.title}` }] };
126
+ });
127
+
128
+ server.tool('add_items', 'Add one or more items to a list', {
129
+ list: { type: 'string', description: 'List name or ID' },
130
+ items: { type: 'string', description: 'Items to add, separated by comma+space or semicolon. Example: "Milk, Eggs, Butter"' },
131
+ }, async ({ list: query, items: itemsStr }) => {
132
+ const all = await api('GET', '/lists');
133
+ const match = findList(all.lists || all, query);
134
+ if (!match) throw new Error(`List "${query}" not found`);
135
+
136
+ // Split on ", " or ";"
137
+ const texts = itemsStr.split(/;\s*|,\s+/).map(s => s.trim()).filter(Boolean);
138
+ const added = [];
139
+
140
+ for (const text of texts) {
141
+ const id = uuid();
142
+ await api('POST', `/lists/${match.id}/items`, { id, text });
143
+ added.push(text);
144
+ }
145
+
146
+ return { content: [{ type: 'text', text: `Added ${added.length} item(s) to ${match.emoji || '๐Ÿ“‹'} ${match.title}:\n${added.map(t => ` + ${t}`).join('\n')}` }] };
147
+ });
148
+
149
+ server.tool('check_item', 'Check off (complete) an item', {
150
+ list: { type: 'string', description: 'List name or ID' },
151
+ item: { type: 'string', description: 'Item text (fuzzy match)' },
152
+ }, async ({ list: query, item: itemQuery }) => {
153
+ const all = await api('GET', '/lists');
154
+ const match = findList(all.lists || all, query);
155
+ if (!match) throw new Error(`List "${query}" not found`);
156
+
157
+ const detail = await api('GET', `/lists/${match.id}`);
158
+ const items = detail.items || [];
159
+ const lower = itemQuery.toLowerCase();
160
+ const found = items.find(i => i.text.toLowerCase() === lower)
161
+ || items.find(i => i.text.toLowerCase().includes(lower));
162
+ if (!found) throw new Error(`Item "${itemQuery}" not found in ${match.title}`);
163
+
164
+ await api('PUT', `/lists/${match.id}/items/${found.id}`, { checked: true });
165
+ return { content: [{ type: 'text', text: `โœ… Checked: ${found.text}` }] };
166
+ });
167
+
168
+ server.tool('uncheck_item', 'Uncheck (reopen) an item', {
169
+ list: { type: 'string', description: 'List name or ID' },
170
+ item: { type: 'string', description: 'Item text (fuzzy match)' },
171
+ }, async ({ list: query, item: itemQuery }) => {
172
+ const all = await api('GET', '/lists');
173
+ const match = findList(all.lists || all, query);
174
+ if (!match) throw new Error(`List "${query}" not found`);
175
+
176
+ const detail = await api('GET', `/lists/${match.id}`);
177
+ const items = detail.items || [];
178
+ const lower = itemQuery.toLowerCase();
179
+ const found = items.find(i => i.text.toLowerCase() === lower)
180
+ || items.find(i => i.text.toLowerCase().includes(lower));
181
+ if (!found) throw new Error(`Item "${itemQuery}" not found in ${match.title}`);
182
+
183
+ await api('PUT', `/lists/${match.id}/items/${found.id}`, { checked: false });
184
+ return { content: [{ type: 'text', text: `โฌœ Unchecked: ${found.text}` }] };
185
+ });
186
+
187
+ server.tool('remove_item', 'Remove an item from a list', {
188
+ list: { type: 'string', description: 'List name or ID' },
189
+ item: { type: 'string', description: 'Item text (fuzzy match)' },
190
+ }, async ({ list: query, item: itemQuery }) => {
191
+ const all = await api('GET', '/lists');
192
+ const match = findList(all.lists || all, query);
193
+ if (!match) throw new Error(`List "${query}" not found`);
194
+
195
+ const detail = await api('GET', `/lists/${match.id}`);
196
+ const items = detail.items || [];
197
+ const lower = itemQuery.toLowerCase();
198
+ const found = items.find(i => i.text.toLowerCase() === lower)
199
+ || items.find(i => i.text.toLowerCase().includes(lower));
200
+ if (!found) throw new Error(`Item "${itemQuery}" not found in ${match.title}`);
201
+
202
+ await api('DELETE', `/lists/${match.id}/items/${found.id}`);
203
+ return { content: [{ type: 'text', text: `๐Ÿ—‘๏ธ Removed: ${found.text}` }] };
204
+ });
205
+
206
+ server.tool('clear_checked', 'Remove all checked (completed) items from a list', {
207
+ list: { type: 'string', description: 'List name or ID' },
208
+ }, async ({ list: query }) => {
209
+ const all = await api('GET', '/lists');
210
+ const match = findList(all.lists || all, query);
211
+ if (!match) throw new Error(`List "${query}" not found`);
212
+
213
+ const data = await api('DELETE', `/lists/${match.id}/items/checked`);
214
+ return { content: [{ type: 'text', text: `๐Ÿงน Cleared ${data.deleted_count} checked item(s) from ${match.emoji || '๐Ÿ“‹'} ${match.title}` }] };
215
+ });
216
+
217
+ server.tool('share_list', 'Generate a share code for a list', {
218
+ list: { type: 'string', description: 'List name or ID' },
219
+ }, async ({ list: query }) => {
220
+ const all = await api('GET', '/lists');
221
+ const match = findList(all.lists || all, query);
222
+ if (!match) throw new Error(`List "${query}" not found`);
223
+
224
+ const data = await api('POST', `/lists/${match.id}/share`);
225
+ return { content: [{ type: 'text', text: `Share code for ${match.emoji || '๐Ÿ“‹'} ${match.title}: ${data.share_code}\nAnyone with this code can join the list.` }] };
226
+ });
227
+
228
+ server.tool('join_list', 'Join a shared list using a share code', {
229
+ code: { type: 'string', description: 'The share code' },
230
+ }, async ({ code }) => {
231
+ const data = await api('POST', '/lists/join', { share_code: code });
232
+ return { content: [{ type: 'text', text: `Joined list: ${data.emoji || '๐Ÿ“‹'} ${data.title || data.list_id}` }] };
233
+ });
234
+
235
+ // Connect via stdio
236
+ const transport = new StdioServerTransport();
237
+ await server.connect(transport);
238
+ }
239
+
240
+ module.exports = { startMcpServer };