@innerstacklabs/neuralingual-mcp 0.3.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/dist/cli.js CHANGED
@@ -1,24 +1,23 @@
1
1
  #!/usr/bin/env node
2
2
  import { Command } from 'commander';
3
- import { spawn, spawnSync, exec } from 'child_process';
3
+ import { spawn, exec } from 'child_process';
4
4
  import { createServer } from 'http';
5
5
  import { randomBytes } from 'crypto';
6
- import { writeFileSync, readFileSync, unlinkSync, existsSync, mkdirSync } from 'fs';
6
+ import { writeFileSync, existsSync, mkdirSync } from 'fs';
7
7
  import { createInterface } from 'readline';
8
- import { tmpdir, homedir } from 'os';
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 { API_BASE_URLS } from './types.js';
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.2.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 `neuralingual login` first.');
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 fetchSetFileData(client, intentId) {
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 applySetFile(client, intentId, content, originalData) {
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 neuralingual render configure first.');
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 bodyParsed;
322
+ let parsed;
765
323
  try {
766
- bodyParsed = JSON.parse(body);
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(bodyParsed);
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 via Apple Sign-In (opens browser)')
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: neuralingual render configure ${intent.id.slice(0, 8)} --voice <id> --context ${intent.sessionContext} --duration <min>`);
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(` neuralingual render configure ${intent.id.slice(0, 8)} --voice <id> --context ${intent.sessionContext} --duration <min>`);
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 \`neuralingual render configure ${intent.id.slice(0, 8)} --voice <id> --context ${intent.sessionContext} --duration <min>\``);
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 `neuralingual render start` first.');
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 <intent-id>')
1362
- .description('Delete a practice set from your library')
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
- const resolvedId = await resolveIntentId(client, intentId);
1368
- // Look up the title for the confirmation prompt
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 { items } = await client.getLibrary();
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
- await client.deleteIntent(resolvedId);
1380
- console.log('Deleted.');
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>')