ff1-cli 1.0.1 → 1.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -10,6 +10,14 @@ FF1-CLI turns a simple prompt into a DP-1–conformant playlist you can preview
10
10
  npm i -g ff1-cli
11
11
  ```
12
12
 
13
+ ## Install (curl)
14
+
15
+ ```bash
16
+ curl -fsSL https://feralfile.com/ff1-cli-install | bash
17
+ ```
18
+
19
+ Installs a prebuilt binary for macOS/Linux (no Node.js required).
20
+
13
21
  ## One-off Usage (npx)
14
22
 
15
23
  ```bash
@@ -34,15 +42,17 @@ ff1 play "https://example.com/video.mp4" -d "Living Room Display" --skip-verify
34
42
  ```bash
35
43
  npm install
36
44
  npm run dev -- config init
37
- npm run dev chat
45
+ npm run dev -- chat
38
46
  npm run dev -- play "https://example.com/video.mp4" -d "Living Room Display" --skip-verify
39
47
  ```
40
48
 
41
49
  ## Documentation
42
50
 
43
- - Getting started, config, and usage: `./docs/README.md`
51
+ - Getting started and usage: `./docs/README.md`
52
+ - Configuration: `./docs/CONFIGURATION.md`
44
53
  - Function calling architecture: `./docs/FUNCTION_CALLING.md`
45
54
  - Examples: `./docs/EXAMPLES.md`
55
+ - SSH access: `ff1 ssh enable|disable` in `./docs/README.md`
46
56
 
47
57
  ## Scripts
48
58
 
