@feralfile/cli 1.5.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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/find.js +55 -4
- package/dist/src/commands/play.js +55 -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/marketplace-url.js +25 -0
- package/dist/src/utilities/playlist-builder.js +63 -16
- package/dist/src/utilities/raster-client.js +30 -1
- package/docs/CONFIGURATION.md +0 -34
- 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.0",
|
|
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,6 +37,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
37
37
|
};
|
|
38
38
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
39
39
|
exports.findCommand = void 0;
|
|
40
|
+
exports.buildRasterMediaItems = buildRasterMediaItems;
|
|
40
41
|
exports.parseLimitOption = parseLimitOption;
|
|
41
42
|
exports.decideActions = decideActions;
|
|
42
43
|
const commander_1 = require("commander");
|
|
@@ -68,7 +69,7 @@ const DP1_MAX_ITEMS = 1024;
|
|
|
68
69
|
const RASTER_PAGE_SIZE = 100;
|
|
69
70
|
exports.findCommand = new commander_1.Command('find')
|
|
70
71
|
.description('Find an artwork on the web and build a DP-1 playlist')
|
|
71
|
-
.argument('<input>', 'URL (Objkt / fxhash / Art Blocks / OpenSea / SuperRare / Feral File / Neort / Verse), `ethereum:{contract}:{tokenId}`, `tezos:{contract}:{tokenId}`, or a wallet address')
|
|
72
|
+
.argument('<input>', 'URL (Objkt / fxhash / Art Blocks / OpenSea / SuperRare / Feral File / Neort / Verse / Raster), `ethereum:{contract}:{tokenId}`, `tezos:{contract}:{tokenId}`, or a wallet address')
|
|
72
73
|
.option('-o, --output <path>', 'Save the playlist to this file (default: ./<slug>.json)')
|
|
73
74
|
.option('-l, --limit <n>', 'Max tokens to include from the series (default: all)')
|
|
74
75
|
.option('-p, --play', 'Play the playlist on an FF1 device after building')
|
|
@@ -83,7 +84,7 @@ exports.findCommand = new commander_1.Command('find')
|
|
|
83
84
|
const parsed = (0, marketplace_url_1.parseFindInput)(input);
|
|
84
85
|
if (!parsed) {
|
|
85
86
|
console.error(chalk_1.default.red('Could not understand input.'));
|
|
86
|
-
console.error(chalk_1.default.dim('Supported URLs: Objkt, fxhash, Art Blocks, OpenSea, SuperRare, Feral File, Neort, Verse. ' +
|
|
87
|
+
console.error(chalk_1.default.dim('Supported URLs: Objkt, fxhash, Art Blocks, OpenSea, SuperRare, Feral File, Neort, Verse, Raster. ' +
|
|
87
88
|
'Or: `ethereum:{contract}:{tokenId}`, `tezos:{contract}:{tokenId}`, ' +
|
|
88
89
|
'or a wallet address (`0x...` / `tz1.../tz2.../tz3...`).'));
|
|
89
90
|
process.exit(1);
|
|
@@ -125,7 +126,7 @@ exports.findCommand = new commander_1.Command('find')
|
|
|
125
126
|
seriesHasMore = fetched.hasMore;
|
|
126
127
|
}
|
|
127
128
|
else {
|
|
128
|
-
tokens = [target.coords];
|
|
129
|
+
tokens = [{ ...target.coords, mediaUrl: null, mediaType: null }];
|
|
129
130
|
}
|
|
130
131
|
if (tokens.length === 0) {
|
|
131
132
|
console.error(chalk_1.default.red('Series has no tokens on supported chains (Ethereum + Tezos mainnet).'));
|
|
@@ -148,11 +149,25 @@ exports.findCommand = new commander_1.Command('find')
|
|
|
148
149
|
// display seconds), not concurrency — concurrency is hardcoded inside.
|
|
149
150
|
// Omit it for auto timing: video/audio items carry no duration and play
|
|
150
151
|
// their natural length (DP-1 §4.1); static items get the config default.
|
|
151
|
-
|
|
152
|
+
let items = await getNFTTokenInfoBatch(tokens.map((t) => ({
|
|
152
153
|
chain: t.chain,
|
|
153
154
|
contractAddress: t.contract,
|
|
154
155
|
tokenId: t.tokenId,
|
|
155
156
|
})));
|
|
157
|
+
// FF-indexer bypass for Raster-minted tokens. The indexer doesn't carry
|
|
158
|
+
// Raster's tokens, so it returns nothing — but Raster handed us each
|
|
159
|
+
// token's playable `media.contentUrl`. Build DP-1 items straight from
|
|
160
|
+
// that (off-chain provenance, same shape as the Neort path), so the
|
|
161
|
+
// series still builds into a signable, verifiable playlist instead of
|
|
162
|
+
// failing the way it did before. The indexer is tried first so tokens
|
|
163
|
+
// it *can* resolve keep their richer on-chain provenance.
|
|
164
|
+
if (!Array.isArray(items) || items.length === 0) {
|
|
165
|
+
const rasterItems = buildRasterMediaItems(tokens, target);
|
|
166
|
+
if (rasterItems.length > 0) {
|
|
167
|
+
console.log(chalk_1.default.dim(` FF indexer returned nothing; building ${rasterItems.length} item${rasterItems.length === 1 ? '' : 's'} directly from Raster media.`));
|
|
168
|
+
items = rasterItems;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
156
171
|
if (!Array.isArray(items) || items.length === 0) {
|
|
157
172
|
console.error(chalk_1.default.red('No tokens could be indexed; cannot build playlist.'));
|
|
158
173
|
console.error(chalk_1.default.dim(' The FF indexer may be under load when warming a series of previously-unseen tokens.'));
|
|
@@ -217,6 +232,14 @@ async function resolveTarget(parsed, skipPrompt) {
|
|
|
217
232
|
const coords = await (0, verse_marketplace_1.resolveVerseSeries)(parsed.slug);
|
|
218
233
|
return resolveCoords(coords);
|
|
219
234
|
}
|
|
235
|
+
if (parsed.kind === 'raster-artwork') {
|
|
236
|
+
const summary = await (0, raster_client_1.resolveSlugToArtwork)(parsed.slug);
|
|
237
|
+
if (summary === null) {
|
|
238
|
+
throw new Error(`No artwork found on Raster for slug "${parsed.slug}". ` +
|
|
239
|
+
'Check the URL — Raster artwork URLs are raster.art/artwork/{slug}.');
|
|
240
|
+
}
|
|
241
|
+
return { kind: 'series', summary };
|
|
242
|
+
}
|
|
220
243
|
if (parsed.kind === 'address') {
|
|
221
244
|
const artist = await (0, raster_client_1.resolveAddressToArtist)(parsed.address);
|
|
222
245
|
if (artist === null) {
|
|
@@ -259,6 +282,32 @@ function playlistTitleFor(target, items) {
|
|
|
259
282
|
}
|
|
260
283
|
return items[0]?.title ?? `Token ${target.coords.tokenId}`;
|
|
261
284
|
}
|
|
285
|
+
/**
|
|
286
|
+
* Build DP-1 items directly from Raster's per-token media, bypassing the FF
|
|
287
|
+
* indexer. Used as a fallback when the indexer resolves nothing — tokens
|
|
288
|
+
* without a Raster `mediaUrl` are dropped (nothing playable to point at).
|
|
289
|
+
*
|
|
290
|
+
* Item shape matches the Neort off-chain path (`buildUrlItem` →
|
|
291
|
+
* `provenance.offChainURI`), so the resulting playlist signs and verifies
|
|
292
|
+
* normally. Titles are "<Series> #<tokenId>" for series, falling back to the
|
|
293
|
+
* series/coords title for a single token.
|
|
294
|
+
*/
|
|
295
|
+
function buildRasterMediaItems(tokens, target) {
|
|
296
|
+
const seriesTitle = target.kind === 'series' ? target.summary.title : undefined;
|
|
297
|
+
const items = [];
|
|
298
|
+
for (const t of tokens) {
|
|
299
|
+
if (!t.mediaUrl) {
|
|
300
|
+
continue;
|
|
301
|
+
}
|
|
302
|
+
const title = seriesTitle ? `${seriesTitle} #${t.tokenId}` : `Token ${t.tokenId}`;
|
|
303
|
+
// No explicit duration: auto timing (video/audio play their natural
|
|
304
|
+
// length). `mediaType` carries Raster's type so an extensionless IPFS
|
|
305
|
+
// contentUrl is still classified correctly — otherwise a video would be
|
|
306
|
+
// stamped a fixed duration and play as a still.
|
|
307
|
+
items.push(buildUrlItem(t.mediaUrl, undefined, { title, mimeType: t.mediaType ?? undefined }));
|
|
308
|
+
}
|
|
309
|
+
return items;
|
|
310
|
+
}
|
|
262
311
|
function parseLimitOption(limitStr) {
|
|
263
312
|
if (limitStr === undefined) {
|
|
264
313
|
return Number.POSITIVE_INFINITY;
|
|
@@ -300,6 +349,8 @@ async function fetchTokens(summary, limit) {
|
|
|
300
349
|
chain: t.chain,
|
|
301
350
|
contract: t.contractAddress,
|
|
302
351
|
tokenId: t.tokenId,
|
|
352
|
+
mediaUrl: t.mediaUrl,
|
|
353
|
+
mediaType: t.mediaType,
|
|
303
354
|
});
|
|
304
355
|
}
|
|
305
356
|
if (hasMore || page.nextCursor === null) {
|
|
@@ -37,16 +37,60 @@ 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
|
+
function describeDiscoveryInput(source) {
|
|
64
|
+
const parsed = (0, marketplace_url_1.parseFindInput)(source);
|
|
65
|
+
if (!parsed || parsed.kind === 'unsupported') {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
switch (parsed.kind) {
|
|
69
|
+
case 'token':
|
|
70
|
+
return 'an on-chain token reference';
|
|
71
|
+
case 'address':
|
|
72
|
+
return 'a wallet address';
|
|
73
|
+
case 'ff-url':
|
|
74
|
+
return 'a Feral File URL';
|
|
75
|
+
case 'objkt-alias':
|
|
76
|
+
return 'an Objkt token';
|
|
77
|
+
case 'ab-collection':
|
|
78
|
+
return 'an Art Blocks collection';
|
|
79
|
+
case 'os-collection':
|
|
80
|
+
return 'an OpenSea collection';
|
|
81
|
+
case 'fxhash-iteration':
|
|
82
|
+
case 'fxhash-project':
|
|
83
|
+
return 'an fxhash work';
|
|
84
|
+
case 'neort-art':
|
|
85
|
+
return 'a Neort work';
|
|
86
|
+
case 'verse-series':
|
|
87
|
+
return 'a Verse series';
|
|
88
|
+
case 'raster-artwork':
|
|
89
|
+
return 'a Raster artwork';
|
|
90
|
+
default:
|
|
91
|
+
return 'an artwork reference';
|
|
92
|
+
}
|
|
93
|
+
}
|
|
50
94
|
exports.playCommand = new commander_1.Command('play')
|
|
51
95
|
.description('Play a playlist or media URL on an FF1 device')
|
|
52
96
|
.argument('<source>', 'Playlist file, playlist URL, or media URL')
|
|
@@ -54,6 +98,17 @@ exports.playCommand = new commander_1.Command('play')
|
|
|
54
98
|
.option('--skip-verify', 'Skip signature verification and auto-sign gating before playing; use only when you accept malformed envelopes')
|
|
55
99
|
.action(async (source, options) => {
|
|
56
100
|
try {
|
|
101
|
+
// `play` casts an exact playable source. A marketplace URL / on-chain
|
|
102
|
+
// coords / wallet must be resolved through the indexer first — redirect
|
|
103
|
+
// to `find` rather than silently wrapping the *page* as a web item.
|
|
104
|
+
const discovery = describeDiscoveryInput(source);
|
|
105
|
+
if (discovery) {
|
|
106
|
+
console.error(chalk_1.default.red('\n`play` casts an exact playlist or media URL — it does not search marketplaces.'));
|
|
107
|
+
console.error(chalk_1.default.dim(` "${source}" looks like ${discovery} to resolve first.`));
|
|
108
|
+
const deviceFlag = options.device ? ` -d "${options.device}"` : '';
|
|
109
|
+
console.log(chalk_1.default.yellow(`\nTry: ff-cli find "${source}" --play${deviceFlag}`));
|
|
110
|
+
process.exit(1);
|
|
111
|
+
}
|
|
57
112
|
const config = (0, config_1.getConfig)();
|
|
58
113
|
// No explicit duration: a direct video/audio URL plays its natural
|
|
59
114
|
// 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,
|