@feralfile/cli 1.2.0 → 1.3.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 CHANGED
@@ -70,6 +70,30 @@ Sources: Art Blocks, Objkt, fxhash (canonical `/gentk/...`, live `/iteration/{sl
70
70
  - **Mainstream PFPs** (Azuki, BAYC, Pudgy Penguins) build a single-item playlist instead of a series — Raster's curation is effectively the series allowlist.
71
71
  - **Indexer 502s on burst load** are intermittent ([ff-indexer-v2#82](https://github.com/feral-file/ff-indexer-v2/issues/82)) — retry clears or use `--limit N`.
72
72
 
73
+ ## Use with Claude Code
74
+
75
+ ff-cli ships a [Claude Code](https://docs.claude.com/en/docs/claude-code) skill at `skills/ff-control/SKILL.md`. Install it once:
76
+
77
+ ```bash
78
+ git clone --depth=1 https://github.com/feral-file/ff-cli /tmp/ff-cli \
79
+ && mkdir -p ~/.claude/skills \
80
+ && cp -r /tmp/ff-cli/skills/ff-control ~/.claude/skills/
81
+ ```
82
+
83
+ Claude Code will surface it when you ask to build a playlist, play an artwork or URL on an Art Computer, or publish to a feed. The skill validates config, builds, validates the playlist, and sends or publishes — reporting the failing command + exit code if anything breaks. Full prompt and exact flow: [`skills/ff-control/SKILL.md`](./skills/ff-control/SKILL.md).
84
+
85
+ ## Use with Codex
86
+
87
+ ff-cli ships the same `ff-control` skill in Codex's native skill format. Install it once:
88
+
89
+ ```bash
90
+ git clone --depth=1 https://github.com/feral-file/ff-cli /tmp/ff-cli \
91
+ && mkdir -p ~/.codex/skills \
92
+ && cp -r /tmp/ff-cli/skills/ff-control ~/.codex/skills/
93
+ ```
94
+
95
+ Codex will surface it when you ask to build a playlist, play an artwork or URL on an Art Computer, or publish to a feed. The installed skill is `skills/ff-control/SKILL.md`, so the execution flow stays defined in one place. Codex metadata and exact flow: [`skills/ff-control/agents/openai.yaml`](./skills/ff-control/agents/openai.yaml), [`skills/ff-control/SKILL.md`](./skills/ff-control/SKILL.md).
96
+
73
97
  ## Dev Quick Start
74
98
 
75
99
  **Set your LLM API key first (default Claude):** `export ANTHROPIC_API_KEY='sk-ant-your-api-key-here'`
package/dist/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@feralfile/cli",
3
- "version": "1.2.0",
3
+ "version": "1.3.1",
4
4
  "description": "CLI for building DP-1 playlists of digital art using AI (Claude, Grok, ChatGPT, Gemini)",
5
5
  "repository": {
6
6
  "type": "git",
@@ -49,6 +49,7 @@ const objkt_marketplace_1 = require("../utilities/objkt-marketplace");
49
49
  const ab_marketplace_1 = require("../utilities/ab-marketplace");
50
50
  const fxhash_marketplace_1 = require("../utilities/fxhash-marketplace");
51
51
  const neort_marketplace_1 = require("../utilities/neort-marketplace");
52
+ const verse_marketplace_1 = require("../utilities/verse-marketplace");
52
53
  const raster_client_1 = require("../utilities/raster-client");
53
54
  const ff1_device_1 = require("../utilities/ff1-device");
54
55
  // nft-indexer + playlist-builder are still CommonJS; require keeps interop simple.
@@ -66,7 +67,7 @@ const DP1_MAX_ITEMS = 1024;
66
67
  const RASTER_PAGE_SIZE = 100;
67
68
  exports.findCommand = new commander_1.Command('find')
68
69
  .description('Find an artwork on the web and build a DP-1 playlist')
69
- .argument('<input>', 'URL (Objkt / fxhash / Art Blocks / OpenSea / SuperRare / Feral File / Neort), `ethereum:{contract}:{tokenId}`, `tezos:{contract}:{tokenId}`, or a wallet address')
70
+ .argument('<input>', 'URL (Objkt / fxhash / Art Blocks / OpenSea / SuperRare / Feral File / Neort / Verse), `ethereum:{contract}:{tokenId}`, `tezos:{contract}:{tokenId}`, or a wallet address')
70
71
  .option('-o, --output <path>', 'Save the playlist to this file (default: ./<slug>.json)')
71
72
  .option('-l, --limit <n>', 'Max tokens to include from the series (default: all)')
72
73
  .option('-p, --play', 'Play the playlist on an FF1 device after building')
@@ -81,7 +82,7 @@ exports.findCommand = new commander_1.Command('find')
81
82
  const parsed = (0, marketplace_url_1.parseFindInput)(input);
82
83
  if (!parsed) {
83
84
  console.error(chalk_1.default.red('Could not understand input.'));
84
- console.error(chalk_1.default.dim('Supported URLs: Objkt, fxhash, Art Blocks, OpenSea, SuperRare, Feral File, Neort. ' +
85
+ console.error(chalk_1.default.dim('Supported URLs: Objkt, fxhash, Art Blocks, OpenSea, SuperRare, Feral File, Neort, Verse. ' +
85
86
  'Or: `ethereum:{contract}:{tokenId}`, `tezos:{contract}:{tokenId}`, ' +
86
87
  'or a wallet address (`0x...` / `tz1.../tz2.../tz3...`).'));
87
88
  process.exit(1);
@@ -190,6 +191,10 @@ async function resolveTarget(parsed, skipPrompt) {
190
191
  const coords = await (0, fxhash_marketplace_1.resolveFxhashProject)(parsed.slug);
191
192
  return resolveCoords(coords);
192
193
  }
194
+ if (parsed.kind === 'verse-series') {
195
+ const coords = await (0, verse_marketplace_1.resolveVerseSeries)(parsed.slug);
196
+ return resolveCoords(coords);
197
+ }
193
198
  if (parsed.kind === 'address') {
194
199
  const artistId = await (0, raster_client_1.resolveAddressToArtist)(parsed.address);
195
200
  if (artistId === null) {
@@ -1,24 +1,13 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.isMissingConfigValue = isMissingConfigValue;
3
+ exports.isMissingConfigValue = void 0;
4
4
  exports.readConfigFile = readConfigFile;
5
5
  exports.resolveExistingConfigPath = resolveExistingConfigPath;
6
6
  exports.ensureConfigFile = ensureConfigFile;
7
7
  const fs_1 = require("fs");
8
8
  const config_1 = require("../../config");
9
- // Treat any value containing the YOUR_/your_ placeholder pattern as missing,
10
- // since createSampleConfig writes those literal placeholders into new files
11
- // and we should not let the user proceed with an unfilled value.
12
- const placeholderPattern = /YOUR_|your_/;
13
- /**
14
- * Whether a config value is unset or still holds a sample placeholder.
15
- */
16
- function isMissingConfigValue(value) {
17
- if (!value) {
18
- return true;
19
- }
20
- return placeholderPattern.test(value);
21
- }
9
+ var config_placeholders_1 = require("../../utilities/config-placeholders");
10
+ Object.defineProperty(exports, "isMissingConfigValue", { enumerable: true, get: function () { return config_placeholders_1.isMissingConfigValue; } });
22
11
  /**
23
12
  * Read and parse a config.json file at the given path.
24
13
  */
@@ -14,6 +14,7 @@ const config_1 = require("../config");
14
14
  const config_files_1 = require("./helpers/config-files");
15
15
  const device_discovery_1 = require("./helpers/device-discovery");
16
16
  const prompt_1 = require("./helpers/prompt");
17
+ const config_placeholders_1 = require("../utilities/config-placeholders");
17
18
  const playlist_signing_role_1 = require("../utilities/playlist-signing-role");
18
19
  exports.setupCommand = new commander_1.Command('setup')
19
20
  .description('Guided setup for config, signing key, and device')
@@ -139,7 +140,12 @@ exports.setupCommand = new commander_1.Command('setup')
139
140
  role: signingRole,
140
141
  };
141
142
  }
142
- const existingDevices = config.ff1Devices?.devices || [];
143
+ const configuredDevices = (0, config_placeholders_1.configuredFF1Devices)(config.ff1Devices?.devices || []);
144
+ const existingDevices = configuredDevices;
145
+ // Drop bundled example rows before discovery. Otherwise setup sees the
146
+ // sample placeholder as an existing device and lets users skip the device
147
+ // prompt without ever configuring a usable FF1 host.
148
+ config.ff1Devices = { devices: configuredDevices };
143
149
  if (existingDevices.length > 0) {
144
150
  console.log(chalk_1.default.dim(`\nConfigured devices: ${existingDevices.map((d) => d.name || d.host).join(', ')}`));
145
151
  }
@@ -198,7 +204,7 @@ exports.setupCommand = new commander_1.Command('setup')
198
204
  console.log(chalk_1.default.dim(` Config: ${configPath}`));
199
205
  const hasApiKey = !(0, config_files_1.isMissingConfigValue)(config.models[selectedModel]?.apiKey);
200
206
  const hasSigningKey = !(0, config_files_1.isMissingConfigValue)(config.playlist?.privateKey || '');
201
- const hasDevice = Boolean(config.ff1Devices?.devices?.[0]?.host);
207
+ const hasDevice = (0, config_placeholders_1.configuredFF1Devices)(config.ff1Devices?.devices || []).length > 0;
202
208
  if (!hasSigningKey || !hasDevice) {
203
209
  console.log(chalk_1.default.yellow('\nNext steps:'));
204
210
  if (!hasSigningKey) {
@@ -10,6 +10,7 @@ const config_files_1 = require("./helpers/config-files");
10
10
  const config_1 = require("../config");
11
11
  const ed25519_key_derive_1 = require("../utilities/ed25519-key-derive");
12
12
  const playlist_signing_role_1 = require("../utilities/playlist-signing-role");
13
+ const config_placeholders_1 = require("../utilities/config-placeholders");
13
14
  exports.statusCommand = new commander_1.Command('status')
14
15
  .description('Show configuration status')
15
16
  .action(async () => {
@@ -32,6 +33,7 @@ exports.statusCommand = new commander_1.Command('status')
32
33
  const playlistKeyMaterial = playlistConfig.privateKey?.trim() || '';
33
34
  const playlistKeyError = playlistKeyMaterial.length > 0 ? validatePlaylistPrivateKey(playlistKeyMaterial) : null;
34
35
  const hasPlaylistSigningKey = playlistKeyMaterial.length > 0 && playlistKeyError === null;
36
+ const configuredDevices = (0, config_placeholders_1.configuredFF1Devices)(config.ff1Devices?.devices || []);
35
37
  let hasValidPlaylistRole = false;
36
38
  let playlistRoleDetail;
37
39
  let playlistRoleError;
@@ -75,12 +77,10 @@ exports.statusCommand = new commander_1.Command('status')
75
77
  hint: ' (used when signing playlists)',
76
78
  },
77
79
  {
78
- label: `FF1 devices (${config.ff1Devices?.devices?.length || 0})`,
79
- ok: (config.ff1Devices?.devices?.length || 0) > 0 &&
80
- (config.ff1Devices?.devices || []).every((d) => !(0, config_files_1.isMissingConfigValue)(d.host)),
81
- detail: (config.ff1Devices?.devices || [])
82
- .map((d) => `${d.name || 'unnamed'} → ${d.host}`)
83
- .join(', ') || undefined,
80
+ label: `FF1 devices (${configuredDevices.length})`,
81
+ ok: configuredDevices.length > 0,
82
+ detail: configuredDevices.map((d) => `${d.name || 'unnamed'} → ${d.host}`).join(', ') ||
83
+ undefined,
84
84
  },
85
85
  ];
86
86
  console.log(chalk_1.default.blue('\n🔎 FF1 Status\n'));
@@ -18,6 +18,7 @@ const fs_1 = __importDefault(require("fs"));
18
18
  const path_1 = __importDefault(require("path"));
19
19
  const os_1 = __importDefault(require("os"));
20
20
  const playlist_signing_role_1 = require("./utilities/playlist-signing-role");
21
+ const config_placeholders_1 = require("./utilities/config-placeholders");
21
22
  // One-shot legacy config migration: copy `$XDG_CONFIG_HOME/ff1/config.json` to
22
23
  // `$XDG_CONFIG_HOME/ff-cli/config.json` on first read after upgrading from
23
24
  // `ff1-cli`. The check is cheap (two fs.existsSync calls) and the
@@ -285,7 +286,7 @@ function getFF1DeviceConfig() {
285
286
  const config = getConfig();
286
287
  const ff1Devices = config.ff1Devices || { devices: [] };
287
288
  return {
288
- devices: ff1Devices.devices || [],
289
+ devices: (0, config_placeholders_1.configuredFF1Devices)(ff1Devices.devices || []),
289
290
  };
290
291
  }
291
292
  /**
@@ -338,7 +339,7 @@ function validateConfig(modelName) {
338
339
  return { valid: false, errors };
339
340
  }
340
341
  const modelConfig = config.models[selectedModel];
341
- if (!modelConfig.apiKey || modelConfig.apiKey === 'your_api_key_here') {
342
+ if ((0, config_placeholders_1.isMissingConfigValue)(modelConfig.apiKey)) {
342
343
  errors.push(`API key for "${selectedModel}" is missing or not configured`);
343
344
  }
344
345
  if (!modelConfig.baseURL) {
@@ -0,0 +1,34 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.isMissingConfigValue = isMissingConfigValue;
4
+ exports.configuredFF1Devices = configuredFF1Devices;
5
+ /**
6
+ * configPlaceholders identifies values copied from config.json.example.
7
+ *
8
+ * The sample file is useful for manual editing, but runtime flows must treat
9
+ * those literal placeholders as unset so setup cannot mistake example rows for
10
+ * configured devices.
11
+ */
12
+ const CONFIG_PLACEHOLDER_PATTERN = /YOUR_|your_/;
13
+ /**
14
+ * isMissingConfigValue reports whether a config string is empty or still a
15
+ * sample placeholder.
16
+ *
17
+ * @param {string | null | undefined} value - Config value to inspect.
18
+ * @returns {boolean} True when the value should be treated as unset.
19
+ */
20
+ function isMissingConfigValue(value) {
21
+ if (!value) {
22
+ return true;
23
+ }
24
+ return CONFIG_PLACEHOLDER_PATTERN.test(value);
25
+ }
26
+ /**
27
+ * configuredFF1Devices returns only device rows with a real host.
28
+ *
29
+ * @param {T[]} devices - Device rows from config.json.
30
+ * @returns {T[]} Rows that can be used for FF1 network operations.
31
+ */
32
+ function configuredFF1Devices(devices = []) {
33
+ return devices.filter((device) => !isMissingConfigValue(device.host));
34
+ }
@@ -3,7 +3,7 @@
3
3
  * Marketplace URL parser for `ff-cli find`.
4
4
  *
5
5
  * Accepts user input in any of these forms and returns a normalized result:
6
- * - Marketplace token URL (Objkt, fxhash, Art Blocks, OpenSea, SuperRare, Feral File, Neort)
6
+ * - Marketplace token URL (Objkt, fxhash, Art Blocks, OpenSea, SuperRare, Feral File, Neort, Verse)
7
7
  * - Raw on-chain coordinates: "ethereum:0xabc...:123" / "tezos:KT1...:456"
8
8
  * - Wallet address: 0x{40-hex} (Ethereum) or tz1/tz2/tz3{33} (Tezos)
9
9
  *
@@ -25,6 +25,7 @@ exports.parseFxhash = parseFxhash;
25
25
  exports.parseOpenSea = parseOpenSea;
26
26
  exports.parseSuperRare = parseSuperRare;
27
27
  exports.parseNeort = parseNeort;
28
+ exports.parseVerse = parseVerse;
28
29
  exports.parseFeralFile = parseFeralFile;
29
30
  const ETH_ADDR = /^0x[a-fA-F0-9]{40}$/;
30
31
  const TEZOS_ADDR = /^(tz1|tz2|tz3)[1-9A-HJ-NP-Za-km-z]{33}$/;
@@ -97,6 +98,9 @@ function parseMarketplaceUrl(url) {
97
98
  if (host === 'neort.io' || host.endsWith('.neort.io')) {
98
99
  return parseNeort(url);
99
100
  }
101
+ if (host === 'verse.works' || host.endsWith('.verse.works')) {
102
+ return parseVerse(url);
103
+ }
100
104
  return null;
101
105
  }
102
106
  /**
@@ -337,6 +341,43 @@ function parseNeort(url) {
337
341
  reason: `Neort URL not recognized: ${url.pathname}. Expected /art/{id}.`,
338
342
  };
339
343
  }
344
+ /**
345
+ * Verse URL forms:
346
+ * verse.works/items/ethereum/{contract}/{tokenId} — direct Ethereum token
347
+ * verse.works/series/{slug} — series page
348
+ *
349
+ * Verse item pages expose the on-chain coordinates directly in the path. Series
350
+ * pages do not include a JSON API in the public URL, but they render edition
351
+ * links with the same `/items/ethereum/...` shape; the async resolver fetches
352
+ * the series page and extracts a representative token for Raster enumeration.
353
+ */
354
+ function parseVerse(url) {
355
+ let m = /^\/items\/ethereum\/(0x[a-fA-F0-9]{40})\/(\d+)\/?$/.exec(url.pathname);
356
+ if (m) {
357
+ return {
358
+ kind: 'token',
359
+ source: 'verse',
360
+ coords: { chain: 'ethereum', contract: m[1].toLowerCase(), tokenId: m[2] },
361
+ };
362
+ }
363
+ m = /^\/items\/([^/]+)\//.exec(url.pathname);
364
+ if (m) {
365
+ return {
366
+ kind: 'unsupported',
367
+ reason: `Verse ${m[1]} item URLs are not supported — ff-cli covers Ethereum and Tezos mainnet only. ` +
368
+ 'Paste an Ethereum Verse item URL or raw `ethereum:{contract}:{tokenId}` coordinates.',
369
+ };
370
+ }
371
+ m = /^\/series\/([A-Za-z0-9][A-Za-z0-9_-]*)\/?$/.exec(url.pathname);
372
+ if (m) {
373
+ return { kind: 'verse-series', slug: m[1] };
374
+ }
375
+ return {
376
+ kind: 'unsupported',
377
+ reason: `Verse URL not recognized: ${url.pathname}. Expected ` +
378
+ '/items/ethereum/{contract}/{tokenId} or /series/{slug}.',
379
+ };
380
+ }
340
381
  /**
341
382
  * Feral File URL forms (source of truth: feralfile-client app-routing
342
383
  * + collection-routing modules):
@@ -318,12 +318,14 @@ function mapIndexerDataToStandardFormat(indexerData, chain) {
318
318
  const artistName = extractArtistName(display.artists);
319
319
  const name = display.name || `Token #${indexerData.token_number}`;
320
320
  const description = display.description || '';
321
+ const standard = detectTokenStandard(chain, indexerData.contract_address);
321
322
  return {
322
323
  success: true,
323
324
  token: {
324
325
  chain,
325
326
  contractAddress: indexerData.contract_address,
326
327
  tokenId: indexerData.token_number,
328
+ standard,
327
329
  name,
328
330
  description,
329
331
  image: {
@@ -440,7 +442,7 @@ function convertToDP1Item(tokenData, duration = 10) {
440
442
  type: 'onChain',
441
443
  contract: {
442
444
  chain: chainMap[token.chain.toLowerCase()] || 'other',
443
- standard: 'other',
445
+ standard: token.standard || detectTokenStandard(token.chain, token.contractAddress),
444
446
  address: token.contractAddress,
445
447
  tokenId: String(token.tokenId),
446
448
  },
@@ -0,0 +1,33 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.resolveVerseSeries = resolveVerseSeries;
4
+ /**
5
+ * Resolve a Verse series slug to representative Ethereum token coordinates.
6
+ *
7
+ * Verse series pages render edition links as
8
+ * `/items/ethereum/{contract}/{tokenId}`. Using the first rendered edition
9
+ * keeps the resolver independent of private frontend state while still handing
10
+ * Raster a token it can use to enumerate the full series.
11
+ *
12
+ * @param slug - Verse series slug from `/series/{slug}`
13
+ * @returns Representative Ethereum token coordinates
14
+ * @throws Error when the page cannot be fetched or no Ethereum edition link is present
15
+ */
16
+ async function resolveVerseSeries(slug) {
17
+ const url = `https://verse.works/series/${encodeURIComponent(slug)}`;
18
+ const response = await fetch(url);
19
+ if (!response.ok) {
20
+ throw new Error(`Verse series lookup failed for ${slug}: ${response.status}`);
21
+ }
22
+ const html = await response.text();
23
+ const match = /\/items\/ethereum\/(0x[a-fA-F0-9]{40})\/(\d+)/.exec(html);
24
+ if (!match) {
25
+ throw new Error(`Verse series ${slug} did not expose an Ethereum edition link. ` +
26
+ 'Paste a specific Verse item URL or raw `ethereum:{contract}:{tokenId}` coordinates.');
27
+ }
28
+ return {
29
+ chain: 'ethereum',
30
+ contract: match[1].toLowerCase(),
31
+ tokenId: match[2],
32
+ };
33
+ }
package/docs/README.md CHANGED
@@ -18,7 +18,7 @@ npm i -g @feralfile/cli
18
18
  curl -fsSL https://feralfile.com/ff-cli-install | bash
19
19
  ```
20
20
 
21
- Installs a prebuilt binary for macOS/Linux (no Node.js required).
21
+ Installs a prebuilt bundle for macOS/Linux. Node.js 22 or newer must still be available in `PATH`; the bundle avoids a global npm install, not the Node runtime requirement.
22
22
 
23
23
  ## Configure
24
24
 
@@ -146,6 +146,10 @@ Notes:
146
146
  - Options: `-k, --key <privateKey>`, `-r, --role <role>`, `-o, --output <file>`
147
147
  - `play <source>` – Play a playlist file, playlist URL, or media URL on an FF1 device (runs `verify` before sending; only the CLI-synthesized media URL fallback is auto-signed when a signing key is configured; use `--skip-verify` to bypass the gate)
148
148
  - Options: `-d, --device <name>`, `--skip-verify` (skip signature verification; not recommended)
149
+ - `find <input>` – Resolve a marketplace URL, raw `chain:contract:tokenId`, or wallet address into a playable DP-1 playlist
150
+ - Sources: Art Blocks, Objkt, fxhash, OpenSea, SuperRare, Feral File, Neort, Verse, raw on-chain coordinates, and wallet addresses
151
+ - Verse forms: `/items/ethereum/{contract}/{tokenId}` and `/series/{slug}`
152
+ - Options: `-o, --output <path>`, `-l, --limit <n>`, `-p, --play`, `-d, --device <name>`, `--publish`, `-s, --server <index>`, `-y, --yes`, `--skip-verify`
149
153
  - `publish <file>` – Publish a playlist to a feed server (runs `verify` before upload and rejects unsigned or broken playlists)
150
154
  - Options: `-s, --server <index>` (server index if multiple configured)
151
155
  - `ssh <enable|disable>` – Manage SSH access on an FF1 device
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@feralfile/cli",
3
- "version": "1.2.0",
3
+ "version": "1.3.1",
4
4
  "description": "CLI for building DP-1 playlists of digital art using AI (Claude, Grok, ChatGPT, Gemini)",
5
5
  "repository": {
6
6
  "type": "git",