Skip to content

Commit 7bb6103

Browse files
jason-rlclaude
andcommitted
feat: make download path optional with stdout support
- Change `object download <id> <path>` to `object download <id> [path]` - When path is omitted, auto-resolve using object name/content_type via the new getDefaultDownloadPath utility - When path is `-`, write downloaded data to stdout - Warn on stderr when writing binary content types to a TTY - Structured output (-o json) goes to stderr when using stdout mode Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent e09175d commit 7bb6103

2 files changed

Lines changed: 59 additions & 8 deletions

File tree

src/commands/object/download.ts

Lines changed: 55 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,18 +5,46 @@
55
import { writeFile } from "fs/promises";
66
import { getClient } from "../../utils/client.js";
77
import { output, outputError } from "../../utils/output.js";
8+
import { getDefaultDownloadPath } from "../../utils/downloadPath.js";
9+
import { processUtils } from "../../utils/processUtils.js";
810

911
interface DownloadObjectOptions {
1012
id: string;
11-
path: string;
13+
path?: string;
1214
extract?: boolean;
1315
durationSeconds?: number;
1416
output?: string;
1517
}
1618

19+
/** Content types that produce non-text (binary) output */
20+
const BINARY_CONTENT_TYPES = new Set([
21+
"binary",
22+
"gzip",
23+
"tar",
24+
"tgz",
25+
"unspecified",
26+
]);
27+
1728
export async function downloadObject(options: DownloadObjectOptions) {
1829
try {
1930
const client = getClient();
31+
const isStdout = options.path === "-";
32+
33+
// Resolve the download path when not provided or when writing to stdout
34+
// (stdout mode still needs content_type for the TTY binary warning)
35+
let resolvedPath = options.path;
36+
let contentType: string | undefined;
37+
if (!resolvedPath || isStdout) {
38+
const obj = await client.objects.retrieve(options.id);
39+
contentType = obj.content_type;
40+
if (!resolvedPath) {
41+
resolvedPath = getDefaultDownloadPath(
42+
obj.name,
43+
obj.id,
44+
obj.content_type,
45+
);
46+
}
47+
}
2048

2149
// Get the download URL
2250
const downloadUrlResponse = await client.objects.download(options.id, {
@@ -32,19 +60,40 @@ export async function downloadObject(options: DownloadObjectOptions) {
3260
// Save the file
3361
const arrayBuffer = await response.arrayBuffer();
3462
const buffer = Buffer.from(arrayBuffer);
35-
await writeFile(options.path, buffer);
63+
64+
if (isStdout) {
65+
// Warn if writing binary data to a terminal
66+
if (
67+
processUtils.stdout.isTTY &&
68+
contentType &&
69+
BINARY_CONTENT_TYPES.has(contentType)
70+
) {
71+
processUtils.stderr.write(
72+
"Warning: writing binary data to terminal; pipe to a file or command instead\n",
73+
);
74+
}
75+
// Raw process.stdout for binary data (processUtils.stdout.write only accepts string)
76+
process.stdout.write(buffer);
77+
} else {
78+
await writeFile(resolvedPath, buffer);
79+
}
3680

3781
// TODO: Handle extraction if requested (options.extract)
3882

3983
const result = {
4084
id: options.id,
41-
path: options.path,
85+
path: isStdout ? "-" : resolvedPath,
4286
extracted: options.extract || false,
4387
};
4488

45-
// Default: just output the local path for easy scripting
46-
if (!options.output || options.output === "text") {
47-
console.log(options.path);
89+
if (isStdout) {
90+
// Structured output goes to stderr to avoid mixing with data (always JSON)
91+
if (options.output && options.output !== "text") {
92+
processUtils.stderr.write(JSON.stringify(result, null, 2) + "\n");
93+
}
94+
} else if (!options.output || options.output === "text") {
95+
// Default: just output the local path for easy scripting
96+
console.log(resolvedPath);
4897
} else {
4998
output(result, { format: options.output, defaultFormat: "json" });
5099
}

src/utils/commands.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -630,8 +630,10 @@ export function createProgram(): Command {
630630
});
631631

632632
object
633-
.command("download <id> <path>")
634-
.description("Download object to local file")
633+
.command("download <id> [path]")
634+
.description(
635+
"Download object to local file (path defaults to ./<name> with inferred extension; use - for stdout)",
636+
)
635637
.option("--extract", "Extract downloaded archive after download")
636638
.option(
637639
"--duration-seconds <seconds>",

0 commit comments

Comments
 (0)