@feralfile/cli 1.6.0 → 2.0.1
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 +6 -11
- package/config.json.example +0 -65
- package/dist/index.js +1 -3
- package/dist/package.json +3 -4
- package/dist/src/commands/config.js +15 -13
- package/dist/src/commands/play.js +63 -0
- package/dist/src/commands/setup.js +1 -73
- package/dist/src/commands/status.js +0 -13
- package/dist/src/config.js +3 -126
- package/dist/src/main.js +2 -361
- package/dist/src/utilities/ff1-device.js +55 -0
- package/dist/src/utilities/playlist-builder.js +79 -17
- package/docs/CONFIGURATION.md +1 -35
- package/docs/EXAMPLES.md +206 -215
- package/docs/PROJECT_SPEC.md +20 -18
- package/docs/README.md +51 -104
- package/package.json +3 -4
- package/dist/src/ai-orchestrator/index.js +0 -1040
- package/dist/src/ai-orchestrator/registry.js +0 -96
- package/dist/src/commands/chat.js +0 -189
- package/dist/src/intent-parser/index.js +0 -1393
- package/dist/src/intent-parser/utils.js +0 -116
- package/docs/FUNCTION_CALLING.md +0 -100
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@ A small Node.js CLI for building DP-1 playlists of digital art.
|
|
|
4
4
|
|
|
5
5
|
**Runtime:** Node.js 22 or newer (matches CI and the `dp1-js` dependency). That engine floor is a **breaking** change if you previously used Node 18 or 20—check the **GitHub Release** for the version you move to; release authors follow `docs/RELEASING.md` so the notes stay explicit.
|
|
6
6
|
|
|
7
|
-
ff-cli
|
|
7
|
+
ff-cli is a set of deterministic commands for building DP-1–conformant playlists and playing them on an Art Computer: schema validation, indexing, JSON‑LD, signing, and device delivery. There's no built-in chat — natural language belongs in your coding agent (Claude Code, Codex), which drives ff-cli through the `ff-control` skill (see [Use with Claude Code](#use-with-claude-code)). No LLM API key required.
|
|
8
8
|
|
|
9
9
|
## Install
|
|
10
10
|
|
|
@@ -24,16 +24,14 @@ Installs a prebuilt binary for macOS/Linux (no Node.js required).
|
|
|
24
24
|
|
|
25
25
|
```bash
|
|
26
26
|
npx @feralfile/cli setup
|
|
27
|
-
npx @feralfile/cli
|
|
27
|
+
npx @feralfile/cli find https://www.artblocks.io/collection/ringers-by-dmitri-cherniak --play
|
|
28
28
|
```
|
|
29
29
|
|
|
30
30
|
## Quick Start
|
|
31
31
|
|
|
32
|
-
**Set your LLM API key first (default Claude):** `export ANTHROPIC_API_KEY='sk-ant-your-api-key-here'`
|
|
33
|
-
|
|
34
32
|
```bash
|
|
35
33
|
ff-cli setup
|
|
36
|
-
ff-cli
|
|
34
|
+
ff-cli find https://objkt.com/tokens/hicetnunc/111068 --play
|
|
37
35
|
ff-cli play "https://example.com/video.mp4" --skip-verify
|
|
38
36
|
```
|
|
39
37
|
|
|
@@ -96,12 +94,10 @@ Codex will surface it when you ask to build a playlist, play an artwork or URL o
|
|
|
96
94
|
|
|
97
95
|
## Dev Quick Start
|
|
98
96
|
|
|
99
|
-
**Set your LLM API key first (default Claude):** `export ANTHROPIC_API_KEY='sk-ant-your-api-key-here'`
|
|
100
|
-
|
|
101
97
|
```bash
|
|
102
98
|
npm ci
|
|
103
99
|
npm run dev -- setup
|
|
104
|
-
npm run dev --
|
|
100
|
+
npm run dev -- find https://objkt.com/tokens/hicetnunc/111068 --play
|
|
105
101
|
npm run dev -- play "https://example.com/video.mp4" --skip-verify
|
|
106
102
|
```
|
|
107
103
|
|
|
@@ -109,16 +105,15 @@ npm run dev -- play "https://example.com/video.mp4" --skip-verify
|
|
|
109
105
|
|
|
110
106
|
- Getting started and usage: `./docs/README.md`
|
|
111
107
|
- Configuration: `./docs/CONFIGURATION.md`
|
|
112
|
-
- Function calling architecture: `./docs/FUNCTION_CALLING.md`
|
|
113
108
|
- Examples: `./docs/EXAMPLES.md`
|
|
114
109
|
- SSH access: `ff-cli ssh enable|disable` in `./docs/README.md`
|
|
115
110
|
|
|
116
111
|
## Verification
|
|
117
112
|
|
|
118
|
-
GitHub Actions runs `.github/workflows/ci.yml` for pull requests, pushes to `main`/`master`, and reusable `workflow_call` jobs. CI uses Node.js 22, installs dependencies with `npm ci`,
|
|
113
|
+
GitHub Actions runs `.github/workflows/ci.yml` for pull requests, pushes to `main`/`master`, and reusable `workflow_call` jobs. CI uses Node.js 22, installs dependencies with `npm ci`, and runs the repo-wide verification entrypoint:
|
|
119
114
|
|
|
120
115
|
```bash
|
|
121
|
-
|
|
116
|
+
npm run verify
|
|
122
117
|
```
|
|
123
118
|
|
|
124
119
|
Run the same command locally before opening a PR. It checks formatting, lint, tests, TypeScript build, playlist validation smoke, and config validation smoke without mutating source files.
|
package/config.json.example
CHANGED
|
@@ -1,69 +1,4 @@
|
|
|
1
1
|
{
|
|
2
|
-
"defaultModel": "claude",
|
|
3
|
-
"models": {
|
|
4
|
-
"claude": {
|
|
5
|
-
"apiKey": "YOUR_ANTHROPIC_API_KEY_FROM_CONSOLE_ANTHROPIC_COM",
|
|
6
|
-
"baseURL": "https://api.anthropic.com/v1/",
|
|
7
|
-
"model": "claude-sonnet-4-6",
|
|
8
|
-
"availableModels": [
|
|
9
|
-
"claude-opus-4-7",
|
|
10
|
-
"claude-sonnet-4-6",
|
|
11
|
-
"claude-haiku-4-5-20251001"
|
|
12
|
-
],
|
|
13
|
-
"timeout": 30000,
|
|
14
|
-
"maxRetries": 3,
|
|
15
|
-
"temperature": 0.3,
|
|
16
|
-
"maxTokens": 4000,
|
|
17
|
-
"supportsFunctionCalling": true
|
|
18
|
-
},
|
|
19
|
-
"grok": {
|
|
20
|
-
"apiKey": "YOUR_GROK_API_KEY_FROM_X_AI_CONSOLE",
|
|
21
|
-
"baseURL": "https://api.x.ai/v1",
|
|
22
|
-
"model": "grok-beta",
|
|
23
|
-
"availableModels": [
|
|
24
|
-
"grok-beta",
|
|
25
|
-
"grok-2-1212",
|
|
26
|
-
"grok-2-vision-1212"
|
|
27
|
-
],
|
|
28
|
-
"timeout": 120000,
|
|
29
|
-
"maxRetries": 3,
|
|
30
|
-
"temperature": 0.3,
|
|
31
|
-
"maxTokens": 4000,
|
|
32
|
-
"supportsFunctionCalling": true
|
|
33
|
-
},
|
|
34
|
-
"gpt": {
|
|
35
|
-
"apiKey": "YOUR_OPENAI_API_KEY_FROM_PLATFORM_OPENAI_COM",
|
|
36
|
-
"baseURL": "https://api.openai.com/v1",
|
|
37
|
-
"model": "gpt-4.1",
|
|
38
|
-
"availableModels": [
|
|
39
|
-
"gpt-4.1",
|
|
40
|
-
"gpt-4.1-mini",
|
|
41
|
-
"gpt-4o",
|
|
42
|
-
"gpt-4o-mini",
|
|
43
|
-
"gpt-4-turbo"
|
|
44
|
-
],
|
|
45
|
-
"timeout": 30000,
|
|
46
|
-
"maxRetries": 3,
|
|
47
|
-
"temperature": 1,
|
|
48
|
-
"maxTokens": 4000,
|
|
49
|
-
"supportsFunctionCalling": true
|
|
50
|
-
},
|
|
51
|
-
"gemini": {
|
|
52
|
-
"apiKey": "YOUR_GOOGLE_AI_STUDIO_API_KEY_FROM_AISTUDIO_GOOGLE_COM",
|
|
53
|
-
"baseURL": "https://generativelanguage.googleapis.com/v1beta/openai/",
|
|
54
|
-
"model": "gemini-2.5-flash",
|
|
55
|
-
"availableModels": [
|
|
56
|
-
"gemini-2.5-flash-lite",
|
|
57
|
-
"gemini-2.5-flash",
|
|
58
|
-
"gemini-flash-lite-latest"
|
|
59
|
-
],
|
|
60
|
-
"timeout": 30000,
|
|
61
|
-
"maxRetries": 3,
|
|
62
|
-
"temperature": 0.3,
|
|
63
|
-
"maxTokens": 4000,
|
|
64
|
-
"supportsFunctionCalling": true
|
|
65
|
-
}
|
|
66
|
-
},
|
|
67
2
|
"defaultDuration": 10,
|
|
68
3
|
"browser": {
|
|
69
4
|
"timeout": 90000,
|
package/dist/index.js
CHANGED
|
@@ -14,7 +14,6 @@ const fs_1 = require("fs");
|
|
|
14
14
|
const path_1 = require("path");
|
|
15
15
|
const setup_1 = require("./src/commands/setup");
|
|
16
16
|
const status_1 = require("./src/commands/status");
|
|
17
|
-
const chat_1 = require("./src/commands/chat");
|
|
18
17
|
const validate_1 = require("./src/commands/validate");
|
|
19
18
|
const sign_1 = require("./src/commands/sign");
|
|
20
19
|
const play_1 = require("./src/commands/play");
|
|
@@ -39,10 +38,9 @@ program
|
|
|
39
38
|
.name('ff-cli')
|
|
40
39
|
.description(packageDescription)
|
|
41
40
|
.version(packageVersion)
|
|
42
|
-
.addHelpText('after', `\nQuick start:\n 1) ff-cli setup\n 2) ff-cli
|
|
41
|
+
.addHelpText('after', `\nQuick start:\n 1) ff-cli setup\n 2) ff-cli play <url-or-playlist>\n\nDocs: https://github.com/feral-file/ff-cli\n`);
|
|
43
42
|
program.addCommand(setup_1.setupCommand);
|
|
44
43
|
program.addCommand(status_1.statusCommand);
|
|
45
|
-
program.addCommand(chat_1.chatCommand);
|
|
46
44
|
program.addCommand(validate_1.verifyCommand);
|
|
47
45
|
program.addCommand(validate_1.validateCommand);
|
|
48
46
|
program.addCommand(sign_1.signCommand);
|
package/dist/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@feralfile/cli",
|
|
3
|
-
"version": "
|
|
4
|
-
"description": "CLI for building DP-1 playlists of digital art
|
|
3
|
+
"version": "2.0.1",
|
|
4
|
+
"description": "CLI for building, signing, and playing DP-1 playlists of digital art on Feral File devices",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
7
7
|
"url": "git+https://github.com/feral-file/ff-cli.git"
|
|
@@ -51,7 +51,7 @@
|
|
|
51
51
|
"nft",
|
|
52
52
|
"playlist",
|
|
53
53
|
"dp1",
|
|
54
|
-
"
|
|
54
|
+
"feralfile",
|
|
55
55
|
"cli"
|
|
56
56
|
],
|
|
57
57
|
"author": "",
|
|
@@ -65,7 +65,6 @@
|
|
|
65
65
|
"dp1-js": "2.0.0",
|
|
66
66
|
"ff1": "^1.0.0",
|
|
67
67
|
"fuzzysort": "^3.1.0",
|
|
68
|
-
"openai": "^4.58.1",
|
|
69
68
|
"viem": "^2.38.2"
|
|
70
69
|
},
|
|
71
70
|
"devDependencies": {
|
|
@@ -25,20 +25,22 @@ exports.configCommand = new commander_1.Command('config')
|
|
|
25
25
|
else if (action === 'show') {
|
|
26
26
|
const config = (0, config_1.getConfig)();
|
|
27
27
|
console.log(chalk_1.default.blue('\nCurrent configuration\n'));
|
|
28
|
-
console.log(chalk_1.default.bold('Default model:'), chalk_1.default.white(config.defaultModel));
|
|
29
28
|
console.log(chalk_1.default.bold('Default duration:'), chalk_1.default.white(config.defaultDuration + 's'));
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
console.log(
|
|
40
|
-
|
|
41
|
-
|
|
29
|
+
const feedServers = config.feedServers || [];
|
|
30
|
+
if (feedServers.length > 0) {
|
|
31
|
+
console.log(chalk_1.default.bold('\nFeed servers:\n'));
|
|
32
|
+
feedServers.forEach((server) => {
|
|
33
|
+
console.log(` ${chalk_1.default.dim(server.baseUrl)}`);
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
const devices = config.ff1Devices?.devices || [];
|
|
37
|
+
if (devices.length > 0) {
|
|
38
|
+
console.log(chalk_1.default.bold('\nFF1 devices:\n'));
|
|
39
|
+
devices.forEach((device) => {
|
|
40
|
+
console.log(` ${chalk_1.default.bold(device.name || 'unnamed')} ${chalk_1.default.dim(`→ ${device.host}`)}`);
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
console.log();
|
|
42
44
|
}
|
|
43
45
|
else if (action === 'validate') {
|
|
44
46
|
const validation = (0, config_1.validateConfig)();
|
|
@@ -37,16 +37,68 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
37
37
|
};
|
|
38
38
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
39
39
|
exports.playCommand = void 0;
|
|
40
|
+
exports.describeDiscoveryInput = describeDiscoveryInput;
|
|
40
41
|
const commander_1 = require("commander");
|
|
41
42
|
const chalk_1 = __importDefault(require("chalk"));
|
|
42
43
|
const config_1 = require("../config");
|
|
43
44
|
const playlist_source_1 = require("../utilities/playlist-source");
|
|
45
|
+
const marketplace_url_1 = require("../utilities/marketplace-url");
|
|
44
46
|
const playlist_display_1 = require("./helpers/playlist-display");
|
|
45
47
|
// ff1-device is still CommonJS; require keeps the interop simple.
|
|
46
48
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
47
49
|
const { sendPlaylistToDevice } = require('../utilities/ff1-device');
|
|
48
50
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
49
51
|
const { signPlaylist } = require('../utilities/playlist-signer');
|
|
52
|
+
/**
|
|
53
|
+
* Human label for a discovery input that `play` cannot cast directly.
|
|
54
|
+
*
|
|
55
|
+
* `play` casts an exact, already-playable source (a playlist file, a hosted
|
|
56
|
+
* playlist URL, or a direct media/web URL). A marketplace URL, on-chain
|
|
57
|
+
* coordinates, or a wallet address must be *resolved* through the indexer
|
|
58
|
+
* first — that is `find`'s job. Returns a label for those inputs so `play`
|
|
59
|
+
* can redirect the user instead of silently wrapping, say, an Art Blocks
|
|
60
|
+
* collection *page* as a web item. Returns null for anything `play` should
|
|
61
|
+
* handle itself (plain URLs and file paths parse to null here).
|
|
62
|
+
*
|
|
63
|
+
* A recognized-but-`unsupported` result (e.g. a legacy Art Blocks
|
|
64
|
+
* `/projects/{id}` URL, or an unsupported-chain OpenSea link) is also a
|
|
65
|
+
* discovery input — it's a marketplace URL, not a playable file — so it is
|
|
66
|
+
* redirected to `find` too, which surfaces the specific reason.
|
|
67
|
+
*/
|
|
68
|
+
function describeDiscoveryInput(source) {
|
|
69
|
+
const parsed = (0, marketplace_url_1.parseFindInput)(source);
|
|
70
|
+
if (!parsed) {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
if (parsed.kind === 'unsupported') {
|
|
74
|
+
return 'a marketplace URL';
|
|
75
|
+
}
|
|
76
|
+
switch (parsed.kind) {
|
|
77
|
+
case 'token':
|
|
78
|
+
return 'an on-chain token reference';
|
|
79
|
+
case 'address':
|
|
80
|
+
return 'a wallet address';
|
|
81
|
+
case 'ff-url':
|
|
82
|
+
return 'a Feral File URL';
|
|
83
|
+
case 'objkt-alias':
|
|
84
|
+
return 'an Objkt token';
|
|
85
|
+
case 'ab-collection':
|
|
86
|
+
return 'an Art Blocks collection';
|
|
87
|
+
case 'os-collection':
|
|
88
|
+
return 'an OpenSea collection';
|
|
89
|
+
case 'fxhash-iteration':
|
|
90
|
+
case 'fxhash-project':
|
|
91
|
+
return 'an fxhash work';
|
|
92
|
+
case 'neort-art':
|
|
93
|
+
return 'a Neort work';
|
|
94
|
+
case 'verse-series':
|
|
95
|
+
return 'a Verse series';
|
|
96
|
+
case 'raster-artwork':
|
|
97
|
+
return 'a Raster artwork';
|
|
98
|
+
default:
|
|
99
|
+
return 'an artwork reference';
|
|
100
|
+
}
|
|
101
|
+
}
|
|
50
102
|
exports.playCommand = new commander_1.Command('play')
|
|
51
103
|
.description('Play a playlist or media URL on an FF1 device')
|
|
52
104
|
.argument('<source>', 'Playlist file, playlist URL, or media URL')
|
|
@@ -54,6 +106,17 @@ exports.playCommand = new commander_1.Command('play')
|
|
|
54
106
|
.option('--skip-verify', 'Skip signature verification and auto-sign gating before playing; use only when you accept malformed envelopes')
|
|
55
107
|
.action(async (source, options) => {
|
|
56
108
|
try {
|
|
109
|
+
// `play` casts an exact playable source. A marketplace URL / on-chain
|
|
110
|
+
// coords / wallet must be resolved through the indexer first — redirect
|
|
111
|
+
// to `find` rather than silently wrapping the *page* as a web item.
|
|
112
|
+
const discovery = describeDiscoveryInput(source);
|
|
113
|
+
if (discovery) {
|
|
114
|
+
console.error(chalk_1.default.red('\n`play` casts an exact playlist or media URL — it does not search marketplaces.'));
|
|
115
|
+
console.error(chalk_1.default.dim(` "${source}" looks like ${discovery} to resolve first.`));
|
|
116
|
+
const deviceFlag = options.device ? ` -d "${options.device}"` : '';
|
|
117
|
+
console.log(chalk_1.default.yellow(`\nTry: ff-cli find "${source}" --play${deviceFlag}`));
|
|
118
|
+
process.exit(1);
|
|
119
|
+
}
|
|
57
120
|
const config = (0, config_1.getConfig)();
|
|
58
121
|
// No explicit duration: a direct video/audio URL plays its natural
|
|
59
122
|
// length (DP-1 §4.1); static media uses config.defaultDuration.
|
|
@@ -10,7 +10,6 @@ const crypto_1 = __importDefault(require("crypto"));
|
|
|
10
10
|
const fs_1 = require("fs");
|
|
11
11
|
const device_lookup_1 = require("../utilities/device-lookup");
|
|
12
12
|
const device_upsert_1 = require("../utilities/device-upsert");
|
|
13
|
-
const config_1 = require("../config");
|
|
14
13
|
const config_files_1 = require("./helpers/config-files");
|
|
15
14
|
const device_discovery_1 = require("./helpers/device-discovery");
|
|
16
15
|
const prompt_1 = require("./helpers/prompt");
|
|
@@ -26,76 +25,9 @@ exports.setupCommand = new commander_1.Command('setup')
|
|
|
26
25
|
console.log(chalk_1.default.green(`Created ${configPath}`));
|
|
27
26
|
}
|
|
28
27
|
const config = await (0, config_files_1.readConfigFile)(configPath);
|
|
29
|
-
const mergedDefaults = (0, config_1.getConfig)();
|
|
30
|
-
// Show every known provider in the menu (in-code defaults first, then any
|
|
31
|
-
// custom providers the user has added to the file) so adding a new provider
|
|
32
|
-
// to defaults shows up for existing users without rerunning createSampleConfig.
|
|
33
|
-
const modelNames = Array.from(new Set([...(0, config_1.listAvailableModels)(), ...Object.keys(config.models || {})]));
|
|
34
|
-
if (modelNames.length === 0) {
|
|
35
|
-
console.error(chalk_1.default.red('No models found in config.json'));
|
|
36
|
-
process.exit(1);
|
|
37
|
-
}
|
|
38
28
|
console.log(chalk_1.default.blue('\nff-cli setup\n'));
|
|
39
29
|
prompt = (0, prompt_1.createPrompt)();
|
|
40
30
|
const ask = prompt.ask;
|
|
41
|
-
const currentModel = config.defaultModel && modelNames.includes(config.defaultModel)
|
|
42
|
-
? config.defaultModel
|
|
43
|
-
: modelNames[0];
|
|
44
|
-
let selectedModel = currentModel;
|
|
45
|
-
while (true) {
|
|
46
|
-
const modelAnswer = await ask(`Default model (${modelNames.join(', ')}) [${currentModel}]: `);
|
|
47
|
-
if (!modelAnswer) {
|
|
48
|
-
selectedModel = currentModel;
|
|
49
|
-
break;
|
|
50
|
-
}
|
|
51
|
-
if (modelNames.includes(modelAnswer)) {
|
|
52
|
-
selectedModel = modelAnswer;
|
|
53
|
-
break;
|
|
54
|
-
}
|
|
55
|
-
console.log(chalk_1.default.red(`Unknown model: ${modelAnswer}`));
|
|
56
|
-
}
|
|
57
|
-
config.defaultModel = selectedModel;
|
|
58
|
-
if (!config.models) {
|
|
59
|
-
config.models = {};
|
|
60
|
-
}
|
|
61
|
-
// Seed missing entries from in-code defaults so baseURL/model are populated,
|
|
62
|
-
// but blank the apiKey so the user is prompted instead of silently inheriting
|
|
63
|
-
// an env-derived value.
|
|
64
|
-
const providerDefaults = mergedDefaults.models[selectedModel];
|
|
65
|
-
const selectedModelConfig = config.models[selectedModel] ||
|
|
66
|
-
(providerDefaults
|
|
67
|
-
? { ...providerDefaults, apiKey: '' }
|
|
68
|
-
: {
|
|
69
|
-
apiKey: '',
|
|
70
|
-
baseURL: '',
|
|
71
|
-
model: '',
|
|
72
|
-
timeout: 0,
|
|
73
|
-
maxRetries: 0,
|
|
74
|
-
temperature: 0,
|
|
75
|
-
maxTokens: 0,
|
|
76
|
-
supportsFunctionCalling: true,
|
|
77
|
-
});
|
|
78
|
-
const hasApiKeyForModel = !(0, config_files_1.isMissingConfigValue)(selectedModelConfig.apiKey);
|
|
79
|
-
const keyHelpUrls = {
|
|
80
|
-
grok: 'https://console.x.ai/',
|
|
81
|
-
gpt: 'https://platform.openai.com/api-keys',
|
|
82
|
-
gemini: 'https://aistudio.google.com/app/apikey',
|
|
83
|
-
claude: 'https://console.anthropic.com/settings/keys',
|
|
84
|
-
};
|
|
85
|
-
if (!hasApiKeyForModel) {
|
|
86
|
-
const helpUrl = keyHelpUrls[selectedModel];
|
|
87
|
-
if (helpUrl) {
|
|
88
|
-
console.log(chalk_1.default.dim(helpUrl));
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
const apiKeyPrompt = hasApiKeyForModel
|
|
92
|
-
? `API key for ${selectedModel} (leave blank to keep current): `
|
|
93
|
-
: `API key for ${selectedModel} (optional, only needed for chat): `;
|
|
94
|
-
const apiKeyAnswer = await ask(apiKeyPrompt);
|
|
95
|
-
if (apiKeyAnswer) {
|
|
96
|
-
selectedModelConfig.apiKey = apiKeyAnswer;
|
|
97
|
-
}
|
|
98
|
-
config.models[selectedModel] = selectedModelConfig;
|
|
99
31
|
const currentKey = config.playlist?.privateKey || '';
|
|
100
32
|
const currentRole = config.playlist?.role || '';
|
|
101
33
|
let signingKey = currentKey;
|
|
@@ -202,7 +134,6 @@ exports.setupCommand = new commander_1.Command('setup')
|
|
|
202
134
|
prompt = null;
|
|
203
135
|
console.log(chalk_1.default.green('\nSetup complete'));
|
|
204
136
|
console.log(chalk_1.default.dim(` Config: ${configPath}`));
|
|
205
|
-
const hasApiKey = !(0, config_files_1.isMissingConfigValue)(config.models[selectedModel]?.apiKey);
|
|
206
137
|
const hasSigningKey = !(0, config_files_1.isMissingConfigValue)(config.playlist?.privateKey || '');
|
|
207
138
|
const hasDevice = (0, config_placeholders_1.configuredFF1Devices)(config.ff1Devices?.devices || []).length > 0;
|
|
208
139
|
if (!hasSigningKey || !hasDevice) {
|
|
@@ -214,10 +145,7 @@ exports.setupCommand = new commander_1.Command('setup')
|
|
|
214
145
|
console.log(chalk_1.default.yellow(' • Add an FF1 device host'));
|
|
215
146
|
}
|
|
216
147
|
}
|
|
217
|
-
|
|
218
|
-
console.log(chalk_1.default.dim(`\nTo use ff-cli chat, add an API key for ${selectedModel}`));
|
|
219
|
-
}
|
|
220
|
-
console.log(chalk_1.default.dim('\nRun: ff-cli play'));
|
|
148
|
+
console.log(chalk_1.default.dim('\nRun: ff-cli play <url-or-playlist>'));
|
|
221
149
|
}
|
|
222
150
|
catch (error) {
|
|
223
151
|
console.error(chalk_1.default.red('\nSetup failed:'), error.message);
|
|
@@ -22,14 +22,7 @@ exports.statusCommand = new commander_1.Command('status')
|
|
|
22
22
|
process.exit(1);
|
|
23
23
|
}
|
|
24
24
|
const config = await (0, config_files_1.readConfigFile)(configPath);
|
|
25
|
-
const modelNames = Object.keys(config.models || {});
|
|
26
|
-
const defaultModel = config.defaultModel && modelNames.includes(config.defaultModel)
|
|
27
|
-
? config.defaultModel
|
|
28
|
-
: modelNames[0];
|
|
29
|
-
const defaultModelLabel = defaultModel || 'unknown';
|
|
30
|
-
const defaultModelConfig = defaultModel ? config.models?.[defaultModel] : undefined;
|
|
31
25
|
const playlistConfig = (0, config_1.getPlaylistConfig)();
|
|
32
|
-
const hasApiKey = defaultModel ? !(0, config_files_1.isMissingConfigValue)(defaultModelConfig?.apiKey) : false;
|
|
33
26
|
const playlistKeyMaterial = playlistConfig.privateKey?.trim() || '';
|
|
34
27
|
const playlistKeyError = playlistKeyMaterial.length > 0 ? validatePlaylistPrivateKey(playlistKeyMaterial) : null;
|
|
35
28
|
const hasPlaylistSigningKey = playlistKeyMaterial.length > 0 && playlistKeyError === null;
|
|
@@ -51,12 +44,6 @@ exports.statusCommand = new commander_1.Command('status')
|
|
|
51
44
|
ok: true,
|
|
52
45
|
detail: configPath,
|
|
53
46
|
},
|
|
54
|
-
{
|
|
55
|
-
label: `Default model (${defaultModelLabel}) API key`,
|
|
56
|
-
ok: hasApiKey,
|
|
57
|
-
optional: true,
|
|
58
|
-
hint: ' (needed for chat)',
|
|
59
|
-
},
|
|
60
47
|
{
|
|
61
48
|
label: 'Playlist signing key',
|
|
62
49
|
ok: hasPlaylistSigningKey,
|
package/dist/src/config.js
CHANGED
|
@@ -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
|
|
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
|
-
*
|
|
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(
|
|
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
|
-
}
|