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 +12 -2
- package/config.json.example +11 -9
- package/dist/index.js +271 -82
- package/dist/src/ai-orchestrator/index.js +62 -5
- package/dist/src/config.js +16 -38
- package/dist/src/intent-parser/index.js +58 -16
- package/dist/src/intent-parser/utils.js +5 -2
- package/dist/src/logger.js +10 -0
- package/dist/src/utilities/ff1-compatibility.js +269 -0
- package/dist/src/utilities/ff1-device.js +9 -27
- package/dist/src/utilities/ff1-discovery.js +147 -0
- package/dist/src/utilities/functions.js +8 -26
- package/dist/src/utilities/index.js +9 -3
- package/dist/src/utilities/playlist-send.js +36 -17
- package/dist/src/utilities/playlist-source.js +77 -0
- package/dist/src/utilities/ssh-access.js +145 -0
- package/docs/CONFIGURATION.md +22 -8
- package/docs/EXAMPLES.md +5 -1
- package/docs/README.md +24 -4
- package/docs/RELEASING.md +33 -4
- package/package.json +3 -10
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
|
|
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
|
|
package/config.json.example
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
{
|
|
2
|
-
"defaultModel": "
|
|
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-
|
|
7
|
+
"model": "grok-beta",
|
|
8
8
|
"availableModels": [
|
|
9
|
-
"grok-
|
|
10
|
-
"grok-
|
|
11
|
-
"grok-
|
|
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
|
-
"
|
|
19
|
+
"chatgpt": {
|
|
20
20
|
"apiKey": "YOUR_OPENAI_API_KEY_FROM_PLATFORM_OPENAI_COM",
|
|
21
21
|
"baseURL": "https://api.openai.com/v1",
|
|
22
|
-
"model": "gpt-
|
|
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.
|
|
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": "
|
|
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
|
|
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
|
|
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(
|
|
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 (
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
670
|
-
.argument('<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
|
-
|
|
677
|
-
const
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|
-
-
|
|
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
|
-
//
|
|
661
|
+
// Only print non-json assistant content while keeping function-call noise low.
|
|
631
662
|
if (message.content) {
|
|
632
|
-
|
|
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
|
-
|
|
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
|