@feralfile/cli 1.4.0 → 1.5.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/dist/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@feralfile/cli",
3
- "version": "1.4.0",
3
+ "version": "1.5.0",
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",
@@ -109,24 +109,40 @@ exports.findCommand = new commander_1.Command('find')
109
109
  }
110
110
  console.log();
111
111
  const userLimit = parseLimitOption(options.limit);
112
- const seriesTotal = target.kind === 'series' ? target.summary.tokenCount : 1;
113
112
  // Implicit cap: no --limit + series > 1024 → cap to DP-1 max with a
114
113
  // clear warning. Explicit `--limit > 1024` was already rejected by
115
114
  // parseLimitOption, so reaching here means either capped-by-default or
116
115
  // the user picked a value within spec.
117
116
  const limit = Math.min(userLimit, DP1_MAX_ITEMS);
118
- if (userLimit === Number.POSITIVE_INFINITY && seriesTotal > DP1_MAX_ITEMS) {
119
- console.log(chalk_1.default.yellow(`Series has ${seriesTotal} tokens; DP-1 caps playlists at ${DP1_MAX_ITEMS}. ` +
117
+ // Raster's GraphQL API exposes no series token counts, so enumerate
118
+ // tokens first (up to the effective limit) and confirm with the real
119
+ // number afterward. `hasMore` means the series continues past `limit`.
120
+ let tokens;
121
+ let seriesHasMore = false;
122
+ if (target.kind === 'series') {
123
+ const fetched = await fetchTokens(target.summary, limit);
124
+ tokens = fetched.tokens;
125
+ seriesHasMore = fetched.hasMore;
126
+ }
127
+ else {
128
+ tokens = [target.coords];
129
+ }
130
+ if (tokens.length === 0) {
131
+ console.error(chalk_1.default.red('Series has no tokens on supported chains (Ethereum + Tezos mainnet).'));
132
+ process.exit(1);
133
+ }
134
+ if (userLimit === Number.POSITIVE_INFINITY && seriesHasMore) {
135
+ console.log(chalk_1.default.yellow(`Series has more than ${DP1_MAX_ITEMS} tokens; DP-1 caps playlists at ${DP1_MAX_ITEMS}. ` +
120
136
  `Building with the first ${DP1_MAX_ITEMS} — pass \`--limit N\` (≤ ${DP1_MAX_ITEMS}) for fewer.`));
121
137
  console.log();
122
138
  }
123
- const willInclude = Math.min(seriesTotal, limit);
124
- const shouldBuild = !!options.yes || !!options.output || (await confirmMakePlaylist(willInclude, seriesTotal));
139
+ const shouldBuild = !!options.yes ||
140
+ !!options.output ||
141
+ (await confirmMakePlaylist(tokens.length, seriesHasMore));
125
142
  if (!shouldBuild) {
126
143
  console.log(chalk_1.default.dim('Cancelled.'));
127
144
  return;
128
145
  }
129
- const tokens = target.kind === 'series' ? await fetchTokens(target.summary, limit) : [target.coords];
130
146
  console.log(chalk_1.default.dim(`Indexing ${tokens.length} token${tokens.length === 1 ? '' : 's'} via FF indexer...`));
131
147
  // Second positional arg on getNFTTokenInfoBatch is `duration` (DP-1 item
132
148
  // display seconds), not concurrency — concurrency is hardcoded inside.
@@ -202,32 +218,33 @@ async function resolveTarget(parsed, skipPrompt) {
202
218
  return resolveCoords(coords);
203
219
  }
204
220
  if (parsed.kind === 'address') {
205
- const artistId = await (0, raster_client_1.resolveAddressToArtist)(parsed.address);
206
- if (artistId === null) {
221
+ const artist = await (0, raster_client_1.resolveAddressToArtist)(parsed.address);
222
+ if (artist === null) {
207
223
  throw new Error(`No artist found on Raster for address ${parsed.address}. ` +
208
224
  'Raster indexes artists by their on-chain addresses; this address may not yet be claimed.');
209
225
  }
210
- const catalog = await (0, raster_client_1.listArtistArtworks)(artistId);
226
+ const catalog = await (0, raster_client_1.listArtistArtworks)(artist.id);
211
227
  if (catalog.length === 0) {
212
228
  throw new Error('Artist has no artworks indexed on Raster.');
213
229
  }
214
230
  const picked = await pickFromCatalog(catalog, skipPrompt);
215
- const summary = await (0, raster_client_1.getArtworkSummary)(picked.artworkId);
216
- return { kind: 'series', summary };
231
+ return {
232
+ kind: 'series',
233
+ summary: { artworkId: picked.artworkId, title: picked.title, artists: [artist] },
234
+ };
217
235
  }
218
236
  // `unsupported` is handled upstream; this branch keeps the type narrowing honest.
219
237
  throw new Error(`Unsupported parse result: ${parsed.kind}`);
220
238
  }
221
239
  /**
222
- * Resolve on-chain coords to a target. If Raster doesn't index this token
223
- * (404), fall back to single-token mode so we still build something playable.
240
+ * Resolve on-chain coords to a target. If Raster doesn't index this token,
241
+ * fall back to single-token mode so we still build something playable.
224
242
  */
225
243
  async function resolveCoords(coords) {
226
- const lookup = await (0, raster_client_1.resolveTokenToArtwork)(coords.chain, coords.contract, coords.tokenId);
227
- if (lookup === null) {
244
+ const summary = await (0, raster_client_1.resolveTokenToArtwork)(coords.chain, coords.contract, coords.tokenId);
245
+ if (summary === null) {
228
246
  return { kind: 'single', coords };
229
247
  }
230
- const summary = await (0, raster_client_1.getArtworkSummary)(lookup.artworkId);
231
248
  return { kind: 'series', summary };
232
249
  }
233
250
  /**
@@ -258,11 +275,17 @@ function parseLimitOption(limitStr) {
258
275
  }
259
276
  return n;
260
277
  }
278
+ /**
279
+ * Enumerate series tokens from Raster up to `limit`. `hasMore` reports
280
+ * whether the series continues past what was collected — the API exposes
281
+ * no total counts, so this is all the size information the flow gets.
282
+ */
261
283
  async function fetchTokens(summary, limit) {
262
284
  const collected = [];
263
285
  let cursor;
264
286
  let skipped = 0;
265
- while (collected.length < limit) {
287
+ let hasMore = false;
288
+ for (;;) {
266
289
  const page = await (0, raster_client_1.listArtworkTokens)(summary.artworkId, {
267
290
  cursor,
268
291
  pageSize: RASTER_PAGE_SIZE,
@@ -270,6 +293,7 @@ async function fetchTokens(summary, limit) {
270
293
  skipped += page.skippedUnsupported;
271
294
  for (const t of page.tokens) {
272
295
  if (collected.length >= limit) {
296
+ hasMore = true;
273
297
  break;
274
298
  }
275
299
  collected.push({
@@ -278,7 +302,11 @@ async function fetchTokens(summary, limit) {
278
302
  tokenId: t.tokenId,
279
303
  });
280
304
  }
281
- if (page.nextCursor === null) {
305
+ if (hasMore || page.nextCursor === null) {
306
+ break;
307
+ }
308
+ if (collected.length >= limit) {
309
+ hasMore = true;
282
310
  break;
283
311
  }
284
312
  cursor = page.nextCursor;
@@ -286,7 +314,7 @@ async function fetchTokens(summary, limit) {
286
314
  if (skipped > 0) {
287
315
  console.log(chalk_1.default.yellow(` Skipped ${skipped} token${skipped === 1 ? '' : 's'} on unsupported chains (FF indexer covers eth + tezos mainnet only).`));
288
316
  }
289
- return collected;
317
+ return { tokens: collected, hasMore };
290
318
  }
291
319
  async function pickFromCatalog(catalog, skipPrompt) {
292
320
  if (catalog.length === 1 || skipPrompt) {
@@ -294,8 +322,7 @@ async function pickFromCatalog(catalog, skipPrompt) {
294
322
  }
295
323
  console.log(chalk_1.default.cyan(`Found ${catalog.length} artworks for this artist:`));
296
324
  catalog.forEach((row, i) => {
297
- const count = `${row.tokenCount} token${row.tokenCount === 1 ? '' : 's'}`;
298
- console.log(chalk_1.default.dim(` ${i}: ${row.title} (${count})`));
325
+ console.log(chalk_1.default.dim(` ${i}: ${row.title}`));
299
326
  });
300
327
  console.log();
301
328
  const prompt = (0, prompt_1.createPrompt)();
@@ -310,9 +337,9 @@ async function pickFromCatalog(catalog, skipPrompt) {
310
337
  }
311
338
  return catalog[index];
312
339
  }
313
- async function confirmMakePlaylist(count, total) {
314
- const noun = total === 1 ? 'token' : 'tokens';
315
- const label = count < total ? `${count} of ${total} ${noun}` : `${count} ${noun}`;
340
+ async function confirmMakePlaylist(count, hasMore) {
341
+ const noun = count === 1 ? 'token' : 'tokens';
342
+ const label = hasMore ? `the first ${count} ${noun}` : `${count} ${noun}`;
316
343
  const prompt = (0, prompt_1.createPrompt)();
317
344
  const yes = await (0, prompt_1.promptYesNo)(prompt.ask, `Build playlist with ${label}?`, true);
318
345
  prompt.close();
@@ -488,7 +515,7 @@ async function runNeortFind(id, options) {
488
515
  console.log(chalk_1.default.cyan(`Neort — ${artistLabel} — ${art.title}`));
489
516
  console.log(chalk_1.default.dim(' (Off-chain platform; building a one-item playlist directly from Neort.)'));
490
517
  console.log();
491
- const shouldBuild = !!options.yes || !!options.output || (await confirmMakePlaylist(1, 1));
518
+ const shouldBuild = !!options.yes || !!options.output || (await confirmMakePlaylist(1, false));
492
519
  if (!shouldBuild) {
493
520
  console.log(chalk_1.default.dim('Cancelled.'));
494
521
  return;
@@ -1,9 +1,9 @@
1
1
  "use strict";
2
2
  /**
3
- * Raster API client (kit.raster.art)
3
+ * Raster API client (api.raster.art — public GraphQL API).
4
4
  *
5
- * Resolves on-chain coordinates and artist addresses into Raster `artwork_id`
6
- * (series) and `artist_id` (catalog) — the inputs the `ff-cli find` flow needs
5
+ * Resolves on-chain coordinates and artist addresses into Raster artworks
6
+ * (series) and artists (catalog) — the inputs the `ff-cli find` flow needs
7
7
  * before handing tokens to the FF GraphQL indexer for DP-1 conversion.
8
8
  *
9
9
  * This module is pure data: it does not construct DP-1 items, send anything
@@ -14,6 +14,14 @@
14
14
  * mainnet. Token lists are filtered here at the CAIP level and a
15
15
  * `skippedUnsupported` count is surfaced so callers can warn-skip without
16
16
  * walking every row themselves.
17
+ *
18
+ * API notes (v0.1.0, https://docs.api.raster.art/):
19
+ * - Auth: `x-api-key` header, sent when RASTER_API_KEY is set in the
20
+ * environment. The endpoint currently answers without a key; the docs
21
+ * declare one required, so the hook is wired in ahead of enforcement.
22
+ * - Not-found is a `null` query field, not an HTTP 404.
23
+ * - Connections expose no total counts — callers learn series size by
24
+ * paginating, so the find flow fetches before it prompts.
17
25
  */
18
26
  var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
19
27
  if (k2 === undefined) k2 = k;
@@ -49,130 +57,112 @@ var __importStar = (this && this.__importStar) || (function () {
49
57
  };
50
58
  })();
51
59
  Object.defineProperty(exports, "__esModule", { value: true });
52
- exports.RasterNotFoundError = void 0;
53
60
  exports.resolveTokenToArtwork = resolveTokenToArtwork;
54
- exports.getArtworkSummary = getArtworkSummary;
55
61
  exports.resolveAddressToArtist = resolveAddressToArtist;
56
62
  exports.listArtworkTokens = listArtworkTokens;
57
63
  exports.listArtistArtworks = listArtistArtworks;
58
64
  exports.formatSummaryLine = formatSummaryLine;
59
65
  const logger = __importStar(require("../logger"));
60
66
  const user_agent_1 = require("./user-agent");
61
- const RASTER_BASE_URL = 'https://kit.raster.art';
67
+ const DEFAULT_RASTER_GRAPHQL_URL = 'https://api.raster.art/graphql';
62
68
  // CAIP-2 chain IDs for the chains the FF indexer supports.
63
69
  // Tezos mainnet's CAIP form uses the genesis block hash (NetXdQprcVkpaWU),
64
70
  // not the human-readable "tezos:mainnet" — Raster returns the hash form on
65
- // token rows and rejects "tezos:mainnet" with HTTP 400.
71
+ // token rows and resolves nothing for "tezos:mainnet".
66
72
  const CAIP_TO_INDEXER_CHAIN = {
67
73
  'eip155:1': 'ethereum',
68
74
  'tezos:NetXdQprcVkpaWU': 'tezos',
69
75
  };
70
- function stripContractChainPrefix(raw) {
71
- const slash = raw.indexOf('/');
72
- return slash >= 0 ? raw.slice(slash + 1) : raw;
73
- }
76
+ const INDEXER_CHAIN_TO_CAIP = {
77
+ ethereum: 'eip155:1',
78
+ tezos: 'tezos:NetXdQprcVkpaWU',
79
+ };
74
80
  function caipToIndexerChain(caip) {
75
81
  return CAIP_TO_INDEXER_CHAIN[caip] ?? null;
76
82
  }
77
83
  /**
78
- * Thrown when Raster returns 404 for a requested path. Used as a soft signal
79
- * by callers that have a fallback path (e.g. `find` falls back to a
80
- * single-token playlist when Raster doesn't index the artwork).
84
+ * POST one GraphQL operation to Raster and return `data`.
85
+ *
86
+ * Throws on HTTP errors and on GraphQL `errors` entries. "Not found" never
87
+ * lands here — Raster models it as a null query field with no error, so
88
+ * callers check their own field for null.
81
89
  */
82
- class RasterNotFoundError extends Error {
83
- notFound = true;
84
- constructor(path) {
85
- super(`Raster: not found (${path})`);
86
- this.name = 'RasterNotFoundError';
90
+ async function rasterQuery(query, variables) {
91
+ const endpoint = process.env.RASTER_API_URL ?? DEFAULT_RASTER_GRAPHQL_URL;
92
+ logger.debug(`[Raster] POST ${endpoint} ${JSON.stringify(variables)}`);
93
+ const headers = {
94
+ 'Content-Type': 'application/json',
95
+ 'User-Agent': user_agent_1.USER_AGENT,
96
+ };
97
+ const apiKey = process.env.RASTER_API_KEY;
98
+ if (apiKey) {
99
+ headers['x-api-key'] = apiKey;
87
100
  }
88
- }
89
- exports.RasterNotFoundError = RasterNotFoundError;
90
- async function rasterFetch(path) {
91
- const url = `${RASTER_BASE_URL}${path}`;
92
- logger.debug(`[Raster] GET ${url}`);
93
- const response = await fetch(url, {
94
- headers: { 'User-Agent': user_agent_1.USER_AGENT, Accept: 'application/json' },
101
+ const response = await fetch(endpoint, {
102
+ method: 'POST',
103
+ headers,
104
+ body: JSON.stringify({ query, variables }),
95
105
  });
96
- if (response.status === 404) {
97
- throw new RasterNotFoundError(path);
98
- }
99
106
  if (!response.ok) {
100
107
  const body = await response.text();
101
- throw new Error(`Raster API ${response.status} ${response.statusText} for ${path}: ${body.slice(0, 200)}`);
108
+ throw new Error(`Raster API ${response.status} ${response.statusText}: ${body.slice(0, 200)}`);
109
+ }
110
+ const payload = (await response.json());
111
+ if (payload.errors && payload.errors.length > 0) {
112
+ throw new Error(`Raster API error: ${payload.errors.map((e) => e.message).join('; ')}`);
113
+ }
114
+ if (payload.data === undefined) {
115
+ throw new Error('Raster API returned no data.');
102
116
  }
103
- return (await response.json());
117
+ return payload.data;
118
+ }
119
+ function toRasterArtist(raw) {
120
+ return { id: raw.id, name: raw.name ?? 'Unknown artist', slug: raw.slug };
104
121
  }
105
122
  /**
106
- * Resolve a token's on-chain coordinates to its Raster artwork (series) id.
123
+ * Resolve a token's on-chain coordinates to its Raster artwork (series).
107
124
  *
108
- * Accepts either a CAIP chain id (`eip155:1`) or human slug (`ethereum`) —
109
- * Raster handles both. Returns `null` when Raster doesn't index this token
110
- * (HTTP 404); callers can fall back to a single-token playlist in that case.
111
- * Other Raster errors throw normally.
125
+ * One round trip: `tokenByRef` returns the artwork with artists inline, so
126
+ * this yields the full summary directly. Returns `null` when Raster doesn't
127
+ * index this token; callers fall back to a single-token playlist in that
128
+ * case. Tokens occasionally belong to multiple artworks; the first one is
129
+ * Raster's primary association.
112
130
  */
113
131
  async function resolveTokenToArtwork(chain, contract, tokenId) {
114
- const path = `/token/${encodeURIComponent(chain)}` +
115
- `/${encodeURIComponent(contract)}` +
116
- `/${encodeURIComponent(tokenId)}`;
117
- try {
118
- const data = await rasterFetch(path);
119
- return {
120
- artworkId: data.artwork.id,
121
- artworkTitle: data.artwork.title,
122
- };
123
- }
124
- catch (error) {
125
- if (error instanceof RasterNotFoundError) {
126
- return null;
127
- }
128
- throw error;
132
+ const data = await rasterQuery(`query ResolveToken($ref: TokenRefInput!) {
133
+ tokenByRef(ref: $ref) {
134
+ artworks { id title artists { id name slug } }
135
+ }
136
+ }`, {
137
+ ref: {
138
+ chainId: INDEXER_CHAIN_TO_CAIP[chain],
139
+ contractAddress: contract,
140
+ tokenId,
141
+ },
142
+ });
143
+ const artwork = data.tokenByRef?.artworks?.[0];
144
+ if (!artwork) {
145
+ return null;
129
146
  }
130
- }
131
- /**
132
- * Get the full Raster artwork summary suitable for the one-line resolve
133
- * message ("Snowfro — send/receive — 8977 tokens") and downstream UX.
134
- */
135
- async function getArtworkSummary(artworkId) {
136
- const data = await rasterFetch(`/artwork/${artworkId}`);
137
147
  return {
138
- artworkId: data.id,
139
- title: data.title,
140
- artists: data.artists ?? [],
141
- tokenCount: data.token_count,
142
- editionSize: data.edition_size,
143
- isEdition: data.is_edition,
144
- hasOneOfOneTokens: data.has_one_of_one_tokens,
145
- platforms: data.platforms ?? [],
146
- contractAddresses: data.contract_addresses ?? [],
148
+ artworkId: artwork.id,
149
+ title: artwork.title,
150
+ artists: (artwork.artists ?? []).map(toRasterArtist),
147
151
  };
148
152
  }
149
153
  /**
150
- * Resolve an artist's wallet address to a Raster artist id via `/search`.
154
+ * Resolve a wallet address to the Raster artist who claims it.
151
155
  *
152
- * `/artist/{id}` does not accept raw addresses (404), so we bridge through
153
- * the multi-search endpoint and pick the artist hit whose `addresses` list
154
- * contains the input. Returns `null` when no artist matches.
156
+ * The `address` query carries artist associations directly (the old REST
157
+ * API needed a `/search` bridge). Returns `null` when no artist claims the
158
+ * address. Checksummed Ethereum input is normalized server-side.
155
159
  */
156
160
  async function resolveAddressToArtist(address) {
157
- const normalized = address.trim().toLowerCase();
158
- const data = await rasterFetch(`/search?query=${encodeURIComponent(address)}`);
159
- for (const result of data.results ?? []) {
160
- for (const hit of result.hits ?? []) {
161
- const doc = hit.document;
162
- if (doc.data_type !== 'artist') {
163
- continue;
164
- }
165
- const addrs = (doc.addresses ?? []).map((a) => a.toLowerCase());
166
- if (!addrs.includes(normalized)) {
167
- continue;
168
- }
169
- const parsed = Number.parseInt(doc.data_pk, 10);
170
- if (Number.isFinite(parsed)) {
171
- return parsed;
172
- }
173
- }
174
- }
175
- return null;
161
+ const data = await rasterQuery(`query ResolveAddress($address: String!) {
162
+ address(address: $address) { artists { id name slug } }
163
+ }`, { address: address.trim() });
164
+ const artist = data.address?.artists?.[0];
165
+ return artist ? toRasterArtist(artist) : null;
176
166
  }
177
167
  /**
178
168
  * List tokens in a Raster artwork (series), one page at a time.
@@ -183,70 +173,79 @@ async function resolveAddressToArtist(address) {
183
173
  * warn-skip line instead of one per token.
184
174
  *
185
175
  * Pagination: pass the returned `nextCursor` back as `options.cursor`.
186
- * `nextCursor` is `null` on the final page. Raster signals end-of-stream by
187
- * returning `cursor === total_count`; that is normalized to `null` here.
176
+ * `nextCursor` is `null` on the final page. The API exposes no total count;
177
+ * the only way to learn a series' size is to walk it.
188
178
  */
189
179
  async function listArtworkTokens(artworkId, options = {}) {
190
- const params = new URLSearchParams();
191
- if (options.cursor !== undefined) {
192
- params.set('cursor', String(options.cursor));
193
- }
194
- if (options.pageSize !== undefined) {
195
- params.set('page_size', String(options.pageSize));
180
+ const data = await rasterQuery(`query ArtworkTokens($id: ID!, $first: Int, $after: String) {
181
+ artwork(id: $id) {
182
+ tokens(first: $first, after: $after) {
183
+ nodes { chainId contractAddress tokenId }
184
+ pageInfo { hasNextPage endCursor }
185
+ }
186
+ }
187
+ }`, { id: artworkId, first: options.pageSize ?? null, after: options.cursor ?? null });
188
+ if (!data.artwork) {
189
+ throw new Error(`Raster: artwork ${artworkId} not found.`);
196
190
  }
197
- const qs = params.toString();
198
- const path = `/artwork/${artworkId}/tokens${qs ? `?${qs}` : ''}`;
199
- const data = await rasterFetch(path);
200
191
  const tokens = [];
201
192
  let skippedUnsupported = 0;
202
- for (const raw of data.tokens ?? []) {
203
- const chain = caipToIndexerChain(raw.chain_id);
204
- if (!chain) {
193
+ for (const raw of data.artwork.tokens.nodes) {
194
+ const chain = caipToIndexerChain(raw.chainId);
195
+ if (!chain || !raw.contractAddress) {
205
196
  skippedUnsupported += 1;
206
197
  continue;
207
198
  }
208
199
  tokens.push({
209
- caipChain: raw.chain_id,
210
200
  chain,
211
- contractAddress: stripContractChainPrefix(raw.contract_address),
212
- tokenId: raw.token_id,
201
+ contractAddress: raw.contractAddress,
202
+ tokenId: raw.tokenId,
213
203
  });
214
204
  }
215
- const rawCursor = data.cursor;
216
- const nextCursor = rawCursor === null || rawCursor >= data.total_count ? null : rawCursor;
205
+ const { hasNextPage, endCursor } = data.artwork.tokens.pageInfo;
217
206
  return {
218
207
  tokens,
219
- totalCount: data.total_count,
220
- nextCursor,
208
+ nextCursor: hasNextPage && endCursor ? endCursor : null,
221
209
  skippedUnsupported,
222
210
  };
223
211
  }
224
212
  /**
225
- * List an artist's full catalog as light artwork rows (id, title, token count,
226
- * edition flag, mint date). Use {@link getArtworkSummary} when full details
227
- * are needed for a specific row.
213
+ * List an artist's full catalog as light artwork rows (id + title — the
214
+ * API exposes no per-artwork token counts or mint dates yet).
228
215
  *
229
- * The endpoint returns the full catalog in a single response; no cursor is
230
- * exposed by Raster for this path.
216
+ * Walks the artist's `artworks` connection to the end so the caller sees
217
+ * the complete catalog, matching the old single-response REST behavior.
231
218
  */
232
219
  async function listArtistArtworks(artistId) {
233
- const data = await rasterFetch(`/artist/${artistId}/artworks`);
234
- return (data.artworks ?? []).map((row) => ({
235
- artworkId: row.id,
236
- title: row.title,
237
- tokenCount: row.num_tokens ?? row.num_pieces ?? 0,
238
- isEdition: row.is_edition,
239
- mintDate: row.mint_date ?? null,
240
- }));
220
+ const rows = [];
221
+ let cursor = null;
222
+ do {
223
+ const data = await rasterQuery(`query ArtistArtworks($id: ID!, $after: String) {
224
+ artist(id: $id) {
225
+ artworks(first: 100, after: $after) {
226
+ nodes { id title }
227
+ pageInfo { hasNextPage endCursor }
228
+ }
229
+ }
230
+ }`, { id: artistId, after: cursor });
231
+ if (!data.artist) {
232
+ throw new Error(`Raster: artist ${artistId} not found.`);
233
+ }
234
+ for (const node of data.artist.artworks.nodes) {
235
+ rows.push({ artworkId: node.id, title: node.title });
236
+ }
237
+ const { hasNextPage, endCursor } = data.artist.artworks.pageInfo;
238
+ cursor = hasNextPage && endCursor ? endCursor : null;
239
+ } while (cursor !== null);
240
+ return rows;
241
241
  }
242
242
  /**
243
243
  * Format a one-line summary for the resolved artwork.
244
244
  *
245
245
  * @example
246
- * formatSummaryLine(summary) // "Snowfro — send/receive — 8977 tokens"
246
+ * formatSummaryLine(summary) // "Snowfro — send/receive"
247
247
  */
248
248
  function formatSummaryLine(summary) {
249
249
  const artistNames = summary.artists.map((a) => a.name).join(', ') || 'Unknown artist';
250
- const tokenWord = summary.tokenCount === 1 ? 'token' : 'tokens';
251
- return `${artistNames} — ${summary.title} — ${summary.tokenCount} ${tokenWord}`;
250
+ return `${artistNames} — ${summary.title}`;
252
251
  }
@@ -116,6 +116,19 @@ Environment variable alternative:
116
116
  FEED_BASE_URLS=https://dp1-feed-operator-api-prod.autonomy-system.workers.dev/api/v1,https://dp1-feed-operator-api-prod.autonomy-system.workers.dev/api/v1
117
117
  ```
118
118
 
119
+ ## find (Raster API)
120
+
121
+ `ff-cli find` resolves tokens, series, and artist addresses through Raster's public GraphQL API ([docs.api.raster.art](https://docs.api.raster.art/)). No configuration is required; two environment variables adjust the integration:
122
+
123
+ ```env
124
+ # API key sent as the x-api-key header. The API currently answers without
125
+ # one, but Raster's docs declare a key required — set this once enforced.
126
+ RASTER_API_KEY=rk_your_key_here
127
+
128
+ # Override the GraphQL endpoint (default: https://api.raster.art/graphql).
129
+ RASTER_API_URL=https://api.raster.art/graphql
130
+ ```
131
+
119
132
  ## ff1Devices
120
133
 
121
134
  Configure devices you want to play content on.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@feralfile/cli",
3
- "version": "1.4.0",
3
+ "version": "1.5.0",
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",