@feralfile/cli 1.5.0 → 2.0.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.
@@ -10,10 +10,8 @@ exports.getBrowserConfig = getBrowserConfig;
10
10
  exports.getPlaylistConfig = getPlaylistConfig;
11
11
  exports.getFeedConfig = getFeedConfig;
12
12
  exports.getFF1DeviceConfig = getFF1DeviceConfig;
13
- exports.getModelConfig = getModelConfig;
14
13
  exports.validateConfig = validateConfig;
15
14
  exports.createSampleConfig = createSampleConfig;
16
- exports.listAvailableModels = listAvailableModels;
17
15
  const fs_1 = __importDefault(require("fs"));
18
16
  const path_1 = __importDefault(require("path"));
19
17
  const os_1 = __importDefault(require("os"));
@@ -58,62 +56,12 @@ function getConfigPaths() {
58
56
  * Load configuration from config.json or environment variables
59
57
  * Priority: config.json > .env > defaults
60
58
  *
61
- * @returns {Object} Configuration object with model settings
62
- * @returns {string} returns.defaultModel - Name of the default model to use
63
- * @returns {Object} returns.models - Available models configuration
59
+ * @returns {Object} Configuration object
64
60
  * @returns {number} returns.defaultDuration - Default duration per item in seconds
65
61
  */
66
62
  function loadConfig() {
67
63
  const { localPath, userPath } = getConfigPaths();
68
- // Default configuration supporting Grok as default
69
64
  const defaultConfig = {
70
- defaultModel: process.env.DEFAULT_MODEL || 'claude',
71
- models: {
72
- claude: {
73
- apiKey: process.env.ANTHROPIC_API_KEY || '',
74
- baseURL: 'https://api.anthropic.com/v1/',
75
- model: 'claude-sonnet-4-6',
76
- availableModels: ['claude-opus-4-7', 'claude-sonnet-4-6', 'claude-haiku-4-5-20251001'],
77
- timeout: 30000,
78
- maxRetries: 3,
79
- temperature: 0.3,
80
- maxTokens: 4000,
81
- supportsFunctionCalling: true,
82
- },
83
- grok: {
84
- apiKey: process.env.GROK_API_KEY || '',
85
- baseURL: process.env.GROK_API_BASE_URL || 'https://api.x.ai/v1',
86
- model: process.env.GROK_MODEL || 'grok-beta',
87
- availableModels: ['grok-beta', 'grok-2-1212', 'grok-2-vision-1212'],
88
- timeout: parseInt(process.env.TIMEOUT || '30000', 10),
89
- maxRetries: parseInt(process.env.MAX_RETRIES || '3', 10),
90
- temperature: parseFloat(process.env.TEMPERATURE || '0.3'),
91
- maxTokens: parseInt(process.env.MAX_TOKENS || '4000', 10),
92
- supportsFunctionCalling: true,
93
- },
94
- gpt: {
95
- apiKey: process.env.OPENAI_API_KEY || '',
96
- baseURL: 'https://api.openai.com/v1',
97
- model: 'gpt-4.1',
98
- availableModels: ['gpt-4.1', 'gpt-4.1-mini', 'gpt-4o', 'gpt-4o-mini', 'gpt-4-turbo'],
99
- timeout: 30000,
100
- maxRetries: 3,
101
- temperature: 0.3,
102
- maxTokens: 4000,
103
- supportsFunctionCalling: true,
104
- },
105
- gemini: {
106
- apiKey: process.env.GEMINI_API_KEY || '',
107
- baseURL: 'https://generativelanguage.googleapis.com/v1beta/openai/',
108
- model: 'gemini-2.5-flash',
109
- availableModels: ['gemini-2.5-flash-lite', 'gemini-2.5-flash', 'gemini-flash-lite-latest'],
110
- timeout: 30000,
111
- maxRetries: 3,
112
- temperature: 0.3,
113
- maxTokens: 4000,
114
- supportsFunctionCalling: true,
115
- },
116
- },
117
65
  defaultDuration: parseInt(process.env.DEFAULT_DURATION || '10', 10),
118
66
  browser: {
119
67
  timeout: parseInt(process.env.BROWSER_TIMEOUT || '90000', 10),
@@ -130,21 +78,10 @@ function loadConfig() {
130
78
  if (fs_1.default.existsSync(configPath)) {
131
79
  try {
132
80
  const fileConfig = JSON.parse(fs_1.default.readFileSync(configPath, 'utf-8'));
133
- // Deep merge models configuration
134
- const mergedModels = { ...defaultConfig.models };
135
- if (fileConfig.models) {
136
- Object.keys(fileConfig.models).forEach((modelName) => {
137
- mergedModels[modelName] = {
138
- ...(defaultConfig.models[modelName] || {}),
139
- ...fileConfig.models[modelName],
140
- };
141
- });
142
- }
143
81
  // Merge with defaults, file config takes precedence
144
82
  return {
145
83
  ...defaultConfig,
146
84
  ...fileConfig,
147
- models: mergedModels,
148
85
  };
149
86
  }
150
87
  catch (error) {
@@ -290,67 +227,16 @@ function getFF1DeviceConfig() {
290
227
  };
291
228
  }
292
229
  /**
293
- * Get configuration for a specific model
294
- *
295
- * @param {string} [modelName] - Name of the model (defaults to defaultModel from config)
296
- * @returns {Object} Model configuration
297
- * @returns {string} returns.apiKey - API key for the model
298
- * @returns {string} returns.baseURL - Base URL for the API
299
- * @returns {string} returns.model - Model name/identifier
300
- * @returns {number} returns.timeout - Request timeout in milliseconds
301
- * @returns {number} returns.maxRetries - Maximum number of retries
302
- * @returns {number} returns.temperature - Temperature for generation
303
- * @returns {number} returns.maxTokens - Maximum tokens for generation
304
- * @returns {boolean} returns.supportsFunctionCalling - Whether model supports function calling
305
- * @throws {Error} If model is not configured or doesn't support function calling
306
- */
307
- function getModelConfig(modelName) {
308
- const config = getConfig();
309
- const selectedModel = modelName || config.defaultModel;
310
- if (!config.models[selectedModel]) {
311
- throw new Error(`Model "${selectedModel}" is not configured. Available models: ${Object.keys(config.models).join(', ')}`);
312
- }
313
- const modelConfig = config.models[selectedModel];
314
- if (!modelConfig.supportsFunctionCalling) {
315
- throw new Error(`Model "${selectedModel}" does not support function calling`);
316
- }
317
- const normalizedBaseURL = modelConfig.baseURL?.replace(/\/+$/, '');
318
- return {
319
- ...modelConfig,
320
- baseURL: normalizedBaseURL,
321
- defaultDuration: config.defaultDuration,
322
- };
323
- }
324
- /**
325
- * Validate configuration for a specific model
230
+ * Validate configuration
326
231
  *
327
- * @param {string} [modelName] - Name of the model to validate
328
232
  * @returns {Object} Validation result
329
233
  * @returns {boolean} returns.valid - Whether the configuration is valid
330
234
  * @returns {Array<string>} returns.errors - List of validation errors
331
235
  */
332
- function validateConfig(modelName) {
236
+ function validateConfig() {
333
237
  const errors = [];
334
238
  try {
335
239
  const config = getConfig();
336
- const selectedModel = modelName || config.defaultModel;
337
- if (!config.models[selectedModel]) {
338
- errors.push(`Model "${selectedModel}" is not configured. Available: ${Object.keys(config.models).join(', ')}`);
339
- return { valid: false, errors };
340
- }
341
- const modelConfig = config.models[selectedModel];
342
- if ((0, config_placeholders_1.isMissingConfigValue)(modelConfig.apiKey)) {
343
- errors.push(`API key for "${selectedModel}" is missing or not configured`);
344
- }
345
- if (!modelConfig.baseURL) {
346
- errors.push(`Base URL for "${selectedModel}" is missing`);
347
- }
348
- if (!modelConfig.model) {
349
- errors.push(`Model identifier for "${selectedModel}" is not set`);
350
- }
351
- if (!modelConfig.supportsFunctionCalling) {
352
- errors.push(`Model "${selectedModel}" does not support function calling (required)`);
353
- }
354
240
  // Validate browser configuration
355
241
  if (config.browser) {
356
242
  if (config.browser.timeout && typeof config.browser.timeout !== 'number') {
@@ -431,12 +317,3 @@ async function createSampleConfig(targetPath) {
431
317
  fs_1.default.writeFileSync(configPath, exampleConfig, 'utf-8');
432
318
  return configPath;
433
319
  }
434
- /**
435
- * List all available models
436
- *
437
- * @returns {Array<string>} List of available model names
438
- */
439
- function listAvailableModels() {
440
- const config = getConfig();
441
- return Object.keys(config.models);
442
- }
package/dist/src/main.js CHANGED
@@ -1,54 +1,12 @@
1
1
  "use strict";
2
2
  /**
3
3
  * Main Flow Controller
4
- * Handles both deterministic and AI-driven playlist creation
4
+ * Builds DP-1 playlists deterministically from structured parameters.
5
5
  */
6
- var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
7
- if (k2 === undefined) k2 = k;
8
- var desc = Object.getOwnPropertyDescriptor(m, k);
9
- if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
10
- desc = { enumerable: true, get: function() { return m[k]; } };
11
- }
12
- Object.defineProperty(o, k2, desc);
13
- }) : (function(o, m, k, k2) {
14
- if (k2 === undefined) k2 = k;
15
- o[k2] = m[k];
16
- }));
17
- var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
18
- Object.defineProperty(o, "default", { enumerable: true, value: v });
19
- }) : function(o, v) {
20
- o["default"] = v;
21
- });
22
- var __importStar = (this && this.__importStar) || (function () {
23
- var ownKeys = function(o) {
24
- ownKeys = Object.getOwnPropertyNames || function (o) {
25
- var ar = [];
26
- for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
27
- return ar;
28
- };
29
- return ownKeys(o);
30
- };
31
- return function (mod) {
32
- if (mod && mod.__esModule) return mod;
33
- var result = {};
34
- if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
35
- __setModuleDefault(result, mod);
36
- return result;
37
- };
38
- })();
39
- var __importDefault = (this && this.__importDefault) || function (mod) {
40
- return (mod && mod.__esModule) ? mod : { "default": mod };
41
- };
42
6
  Object.defineProperty(exports, "__esModule", { value: true });
43
- exports.SEND_SHORTCUT_PATTERN = void 0;
44
- exports.resolveSendShortcutDevice = resolveSendShortcutDevice;
45
- exports.resolveEffectiveDeviceName = resolveEffectiveDeviceName;
46
- exports.resolveSendPlaylistDeviceName = resolveSendPlaylistDeviceName;
47
- exports.sendPreparedPlaylistToDevice = sendPreparedPlaylistToDevice;
48
7
  exports.validateRequirements = validateRequirements;
49
8
  exports.applyPlaylistDefaults = applyPlaylistDefaults;
50
9
  exports.buildPlaylistDirect = buildPlaylistDirect;
51
- exports.buildPlaylist = buildPlaylist;
52
10
  // Suppress Ed25519 experimental warning immediately
53
11
  const originalEmitWarning = process.emitWarning;
54
12
  process.emitWarning = function (warning, type, ctor) {
@@ -60,83 +18,10 @@ process.emitWarning = function (warning, type, ctor) {
60
18
  }
61
19
  return originalEmitWarning.apply(this, [warning, type, ctor]);
62
20
  };
63
- const chalk_1 = __importDefault(require("chalk"));
64
- const readline = __importStar(require("readline"));
65
21
  const config_1 = require("./config");
66
- const logger = __importStar(require("./logger"));
67
- // Lazy load utilities and orchestrator to avoid circular dependencies
22
+ // Lazy load utilities to avoid circular dependencies
68
23
  // eslint-disable-next-line @typescript-eslint/no-require-imports
69
24
  const getUtilities = () => require('./utilities');
70
- // eslint-disable-next-line @typescript-eslint/no-require-imports
71
- const getIntentParser = () => require('./intent-parser');
72
- // eslint-disable-next-line @typescript-eslint/no-require-imports
73
- const getAIOrchestrator = () => require('./ai-orchestrator');
74
- /**
75
- * Regex that matches the inline "send last / send playlist / send to <device>"
76
- * shortcut inside the chat flow. Exported for testing.
77
- */
78
- exports.SEND_SHORTCUT_PATTERN = /^send(?:\s+(?:last|playlist|the playlist))?(?:\s+to\s+(.+))?$/i;
79
- /**
80
- * Extract the target device from a sendMatch result, falling back to the CLI
81
- * --device flag. Exported for testing.
82
- */
83
- function resolveSendShortcutDevice(match, cliDefault) {
84
- return match[1]?.trim() || cliDefault;
85
- }
86
- /**
87
- * Resolve the effective device name for a send operation.
88
- * The intent (from NL parsing) takes precedence; CLI flag is the fallback.
89
- * Exported for testing.
90
- */
91
- function resolveEffectiveDeviceName(fromIntent, fromCLI) {
92
- return fromIntent || fromCLI;
93
- }
94
- /**
95
- * Sanitize and resolve the device name for the direct send_playlist action path.
96
- *
97
- * The intent parser can emit literal "null" or "" as a device name string; these
98
- * must be treated as absent so the CLI --device fallback is used instead.
99
- * Exported for testing — mirrors the sanitization in confirmPlaylistForSending().
100
- */
101
- function resolveSendPlaylistDeviceName(intentDeviceName, cliDeviceName) {
102
- const sanitized = intentDeviceName === 'null' ||
103
- intentDeviceName === '' ||
104
- intentDeviceName === null ||
105
- intentDeviceName === undefined
106
- ? undefined
107
- : intentDeviceName;
108
- return resolveEffectiveDeviceName(sanitized, cliDeviceName);
109
- }
110
- /**
111
- * Verify a playlist for device delivery and send it using the supplied transport.
112
- *
113
- * This helper keeps the send branches in `buildPlaylist` testable without
114
- * duplicating the verification and transport wiring in tests.
115
- */
116
- async function sendPreparedPlaylistToDevice(playlist, deps) {
117
- const verificationResult = await deps.verifyPlaylist(playlist);
118
- if (!verificationResult.valid) {
119
- return {
120
- success: false,
121
- error: verificationResult.error || 'Failed to verify playlist before sending',
122
- playlist: null,
123
- };
124
- }
125
- const sendResult = await deps.sendToDevice(playlist, deps.resolveDeviceName());
126
- if (sendResult.success) {
127
- return {
128
- success: true,
129
- playlist,
130
- deviceName: sendResult.deviceName,
131
- device: sendResult.device,
132
- };
133
- }
134
- return {
135
- success: false,
136
- error: sendResult.error || 'Failed to send playlist',
137
- playlist: null,
138
- };
139
- }
140
25
  /**
141
26
  * Validate and apply constraints to requirements
142
27
  *
@@ -242,7 +127,6 @@ function applyPlaylistDefaults(settings = {}) {
242
127
  }
243
128
  /**
244
129
  * Build playlist deterministically from structured parameters
245
- * Path 1: Direct execution (no AI)
246
130
  *
247
131
  * @param {Object} params - Playlist parameters
248
132
  * @param {Array<Object>} params.requirements - Array of requirements
@@ -261,246 +145,3 @@ async function buildPlaylistDirect(params, options = {}) {
261
145
  utilities.initializeUtilities(config);
262
146
  return await utilities.buildPlaylistDirect({ requirements, playlistSettings }, options);
263
147
  }
264
- /**
265
- * Build playlist from natural language request
266
- * Path 2: AI-driven execution (intent parser → AI orchestrator → utilities)
267
- *
268
- * @param {string} userRequest - Natural language request
269
- * @param {Object} options - Options
270
- * @param {boolean} [options.verbose=false] - Verbose output
271
- * @param {string} [options.outputPath='playlist.json'] - Output path
272
- * @param {string} [options.modelName] - AI model to use
273
- * @param {boolean} [options.interactive=true] - Interactive mode (allow clarification prompts)
274
- * @returns {Promise<Object>} Result with playlist
275
- */
276
- async function buildPlaylist(userRequest, options = {}) {
277
- const { verbose = false, outputPath = 'playlist.json', modelName, interactive = true, deviceName: defaultDeviceName, } = options;
278
- // Enable verbose logging if requested
279
- if (verbose) {
280
- logger.setVerbose(true);
281
- }
282
- // Initialize utilities with config (indexer endpoint, API key, etc.)
283
- const utilities = getUtilities();
284
- const config = (0, config_1.getConfig)();
285
- utilities.initializeUtilities(config);
286
- try {
287
- const trimmedRequest = userRequest.trim();
288
- const sendMatch = trimmedRequest.match(/^send(?:\s+(?:last|playlist|the playlist))?(?:\s+to\s+(.+))?$/i);
289
- if (sendMatch) {
290
- const deviceName = sendMatch[1]?.trim() || defaultDeviceName;
291
- const { confirmPlaylistForSending } = await Promise.resolve().then(() => __importStar(require('./utilities/playlist-send')));
292
- const { verifyPlaylist } = await Promise.resolve().then(() => __importStar(require('./utilities/playlist-verifier')));
293
- const confirmation = await confirmPlaylistForSending(outputPath, deviceName);
294
- if (!confirmation.success) {
295
- if (confirmation.message) {
296
- console.log(chalk_1.default.red(`\n${confirmation.message}`));
297
- }
298
- return {
299
- success: false,
300
- error: confirmation.error || 'Failed to prepare playlist for sending',
301
- action: 'send_playlist',
302
- playlist: null,
303
- };
304
- }
305
- const sendResult = await sendPreparedPlaylistToDevice(confirmation.playlist, {
306
- verifyPlaylist,
307
- sendToDevice: utilities.sendToDevice,
308
- resolveDeviceName: () => confirmation.deviceName,
309
- });
310
- if (sendResult.success) {
311
- console.log(chalk_1.default.green('\nPlaylist sent'));
312
- if (sendResult.deviceName) {
313
- console.log(chalk_1.default.dim(` Device: ${sendResult.deviceName}`));
314
- }
315
- console.log();
316
- return {
317
- success: true,
318
- playlist: sendResult.playlist,
319
- action: 'send_playlist',
320
- };
321
- }
322
- console.log();
323
- console.error(chalk_1.default.red('Send failed'));
324
- if (sendResult.error) {
325
- console.error(chalk_1.default.red(` ${sendResult.error}`));
326
- }
327
- return {
328
- success: false,
329
- error: sendResult.error || 'Failed to send playlist',
330
- playlist: null,
331
- action: 'send_playlist',
332
- };
333
- }
334
- // STEP 1: INTENT PARSER
335
- // Parse user intent into structured requirements
336
- const { processIntentParserRequest } = getIntentParser();
337
- let intentParserResult = await processIntentParserRequest(userRequest, {
338
- modelName,
339
- defaultDeviceName,
340
- });
341
- // Handle interactive clarification loop
342
- while (intentParserResult.needsMoreInfo) {
343
- if (!interactive) {
344
- // Non-interactive mode: cannot ask for clarification
345
- console.error(chalk_1.default.red('\nNeed more information, but running in non-interactive mode. Provide a complete request.'));
346
- if (intentParserResult.question) {
347
- console.error(chalk_1.default.yellow('\nQuestion: ') + intentParserResult.question);
348
- }
349
- process.exit(1);
350
- }
351
- // Ask user for clarification
352
- const rl = readline.createInterface({
353
- input: process.stdin,
354
- output: process.stdout,
355
- });
356
- // Display the AI's question before asking for input
357
- if (intentParserResult.question) {
358
- console.log(chalk_1.default.cyan('\n') + intentParserResult.question);
359
- }
360
- const userResponse = await new Promise((resolve) => {
361
- rl.question(chalk_1.default.yellow('Your response: '), (answer) => {
362
- rl.close();
363
- resolve(answer.trim());
364
- });
365
- });
366
- if (!userResponse) {
367
- console.error(chalk_1.default.red('\nNo response provided. Exiting.'));
368
- process.exit(1);
369
- }
370
- console.log();
371
- // Continue intent parser conversation
372
- intentParserResult = await processIntentParserRequest(userResponse, {
373
- modelName,
374
- defaultDeviceName,
375
- conversationContext: {
376
- messages: intentParserResult.messages,
377
- },
378
- });
379
- }
380
- if (!intentParserResult.approved) {
381
- console.error(chalk_1.default.red('\nRequest not approved by intent parser'));
382
- return null;
383
- }
384
- const params = intentParserResult.params;
385
- // NOTE: do NOT merge defaultDeviceName into playlistSettings here.
386
- // buildPlaylistDirect (src/utilities/index.js) sends whenever
387
- // playlistSettings.deviceName is defined, so setting it here would turn
388
- // every build-only `chat --device` invocation into an implicit network send.
389
- // The CLI --device flag is used only in the two explicit send paths below
390
- // (sendMatch shortcut and send_playlist action) via resolveEffectiveDeviceName.
391
- // Check if this is a send_playlist action
392
- if (params && params.action === 'send_playlist') {
393
- // Handle playlist sending directly
394
- const sendParams = params;
395
- const utilities = getUtilities();
396
- const { verifyPlaylist } = await Promise.resolve().then(() => __importStar(require('./utilities/playlist-verifier')));
397
- console.log();
398
- console.log(chalk_1.default.cyan('Sending to device'));
399
- const sendResult = await sendPreparedPlaylistToDevice(sendParams.playlist, {
400
- verifyPlaylist,
401
- sendToDevice: utilities.sendToDevice,
402
- resolveDeviceName: () => resolveSendPlaylistDeviceName(sendParams.deviceName, defaultDeviceName),
403
- });
404
- if (!sendResult.success) {
405
- console.log();
406
- console.error(chalk_1.default.red('Send failed'));
407
- if (sendResult.error) {
408
- console.error(chalk_1.default.red(` ${sendResult.error}`));
409
- }
410
- return {
411
- success: false,
412
- error: sendResult.error || 'Failed to send playlist',
413
- playlist: null,
414
- action: 'send_playlist',
415
- };
416
- }
417
- console.log(chalk_1.default.green('\nPlaylist sent'));
418
- if (sendResult.deviceName) {
419
- console.log(chalk_1.default.dim(` Device: ${sendResult.deviceName}`));
420
- }
421
- console.log();
422
- return {
423
- success: true,
424
- playlist: sendResult.playlist,
425
- action: 'send_playlist',
426
- };
427
- }
428
- // Check if this is a publish_playlist action
429
- if (params && params.action === 'publish_playlist') {
430
- // Publishing was already handled by intent parser, just return the result
431
- const publishParams = params;
432
- if (publishParams.success) {
433
- return {
434
- success: true,
435
- action: 'publish_playlist',
436
- playlistId: publishParams.playlistId,
437
- feedServer: publishParams.feedServer,
438
- };
439
- }
440
- else {
441
- return {
442
- success: false,
443
- error: publishParams.error,
444
- action: 'publish_playlist',
445
- };
446
- }
447
- }
448
- // STEP 2: AI ORCHESTRATOR (Function Calling)
449
- // AI orchestrates function calls to build playlist
450
- const { buildPlaylistWithAI } = getAIOrchestrator();
451
- let result = await buildPlaylistWithAI(params, {
452
- modelName,
453
- verbose,
454
- outputPath,
455
- interactive,
456
- defaultDeviceName,
457
- });
458
- // Handle confirmation loop in interactive mode
459
- while (result.needsConfirmation && interactive) {
460
- console.log(chalk_1.default.yellow('\n' + result.question));
461
- console.log();
462
- const rl = readline.createInterface({
463
- input: process.stdin,
464
- output: process.stdout,
465
- });
466
- const userResponse = await new Promise((resolve) => {
467
- rl.question(chalk_1.default.cyan('Your response: '), (answer) => {
468
- rl.close();
469
- resolve(answer.trim());
470
- });
471
- });
472
- if (!userResponse) {
473
- console.error(chalk_1.default.red('\nNo response provided. Canceling.'));
474
- return null;
475
- }
476
- console.log();
477
- // Continue orchestrator with user's response
478
- result = await buildPlaylistWithAI(result.params, {
479
- modelName,
480
- verbose,
481
- outputPath,
482
- interactive,
483
- defaultDeviceName,
484
- conversationContext: {
485
- messages: result.messages,
486
- userResponse,
487
- },
488
- });
489
- }
490
- // If no playlist was built, display the AI's message
491
- if (!result.playlist && result.message) {
492
- console.log(chalk_1.default.yellow('\n' + result.message));
493
- }
494
- else if (!result.playlist && result.error) {
495
- console.error(chalk_1.default.red('\n' + result.error));
496
- }
497
- return result;
498
- }
499
- catch (error) {
500
- console.error(chalk_1.default.red('\nError:'), error.message);
501
- if (verbose) {
502
- console.error(chalk_1.default.dim(error.stack));
503
- }
504
- throw error;
505
- }
506
- }
@@ -38,11 +38,16 @@ var __importStar = (this && this.__importStar) || (function () {
38
38
  })();
39
39
  Object.defineProperty(exports, "__esModule", { value: true });
40
40
  exports.isTransientDeviceNetworkError = isTransientDeviceNetworkError;
41
+ exports.parseRetryAfterMs = parseRetryAfterMs;
41
42
  exports.sendPlaylistToDevice = sendPlaylistToDevice;
42
43
  const logger = __importStar(require("../logger"));
43
44
  const ff1_compatibility_1 = require("./ff1-compatibility");
44
45
  const SEND_RETRY_ATTEMPTS = 3;
45
46
  const SEND_RETRY_BASE_DELAY_MS = 750;
47
+ const RATE_LIMIT_STATUS = 429;
48
+ // Cap how long a device-supplied Retry-After can stall the CLI, so a buggy or
49
+ // hostile device cannot pin a command for minutes/hours.
50
+ const MAX_RETRY_DELAY_MS = 30_000;
46
51
  /**
47
52
  * Sleep for a short duration before retrying transient network errors.
48
53
  *
@@ -96,6 +101,36 @@ function isTransientDeviceNetworkError(error) {
96
101
  : undefined;
97
102
  return Boolean(causeCode && networkCodes.has(causeCode));
98
103
  }
104
+ /**
105
+ * parseRetryAfterMs converts an HTTP `Retry-After` header into milliseconds.
106
+ *
107
+ * Supports both forms from RFC 7231: a delta-seconds integer (e.g. "5") and an
108
+ * HTTP-date (e.g. "Wed, 21 Oct 2025 07:28:00 GMT"). Returns undefined when the
109
+ * header is absent or unparseable, so callers can fall back to their own
110
+ * backoff. A past date clamps to 0 (retry immediately).
111
+ *
112
+ * @param {string | null | undefined} headerValue - Raw Retry-After header value
113
+ * @returns {number | undefined} Delay in milliseconds, or undefined if unknown
114
+ */
115
+ function parseRetryAfterMs(headerValue) {
116
+ if (!headerValue) {
117
+ return undefined;
118
+ }
119
+ const trimmed = headerValue.trim();
120
+ if (/^\d+$/.test(trimmed)) {
121
+ return Number(trimmed) * 1000;
122
+ }
123
+ // A valid HTTP-date always contains letters (month/day names, or the ISO
124
+ // 'T'/'Z'). Requiring one rejects bare numeric junk (e.g. "-5", "5.5") that
125
+ // Date.parse would otherwise misinterpret as a date.
126
+ if (/[a-zA-Z]/.test(trimmed)) {
127
+ const dateMs = Date.parse(trimmed);
128
+ if (!Number.isNaN(dateMs)) {
129
+ return Math.max(0, dateMs - Date.now());
130
+ }
131
+ }
132
+ return undefined;
133
+ }
99
134
  /**
100
135
  * Send a DP1 playlist to an FF1 device using the cast API
101
136
  *
@@ -185,6 +220,17 @@ async function sendPlaylistToDevice({ playlist, deviceName, }) {
185
220
  headers,
186
221
  body: JSON.stringify(requestBody),
187
222
  });
223
+ // The device sheds bursts of commands with HTTP 429 to protect itself.
224
+ // Treat it as transient: honor Retry-After when present, otherwise back
225
+ // off, and retry within the bounded attempt budget.
226
+ if (response.status === RATE_LIMIT_STATUS && attempt < SEND_RETRY_ATTEMPTS) {
227
+ const retryAfterMs = parseRetryAfterMs(response.headers.get('retry-after'));
228
+ const retryDelay = Math.min(retryAfterMs ?? SEND_RETRY_BASE_DELAY_MS * attempt, MAX_RETRY_DELAY_MS);
229
+ logger.warn(`Device rate-limited the command (attempt ${attempt}/${SEND_RETRY_ATTEMPTS}); retrying in ${retryDelay}ms`);
230
+ response = null;
231
+ await waitForRetry(retryDelay);
232
+ continue;
233
+ }
188
234
  break;
189
235
  }
190
236
  catch (error) {
@@ -212,6 +258,15 @@ async function sendPlaylistToDevice({ playlist, deviceName, }) {
212
258
  const errorText = await response.text();
213
259
  logger.error(`Failed to cast to device: ${response.status} ${response.statusText}`);
214
260
  logger.debug(`Error details: ${errorText}`);
261
+ if (response.status === RATE_LIMIT_STATUS) {
262
+ const deviceLabel = device.name || device.host;
263
+ return {
264
+ success: false,
265
+ error: `Device "${deviceLabel}" is busy and rate-limited the command`,
266
+ details: 'The Art Computer is shedding a burst of commands to protect itself. ' +
267
+ 'Wait a few seconds and try again.',
268
+ };
269
+ }
215
270
  return {
216
271
  success: false,
217
272
  error: `Device returned error ${response.status}: ${response.statusText}`,