diff --git a/src/cli.ts b/src/cli.ts index dc1fbbb..7c4e659 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,25 @@ function getFormat(): OutputFormat { return "json" } +program.hook("preAction", () => { + const opts = program.opts<{ + 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, + }) +}) + program.addCommand(collectionsCommand(getClient, getFormat)) program.addCommand(nftsCommand(getClient, getFormat)) program.addCommand(listingsCommand(getClient, getFormat)) diff --git a/src/output.ts b/src/output.ts index cbc78a2..73a66c2 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 != null) { + result = truncateOutput(result, _outputOptions.maxLines) } - return JSON.stringify(data, null, 2) + + return result } function formatTable(data: unknown): string { @@ -57,3 +79,36 @@ 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") { + return pickFields(data as Record, 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..4a4af80 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,141 @@ 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("picks matching fields from wrapper objects", () => { + setOutputOptions({ fields: ["nfts", "next"] }) + const data = { + nfts: [ + { identifier: "1", name: "NFT #1" }, + { identifier: "2", name: "NFT #2" }, + ], + next: "cursor123", + extra: "dropped", + } + 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("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"') + }) + + 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 = [ + { 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") + expect(lines).toHaveLength(4) + expect(lines[3]).toMatch(/\.\.\. \(\d+ more lines?\)/) + }) + }) })