@innerstacklabs/neuralingual-mcp 0.2.0 → 0.4.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 +62 -0
- package/dist/cli.js +209 -487
- package/dist/cli.js.map +1 -1
- package/dist/set-file.d.ts +5 -0
- package/dist/set-file.d.ts.map +1 -1
- package/dist/set-file.js +2 -0
- package/dist/set-file.js.map +1 -1
- package/dist/types.d.ts +72 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/user-client.d.ts +15 -2
- package/dist/user-client.d.ts.map +1 -1
- package/dist/user-client.js +46 -3
- package/dist/user-client.js.map +1 -1
- package/dist/user-mcp.d.ts +10 -0
- package/dist/user-mcp.d.ts.map +1 -0
- package/dist/user-mcp.js +553 -0
- package/dist/user-mcp.js.map +1 -0
- package/package.json +5 -3
package/dist/cli.js
CHANGED
|
@@ -1,24 +1,23 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { Command } from 'commander';
|
|
3
|
-
import { spawn,
|
|
3
|
+
import { spawn, exec } from 'child_process';
|
|
4
4
|
import { createServer } from 'http';
|
|
5
5
|
import { randomBytes } from 'crypto';
|
|
6
|
-
import { writeFileSync,
|
|
6
|
+
import { writeFileSync, existsSync, mkdirSync } from 'fs';
|
|
7
7
|
import { createInterface } from 'readline';
|
|
8
|
-
import {
|
|
8
|
+
import { homedir } from 'os';
|
|
9
9
|
import { join } from 'path';
|
|
10
10
|
import { UserApiClient } from './user-client.js';
|
|
11
11
|
import { loadAuth, clearAuth } from './auth-store.js';
|
|
12
|
-
import {
|
|
13
|
-
import { serializeSetFile, parseSetFile } from './set-file.js';
|
|
12
|
+
import { parseSetFile } from './set-file.js';
|
|
14
13
|
import { z } from 'zod';
|
|
15
14
|
const VALID_TONES = ['grounded', 'open', 'mystical'];
|
|
16
15
|
const VALID_CONTEXTS = ['general', 'sleep', 'nap', 'meditation', 'workout', 'focus', 'walk', 'chores'];
|
|
17
16
|
const program = new Command();
|
|
18
17
|
program
|
|
19
18
|
.name('neuralingual')
|
|
20
|
-
.description('Neuralingual — AI-powered affirmation practice sets')
|
|
21
|
-
.version('0.
|
|
19
|
+
.description('Neuralingual CLI — AI-powered affirmation practice sets')
|
|
20
|
+
.version('0.1.0')
|
|
22
21
|
.option('--env <env>', 'API environment: dev or production (default: production)', 'production');
|
|
23
22
|
function printResult(data, isError = false) {
|
|
24
23
|
const text = typeof data === 'string' ? data : JSON.stringify(data, null, 2);
|
|
@@ -42,301 +41,6 @@ function printTable(rows, headers) {
|
|
|
42
41
|
console.log(line(row));
|
|
43
42
|
}
|
|
44
43
|
}
|
|
45
|
-
// ─── render ──────────────────────────────────────────────────────────────────
|
|
46
|
-
const renderCmd = program.command('render').description('Render audio for an intent');
|
|
47
|
-
renderCmd
|
|
48
|
-
.command('configure <intent-id>')
|
|
49
|
-
.description('Configure render settings for an intent')
|
|
50
|
-
.requiredOption('--voice <name>', 'Voice ID (externalId) to use for rendering')
|
|
51
|
-
.requiredOption('--context <context>', `Session context: ${VALID_CONTEXTS.join(', ')}`)
|
|
52
|
-
.requiredOption('--duration <minutes>', 'Duration in minutes', parseInt)
|
|
53
|
-
.option('--pace <wpm>', 'Pace in words per minute (uses context default if omitted)', parseInt)
|
|
54
|
-
.option('--background <key>', 'Background sound storageKey (use neuralingual voices list; omit to disable)')
|
|
55
|
-
.option('--background-volume <level>', 'Background volume 0–1 (uses context default if omitted)', parseFloat)
|
|
56
|
-
.option('--repeats <n>', 'Number of times each affirmation repeats (uses context default if omitted)', parseInt)
|
|
57
|
-
.option('--preamble <on|off>', 'Include intro/outro preamble: on or off (preserves existing setting if omitted)')
|
|
58
|
-
.option('--play-all <on|off>', 'Play all affirmations instead of fitting within duration: on or off (preserves existing setting if omitted)')
|
|
59
|
-
.action(async (intentId, opts) => {
|
|
60
|
-
if (!VALID_CONTEXTS.includes(opts.context)) {
|
|
61
|
-
console.error(`Error: --context must be one of: ${VALID_CONTEXTS.join(', ')}`);
|
|
62
|
-
process.exit(1);
|
|
63
|
-
}
|
|
64
|
-
if (isNaN(opts.duration) || opts.duration < 1) {
|
|
65
|
-
console.error('Error: --duration must be a positive integer');
|
|
66
|
-
process.exit(1);
|
|
67
|
-
}
|
|
68
|
-
if (opts.pace !== undefined && (isNaN(opts.pace) || opts.pace < 90 || opts.pace > 220)) {
|
|
69
|
-
console.error('Error: --pace must be between 90 and 220');
|
|
70
|
-
process.exit(1);
|
|
71
|
-
}
|
|
72
|
-
if (opts.backgroundVolume !== undefined &&
|
|
73
|
-
(isNaN(opts.backgroundVolume) || opts.backgroundVolume < 0 || opts.backgroundVolume > 1)) {
|
|
74
|
-
console.error('Error: --background-volume must be between 0 and 1');
|
|
75
|
-
process.exit(1);
|
|
76
|
-
}
|
|
77
|
-
if (opts.repeats !== undefined && (isNaN(opts.repeats) || opts.repeats < 1 || opts.repeats > 5)) {
|
|
78
|
-
console.error('Error: --repeats must be between 1 and 5');
|
|
79
|
-
process.exit(1);
|
|
80
|
-
}
|
|
81
|
-
if (opts.preamble !== undefined && opts.preamble !== 'on' && opts.preamble !== 'off') {
|
|
82
|
-
console.error('Error: --preamble must be "on" or "off"');
|
|
83
|
-
process.exit(1);
|
|
84
|
-
}
|
|
85
|
-
if (opts.playAll !== undefined && opts.playAll !== 'on' && opts.playAll !== 'off') {
|
|
86
|
-
console.error('Error: --play-all must be "on" or "off"');
|
|
87
|
-
process.exit(1);
|
|
88
|
-
}
|
|
89
|
-
try {
|
|
90
|
-
const client = getUserClient();
|
|
91
|
-
const resolvedId = await resolveIntentId(client, intentId);
|
|
92
|
-
const input = {
|
|
93
|
-
voiceId: opts.voice,
|
|
94
|
-
sessionContext: opts.context,
|
|
95
|
-
durationMinutes: opts.duration,
|
|
96
|
-
};
|
|
97
|
-
if (opts.pace !== undefined)
|
|
98
|
-
input.paceWpm = opts.pace;
|
|
99
|
-
if (opts.background !== undefined)
|
|
100
|
-
input.backgroundAudioPath = opts.background;
|
|
101
|
-
if (opts.backgroundVolume !== undefined)
|
|
102
|
-
input.backgroundVolume = opts.backgroundVolume;
|
|
103
|
-
if (opts.repeats !== undefined)
|
|
104
|
-
input.affirmationRepeatCount = opts.repeats;
|
|
105
|
-
if (opts.preamble !== undefined)
|
|
106
|
-
input.includePreamble = opts.preamble === 'on';
|
|
107
|
-
if (opts.playAll !== undefined)
|
|
108
|
-
input.playAll = opts.playAll === 'on';
|
|
109
|
-
const result = await client.configureRender(resolvedId, input);
|
|
110
|
-
printResult(result);
|
|
111
|
-
}
|
|
112
|
-
catch (err) {
|
|
113
|
-
printResult(err instanceof Error ? err.message : String(err), true);
|
|
114
|
-
}
|
|
115
|
-
});
|
|
116
|
-
renderCmd
|
|
117
|
-
.command('start <intent-id>')
|
|
118
|
-
.description('Start a render job for an intent')
|
|
119
|
-
.option('--wait', 'Wait for the render to complete, showing progress')
|
|
120
|
-
.action(async (intentId, opts) => {
|
|
121
|
-
const client = getUserClient();
|
|
122
|
-
const resolvedId = await resolveIntentId(client, intentId);
|
|
123
|
-
try {
|
|
124
|
-
const result = await client.startRender(resolvedId);
|
|
125
|
-
if (!opts.wait) {
|
|
126
|
-
printResult(result);
|
|
127
|
-
return;
|
|
128
|
-
}
|
|
129
|
-
const { jobId } = result;
|
|
130
|
-
console.error(`Render queued (job: ${jobId}). Waiting for completion...`);
|
|
131
|
-
// Poll until the specific job we started completes or fails
|
|
132
|
-
let elapsedMs = 0;
|
|
133
|
-
const POLL_INITIAL_MS = 3000;
|
|
134
|
-
const POLL_BACKOFF_AFTER_MS = 30000;
|
|
135
|
-
const POLL_BACKOFF_MS = 6000;
|
|
136
|
-
for (;;) {
|
|
137
|
-
const intervalMs = elapsedMs >= POLL_BACKOFF_AFTER_MS ? POLL_BACKOFF_MS : POLL_INITIAL_MS;
|
|
138
|
-
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
|
139
|
-
elapsedMs += intervalMs;
|
|
140
|
-
let status;
|
|
141
|
-
try {
|
|
142
|
-
status = await client.getRenderStatus(resolvedId);
|
|
143
|
-
}
|
|
144
|
-
catch (pollErr) {
|
|
145
|
-
console.error(`Warning: status poll failed — ${pollErr instanceof Error ? pollErr.message : String(pollErr)}`);
|
|
146
|
-
continue;
|
|
147
|
-
}
|
|
148
|
-
// Guard: if status has no jobId at all, the render config was likely reconfigured
|
|
149
|
-
// and the job we started is no longer the current one — exit to avoid infinite loop
|
|
150
|
-
if (status.jobId === undefined) {
|
|
151
|
-
console.error(`Warning: render status has no active job (config may have been reconfigured). Exiting --wait.`);
|
|
152
|
-
printResult(status);
|
|
153
|
-
return;
|
|
154
|
-
}
|
|
155
|
-
// Guard: if the status is tracking a different job (concurrent start), stop waiting
|
|
156
|
-
if (status.jobId !== jobId) {
|
|
157
|
-
console.error(`Warning: render status is now tracking a different job (${status.jobId}). Exiting --wait.`);
|
|
158
|
-
printResult(status);
|
|
159
|
-
return;
|
|
160
|
-
}
|
|
161
|
-
console.error(` [${Math.round(elapsedMs / 1000)}s] status=${status.status} progress=${status.progress}%`);
|
|
162
|
-
if (status.status === 'completed') {
|
|
163
|
-
printResult(status);
|
|
164
|
-
return;
|
|
165
|
-
}
|
|
166
|
-
if (status.status === 'failed') {
|
|
167
|
-
console.error(`Render failed: ${status.errorMessage ?? 'unknown error'}`);
|
|
168
|
-
process.exit(1);
|
|
169
|
-
}
|
|
170
|
-
}
|
|
171
|
-
}
|
|
172
|
-
catch (err) {
|
|
173
|
-
printResult(err instanceof Error ? err.message : String(err), true);
|
|
174
|
-
}
|
|
175
|
-
});
|
|
176
|
-
renderCmd
|
|
177
|
-
.command('status <intent-id>')
|
|
178
|
-
.description('Get the current render status for an intent')
|
|
179
|
-
.action(async (intentId) => {
|
|
180
|
-
const client = getUserClient();
|
|
181
|
-
try {
|
|
182
|
-
const resolvedId = await resolveIntentId(client, intentId);
|
|
183
|
-
const result = await client.getRenderStatus(resolvedId);
|
|
184
|
-
printResult(result);
|
|
185
|
-
}
|
|
186
|
-
catch (err) {
|
|
187
|
-
printResult(err instanceof Error ? err.message : String(err), true);
|
|
188
|
-
}
|
|
189
|
-
});
|
|
190
|
-
// ─── voices ──────────────────────────────────────────────────────────────────
|
|
191
|
-
const voicesCmd = program.command('voices').description('Browse and preview available voices');
|
|
192
|
-
/** Resolve API env: explicit --env flag wins, then stored auth, then default 'production'. */
|
|
193
|
-
function resolveApiEnv() {
|
|
194
|
-
const opts = program.opts();
|
|
195
|
-
const explicitEnv = process.argv.some((a) => a === '--env' || a.startsWith('--env='));
|
|
196
|
-
const env = (explicitEnv ? opts['env'] : loadAuth()?.env ?? opts['env'] ?? 'production');
|
|
197
|
-
if (env !== 'dev' && env !== 'production') {
|
|
198
|
-
console.error(`Error: --env must be "dev" or "production", got "${env}"`);
|
|
199
|
-
process.exit(1);
|
|
200
|
-
}
|
|
201
|
-
return env;
|
|
202
|
-
}
|
|
203
|
-
/** Resolve the API base URL using resolveApiEnv(). */
|
|
204
|
-
function getApiBaseUrl() {
|
|
205
|
-
return API_BASE_URLS[resolveApiEnv()];
|
|
206
|
-
}
|
|
207
|
-
const voiceDtoSchema = z.object({
|
|
208
|
-
id: z.string(),
|
|
209
|
-
provider: z.string(),
|
|
210
|
-
displayName: z.string(),
|
|
211
|
-
description: z.string().nullable(),
|
|
212
|
-
gender: z.string(),
|
|
213
|
-
accent: z.string(),
|
|
214
|
-
tier: z.string(),
|
|
215
|
-
category: z.string().nullable(),
|
|
216
|
-
playCount: z.number(),
|
|
217
|
-
});
|
|
218
|
-
const voicesResponseSchema = z.object({
|
|
219
|
-
voices: z.array(voiceDtoSchema),
|
|
220
|
-
});
|
|
221
|
-
voicesCmd
|
|
222
|
-
.command('show', { isDefault: true })
|
|
223
|
-
.description('List available voices')
|
|
224
|
-
.option('--gender <gender>', 'Filter by gender (e.g. Male, Female)')
|
|
225
|
-
.option('--accent <accent>', 'Filter by accent (e.g. US, UK, AU)')
|
|
226
|
-
.option('--tier <tier>', 'Filter by tier (e.g. free, premium)')
|
|
227
|
-
.option('--json', 'Output raw JSON')
|
|
228
|
-
.action(async (opts) => {
|
|
229
|
-
try {
|
|
230
|
-
const baseUrl = getApiBaseUrl();
|
|
231
|
-
const res = await fetch(`${baseUrl}/voices`);
|
|
232
|
-
if (!res.ok)
|
|
233
|
-
throw new Error(`HTTP ${res.status}`);
|
|
234
|
-
let { voices } = voicesResponseSchema.parse(await res.json());
|
|
235
|
-
if (opts.gender) {
|
|
236
|
-
const g = opts.gender.toLowerCase();
|
|
237
|
-
voices = voices.filter((v) => v.gender.toLowerCase() === g);
|
|
238
|
-
}
|
|
239
|
-
if (opts.accent) {
|
|
240
|
-
const a = opts.accent.toLowerCase();
|
|
241
|
-
voices = voices.filter((v) => v.accent.toLowerCase() === a);
|
|
242
|
-
}
|
|
243
|
-
if (opts.tier) {
|
|
244
|
-
const t = opts.tier.toLowerCase();
|
|
245
|
-
voices = voices.filter((v) => v.tier.toLowerCase() === t);
|
|
246
|
-
}
|
|
247
|
-
if (opts.json) {
|
|
248
|
-
console.log(JSON.stringify(voices, null, 2));
|
|
249
|
-
return;
|
|
250
|
-
}
|
|
251
|
-
if (voices.length === 0) {
|
|
252
|
-
console.log('No voices found matching your filters.');
|
|
253
|
-
return;
|
|
254
|
-
}
|
|
255
|
-
const truncate = (s, max) => {
|
|
256
|
-
if (!s)
|
|
257
|
-
return '';
|
|
258
|
-
return s.length > max ? s.slice(0, max - 1) + '\u2026' : s;
|
|
259
|
-
};
|
|
260
|
-
printTable(voices.map((v) => [v.id, v.displayName, v.gender, v.accent, v.tier, truncate(v.description, 50)]), ['ID', 'NAME', 'GENDER', 'ACCENT', 'TIER', 'DESCRIPTION']);
|
|
261
|
-
console.log(`\nUse --voice <ID> with 'neuralingual render configure' to select a voice.`);
|
|
262
|
-
console.log(`Use 'neuralingual voices preview <ID>' to hear a voice sample.`);
|
|
263
|
-
}
|
|
264
|
-
catch (err) {
|
|
265
|
-
console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
|
|
266
|
-
process.exit(1);
|
|
267
|
-
}
|
|
268
|
-
});
|
|
269
|
-
voicesCmd
|
|
270
|
-
.command('preview <voice-id>')
|
|
271
|
-
.description('Play a short audio preview of a voice')
|
|
272
|
-
.option('--no-cache', 'Skip cache, always re-download')
|
|
273
|
-
.action(async (voiceId, opts) => {
|
|
274
|
-
try {
|
|
275
|
-
const baseUrl = getApiBaseUrl();
|
|
276
|
-
const env = resolveApiEnv();
|
|
277
|
-
// Sanitize voiceId for safe use as a filename component
|
|
278
|
-
const safeVoiceId = voiceId.replace(/[^a-zA-Z0-9_-]/g, '_');
|
|
279
|
-
const cacheFile = join(AUDIO_CACHE_DIR, `preview-${env}-${safeVoiceId}.mp3`);
|
|
280
|
-
if (opts.cache && existsSync(cacheFile)) {
|
|
281
|
-
console.log(`Playing preview for '${voiceId}' (cached)`);
|
|
282
|
-
}
|
|
283
|
-
else {
|
|
284
|
-
console.log(`Downloading preview for '${voiceId}'...`);
|
|
285
|
-
const res = await fetch(`${baseUrl}/voices/${encodeURIComponent(voiceId)}/preview`);
|
|
286
|
-
if (!res.ok) {
|
|
287
|
-
const data = (await res.json().catch(() => null));
|
|
288
|
-
throw new Error(data && typeof data['error'] === 'string' ? data['error'] : `HTTP ${res.status}`);
|
|
289
|
-
}
|
|
290
|
-
const ab = await res.arrayBuffer();
|
|
291
|
-
mkdirSync(AUDIO_CACHE_DIR, { recursive: true });
|
|
292
|
-
writeFileSync(cacheFile, Buffer.from(ab));
|
|
293
|
-
console.log(`Playing preview for '${voiceId}'`);
|
|
294
|
-
}
|
|
295
|
-
const player = process.platform === 'darwin' ? 'afplay' : 'xdg-open';
|
|
296
|
-
const result = spawnSync(player, [cacheFile], { stdio: 'inherit' });
|
|
297
|
-
if (result.status !== 0) {
|
|
298
|
-
console.error('Playback failed. Try opening the file manually:');
|
|
299
|
-
console.log(cacheFile);
|
|
300
|
-
}
|
|
301
|
-
// Report play count (fire-and-forget, same as web client)
|
|
302
|
-
fetch(`${baseUrl}/voices/${encodeURIComponent(voiceId)}/play`, { method: 'POST' }).catch(() => { });
|
|
303
|
-
}
|
|
304
|
-
catch (err) {
|
|
305
|
-
console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
|
|
306
|
-
process.exit(1);
|
|
307
|
-
}
|
|
308
|
-
});
|
|
309
|
-
// ─── set (declarative YAML file) ────────────────────────────────────────────
|
|
310
|
-
const setCmd = program.command('set').description('Export/import a complete affirmation set as a YAML file');
|
|
311
|
-
/** Read all of stdin and return as a string. */
|
|
312
|
-
function readStdin() {
|
|
313
|
-
return new Promise((resolve, reject) => {
|
|
314
|
-
const chunks = [];
|
|
315
|
-
process.stdin.on('data', (chunk) => chunks.push(chunk));
|
|
316
|
-
process.stdin.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
|
|
317
|
-
process.stdin.on('error', reject);
|
|
318
|
-
});
|
|
319
|
-
}
|
|
320
|
-
/** Read content from --file <path>, --file - (stdin), or piped stdin. */
|
|
321
|
-
async function readContentFromFileOrStdin(opts) {
|
|
322
|
-
if (opts.file === '-') {
|
|
323
|
-
return readStdin();
|
|
324
|
-
}
|
|
325
|
-
if (opts.file) {
|
|
326
|
-
try {
|
|
327
|
-
return readFileSync(opts.file, 'utf8');
|
|
328
|
-
}
|
|
329
|
-
catch (err) {
|
|
330
|
-
console.error(`Error: could not read file '${opts.file}': ${err instanceof Error ? err.message : String(err)}`);
|
|
331
|
-
process.exit(1);
|
|
332
|
-
}
|
|
333
|
-
}
|
|
334
|
-
if (process.stdin.isTTY) {
|
|
335
|
-
console.error('Error: provide YAML via --file <path> or pipe to stdin (--file -)');
|
|
336
|
-
process.exit(1);
|
|
337
|
-
}
|
|
338
|
-
return readStdin();
|
|
339
|
-
}
|
|
340
44
|
/** Build a RenderConfigInput from parsed YAML fields. Used by set create and set apply. */
|
|
341
45
|
function buildRenderInputFromParsed(parsed, fallback) {
|
|
342
46
|
const input = {
|
|
@@ -356,172 +60,12 @@ function buildRenderInputFromParsed(parsed, fallback) {
|
|
|
356
60
|
input.includePreamble = parsed.preamble;
|
|
357
61
|
if (parsed.playAll !== undefined)
|
|
358
62
|
input.playAll = parsed.playAll;
|
|
63
|
+
if (parsed.repetitionModel !== undefined)
|
|
64
|
+
input.repetitionModel = parsed.repetitionModel;
|
|
359
65
|
return input;
|
|
360
66
|
}
|
|
361
|
-
setCmd
|
|
362
|
-
.command('export <intent-id>')
|
|
363
|
-
.description('Export an affirmation set to YAML (stdout)')
|
|
364
|
-
.action(async (intentId) => {
|
|
365
|
-
const client = getUserClient();
|
|
366
|
-
try {
|
|
367
|
-
const resolvedId = await resolveIntentId(client, intentId);
|
|
368
|
-
const data = await fetchSetFileData(client, resolvedId);
|
|
369
|
-
process.stdout.write(serializeSetFile(data));
|
|
370
|
-
}
|
|
371
|
-
catch (err) {
|
|
372
|
-
printResult(err instanceof Error ? err.message : String(err), true);
|
|
373
|
-
}
|
|
374
|
-
});
|
|
375
|
-
setCmd
|
|
376
|
-
.command('edit <intent-id>')
|
|
377
|
-
.description('Open a set file in $EDITOR, then apply changes')
|
|
378
|
-
.action(async (intentId) => {
|
|
379
|
-
const client = getUserClient();
|
|
380
|
-
const resolvedId = await resolveIntentId(client, intentId);
|
|
381
|
-
let originalData;
|
|
382
|
-
try {
|
|
383
|
-
originalData = await fetchSetFileData(client, resolvedId);
|
|
384
|
-
}
|
|
385
|
-
catch (err) {
|
|
386
|
-
printResult(err instanceof Error ? err.message : String(err), true);
|
|
387
|
-
return;
|
|
388
|
-
}
|
|
389
|
-
const yaml = serializeSetFile(originalData);
|
|
390
|
-
const isTTY = Boolean(process.stdout.isTTY && process.stdin.isTTY);
|
|
391
|
-
if (!isTTY) {
|
|
392
|
-
console.error('Error: no TTY available for interactive editor. Use one of:\n' +
|
|
393
|
-
' neuralingual set export <id> Print YAML to stdout\n' +
|
|
394
|
-
' neuralingual set apply <id> --file <path> Apply from a file\n' +
|
|
395
|
-
' neuralingual set apply <id> --file - Apply from stdin');
|
|
396
|
-
process.exit(1);
|
|
397
|
-
}
|
|
398
|
-
const editorEnv = (process.env['EDITOR'] ?? 'vi').trim();
|
|
399
|
-
if (!editorEnv) {
|
|
400
|
-
console.error('Error: $EDITOR is empty. Set it to your preferred editor.');
|
|
401
|
-
process.exit(1);
|
|
402
|
-
}
|
|
403
|
-
const tmpFile = join(tmpdir(), `nl-set-${resolvedId}-${Date.now()}.yaml`);
|
|
404
|
-
writeFileSync(tmpFile, yaml, 'utf8');
|
|
405
|
-
const spawnResult = spawnSync(`${editorEnv} ${JSON.stringify(tmpFile)}`, { stdio: 'inherit', shell: true });
|
|
406
|
-
if (spawnResult.error) {
|
|
407
|
-
unlinkSync(tmpFile);
|
|
408
|
-
console.error(`Error: could not open editor '${editorEnv}': ${spawnResult.error.message}`);
|
|
409
|
-
process.exit(1);
|
|
410
|
-
}
|
|
411
|
-
if (spawnResult.status !== 0) {
|
|
412
|
-
unlinkSync(tmpFile);
|
|
413
|
-
console.error(`Editor exited with status ${spawnResult.status ?? 'unknown'}. No changes applied.`);
|
|
414
|
-
process.exit(1);
|
|
415
|
-
}
|
|
416
|
-
let editedContent;
|
|
417
|
-
try {
|
|
418
|
-
editedContent = readFileSync(tmpFile, 'utf8');
|
|
419
|
-
}
|
|
420
|
-
finally {
|
|
421
|
-
unlinkSync(tmpFile);
|
|
422
|
-
}
|
|
423
|
-
try {
|
|
424
|
-
await applySetFile(client, resolvedId, editedContent, originalData);
|
|
425
|
-
}
|
|
426
|
-
catch (err) {
|
|
427
|
-
printResult(err instanceof Error ? err.message : String(err), true);
|
|
428
|
-
}
|
|
429
|
-
});
|
|
430
|
-
setCmd
|
|
431
|
-
.command('apply <intent-id>')
|
|
432
|
-
.description('Apply a YAML set file to an existing intent')
|
|
433
|
-
.option('--file <path>', 'Read YAML from a file (use "-" for stdin)')
|
|
434
|
-
.action(async (intentId, opts) => {
|
|
435
|
-
const client = getUserClient();
|
|
436
|
-
const content = await readContentFromFileOrStdin(opts);
|
|
437
|
-
const resolvedId = await resolveIntentId(client, intentId);
|
|
438
|
-
try {
|
|
439
|
-
const originalData = await fetchSetFileData(client, resolvedId);
|
|
440
|
-
await applySetFile(client, resolvedId, content, originalData);
|
|
441
|
-
}
|
|
442
|
-
catch (err) {
|
|
443
|
-
printResult(err instanceof Error ? err.message : String(err), true);
|
|
444
|
-
}
|
|
445
|
-
});
|
|
446
|
-
setCmd
|
|
447
|
-
.command('create')
|
|
448
|
-
.description('Create a new intent from a YAML set file (full round-trip)')
|
|
449
|
-
.option('--file <path>', 'Read YAML from a file (use "-" for stdin)')
|
|
450
|
-
.action(async (opts) => {
|
|
451
|
-
const client = getUserClient();
|
|
452
|
-
const content = await readContentFromFileOrStdin(opts);
|
|
453
|
-
let parsed;
|
|
454
|
-
try {
|
|
455
|
-
parsed = parseSetFile(content);
|
|
456
|
-
}
|
|
457
|
-
catch (err) {
|
|
458
|
-
console.error(`Error: invalid YAML — ${err instanceof Error ? err.message : String(err)}`);
|
|
459
|
-
process.exit(1);
|
|
460
|
-
}
|
|
461
|
-
if (!parsed.intent) {
|
|
462
|
-
console.error('Error: "intent" field is required to create a new set');
|
|
463
|
-
process.exit(1);
|
|
464
|
-
}
|
|
465
|
-
await createSetFromFile(client, parsed);
|
|
466
|
-
});
|
|
467
|
-
/** Create a new intent from a parsed set file using user API. */
|
|
468
|
-
async function createSetFromFile(client, parsed) {
|
|
469
|
-
if (!parsed.affirmations || parsed.affirmations.length === 0) {
|
|
470
|
-
console.error('Error: "affirmations" are required to create a new set');
|
|
471
|
-
process.exit(1);
|
|
472
|
-
}
|
|
473
|
-
let createdIntentId;
|
|
474
|
-
try {
|
|
475
|
-
const steps = [];
|
|
476
|
-
// 1. Create intent + affirmations together via POST /intents/manual
|
|
477
|
-
const title = parsed.title ?? parsed.intent.slice(0, 120);
|
|
478
|
-
const result = await client.createManualIntent({
|
|
479
|
-
title,
|
|
480
|
-
rawText: parsed.intent,
|
|
481
|
-
tonePreference: parsed.tone ?? null,
|
|
482
|
-
sessionContext: parsed.intentContext,
|
|
483
|
-
affirmations: parsed.affirmations.map((a) => ({ text: a.text })),
|
|
484
|
-
});
|
|
485
|
-
createdIntentId = result.intent.id;
|
|
486
|
-
steps.push(`intent: created with ${result.affirmationSet.affirmations.length} affirmations`);
|
|
487
|
-
// 2. Update emoji if specified (not part of manual create)
|
|
488
|
-
if (parsed.emoji !== undefined) {
|
|
489
|
-
await client.updateIntent(result.intent.id, { emoji: parsed.emoji });
|
|
490
|
-
steps.push('intent: updated emoji');
|
|
491
|
-
}
|
|
492
|
-
// 3. Sync affirmations to set enabled/disabled state
|
|
493
|
-
// The manual create endpoint doesn't support per-affirmation enabled state,
|
|
494
|
-
// so we sync to apply the exact desired state from the YAML.
|
|
495
|
-
const hasDisabled = parsed.affirmations.some((a) => !a.enabled);
|
|
496
|
-
if (hasDisabled) {
|
|
497
|
-
await client.syncAffirmations(result.intent.id, {
|
|
498
|
-
affirmations: parsed.affirmations.map((a) => ({
|
|
499
|
-
text: a.text,
|
|
500
|
-
enabled: a.enabled,
|
|
501
|
-
})),
|
|
502
|
-
});
|
|
503
|
-
steps.push('affirmations: synced enabled/disabled state');
|
|
504
|
-
}
|
|
505
|
-
// 4. Configure render (if voice is specified)
|
|
506
|
-
if (parsed.voice) {
|
|
507
|
-
await client.configureRender(result.intent.id, buildRenderInputFromParsed(parsed));
|
|
508
|
-
steps.push('render config: created');
|
|
509
|
-
}
|
|
510
|
-
console.log(`Created intent: ${result.intent.id}`);
|
|
511
|
-
for (const s of steps) {
|
|
512
|
-
console.error(` - ${s}`);
|
|
513
|
-
}
|
|
514
|
-
}
|
|
515
|
-
catch (err) {
|
|
516
|
-
if (createdIntentId) {
|
|
517
|
-
console.error(`Error during set create. Partial intent was created: ${createdIntentId}`);
|
|
518
|
-
console.error(`Clean up with: neuralingual delete ${createdIntentId}`);
|
|
519
|
-
}
|
|
520
|
-
printResult(err instanceof Error ? err.message : String(err), true);
|
|
521
|
-
}
|
|
522
|
-
}
|
|
523
67
|
// ═══════════════════════════════════════════════════════════════════════════════
|
|
524
|
-
// User-facing commands (JWT auth)
|
|
68
|
+
// User-facing commands (JWT auth, not admin key)
|
|
525
69
|
// ═══════════════════════════════════════════════════════════════════════════════
|
|
526
70
|
/** Get a user client from stored auth. Exits with helpful message if not logged in. */
|
|
527
71
|
function getUserClient() {
|
|
@@ -529,13 +73,14 @@ function getUserClient() {
|
|
|
529
73
|
return UserApiClient.fromAuth();
|
|
530
74
|
}
|
|
531
75
|
catch {
|
|
532
|
-
console.error('Not logged in. Run `
|
|
76
|
+
console.error('Not logged in. Run `nl login` first.');
|
|
533
77
|
process.exit(1);
|
|
534
78
|
}
|
|
535
79
|
}
|
|
536
80
|
/**
|
|
537
81
|
* Resolve a short/truncated intent ID to the full ID by fetching the user's library.
|
|
538
82
|
* Handles exact match, prefix match, and ambiguous matches (multiple prefix hits).
|
|
83
|
+
* Only works for user-authenticated clients (UserApiClient has getLibrary()).
|
|
539
84
|
*/
|
|
540
85
|
async function resolveIntentId(client, shortId) {
|
|
541
86
|
const { items } = await client.getLibrary();
|
|
@@ -559,7 +104,7 @@ async function resolveIntentId(client, shortId) {
|
|
|
559
104
|
* Fetch set file data using user API.
|
|
560
105
|
* Maps the user intent detail shape to SetFileData.
|
|
561
106
|
*/
|
|
562
|
-
async function
|
|
107
|
+
async function fetchSetFileDataUser(client, intentId) {
|
|
563
108
|
const { intent } = await client.getIntent(intentId);
|
|
564
109
|
if (!intent) {
|
|
565
110
|
throw new Error(`Intent not found: ${intentId}`);
|
|
@@ -599,6 +144,7 @@ async function fetchSetFileData(client, intentId) {
|
|
|
599
144
|
backgroundAudioPath: latestConfig.backgroundAudioPath,
|
|
600
145
|
backgroundVolume: latestConfig.backgroundVolume,
|
|
601
146
|
affirmationRepeatCount: latestConfig.affirmationRepeatCount,
|
|
147
|
+
repetitionModel: latestConfig.repetitionModel,
|
|
602
148
|
includePreamble: latestConfig.includePreamble,
|
|
603
149
|
playAll: latestConfig.playAll,
|
|
604
150
|
createdAt: latestConfig.createdAt,
|
|
@@ -628,8 +174,9 @@ async function fetchSetFileData(client, intentId) {
|
|
|
628
174
|
}
|
|
629
175
|
/**
|
|
630
176
|
* Apply a parsed set file using user API.
|
|
177
|
+
* Silently skips admin-only fields (catalog, sessionContext on intent).
|
|
631
178
|
*/
|
|
632
|
-
async function
|
|
179
|
+
async function applySetFileUser(client, intentId, content, originalData) {
|
|
633
180
|
let parsed;
|
|
634
181
|
try {
|
|
635
182
|
parsed = parseSetFile(content);
|
|
@@ -653,6 +200,8 @@ async function applySetFile(client, intentId, content, originalData) {
|
|
|
653
200
|
if (parsed.emoji !== undefined && parsed.emoji !== originalData.intent.emoji) {
|
|
654
201
|
intentUpdates.emoji = parsed.emoji;
|
|
655
202
|
}
|
|
203
|
+
// Note: intentContext/sessionContext changes silently skipped for user auth
|
|
204
|
+
// (user update API doesn't support sessionContext)
|
|
656
205
|
if (Object.keys(intentUpdates).length > 0) {
|
|
657
206
|
await client.updateIntent(intentId, intentUpdates);
|
|
658
207
|
changes.push(`intent: updated ${Object.keys(intentUpdates).join(', ')}`);
|
|
@@ -695,7 +244,7 @@ async function applySetFile(client, intentId, content, originalData) {
|
|
|
695
244
|
parsed.playAll !== undefined;
|
|
696
245
|
if (hasRenderFields) {
|
|
697
246
|
if (!originalData.renderConfig) {
|
|
698
|
-
console.error('Warning: no render config exists yet — skipping render settings. Run
|
|
247
|
+
console.error('Warning: no render config exists yet — skipping render settings. Run nl render configure first.');
|
|
699
248
|
}
|
|
700
249
|
else {
|
|
701
250
|
const rc = originalData.renderConfig;
|
|
@@ -703,6 +252,15 @@ async function applySetFile(client, intentId, content, originalData) {
|
|
|
703
252
|
changes.push('render config: updated');
|
|
704
253
|
}
|
|
705
254
|
}
|
|
255
|
+
// 4. Catalog fields silently skipped for user auth
|
|
256
|
+
const hasCatalogFields = parsed.slug !== undefined ||
|
|
257
|
+
parsed.category !== undefined ||
|
|
258
|
+
parsed.description !== undefined ||
|
|
259
|
+
parsed.subtitle !== undefined ||
|
|
260
|
+
parsed.order !== undefined;
|
|
261
|
+
if (hasCatalogFields) {
|
|
262
|
+
console.error('Note: catalog fields (slug, category, description, etc.) are admin-only and were skipped.');
|
|
263
|
+
}
|
|
706
264
|
if (changes.length === 0) {
|
|
707
265
|
console.error('No changes detected.');
|
|
708
266
|
}
|
|
@@ -761,9 +319,9 @@ async function browserLogin(env) {
|
|
|
761
319
|
chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk);
|
|
762
320
|
}
|
|
763
321
|
const body = Buffer.concat(chunks).toString('utf8');
|
|
764
|
-
let
|
|
322
|
+
let parsed;
|
|
765
323
|
try {
|
|
766
|
-
|
|
324
|
+
parsed = JSON.parse(body);
|
|
767
325
|
}
|
|
768
326
|
catch {
|
|
769
327
|
res.writeHead(400, {
|
|
@@ -773,7 +331,7 @@ async function browserLogin(env) {
|
|
|
773
331
|
res.end(JSON.stringify({ error: 'Invalid JSON' }));
|
|
774
332
|
return;
|
|
775
333
|
}
|
|
776
|
-
const result = callbackPayloadSchema.safeParse(
|
|
334
|
+
const result = callbackPayloadSchema.safeParse(parsed);
|
|
777
335
|
if (!result.success) {
|
|
778
336
|
res.writeHead(400, {
|
|
779
337
|
'Content-Type': 'application/json',
|
|
@@ -844,7 +402,7 @@ async function browserLogin(env) {
|
|
|
844
402
|
}
|
|
845
403
|
program
|
|
846
404
|
.command('login')
|
|
847
|
-
.description('Log in to Neuralingual
|
|
405
|
+
.description('Log in to Neuralingual (Apple Sign-In via browser)')
|
|
848
406
|
.option('--env <env>', 'API environment: dev or production', 'production')
|
|
849
407
|
.action(async (opts) => {
|
|
850
408
|
const env = opts.env;
|
|
@@ -1042,7 +600,7 @@ program
|
|
|
1042
600
|
}
|
|
1043
601
|
else {
|
|
1044
602
|
console.log('Render Config: not configured');
|
|
1045
|
-
console.log(` Configure with:
|
|
603
|
+
console.log(` Configure with: nl render configure ${intent.id.slice(0, 8)} --voice <id> --context ${intent.sessionContext} --duration <min>`);
|
|
1046
604
|
console.log();
|
|
1047
605
|
}
|
|
1048
606
|
// Render status — check via render-status endpoint
|
|
@@ -1124,7 +682,7 @@ program
|
|
|
1124
682
|
}
|
|
1125
683
|
if (!intent.renderConfigs || intent.renderConfigs.length === 0) {
|
|
1126
684
|
console.error('Error: no render config found. Configure first with:');
|
|
1127
|
-
console.error(`
|
|
685
|
+
console.error(` nl render configure ${intent.id.slice(0, 8)} --voice <id> --context ${intent.sessionContext} --duration <min>`);
|
|
1128
686
|
process.exit(1);
|
|
1129
687
|
}
|
|
1130
688
|
console.error(`Re-rendering: ${intent.emoji ?? ''} ${intent.title}`.trim());
|
|
@@ -1199,7 +757,7 @@ program
|
|
|
1199
757
|
for (const a of affirmationSet.affirmations) {
|
|
1200
758
|
console.log(` ${a.isEnabled ? '[x]' : '[ ]'} ${a.text}`);
|
|
1201
759
|
}
|
|
1202
|
-
console.log(`\nNext: configure and render with \`
|
|
760
|
+
console.log(`\nNext: configure and render with \`nl render ${intent.id}\``);
|
|
1203
761
|
}
|
|
1204
762
|
catch (err) {
|
|
1205
763
|
console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
|
|
@@ -1273,7 +831,7 @@ program
|
|
|
1273
831
|
}
|
|
1274
832
|
const renderJob = item.latestRenderJob;
|
|
1275
833
|
if (!renderJob?.id) {
|
|
1276
|
-
console.error('Error: no rendered audio available. Run `
|
|
834
|
+
console.error('Error: no rendered audio available. Run `nl render start` first.');
|
|
1277
835
|
process.exit(1);
|
|
1278
836
|
}
|
|
1279
837
|
if (renderJob.status !== 'completed') {
|
|
@@ -1357,27 +915,167 @@ function prompt(question) {
|
|
|
1357
915
|
});
|
|
1358
916
|
});
|
|
1359
917
|
}
|
|
918
|
+
/**
|
|
919
|
+
* Parse a duration string like "7d", "30d", "2w" into an ISO date string
|
|
920
|
+
* representing `now - duration`.
|
|
921
|
+
*/
|
|
922
|
+
function parseDurationToIsoDate(duration) {
|
|
923
|
+
const match = duration.match(/^(\d+)([dwDW])$/);
|
|
924
|
+
if (!match) {
|
|
925
|
+
throw new Error(`Invalid duration "${duration}". Use format like "7d" (days) or "2w" (weeks).`);
|
|
926
|
+
}
|
|
927
|
+
const value = parseInt(match[1], 10);
|
|
928
|
+
const unit = match[2].toLowerCase();
|
|
929
|
+
const days = unit === 'w' ? value * 7 : value;
|
|
930
|
+
const cutoff = new Date();
|
|
931
|
+
cutoff.setDate(cutoff.getDate() - days);
|
|
932
|
+
return cutoff.toISOString();
|
|
933
|
+
}
|
|
934
|
+
/**
|
|
935
|
+
* Fetch library items matching the given filter criteria.
|
|
936
|
+
* Uses server-side filtering via query params where possible.
|
|
937
|
+
*/
|
|
938
|
+
async function getFilteredLibraryItems(client, filter, notPlayedSince) {
|
|
939
|
+
const params = {};
|
|
940
|
+
// Map CLI filter names to API filter param
|
|
941
|
+
if (filter === 'no-audio' || filter === 'has-audio' || filter === 'never-played') {
|
|
942
|
+
params.filter = filter;
|
|
943
|
+
}
|
|
944
|
+
// Parse duration for not-played-since
|
|
945
|
+
if (notPlayedSince) {
|
|
946
|
+
params.notPlayedSince = parseDurationToIsoDate(notPlayedSince);
|
|
947
|
+
}
|
|
948
|
+
const { items } = await client.getLibrary(params);
|
|
949
|
+
return items;
|
|
950
|
+
}
|
|
951
|
+
/** Print a table of library items for confirmation display. */
|
|
952
|
+
function printFilteredItems(items) {
|
|
953
|
+
const rows = items.map((item) => {
|
|
954
|
+
const hasAudio = item.configs?.some((c) => c.latestRenderJob?.status === 'completed') ??
|
|
955
|
+
item.latestRenderJob?.status === 'completed';
|
|
956
|
+
return [
|
|
957
|
+
item.intent.id.slice(0, 8),
|
|
958
|
+
item.intent.emoji ?? '',
|
|
959
|
+
item.intent.title ?? '(untitled)',
|
|
960
|
+
item.intent.sessionContext,
|
|
961
|
+
hasAudio ? 'yes' : 'no',
|
|
962
|
+
String(item.stats?.playCount ?? 0),
|
|
963
|
+
item.stats?.lastPlayedAt ? new Date(item.stats.lastPlayedAt).toLocaleDateString() : 'never',
|
|
964
|
+
];
|
|
965
|
+
});
|
|
966
|
+
printTable(rows, ['ID', '', 'Title', 'Context', 'Audio', 'Plays', 'Last Played']);
|
|
967
|
+
}
|
|
1360
968
|
program
|
|
1361
|
-
.command('delete
|
|
1362
|
-
.description('Delete
|
|
969
|
+
.command('delete [intent-id]')
|
|
970
|
+
.description('Delete practice sets from your library. Pass an ID for single delete, or use filters for bulk delete.')
|
|
1363
971
|
.option('-f, --force', 'Skip confirmation prompt')
|
|
972
|
+
.option('--filter <filter>', 'Filter: no-audio, has-audio, never-played')
|
|
973
|
+
.option('--not-played-since <duration>', 'Delete sets not played in N days/weeks (e.g. 7d, 2w)')
|
|
1364
974
|
.action(async (intentId, opts) => {
|
|
975
|
+
const hasFilters = opts.filter !== undefined || opts.notPlayedSince !== undefined;
|
|
976
|
+
if (intentId && hasFilters) {
|
|
977
|
+
console.error('Error: provide either an intent ID or filter flags, not both.');
|
|
978
|
+
process.exit(1);
|
|
979
|
+
}
|
|
980
|
+
if (!intentId && !hasFilters) {
|
|
981
|
+
console.error('Error: provide an intent ID or use filter flags (--filter, --not-played-since).');
|
|
982
|
+
console.error(' Examples:');
|
|
983
|
+
console.error(' nl delete abc12345');
|
|
984
|
+
console.error(' nl delete --filter no-audio');
|
|
985
|
+
console.error(' nl delete --not-played-since 7d');
|
|
986
|
+
process.exit(1);
|
|
987
|
+
}
|
|
988
|
+
const validFilters = ['no-audio', 'has-audio', 'never-played'];
|
|
989
|
+
if (opts.filter && !validFilters.includes(opts.filter)) {
|
|
990
|
+
console.error(`Error: --filter must be one of: ${validFilters.join(', ')}`);
|
|
991
|
+
process.exit(1);
|
|
992
|
+
}
|
|
1365
993
|
const client = getUserClient();
|
|
1366
994
|
try {
|
|
1367
|
-
|
|
1368
|
-
|
|
995
|
+
// Single-ID delete (backward compatible)
|
|
996
|
+
if (intentId) {
|
|
997
|
+
const resolvedId = await resolveIntentId(client, intentId);
|
|
998
|
+
if (!opts.force) {
|
|
999
|
+
const { items } = await client.getLibrary();
|
|
1000
|
+
const item = items.find((i) => i.intent.id === resolvedId);
|
|
1001
|
+
const name = item ? `'${item.intent.title ?? '(untitled)'}'` : resolvedId;
|
|
1002
|
+
const answer = await prompt(`Delete ${name}? This cannot be undone. (y/N) `);
|
|
1003
|
+
if (answer.toLowerCase() !== 'y') {
|
|
1004
|
+
console.log('Cancelled.');
|
|
1005
|
+
return;
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
await client.deleteIntent(resolvedId);
|
|
1009
|
+
console.log('Deleted.');
|
|
1010
|
+
return;
|
|
1011
|
+
}
|
|
1012
|
+
// Filter-based bulk delete
|
|
1013
|
+
const items = await getFilteredLibraryItems(client, opts.filter, opts.notPlayedSince);
|
|
1014
|
+
if (items.length === 0) {
|
|
1015
|
+
console.log('No matching practice sets found.');
|
|
1016
|
+
return;
|
|
1017
|
+
}
|
|
1018
|
+
console.log(`Found ${items.length} matching practice set(s):\n`);
|
|
1019
|
+
printFilteredItems(items);
|
|
1020
|
+
console.log();
|
|
1369
1021
|
if (!opts.force) {
|
|
1370
|
-
const {
|
|
1371
|
-
const item = items.find((i) => i.intent.id === resolvedId);
|
|
1372
|
-
const name = item ? `'${item.intent.title ?? '(untitled)'}'` : resolvedId;
|
|
1373
|
-
const answer = await prompt(`Delete ${name}? This cannot be undone. (y/N) `);
|
|
1022
|
+
const answer = await prompt(`Delete ${items.length} set(s)? This cannot be undone. (y/N) `);
|
|
1374
1023
|
if (answer.toLowerCase() !== 'y') {
|
|
1375
1024
|
console.log('Cancelled.');
|
|
1376
1025
|
return;
|
|
1377
1026
|
}
|
|
1378
1027
|
}
|
|
1379
|
-
|
|
1380
|
-
|
|
1028
|
+
// Bulk delete in batches of 50
|
|
1029
|
+
const ids = items.map((i) => i.intent.id);
|
|
1030
|
+
let totalDeleted = 0;
|
|
1031
|
+
for (let i = 0; i < ids.length; i += 50) {
|
|
1032
|
+
const batch = ids.slice(i, i + 50);
|
|
1033
|
+
const result = await client.bulkDeleteIntents(batch);
|
|
1034
|
+
totalDeleted += result.deleted;
|
|
1035
|
+
}
|
|
1036
|
+
console.log(`Deleted ${totalDeleted} practice set(s).`);
|
|
1037
|
+
}
|
|
1038
|
+
catch (err) {
|
|
1039
|
+
console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
|
|
1040
|
+
process.exit(1);
|
|
1041
|
+
}
|
|
1042
|
+
});
|
|
1043
|
+
// ─── cleanup (dry-run) ────────────────────────────────────────────────────
|
|
1044
|
+
program
|
|
1045
|
+
.command('cleanup')
|
|
1046
|
+
.description('Preview which practice sets would be deleted by a filter (dry run — does not delete)')
|
|
1047
|
+
.option('--filter <filter>', 'Filter: no-audio, has-audio, never-played')
|
|
1048
|
+
.option('--not-played-since <duration>', 'Sets not played in N days/weeks (e.g. 7d, 2w)')
|
|
1049
|
+
.action(async (opts) => {
|
|
1050
|
+
if (!opts.filter && !opts.notPlayedSince) {
|
|
1051
|
+
console.error('Error: provide at least one filter (--filter, --not-played-since).');
|
|
1052
|
+
console.error(' Examples:');
|
|
1053
|
+
console.error(' nl cleanup --filter no-audio');
|
|
1054
|
+
console.error(' nl cleanup --not-played-since 30d');
|
|
1055
|
+
console.error(' nl cleanup --filter never-played --not-played-since 7d');
|
|
1056
|
+
process.exit(1);
|
|
1057
|
+
}
|
|
1058
|
+
const validFilters = ['no-audio', 'has-audio', 'never-played'];
|
|
1059
|
+
if (opts.filter && !validFilters.includes(opts.filter)) {
|
|
1060
|
+
console.error(`Error: --filter must be one of: ${validFilters.join(', ')}`);
|
|
1061
|
+
process.exit(1);
|
|
1062
|
+
}
|
|
1063
|
+
const client = getUserClient();
|
|
1064
|
+
try {
|
|
1065
|
+
const items = await getFilteredLibraryItems(client, opts.filter, opts.notPlayedSince);
|
|
1066
|
+
if (items.length === 0) {
|
|
1067
|
+
console.log('No matching practice sets found.');
|
|
1068
|
+
return;
|
|
1069
|
+
}
|
|
1070
|
+
console.log(`Found ${items.length} matching practice set(s):\n`);
|
|
1071
|
+
printFilteredItems(items);
|
|
1072
|
+
// Build the equivalent delete command
|
|
1073
|
+
const parts = ['nl delete'];
|
|
1074
|
+
if (opts.filter)
|
|
1075
|
+
parts.push(`--filter ${opts.filter}`);
|
|
1076
|
+
if (opts.notPlayedSince)
|
|
1077
|
+
parts.push(`--not-played-since ${opts.notPlayedSince}`);
|
|
1078
|
+
console.log(`\nTo delete these, run:\n ${parts.join(' ')}`);
|
|
1381
1079
|
}
|
|
1382
1080
|
catch (err) {
|
|
1383
1081
|
console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
|
|
@@ -1448,6 +1146,30 @@ settingsCmd
|
|
|
1448
1146
|
process.exit(1);
|
|
1449
1147
|
}
|
|
1450
1148
|
});
|
|
1149
|
+
// ─── account ───────────────────────────────────────────────────────────────
|
|
1150
|
+
const accountCmd = program.command('account').description('Account management');
|
|
1151
|
+
accountCmd
|
|
1152
|
+
.command('delete')
|
|
1153
|
+
.description('Permanently delete your account')
|
|
1154
|
+
.action(async () => {
|
|
1155
|
+
console.log('WARNING: This will permanently delete your account and all data.');
|
|
1156
|
+
console.log('This action cannot be undone.\n');
|
|
1157
|
+
const answer = await prompt('Type DELETE to confirm: ');
|
|
1158
|
+
if (answer !== 'DELETE') {
|
|
1159
|
+
console.log('Cancelled.');
|
|
1160
|
+
return;
|
|
1161
|
+
}
|
|
1162
|
+
const client = getUserClient();
|
|
1163
|
+
try {
|
|
1164
|
+
await client.deleteAccount();
|
|
1165
|
+
clearAuth();
|
|
1166
|
+
console.log('Account deleted. All data has been removed.');
|
|
1167
|
+
}
|
|
1168
|
+
catch (err) {
|
|
1169
|
+
console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
|
|
1170
|
+
process.exit(1);
|
|
1171
|
+
}
|
|
1172
|
+
});
|
|
1451
1173
|
// ─── library search ────────────────────────────────────────────────────────
|
|
1452
1174
|
program
|
|
1453
1175
|
.command('search <query>')
|