lystbot 0.3.6 → 0.3.7
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 +13 -0
- package/package.json +1 -1
- package/server.json +11 -3
- package/src/api.js +22 -1
- package/src/index.js +249 -9
- package/src/mcp.js +191 -3
package/README.md
CHANGED
|
@@ -9,6 +9,12 @@ npx lystbot login <your-api-key>
|
|
|
9
9
|
npx lystbot lists
|
|
10
10
|
npx lystbot add "Groceries" "Milk, Eggs, Butter"
|
|
11
11
|
npx lystbot check "Groceries" "Milk"
|
|
12
|
+
|
|
13
|
+
# Categories
|
|
14
|
+
npx lystbot categories "Groceries"
|
|
15
|
+
npx lystbot category add "Groceries" "Fruits"
|
|
16
|
+
npx lystbot add "Groceries" "Bananas" --category "Fruits"
|
|
17
|
+
npx lystbot move "Groceries" "Bananas" --category other
|
|
12
18
|
```
|
|
13
19
|
|
|
14
20
|
## MCP Server (Claude Desktop, Cursor, Windsurf)
|
|
@@ -65,8 +71,15 @@ Add to `.cursor/mcp.json` or `.windsurf/mcp.json`:
|
|
|
65
71
|
| `check_item` | Check off an item |
|
|
66
72
|
| `uncheck_item` | Reopen a checked item |
|
|
67
73
|
| `remove_item` | Delete an item |
|
|
74
|
+
| `clear_checked` | Remove all checked (completed) items from a list |
|
|
68
75
|
| `share_list` | Generate a share code |
|
|
69
76
|
| `join_list` | Join a shared list |
|
|
77
|
+
| `list_categories` | List categories for a list |
|
|
78
|
+
| `create_category` | Create a category |
|
|
79
|
+
| `rename_category` | Rename a category |
|
|
80
|
+
| `delete_category` | Delete a category |
|
|
81
|
+
| `reorder_categories` | Reorder categories |
|
|
82
|
+
| `move_item` | Move an item to a category (or Other) |
|
|
70
83
|
|
|
71
84
|
## Documentation
|
|
72
85
|
|
package/package.json
CHANGED
package/server.json
CHANGED
|
@@ -7,12 +7,12 @@
|
|
|
7
7
|
"source": "github",
|
|
8
8
|
"subfolder": "cli"
|
|
9
9
|
},
|
|
10
|
-
"version": "0.3.
|
|
10
|
+
"version": "0.3.7",
|
|
11
11
|
"packages": [
|
|
12
12
|
{
|
|
13
13
|
"registryType": "npm",
|
|
14
14
|
"identifier": "lystbot",
|
|
15
|
-
"version": "0.3.
|
|
15
|
+
"version": "0.3.7",
|
|
16
16
|
"transport": {
|
|
17
17
|
"type": "stdio"
|
|
18
18
|
},
|
|
@@ -30,7 +30,15 @@
|
|
|
30
30
|
{ "name": "check_item", "description": "Check off an item" },
|
|
31
31
|
{ "name": "uncheck_item", "description": "Uncheck an item" },
|
|
32
32
|
{ "name": "remove_item", "description": "Remove an item from a list" },
|
|
33
|
+
{ "name": "clear_checked", "description": "Remove all checked (completed) items from a list" },
|
|
33
34
|
{ "name": "share_list", "description": "Generate a share code for a list" },
|
|
34
|
-
{ "name": "join_list", "description": "Join a shared list via code" }
|
|
35
|
+
{ "name": "join_list", "description": "Join a shared list via code" },
|
|
36
|
+
|
|
37
|
+
{ "name": "list_categories", "description": "List categories for a list" },
|
|
38
|
+
{ "name": "create_category", "description": "Create a category" },
|
|
39
|
+
{ "name": "rename_category", "description": "Rename a category" },
|
|
40
|
+
{ "name": "delete_category", "description": "Delete a category" },
|
|
41
|
+
{ "name": "reorder_categories", "description": "Reorder categories" },
|
|
42
|
+
{ "name": "move_item", "description": "Move an item to a category (or Other)" }
|
|
35
43
|
]
|
|
36
44
|
}
|
package/src/api.js
CHANGED
|
@@ -121,6 +121,27 @@ function findItem(items, query) {
|
|
|
121
121
|
return null;
|
|
122
122
|
}
|
|
123
123
|
|
|
124
|
+
// Fuzzy match: find a category by name or ID from an array of categories
|
|
125
|
+
function findCategory(categories, query) {
|
|
126
|
+
if (!Array.isArray(categories)) return null;
|
|
127
|
+
|
|
128
|
+
// Try exact ID match
|
|
129
|
+
const byId = categories.find(c => String(c.id) === String(query));
|
|
130
|
+
if (byId) return byId;
|
|
131
|
+
|
|
132
|
+
const lower = String(query).toLowerCase();
|
|
133
|
+
const exact = categories.find(c => (c.name || '').toLowerCase() === lower);
|
|
134
|
+
if (exact) return exact;
|
|
135
|
+
|
|
136
|
+
const starts = categories.find(c => (c.name || '').toLowerCase().startsWith(lower));
|
|
137
|
+
if (starts) return starts;
|
|
138
|
+
|
|
139
|
+
const includes = categories.find(c => (c.name || '').toLowerCase().includes(lower));
|
|
140
|
+
if (includes) return includes;
|
|
141
|
+
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
|
|
124
145
|
// Resolve a list query to {list, detail} - fetches all lists then the specific one
|
|
125
146
|
async function resolveList(query, { withItems = false } = {}) {
|
|
126
147
|
const res = await request('GET', '/lists');
|
|
@@ -156,4 +177,4 @@ function autoEmoji(name) {
|
|
|
156
177
|
return '📋';
|
|
157
178
|
}
|
|
158
179
|
|
|
159
|
-
module.exports = { request, rawRequest, findList, findItem, resolveList, autoEmoji };
|
|
180
|
+
module.exports = { request, rawRequest, findList, findItem, findCategory, resolveList, autoEmoji };
|
package/src/index.js
CHANGED
|
@@ -177,28 +177,209 @@ program
|
|
|
177
177
|
const shared = detail.is_shared ? ` 👥 (${memberCount} member${memberCount === 1 ? '' : 's'})` : '';
|
|
178
178
|
console.log(`\n${emoji} ${listTitle}${shared}\n`);
|
|
179
179
|
|
|
180
|
-
|
|
180
|
+
const items = detail.items || [];
|
|
181
|
+
const categories = Array.isArray(detail.categories)
|
|
182
|
+
? [...detail.categories].sort((a, b) => (a.position ?? 0) - (b.position ?? 0))
|
|
183
|
+
: [];
|
|
184
|
+
|
|
185
|
+
if (!items.length) {
|
|
181
186
|
console.log(' (empty list)');
|
|
182
187
|
return;
|
|
183
188
|
}
|
|
184
189
|
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
+
// If categories exist, render sectioned view like the app.
|
|
191
|
+
if (categories.length > 0) {
|
|
192
|
+
const byCategory = new Map();
|
|
193
|
+
for (const c of categories) byCategory.set(c.id, []);
|
|
194
|
+
const other = [];
|
|
195
|
+
|
|
196
|
+
for (const item of items) {
|
|
197
|
+
const cid = item.category_id || null;
|
|
198
|
+
if (cid && byCategory.has(cid)) byCategory.get(cid).push(item);
|
|
199
|
+
else other.push(item);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
for (const c of categories) {
|
|
203
|
+
const sectionItems = byCategory.get(c.id) || [];
|
|
204
|
+
console.log(` ${c.name}`);
|
|
205
|
+
if (sectionItems.length === 0) {
|
|
206
|
+
console.log(' (empty)');
|
|
207
|
+
} else {
|
|
208
|
+
for (const item of sectionItems) {
|
|
209
|
+
console.log(item.checked ? ` ✅ ~~${item.text}~~` : ` ⬜ ${item.text}`);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
console.log('');
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (other.length > 0) {
|
|
216
|
+
console.log(' Other');
|
|
217
|
+
for (const item of other) {
|
|
218
|
+
console.log(item.checked ? ` ✅ ~~${item.text}~~` : ` ⬜ ${item.text}`);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
} else {
|
|
222
|
+
// Flat view
|
|
223
|
+
for (const item of items) {
|
|
224
|
+
console.log(item.checked ? ` ✅ ~~${item.text}~~` : ` ⬜ ${item.text}`);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const checked = items.filter(i => i.checked).length;
|
|
229
|
+
console.log(`\n ${checked}/${items.length} done`);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
// ── categories (read-only shortcut) ───────────────────
|
|
233
|
+
program
|
|
234
|
+
.command('categories <list>')
|
|
235
|
+
.description('Show categories in a list (including Other/uncategorized)')
|
|
236
|
+
.option('--json', 'Output as JSON')
|
|
237
|
+
.action(async (listQuery, options) => {
|
|
238
|
+
config.getApiKey();
|
|
239
|
+
const { list, detail } = await api.resolveList(listQuery, { withItems: true });
|
|
240
|
+
|
|
241
|
+
const categories = Array.isArray(detail.categories)
|
|
242
|
+
? [...detail.categories].sort((a, b) => (a.position ?? 0) - (b.position ?? 0))
|
|
243
|
+
: [];
|
|
244
|
+
const items = detail.items || [];
|
|
245
|
+
|
|
246
|
+
if (options.json) {
|
|
247
|
+
const otherCount = items.filter(i => !i.category_id).length;
|
|
248
|
+
console.log(JSON.stringify({ list_id: list.id, categories, other_count: otherCount }, null, 2));
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
console.log(`\n${detail.emoji || '📋'} ${detail.title || detail.name} — Categories\n`);
|
|
253
|
+
if (categories.length === 0) {
|
|
254
|
+
console.log(' (no categories yet)');
|
|
255
|
+
} else {
|
|
256
|
+
for (const c of categories) {
|
|
257
|
+
const count = items.filter(i => i.category_id === c.id).length;
|
|
258
|
+
console.log(` • ${c.name} (${count} item${count === 1 ? '' : 's'})`);
|
|
190
259
|
}
|
|
191
260
|
}
|
|
192
261
|
|
|
193
|
-
const
|
|
194
|
-
console.log(
|
|
262
|
+
const otherCount = items.filter(i => !i.category_id).length;
|
|
263
|
+
console.log(` • Other (${otherCount} item${otherCount === 1 ? '' : 's'})`);
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
// ── category (manage) ─────────────────────────────────
|
|
267
|
+
const categoryCmd = program
|
|
268
|
+
.command('category')
|
|
269
|
+
.description('Manage list categories');
|
|
270
|
+
|
|
271
|
+
categoryCmd
|
|
272
|
+
.command('add <list> <name>')
|
|
273
|
+
.description('Create a category')
|
|
274
|
+
.option('--position <n>', 'Insert position (default: end)')
|
|
275
|
+
.action(async (listQuery, name, options) => {
|
|
276
|
+
config.getApiKey();
|
|
277
|
+
const { list } = await api.resolveList(listQuery);
|
|
278
|
+
const body = { id: randomUUID(), name: name.trim() };
|
|
279
|
+
if (options.position !== undefined) body.position = parseInt(options.position, 10);
|
|
280
|
+
const res = await api.request('POST', `/lists/${list.id}/categories`, body);
|
|
281
|
+
console.log(`✅ Category created: ${res.name} [id: ${res.id}]`);
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
categoryCmd
|
|
285
|
+
.command('rename <list> <category> <newName>')
|
|
286
|
+
.description('Rename a category')
|
|
287
|
+
.action(async (listQuery, categoryQuery, newName) => {
|
|
288
|
+
config.getApiKey();
|
|
289
|
+
const { list, detail } = await api.resolveList(listQuery, { withItems: true });
|
|
290
|
+
const cat = api.findCategory(detail.categories || [], categoryQuery);
|
|
291
|
+
if (!cat) {
|
|
292
|
+
const names = (detail.categories || []).map(c => c.name).join(', ');
|
|
293
|
+
console.error(`❌ Category '${categoryQuery}' not found. Categories: ${names || '(none)'}`);
|
|
294
|
+
process.exit(1);
|
|
295
|
+
}
|
|
296
|
+
const res = await api.request('PUT', `/lists/${list.id}/categories/${cat.id}`, { name: newName.trim() });
|
|
297
|
+
console.log(`✅ Category renamed: ${cat.name} → ${res.name}`);
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
categoryCmd
|
|
301
|
+
.command('delete <list> <category>')
|
|
302
|
+
.description('Delete a category (items will move to Other)')
|
|
303
|
+
.option('--force', 'Skip confirmation')
|
|
304
|
+
.action(async (listQuery, categoryQuery, options) => {
|
|
305
|
+
config.getApiKey();
|
|
306
|
+
const { list, detail } = await api.resolveList(listQuery, { withItems: true });
|
|
307
|
+
const cat = api.findCategory(detail.categories || [], categoryQuery);
|
|
308
|
+
if (!cat) {
|
|
309
|
+
const names = (detail.categories || []).map(c => c.name).join(', ');
|
|
310
|
+
console.error(`❌ Category '${categoryQuery}' not found. Categories: ${names || '(none)'}`);
|
|
311
|
+
process.exit(1);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
if (!options.force) {
|
|
315
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
316
|
+
const answer = await new Promise(resolve =>
|
|
317
|
+
rl.question(`🗑️ Delete category '${cat.name}'? Items will be moved to Other. (y/N) `, resolve)
|
|
318
|
+
);
|
|
319
|
+
rl.close();
|
|
320
|
+
if (answer.toLowerCase() !== 'y') {
|
|
321
|
+
console.log('Cancelled.');
|
|
322
|
+
process.exit(0);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const res = await api.request('DELETE', `/lists/${list.id}/categories/${cat.id}`);
|
|
327
|
+
console.log(`🗑️ Deleted category: ${cat.name} (moved ${res.moved_count ?? 0} item(s) to Other)`);
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
categoryCmd
|
|
331
|
+
.command('reorder <list>')
|
|
332
|
+
.description('Reorder categories')
|
|
333
|
+
.requiredOption('--order <csv>', 'Comma-separated category names or IDs in desired order')
|
|
334
|
+
.action(async (listQuery, options) => {
|
|
335
|
+
config.getApiKey();
|
|
336
|
+
const { list, detail } = await api.resolveList(listQuery, { withItems: true });
|
|
337
|
+
const categories = detail.categories || [];
|
|
338
|
+
if (!categories.length) {
|
|
339
|
+
console.error('❌ This list has no categories to reorder.');
|
|
340
|
+
process.exit(1);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const parts = String(options.order)
|
|
344
|
+
.split(',')
|
|
345
|
+
.map(s => s.trim())
|
|
346
|
+
.filter(Boolean);
|
|
347
|
+
|
|
348
|
+
if (parts.length === 0) {
|
|
349
|
+
console.error('❌ --order cannot be empty');
|
|
350
|
+
process.exit(1);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
const resolved = [];
|
|
354
|
+
for (const q of parts) {
|
|
355
|
+
const cat = api.findCategory(categories, q);
|
|
356
|
+
if (!cat) {
|
|
357
|
+
console.error(`❌ Category '${q}' not found in list.`);
|
|
358
|
+
process.exit(1);
|
|
359
|
+
}
|
|
360
|
+
resolved.push(cat);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// Require full order to avoid surprises
|
|
364
|
+
const uniqueIds = new Set(resolved.map(c => c.id));
|
|
365
|
+
if (uniqueIds.size !== categories.length) {
|
|
366
|
+
console.error('❌ --order must include each category exactly once.');
|
|
367
|
+
console.error(` Expected ${categories.length} unique categories, got ${uniqueIds.size}.`);
|
|
368
|
+
process.exit(1);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
const order = resolved.map((c, idx) => ({ category_id: c.id, position: idx }));
|
|
372
|
+
await api.request('PUT', `/lists/${list.id}/categories/reorder`, { order });
|
|
373
|
+
console.log('✅ Categories reordered.');
|
|
195
374
|
});
|
|
196
375
|
|
|
197
376
|
// ── add ────────────────────────────────────────────────
|
|
198
377
|
program
|
|
199
378
|
.command('add <list> <items...>')
|
|
200
379
|
.description('Add items to a list (supports comma-separated)')
|
|
201
|
-
.
|
|
380
|
+
.option('--category <category>', 'Add items to a category (name or ID)')
|
|
381
|
+
.option('--create-category', 'Auto-create the category if it does not exist (only works with --category name)')
|
|
382
|
+
.action(async (listQuery, rawItems, options) => {
|
|
202
383
|
config.getApiKey();
|
|
203
384
|
|
|
204
385
|
// Smart parsing: each CLI argument is one item.
|
|
@@ -246,6 +427,30 @@ program
|
|
|
246
427
|
console.log(`✨ Created list: ${emoji} ${listQuery}`);
|
|
247
428
|
}
|
|
248
429
|
|
|
430
|
+
// Resolve category (optional)
|
|
431
|
+
let categoryId = null;
|
|
432
|
+
if (options.category) {
|
|
433
|
+
const detail = await api.request('GET', `/lists/${match.id}`);
|
|
434
|
+
const cat = api.findCategory(detail.categories || [], options.category);
|
|
435
|
+
if (!cat) {
|
|
436
|
+
if (options.createCategory) {
|
|
437
|
+
const created = await api.request('POST', `/lists/${match.id}/categories`, {
|
|
438
|
+
id: randomUUID(),
|
|
439
|
+
name: String(options.category).trim(),
|
|
440
|
+
});
|
|
441
|
+
categoryId = created.id;
|
|
442
|
+
console.log(`✅ Created category: ${created.name}`);
|
|
443
|
+
} else {
|
|
444
|
+
const names = (detail.categories || []).map(c => c.name).join(', ');
|
|
445
|
+
console.error(`❌ Category '${options.category}' not found. Categories: ${names || '(none)'}`);
|
|
446
|
+
console.error(' Tip: Use --create-category to create it automatically.');
|
|
447
|
+
process.exit(1);
|
|
448
|
+
}
|
|
449
|
+
} else {
|
|
450
|
+
categoryId = cat.id;
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
249
454
|
// Add items one by one (each needs a client-generated UUID)
|
|
250
455
|
let added = 0;
|
|
251
456
|
for (let idx = 0; idx < items.length; idx++) {
|
|
@@ -255,6 +460,7 @@ program
|
|
|
255
460
|
quantity: 1,
|
|
256
461
|
unit: null,
|
|
257
462
|
position: idx,
|
|
463
|
+
category_id: categoryId,
|
|
258
464
|
});
|
|
259
465
|
added++;
|
|
260
466
|
}
|
|
@@ -266,6 +472,40 @@ program
|
|
|
266
472
|
}
|
|
267
473
|
});
|
|
268
474
|
|
|
475
|
+
// ── move (item category) ──────────────────────────────
|
|
476
|
+
program
|
|
477
|
+
.command('move <list> <item>')
|
|
478
|
+
.description('Move an item to a category (or to Other)')
|
|
479
|
+
.requiredOption('--category <category>', 'Target category name/ID, or "other" to uncategorize')
|
|
480
|
+
.action(async (listQuery, itemQuery, options) => {
|
|
481
|
+
config.getApiKey();
|
|
482
|
+
const { list, detail } = await api.resolveList(listQuery, { withItems: true });
|
|
483
|
+
|
|
484
|
+
const item = api.findItem(detail.items || [], itemQuery);
|
|
485
|
+
if (!item) {
|
|
486
|
+
const names = (detail.items || []).map(i => i.text).join(', ');
|
|
487
|
+
console.error(`❌ Item '${itemQuery}' not found in ${list.title || list.name}. Items: ${names || '(empty)'}`);
|
|
488
|
+
process.exit(1);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
const q = String(options.category).trim().toLowerCase();
|
|
492
|
+
let categoryId = null;
|
|
493
|
+
if (q === 'other' || q === 'uncategorized' || q === 'none' || q === 'null') {
|
|
494
|
+
categoryId = null;
|
|
495
|
+
} else {
|
|
496
|
+
const cat = api.findCategory(detail.categories || [], options.category);
|
|
497
|
+
if (!cat) {
|
|
498
|
+
const names = (detail.categories || []).map(c => c.name).join(', ');
|
|
499
|
+
console.error(`❌ Category '${options.category}' not found. Categories: ${names || '(none)'}`);
|
|
500
|
+
process.exit(1);
|
|
501
|
+
}
|
|
502
|
+
categoryId = cat.id;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
await api.request('PUT', `/lists/${list.id}/items/${item.id}`, { category_id: categoryId });
|
|
506
|
+
console.log(`✅ Moved: ${item.text} → ${categoryId ? options.category : 'Other'}`);
|
|
507
|
+
});
|
|
508
|
+
|
|
269
509
|
// ── check ──────────────────────────────────────────────
|
|
270
510
|
program
|
|
271
511
|
.command('check <list> <item>')
|
package/src/mcp.js
CHANGED
|
@@ -35,6 +35,17 @@ function findList(lists, query) {
|
|
|
35
35
|
|| null;
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
function findCategory(categories, query) {
|
|
39
|
+
if (!Array.isArray(categories)) return null;
|
|
40
|
+
const byId = categories.find(c => String(c.id) === String(query));
|
|
41
|
+
if (byId) return byId;
|
|
42
|
+
const lower = String(query).toLowerCase();
|
|
43
|
+
return categories.find(c => (c.name || '').toLowerCase() === lower)
|
|
44
|
+
|| categories.find(c => (c.name || '').toLowerCase().startsWith(lower))
|
|
45
|
+
|| categories.find(c => (c.name || '').toLowerCase().includes(lower))
|
|
46
|
+
|| null;
|
|
47
|
+
}
|
|
48
|
+
|
|
38
49
|
function uuid() {
|
|
39
50
|
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {
|
|
40
51
|
const r = Math.random() * 16 | 0;
|
|
@@ -79,12 +90,46 @@ async function startMcpServer() {
|
|
|
79
90
|
|
|
80
91
|
const detail = await api('GET', `/lists/${match.id}`);
|
|
81
92
|
const items = detail.items || [];
|
|
93
|
+
const categories = Array.isArray(detail.categories)
|
|
94
|
+
? [...detail.categories].sort((a, b) => (a.position ?? 0) - (b.position ?? 0))
|
|
95
|
+
: [];
|
|
82
96
|
|
|
83
97
|
let text = `${match.emoji || '📋'} ${match.title}\n`;
|
|
84
98
|
text += `Type: ${match.type || 'generic'} | ${items.length} items\n\n`;
|
|
85
99
|
|
|
86
100
|
if (items.length === 0) {
|
|
87
101
|
text += '(empty)';
|
|
102
|
+
} else if (categories.length > 0) {
|
|
103
|
+
const byCategory = new Map();
|
|
104
|
+
for (const c of categories) byCategory.set(c.id, []);
|
|
105
|
+
const other = [];
|
|
106
|
+
|
|
107
|
+
for (const i of items) {
|
|
108
|
+
const cid = i.category_id || null;
|
|
109
|
+
if (cid && byCategory.has(cid)) byCategory.get(cid).push(i);
|
|
110
|
+
else other.push(i);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const renderItem = (i) => {
|
|
114
|
+
const check = i.checked ? '✅' : '⬜';
|
|
115
|
+
const qty = (i.quantity || 1) > 1 ? ` (${i.quantity}x)` : '';
|
|
116
|
+
const unit = i.unit ? ` ${i.unit}` : '';
|
|
117
|
+
return `${check} ${i.text}${qty}${unit}`;
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
for (const c of categories) {
|
|
121
|
+
text += `${c.name}\n`;
|
|
122
|
+
const sectionItems = byCategory.get(c.id) || [];
|
|
123
|
+
if (sectionItems.length === 0) {
|
|
124
|
+
text += ' (empty)\n\n';
|
|
125
|
+
} else {
|
|
126
|
+
text += sectionItems.map(i => ` ${renderItem(i)}`).join('\n') + '\n\n';
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
if (other.length > 0) {
|
|
130
|
+
text += `Other\n`;
|
|
131
|
+
text += other.map(i => ` ${renderItem(i)}`).join('\n');
|
|
132
|
+
}
|
|
88
133
|
} else {
|
|
89
134
|
text += items.map(i => {
|
|
90
135
|
const check = i.checked ? '✅' : '⬜';
|
|
@@ -129,17 +174,34 @@ async function startMcpServer() {
|
|
|
129
174
|
server.tool('add_items', 'Add one or more items to a list', {
|
|
130
175
|
list: z.string().describe('List name or ID'),
|
|
131
176
|
items: z.string().describe('Items to add, separated by comma+space or semicolon. Example: "Milk, Eggs, Butter"'),
|
|
132
|
-
|
|
177
|
+
category: z.string().optional().describe('Optional category name or ID. Use "other" to add uncategorized.'),
|
|
178
|
+
}, async ({ list: query, items: itemsStr, category }) => {
|
|
133
179
|
const all = await api('GET', '/lists');
|
|
134
180
|
const match = findList(all.lists || all, query);
|
|
135
181
|
if (!match) throw new Error(`List "${query}" not found`);
|
|
136
182
|
|
|
183
|
+
const detail = await api('GET', `/lists/${match.id}`);
|
|
184
|
+
const categories = detail.categories || [];
|
|
185
|
+
|
|
186
|
+
let categoryId = null;
|
|
187
|
+
if (typeof category === 'string' && category.trim() !== '') {
|
|
188
|
+
const cQuery = category.trim();
|
|
189
|
+
const lower = cQuery.toLowerCase();
|
|
190
|
+
if (lower === 'other' || lower === 'uncategorized' || lower === 'none' || lower === 'null') {
|
|
191
|
+
categoryId = null;
|
|
192
|
+
} else {
|
|
193
|
+
const cat = findCategory(categories, cQuery);
|
|
194
|
+
if (!cat) throw new Error(`Category "${cQuery}" not found`);
|
|
195
|
+
categoryId = cat.id;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
137
199
|
const texts = itemsStr.split(/;\s*|,\s+/).map(s => s.trim()).filter(Boolean);
|
|
138
200
|
const added = [];
|
|
139
201
|
|
|
140
202
|
for (const text of texts) {
|
|
141
203
|
const id = uuid();
|
|
142
|
-
await api('POST', `/lists/${match.id}/items`, { id, text });
|
|
204
|
+
await api('POST', `/lists/${match.id}/items`, { id, text, category_id: categoryId });
|
|
143
205
|
added.push(text);
|
|
144
206
|
}
|
|
145
207
|
|
|
@@ -228,10 +290,136 @@ async function startMcpServer() {
|
|
|
228
290
|
server.tool('join_list', 'Join a shared list using a share code', {
|
|
229
291
|
code: z.string().describe('The share code'),
|
|
230
292
|
}, async ({ code }) => {
|
|
231
|
-
const data = await api('POST', '/lists/join', {
|
|
293
|
+
const data = await api('POST', '/lists/join', { code });
|
|
232
294
|
return { content: [{ type: 'text', text: `Joined list: ${data.emoji || '📋'} ${data.title || data.list_id}` }] };
|
|
233
295
|
});
|
|
234
296
|
|
|
297
|
+
// --- Categories ---
|
|
298
|
+
|
|
299
|
+
server.tool('list_categories', 'List categories for a list (includes Other/uncategorized count)', {
|
|
300
|
+
list: z.string().describe('List name or ID'),
|
|
301
|
+
}, async ({ list: query }) => {
|
|
302
|
+
const all = await api('GET', '/lists');
|
|
303
|
+
const match = findList(all.lists || all, query);
|
|
304
|
+
if (!match) throw new Error(`List "${query}" not found`);
|
|
305
|
+
const detail = await api('GET', `/lists/${match.id}`);
|
|
306
|
+
const categories = (detail.categories || []).slice().sort((a, b) => (a.position ?? 0) - (b.position ?? 0));
|
|
307
|
+
const items = detail.items || [];
|
|
308
|
+
|
|
309
|
+
if (categories.length === 0) {
|
|
310
|
+
const otherCount = items.filter(i => !i.category_id).length;
|
|
311
|
+
return { content: [{ type: 'text', text: `No categories in ${match.emoji || '📋'} ${match.title}.\nOther: ${otherCount} item(s)` }] };
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const lines = categories.map(c => {
|
|
315
|
+
const count = items.filter(i => i.category_id === c.id).length;
|
|
316
|
+
return `- ${c.name} (${count} item(s)) [id: ${c.id}]`;
|
|
317
|
+
});
|
|
318
|
+
const otherCount = items.filter(i => !i.category_id).length;
|
|
319
|
+
lines.push(`- Other (${otherCount} item(s))`);
|
|
320
|
+
return { content: [{ type: 'text', text: lines.join('\n') }] };
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
server.tool('create_category', 'Create a category', {
|
|
324
|
+
list: z.string().describe('List name or ID'),
|
|
325
|
+
name: z.string().describe('Category name'),
|
|
326
|
+
position: z.number().optional().describe('Optional position (default: end)'),
|
|
327
|
+
}, async ({ list: query, name, position }) => {
|
|
328
|
+
const all = await api('GET', '/lists');
|
|
329
|
+
const match = findList(all.lists || all, query);
|
|
330
|
+
if (!match) throw new Error(`List "${query}" not found`);
|
|
331
|
+
const id = uuid();
|
|
332
|
+
const body = { id, name };
|
|
333
|
+
if (typeof position === 'number') body.position = position;
|
|
334
|
+
const res = await api('POST', `/lists/${match.id}/categories`, body);
|
|
335
|
+
return { content: [{ type: 'text', text: `Created category: ${res.name} [id: ${res.id}]` }] };
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
server.tool('rename_category', 'Rename a category', {
|
|
339
|
+
list: z.string().describe('List name or ID'),
|
|
340
|
+
category: z.string().describe('Category name or ID'),
|
|
341
|
+
name: z.string().describe('New category name'),
|
|
342
|
+
}, async ({ list: query, category, name }) => {
|
|
343
|
+
const all = await api('GET', '/lists');
|
|
344
|
+
const match = findList(all.lists || all, query);
|
|
345
|
+
if (!match) throw new Error(`List "${query}" not found`);
|
|
346
|
+
const detail = await api('GET', `/lists/${match.id}`);
|
|
347
|
+
const cat = findCategory(detail.categories || [], category);
|
|
348
|
+
if (!cat) throw new Error(`Category "${category}" not found`);
|
|
349
|
+
const res = await api('PUT', `/lists/${match.id}/categories/${cat.id}`, { name });
|
|
350
|
+
return { content: [{ type: 'text', text: `Renamed category: ${cat.name} → ${res.name}` }] };
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
server.tool('delete_category', 'Delete a category (items will move to Other)', {
|
|
354
|
+
list: z.string().describe('List name or ID'),
|
|
355
|
+
category: z.string().describe('Category name or ID'),
|
|
356
|
+
}, async ({ list: query, category }) => {
|
|
357
|
+
const all = await api('GET', '/lists');
|
|
358
|
+
const match = findList(all.lists || all, query);
|
|
359
|
+
if (!match) throw new Error(`List "${query}" not found`);
|
|
360
|
+
const detail = await api('GET', `/lists/${match.id}`);
|
|
361
|
+
const cat = findCategory(detail.categories || [], category);
|
|
362
|
+
if (!cat) throw new Error(`Category "${category}" not found`);
|
|
363
|
+
const res = await api('DELETE', `/lists/${match.id}/categories/${cat.id}`);
|
|
364
|
+
return { content: [{ type: 'text', text: `Deleted category: ${cat.name} (moved ${res.moved_count ?? 0} item(s) to Other)` }] };
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
server.tool('reorder_categories', 'Reorder categories', {
|
|
368
|
+
list: z.string().describe('List name or ID'),
|
|
369
|
+
order: z.array(z.string()).describe('Desired order as an array of category names or IDs (must include each category exactly once)'),
|
|
370
|
+
}, async ({ list: query, order }) => {
|
|
371
|
+
const all = await api('GET', '/lists');
|
|
372
|
+
const match = findList(all.lists || all, query);
|
|
373
|
+
if (!match) throw new Error(`List "${query}" not found`);
|
|
374
|
+
const detail = await api('GET', `/lists/${match.id}`);
|
|
375
|
+
const categories = detail.categories || [];
|
|
376
|
+
if (categories.length === 0) throw new Error('This list has no categories');
|
|
377
|
+
|
|
378
|
+
const resolved = order.map(q => {
|
|
379
|
+
const cat = findCategory(categories, q);
|
|
380
|
+
if (!cat) throw new Error(`Category "${q}" not found`);
|
|
381
|
+
return cat;
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
const unique = new Set(resolved.map(c => c.id));
|
|
385
|
+
if (unique.size !== categories.length) {
|
|
386
|
+
throw new Error(`order must include each category exactly once (expected ${categories.length}, got ${unique.size})`);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
const payload = resolved.map((c, idx) => ({ category_id: c.id, position: idx }));
|
|
390
|
+
await api('PUT', `/lists/${match.id}/categories/reorder`, { order: payload });
|
|
391
|
+
return { content: [{ type: 'text', text: 'Categories reordered.' }] };
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
server.tool('move_item', 'Move an item to a category (or to Other)', {
|
|
395
|
+
list: z.string().describe('List name or ID'),
|
|
396
|
+
item: z.string().describe('Item text (fuzzy match)'),
|
|
397
|
+
category: z.string().describe('Target category name/ID, or "other" to uncategorize'),
|
|
398
|
+
}, async ({ list: query, item: itemQuery, category }) => {
|
|
399
|
+
const all = await api('GET', '/lists');
|
|
400
|
+
const match = findList(all.lists || all, query);
|
|
401
|
+
if (!match) throw new Error(`List "${query}" not found`);
|
|
402
|
+
const detail = await api('GET', `/lists/${match.id}`);
|
|
403
|
+
const items = detail.items || [];
|
|
404
|
+
const lower = itemQuery.toLowerCase();
|
|
405
|
+
const found = items.find(i => i.text.toLowerCase() === lower)
|
|
406
|
+
|| items.find(i => i.text.toLowerCase().includes(lower));
|
|
407
|
+
if (!found) throw new Error(`Item "${itemQuery}" not found in ${match.title}`);
|
|
408
|
+
|
|
409
|
+
const cLower = String(category).trim().toLowerCase();
|
|
410
|
+
let categoryId = null;
|
|
411
|
+
if (cLower === 'other' || cLower === 'uncategorized' || cLower === 'none' || cLower === 'null') {
|
|
412
|
+
categoryId = null;
|
|
413
|
+
} else {
|
|
414
|
+
const cat = findCategory(detail.categories || [], category);
|
|
415
|
+
if (!cat) throw new Error(`Category "${category}" not found`);
|
|
416
|
+
categoryId = cat.id;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
await api('PUT', `/lists/${match.id}/items/${found.id}`, { category_id: categoryId });
|
|
420
|
+
return { content: [{ type: 'text', text: `Moved: ${found.text} → ${categoryId ? category : 'Other'}` }] };
|
|
421
|
+
});
|
|
422
|
+
|
|
235
423
|
// Connect via stdio
|
|
236
424
|
const transport = new StdioServerTransport();
|
|
237
425
|
await server.connect(transport);
|