@@ -1,14 +1,14 @@
1
1
  {
2
- "defaultModel": "gemini",
2
+ "defaultModel": "grok",
3
3
  "models": {
4
4
  "grok": {
5
5
  "apiKey": "YOUR_GROK_API_KEY_FROM_X_AI_CONSOLE",
6
6
  "baseURL": "https://api.x.ai/v1",
7
- "model": "grok-4-fast-non-reasoning",
7
+ "model": "grok-beta",
8
8
  "availableModels": [
9
- "grok-4-fast-reasoning",
10
- "grok-4-fast-non-reasoning",
11
- "grok-code-fast-1"
9
+ "grok-beta",
10
+ "grok-2-1212",
11
+ "grok-2-vision-1212"
12
12
  ],
13
13
  "timeout": 120000,
14
14
  "maxRetries": 3,
@@ -16,11 +16,13 @@
16
16
  "maxTokens": 4000,
17
17
  "supportsFunctionCalling": true
18
18
  },
19
- "gpt": {
19
+ "chatgpt": {
20
20
  "apiKey": "YOUR_OPENAI_API_KEY_FROM_PLATFORM_OPENAI_COM",
21
21
  "baseURL": "https://api.openai.com/v1",
22
- "model": "gpt-4o-mini",
22
+ "model": "gpt-4.1",
23
23
  "availableModels": [
24
+ "gpt-4.1",
25
+ "gpt-4.1-mini",
24
26
  "gpt-4o",
25
27
  "gpt-4o-mini",
26
28
  "gpt-4-turbo"
@@ -54,7 +56,7 @@
54
56
  },
55
57
  "feedServers": [
56
58
  {
57
- "baseUrl": "https://feed.feralfile.com/api/v1",
59
+ "baseUrl": "https://dp1-feed-operator-api-prod.autonomy-system.workers.dev/api/v1",
58
60
  "apiKey": "YOUR_FEED_SERVER_API_KEY_OPTIONAL"
59
61
  },
60
62
  {
@@ -63,7 +65,7 @@
63
65
  }
64
66
  ],
65
67
  "playlist": {
66
- "privateKey": "YOUR_ED25519_PRIVATE_KEY_BASE64_ENCODED_RUN_NODE_INDEX_JS_GENERATE_KEYS"
68
+ "privateKey": "YOUR_ED25519_PRIVATE_KEY_BASE64_OR_HEX"
67
69
  },
68
70
  "ff1Devices": {
69
71
  "devices": [
package/dist/index.js CHANGED
@@ -37,8 +37,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
37
37
  return (mod && mod.__esModule) ? mod : { "default": mod };
38
38
  };
39
39
  Object.defineProperty(exports, "__esModule", { value: true });
40
- // Suppress punycode deprecation warning from jsdom dependency
41
- process.removeAllListeners('warning');
40
+ // Suppress punycode deprecation warnings from dependencies
42
41
  process.on('warning', (warning) => {
43
42
  if (warning.name === 'DeprecationWarning' && warning.message.includes('punycode')) {
44
43
  return; // Ignore punycode deprecation warnings from dependencies
@@ -51,8 +50,24 @@ const chalk_1 = __importDefault(require("chalk"));
51
50
  const fs_1 = require("fs");
52
51
  const crypto_1 = __importDefault(require("crypto"));
53
52
  const readline = __importStar(require("readline"));
53
+ const fs_2 = require("fs");
54
+ const path_1 = require("path");
54
55
  const config_1 = require("./src/config");
55
56
  const main_1 = require("./src/main");
57
+ const ff1_discovery_1 = require("./src/utilities/ff1-discovery");
58
+ const playlist_source_1 = require("./src/utilities/playlist-source");
59
+ // Load version from package.json
60
+ // Try built location first (dist/index.js -> ../package.json)
61
+ // Fall back to dev location (index.ts -> ./package.json)
62
+ let packageJsonPath = (0, path_1.resolve)((0, path_1.dirname)(__filename), '..', 'package.json');
63
+ try {
64
+ (0, fs_2.readFileSync)(packageJsonPath, 'utf8');
65
+ }
66
+ catch {
67
+ // Dev mode: tsx runs from project root
68
+ packageJsonPath = (0, path_1.resolve)((0, path_1.dirname)(__filename), 'package.json');
69
+ }
70
+ const { version: packageVersion } = JSON.parse((0, fs_2.readFileSync)(packageJsonPath, 'utf8'));
56
71
  const program = new commander_1.Command();
57
72
  const placeholderPattern = /YOUR_|your_/;
58
73
  /**
@@ -62,7 +77,7 @@ const placeholderPattern = /YOUR_|your_/;
62
77
  * @param {string} outputPath - Path where the playlist was saved
63
78
  */
64
79
  function displayPlaylistSummary(playlist, outputPath) {
65
- console.log(chalk_1.default.green('\nPlaylist created'));
80
+ console.log(chalk_1.default.green('\nPlaylist saved'));
66
81
  console.log(chalk_1.default.dim(` Output: ./${outputPath}`));
67
82
  console.log(chalk_1.default.dim(' Next: send last | publish playlist'));
68
83
  console.log();
@@ -102,8 +117,49 @@ async function ensureConfigFile() {
102
117
  const createdPath = await (0, config_1.createSampleConfig)(userPath);
103
118
  return { path: createdPath, created: true };
104
119
  }
120
+ /**
121
+ * Parse TTL duration string into seconds.
122
+ *
123
+ * @param {string} ttl - Duration string (e.g. "900", "15m", "2h")
124
+ * @returns {number} TTL in seconds
125
+ * @throws {Error} When ttl format is invalid
126
+ */
127
+ function parseTtlSeconds(ttl) {
128
+ const trimmed = ttl.trim();
129
+ const match = trimmed.match(/^(\d+)([smh]?)$/i);
130
+ if (!match) {
131
+ throw new Error('TTL must be a number of seconds or a duration like 15m or 2h');
132
+ }
133
+ const value = parseInt(match[1], 10);
134
+ const unit = match[2].toLowerCase();
135
+ if (Number.isNaN(value)) {
136
+ throw new Error('TTL value is not a number');
137
+ }
138
+ if (unit === 'm') {
139
+ return value * 60;
140
+ }
141
+ if (unit === 'h') {
142
+ return value * 60 * 60;
143
+ }
144
+ return value;
145
+ }
146
+ /**
147
+ * Read an SSH public key from a file.
148
+ *
149
+ * @param {string} keyPath - Path to public key file
150
+ * @returns {Promise<string>} Public key contents
151
+ * @throws {Error} When the file is empty or unreadable
152
+ */
153
+ async function readPublicKeyFile(keyPath) {
154
+ const content = await fs_1.promises.readFile(keyPath, 'utf-8');
155
+ const trimmed = content.trim();
156
+ if (!trimmed) {
157
+ throw new Error('Public key file is empty');
158
+ }
159
+ return trimmed;
160
+ }
105
161
  function normalizeDeviceHost(host) {
106
- let normalized = host.trim();
162
+ let normalized = host.trim().replace(/\.$/, '');
107
163
  if (!normalized) {
108
164
  return normalized;
109
165
  }
@@ -119,6 +175,81 @@ function normalizeDeviceHost(host) {
119
175
  return normalized;
120
176
  }
121
177
  }
178
+ /**
179
+ * Print a focused failure for playlist source loading problems.
180
+ *
181
+ * @param {string} source - Playlist source value
182
+ * @param {Error} error - Load or parse error
183
+ */
184
+ function printPlaylistSourceLoadFailure(source, error) {
185
+ const isUrl = (0, playlist_source_1.isPlaylistSourceUrl)(source);
186
+ if (isUrl) {
187
+ console.error(chalk_1.default.red('\nCould not load hosted playlist URL'));
188
+ console.error(chalk_1.default.red(` Source: ${source}`));
189
+ console.error(chalk_1.default.red(` Error: ${error.message}`));
190
+ console.log(chalk_1.default.yellow('\n Hint:'));
191
+ console.log(chalk_1.default.yellow(' • Check the URL is reachable'));
192
+ console.log(chalk_1.default.yellow(' • Confirm the response is JSON'));
193
+ console.log(chalk_1.default.yellow(' • Use a local file path if network access is unavailable'));
194
+ return;
195
+ }
196
+ console.error(chalk_1.default.red(`\nCould not load playlist file`));
197
+ console.error(chalk_1.default.red(` Source: ${source}`));
198
+ console.error(chalk_1.default.red(` Error: ${error.message}`));
199
+ }
200
+ /**
201
+ * Print playlist verification failure details consistently.
202
+ *
203
+ * @param {Object} verifyResult - DP-1 verification result
204
+ * @param {string} [source] - Optional source label
205
+ */
206
+ function printPlaylistVerificationFailure(verifyResult, source) {
207
+ console.error(chalk_1.default.red(`\nPlaylist verification failed:${source ? ` (${source})` : ''}`), verifyResult.error);
208
+ if (verifyResult.details && verifyResult.details.length > 0) {
209
+ console.log(chalk_1.default.yellow('\n Validation errors:'));
210
+ verifyResult.details.forEach((detail) => {
211
+ console.log(chalk_1.default.yellow(` • ${detail.path}: ${detail.message}`));
212
+ });
213
+ }
214
+ console.log(chalk_1.default.yellow('\n Use --skip-verify to send anyway (not recommended)\n'));
215
+ }
216
+ /**
217
+ * Load and verify a DP-1 playlist from local file or hosted URL.
218
+ *
219
+ * @param {string} source - Playlist source value
220
+ * @returns {Promise<Object>} Verification result with parsed playlist when valid
221
+ */
222
+ async function verifyPlaylistSource(source) {
223
+ const loaded = await (0, playlist_source_1.loadPlaylistSource)(source);
224
+ const verifier = await Promise.resolve().then(() => __importStar(require('./src/utilities/playlist-verifier')));
225
+ const { verifyPlaylist } = verifier;
226
+ const verifyResult = verifyPlaylist(loaded.playlist);
227
+ return {
228
+ ...verifyResult,
229
+ playlist: verifyResult.valid ? loaded.playlist : undefined,
230
+ };
231
+ }
232
+ /**
233
+ * Run a shared verify/validate command flow.
234
+ *
235
+ * @param {string} source - Playlist source path or URL
236
+ */
237
+ async function runVerifyCommand(source) {
238
+ try {
239
+ console.log(chalk_1.default.blue('\nVerify playlist\n'));
240
+ const verifier = await Promise.resolve().then(() => __importStar(require('./src/utilities/playlist-verifier')));
241
+ const { printVerificationResult } = verifier;
242
+ const result = await verifyPlaylistSource(source);
243
+ printVerificationResult(result, source);
244
+ if (!result.valid) {
245
+ process.exit(1);
246
+ }
247
+ }
248
+ catch (error) {
249
+ printPlaylistSourceLoadFailure(source, error);
250
+ process.exit(1);
251
+ }
252
+ }
122
253
  async function promptYesNo(ask, question, defaultYes = true) {
123
254
  const suffix = defaultYes ? 'Y/n' : 'y/N';
124
255
  const answer = (await ask(`${question} [${suffix}] `)).trim().toLowerCase();
@@ -130,7 +261,7 @@ async function promptYesNo(ask, question, defaultYes = true) {
130
261
  program
131
262
  .name('ff1')
132
263
  .description('CLI to fetch NFT information and build DP1 playlists using AI (Grok, ChatGPT, Gemini)')
133
- .version('1.0.1')
264
+ .version(packageVersion)
134
265
  .addHelpText('after', `\nQuick start:\n 1) ff1 setup\n 2) ff1 chat\n\nDocs: https://github.com/feralfile/ff1-cli\n`);
135
266
  program
136
267
  .command('setup')
@@ -189,7 +320,6 @@ program
189
320
  const keyHelpUrls = {
190
321
  grok: 'https://console.x.ai/',
191
322
  gpt: 'https://platform.openai.com/api-keys',
192
- chatgpt: 'https://platform.openai.com/api-keys',
193
323
  gemini: 'https://aistudio.google.com/app/apikey',
194
324
  };
195
325
  if (!hasApiKeyForModel) {
@@ -234,6 +364,41 @@ program
234
364
  };
235
365
  }
236
366
  const existingDevice = config.ff1Devices?.devices?.[0];
367
+ const discoveryResult = await (0, ff1_discovery_1.discoverFF1Devices)({ timeoutMs: 2000 });
368
+ const discoveredDevices = discoveryResult.devices;
369
+ let selectedDeviceIndex = null;
370
+ if (discoveryResult.error && discoveredDevices.length === 0) {
371
+ const errorMessage = discoveryResult.error.endsWith('.')
372
+ ? discoveryResult.error
373
+ : `${discoveryResult.error}.`;
374
+ console.log(chalk_1.default.dim(`mDNS discovery failed: ${errorMessage} Continuing with manual entry.`));
375
+ }
376
+ else if (discoveryResult.error) {
377
+ console.log(chalk_1.default.dim(`mDNS discovery warning: ${discoveryResult.error}`));
378
+ }
379
+ if (discoveredDevices.length > 0) {
380
+ console.log(chalk_1.default.green('\nFF1 devices on your network:'));
381
+ discoveredDevices.forEach((device, index) => {
382
+ const displayId = device.id || device.name || device.host;
383
+ console.log(chalk_1.default.dim(` ${index + 1}) ${displayId}`));
384
+ });
385
+ const selectionAnswer = await ask(`Select device [1-${discoveredDevices.length}] or press Enter to skip: `);
386
+ if (selectionAnswer) {
387
+ const parsedIndex = Number.parseInt(selectionAnswer, 10);
388
+ if (Number.isNaN(parsedIndex) ||
389
+ parsedIndex < 1 ||
390
+ parsedIndex > discoveredDevices.length) {
391
+ console.log(chalk_1.default.red('Invalid selection. Skipping auto-discovery.'));
392
+ }
393
+ else {
394
+ selectedDeviceIndex = parsedIndex - 1;
395
+ }
396
+ }
397
+ }
398
+ else if (!discoveryResult.error) {
399
+ console.log(chalk_1.default.dim('No FF1 devices found via mDNS. Continuing with manual entry.'));
400
+ }
401
+ const selectedDevice = selectedDeviceIndex === null ? null : discoveredDevices[selectedDeviceIndex];
237
402
  {
238
403
  const existingHost = existingDevice?.host || '';
239
404
  let rawDefaultDeviceId = '';
@@ -249,25 +414,32 @@ program
249
414
  }
250
415
  }
251
416
  const defaultDeviceId = isMissingConfigValue(rawDefaultDeviceId) ? '' : rawDefaultDeviceId;
252
- const idPrompt = defaultDeviceId
253
- ? `Device ID (e.g. ff1-ABCD1234) [${defaultDeviceId}]: `
254
- : 'Device ID (e.g. ff1-ABCD1234): ';
255
- const idAnswer = await ask(idPrompt);
256
- const rawDeviceId = idAnswer || defaultDeviceId;
257
417
  let hostValue = '';
258
- if (rawDeviceId) {
259
- const looksLikeHost = rawDeviceId.includes('.') ||
260
- rawDeviceId.includes(':') ||
261
- rawDeviceId.startsWith('http');
262
- if (looksLikeHost) {
263
- hostValue = normalizeDeviceHost(rawDeviceId);
264
- }
265
- else {
266
- const deviceId = rawDeviceId.startsWith('ff1-') ? rawDeviceId : `ff1-${rawDeviceId}`;
267
- hostValue = normalizeDeviceHost(`${deviceId}.local`);
418
+ if (selectedDevice) {
419
+ hostValue = normalizeDeviceHost(`${selectedDevice.host}:${selectedDevice.port}`);
420
+ console.log(chalk_1.default.dim(`Using discovered device: ${selectedDevice.name} (${selectedDevice.host}:${selectedDevice.port})`));
421
+ }
422
+ else {
423
+ const idPrompt = defaultDeviceId
424
+ ? `Device ID (e.g. ff1-ABCD1234) [${defaultDeviceId}]: `
425
+ : 'Device ID (e.g. ff1-ABCD1234): ';
426
+ const idAnswer = await ask(idPrompt);
427
+ const rawDeviceId = idAnswer || defaultDeviceId;
428
+ if (rawDeviceId) {
429
+ const looksLikeHost = rawDeviceId.includes('.') ||
430
+ rawDeviceId.includes(':') ||
431
+ rawDeviceId.startsWith('http');
432
+ if (looksLikeHost) {
433
+ hostValue = normalizeDeviceHost(rawDeviceId);
434
+ }
435
+ else {
436
+ const deviceId = rawDeviceId.startsWith('ff1-') ? rawDeviceId : `ff1-${rawDeviceId}`;
437
+ hostValue = normalizeDeviceHost(`${deviceId}.local`);
438
+ }
268
439
  }
269
440
  }
270
- const rawName = existingDevice?.name || 'ff1';
441
+ const discoveredName = selectedDevice?.name || selectedDevice?.id || '';
442
+ const rawName = existingDevice?.name || discoveredName || 'ff1';
271
443
  const defaultName = isMissingConfigValue(rawName) ? '' : rawName;
272
444
  const namePrompt = defaultName
273
445
  ? `Device name (kitchen, office, etc.) [${defaultName}]: `
@@ -420,7 +592,7 @@ program
420
592
  });
421
593
  // Print final summary
422
594
  if (result && result.playlist) {
423
- console.log(chalk_1.default.green('\nPlaylist created'));
595
+ console.log(chalk_1.default.green('\nPlaylist saved'));
424
596
  console.log(chalk_1.default.dim(` Title: ${result.playlist.title}`));
425
597
  console.log(chalk_1.default.dim(` Items: ${result.playlist.items?.length || 0}`));
426
598
  console.log(chalk_1.default.dim(` Output: ${options.output}\n`));
@@ -523,48 +695,16 @@ program
523
695
  program
524
696
  .command('verify')
525
697
  .description('Verify a DP1 playlist file against DP-1 specification')
526
- .argument('<file>', 'Path to the playlist file')
698
+ .argument('<file>', 'Path to the playlist file or hosted playlist URL')
527
699
  .action(async (file) => {
528
- try {
529
- console.log(chalk_1.default.blue('\nVerify playlist\n'));
530
- // Import the verification utility
531
- const verifier = await Promise.resolve().then(() => __importStar(require('./src/utilities/playlist-verifier')));
532
- const { verifyPlaylistFile, printVerificationResult } = verifier;
533
- // Verify the playlist
534
- const result = await verifyPlaylistFile(file);
535
- // Print results
536
- printVerificationResult(result, file);
537
- if (!result.valid) {
538
- process.exit(1);
539
- }
540
- }
541
- catch (error) {
542
- console.error(chalk_1.default.red('\nError:'), error.message);
543
- process.exit(1);
544
- }
700
+ await runVerifyCommand(file);
545
701
  });
546
702
  program
547
703
  .command('validate')
548
704
  .description('Validate a DP1 playlist file (alias for verify)')
549
- .argument('<file>', 'Path to the playlist file')
705
+ .argument('<file>', 'Path to the playlist file or hosted playlist URL')
550
706
  .action(async (file) => {
551
- try {
552
- console.log(chalk_1.default.blue('\nVerify playlist\n'));
553
- // Import the verification utility
554
- const verifier = await Promise.resolve().then(() => __importStar(require('./src/utilities/playlist-verifier')));
555
- const { verifyPlaylistFile, printVerificationResult } = verifier;
556
- // Verify the playlist
557
- const result = await verifyPlaylistFile(file);
558
- // Print results
559
- printVerificationResult(result, file);
560
- if (!result.valid) {
561
- process.exit(1);
562
- }
563
- }
564
- catch (error) {
565
- console.error(chalk_1.default.red('\nError:'), error.message);
566
- process.exit(1);
567
- }
707
+ await runVerifyCommand(file);
568
708
  });
569
709
  program
570
710
  .command('sign')
@@ -624,14 +764,7 @@ program
624
764
  const { verifyPlaylist } = verifier;
625
765
  const verifyResult = verifyPlaylist(playlist);
626
766
  if (!verifyResult.valid) {
627
- console.error(chalk_1.default.red('\nPlaylist verification failed:'), verifyResult.error);
628
- if (verifyResult.details && verifyResult.details.length > 0) {
629
- console.log(chalk_1.default.yellow('\n Validation errors:'));
630
- verifyResult.details.forEach((detail) => {
631
- console.log(chalk_1.default.yellow(` • ${detail.path}: ${detail.message}`));
632
- });
633
- }
634
- console.log(chalk_1.default.yellow('\n Use --skip-verify to send anyway (not recommended)\n'));
767
+ printPlaylistVerificationFailure(verifyResult);
635
768
  process.exit(1);
636
769
  }
637
770
  }
@@ -666,31 +799,23 @@ program
666
799
  });
667
800
  program
668
801
  .command('send')
669
- .description('Send a playlist file to an FF1 device')
670
- .argument('<file>', 'Path to the playlist file')
802
+ .description('Send a playlist to an FF1 device')
803
+ .argument('<file>', 'Playlist file path or hosted playlist URL')
671
804
  .option('-d, --device <name>', 'Device name (uses first device if not specified)')
672
805
  .option('--skip-verify', 'Skip playlist verification before sending')
673
806
  .action(async (file, options) => {
674
807
  try {
675
808
  console.log(chalk_1.default.blue('\nSend playlist to FF1\n'));
676
- // Read the playlist file
677
- const content = await fs_1.promises.readFile(file, 'utf-8');
678
- const playlist = JSON.parse(content);
809
+ const playlistResult = await (0, playlist_source_1.loadPlaylistSource)(file);
810
+ const playlist = playlistResult.playlist;
679
811
  // Verify playlist before sending (unless skipped)
680
812
  if (!options.skipVerify) {
681
- console.log(chalk_1.default.cyan('Verify playlist'));
813
+ console.log(chalk_1.default.cyan(`Verify playlist (${playlistResult.sourceType}: ${playlistResult.source})`));
682
814
  const verifier = await Promise.resolve().then(() => __importStar(require('./src/utilities/playlist-verifier')));
683
815
  const { verifyPlaylist } = verifier;
684
816
  const verifyResult = verifyPlaylist(playlist);
685
817
  if (!verifyResult.valid) {
686
- console.error(chalk_1.default.red('\nPlaylist verification failed:'), verifyResult.error);
687
- if (verifyResult.details && verifyResult.details.length > 0) {
688
- console.log(chalk_1.default.yellow('\n Validation errors:'));
689
- verifyResult.details.forEach((detail) => {
690
- console.log(chalk_1.default.yellow(` • ${detail.path}: ${detail.message}`));
691
- });
692
- }
693
- console.log(chalk_1.default.yellow('\n Use --skip-verify to send anyway (not recommended)\n'));
818
+ printPlaylistVerificationFailure(verifyResult, `source: ${playlistResult.source}`);
694
819
  process.exit(1);
695
820
  }
696
821
  console.log(chalk_1.default.green('✓ Verified\n'));
@@ -858,7 +983,7 @@ program
858
983
  outputPath: options.output,
859
984
  });
860
985
  if (result && result.playlist) {
861
- console.log(chalk_1.default.green('\nPlaylist created'));
986
+ console.log(chalk_1.default.green('\nPlaylist saved'));
862
987
  console.log(chalk_1.default.dim(` Title: ${result.playlist.title}`));
863
988
  console.log(chalk_1.default.dim(` Items: ${result.playlist.items?.length || 0}`));
864
989
  console.log(chalk_1.default.dim(` Output: ${options.output}\n`));
@@ -929,4 +1054,68 @@ program
929
1054
  process.exit(1);
930
1055
  }
931
1056
  });
1057
+ program
1058
+ .command('ssh')
1059
+ .description('Enable or disable SSH access on an FF1 device')
1060
+ .argument('<action>', 'Action: enable or disable')
1061
+ .option('-d, --device <name>', 'Device name (uses first device if not specified)')
1062
+ .option('--pubkey <path>', 'SSH public key file (required for enable)')
1063
+ .option('--ttl <duration>', 'Auto-disable after duration (e.g. 30m, 2h, 900s)')
1064
+ .action(async (action, options) => {
1065
+ try {
1066
+ const normalizedAction = action.trim().toLowerCase();
1067
+ if (normalizedAction !== 'enable' && normalizedAction !== 'disable') {
1068
+ console.error(chalk_1.default.red('\nUnknown action:'), action);
1069
+ console.log(chalk_1.default.yellow('Available actions: enable, disable\n'));
1070
+ process.exit(1);
1071
+ }
1072
+ const isEnable = normalizedAction === 'enable';
1073
+ let publicKey;
1074
+ if (isEnable) {
1075
+ if (!options.pubkey) {
1076
+ console.error(chalk_1.default.red('\nPublic key is required to enable SSH'));
1077
+ console.log(chalk_1.default.yellow('Use: ff1 ssh enable --pubkey ~/.ssh/id_ed25519.pub\n'));
1078
+ process.exit(1);
1079
+ }
1080
+ publicKey = await readPublicKeyFile(options.pubkey);
1081
+ }
1082
+ let ttlSeconds;
1083
+ if (options.ttl) {
1084
+ ttlSeconds = parseTtlSeconds(options.ttl);
1085
+ }
1086
+ const { sendSshAccessCommand } = await Promise.resolve().then(() => __importStar(require('./src/utilities/ssh-access')));
1087
+ const result = await sendSshAccessCommand({
1088
+ enabled: isEnable,
1089
+ deviceName: options.device,
1090
+ publicKey,
1091
+ ttlSeconds,
1092
+ });
1093
+ if (result.success) {
1094
+ console.log(chalk_1.default.green(`SSH ${isEnable ? 'enabled' : 'disabled'}`));
1095
+ if (result.deviceName) {
1096
+ console.log(chalk_1.default.dim(` Device: ${result.deviceName}`));
1097
+ }
1098
+ if (result.device) {
1099
+ console.log(chalk_1.default.dim(` Host: ${result.device}`));
1100
+ }
1101
+ if (result.response && typeof result.response === 'object') {
1102
+ const expiresAt = result.response.expiresAt;
1103
+ if (expiresAt) {
1104
+ console.log(chalk_1.default.dim(` Expires: ${expiresAt}`));
1105
+ }
1106
+ }
1107
+ console.log();
1108
+ return;
1109
+ }
1110
+ console.error(chalk_1.default.red('\nSSH request failed:'), result.error);
1111
+ if (result.details) {
1112
+ console.error(chalk_1.default.dim(` Details: ${result.details}`));
1113
+ }
1114
+ process.exit(1);
1115
+ }
1116
+ catch (error) {
1117
+ console.error(chalk_1.default.red('\nError:'), error.message);
1118
+ process.exit(1);
1119
+ }
1120
+ });
932
1121
  program.parse();
@@ -7,6 +7,18 @@ const registry = require('./registry');
7
7
  function sleep(ms) {
8
8
  return new Promise((resolve) => setTimeout(resolve, ms));
9
9
  }
10
+ function stableStringify(value) {
11
+ if (value === null || typeof value !== 'object') {
12
+ return JSON.stringify(value);
13
+ }
14
+ if (Array.isArray(value)) {
15
+ return `[${value.map((item) => stableStringify(item)).join(',')}]`;
16
+ }
17
+ const keys = Object.keys(value).sort();
18
+ return `{${keys
19
+ .map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`)
20
+ .join(',')}}`;
21
+ }
10
22
  async function createCompletionWithRetry(client, requestParams, maxRetries = 0) {
11
23
  for (let attempt = 0; attempt <= maxRetries; attempt += 1) {
12
24
  try {
@@ -456,8 +468,9 @@ KEY RULES
456
468
  - The registry system handles full objects internally
457
469
 
458
470
  OUTPUT RULES
471
+ - Do NOT print JSON arguments.
459
472
  - Before each function call, print exactly one sentence: "→ I'm …" describing the action.
460
- - Then call exactly one function with JSON arguments.
473
+ - Call the function ONLY via tool calls and do not include tool names, JSON, or arguments in assistant content.
461
474
  - No chain‑of‑thought or extra narration; keep public output minimal.
462
475
 
463
476
  STOPPING CONDITIONS
@@ -529,6 +542,7 @@ async function buildPlaylistWithAI(params, options = {}) {
529
542
  let collectedItems = [];
530
543
  let verificationFailures = 0;
531
544
  let sentToDevice = false;
545
+ const queryRequirementCache = new Map();
532
546
  const maxIterations = 20;
533
547
  const maxVerificationRetries = 3;
534
548
  while (iterationCount < maxIterations) {
@@ -626,10 +640,32 @@ async function buildPlaylistWithAI(params, options = {}) {
626
640
  continue; // Go to next iteration
627
641
  }
628
642
  }
643
+ const contentText = message.content || '';
644
+ const looksLikeToolAttempt = !message.tool_calls &&
645
+ contentText &&
646
+ (contentText.includes("→ I'm") ||
647
+ contentText.includes('"requirement"') ||
648
+ contentText.trim().startsWith('{'));
649
+ if (looksLikeToolAttempt && iterationCount < maxIterations - 1) {
650
+ if (verbose) {
651
+ console.log(chalk.yellow('AI returned tool arguments in text. Forcing function call with a system reminder.'));
652
+ }
653
+ messages.push(message);
654
+ messages.push({
655
+ role: 'system',
656
+ content: 'CRITICAL: You MUST call the required function via tool_calls. Do not output JSON or arguments in plain text. Call the function now.',
657
+ });
658
+ continue;
659
+ }
629
660
  messages.push(message);
630
- // Always print AI content when present
661
+ // Only print non-json assistant content while keeping function-call noise low.
631
662
  if (message.content) {
632
- console.log(chalk.cyan(message.content));
663
+ const trimmed = message.content.trim();
664
+ const hasToolCalls = message.tool_calls && message.tool_calls.length > 0;
665
+ const isJson = trimmed.startsWith('{') || trimmed.startsWith('[');
666
+ if (!isJson && (verbose || !hasToolCalls)) {
667
+ console.log(chalk.cyan(trimmed));
668
+ }
633
669
  }
634
670
  if (verbose) {
635
671
  console.log(chalk.dim(`\nIteration ${iterationCount}:`));
@@ -647,12 +683,33 @@ async function buildPlaylistWithAI(params, options = {}) {
647
683
  console.log(chalk.dim(` Input: ${JSON.stringify(args, null, 2).split('\n').join('\n ')}`));
648
684
  }
649
685
  try {
650
- const result = await executeFunction(functionName, args);
686
+ let result;
687
+ let usedCache = false;
688
+ if (functionName === 'query_requirement') {
689
+ const cacheKey = stableStringify({
690
+ requirement: args.requirement,
691
+ duration: args.duration,
692
+ });
693
+ if (queryRequirementCache.has(cacheKey)) {
694
+ result = queryRequirementCache.get(cacheKey);
695
+ usedCache = true;
696
+ if (verbose) {
697
+ console.log(chalk.dim(' ↺ Using cached result for duplicate query_requirement'));
698
+ }
699
+ }
700
+ else {
701
+ result = await executeFunction(functionName, args);
702
+ queryRequirementCache.set(cacheKey, result);
703
+ }
704
+ }
705
+ else {
706
+ result = await executeFunction(functionName, args);
707
+ }
651
708
  if (verbose) {
652
709
  console.log(chalk.dim(` Output: ${JSON.stringify(result, null, 2).split('\n').join('\n ')}`));
653
710
  }
654
711
  // Track collected item IDs from query_requirement
655
- if (functionName === 'query_requirement' && Array.isArray(result)) {
712
+ if (functionName === 'query_requirement' && Array.isArray(result) && !usedCache) {
656
713
  // Result now contains minimal item objects with id field
657
714
  const itemIds = result.map((item) => item.id).filter((id) => id);
658
715
  collectedItems = collectedItems.concat(itemIds); // Now storing IDs, not full items