Skip to content

Commit 06aaf5b

Browse files
feat: add --fields and --max-lines global CLI options (DIS-145)
Add --fields <fields> to select specific JSON fields in output. Add --max-lines <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 <ckorhonen@gmail.com>
1 parent 7a06fc4 commit 06aaf5b

4 files changed

Lines changed: 231 additions & 9 deletions

File tree

src/cli.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import {
1111
swapsCommand,
1212
tokensCommand,
1313
} from "./commands/index.js"
14-
import type { OutputFormat } from "./output.js"
14+
import { type OutputFormat, setOutputOptions } from "./output.js"
1515
import { parseIntOption } from "./parse.js"
1616

1717
const BANNER = `
@@ -38,6 +38,11 @@ program
3838
.option("--base-url <url>", "API base URL")
3939
.option("--timeout <ms>", "Request timeout in milliseconds", "30000")
4040
.option("--verbose", "Log request and response info to stderr")
41+
.option(
42+
"--fields <fields>",
43+
"Comma-separated list of fields to include in output",
44+
)
45+
.option("--max-lines <lines>", "Truncate output after N lines")
4146

4247
function getClient(): OpenSeaClient {
4348
const opts = program.opts<{
@@ -72,6 +77,19 @@ function getFormat(): OutputFormat {
7277
return "json"
7378
}
7479

80+
program.hook("preAction", () => {
81+
const opts = program.opts<{
82+
fields?: string
83+
maxLines?: string
84+
}>()
85+
setOutputOptions({
86+
fields: opts.fields?.split(",").map(f => f.trim()),
87+
maxLines: opts.maxLines
88+
? parseIntOption(opts.maxLines, "--max-lines")
89+
: undefined,
90+
})
91+
})
92+
7593
program.addCommand(collectionsCommand(getClient, getFormat))
7694
program.addCommand(nftsCommand(getClient, getFormat))
7795
program.addCommand(listingsCommand(getClient, getFormat))

src/index.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
export { OpenSeaAPIError, OpenSeaClient } from "./client.js"
2-
export type { OutputFormat } from "./output.js"
3-
export { formatOutput } from "./output.js"
2+
export type { OutputFormat, OutputOptions } from "./output.js"
3+
export { formatOutput, setOutputOptions } from "./output.js"
44
export { OpenSeaCLI } from "./sdk.js"
55
export { formatToon } from "./toon.js"
66
export type * from "./types/index.js"

src/output.ts

Lines changed: 74 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,36 @@ import { formatToon } from "./toon.js"
22

33
export type OutputFormat = "json" | "table" | "toon"
44

5+
export interface OutputOptions {
6+
fields?: string[]
7+
maxLines?: number
8+
}
9+
10+
let _outputOptions: OutputOptions = {}
11+
12+
export function setOutputOptions(options: OutputOptions): void {
13+
_outputOptions = options
14+
}
15+
516
export function formatOutput(data: unknown, format: OutputFormat): string {
17+
const processed = _outputOptions.fields
18+
? filterFields(data, _outputOptions.fields)
19+
: data
20+
21+
let result: string
622
if (format === "table") {
7-
return formatTable(data)
23+
result = formatTable(processed)
24+
} else if (format === "toon") {
25+
result = formatToon(processed)
26+
} else {
27+
result = JSON.stringify(processed, null, 2)
828
}
9-
if (format === "toon") {
10-
return formatToon(data)
29+
30+
if (_outputOptions.maxLines) {
31+
result = truncateOutput(result, _outputOptions.maxLines)
1132
}
12-
return JSON.stringify(data, null, 2)
33+
34+
return result
1335
}
1436

1537
function formatTable(data: unknown): string {
@@ -57,3 +79,51 @@ function formatTable(data: unknown): string {
5779

5880
return String(data)
5981
}
82+
83+
function pickFields(
84+
obj: Record<string, unknown>,
85+
fields: string[],
86+
): Record<string, unknown> {
87+
const result: Record<string, unknown> = {}
88+
for (const field of fields) {
89+
if (field in obj) {
90+
result[field] = obj[field]
91+
}
92+
}
93+
return result
94+
}
95+
96+
function filterFields(data: unknown, fields: string[]): unknown {
97+
if (Array.isArray(data)) {
98+
return data.map(item => filterFields(item, fields))
99+
}
100+
if (data && typeof data === "object") {
101+
const obj = data as Record<string, unknown>
102+
const arrayKeys = Object.keys(obj).filter(k => Array.isArray(obj[k]))
103+
if (arrayKeys.length > 0) {
104+
const result: Record<string, unknown> = {}
105+
for (const [key, value] of Object.entries(obj)) {
106+
result[key] = Array.isArray(value)
107+
? value.map(item =>
108+
item && typeof item === "object"
109+
? pickFields(item as Record<string, unknown>, fields)
110+
: item,
111+
)
112+
: value
113+
}
114+
return result
115+
}
116+
return pickFields(obj, fields)
117+
}
118+
return data
119+
}
120+
121+
function truncateOutput(text: string, maxLines: number): string {
122+
const lines = text.split("\n")
123+
if (lines.length <= maxLines) return text
124+
const omitted = lines.length - maxLines
125+
return (
126+
lines.slice(0, maxLines).join("\n") +
127+
`\n... (${omitted} more line${omitted === 1 ? "" : "s"})`
128+
)
129+
}

test/output.test.ts

Lines changed: 136 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
1-
import { describe, expect, it } from "vitest"
2-
import { formatOutput } from "../src/output.js"
1+
import { afterEach, describe, expect, it } from "vitest"
2+
import { formatOutput, setOutputOptions } from "../src/output.js"
33

44
describe("formatOutput", () => {
5+
afterEach(() => {
6+
setOutputOptions({})
7+
})
8+
59
describe("json format", () => {
610
it("formats data as pretty JSON", () => {
711
const data = { name: "test", value: 42 }
@@ -70,4 +74,134 @@ describe("formatOutput", () => {
7074
expect(formatOutput(42, "table")).toBe("42")
7175
})
7276
})
77+
78+
describe("--fields option", () => {
79+
it("filters top-level fields on a plain object", () => {
80+
setOutputOptions({ fields: ["name", "collection"] })
81+
const data = {
82+
name: "Cool NFT",
83+
collection: "cool-cats",
84+
description: "A cool cat",
85+
image_url: "https://example.com/img.png",
86+
}
87+
const result = JSON.parse(formatOutput(data, "json"))
88+
expect(result).toEqual({ name: "Cool NFT", collection: "cool-cats" })
89+
})
90+
91+
it("filters fields on items inside wrapped arrays", () => {
92+
setOutputOptions({ fields: ["identifier", "name"] })
93+
const data = {
94+
nfts: [
95+
{
96+
identifier: "1",
97+
name: "NFT #1",
98+
image_url: "https://example.com/1.png",
99+
},
100+
{
101+
identifier: "2",
102+
name: "NFT #2",
103+
image_url: "https://example.com/2.png",
104+
},
105+
],
106+
next: "cursor123",
107+
}
108+
const result = JSON.parse(formatOutput(data, "json"))
109+
expect(result).toEqual({
110+
nfts: [
111+
{ identifier: "1", name: "NFT #1" },
112+
{ identifier: "2", name: "NFT #2" },
113+
],
114+
next: "cursor123",
115+
})
116+
})
117+
118+
it("filters fields on a bare array", () => {
119+
setOutputOptions({ fields: ["name"] })
120+
const data = [
121+
{ name: "Alice", age: 30 },
122+
{ name: "Bob", age: 25 },
123+
]
124+
const result = JSON.parse(formatOutput(data, "json"))
125+
expect(result).toEqual([{ name: "Alice" }, { name: "Bob" }])
126+
})
127+
128+
it("ignores fields that do not exist", () => {
129+
setOutputOptions({ fields: ["name", "nonexistent"] })
130+
const data = { name: "test", value: 42 }
131+
const result = JSON.parse(formatOutput(data, "json"))
132+
expect(result).toEqual({ name: "test" })
133+
})
134+
135+
it("returns primitives unchanged", () => {
136+
setOutputOptions({ fields: ["name"] })
137+
expect(formatOutput("hello", "json")).toBe('"hello"')
138+
})
139+
140+
it("works with table format", () => {
141+
setOutputOptions({ fields: ["name"] })
142+
const data = [
143+
{ name: "Alice", age: 30 },
144+
{ name: "Bob", age: 25 },
145+
]
146+
const result = formatOutput(data, "table")
147+
expect(result).toContain("name")
148+
expect(result).not.toContain("age")
149+
})
150+
})
151+
152+
describe("--max-lines option", () => {
153+
it("truncates output exceeding max lines", () => {
154+
setOutputOptions({ maxLines: 3 })
155+
const data = { a: 1, b: 2, c: 3, d: 4, e: 5 }
156+
const result = formatOutput(data, "json")
157+
const lines = result.split("\n")
158+
expect(lines).toHaveLength(4)
159+
expect(lines[3]).toBe("... (4 more lines)")
160+
})
161+
162+
it("does not truncate when output fits within max lines", () => {
163+
setOutputOptions({ maxLines: 100 })
164+
const data = { a: 1 }
165+
const result = formatOutput(data, "json")
166+
expect(result).not.toContain("...")
167+
expect(result).toBe(JSON.stringify(data, null, 2))
168+
})
169+
170+
it("uses singular 'line' for exactly one omitted line", () => {
171+
setOutputOptions({ maxLines: 2 })
172+
const data = { a: 1 }
173+
const result = formatOutput(data, "json")
174+
const lines = result.split("\n")
175+
expect(lines[lines.length - 1]).toBe("... (1 more line)")
176+
})
177+
178+
it("works with table format", () => {
179+
setOutputOptions({ maxLines: 2 })
180+
const data = [{ name: "Alice" }, { name: "Bob" }, { name: "Charlie" }]
181+
const result = formatOutput(data, "table")
182+
const lines = result.split("\n")
183+
expect(lines).toHaveLength(3)
184+
expect(lines[2]).toMatch(/\.\.\. \(\d+ more lines?\)/)
185+
})
186+
})
187+
188+
describe("--fields and --max-lines combined", () => {
189+
it("applies field filtering then truncation", () => {
190+
setOutputOptions({ fields: ["name"], maxLines: 3 })
191+
const data = {
192+
nfts: [
193+
{ name: "A", id: 1 },
194+
{ name: "B", id: 2 },
195+
{ name: "C", id: 3 },
196+
{ name: "D", id: 4 },
197+
],
198+
next: "cursor",
199+
}
200+
const result = formatOutput(data, "json")
201+
expect(result).not.toContain("id")
202+
const lines = result.split("\n")
203+
expect(lines).toHaveLength(4)
204+
expect(lines[3]).toMatch(/\.\.\. \(\d+ more lines?\)/)
205+
})
206+
})
73207
})

0 commit comments

Comments
 (0)