@innerstacklabs/neuralingual-mcp 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js ADDED
@@ -0,0 +1,1477 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from 'commander';
3
+ import { spawn, spawnSync, exec } from 'child_process';
4
+ import { createServer } from 'http';
5
+ import { randomBytes } from 'crypto';
6
+ import { writeFileSync, readFileSync, unlinkSync, existsSync, mkdirSync } from 'fs';
7
+ import { createInterface } from 'readline';
8
+ import { tmpdir, homedir } from 'os';
9
+ import { join } from 'path';
10
+ import { UserApiClient } from './user-client.js';
11
+ import { loadAuth, clearAuth } from './auth-store.js';
12
+ import { API_BASE_URLS } from './types.js';
13
+ import { serializeSetFile, parseSetFile } from './set-file.js';
14
+ import { z } from 'zod';
15
+ const VALID_TONES = ['grounded', 'open', 'mystical'];
16
+ const VALID_CONTEXTS = ['general', 'sleep', 'nap', 'meditation', 'workout', 'focus', 'walk', 'chores'];
17
+ const program = new Command();
18
+ program
19
+ .name('neuralingual')
20
+ .description('Neuralingual — AI-powered affirmation practice sets')
21
+ .version('0.1.0')
22
+ .option('--env <env>', 'API environment: dev or production (default: production)', 'production');
23
+ function printResult(data, isError = false) {
24
+ const text = typeof data === 'string' ? data : JSON.stringify(data, null, 2);
25
+ if (isError) {
26
+ console.error(text);
27
+ process.exit(1);
28
+ }
29
+ else {
30
+ console.log(text);
31
+ }
32
+ }
33
+ /** Render a simple text table with left-aligned columns. */
34
+ function printTable(rows, headers) {
35
+ const allRows = [headers, ...rows];
36
+ const widths = headers.map((_, i) => Math.max(...allRows.map((r) => (r[i] ?? '').length)));
37
+ const line = (row) => row.map((cell, i) => (cell ?? '').padEnd(widths[i] ?? 0)).join(' ');
38
+ const separator = widths.map((w) => '-'.repeat(w)).join(' ');
39
+ console.log(line(headers));
40
+ console.log(separator);
41
+ for (const row of rows) {
42
+ console.log(line(row));
43
+ }
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
+ // ─── render ──────────────────────────────────────────────────────────────────
112
+ const renderCmd = program.command('render').description('Render audio for an intent');
113
+ renderCmd
114
+ .command('configure <intent-id>')
115
+ .description('Configure render settings for an intent')
116
+ .requiredOption('--voice <name>', 'Voice ID (externalId) to use for rendering')
117
+ .requiredOption('--context <context>', `Session context: ${VALID_CONTEXTS.join(', ')}`)
118
+ .requiredOption('--duration <minutes>', 'Duration in minutes', parseInt)
119
+ .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)')
121
+ .option('--background-volume <level>', 'Background volume 0–1 (uses context default if omitted)', parseFloat)
122
+ .option('--repeats <n>', 'Number of times each affirmation repeats (uses context default if omitted)', parseInt)
123
+ .option('--preamble <on|off>', 'Include intro/outro preamble: on or off (preserves existing setting if omitted)')
124
+ .option('--play-all <on|off>', 'Play all affirmations instead of fitting within duration: on or off (preserves existing setting if omitted)')
125
+ .action(async (intentId, opts) => {
126
+ if (!VALID_CONTEXTS.includes(opts.context)) {
127
+ console.error(`Error: --context must be one of: ${VALID_CONTEXTS.join(', ')}`);
128
+ process.exit(1);
129
+ }
130
+ if (isNaN(opts.duration) || opts.duration < 1) {
131
+ console.error('Error: --duration must be a positive integer');
132
+ process.exit(1);
133
+ }
134
+ if (opts.pace !== undefined && (isNaN(opts.pace) || opts.pace < 90 || opts.pace > 220)) {
135
+ console.error('Error: --pace must be between 90 and 220');
136
+ process.exit(1);
137
+ }
138
+ if (opts.backgroundVolume !== undefined &&
139
+ (isNaN(opts.backgroundVolume) || opts.backgroundVolume < 0 || opts.backgroundVolume > 1)) {
140
+ console.error('Error: --background-volume must be between 0 and 1');
141
+ process.exit(1);
142
+ }
143
+ if (opts.repeats !== undefined && (isNaN(opts.repeats) || opts.repeats < 1 || opts.repeats > 5)) {
144
+ console.error('Error: --repeats must be between 1 and 5');
145
+ process.exit(1);
146
+ }
147
+ if (opts.preamble !== undefined && opts.preamble !== 'on' && opts.preamble !== 'off') {
148
+ console.error('Error: --preamble must be "on" or "off"');
149
+ process.exit(1);
150
+ }
151
+ if (opts.playAll !== undefined && opts.playAll !== 'on' && opts.playAll !== 'off') {
152
+ console.error('Error: --play-all must be "on" or "off"');
153
+ process.exit(1);
154
+ }
155
+ try {
156
+ const client = getUserClient();
157
+ const resolvedId = await resolveIntentId(client, intentId);
158
+ const input = {
159
+ voiceId: opts.voice,
160
+ sessionContext: opts.context,
161
+ durationMinutes: opts.duration,
162
+ };
163
+ if (opts.pace !== undefined)
164
+ input.paceWpm = opts.pace;
165
+ if (opts.background !== undefined)
166
+ input.backgroundAudioPath = opts.background;
167
+ if (opts.backgroundVolume !== undefined)
168
+ input.backgroundVolume = opts.backgroundVolume;
169
+ if (opts.repeats !== undefined)
170
+ input.affirmationRepeatCount = opts.repeats;
171
+ if (opts.preamble !== undefined)
172
+ input.includePreamble = opts.preamble === 'on';
173
+ if (opts.playAll !== undefined)
174
+ input.playAll = opts.playAll === 'on';
175
+ const result = await client.configureRender(resolvedId, input);
176
+ printResult(result);
177
+ }
178
+ catch (err) {
179
+ printResult(err instanceof Error ? err.message : String(err), true);
180
+ }
181
+ });
182
+ renderCmd
183
+ .command('start <intent-id>')
184
+ .description('Start a render job for an intent')
185
+ .option('--wait', 'Wait for the render to complete, showing progress')
186
+ .action(async (intentId, opts) => {
187
+ const client = getUserClient();
188
+ const resolvedId = await resolveIntentId(client, intentId);
189
+ try {
190
+ const result = await client.startRender(resolvedId);
191
+ if (!opts.wait) {
192
+ printResult(result);
193
+ return;
194
+ }
195
+ const { jobId } = result;
196
+ console.error(`Render queued (job: ${jobId}). Waiting for completion...`);
197
+ // Poll until the specific job we started completes or fails
198
+ let elapsedMs = 0;
199
+ const POLL_INITIAL_MS = 3000;
200
+ const POLL_BACKOFF_AFTER_MS = 30000;
201
+ const POLL_BACKOFF_MS = 6000;
202
+ for (;;) {
203
+ const intervalMs = elapsedMs >= POLL_BACKOFF_AFTER_MS ? POLL_BACKOFF_MS : POLL_INITIAL_MS;
204
+ await new Promise((resolve) => setTimeout(resolve, intervalMs));
205
+ elapsedMs += intervalMs;
206
+ let status;
207
+ try {
208
+ status = await client.getRenderStatus(resolvedId);
209
+ }
210
+ catch (pollErr) {
211
+ console.error(`Warning: status poll failed — ${pollErr instanceof Error ? pollErr.message : String(pollErr)}`);
212
+ continue;
213
+ }
214
+ if (status.jobId === undefined) {
215
+ console.error(`Warning: render status has no active job (config may have been reconfigured). Exiting --wait.`);
216
+ printResult(status);
217
+ return;
218
+ }
219
+ if (status.jobId !== jobId) {
220
+ console.error(`Warning: render status is now tracking a different job (${status.jobId}). Exiting --wait.`);
221
+ printResult(status);
222
+ return;
223
+ }
224
+ console.error(` [${Math.round(elapsedMs / 1000)}s] status=${status.status} progress=${status.progress}%`);
225
+ if (status.status === 'completed') {
226
+ printResult(status);
227
+ return;
228
+ }
229
+ if (status.status === 'failed') {
230
+ console.error(`Render failed: ${status.errorMessage ?? 'unknown error'}`);
231
+ process.exit(1);
232
+ }
233
+ }
234
+ }
235
+ catch (err) {
236
+ printResult(err instanceof Error ? err.message : String(err), true);
237
+ }
238
+ });
239
+ renderCmd
240
+ .command('status <intent-id>')
241
+ .description('Get the current render status for an intent')
242
+ .action(async (intentId) => {
243
+ const client = getUserClient();
244
+ try {
245
+ const resolvedId = await resolveIntentId(client, intentId);
246
+ const result = await client.getRenderStatus(resolvedId);
247
+ printResult(result);
248
+ }
249
+ catch (err) {
250
+ printResult(err instanceof Error ? err.message : String(err), true);
251
+ }
252
+ });
253
+ // ─── voices ──────────────────────────────────────────────────────────────────
254
+ const voicesCmd = program.command('voices').description('Browse and preview available voices');
255
+ const voiceDtoSchema = z.object({
256
+ id: z.string(),
257
+ provider: z.string(),
258
+ displayName: z.string(),
259
+ description: z.string().nullable(),
260
+ gender: z.string(),
261
+ accent: z.string(),
262
+ tier: z.string(),
263
+ category: z.string().nullable(),
264
+ playCount: z.number(),
265
+ });
266
+ const voicesResponseSchema = z.object({
267
+ voices: z.array(voiceDtoSchema),
268
+ });
269
+ const AUDIO_CACHE_DIR = join(homedir(), '.config', 'neuralingual', 'audio');
270
+ voicesCmd
271
+ .command('show', { isDefault: true })
272
+ .description('List available voices')
273
+ .option('--gender <gender>', 'Filter by gender (e.g. Male, Female)')
274
+ .option('--accent <accent>', 'Filter by accent (e.g. US, UK, AU)')
275
+ .option('--tier <tier>', 'Filter by tier (e.g. free, premium)')
276
+ .option('--json', 'Output raw JSON')
277
+ .action(async (opts) => {
278
+ try {
279
+ const baseUrl = getApiBaseUrl();
280
+ const res = await fetch(`${baseUrl}/voices`);
281
+ if (!res.ok)
282
+ throw new Error(`HTTP ${res.status}`);
283
+ let { voices } = voicesResponseSchema.parse(await res.json());
284
+ if (opts.gender) {
285
+ const g = opts.gender.toLowerCase();
286
+ voices = voices.filter((v) => v.gender.toLowerCase() === g);
287
+ }
288
+ if (opts.accent) {
289
+ const a = opts.accent.toLowerCase();
290
+ voices = voices.filter((v) => v.accent.toLowerCase() === a);
291
+ }
292
+ if (opts.tier) {
293
+ const t = opts.tier.toLowerCase();
294
+ voices = voices.filter((v) => v.tier.toLowerCase() === t);
295
+ }
296
+ if (opts.json) {
297
+ console.log(JSON.stringify(voices, null, 2));
298
+ return;
299
+ }
300
+ if (voices.length === 0) {
301
+ console.log('No voices found matching your filters.');
302
+ return;
303
+ }
304
+ const truncate = (s, max) => {
305
+ if (!s)
306
+ return '';
307
+ return s.length > max ? s.slice(0, max - 1) + '\u2026' : s;
308
+ };
309
+ printTable(voices.map((v) => [v.id, v.displayName, v.gender, v.accent, v.tier, truncate(v.description, 50)]), ['ID', 'NAME', 'GENDER', 'ACCENT', 'TIER', 'DESCRIPTION']);
310
+ console.log(`\nUse --voice <ID> with 'neuralingual render configure' to select a voice.`);
311
+ console.log(`Use 'neuralingual voices preview <ID>' to hear a voice sample.`);
312
+ }
313
+ catch (err) {
314
+ console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
315
+ process.exit(1);
316
+ }
317
+ });
318
+ voicesCmd
319
+ .command('preview <voice-id>')
320
+ .description('Play a short audio preview of a voice')
321
+ .option('--no-cache', 'Skip cache, always re-download')
322
+ .action(async (voiceId, opts) => {
323
+ try {
324
+ const baseUrl = getApiBaseUrl();
325
+ const env = resolveApiEnv();
326
+ // Sanitize voiceId for safe use as a filename component
327
+ const safeVoiceId = voiceId.replace(/[^a-zA-Z0-9_-]/g, '_');
328
+ const cacheFile = join(AUDIO_CACHE_DIR, `preview-${env}-${safeVoiceId}.mp3`);
329
+ if (opts.cache && existsSync(cacheFile)) {
330
+ console.log(`Playing preview for '${voiceId}' (cached)`);
331
+ }
332
+ else {
333
+ console.log(`Downloading preview for '${voiceId}'...`);
334
+ const res = await fetch(`${baseUrl}/voices/${encodeURIComponent(voiceId)}/preview`);
335
+ if (!res.ok) {
336
+ const data = (await res.json().catch(() => null));
337
+ throw new Error(data && typeof data['error'] === 'string' ? data['error'] : `HTTP ${res.status}`);
338
+ }
339
+ const ab = await res.arrayBuffer();
340
+ mkdirSync(AUDIO_CACHE_DIR, { recursive: true });
341
+ writeFileSync(cacheFile, Buffer.from(ab));
342
+ console.log(`Playing preview for '${voiceId}'`);
343
+ }
344
+ const player = process.platform === 'darwin' ? 'afplay' : 'xdg-open';
345
+ const result = spawnSync(player, [cacheFile], { stdio: 'inherit' });
346
+ if (result.status !== 0) {
347
+ console.error('Playback failed. Try opening the file manually:');
348
+ console.log(cacheFile);
349
+ }
350
+ // Report play count (fire-and-forget, same as web client)
351
+ fetch(`${baseUrl}/voices/${encodeURIComponent(voiceId)}/play`, { method: 'POST' }).catch(() => { });
352
+ }
353
+ catch (err) {
354
+ console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
355
+ process.exit(1);
356
+ }
357
+ });
358
+ // ─── set (declarative YAML file) ────────────────────────────────────────────
359
+ const setCmd = program.command('set').description('Export/import a complete affirmation set as a YAML file');
360
+ /** Read content from --file <path>, --file - (stdin), or piped stdin. */
361
+ async function readContentFromFileOrStdin(opts) {
362
+ if (opts.file === '-') {
363
+ return readStdin();
364
+ }
365
+ if (opts.file) {
366
+ try {
367
+ return readFileSync(opts.file, 'utf8');
368
+ }
369
+ catch (err) {
370
+ console.error(`Error: could not read file '${opts.file}': ${err instanceof Error ? err.message : String(err)}`);
371
+ process.exit(1);
372
+ }
373
+ }
374
+ if (process.stdin.isTTY) {
375
+ console.error('Error: provide YAML via --file <path> or pipe to stdin (--file -)');
376
+ process.exit(1);
377
+ }
378
+ return readStdin();
379
+ }
380
+ /** Build a RenderConfigInput from parsed YAML fields. Used by set create and set apply. */
381
+ function buildRenderInputFromParsed(parsed, fallback) {
382
+ const input = {
383
+ voiceId: parsed.voice ?? fallback?.voiceId ?? '',
384
+ sessionContext: (parsed.renderContext ?? fallback?.sessionContext ?? parsed.intentContext ?? 'general'),
385
+ durationMinutes: parsed.duration ?? (fallback?.durationSeconds ? Math.round(fallback.durationSeconds / 60) : 10),
386
+ };
387
+ if (parsed.pace !== undefined)
388
+ input.paceWpm = parsed.pace;
389
+ if (parsed.background !== undefined)
390
+ input.backgroundAudioPath = parsed.background;
391
+ if (parsed.backgroundVolume !== undefined)
392
+ input.backgroundVolume = parsed.backgroundVolume;
393
+ if (parsed.repeats !== undefined)
394
+ input.affirmationRepeatCount = parsed.repeats;
395
+ if (parsed.preamble !== undefined)
396
+ input.includePreamble = parsed.preamble;
397
+ if (parsed.playAll !== undefined)
398
+ input.playAll = parsed.playAll;
399
+ return input;
400
+ }
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
+ setCmd
473
+ .command('export <intent-id>')
474
+ .description('Export an affirmation set to YAML (stdout)')
475
+ .action(async (intentId) => {
476
+ const client = getUserClient();
477
+ try {
478
+ const resolvedId = await resolveIntentId(client, intentId);
479
+ const data = await fetchSetFileData(client, resolvedId);
480
+ process.stdout.write(serializeSetFile(data));
481
+ }
482
+ catch (err) {
483
+ printResult(err instanceof Error ? err.message : String(err), true);
484
+ }
485
+ });
486
+ setCmd
487
+ .command('edit <intent-id>')
488
+ .description('Open a set file in $EDITOR, then apply changes')
489
+ .action(async (intentId) => {
490
+ const client = getUserClient();
491
+ const resolvedId = await resolveIntentId(client, intentId);
492
+ let originalData;
493
+ try {
494
+ originalData = await fetchSetFileData(client, resolvedId);
495
+ }
496
+ catch (err) {
497
+ printResult(err instanceof Error ? err.message : String(err), true);
498
+ return;
499
+ }
500
+ const yaml = serializeSetFile(originalData);
501
+ const isTTY = Boolean(process.stdout.isTTY && process.stdin.isTTY);
502
+ if (!isTTY) {
503
+ console.error('Error: no TTY available for interactive editor. Use one of:\n' +
504
+ ' neuralingual set export <id> Print YAML to stdout\n' +
505
+ ' neuralingual set apply <id> --file <path> Apply from a file\n' +
506
+ ' neuralingual set apply <id> --file - Apply from stdin');
507
+ process.exit(1);
508
+ }
509
+ const editorEnv = (process.env['EDITOR'] ?? 'vi').trim();
510
+ if (!editorEnv) {
511
+ console.error('Error: $EDITOR is empty. Set it to your preferred editor.');
512
+ process.exit(1);
513
+ }
514
+ const tmpFile = join(tmpdir(), `nl-set-${resolvedId}-${Date.now()}.yaml`);
515
+ writeFileSync(tmpFile, yaml, 'utf8');
516
+ const spawnResult = spawnSync(`${editorEnv} ${JSON.stringify(tmpFile)}`, { stdio: 'inherit', shell: true });
517
+ if (spawnResult.error) {
518
+ unlinkSync(tmpFile);
519
+ console.error(`Error: could not open editor '${editorEnv}': ${spawnResult.error.message}`);
520
+ process.exit(1);
521
+ }
522
+ if (spawnResult.status !== 0) {
523
+ unlinkSync(tmpFile);
524
+ console.error(`Editor exited with status ${spawnResult.status ?? 'unknown'}. No changes applied.`);
525
+ process.exit(1);
526
+ }
527
+ let editedContent;
528
+ try {
529
+ editedContent = readFileSync(tmpFile, 'utf8');
530
+ }
531
+ finally {
532
+ unlinkSync(tmpFile);
533
+ }
534
+ try {
535
+ await applySetFile(client, resolvedId, editedContent, originalData);
536
+ }
537
+ catch (err) {
538
+ printResult(err instanceof Error ? err.message : String(err), true);
539
+ }
540
+ });
541
+ setCmd
542
+ .command('apply <intent-id>')
543
+ .description('Apply a YAML set file to an existing intent')
544
+ .option('--file <path>', 'Read YAML from a file (use "-" for stdin)')
545
+ .action(async (intentId, opts) => {
546
+ const client = getUserClient();
547
+ const content = await readContentFromFileOrStdin(opts);
548
+ const resolvedId = await resolveIntentId(client, intentId);
549
+ try {
550
+ const originalData = await fetchSetFileData(client, resolvedId);
551
+ await applySetFile(client, resolvedId, content, originalData);
552
+ }
553
+ catch (err) {
554
+ printResult(err instanceof Error ? err.message : String(err), true);
555
+ }
556
+ });
557
+ setCmd
558
+ .command('create')
559
+ .description('Create a new intent from a YAML set file (full round-trip)')
560
+ .option('--file <path>', 'Read YAML from a file (use "-" for stdin)')
561
+ .action(async (opts) => {
562
+ const client = getUserClient();
563
+ const content = await readContentFromFileOrStdin(opts);
564
+ let parsed;
565
+ try {
566
+ parsed = parseSetFile(content);
567
+ }
568
+ catch (err) {
569
+ console.error(`Error: invalid YAML — ${err instanceof Error ? err.message : String(err)}`);
570
+ process.exit(1);
571
+ }
572
+ if (!parsed.intent) {
573
+ console.error('Error: "intent" field is required to create a new set');
574
+ process.exit(1);
575
+ }
576
+ await createSetFromFile(client, parsed);
577
+ });
578
+ /** Create a new intent from a parsed set file. */
579
+ async function createSetFromFile(client, parsed) {
580
+ if (!parsed.affirmations || parsed.affirmations.length === 0) {
581
+ console.error('Error: "affirmations" are required to create a new set');
582
+ process.exit(1);
583
+ }
584
+ let createdIntentId;
585
+ try {
586
+ const steps = [];
587
+ // 1. Create intent + affirmations together via POST /intents/manual
588
+ const title = parsed.title ?? parsed.intent.slice(0, 120);
589
+ const result = await client.createManualIntent({
590
+ title,
591
+ rawText: parsed.intent,
592
+ tonePreference: parsed.tone ?? null,
593
+ sessionContext: parsed.intentContext,
594
+ affirmations: parsed.affirmations.map((a) => ({ text: a.text })),
595
+ });
596
+ createdIntentId = result.intent.id;
597
+ steps.push(`intent: created with ${result.affirmationSet.affirmations.length} affirmations`);
598
+ // 2. Update emoji if specified (not part of manual create)
599
+ if (parsed.emoji !== undefined) {
600
+ await client.updateIntent(result.intent.id, { emoji: parsed.emoji });
601
+ steps.push('intent: updated emoji');
602
+ }
603
+ // 3. Sync affirmations to set enabled/disabled state
604
+ // The manual create endpoint doesn't support per-affirmation enabled state,
605
+ // so we sync to apply the exact desired state from the YAML.
606
+ const hasDisabled = parsed.affirmations.some((a) => !a.enabled);
607
+ if (hasDisabled) {
608
+ await client.syncAffirmations(result.intent.id, {
609
+ affirmations: parsed.affirmations.map((a) => ({
610
+ text: a.text,
611
+ enabled: a.enabled,
612
+ })),
613
+ });
614
+ steps.push('affirmations: synced enabled/disabled state');
615
+ }
616
+ // 4. Configure render (if voice is specified)
617
+ if (parsed.voice) {
618
+ await client.configureRender(result.intent.id, buildRenderInputFromParsed(parsed));
619
+ steps.push('render config: created');
620
+ }
621
+ console.log(`Created intent: ${result.intent.id}`);
622
+ for (const s of steps) {
623
+ console.error(` - ${s}`);
624
+ }
625
+ }
626
+ catch (err) {
627
+ if (createdIntentId) {
628
+ console.error(`Error during set create. Partial intent was created: ${createdIntentId}`);
629
+ console.error(`Clean up with: neuralingual delete ${createdIntentId}`);
630
+ }
631
+ printResult(err instanceof Error ? err.message : String(err), true);
632
+ }
633
+ }
634
+ /**
635
+ * Apply a parsed set file's changes to an existing intent.
636
+ */
637
+ async function applySetFile(client, intentId, content, originalData) {
638
+ let parsed;
639
+ try {
640
+ parsed = parseSetFile(content);
641
+ }
642
+ catch (err) {
643
+ console.error(`Error: invalid YAML — ${err instanceof Error ? err.message : String(err)}`);
644
+ process.exit(1);
645
+ }
646
+ const changes = [];
647
+ // 1. Intent metadata updates (user API uses intentText, not rawText)
648
+ const intentUpdates = {};
649
+ if (parsed.title !== undefined && parsed.title !== originalData.intent.title) {
650
+ intentUpdates.title = parsed.title;
651
+ }
652
+ if (parsed.tone !== undefined && parsed.tone !== originalData.intent.tonePreference) {
653
+ intentUpdates.tonePreference = parsed.tone;
654
+ }
655
+ if (parsed.intent !== undefined && parsed.intent !== originalData.intent.rawText) {
656
+ intentUpdates.intentText = parsed.intent;
657
+ }
658
+ if (parsed.emoji !== undefined && parsed.emoji !== originalData.intent.emoji) {
659
+ intentUpdates.emoji = parsed.emoji;
660
+ }
661
+ if (Object.keys(intentUpdates).length > 0) {
662
+ await client.updateIntent(intentId, intentUpdates);
663
+ changes.push(`intent: updated ${Object.keys(intentUpdates).join(', ')}`);
664
+ }
665
+ // 2. Affirmation sync (declarative — YAML list is source of truth)
666
+ if (parsed.affirmations && parsed.affirmations.length > 0) {
667
+ const missingIds = parsed.affirmations.filter((a) => !a.id);
668
+ if (missingIds.length > 0 && originalData.affirmations.length > 0) {
669
+ console.error(`Warning: ${missingIds.length} affirmation(s) are missing an ID. ` +
670
+ 'They will be treated as new additions. If this was unintentional, ' +
671
+ 're-export the set to get the current IDs.');
672
+ }
673
+ const syncResult = await client.syncAffirmations(intentId, {
674
+ affirmations: parsed.affirmations.map((a) => ({
675
+ id: a.id,
676
+ text: a.text,
677
+ enabled: a.enabled,
678
+ })),
679
+ });
680
+ const parts = [];
681
+ if (syncResult.added > 0)
682
+ parts.push(`${syncResult.added} added`);
683
+ if (syncResult.updated > 0)
684
+ parts.push(`${syncResult.updated} updated`);
685
+ if (syncResult.removed > 0)
686
+ parts.push(`${syncResult.removed} removed`);
687
+ if (parts.length > 0) {
688
+ changes.push(`affirmations: ${parts.join(', ')}`);
689
+ }
690
+ }
691
+ // 3. Render config updates
692
+ const hasRenderFields = parsed.voice !== undefined ||
693
+ parsed.duration !== undefined ||
694
+ parsed.pace !== undefined ||
695
+ parsed.renderContext !== undefined ||
696
+ parsed.background !== undefined ||
697
+ parsed.backgroundVolume !== undefined ||
698
+ parsed.repeats !== undefined ||
699
+ parsed.preamble !== undefined ||
700
+ parsed.playAll !== undefined;
701
+ if (hasRenderFields) {
702
+ if (!originalData.renderConfig) {
703
+ console.error('Warning: no render config exists yet — skipping render settings. Run neuralingual render configure first.');
704
+ }
705
+ else {
706
+ const rc = originalData.renderConfig;
707
+ await client.configureRender(intentId, buildRenderInputFromParsed(parsed, rc));
708
+ changes.push('render config: updated');
709
+ }
710
+ }
711
+ if (changes.length === 0) {
712
+ console.error('No changes detected.');
713
+ }
714
+ else {
715
+ console.error(`Applied ${changes.length} change(s):`);
716
+ for (const c of changes) {
717
+ console.error(` - ${c}`);
718
+ }
719
+ }
720
+ }
721
+ // ─── login ──────────────────────────────────────────────────────────────────
722
+ const WEB_BASE_URLS = {
723
+ dev: 'http://localhost:3010',
724
+ production: 'https://neuralingual.com',
725
+ };
726
+ const LOGIN_TIMEOUT_MS = 120_000;
727
+ const callbackPayloadSchema = z.object({
728
+ state: z.string().min(1),
729
+ idToken: z.string().min(1),
730
+ displayName: z.string().max(200).optional(),
731
+ });
732
+ /** Open a URL in the default browser. */
733
+ function openBrowser(url) {
734
+ const cmd = process.platform === 'darwin' ? 'open'
735
+ : process.platform === 'win32' ? 'start ""'
736
+ : 'xdg-open';
737
+ exec(`${cmd} ${JSON.stringify(url)}`, () => { });
738
+ }
739
+ /** Browser-based Apple Sign-In flow for regular users. */
740
+ async function browserLogin(env) {
741
+ const state = randomBytes(32).toString('hex');
742
+ return new Promise((resolve, reject) => {
743
+ let settled = false;
744
+ const server = createServer(async (req, res) => {
745
+ // CORS preflight for the web page POST
746
+ // Includes Private Network Access header for Chromium PNA enforcement
747
+ if (req.method === 'OPTIONS') {
748
+ res.writeHead(204, {
749
+ 'Access-Control-Allow-Origin': '*',
750
+ 'Access-Control-Allow-Methods': 'POST',
751
+ 'Access-Control-Allow-Headers': 'Content-Type',
752
+ 'Access-Control-Allow-Private-Network': 'true',
753
+ 'Access-Control-Max-Age': '86400',
754
+ });
755
+ res.end();
756
+ return;
757
+ }
758
+ if (req.method !== 'POST' || req.url !== '/callback') {
759
+ res.writeHead(404, { 'Content-Type': 'text/plain' });
760
+ res.end('Not Found');
761
+ return;
762
+ }
763
+ // Read POST body
764
+ const chunks = [];
765
+ for await (const chunk of req) {
766
+ chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk);
767
+ }
768
+ const body = Buffer.concat(chunks).toString('utf8');
769
+ let bodyParsed;
770
+ try {
771
+ bodyParsed = JSON.parse(body);
772
+ }
773
+ catch {
774
+ res.writeHead(400, {
775
+ 'Content-Type': 'application/json',
776
+ 'Access-Control-Allow-Origin': '*',
777
+ });
778
+ res.end(JSON.stringify({ error: 'Invalid JSON' }));
779
+ return;
780
+ }
781
+ const result = callbackPayloadSchema.safeParse(bodyParsed);
782
+ if (!result.success) {
783
+ res.writeHead(400, {
784
+ 'Content-Type': 'application/json',
785
+ 'Access-Control-Allow-Origin': '*',
786
+ });
787
+ res.end(JSON.stringify({ error: 'Invalid callback payload' }));
788
+ return;
789
+ }
790
+ const payload = result.data;
791
+ // Validate state to prevent CSRF
792
+ if (payload.state !== state) {
793
+ res.writeHead(403, {
794
+ 'Content-Type': 'application/json',
795
+ 'Access-Control-Allow-Origin': '*',
796
+ });
797
+ res.end(JSON.stringify({ error: 'State mismatch' }));
798
+ return;
799
+ }
800
+ try {
801
+ const { user } = await UserApiClient.loginWithApple(env, payload.idToken, payload.displayName);
802
+ res.writeHead(200, {
803
+ 'Content-Type': 'application/json',
804
+ 'Access-Control-Allow-Origin': '*',
805
+ });
806
+ res.end(JSON.stringify({ ok: true }));
807
+ console.log(`\nLogged in as ${user.displayName ?? user.email ?? user.id} (${env})`);
808
+ console.log(`Credits: ${user.creditBalance}`);
809
+ settled = true;
810
+ server.close();
811
+ resolve();
812
+ }
813
+ catch (err) {
814
+ const msg = err instanceof Error ? err.message : String(err);
815
+ res.writeHead(500, {
816
+ 'Content-Type': 'application/json',
817
+ 'Access-Control-Allow-Origin': '*',
818
+ });
819
+ res.end(JSON.stringify({ error: msg }));
820
+ settled = true;
821
+ server.close();
822
+ reject(new Error(`Login failed: ${msg}`));
823
+ }
824
+ });
825
+ // Bind to loopback IP (RFC 8252), OS-assigned port
826
+ server.listen(0, '127.0.0.1', () => {
827
+ const addr = server.address();
828
+ if (!addr || typeof addr === 'string') {
829
+ settled = true;
830
+ reject(new Error('Failed to start local server'));
831
+ return;
832
+ }
833
+ const port = addr.port;
834
+ const webBase = WEB_BASE_URLS[env];
835
+ const loginUrl = `${webBase}/auth/cli?port=${port}&state=${encodeURIComponent(state)}`;
836
+ console.log('Opening browser for Apple Sign-In...');
837
+ console.log(`If the browser does not open, visit: ${loginUrl}`);
838
+ openBrowser(loginUrl);
839
+ });
840
+ // Timeout to prevent orphaned servers
841
+ setTimeout(() => {
842
+ if (!settled) {
843
+ settled = true;
844
+ server.close();
845
+ reject(new Error('Login timed out. Please try again.'));
846
+ }
847
+ }, LOGIN_TIMEOUT_MS);
848
+ });
849
+ }
850
+ program
851
+ .command('login')
852
+ .description('Log in to Neuralingual (Apple Sign-In via browser)')
853
+ .option('--env <env>', 'API environment: dev or production', 'production')
854
+ .action(async (opts) => {
855
+ const env = opts.env;
856
+ if (env !== 'dev' && env !== 'production') {
857
+ console.error('Error: --env must be "dev" or "production"');
858
+ process.exit(1);
859
+ }
860
+ try {
861
+ await browserLogin(env);
862
+ }
863
+ catch (err) {
864
+ console.error(err instanceof Error ? err.message : String(err));
865
+ process.exit(1);
866
+ }
867
+ });
868
+ // ─── logout ─────────────────────────────────────────────────────────────────
869
+ program
870
+ .command('logout')
871
+ .description('Log out and clear stored tokens')
872
+ .action(async () => {
873
+ const auth = loadAuth();
874
+ if (!auth) {
875
+ console.log('Not logged in.');
876
+ return;
877
+ }
878
+ try {
879
+ const client = getUserClient();
880
+ await client.logout();
881
+ }
882
+ catch {
883
+ // Best-effort server logout; always clear local tokens
884
+ clearAuth();
885
+ }
886
+ console.log('Logged out.');
887
+ });
888
+ // ─── whoami ─────────────────────────────────────────────────────────────────
889
+ program
890
+ .command('whoami')
891
+ .description('Show current user info')
892
+ .action(async () => {
893
+ const client = getUserClient();
894
+ try {
895
+ const { user } = await client.getMe();
896
+ const lines = [
897
+ `User: ${user.displayName ?? '(no name)'}`,
898
+ `Email: ${user.email ?? '(none)'}`,
899
+ `ID: ${user.id}`,
900
+ `Tier: ${user.subscriptionTier ?? 'free'}`,
901
+ `Credits: ${user.creditBalance}`,
902
+ `Role: ${user.role}`,
903
+ ];
904
+ console.log(lines.join('\n'));
905
+ }
906
+ catch (err) {
907
+ console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
908
+ process.exit(1);
909
+ }
910
+ });
911
+ // ─── library ────────────────────────────────────────────────────────────────
912
+ program
913
+ .command('library')
914
+ .description('List your practice sets')
915
+ .option('--context <ctx>', `Filter by context: ${VALID_CONTEXTS.join(', ')}`)
916
+ .option('--status <status>', 'Filter by render status: rendered, pending, failed')
917
+ .option('--sort <order>', 'Sort order: newest, oldest, title (default: newest)', 'newest')
918
+ .option('--limit <n>', 'Limit number of results', parseInt)
919
+ .action(async (opts) => {
920
+ if (opts.context && !VALID_CONTEXTS.includes(opts.context)) {
921
+ console.error(`Error: --context must be one of: ${VALID_CONTEXTS.join(', ')}`);
922
+ process.exit(1);
923
+ }
924
+ const validStatuses = ['rendered', 'pending', 'failed'];
925
+ if (opts.status && !validStatuses.includes(opts.status)) {
926
+ console.error(`Error: --status must be one of: ${validStatuses.join(', ')}`);
927
+ process.exit(1);
928
+ }
929
+ const validSorts = ['newest', 'oldest', 'title'];
930
+ if (!validSorts.includes(opts.sort)) {
931
+ console.error(`Error: --sort must be one of: ${validSorts.join(', ')}`);
932
+ process.exit(1);
933
+ }
934
+ const client = getUserClient();
935
+ try {
936
+ const { items } = await client.getLibrary();
937
+ let filtered = items;
938
+ // Filter by context
939
+ if (opts.context) {
940
+ filtered = filtered.filter((item) => item.intent?.sessionContext === opts.context);
941
+ }
942
+ // Filter by render status — use latestRenderJob which matches the displayed Render column
943
+ if (opts.status) {
944
+ filtered = filtered.filter((item) => {
945
+ const jobStatus = item.latestRenderJob?.status;
946
+ if (opts.status === 'rendered')
947
+ return jobStatus === 'completed';
948
+ if (opts.status === 'failed')
949
+ return jobStatus === 'failed';
950
+ // pending = not completed and not failed (includes no job, queued, processing)
951
+ return jobStatus !== 'completed' && jobStatus !== 'failed';
952
+ });
953
+ }
954
+ // Sort
955
+ if (opts.sort === 'oldest') {
956
+ filtered.sort((a, b) => new Date(a.intent?.createdAt ?? 0).getTime() - new Date(b.intent?.createdAt ?? 0).getTime());
957
+ }
958
+ else if (opts.sort === 'title') {
959
+ filtered.sort((a, b) => (a.intent?.title ?? '').localeCompare(b.intent?.title ?? ''));
960
+ }
961
+ else {
962
+ // newest (default) — reverse chronological
963
+ filtered.sort((a, b) => new Date(b.intent?.createdAt ?? 0).getTime() - new Date(a.intent?.createdAt ?? 0).getTime());
964
+ }
965
+ // Limit
966
+ if (opts.limit && opts.limit > 0) {
967
+ filtered = filtered.slice(0, opts.limit);
968
+ }
969
+ if (filtered.length === 0) {
970
+ console.log('No matching practice sets found.');
971
+ return;
972
+ }
973
+ const rows = filtered.map((item) => {
974
+ const hasAudio = item.configs?.some((c) => c.latestRenderJob?.status === 'completed') ??
975
+ item.latestRenderJob?.status === 'completed';
976
+ return [
977
+ item.intent?.id?.slice(0, 8) ?? '?',
978
+ item.intent?.emoji ?? '',
979
+ item.intent?.title ?? '(untitled)',
980
+ item.intent?.sessionContext ?? '',
981
+ String(item.latestAffirmationSet?.affirmationCount ?? 0),
982
+ hasAudio ? 'yes' : 'no',
983
+ item.latestRenderJob?.status ?? '-',
984
+ ];
985
+ });
986
+ printTable(rows, ['ID', '', 'Title', 'Context', 'Affirmations', 'Audio', 'Render']);
987
+ }
988
+ catch (err) {
989
+ console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
990
+ process.exit(1);
991
+ }
992
+ });
993
+ // ─── info ───────────────────────────────────────────────────────────────────
994
+ program
995
+ .command('info <intent-id>')
996
+ .description('Show detailed info for a practice set')
997
+ .action(async (intentId) => {
998
+ const client = getUserClient();
999
+ try {
1000
+ const resolvedId = await resolveIntentId(client, intentId);
1001
+ const { intent } = await client.getIntent(resolvedId);
1002
+ if (!intent) {
1003
+ console.error('Error: practice set not found');
1004
+ process.exit(1);
1005
+ }
1006
+ // Header
1007
+ console.log(`${intent.emoji ?? ''} ${intent.title}`.trim());
1008
+ console.log(`ID: ${intent.id}`);
1009
+ console.log(`Context: ${intent.sessionContext}`);
1010
+ if (intent.tonePreference)
1011
+ console.log(`Tone: ${intent.tonePreference}`);
1012
+ console.log(`Intent: ${intent.rawText}`);
1013
+ console.log();
1014
+ // Affirmations
1015
+ const latestSet = intent.affirmationSets[0];
1016
+ if (latestSet) {
1017
+ const enabled = latestSet.affirmations.filter((a) => a.isEnabled).length;
1018
+ const total = latestSet.affirmations.length;
1019
+ console.log(`Affirmations: ${enabled} enabled / ${total} total`);
1020
+ const display = latestSet.affirmations.slice(0, 10);
1021
+ for (const a of display) {
1022
+ console.log(` ${a.isEnabled ? '[x]' : '[ ]'} ${a.text}`);
1023
+ }
1024
+ if (total > 10) {
1025
+ console.log(` ... and ${total - 10} more`);
1026
+ }
1027
+ console.log();
1028
+ }
1029
+ // Render config — scope to latest affirmation set to avoid showing stale configs
1030
+ const latestSetId = latestSet?.id;
1031
+ const scopedConfigs = latestSetId
1032
+ ? intent.renderConfigs.filter((c) => c.affirmationSetId === latestSetId)
1033
+ : intent.renderConfigs;
1034
+ const latestConfig = scopedConfigs[0] ?? intent.renderConfigs[0];
1035
+ if (latestConfig) {
1036
+ console.log('Render Config:');
1037
+ console.log(` Voice: ${latestConfig.voiceId ?? latestConfig.voiceProvider}`);
1038
+ console.log(` Duration: ${Math.round(latestConfig.durationSeconds / 60)} min`);
1039
+ console.log(` Pace: ${latestConfig.paceWpm} wpm`);
1040
+ console.log(` Preamble: ${latestConfig.includePreamble ? 'on' : 'off'}`);
1041
+ console.log(` Repeats: ${latestConfig.affirmationRepeatCount}`);
1042
+ console.log(` Play all: ${latestConfig.playAll ? 'on' : 'off'}`);
1043
+ if (latestConfig.backgroundAudioPath) {
1044
+ console.log(` Background: ${latestConfig.backgroundAudioPath} (vol: ${latestConfig.backgroundVolume})`);
1045
+ }
1046
+ console.log();
1047
+ }
1048
+ else {
1049
+ console.log('Render Config: not configured');
1050
+ console.log(` Configure with: neuralingual render configure ${intent.id.slice(0, 8)} --voice <id> --context ${intent.sessionContext} --duration <min>`);
1051
+ console.log();
1052
+ }
1053
+ // Render status — check via render-status endpoint
1054
+ try {
1055
+ const status = await client.getRenderStatus(resolvedId);
1056
+ console.log(`Render Status: ${status.status}`);
1057
+ if (status.status === 'processing')
1058
+ console.log(` Progress: ${status.progress}%`);
1059
+ if (status.errorMessage)
1060
+ console.log(` Error: ${status.errorMessage}`);
1061
+ }
1062
+ catch (statusErr) {
1063
+ const msg = statusErr instanceof Error ? statusErr.message : String(statusErr);
1064
+ // 404 means no render config/job exists — expected for unconfigured sets
1065
+ if (msg.includes('404') || msg.includes('not found') || msg.includes('Not found')) {
1066
+ console.log('Render Status: none');
1067
+ }
1068
+ else {
1069
+ console.log(`Render Status: unknown (${msg})`);
1070
+ }
1071
+ }
1072
+ // Share status
1073
+ if (intent.shareToken) {
1074
+ console.log(`Shared: yes (https://neuralingual.com/shared/${intent.shareToken})`);
1075
+ }
1076
+ else {
1077
+ console.log('Shared: no');
1078
+ }
1079
+ // Timestamps
1080
+ console.log(`Created: ${new Date(intent.createdAt).toLocaleString()}`);
1081
+ console.log(`Updated: ${new Date(intent.updatedAt).toLocaleString()}`);
1082
+ }
1083
+ catch (err) {
1084
+ console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
1085
+ process.exit(1);
1086
+ }
1087
+ });
1088
+ // ─── rename ─────────────────────────────────────────────────────────────────
1089
+ program
1090
+ .command('rename <intent-id>')
1091
+ .description('Rename a practice set')
1092
+ .option('--title <title>', 'New title')
1093
+ .option('--emoji <emoji>', 'New emoji')
1094
+ .action(async (intentId, opts) => {
1095
+ if (!opts.title && !opts.emoji) {
1096
+ console.error('Error: at least one of --title or --emoji must be provided');
1097
+ process.exit(1);
1098
+ }
1099
+ const client = getUserClient();
1100
+ try {
1101
+ const resolvedId = await resolveIntentId(client, intentId);
1102
+ const input = {};
1103
+ if (opts.title)
1104
+ input.title = opts.title;
1105
+ if (opts.emoji !== undefined)
1106
+ input.emoji = opts.emoji;
1107
+ const { intent } = await client.updateIntent(resolvedId, input);
1108
+ console.log(`Updated: ${intent.emoji ?? ''} ${intent.title}`.trim());
1109
+ }
1110
+ catch (err) {
1111
+ console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
1112
+ process.exit(1);
1113
+ }
1114
+ });
1115
+ // ─── rerender ───────────────────────────────────────────────────────────────
1116
+ program
1117
+ .command('rerender <intent-id>')
1118
+ .description('Re-render a practice set with its current config')
1119
+ .option('--wait', 'Wait for the render to complete')
1120
+ .action(async (intentId, opts) => {
1121
+ const client = getUserClient();
1122
+ try {
1123
+ const resolvedId = await resolveIntentId(client, intentId);
1124
+ // Check if render config exists
1125
+ const { intent } = await client.getIntent(resolvedId);
1126
+ if (!intent) {
1127
+ console.error('Error: practice set not found');
1128
+ process.exit(1);
1129
+ }
1130
+ if (!intent.renderConfigs || intent.renderConfigs.length === 0) {
1131
+ console.error('Error: no render config found. Configure first with:');
1132
+ console.error(` neuralingual render configure ${intent.id.slice(0, 8)} --voice <id> --context ${intent.sessionContext} --duration <min>`);
1133
+ process.exit(1);
1134
+ }
1135
+ console.error(`Re-rendering: ${intent.emoji ?? ''} ${intent.title}`.trim());
1136
+ const result = await client.startRender(resolvedId);
1137
+ if (!opts.wait) {
1138
+ console.log(`Render queued (job: ${result.jobId})`);
1139
+ return;
1140
+ }
1141
+ const { jobId } = result;
1142
+ console.error(`Render queued (job: ${jobId}). Waiting for completion...`);
1143
+ let elapsedMs = 0;
1144
+ const POLL_INITIAL_MS = 3000;
1145
+ const POLL_BACKOFF_AFTER_MS = 30000;
1146
+ const POLL_BACKOFF_MS = 6000;
1147
+ for (;;) {
1148
+ const intervalMs = elapsedMs >= POLL_BACKOFF_AFTER_MS ? POLL_BACKOFF_MS : POLL_INITIAL_MS;
1149
+ await new Promise((resolve) => setTimeout(resolve, intervalMs));
1150
+ elapsedMs += intervalMs;
1151
+ let status;
1152
+ try {
1153
+ status = await client.getRenderStatus(resolvedId);
1154
+ }
1155
+ catch (pollErr) {
1156
+ console.error(`Warning: status poll failed — ${pollErr instanceof Error ? pollErr.message : String(pollErr)}`);
1157
+ continue;
1158
+ }
1159
+ if (status.jobId === undefined) {
1160
+ console.error('Warning: render status has no active job. Exiting --wait.');
1161
+ printResult(status);
1162
+ return;
1163
+ }
1164
+ if (status.jobId !== jobId) {
1165
+ console.error(`Warning: render status is now tracking a different job (${status.jobId}). Exiting --wait.`);
1166
+ printResult(status);
1167
+ return;
1168
+ }
1169
+ console.error(` [${Math.round(elapsedMs / 1000)}s] status=${status.status} progress=${status.progress}%`);
1170
+ if (status.status === 'completed') {
1171
+ console.log('Render complete.');
1172
+ return;
1173
+ }
1174
+ if (status.status === 'failed') {
1175
+ console.error(`Render failed: ${status.errorMessage ?? 'unknown error'}`);
1176
+ process.exit(1);
1177
+ }
1178
+ }
1179
+ }
1180
+ catch (err) {
1181
+ console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
1182
+ process.exit(1);
1183
+ }
1184
+ });
1185
+ // ─── create ─────────────────────────────────────────────────────────────────
1186
+ program
1187
+ .command('create <text>')
1188
+ .description('Create a new practice set from intent text')
1189
+ .option('--tone <tone>', 'Tone preference: grounded, open, or mystical')
1190
+ .action(async (text, opts) => {
1191
+ if (opts.tone && !VALID_TONES.includes(opts.tone)) {
1192
+ console.error(`Error: --tone must be one of: ${VALID_TONES.join(', ')}`);
1193
+ process.exit(1);
1194
+ }
1195
+ const client = getUserClient();
1196
+ console.error('Creating practice set (this may take 10-30 seconds)...');
1197
+ try {
1198
+ const result = await client.createAndGenerate(text, opts.tone);
1199
+ const { intent, affirmationSet } = result;
1200
+ console.log(`\nCreated: ${intent.emoji ?? ''} ${intent.title}`);
1201
+ console.log(`Intent ID: ${intent.id}`);
1202
+ console.log(`Context: ${intent.sessionContext}`);
1203
+ console.log(`\nAffirmations (${affirmationSet.affirmations.length}):`);
1204
+ for (const a of affirmationSet.affirmations) {
1205
+ console.log(` ${a.isEnabled ? '[x]' : '[ ]'} ${a.text}`);
1206
+ }
1207
+ console.log(`\nNext: configure and render with \`neuralingual render configure ${intent.id.slice(0, 8)} --voice <id> --context ${intent.sessionContext} --duration <min>\``);
1208
+ }
1209
+ catch (err) {
1210
+ console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
1211
+ process.exit(1);
1212
+ }
1213
+ });
1214
+ // ─── download ───────────────────────────────────────────────────────────────
1215
+ program
1216
+ .command('download <render-job-id>')
1217
+ .description('Download rendered audio as MP3')
1218
+ .option('-o, --output <path>', 'Output file path (default: nl-<id>.mp3)')
1219
+ .action(async (renderJobId, opts) => {
1220
+ const client = getUserClient();
1221
+ try {
1222
+ const audio = await client.getAudio(renderJobId);
1223
+ const outPath = opts.output ?? `nl-${renderJobId.slice(0, 8)}.mp3`;
1224
+ writeFileSync(outPath, audio);
1225
+ console.log(`Saved: ${outPath} (${(audio.length / 1024).toFixed(0)} KB)`);
1226
+ }
1227
+ catch (err) {
1228
+ console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
1229
+ process.exit(1);
1230
+ }
1231
+ });
1232
+ // ─── credits ────────────────────────────────────────────────────────────────
1233
+ program
1234
+ .command('credits')
1235
+ .description('Show credit balance and recent transactions')
1236
+ .action(async () => {
1237
+ const client = getUserClient();
1238
+ try {
1239
+ const [{ user }, { transactions }] = await Promise.all([
1240
+ client.getMe(),
1241
+ client.getCreditTransactions(10),
1242
+ ]);
1243
+ console.log(`Credit Balance: ${user.creditBalance}`);
1244
+ console.log(` Subscription: ${user.subscriptionCredits}`);
1245
+ console.log(` Purchased: ${user.purchasedCredits}`);
1246
+ if (transactions.length > 0) {
1247
+ console.log(`\nRecent transactions:`);
1248
+ const rows = transactions.map((t) => [
1249
+ new Date(t.createdAt).toLocaleDateString(),
1250
+ t.type,
1251
+ t.amount > 0 ? `+${t.amount}` : String(t.amount),
1252
+ String(t.balanceAfter),
1253
+ ]);
1254
+ printTable(rows, ['Date', 'Type', 'Amount', 'Balance']);
1255
+ }
1256
+ }
1257
+ catch (err) {
1258
+ console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
1259
+ process.exit(1);
1260
+ }
1261
+ });
1262
+ // ─── play ──────────────────────────────────────────────────────────────────
1263
+ program
1264
+ .command('play <intent-id>')
1265
+ .description('Download rendered audio (prints file path). Use --open to launch in default player.')
1266
+ .option('--no-cache', 'Skip cache, always re-download')
1267
+ .option('--open', 'Open in the platform default audio player (non-blocking)')
1268
+ .action(async (intentId, opts) => {
1269
+ const client = getUserClient();
1270
+ try {
1271
+ // Find the render job ID from the library
1272
+ const { items } = await client.getLibrary();
1273
+ const item = items.find((i) => i.intent.id === intentId || i.intent.id.startsWith(intentId));
1274
+ if (!item) {
1275
+ console.error('Error: practice set not found in your library');
1276
+ process.exit(1);
1277
+ }
1278
+ const renderJob = item.latestRenderJob;
1279
+ if (!renderJob?.id) {
1280
+ console.error('Error: no rendered audio available. Run `neuralingual render start` first.');
1281
+ process.exit(1);
1282
+ }
1283
+ if (renderJob.status !== 'completed') {
1284
+ console.error(`Error: render is ${renderJob.status ?? 'not started'}, not ready to play`);
1285
+ process.exit(1);
1286
+ }
1287
+ const title = `${item.intent.emoji ?? ''} ${item.intent.title ?? '(untitled)'}`;
1288
+ // Check cache
1289
+ const cacheFile = join(AUDIO_CACHE_DIR, `${renderJob.id}.mp3`);
1290
+ let cached = false;
1291
+ if (opts.cache && existsSync(cacheFile)) {
1292
+ cached = true;
1293
+ }
1294
+ else {
1295
+ console.log(`Downloading: ${title}...`);
1296
+ const audio = await client.getAudio(renderJob.id);
1297
+ mkdirSync(AUDIO_CACHE_DIR, { recursive: true });
1298
+ writeFileSync(cacheFile, audio);
1299
+ }
1300
+ if (opts.open) {
1301
+ // Open in platform default audio player, non-blocking
1302
+ const opener = process.platform === 'darwin' ? 'open'
1303
+ : process.platform === 'win32' ? 'start' : 'xdg-open';
1304
+ const args = process.platform === 'win32' ? ['""', cacheFile] : [cacheFile];
1305
+ const child = spawn(opener, args, { detached: true, stdio: 'ignore', shell: process.platform === 'win32' });
1306
+ child.on('error', () => {
1307
+ console.error(`Failed to open audio player. File is at: ${cacheFile}`);
1308
+ });
1309
+ child.unref();
1310
+ console.log(`Opened: ${cacheFile}`);
1311
+ }
1312
+ else {
1313
+ // Default: print the file path and return immediately
1314
+ console.log(`${cached ? 'Cached' : 'Downloaded'}: ${cacheFile}`);
1315
+ }
1316
+ }
1317
+ catch (err) {
1318
+ console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
1319
+ process.exit(1);
1320
+ }
1321
+ });
1322
+ // ─── share ─────────────────────────────────────────────────────────────────
1323
+ program
1324
+ .command('share <intent-id>')
1325
+ .description('Generate a share link for a practice set')
1326
+ .action(async (intentId) => {
1327
+ const client = getUserClient();
1328
+ try {
1329
+ const resolvedId = await resolveIntentId(client, intentId);
1330
+ const result = await client.shareIntent(resolvedId);
1331
+ console.log(`Share URL: ${result.shareUrl}`);
1332
+ }
1333
+ catch (err) {
1334
+ console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
1335
+ process.exit(1);
1336
+ }
1337
+ });
1338
+ program
1339
+ .command('unshare <intent-id>')
1340
+ .description('Revoke a share link for a practice set')
1341
+ .action(async (intentId) => {
1342
+ const client = getUserClient();
1343
+ try {
1344
+ const resolvedId = await resolveIntentId(client, intentId);
1345
+ await client.unshareIntent(resolvedId);
1346
+ console.log('Share link revoked.');
1347
+ }
1348
+ catch (err) {
1349
+ console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
1350
+ process.exit(1);
1351
+ }
1352
+ });
1353
+ // ─── delete ────────────────────────────────────────────────────────────────
1354
+ program
1355
+ .command('delete <intent-id>')
1356
+ .description('Delete a practice set from your library')
1357
+ .option('-f, --force', 'Skip confirmation prompt')
1358
+ .action(async (intentId, opts) => {
1359
+ const client = getUserClient();
1360
+ try {
1361
+ const resolvedId = await resolveIntentId(client, intentId);
1362
+ // Look up the title for the confirmation prompt
1363
+ if (!opts.force) {
1364
+ const { items } = await client.getLibrary();
1365
+ const item = items.find((i) => i.intent.id === resolvedId);
1366
+ const name = item ? `'${item.intent.title ?? '(untitled)'}'` : resolvedId;
1367
+ const answer = await prompt(`Delete ${name}? This cannot be undone. (y/N) `);
1368
+ if (answer.toLowerCase() !== 'y') {
1369
+ console.log('Cancelled.');
1370
+ return;
1371
+ }
1372
+ }
1373
+ await client.deleteIntent(resolvedId);
1374
+ console.log('Deleted.');
1375
+ }
1376
+ catch (err) {
1377
+ console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
1378
+ process.exit(1);
1379
+ }
1380
+ });
1381
+ // ─── settings ──────────────────────────────────────────────────────────────
1382
+ const settingsCmd = program.command('settings').description('View and update user settings');
1383
+ settingsCmd
1384
+ .command('show', { isDefault: true })
1385
+ .description('Show current settings')
1386
+ .action(async () => {
1387
+ const client = getUserClient();
1388
+ try {
1389
+ const [{ user }, { settings: ctxSettings }] = await Promise.all([
1390
+ client.getMe(),
1391
+ client.getContextSettings(),
1392
+ ]);
1393
+ console.log('User Settings');
1394
+ console.log(` Tone preference: ${user.tonePreference ?? 'default'}`);
1395
+ console.log(` Display name: ${user.displayName ?? '(not set)'}`);
1396
+ if (ctxSettings.length > 0) {
1397
+ console.log('\nContext Overrides');
1398
+ const rows = ctxSettings.map((s) => [
1399
+ s.sessionContext,
1400
+ s.paceWpm != null ? String(s.paceWpm) : '-',
1401
+ s.pauseMs != null ? String(s.pauseMs) : '-',
1402
+ s.durationMinutes != null ? String(s.durationMinutes) : '-',
1403
+ s.repeatCount != null ? String(s.repeatCount) : '-',
1404
+ s.backgroundVolume != null ? String(s.backgroundVolume) : '-',
1405
+ ]);
1406
+ printTable(rows, ['Context', 'Pace (wpm)', 'Pause (ms)', 'Duration', 'Repeats', 'BG Volume']);
1407
+ }
1408
+ }
1409
+ catch (err) {
1410
+ console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
1411
+ process.exit(1);
1412
+ }
1413
+ });
1414
+ settingsCmd
1415
+ .command('set')
1416
+ .description('Update settings')
1417
+ .option('--tone <tone>', 'Tone preference: grounded, open, or mystical')
1418
+ .option('--name <name>', 'Display name')
1419
+ .action(async (opts) => {
1420
+ if (!opts.tone && !opts.name) {
1421
+ console.error('Error: at least one of --tone or --name must be provided');
1422
+ process.exit(1);
1423
+ }
1424
+ if (opts.tone && !VALID_TONES.includes(opts.tone)) {
1425
+ console.error(`Error: --tone must be one of: ${VALID_TONES.join(', ')}`);
1426
+ process.exit(1);
1427
+ }
1428
+ const client = getUserClient();
1429
+ try {
1430
+ const data = {};
1431
+ if (opts.tone)
1432
+ data.tonePreference = opts.tone;
1433
+ if (opts.name)
1434
+ data.displayName = opts.name;
1435
+ const { user } = await client.updateProfile(data);
1436
+ console.log('Settings updated.');
1437
+ console.log(` Tone preference: ${user.tonePreference ?? 'default'}`);
1438
+ console.log(` Display name: ${user.displayName ?? '(not set)'}`);
1439
+ }
1440
+ catch (err) {
1441
+ console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
1442
+ process.exit(1);
1443
+ }
1444
+ });
1445
+ // ─── library search ────────────────────────────────────────────────────────
1446
+ program
1447
+ .command('search <query>')
1448
+ .description('Search your library by title or context')
1449
+ .action(async (query) => {
1450
+ const client = getUserClient();
1451
+ try {
1452
+ const { items } = await client.getLibrary();
1453
+ const q = query.toLowerCase();
1454
+ const matches = items.filter((item) => (item.intent.title ?? '').toLowerCase().includes(q) ||
1455
+ (item.intent.sessionContext ?? '').toLowerCase().includes(q));
1456
+ if (matches.length === 0) {
1457
+ console.log(`No matches for "${query}".`);
1458
+ return;
1459
+ }
1460
+ const rows = matches.map((item) => [
1461
+ item.intent.id?.slice(0, 8) ?? '?',
1462
+ item.intent.emoji ?? '',
1463
+ item.intent.title ?? '(untitled)',
1464
+ item.intent.sessionContext ?? '-',
1465
+ String(item.latestAffirmationSet?.affirmationCount ?? 0),
1466
+ item.latestRenderJob?.status === 'completed' ? 'yes' : 'no',
1467
+ item.latestRenderJob?.status ?? '-',
1468
+ ]);
1469
+ printTable(rows, ['ID', '', 'Title', 'Context', 'Affirmations', 'Audio', 'Render']);
1470
+ }
1471
+ catch (err) {
1472
+ console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
1473
+ process.exit(1);
1474
+ }
1475
+ });
1476
+ program.parse(process.argv);
1477
+ //# sourceMappingURL=cli.js.map