lystbot 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,3 @@
1
+ # @lystbot/cli
2
+
3
+ LystBot command-line interface. See the full documentation at [docs/cli/](../docs/cli/).
package/package.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "lystbot",
3
+ "version": "0.1.0",
4
+ "description": "LystBot CLI - Manage your lists from the terminal",
5
+ "main": "src/index.js",
6
+ "bin": {
7
+ "lystbot": "src/index.js"
8
+ },
9
+ "scripts": {
10
+ "start": "node src/index.js",
11
+ "test": "echo \"Tests coming soon\" && exit 0"
12
+ },
13
+ "keywords": [
14
+ "lystbot",
15
+ "lists",
16
+ "todo",
17
+ "ai",
18
+ "cli",
19
+ "productivity"
20
+ ],
21
+ "author": "AI Ventures Holding / TourAround UG",
22
+ "license": "MIT",
23
+ "dependencies": {
24
+ "commander": "^12.0.0"
25
+ },
26
+ "engines": {
27
+ "node": ">=18.0.0"
28
+ },
29
+ "repository": {
30
+ "type": "git",
31
+ "url": "https://github.com/TourAround/LystBot.git"
32
+ },
33
+ "homepage": "https://github.com/TourAround/LystBot/tree/main/docs/cli"
34
+ }
package/src/api.js ADDED
@@ -0,0 +1,159 @@
1
+ const config = require('./config');
2
+
3
+ async function request(method, path, body = null) {
4
+ const baseUrl = config.getBaseUrl();
5
+ const url = `${baseUrl}${path}`;
6
+
7
+ const headers = { 'Content-Type': 'application/json' };
8
+
9
+ // Some endpoints don't need auth (device registration)
10
+ const cfg = config.read();
11
+ if (cfg && cfg.apiKey) {
12
+ headers['Authorization'] = `Bearer ${cfg.apiKey}`;
13
+ }
14
+
15
+ const opts = { method, headers };
16
+ if (body) opts.body = JSON.stringify(body);
17
+
18
+ let res;
19
+ try {
20
+ res = await fetch(url, opts);
21
+ } catch (err) {
22
+ console.error(`❌ Network error: ${err.message}`);
23
+ process.exit(1);
24
+ }
25
+
26
+ if (res.status === 204) return null;
27
+
28
+ let data;
29
+ try {
30
+ data = await res.json();
31
+ } catch {
32
+ if (!res.ok) {
33
+ console.error(`❌ HTTP ${res.status}: ${res.statusText}`);
34
+ process.exit(1);
35
+ }
36
+ return null;
37
+ }
38
+
39
+ if (!res.ok) {
40
+ const msg = data?.error?.message || data?.message || JSON.stringify(data);
41
+ console.error(`❌ ${msg}`);
42
+ process.exit(1);
43
+ }
44
+
45
+ return data;
46
+ }
47
+
48
+ // Unauthenticated request with custom headers
49
+ async function rawRequest(method, path, body = null, extraHeaders = {}) {
50
+ const baseUrl = config.getBaseUrl();
51
+ const url = `${baseUrl}${path}`;
52
+ const headers = { 'Content-Type': 'application/json', ...extraHeaders };
53
+
54
+ const opts = { method, headers };
55
+ if (body) opts.body = JSON.stringify(body);
56
+
57
+ let res;
58
+ try {
59
+ res = await fetch(url, opts);
60
+ } catch (err) {
61
+ console.error(`❌ Network error: ${err.message}`);
62
+ process.exit(1);
63
+ }
64
+
65
+ if (res.status === 204) return null;
66
+
67
+ let data;
68
+ try {
69
+ data = await res.json();
70
+ } catch {
71
+ if (!res.ok) {
72
+ console.error(`❌ HTTP ${res.status}: ${res.statusText}`);
73
+ process.exit(1);
74
+ }
75
+ return null;
76
+ }
77
+
78
+ if (!res.ok) {
79
+ const msg = data?.error?.message || data?.message || JSON.stringify(data);
80
+ console.error(`❌ ${msg}`);
81
+ process.exit(1);
82
+ }
83
+
84
+ return data;
85
+ }
86
+
87
+ // Fuzzy match: find a list by name or ID from an array of lists
88
+ function findList(lists, query) {
89
+ // Try exact ID match
90
+ const byId = lists.find(l => String(l.id) === String(query));
91
+ if (byId) return byId;
92
+
93
+ // Try exact name match (case-insensitive)
94
+ const lower = query.toLowerCase();
95
+ const exact = lists.find(l => (l.title || l.name || '').toLowerCase() === lower);
96
+ if (exact) return exact;
97
+
98
+ // Try startsWith
99
+ const starts = lists.find(l => (l.title || l.name || '').toLowerCase().startsWith(lower));
100
+ if (starts) return starts;
101
+
102
+ // Try includes
103
+ const includes = lists.find(l => (l.title || l.name || '').toLowerCase().includes(lower));
104
+ if (includes) return includes;
105
+
106
+ return null;
107
+ }
108
+
109
+ // Fuzzy match an item by text
110
+ function findItem(items, query) {
111
+ const lower = query.toLowerCase();
112
+ const exact = items.find(i => i.text.toLowerCase() === lower);
113
+ if (exact) return exact;
114
+
115
+ const starts = items.find(i => i.text.toLowerCase().startsWith(lower));
116
+ if (starts) return starts;
117
+
118
+ const includes = items.find(i => i.text.toLowerCase().includes(lower));
119
+ if (includes) return includes;
120
+
121
+ return null;
122
+ }
123
+
124
+ // Resolve a list query to {list, detail} - fetches all lists then the specific one
125
+ async function resolveList(query, { withItems = false } = {}) {
126
+ const res = await request('GET', '/lists');
127
+ const lists = res.lists || res;
128
+ const match = findList(lists, query);
129
+ if (!match) {
130
+ const names = lists.map(l => `${l.emoji || '📋'} ${l.title || l.name}`).join(', ');
131
+ console.error(`❌ List '${query}' not found. Your lists: ${names || '(none)'}`);
132
+ process.exit(1);
133
+ }
134
+ if (withItems) {
135
+ const detail = await request('GET', `/lists/${match.id}`);
136
+ return { list: match, detail };
137
+ }
138
+ return { list: match };
139
+ }
140
+
141
+ // Pick an emoji based on list name
142
+ function autoEmoji(name) {
143
+ const lower = name.toLowerCase();
144
+ const map = {
145
+ grocer: '🛒', shopping: '🛍️', food: '🍕', meal: '🍽️',
146
+ todo: '✅', task: '📝', work: '💼', home: '🏠',
147
+ travel: '✈️', trip: '🧳', pack: '🧳', vacation: '🏖️',
148
+ book: '📚', read: '📖', movie: '🎬', watch: '📺',
149
+ music: '🎵', gift: '🎁', wish: '⭐', fitness: '💪',
150
+ recipe: '👨‍🍳', cook: '🍳', clean: '🧹', garden: '🌱',
151
+ pet: '🐾', baby: '👶', school: '🎓', project: '🚀',
152
+ };
153
+ for (const [key, emoji] of Object.entries(map)) {
154
+ if (lower.includes(key)) return emoji;
155
+ }
156
+ return '📋';
157
+ }
158
+
159
+ module.exports = { request, rawRequest, findList, findItem, resolveList, autoEmoji };
package/src/config.js ADDED
@@ -0,0 +1,56 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const os = require('os');
4
+
5
+ const CONFIG_DIR = path.join(os.homedir(), '.lystbot');
6
+ const CONFIG_PATH = path.join(CONFIG_DIR, 'config.json');
7
+
8
+ function read() {
9
+ try {
10
+ const data = fs.readFileSync(CONFIG_PATH, 'utf8');
11
+ return JSON.parse(data);
12
+ } catch {
13
+ return null;
14
+ }
15
+ }
16
+
17
+ function write(config) {
18
+ fs.mkdirSync(CONFIG_DIR, { recursive: true });
19
+ fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2) + '\n');
20
+ }
21
+
22
+ function remove() {
23
+ try {
24
+ fs.unlinkSync(CONFIG_PATH);
25
+ return true;
26
+ } catch {
27
+ return false;
28
+ }
29
+ }
30
+
31
+ function getApiKey() {
32
+ const cfg = read();
33
+ if (!cfg || !cfg.apiKey) {
34
+ console.error('❌ Not logged in. Run: lystbot login');
35
+ process.exit(1);
36
+ }
37
+ return cfg.apiKey;
38
+ }
39
+
40
+ const PROD_URL = 'https://lystbot.com/api/v1';
41
+
42
+ let _customUrl = null;
43
+
44
+ function setCustomUrl(url) {
45
+ _customUrl = url;
46
+ }
47
+
48
+ function getBaseUrl() {
49
+ if (_customUrl) return _customUrl;
50
+ if (process.env.LYSTBOT_API_URL) return process.env.LYSTBOT_API_URL;
51
+ const cfg = read();
52
+ if (cfg && cfg.apiUrl) return cfg.apiUrl;
53
+ return PROD_URL;
54
+ }
55
+
56
+ module.exports = { read, write, remove, getApiKey, getBaseUrl, setCustomUrl, PROD_URL, CONFIG_PATH };
package/src/index.js ADDED
@@ -0,0 +1,369 @@
1
+ #!/usr/bin/env node
2
+
3
+ const { Command } = require('commander');
4
+ const { randomUUID } = require('crypto');
5
+ const readline = require('readline');
6
+ const config = require('./config');
7
+ const api = require('./api');
8
+
9
+ const program = new Command();
10
+
11
+ program
12
+ .name('lystbot')
13
+ .description('📋 LystBot CLI - Manage your lists from the terminal')
14
+ .version('0.1.0')
15
+ .option('--api <url>', 'Use custom API URL')
16
+ .hook('preAction', () => {
17
+ const opts = program.opts();
18
+ if (opts.api) {
19
+ config.setCustomUrl(opts.api);
20
+ console.log(`🔗 API: ${opts.api}\n`);
21
+ }
22
+ });
23
+
24
+ // ── login ──────────────────────────────────────────────
25
+ program
26
+ .command('login')
27
+ .description('Authenticate with LystBot')
28
+ .option('--token <api-key>', 'Set API key directly')
29
+ .action(async (options) => {
30
+ if (options.token) {
31
+ config.write({ apiKey: options.token, apiUrl: config.getBaseUrl() });
32
+ console.log('✅ Logged in! API key stored.');
33
+ return;
34
+ }
35
+
36
+ // Interactive registration
37
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
38
+ const ask = (q) => new Promise(resolve => rl.question(q, resolve));
39
+
40
+ console.log('🔐 LystBot Device Registration\n');
41
+ const name = await ask('Device name (e.g. "My Laptop"): ');
42
+ rl.close();
43
+
44
+ if (!name.trim()) {
45
+ console.error('❌ Name cannot be empty.');
46
+ process.exit(1);
47
+ }
48
+
49
+ console.log('\n⏳ Registering device...');
50
+ const uuid = randomUUID();
51
+ const device = await api.rawRequest('POST', '/devices/register', {
52
+ uuid,
53
+ name: name.trim(),
54
+ platform: 'cli',
55
+ });
56
+
57
+ console.log(`📱 Device registered: ${device.uuid}`);
58
+
59
+ // API key may come directly in register response or via separate endpoint
60
+ let apiKey = device.api_key || device.apiKey;
61
+ if (!apiKey) {
62
+ console.log('⏳ Fetching API key...');
63
+ const keyData = await api.rawRequest('GET', `/devices/${device.uuid}/api-key`, null, {
64
+ 'X-Device-UUID': device.uuid,
65
+ });
66
+ apiKey = keyData.api_key || keyData.apiKey;
67
+ }
68
+
69
+ config.write({
70
+ apiKey,
71
+ deviceUuid: device.uuid,
72
+ deviceName: name.trim(),
73
+ apiUrl: config.getBaseUrl(),
74
+ });
75
+
76
+ console.log('✅ Logged in! You\'re all set 🎉');
77
+ });
78
+
79
+ // ── logout ─────────────────────────────────────────────
80
+ program
81
+ .command('logout')
82
+ .description('Remove stored credentials')
83
+ .action(() => {
84
+ if (config.remove()) {
85
+ console.log('👋 Logged out. Config removed.');
86
+ } else {
87
+ console.log('ℹ️ Already logged out (no config found).');
88
+ }
89
+ });
90
+
91
+ // ── whoami ─────────────────────────────────────────────
92
+ program
93
+ .command('whoami')
94
+ .description('Show current auth info')
95
+ .action(() => {
96
+ const cfg = config.read();
97
+ if (!cfg) {
98
+ console.log('❌ Not logged in. Run: lystbot login');
99
+ process.exit(1);
100
+ }
101
+ const masked = cfg.apiKey
102
+ ? cfg.apiKey.slice(0, 12) + '...' + cfg.apiKey.slice(-4)
103
+ : '(none)';
104
+ console.log('👤 LystBot CLI');
105
+ console.log(` API URL: ${cfg.apiUrl || config.getBaseUrl()}`);
106
+ if (cfg.deviceName) console.log(` Device: ${cfg.deviceName}`);
107
+ if (cfg.deviceUuid) console.log(` UUID: ${cfg.deviceUuid}`);
108
+ console.log(` API Key: ${masked}`);
109
+ });
110
+
111
+ // ── lists ──────────────────────────────────────────────
112
+ program
113
+ .command('lists')
114
+ .description('Show all your lists')
115
+ .option('--json', 'Output as JSON')
116
+ .action(async (options) => {
117
+ config.getApiKey(); // ensure logged in
118
+ const res = await api.request('GET', '/lists');
119
+ const lists = res.lists || res;
120
+
121
+ if (options.json) {
122
+ console.log(JSON.stringify(lists, null, 2));
123
+ return;
124
+ }
125
+
126
+ if (!lists.length) {
127
+ console.log('📋 No lists yet. Create one: lystbot create "My List"');
128
+ return;
129
+ }
130
+
131
+ console.log('📋 Your Lists\n');
132
+ for (const l of lists) {
133
+ const emoji = l.emoji || '📋';
134
+ const shared = l.is_shared ? ' 👥' : '';
135
+ const total = l.item_count || 0;
136
+ const unchecked = l.unchecked_count || 0;
137
+ const checked = total - unchecked;
138
+ const count = `${checked}/${total}`;
139
+ console.log(` ${emoji} ${l.title || l.name} (${count} done)${shared}`);
140
+ }
141
+ console.log(`\n ${lists.length} list${lists.length === 1 ? '' : 's'} total`);
142
+ });
143
+
144
+ // ── show ───────────────────────────────────────────────
145
+ program
146
+ .command('show <list>')
147
+ .description('Show items in a list')
148
+ .option('--json', 'Output as JSON')
149
+ .action(async (listQuery, options) => {
150
+ config.getApiKey();
151
+ const { detail } = await api.resolveList(listQuery, { withItems: true });
152
+
153
+ if (options.json) {
154
+ console.log(JSON.stringify(detail, null, 2));
155
+ return;
156
+ }
157
+
158
+ const emoji = detail.emoji || '📋';
159
+ const listTitle = detail.title || detail.name;
160
+ const memberCount = Array.isArray(detail.members) ? detail.members.length : (detail.members || 0);
161
+ const shared = detail.is_shared ? ` 👥 (${memberCount} member${memberCount === 1 ? '' : 's'})` : '';
162
+ console.log(`\n${emoji} ${listTitle}${shared}\n`);
163
+
164
+ if (!detail.items || !detail.items.length) {
165
+ console.log(' (empty list)');
166
+ return;
167
+ }
168
+
169
+ for (const item of detail.items) {
170
+ if (item.checked) {
171
+ console.log(` ✅ ~~${item.text}~~`);
172
+ } else {
173
+ console.log(` ⬜ ${item.text}`);
174
+ }
175
+ }
176
+
177
+ const checked = detail.items.filter(i => i.checked).length;
178
+ console.log(`\n ${checked}/${detail.items.length} done`);
179
+ });
180
+
181
+ // ── add ────────────────────────────────────────────────
182
+ program
183
+ .command('add <list> <items...>')
184
+ .description('Add items to a list (supports comma-separated)')
185
+ .action(async (listQuery, rawItems) => {
186
+ config.getApiKey();
187
+
188
+ // Smart parsing: split on commas, trim, flatten
189
+ const items = rawItems
190
+ .flatMap(i => i.split(','))
191
+ .map(i => i.trim())
192
+ .filter(Boolean);
193
+
194
+ if (!items.length) {
195
+ console.error('❌ No items to add.');
196
+ process.exit(1);
197
+ }
198
+
199
+ // Try to find the list
200
+ const listsRes = await api.request('GET', '/lists');
201
+ const lists = listsRes.lists || listsRes;
202
+ let match = api.findList(lists, listQuery);
203
+
204
+ // If not found, offer to create it
205
+ if (!match) {
206
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
207
+ const answer = await new Promise(resolve =>
208
+ rl.question(`📋 List '${listQuery}' doesn't exist. Create it? (Y/n) `, resolve)
209
+ );
210
+ rl.close();
211
+
212
+ if (answer.toLowerCase() === 'n') {
213
+ console.log('Cancelled.');
214
+ process.exit(0);
215
+ }
216
+
217
+ const emoji = api.autoEmoji(listQuery);
218
+ const newId = randomUUID();
219
+ await api.request('POST', '/lists', { id: newId, title: listQuery, type: 'generic', emoji });
220
+ match = { id: newId, title: listQuery, emoji };
221
+ console.log(`✨ Created list: ${emoji} ${listQuery}`);
222
+ }
223
+
224
+ // Add items one by one (each needs a client-generated UUID)
225
+ let added = 0;
226
+ for (let idx = 0; idx < items.length; idx++) {
227
+ await api.request('POST', `/lists/${match.id}/items`, {
228
+ id: randomUUID(),
229
+ text: items[idx],
230
+ quantity: 1,
231
+ unit: null,
232
+ position: idx,
233
+ });
234
+ added++;
235
+ }
236
+
237
+ const emoji = match.emoji || '📋';
238
+ console.log(`➕ Added ${added} item${added === 1 ? '' : 's'} to ${emoji} ${match.title || match.name}`);
239
+ for (const text of items) {
240
+ console.log(` • ${text}`);
241
+ }
242
+ });
243
+
244
+ // ── check ──────────────────────────────────────────────
245
+ program
246
+ .command('check <list> <item>')
247
+ .description('Mark an item as done')
248
+ .action(async (listQuery, itemQuery) => {
249
+ config.getApiKey();
250
+ const { list, detail } = await api.resolveList(listQuery, { withItems: true });
251
+
252
+ const item = api.findItem(detail.items || [], itemQuery);
253
+ if (!item) {
254
+ const names = (detail.items || []).map(i => i.text).join(', ');
255
+ console.error(`❌ Item '${itemQuery}' not found in ${list.title || list.name}. Items: ${names || '(empty)'}`);
256
+ process.exit(1);
257
+ }
258
+
259
+ await api.request('PUT', `/lists/${list.id}/items/${item.id}`, { checked: true });
260
+ console.log(`✅ Checked: ${item.text}`);
261
+ });
262
+
263
+ // ── uncheck ────────────────────────────────────────────
264
+ program
265
+ .command('uncheck <list> <item>')
266
+ .description('Unmark an item')
267
+ .action(async (listQuery, itemQuery) => {
268
+ config.getApiKey();
269
+ const { list, detail } = await api.resolveList(listQuery, { withItems: true });
270
+
271
+ const item = api.findItem(detail.items || [], itemQuery);
272
+ if (!item) {
273
+ const names = (detail.items || []).map(i => i.text).join(', ');
274
+ console.error(`❌ Item '${itemQuery}' not found in ${list.title || list.name}. Items: ${names || '(empty)'}`);
275
+ process.exit(1);
276
+ }
277
+
278
+ await api.request('PUT', `/lists/${list.id}/items/${item.id}`, { checked: false });
279
+ console.log(`⬜ Unchecked: ${item.text}`);
280
+ });
281
+
282
+ // ── remove ─────────────────────────────────────────────
283
+ program
284
+ .command('remove <list> <item>')
285
+ .description('Remove an item from a list')
286
+ .action(async (listQuery, itemQuery) => {
287
+ config.getApiKey();
288
+ const { list, detail } = await api.resolveList(listQuery, { withItems: true });
289
+
290
+ const item = api.findItem(detail.items || [], itemQuery);
291
+ if (!item) {
292
+ const names = (detail.items || []).map(i => i.text).join(', ');
293
+ console.error(`❌ Item '${itemQuery}' not found in ${list.title || list.name}. Items: ${names || '(empty)'}`);
294
+ process.exit(1);
295
+ }
296
+
297
+ await api.request('DELETE', `/lists/${list.id}/items/${item.id}`);
298
+ console.log(`🗑️ Removed: ${item.text}`);
299
+ });
300
+
301
+ // ── create ─────────────────────────────────────────────
302
+ program
303
+ .command('create <name>')
304
+ .description('Create a new list')
305
+ .option('--emoji <emoji>', 'List emoji')
306
+ .option('--type <type>', 'List type (shopping|todo|packing|generic)', 'generic')
307
+ .action(async (name, options) => {
308
+ config.getApiKey();
309
+ const emoji = options.emoji || api.autoEmoji(name);
310
+ const list = await api.request('POST', '/lists', {
311
+ id: randomUUID(),
312
+ title: name,
313
+ type: options.type,
314
+ emoji,
315
+ });
316
+ console.log(`✨ Created: ${emoji} ${name}`);
317
+ });
318
+
319
+ // ── delete ─────────────────────────────────────────────
320
+ program
321
+ .command('delete <list>')
322
+ .description('Delete a list')
323
+ .option('--force', 'Skip confirmation')
324
+ .action(async (listQuery, options) => {
325
+ config.getApiKey();
326
+ const { list } = await api.resolveList(listQuery);
327
+
328
+ if (!options.force) {
329
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
330
+ const answer = await new Promise(resolve =>
331
+ rl.question(`🗑️ Delete '${list.emoji || ''} ${list.title || list.name}'? This cannot be undone. (y/N) `, resolve)
332
+ );
333
+ rl.close();
334
+
335
+ if (answer.toLowerCase() !== 'y') {
336
+ console.log('Cancelled.');
337
+ process.exit(0);
338
+ }
339
+ }
340
+
341
+ await api.request('DELETE', `/lists/${list.id}`);
342
+ console.log(`🗑️ Deleted: ${list.emoji || '📋'} ${list.title || list.name}`);
343
+ });
344
+
345
+ // ── share ──────────────────────────────────────────────
346
+ program
347
+ .command('share <list>')
348
+ .description('Generate a share code for a list')
349
+ .action(async (listQuery) => {
350
+ config.getApiKey();
351
+ const { list } = await api.resolveList(listQuery);
352
+
353
+ const result = await api.request('POST', `/lists/${list.id}/share`);
354
+ console.log(`\n🔗 Share code for ${list.emoji || '📋'} ${list.title || list.name}:\n`);
355
+ console.log(` ${result.shareCode}\n`);
356
+ console.log(` Others can join with: lystbot join ${result.shareCode}`);
357
+ });
358
+
359
+ // ── join ───────────────────────────────────────────────
360
+ program
361
+ .command('join <code>')
362
+ .description('Join a shared list using a share code')
363
+ .action(async (code) => {
364
+ config.getApiKey();
365
+ const list = await api.request('POST', '/lists/join', { shareCode: code });
366
+ console.log(`🤝 Joined: ${list.emoji || '📋'} ${list.title || list.name} (${list.item_count || 0} items)`);
367
+ });
368
+
369
+ program.parse();