@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 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 turns a simple prompt into a DP-1–conformant playlist you can preview on an Art Computer. The model orchestrates; deterministic tools do the real work (schema validation, indexing, JSON‑LD). If something comes back invalid, validation rejects it and we loop until it’s right.
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 chat
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 chat
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 -- chat
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`, sets `ANTHROPIC_API_KEY=dummy`, and runs the repo-wide verification entrypoint:
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
- ANTHROPIC_API_KEY=dummy npm run verify
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.
@@ -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 chat\n\nDocs: https://github.com/feral-file/ff-cli\n`);
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": "1.5.0",
4
- "description": "CLI for building DP-1 playlists of digital art using AI (Claude, Grok, ChatGPT, Gemini)",
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
- "grok",
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
- console.log(chalk_1.default.bold('\nAvailable models:\n'));
31
- const models = (0, config_1.listAvailableModels)();
32
- models.forEach((modelName) => {
33
- const modelConfig = config.models[modelName];
34
- const isCurrent = modelName === config.defaultModel;
35
- console.log(` ${isCurrent ? chalk_1.default.green('→') : ' '} ${chalk_1.default.bold(modelName)}`);
36
- console.log(` API key: ${modelConfig.apiKey && modelConfig.apiKey !== 'your_api_key_here' ? chalk_1.default.green('Set') : chalk_1.default.red('Missing')}`);
37
- console.log(` Base URL: ${chalk_1.default.dim(modelConfig.baseURL)}`);
38
- console.log(` Model: ${chalk_1.default.dim(modelConfig.model)}`);
39
- console.log(` Function calling: ${modelConfig.supportsFunctionCalling ? chalk_1.default.green('Supported') : chalk_1.default.red('Not supported')}`);
40
- console.log();
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
- const items = await getNFTTokenInfoBatch(tokens.map((t) => ({
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
- if (!hasApiKey) {
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,