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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lystbot",
3
- "version": "0.3.6",
3
+ "version": "0.3.7",
4
4
  "mcpName": "io.github.TourAround/lystbot",
5
5
  "description": "LystBot CLI - Manage your lists from the terminal",
6
6
  "main": "src/index.js",
package/server.json CHANGED
@@ -7,12 +7,12 @@
7
7
  "source": "github",
8
8
  "subfolder": "cli"
9
9
  },
10
- "version": "0.3.5",
10
+ "version": "0.3.7",
11
11
  "packages": [
12
12
  {
13
13
  "registryType": "npm",
14
14
  "identifier": "lystbot",
15
- "version": "0.3.5",
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
- if (!detail.items || !detail.items.length) {
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
- for (const item of detail.items) {
186
- if (item.checked) {
187
- console.log(` ✅ ~~${item.text}~~`);
188
- } else {
189
- console.log(` ⬜ ${item.text}`);
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 checked = detail.items.filter(i => i.checked).length;
194
- console.log(`\n ${checked}/${detail.items.length} done`);
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
- .action(async (listQuery, rawItems) => {
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
- }, async ({ list: query, items: itemsStr }) => {
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', { share_code: code });
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);