@innerstacklabs/neuralingual-mcp 0.1.2 → 0.3.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 CHANGED
@@ -70,6 +70,8 @@ neuralingual play <id> --open
70
70
  | `set create` | Create set from YAML |
71
71
  | `settings` | View/update preferences |
72
72
 
73
+ See the [User Guide](docs/USER_GUIDE.md) for detailed usage and examples.
74
+
73
75
  ## License
74
76
 
75
77
  MIT
package/dist/cli.js CHANGED
@@ -18,7 +18,7 @@ const program = new Command();
18
18
  program
19
19
  .name('neuralingual')
20
20
  .description('Neuralingual — AI-powered affirmation practice sets')
21
- .version('0.1.0')
21
+ .version('0.2.0')
22
22
  .option('--env <env>', 'API environment: dev or production (default: production)', 'production');
23
23
  function printResult(data, isError = false) {
24
24
  const text = typeof data === 'string' ? data : JSON.stringify(data, null, 2);
@@ -42,72 +42,6 @@ function printTable(rows, headers) {
42
42
  console.log(line(row));
43
43
  }
44
44
  }
45
- /** Read all of stdin and return as a string. */
46
- function readStdin() {
47
- return new Promise((resolve, reject) => {
48
- const chunks = [];
49
- process.stdin.on('data', (chunk) => chunks.push(chunk));
50
- process.stdin.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
51
- process.stdin.on('error', reject);
52
- });
53
- }
54
- /** Get a user client from stored auth. Exits with helpful message if not logged in. */
55
- function getUserClient() {
56
- try {
57
- return UserApiClient.fromAuth();
58
- }
59
- catch {
60
- console.error('Not logged in. Run `neuralingual login` first.');
61
- process.exit(1);
62
- }
63
- }
64
- /** Resolve API env: explicit --env flag wins, then stored auth, then default 'production'. */
65
- function resolveApiEnv() {
66
- const opts = program.opts();
67
- const explicitEnv = process.argv.some((a) => a === '--env' || a.startsWith('--env='));
68
- const env = (explicitEnv ? opts['env'] : loadAuth()?.env ?? opts['env'] ?? 'production');
69
- if (env !== 'dev' && env !== 'production') {
70
- console.error(`Error: --env must be "dev" or "production", got "${env}"`);
71
- process.exit(1);
72
- }
73
- return env;
74
- }
75
- /** Resolve the API base URL using resolveApiEnv(). */
76
- function getApiBaseUrl() {
77
- return API_BASE_URLS[resolveApiEnv()];
78
- }
79
- /**
80
- * Resolve a short/truncated intent ID to the full ID by fetching the user's library.
81
- * Handles exact match, prefix match, and ambiguous matches (multiple prefix hits).
82
- */
83
- async function resolveIntentId(client, shortId) {
84
- const { items } = await client.getLibrary();
85
- const exact = items.find((i) => i.intent.id === shortId);
86
- if (exact)
87
- return exact.intent.id;
88
- const prefixMatches = items.filter((i) => i.intent.id.startsWith(shortId));
89
- if (prefixMatches.length === 1)
90
- return prefixMatches[0].intent.id;
91
- if (prefixMatches.length > 1) {
92
- console.error(`Error: ambiguous ID "${shortId}" matches ${prefixMatches.length} intents:`);
93
- for (const m of prefixMatches) {
94
- console.error(` ${m.intent.id.slice(0, 12)} ${m.intent.title ?? '(untitled)'}`);
95
- }
96
- process.exit(1);
97
- }
98
- console.error(`Error: no practice set found matching "${shortId}"`);
99
- process.exit(1);
100
- }
101
- /** Prompt the user for input on stdin. */
102
- function prompt(question) {
103
- const rl = createInterface({ input: process.stdin, output: process.stdout });
104
- return new Promise((resolve) => {
105
- rl.question(question, (answer) => {
106
- rl.close();
107
- resolve(answer.trim());
108
- });
109
- });
110
- }
111
45
  // ─── render ──────────────────────────────────────────────────────────────────
112
46
  const renderCmd = program.command('render').description('Render audio for an intent');
113
47
  renderCmd
@@ -117,7 +51,7 @@ renderCmd
117
51
  .requiredOption('--context <context>', `Session context: ${VALID_CONTEXTS.join(', ')}`)
118
52
  .requiredOption('--duration <minutes>', 'Duration in minutes', parseInt)
119
53
  .option('--pace <wpm>', 'Pace in words per minute (uses context default if omitted)', parseInt)
