@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/LICENSE +21 -0
- package/README.md +75 -0
- package/dist/auth-store.d.ts +15 -0
- package/dist/auth-store.d.ts.map +1 -0
- package/dist/auth-store.js +33 -0
- package/dist/auth-store.js.map +1 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +1477 -0
- package/dist/cli.js.map +1 -0
- package/dist/set-file.d.ts +72 -0
- package/dist/set-file.d.ts.map +1 -0
- package/dist/set-file.js +95 -0
- package/dist/set-file.js.map +1 -0
- package/dist/types.d.ts +121 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +5 -0
- package/dist/types.js.map +1 -0
- package/dist/user-client.d.ts +254 -0
- package/dist/user-client.d.ts.map +1 -0
- package/dist/user-client.js +249 -0
- package/dist/user-client.js.map +1 -0
- package/package.json +41 -0
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
|