lystbot 0.2.2 โ†’ 0.3.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 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.2",
3
+ "version": "0.3.0",
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
@@ -201,9 +201,18 @@ program
201
201
  .action(async (listQuery, rawItems) => {
202
202
  config.getApiKey();
203
203
 
204
- // Smart parsing: split on commas, trim, flatten
204
+ // Smart parsing: each CLI argument is one item.
205
+ // Within a single argument, split on ", " (comma+space) for batch input.
206
+ // To include a literal comma+space in an item, use semicolons as separator instead.
207
+ // e.g. "Milch; Eier; Brot" or "Kรคse, Wurst, Senf" both work as batch.
208
+ // Single item with comma: pass as separate arg without comma+space pattern.
205
209
  const items = rawItems
206
- .flatMap(i => i.split(','))
210
+ .flatMap(i => {
211
+ // If argument contains semicolons, use those as separators (commas stay literal)
212
+ if (i.includes(';')) return i.split(';');
213
+ // Otherwise split on ", " (comma followed by space)
214
+ return i.split(', ');
215
+ })
207
216
  .map(i => i.trim())
208
217
  .filter(Boolean);
209
218
 
@@ -382,4 +391,13 @@ program
382
391
  console.log(`๐Ÿค Joined: ${list.emoji || '๐Ÿ“‹'} ${list.title || list.name} (${list.item_count || 0} items)`);
383
392
  });
384
393
 
394
+ // โ”€โ”€ mcp โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
395
+ program
396
+ .command('mcp')
397
+ .description('Start MCP (Model Context Protocol) server for Claude Desktop, Cursor, etc.')
398
+ .action(async () => {
399
+ const { startMcpServer } = require('./mcp');
400
+ await startMcpServer();
401
+ });
402
+
385
403
  program.parse();
package/src/mcp.js ADDED
@@ -0,0 +1,229 @@
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('share_list', 'Generate a share code for 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('POST', `/lists/${match.id}/share`);
214
+ return { content: [{ type: 'text', text: `Share code for ${match.emoji || '๐Ÿ“‹'} ${match.title}: ${data.share_code}\nAnyone with this code can join the list.` }] };
215
+ });
216
+
217
+ server.tool('join_list', 'Join a shared list using a share code', {
218
+ code: { type: 'string', description: 'The share code' },
219
+ }, async ({ code }) => {
220
+ const data = await api('POST', '/lists/join', { share_code: code });
221
+ return { content: [{ type: 'text', text: `Joined list: ${data.emoji || '๐Ÿ“‹'} ${data.title || data.list_id}` }] };
222
+ });
223
+
224
+ // Connect via stdio
225
+ const transport = new StdioServerTransport();
226
+ await server.connect(transport);
227
+ }
228
+
229
+ module.exports = { startMcpServer };