From 06aaf5b4e181778fb94f9b65aa441ad3919b04b0 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 4 Mar 2026 16:11:19 +0000 Subject: [PATCH 1/3] feat: add --fields and --max-lines global CLI options (DIS-145) Add --fields to select specific JSON fields in output. Add --max-lines to truncate output with line count indicator. Uses Commander preAction hook and module-level output options to avoid changing any command files. Field filtering handles plain objects, arrays, and wrapped API responses (e.g. {nfts: [...], next: ...}). Truncation appends '... (N more lines)' indicator. Co-Authored-By: Chris K --- src/cli.ts | 20 ++++++- src/index.ts | 4 +- src/output.ts | 78 +++++++++++++++++++++++-- test/output.test.ts | 138 +++++++++++++++++++++++++++++++++++++++++++- 4 files changed, 231 insertions(+), 9 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index dc1fbbb..e92dd99 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -11,7 +11,7 @@ import { swapsCommand, tokensCommand, } from "./commands/index.js" -import type { OutputFormat } from "./output.js" +import { type OutputFormat, setOutputOptions } from "./output.js" import { parseIntOption } from "./parse.js" const BANNER = ` @@ -38,6 +38,11 @@ program .option("--base-url ", "API base URL") .option("--timeout ", "Request timeout in milliseconds", "30000") .option("--verbose", "Log request and response info to stderr") + .option( + "--fields ", + "Comma-separated list of fields to include in output", + ) + .option("--max-lines ", "Truncate output after N lines") function getClient(): OpenSeaClient { const opts = program.opts<{ @@ -72,6 +77,19 @@ function getFormat(): OutputFormat { return "json" } +program.hook("preAction", () => { + const opts = program.opts<{ + fields?: string + maxLines?: string + }>() + setOutputOptions({ + fields: opts.fields?.split(",").map(f => f.trim()), + maxLines: opts.maxLines + ? parseIntOption(opts.maxLines, "--max-lines") + : undefined, + }) +}) + program.addCommand(collectionsCommand(getClient, getFormat)) program.addCommand(nftsCommand(getClient, getFormat)) program.addCommand(listingsCommand(getClient, getFormat)) diff --git a/src/index.ts b/src/index.ts index 8e563d8..01cc24e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,6 @@ export { OpenSeaAPIError, OpenSeaClient } from "./client.js" -export type { OutputFormat } from "./output.js" -export { formatOutput } from "./output.js" +export type { OutputFormat, OutputOptions } from "./output.js" +export { formatOutput, setOutputOptions } from "./output.js" export { OpenSeaCLI } from "./sdk.js" export { formatToon } from "./toon.js" export type * from "./types/index.js" diff --git a/src/output.ts b/src/output.ts index cbc78a2..ac4487a 100644 --- a/src/output.ts +++ b/src/output.ts @@ -2,14 +2,36 @@ import { formatToon } from "./toon.js" export type OutputFormat = "json" | "table" | "toon" +export interface OutputOptions { + fields?: string[] + maxLines?: number +} + +let _outputOptions: OutputOptions = {} + +export function setOutputOptions(options: OutputOptions): void { + _outputOptions = options +} + export function formatOutput(data: unknown, format: OutputFormat): string { + const processed = _outputOptions.fields + ? filterFields(data, _outputOptions.fields) + : data + + let result: string if (format === "table") { - return formatTable(data) + result = formatTable(processed) + } else if (format === "toon") { + result = formatToon(processed) + } else { + result = JSON.stringify(processed, null, 2) } - if (format === "toon") { - return formatToon(data) + + if (_outputOptions.maxLines) { + result = truncateOutput(result, _outputOptions.maxLines) } - return JSON.stringify(data, null, 2) + + return result } function formatTable(data: unknown): string { @@ -57,3 +79,51 @@ function formatTable(data: unknown): string { return String(data) } + +function pickFields( + obj: Record, + fields: string[], +): Record { + const result: Record = {} + for (const field of fields) { + if (field in obj) { + result[field] = obj[field] + } + } + return result +} + +function filterFields(data: unknown, fields: string[]): unknown { + if (Array.isArray(data)) { + return data.map(item => filterFields(item, fields)) + } + if (data && typeof data === "object") { + const obj = data as Record + const arrayKeys = Object.keys(obj).filter(k => Array.isArray(obj[k])) + if (arrayKeys.length > 0) { + const result: Record = {} + for (const [key, value] of Object.entries(obj)) { + result[key] = Array.isArray(value) + ? value.map(item => + item && typeof item === "object" + ? pickFields(item as Record, fields) + : item, + ) + : value + } + return result + } + return pickFields(obj, fields) + } + return data +} + +function truncateOutput(text: string, maxLines: number): string { + const lines = text.split("\n") + if (lines.length <= maxLines) return text + const omitted = lines.length - maxLines + return ( + lines.slice(0, maxLines).join("\n") + + `\n... (${omitted} more line${omitted === 1 ? "" : "s"})` + ) +} diff --git a/test/output.test.ts b/test/output.test.ts index 765f655..2d343bf 100644 --- a/test/output.test.ts +++ b/test/output.test.ts @@ -1,7 +1,11 @@ -import { describe, expect, it } from "vitest" -import { formatOutput } from "../src/output.js" +import { afterEach, describe, expect, it } from "vitest" +import { formatOutput, setOutputOptions } from "../src/output.js" describe("formatOutput", () => { + afterEach(() => { + setOutputOptions({}) + }) + describe("json format", () => { it("formats data as pretty JSON", () => { const data = { name: "test", value: 42 } @@ -70,4 +74,134 @@ describe("formatOutput", () => { expect(formatOutput(42, "table")).toBe("42") }) }) + + describe("--fields option", () => { + it("filters top-level fields on a plain object", () => { + setOutputOptions({ fields: ["name", "collection"] }) + const data = { + name: "Cool NFT", + collection: "cool-cats", + description: "A cool cat", + image_url: "https://example.com/img.png", + } + const result = JSON.parse(formatOutput(data, "json")) + expect(result).toEqual({ name: "Cool NFT", collection: "cool-cats" }) + }) + + it("filters fields on items inside wrapped arrays", () => { + setOutputOptions({ fields: ["identifier", "name"] }) + const data = { + nfts: [ + { + identifier: "1", + name: "NFT #1", + image_url: "https://example.com/1.png", + }, + { + identifier: "2", + name: "NFT #2", + image_url: "https://example.com/2.png", + }, + ], + next: "cursor123", + } + const result = JSON.parse(formatOutput(data, "json")) + expect(result).toEqual({ + nfts: [ + { identifier: "1", name: "NFT #1" }, + { identifier: "2", name: "NFT #2" }, + ], + next: "cursor123", + }) + }) + + it("filters fields on a bare array", () => { + setOutputOptions({ fields: ["name"] }) + const data = [ + { name: "Alice", age: 30 }, + { name: "Bob", age: 25 }, + ] + const result = JSON.parse(formatOutput(data, "json")) + expect(result).toEqual([{ name: "Alice" }, { name: "Bob" }]) + }) + + it("ignores fields that do not exist", () => { + setOutputOptions({ fields: ["name", "nonexistent"] }) + const data = { name: "test", value: 42 } + const result = JSON.parse(formatOutput(data, "json")) + expect(result).toEqual({ name: "test" }) + }) + + it("returns primitives unchanged", () => { + setOutputOptions({ fields: ["name"] }) + expect(formatOutput("hello", "json")).toBe('"hello"') + }) + + it("works with table format", () => { + setOutputOptions({ fields: ["name"] }) + const data = [ + { name: "Alice", age: 30 }, + { name: "Bob", age: 25 }, + ] + const result = formatOutput(data, "table") + expect(result).toContain("name") + expect(result).not.toContain("age") + }) + }) + + describe("--max-lines option", () => { + it("truncates output exceeding max lines", () => { + setOutputOptions({ maxLines: 3 }) + const data = { a: 1, b: 2, c: 3, d: 4, e: 5 } + const result = formatOutput(data, "json") + const lines = result.split("\n") + expect(lines).toHaveLength(4) + expect(lines[3]).toBe("... (4 more lines)") + }) + + it("does not truncate when output fits within max lines", () => { + setOutputOptions({ maxLines: 100 }) + const data = { a: 1 } + const result = formatOutput(data, "json") + expect(result).not.toContain("...") + expect(result).toBe(JSON.stringify(data, null, 2)) + }) + + it("uses singular 'line' for exactly one omitted line", () => { + setOutputOptions({ maxLines: 2 }) + const data = { a: 1 } + const result = formatOutput(data, "json") + const lines = result.split("\n") + expect(lines[lines.length - 1]).toBe("... (1 more line)") + }) + + it("works with table format", () => { + setOutputOptions({ maxLines: 2 }) + const data = [{ name: "Alice" }, { name: "Bob" }, { name: "Charlie" }] + const result = formatOutput(data, "table") + const lines = result.split("\n") + expect(lines).toHaveLength(3) + expect(lines[2]).toMatch(/\.\.\. \(\d+ more lines?\)/) + }) + }) + + describe("--fields and --max-lines combined", () => { + it("applies field filtering then truncation", () => { + setOutputOptions({ fields: ["name"], maxLines: 3 }) + const data = { + nfts: [ + { name: "A", id: 1 }, + { name: "B", id: 2 }, + { name: "C", id: 3 }, + { name: "D", id: 4 }, + ], + next: "cursor", + } + const result = formatOutput(data, "json") + expect(result).not.toContain("id") + const lines = result.split("\n") + expect(lines).toHaveLength(4) + expect(lines[3]).toMatch(/\.\.\. \(\d+ more lines?\)/) + }) + }) }) From a84fe2b556ade042195c6ce99edbc5b088fd2c47 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 4 Mar 2026 16:17:24 +0000 Subject: [PATCH 2/3] fix: improve filterFields heuristic and maxLines nullish check (DIS-145) - filterFields now checks if any requested field matches a top-level key. If yes, picks from top level (handles Collection with array props like contracts, editors). If no match, treats as wrapper and filters array items (handles {nfts: [...], next: ...} responses). - Changed maxLines guard from truthiness to != null so --max-lines 0 works. - Added tests for objects with array properties and maxLines 0. Co-Authored-By: Chris K --- src/output.ts | 32 +++++++++++++++++--------------- test/output.test.ts | 24 ++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 15 deletions(-) diff --git a/src/output.ts b/src/output.ts index ac4487a..4ce956e 100644 --- a/src/output.ts +++ b/src/output.ts @@ -27,7 +27,7 @@ export function formatOutput(data: unknown, format: OutputFormat): string { result = JSON.stringify(processed, null, 2) } - if (_outputOptions.maxLines) { + if (_outputOptions.maxLines != null) { result = truncateOutput(result, _outputOptions.maxLines) } @@ -99,21 +99,23 @@ function filterFields(data: unknown, fields: string[]): unknown { } if (data && typeof data === "object") { const obj = data as Record - const arrayKeys = Object.keys(obj).filter(k => Array.isArray(obj[k])) - if (arrayKeys.length > 0) { - const result: Record = {} - for (const [key, value] of Object.entries(obj)) { - result[key] = Array.isArray(value) - ? value.map(item => - item && typeof item === "object" - ? pickFields(item as Record, fields) - : item, - ) - : value - } - return result + const keys = Object.keys(obj) + const hasMatchingKey = fields.some(f => f in obj) + if (hasMatchingKey) { + return pickFields(obj, fields) } - return pickFields(obj, fields) + const result: Record = {} + for (const key of keys) { + const value = obj[key] + result[key] = Array.isArray(value) + ? value.map(item => + item && typeof item === "object" + ? pickFields(item as Record, fields) + : item, + ) + : value + } + return result } return data } diff --git a/test/output.test.ts b/test/output.test.ts index 2d343bf..a31b89d 100644 --- a/test/output.test.ts +++ b/test/output.test.ts @@ -132,6 +132,23 @@ describe("formatOutput", () => { expect(result).toEqual({ name: "test" }) }) + it("filters top-level fields on objects with array properties", () => { + setOutputOptions({ fields: ["name", "collection"] }) + const data = { + name: "Cool Cats", + collection: "cool-cats", + description: "A cool collection", + contracts: [{ address: "0x1", chain: "ethereum" }], + editors: ["alice"], + fees: [{ fee: 250, recipient: "0x2", required: true }], + } + const result = JSON.parse(formatOutput(data, "json")) + expect(result).toEqual({ + name: "Cool Cats", + collection: "cool-cats", + }) + }) + it("returns primitives unchanged", () => { setOutputOptions({ fields: ["name"] }) expect(formatOutput("hello", "json")).toBe('"hello"') @@ -183,6 +200,13 @@ describe("formatOutput", () => { expect(lines).toHaveLength(3) expect(lines[2]).toMatch(/\.\.\. \(\d+ more lines?\)/) }) + + it("handles max-lines 0 by truncating all lines", () => { + setOutputOptions({ maxLines: 0 }) + const data = { a: 1 } + const result = formatOutput(data, "json") + expect(result).toContain("... (3 more lines)") + }) }) describe("--fields and --max-lines combined", () => { From c8df287bd3eacd73b2de23b43effb5feced9adc7 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 4 Mar 2026 16:25:13 +0000 Subject: [PATCH 3/3] fix: address code review feedback (DIS-145) - Remove wrapper-detection heuristic from filterFields; always apply pickFields at top level (issue #1 from review) - Remove setOutputOptions/OutputOptions from SDK barrel export to avoid state leakage for SDK consumers (issue #2 from review) - Add --max-lines validation requiring >= 1 (issue #3 from review) - Update tests to match simplified filterFields behavior Co-Authored-By: Chris K --- src/cli.ts | 12 +++++++++--- src/index.ts | 4 ++-- src/output.ts | 19 +------------------ test/output.test.ts | 39 +++++++++++---------------------------- 4 files changed, 23 insertions(+), 51 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index e92dd99..7c4e659 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -82,11 +82,17 @@ program.hook("preAction", () => { fields?: string maxLines?: string }>() + let maxLines: number | undefined + if (opts.maxLines) { + maxLines = parseIntOption(opts.maxLines, "--max-lines") + if (maxLines < 1) { + console.error("Error: --max-lines must be >= 1") + process.exit(2) + } + } setOutputOptions({ fields: opts.fields?.split(",").map(f => f.trim()), - maxLines: opts.maxLines - ? parseIntOption(opts.maxLines, "--max-lines") - : undefined, + maxLines, }) }) diff --git a/src/index.ts b/src/index.ts index 01cc24e..8e563d8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,6 @@ export { OpenSeaAPIError, OpenSeaClient } from "./client.js" -export type { OutputFormat, OutputOptions } from "./output.js" -export { formatOutput, setOutputOptions } from "./output.js" +export type { OutputFormat } from "./output.js" +export { formatOutput } from "./output.js" export { OpenSeaCLI } from "./sdk.js" export { formatToon } from "./toon.js" export type * from "./types/index.js" diff --git a/src/output.ts b/src/output.ts index 4ce956e..73a66c2 100644 --- a/src/output.ts +++ b/src/output.ts @@ -98,24 +98,7 @@ function filterFields(data: unknown, fields: string[]): unknown { return data.map(item => filterFields(item, fields)) } if (data && typeof data === "object") { - const obj = data as Record - const keys = Object.keys(obj) - const hasMatchingKey = fields.some(f => f in obj) - if (hasMatchingKey) { - return pickFields(obj, fields) - } - const result: Record = {} - for (const key of keys) { - const value = obj[key] - result[key] = Array.isArray(value) - ? value.map(item => - item && typeof item === "object" - ? pickFields(item as Record, fields) - : item, - ) - : value - } - return result + return pickFields(data as Record, fields) } return data } diff --git a/test/output.test.ts b/test/output.test.ts index a31b89d..4a4af80 100644 --- a/test/output.test.ts +++ b/test/output.test.ts @@ -88,22 +88,15 @@ describe("formatOutput", () => { expect(result).toEqual({ name: "Cool NFT", collection: "cool-cats" }) }) - it("filters fields on items inside wrapped arrays", () => { - setOutputOptions({ fields: ["identifier", "name"] }) + it("picks matching fields from wrapper objects", () => { + setOutputOptions({ fields: ["nfts", "next"] }) const data = { nfts: [ - { - identifier: "1", - name: "NFT #1", - image_url: "https://example.com/1.png", - }, - { - identifier: "2", - name: "NFT #2", - image_url: "https://example.com/2.png", - }, + { identifier: "1", name: "NFT #1" }, + { identifier: "2", name: "NFT #2" }, ], next: "cursor123", + extra: "dropped", } const result = JSON.parse(formatOutput(data, "json")) expect(result).toEqual({ @@ -200,27 +193,17 @@ describe("formatOutput", () => { expect(lines).toHaveLength(3) expect(lines[2]).toMatch(/\.\.\. \(\d+ more lines?\)/) }) - - it("handles max-lines 0 by truncating all lines", () => { - setOutputOptions({ maxLines: 0 }) - const data = { a: 1 } - const result = formatOutput(data, "json") - expect(result).toContain("... (3 more lines)") - }) }) describe("--fields and --max-lines combined", () => { it("applies field filtering then truncation", () => { setOutputOptions({ fields: ["name"], maxLines: 3 }) - const data = { - nfts: [ - { name: "A", id: 1 }, - { name: "B", id: 2 }, - { name: "C", id: 3 }, - { name: "D", id: 4 }, - ], - next: "cursor", - } + const data = [ + { name: "A", id: 1 }, + { name: "B", id: 2 }, + { name: "C", id: 3 }, + { name: "D", id: 4 }, + ] const result = formatOutput(data, "json") expect(result).not.toContain("id") const lines = result.split("\n")