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 +3 -0
- package/package.json +34 -0
- package/src/api.js +159 -0
- package/src/config.js +56 -0
- package/src/index.js +369 -0
package/README.md
ADDED
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();
|