120
- .option('--background <key>', 'Background sound storageKey (use neuralingual voices; omit to disable)')
54
+ .option('--background <key>', 'Background sound storageKey (use neuralingual voices list; omit to disable)')
121
55
  .option('--background-volume <level>', 'Background volume 0–1 (uses context default if omitted)', parseFloat)
122
56
  .option('--repeats <n>', 'Number of times each affirmation repeats (uses context default if omitted)', parseInt)
123
57
  .option('--preamble <on|off>', 'Include intro/outro preamble: on or off (preserves existing setting if omitted)')
@@ -211,11 +145,14 @@ renderCmd
211
145
  console.error(`Warning: status poll failed — ${pollErr instanceof Error ? pollErr.message : String(pollErr)}`);
212
146
  continue;
213
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
214
150
  if (status.jobId === undefined) {
215
151
  console.error(`Warning: render status has no active job (config may have been reconfigured). Exiting --wait.`);
216
152
  printResult(status);
217
153
  return;
218
154
  }
155
+ // Guard: if the status is tracking a different job (concurrent start), stop waiting
219
156
  if (status.jobId !== jobId) {
220
157
  console.error(`Warning: render status is now tracking a different job (${status.jobId}). Exiting --wait.`);
221
158
  printResult(status);
@@ -252,6 +189,21 @@ renderCmd
252
189
  });
253
190
  // ─── voices ──────────────────────────────────────────────────────────────────
254
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
+ }
255
207
  const voiceDtoSchema = z.object({
256
208
  id: z.string(),
257
209
  provider: z.string(),
@@ -266,7 +218,6 @@ const voiceDtoSchema = z.object({
266
218
  const voicesResponseSchema = z.object({
267
219
  voices: z.array(voiceDtoSchema),
268
220
  });
269
- const AUDIO_CACHE_DIR = join(homedir(), '.config', 'neuralingual', 'audio');
270
221
  voicesCmd
271
222
  .command('show', { isDefault: true })
272
223
  .description('List available voices')
@@ -357,6 +308,15 @@ voicesCmd
357
308
  });
358
309
  // ─── set (declarative YAML file) ────────────────────────────────────────────
359
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
+ }
360
320
  /** Read content from --file <path>, --file - (stdin), or piped stdin. */
361
321
  async function readContentFromFileOrStdin(opts) {
362
322
  if (opts.file === '-') {
@@ -398,77 +358,6 @@ function buildRenderInputFromParsed(parsed, fallback) {
398
358
  input.playAll = parsed.playAll;
399
359
  return input;
400
360
  }
401
- /**
402
- * Fetch set file data using user API.
403
- * Maps the user intent detail shape to SetFileData.
404
- */
405
- async function fetchSetFileData(client, intentId) {
406
- const { intent } = await client.getIntent(intentId);
407
- if (!intent) {
408
- throw new Error(`Intent not found: ${intentId}`);
409
- }
410
- // Get latest affirmation set (first in the desc-ordered array)
411
- const latestSet = intent.affirmationSets[0];
412
- const affirmations = (latestSet?.affirmations ?? []).map((a, idx) => ({
413
- id: a.id,
414
- setId: latestSet?.id ?? '',
415
- text: a.text,
416
- tone: a.tone,
417
- intensity: 3,
418
- length: a.text.length < 60 ? 'short' : 'medium',
419
- tags: [],
420
- weight: 3,
421
- isFavorite: false,
422
- isEnabled: a.isEnabled,
423
- orderIndex: idx,
424
- createdAt: '',
425
- updatedAt: '',
426
- }));
427
- // Get render config scoped to the latest affirmation set (matching the info command pattern).
428
- // Without scoping, we could export a stale config from an older set.
429
- const latestSetId = latestSet?.id;
430
- const latestConfig = (latestSetId
431
- ? intent.renderConfigs.find((rc) => rc.affirmationSetId === latestSetId)
432
- : intent.renderConfigs[0]) ?? null;
433
- const renderConfig = latestConfig ? {
434
- id: latestConfig.id,
435
- intentId: intent.id,
436
- affirmationSetId: latestConfig.affirmationSetId,
437
- voiceId: latestConfig.voiceId,
438
- voiceProvider: latestConfig.voiceProvider,
439
- sessionContext: latestConfig.sessionContext,
440
- paceWpm: latestConfig.paceWpm,
441
- durationSeconds: latestConfig.durationSeconds,
442
- backgroundAudioPath: latestConfig.backgroundAudioPath,
443
- backgroundVolume: latestConfig.backgroundVolume,
444
- affirmationRepeatCount: latestConfig.affirmationRepeatCount,
445
- includePreamble: latestConfig.includePreamble,
446
- playAll: latestConfig.playAll,
447
- createdAt: latestConfig.createdAt,
448
- updatedAt: latestConfig.updatedAt,
449
- } : null;
450
- // Map user intent detail to the Intent type expected by SetFileData.
451
- // User intents don't have catalog fields — default them.
452
- const mappedIntent = {
453
- id: intent.id,
454
- userId: '',
455
- title: intent.title,
456
- emoji: intent.emoji,
457
- rawText: intent.rawText,
458
- tonePreference: intent.tonePreference ?? null,
459
- sessionContext: intent.sessionContext,
460
- isCatalog: false,
461
- catalogSlug: null,
462
- catalogCategory: null,
463
- catalogSubtitle: null,
464
- catalogDescription: null,
465
- catalogOrder: null,
466
- createdAt: intent.createdAt,
467
- updatedAt: intent.updatedAt,
468
- archivedAt: null,
469
- };
470
- return { intent: mappedIntent, affirmations, renderConfig };
471
- }
472
361
  setCmd
473
362
  .command('export <intent-id>')
474
363
  .description('Export an affirmation set to YAML (stdout)')
@@ -575,7 +464,7 @@ setCmd
575
464
  }
576
465
  await createSetFromFile(client, parsed);
577
466
  });
