Skip to content

Commit 8f2fb5a

Browse files
feat: add --fields and --max-lines global CLI options (DIS-145) (#38)
* 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> * 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 <ckorhonen@gmail.com> * 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 <ckorhonen@gmail.com> --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
1 parent eb3ef4a commit 8f2fb5a

3 files changed

Lines changed: 227 additions & 7 deletions

File tree

src/cli.ts

Lines changed: 25 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 EXIT_API_ERROR = 1
@@ -42,6 +42,11 @@ program
4242
.option("--base-url <url>", "API base URL")
4343
.option("--timeout <ms>", "Request timeout in milliseconds", "30000")
4444
.option("--verbose", "Log request and response info to stderr")
45+
.option(
46+
"--fields <fields>",
47+
"Comma-separated list of fields to include in output",
48+
)
49+
.option("--max-lines <lines>", "Truncate output after N lines")
4550

4651
function getClient(): OpenSeaClient {
4752
const opts = program.opts<{
@@ -76,6 +81,25 @@ function getFormat(): OutputFormat {
7681
return "json"
7782
}
7883

84+
program.hook("preAction", () => {
85+
const opts = program.opts<{
86+
fields?: string
87+
maxLines?: string
88+
}>()
89+
let maxLines: number | undefined
90+
if (opts.maxLines) {
91+
maxLines = parseIntOption(opts.maxLines, "--max-lines")
92+
if (maxLines < 1) {
93+
console.error("Error: --max-lines must be >= 1")
94+
process.exit(2)
95+
}
96+
}
97+
setOutputOptions({
98+
fields: opts.fields?.split(",").map(f => f.trim()),
99+
maxLines,
100+
})
101+
})
102+
79103
program.addCommand(collectionsCommand(getClient, getFormat))
80104
program.addCommand(nftsCommand(getClient, getFormat))
81105
program.addCommand(listingsCommand(getClient, getFormat))

src/output.ts

Lines changed: 59 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 != null) {
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,36 @@ 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+
return pickFields(data as Record<string, unknown>, fields)
102+
}
103+
return data
104+
}
105+
106+
function truncateOutput(text: string, maxLines: number): string {
107+
const lines = text.split("\n")
108+
if (lines.length <= maxLines) return text
109+
const omitted = lines.length - maxLines
110+
return (
111+
lines.slice(0, maxLines).join("\n") +
112+
`\n... (${omitted} more line${omitted === 1 ? "" : "s"})`
113+
)
114+
}

test/output.test.ts

Lines changed: 143 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,141 @@ 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("picks matching fields from wrapper objects", () => {
92+
setOutputOptions({ fields: ["nfts", "next"] })
93+
const data = {
94+
nfts: [
95+
{ identifier: "1", name: "NFT #1" },
96+
{ identifier: "2", name: "NFT #2" },
97+
],
98+
next: "cursor123",
99+
extra: "dropped",
100+
}
101+
const result = JSON.parse(formatOutput(data, "json"))
102+
expect(result).toEqual({
103+
nfts: [
104+
{ identifier: "1", name: "NFT #1" },
105+
{ identifier: "2", name: "NFT #2" },
106+
],
107+
next: "cursor123",
108+
})
109+
})
110+
111+
it("filters fields on a bare array", () => {
112+
setOutputOptions({ fields: ["name"] })
113+
const data = [
114+
{ name: "Alice", age: 30 },
115+
{ name: "Bob", age: 25 },
116+
]
117+
const result = JSON.parse(formatOutput(data, "json"))
118+
expect(result).toEqual([{ name: "Alice" }, { name: "Bob" }])
119+
})
120+
121+
it("ignores fields that do not exist", () => {
122+
setOutputOptions({ fields: ["name", "nonexistent"] })
123+
const data = { name: "test", value: 42 }
124+
const result = JSON.parse(formatOutput(data, "json"))
125+
expect(result).toEqual({ name: "test" })
126+
})
127+
128+
it("filters top-level fields on objects with array properties", () => {
129+
setOutputOptions({ fields: ["name", "collection"] })
130+
const data = {
131+
name: "Cool Cats",
132+
collection: "cool-cats",
133+
description: "A cool collection",
134+
contracts: [{ address: "0x1", chain: "ethereum" }],
135+
editors: ["alice"],
136+
fees: [{ fee: 250, recipient: "0x2", required: true }],
137+
}
138+
const result = JSON.parse(formatOutput(data, "json"))
139+
expect(result).toEqual({
140+
name: "Cool Cats",
141+
collection: "cool-cats",
142+
})
143+
})
144+
145+
it("returns primitives unchanged", () => {
146+
setOutputOptions({ fields: ["name"] })
147+
expect(formatOutput("hello", "json")).toBe('"hello"')
148+
})
149+
150+
it("works with table format", () => {
151+
setOutputOptions({ fields: ["name"] })
152+
const data = [
153+
{ name: "Alice", age: 30 },
154+
{ name: "Bob", age: 25 },
155+
]
156+
const result = formatOutput(data, "table")
157+
expect(result).toContain("name")
158+
expect(result).not.toContain("age")
159+
})
160+
})
161+
162+
describe("--max-lines option", () => {
163+
it("truncates output exceeding max lines", () => {
164+
setOutputOptions({ maxLines: 3 })
165+
const data = { a: 1, b: 2, c: 3, d: 4, e: 5 }
166+
const result = formatOutput(data, "json")
167+
const lines = result.split("\n")
168+
expect(lines).toHaveLength(4)
169+
expect(lines[3]).toBe("... (4 more lines)")
170+
})
171+
172+
it("does not truncate when output fits within max lines", () => {
173+
setOutputOptions({ maxLines: 100 })
174+
const data = { a: 1 }
175+
const result = formatOutput(data, "json")
176+
expect(result).not.toContain("...")
177+
expect(result).toBe(JSON.stringify(data, null, 2))
178+
})
179+
180+
it("uses singular 'line' for exactly one omitted line", () => {
181+
setOutputOptions({ maxLines: 2 })
182+
const data = { a: 1 }
183+
const result = formatOutput(data, "json")
184+
const lines = result.split("\n")
185+
expect(lines[lines.length - 1]).toBe("... (1 more line)")
186+
})
187+
188+
it("works with table format", () => {
189+
setOutputOptions({ maxLines: 2 })
190+
const data = [{ name: "Alice" }, { name: "Bob" }, { name: "Charlie" }]
191+
const result = formatOutput(data, "table")
192+
const lines = result.split("\n")
193+
expect(lines).toHaveLength(3)
194+
expect(lines[2]).toMatch(/\.\.\. \(\d+ more lines?\)/)
195+
})
196+
})
197+
198+
describe("--fields and --max-lines combined", () => {
199+
it("applies field filtering then truncation", () => {
200+
setOutputOptions({ fields: ["name"], maxLines: 3 })
201+
const data = [
202+
{ name: "A", id: 1 },
203+
{ name: "B", id: 2 },
204+
{ name: "C", id: 3 },
205+
{ name: "D", id: 4 },
206+
]
207+
const result = formatOutput(data, "json")
208+
expect(result).not.toContain("id")
209+
const lines = result.split("\n")
210+
expect(lines).toHaveLength(4)
211+
expect(lines[3]).toMatch(/\.\.\. \(\d+ more lines?\)/)
212+
})
213+
})
73214
})

0 commit comments

Comments
 (0)