578
- /** Create a new intent from a parsed set file. */
467
+ /** Create a new intent from a parsed set file using user API. */
579
468
  async function createSetFromFile(client, parsed) {
580
469
  if (!parsed.affirmations || parsed.affirmations.length === 0) {
581
470
  console.error('Error: "affirmations" are required to create a new set');
@@ -631,8 +520,114 @@ async function createSetFromFile(client, parsed) {
631
520
  printResult(err instanceof Error ? err.message : String(err), true);
632
521
  }
633
522
  }
523
+ // ═══════════════════════════════════════════════════════════════════════════════
524
+ // User-facing commands (JWT auth)
525
+ // ═══════════════════════════════════════════════════════════════════════════════
526
+ /** Get a user client from stored auth. Exits with helpful message if not logged in. */
527
+ function getUserClient() {
528
+ try {
529
+ return UserApiClient.fromAuth();
530
+ }
531
+ catch {
532
+ console.error('Not logged in. Run `neuralingual login` first.');
533
+ process.exit(1);
534
+ }
535
+ }
536
+ /**
537
+ * Resolve a short/truncated intent ID to the full ID by fetching the user's library.
538
+ * Handles exact match, prefix match, and ambiguous matches (multiple prefix hits).
539
+ */
540
+ async function resolveIntentId(client, shortId) {
541
+ const { items } = await client.getLibrary();
542
+ const exact = items.find((i) => i.intent.id === shortId);
543
+ if (exact)
544
+ return exact.intent.id;
545
+ const prefixMatches = items.filter((i) => i.intent.id.startsWith(shortId));
546
+ if (prefixMatches.length === 1)
547
+ return prefixMatches[0].intent.id;
548
+ if (prefixMatches.length > 1) {
549
+ console.error(`Error: ambiguous ID "${shortId}" matches ${prefixMatches.length} intents:`);
550
+ for (const m of prefixMatches) {
551
+ console.error(` ${m.intent.id.slice(0, 12)} ${m.intent.title ?? '(untitled)'}`);
552
+ }
553
+ process.exit(1);
554
+ }
555
+ console.error(`Error: no practice set found matching "${shortId}"`);
556
+ process.exit(1);
557
+ }
558
+ /**
559
+ * Fetch set file data using user API.
560
+ * Maps the user intent detail shape to SetFileData.
561
+ */
562
+ async function fetchSetFileData(client, intentId) {
563
+ const { intent } = await client.getIntent(intentId);
564
+ if (!intent) {
565
+ throw new Error(`Intent not found: ${intentId}`);
566
+ }
567
+ // Get latest affirmation set (first in the desc-ordered array)
568
+ const latestSet = intent.affirmationSets[0];
569
+ const affirmations = (latestSet?.affirmations ?? []).map((a, idx) => ({
570
+ id: a.id,
571
+ setId: latestSet?.id ?? '',
572
+ text: a.text,
573
+ tone: a.tone,
574
+ intensity: 3,
575
+ length: a.text.length < 60 ? 'short' : 'medium',
576
+ tags: [],
577
+ weight: 3,
578
+ isFavorite: false,
579
+ isEnabled: a.isEnabled,
580
+ orderIndex: idx,
581
+ createdAt: '',
582
+ updatedAt: '',
583
+ }));
584
+ // Get render config scoped to the latest affirmation set (matching the info command pattern).
585
+ // Without scoping, we could export a stale config from an older set.
586
+ const latestSetId = latestSet?.id;
587
+ const latestConfig = (latestSetId
588
+ ? intent.renderConfigs.find((rc) => rc.affirmationSetId === latestSetId)
589
+ : intent.renderConfigs[0]) ?? null;
590
+ const renderConfig = latestConfig ? {
591
+ id: latestConfig.id,
592
+ intentId: intent.id,
593
+ affirmationSetId: latestConfig.affirmationSetId,
594
+ voiceId: latestConfig.voiceId,
595
+ voiceProvider: latestConfig.voiceProvider,
596
+ sessionContext: latestConfig.sessionContext,
597
+ paceWpm: latestConfig.paceWpm,
598
+ durationSeconds: latestConfig.durationSeconds,
599
+ backgroundAudioPath: latestConfig.backgroundAudioPath,
600
+ backgroundVolume: latestConfig.backgroundVolume,
601
+ affirmationRepeatCount: latestConfig.affirmationRepeatCount,
602
+ includePreamble: latestConfig.includePreamble,
603
+ playAll: latestConfig.playAll,
604
+ createdAt: latestConfig.createdAt,
605
+ updatedAt: latestConfig.updatedAt,
606
+ } : null;
607
+ // Map user intent detail to the Intent type expected by SetFileData.
608
+ // User intents don't have catalog fields — default them.
609
+ const mappedIntent = {
610
+ id: intent.id,
611
+ userId: '',
612
+ title: intent.title,
613
+ emoji: intent.emoji,
614
+ rawText: intent.rawText,
615
+ tonePreference: intent.tonePreference ?? null,
616
+ sessionContext: intent.sessionContext,
617
+ isCatalog: false,
618
+ catalogSlug: null,
619
+ catalogCategory: null,
620
+ catalogSubtitle: null,
621
+ catalogDescription: null,
622
+ catalogOrder: null,
623
+ createdAt: intent.createdAt,
624
+ updatedAt: intent.updatedAt,
625
+ archivedAt: null,
626
+ };
627
+ return { intent: mappedIntent, affirmations, renderConfig };
628
+ }
634
629
  /**
635
- * Apply a parsed set file's changes to an existing intent.
630
+ * Apply a parsed set file using user API.
636
631
  */
637
632
  async function applySetFile(client, intentId, content, originalData) {
638
633
  let parsed;
@@ -662,7 +657,7 @@ async function applySetFile(client, intentId, content, originalData) {
662
657
  await client.updateIntent(intentId, intentUpdates);
663
658
  changes.push(`intent: updated ${Object.keys(intentUpdates).join(', ')}`);
664
659
  }
665
- // 2. Affirmation sync (declarative — YAML list is source of truth)
660
+ // 2. Affirmation sync (declarative)
666
661
  if (parsed.affirmations && parsed.affirmations.length > 0) {
667
662
  const missingIds = parsed.affirmations.filter((a) => !a.id);
668
663
  if (missingIds.length > 0 && originalData.affirmations.length > 0) {
@@ -849,7 +844,7 @@ async function browserLogin(env) {
849
844
  }
850
845
  program
851
846
  .command('login')
852
- .description('Log in to Neuralingual (Apple Sign-In via browser)')
847
+ .description('Log in to Neuralingual via Apple Sign-In (opens browser)')
853
848
  .option('--env <env>', 'API environment: dev or production', 'production')
854
849
  .action(async (opts) => {
855
850
  const env = opts.env;
@@ -1260,6 +1255,7 @@ program
1260
1255
  }
1261
1256
  });
1262
1257
  // ─── play ──────────────────────────────────────────────────────────────────
1258
+ const AUDIO_CACHE_DIR = join(homedir(), '.config', 'neuralingual', 'audio');
1263
1259
  program
1264
1260
  .command('play <intent-id>')
1265
1261
  .description('Download rendered audio (prints file path). Use --open to launch in default player.')
@@ -1351,6 +1347,16 @@ program
1351
1347
  }
1352
1348
  });
1353
1349
  // ─── delete ────────────────────────────────────────────────────────────────
1350
+ /** Prompt the user for input on stdin. */
1351
+ function prompt(question) {
1352
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
1353
+ return new Promise((resolve) => {
1354
+ rl.question(question, (answer) => {
1355
+ rl.close();
1356
+ resolve(answer.trim());
1357
+ });
1358
+ });
1359
+ }
1354
1360
  program
1355
1361
  .command('delete <intent-id>')
1356
1362
  .description('Delete a practice set from your library')