Skip to content

Commit 419a961

Browse files
jason-rlclaude
andauthored
feat: smart default download path + stdin/stdout support (#222)
## Summary - **Optional download path**: `object download <id> [path]` — when path is omitted, auto-generates from object name + content_type with smart extension inference. Use `-` to write to stdout. - **Stdin upload**: `object upload - --name <name> --content-type <type>` reads from stdin. Also auto-detected when stdin is piped with no paths (e.g. `echo data | rli obj upload --name foo --content-type text`). Zero-byte pipes upload an empty object rather than printing the URL. - **URL-only upload**: `object upload --name <name>` with no paths and no piped stdin creates the object and prints the pre-signed upload URL to stdout, enabling external upload workflows (e.g. `curl -X PUT -T file "$(rli object upload --name foo)"`) - **processUtils async I/O**: `stdout.write`/`stderr.write` accept `string | Buffer` (was `string` only), and `stdin` is now `AsyncIterable<Buffer>` — upload and download go through the mockable abstraction instead of `process` globals directly - **TUI improvement**: Default download path in both list and detail TUI screens now infers extensions from content_type ### Extension inference rules (case-insensitive) | Content type | No suffix | Mismatched suffix | Special | |---|---|---|---| | `text` | → `.txt` | no change | — | | `binary` | no change | no change | — | | `gzip` | → `.gz` | → append `.gz` | `.tar` → `.tgz` | | `tar` | → `.tar` | → append `.tar` | — | | `tgz` | → `.tgz` | → append `.tgz` | — | This is the complement of `adjustFileExtension()` from runloop-fe PR #1714 — that strips extensions for mount paths (post-decompression), this adds extensions for download paths. ## Test plan - [x] 40 unit tests for `inferDownloadExtension` and `getDefaultDownloadPath` pass - [x] 4 unit tests for 0-paths URL-only upload mode pass - [x] 9 unit tests for stdin upload (explicit `-` path and piped stdin auto-detection, including 0-byte pipes) - [x] 7 unit tests for download command (file path, auto-resolve, stdout, binary TTY warning, structured stderr output) - [ ] `rl object download <id>` without path → verify auto-generated filename has correct extension - [ ] `rl object download <id> custom.out` → verify explicit path still works - [ ] `rl object download <id> -` → verify data goes to stdout; verify TTY warning for binary types - [ ] `echo "hello" | rl object upload --name test --content-type text` → verify upload from stdin (no `-` needed) - [ ] `echo -n '' | rl object upload --name test --content-type text` → verify 0-byte upload (not URL mode) - [ ] `rl object upload - --name test --content-type text` → verify explicit stdin upload - [ ] `rl object upload --name test-url --content-type text` → verify prints upload URL (no pipe) - [ ] TUI: select download on objects with various content types → verify pre-filled path has correct extension 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 6e7a8b3 commit 419a961

11 files changed

Lines changed: 1074 additions & 74 deletions

File tree

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -147,8 +147,8 @@ rli blueprint from-dockerfile # Create a blueprint from a Dockerfile
147147
```bash
148148
rli object list # List objects
149149
rli object get <id> # Get object details
150-
rli object download <id> <path> # Download object to local file
151-
rli object upload <paths...> # Upload file(s) or directory as an obj...
150+
rli object download <id> [path] # Download an object. Omit path to save...
151+
rli object upload [paths...] # Upload an object. Reads from piped st...
152152
rli object delete <id> # Delete an object (irreversible)
153153
```
154154

src/commands/object/download.ts

Lines changed: 54 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,39 @@ 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+
processUtils.stdout.write(buffer);
76+
} else {
77+
await writeFile(resolvedPath, buffer);
78+
}
3679

3780
// TODO: Handle extraction if requested (options.extract)
3881

3982
const result = {
4083
id: options.id,
41-
path: options.path,
84+
path: isStdout ? "-" : resolvedPath,
4285
extracted: options.extract || false,
4386
};
4487

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

src/commands/object/list.tsx

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import { useListSearch } from "../../hooks/useListSearch.js";
2424
import { useNavigation } from "../../store/navigationStore.js";
2525
import { formatFileSize } from "../../services/objectService.js";
2626
import { ConfirmationPrompt } from "../../components/ConfirmationPrompt.js";
27+
import { getDefaultDownloadPath } from "../../utils/downloadPath.js";
2728

2829
interface ListOptions {
2930
name?: string;
@@ -471,8 +472,13 @@ const ListObjectsUI = ({
471472
} else if (operationKey === "download") {
472473
// Show download prompt
473474
setSelectedObject(selectedObjectItem);
474-
const defaultName = selectedObjectItem.name || selectedObjectItem.id;
475-
setDownloadPath(`./${defaultName}`);
475+
setDownloadPath(
476+
getDefaultDownloadPath(
477+
selectedObjectItem.name,
478+
selectedObjectItem.id,
479+
selectedObjectItem.content_type,
480+
),
481+
);
476482
setShowDownloadPrompt(true);
477483
} else if (operationKey === "delete") {
478484
// Show delete confirmation
@@ -497,8 +503,13 @@ const ListObjectsUI = ({
497503
// Download hotkey - show prompt
498504
setShowPopup(false);
499505
setSelectedObject(selectedObjectItem);
500-
const defaultName = selectedObjectItem.name || selectedObjectItem.id;
501-
setDownloadPath(`./${defaultName}`);
506+
setDownloadPath(
507+
getDefaultDownloadPath(
508+
selectedObjectItem.name,
509+
selectedObjectItem.id,
510+
selectedObjectItem.content_type,
511+
),
512+
);
502513
setShowDownloadPrompt(true);
503514
} else if (input === "d") {
504515
// Delete hotkey - show confirmation

src/commands/object/upload.ts

Lines changed: 114 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { createTar, createTarGzip } from "nanotar";
88
import type { TarFileInput } from "nanotar";
99
import { getClient } from "../../utils/client.js";
1010
import { output, outputError } from "../../utils/output.js";
11+
import { processUtils } from "../../utils/processUtils.js";
1112

1213
interface UploadObjectOptions {
1314
paths: string[];
@@ -161,79 +162,145 @@ export async function createTarBuffer(
161162
return Buffer.from(data);
162163
}
163164

165+
async function readStdinBuffer(): Promise<Buffer> {
166+
const chunks: Buffer[] = [];
167+
for await (const chunk of processUtils.stdin) {
168+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
169+
}
170+
return Buffer.concat(chunks);
171+
}
172+
164173
export async function uploadObject(options: UploadObjectOptions) {
165174
try {
166175
const client = getClient();
167176
const { paths, name, contentType, output: outputFormat } = options;
168177

169178
if (paths.length === 0) {
170-
outputError("At least one path is required");
171-
return;
172-
}
179+
if (!processUtils.stdin.isTTY) {
180+
// Piped stdin detected — normalize to explicit stdin path below
181+
paths.push("-");
182+
} else {
183+
// Interactive terminal: print pre-signed upload URL
184+
if (!name) {
185+
outputError("--name is required when no paths are provided");
186+
}
187+
const resolvedContentType: ContentType =
188+
(contentType as ContentType) || "unspecified";
173189

174-
// Validate all paths exist (use lstat to match collectEntries and detect symlinks)
175-
// Key by resolved absolute path so collectEntries can reuse stats
176-
const statsMap = new Map<string, Awaited<ReturnType<typeof lstat>>>();
177-
for (const p of paths) {
178-
try {
179-
const s = await lstat(p);
180-
if (s.isSymbolicLink()) {
181-
outputError(
182-
`Path is a symlink: ${p}. Resolve the symlink or pass the target path directly.`,
183-
);
184-
return;
190+
const createResponse = await client.objects.create({
191+
name,
192+
content_type: resolvedContentType,
193+
});
194+
195+
if (!createResponse.upload_url) {
196+
outputError("API did not return an upload URL");
197+
}
198+
199+
const result = {
200+
id: createResponse.id,
201+
name,
202+
contentType: resolvedContentType,
203+
uploadUrl: createResponse.upload_url,
204+
};
205+
206+
if (!outputFormat || outputFormat === "text") {
207+
console.log(createResponse.upload_url);
208+
} else {
209+
output(result, { format: outputFormat, defaultFormat: "json" });
185210
}
186-
statsMap.set(resolve(p), s);
187-
} catch {
188-
outputError(`Path does not exist: ${p}`);
189211
return;
190212
}
191213
}
192214

193-
const isTarType = contentType === "tar" || contentType === "tgz";
194-
const isSinglePath = paths.length === 1;
195-
const firstStats = isSinglePath
196-
? statsMap.get(resolve(paths[0]))!
197-
: undefined;
198-
const singleIsDir = isSinglePath && firstStats!.isDirectory();
215+
const hasStdin = paths.includes("-");
216+
const isStdin = paths.length === 1 && hasStdin;
199217

200-
// Multi-path requires tar/tgz content type
201-
if (paths.length > 1 && !isTarType) {
218+
// stdin cannot be mixed with other paths (e.g. `upload - file1.txt`)
219+
if (hasStdin && !isStdin) {
202220
outputError(
203-
"Multiple paths require --content-type tar or --content-type tgz",
221+
"Cannot mix stdin (-) with other paths. Use - alone or provide only file/directory paths.",
204222
);
205-
return;
206223
}
207224

208-
// Directory without tar/tgz type
209-
if (singleIsDir && !isTarType) {
210-
outputError(
211-
"Cannot upload a directory directly. Use --content-type tar or --content-type tgz to create an archive.",
212-
);
213-
return;
225+
if (isStdin) {
226+
if (!name) {
227+
outputError("--name is required when uploading from stdin");
228+
}
229+
if (!contentType) {
230+
outputError("--content-type is required when uploading from stdin");
231+
}
214232
}
215233

216234
let fileBuffer: Buffer;
217235
let detectedContentType: ContentType;
218236
let fileSize: number;
219237

220-
const shouldCreateArchive = isTarType && (paths.length > 1 || singleIsDir);
221-
222-
if (shouldCreateArchive) {
223-
const gzip = contentType === "tgz";
224-
fileBuffer = await createTarBuffer(paths, gzip, statsMap);
225-
detectedContentType = contentType as ContentType;
238+
if (isStdin) {
239+
fileBuffer = await readStdinBuffer();
226240
fileSize = fileBuffer.length;
241+
detectedContentType = contentType as ContentType;
227242
} else {
228-
// Single file upload (existing behavior)
229-
const filePath = paths[0];
230-
fileBuffer = await readFile(filePath);
231-
fileSize = fileBuffer.length;
243+
// Validate all paths exist (use lstat to match collectEntries and detect symlinks)
244+
// Key by resolved absolute path so collectEntries can reuse stats
245+
const statsMap = new Map<string, Awaited<ReturnType<typeof lstat>>>();
246+
for (const p of paths) {
247+
try {
248+
const s = await lstat(p);
249+
if (s.isSymbolicLink()) {
250+
outputError(
251+
`Path is a symlink: ${p}. Resolve the symlink or pass the target path directly.`,
252+
);
253+
return;
254+
}
255+
statsMap.set(resolve(p), s);
256+
} catch {
257+
outputError(`Path does not exist: ${p}`);
258+
return;
259+
}
260+
}
232261

233-
detectedContentType = contentType as ContentType;
234-
if (!detectedContentType) {
235-
const ext = extname(filePath).toLowerCase();
236-
detectedContentType = CONTENT_TYPE_MAP[ext] || "unspecified";
262+
const isTarType = contentType === "tar" || contentType === "tgz";
263+
const isSinglePath = paths.length === 1;
264+
const firstStats = isSinglePath
265+
? statsMap.get(resolve(paths[0]))!
266+
: undefined;
267+
const singleIsDir = isSinglePath && firstStats!.isDirectory();
268+
269+
// Multi-path requires tar/tgz content type
270+
if (paths.length > 1 && !isTarType) {
271+
outputError(
272+
"Multiple paths require --content-type tar or --content-type tgz",
273+
);
274+
return;
275+
}
276+
277+
// Directory without tar/tgz type
278+
if (singleIsDir && !isTarType) {
279+
outputError(
280+
"Cannot upload a directory directly. Use --content-type tar or --content-type tgz to create an archive.",
281+
);
282+
return;
283+
}
284+
285+
const shouldCreateArchive =
286+
isTarType && (paths.length > 1 || singleIsDir);
287+
288+
if (shouldCreateArchive) {
289+
const gzip = contentType === "tgz";
290+
fileBuffer = await createTarBuffer(paths, gzip, statsMap);
291+
detectedContentType = contentType as ContentType;
292+
fileSize = fileBuffer.length;
293+
} else {
294+
// Single file upload (existing behavior)
295+
const filePath = paths[0];
296+
fileBuffer = await readFile(filePath);
297+
fileSize = fileBuffer.length;
298+
299+
detectedContentType = contentType as ContentType;
300+
if (!detectedContentType) {
301+
const ext = extname(filePath).toLowerCase();
302+
detectedContentType = CONTENT_TYPE_MAP[ext] || "unspecified";
303+
}
237304
}
238305
}
239306

src/screens/ObjectDetailScreen.tsx

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import { Breadcrumb } from "../components/Breadcrumb.js";
3232
import { Header } from "../components/Header.js";
3333
import { ConfirmationPrompt } from "../components/ConfirmationPrompt.js";
3434
import { colors } from "../utils/theme.js";
35+
import { getDefaultDownloadPath } from "../utils/downloadPath.js";
3536

3637
interface ObjectDetailScreenProps {
3738
objectId?: string;
@@ -249,8 +250,13 @@ export function ObjectDetailScreen({ objectId }: ObjectDetailScreenProps) {
249250
switch (operation) {
250251
case "download":
251252
// Show download prompt
252-
const defaultName = resource.name || resource.id;
253-
setDownloadPath(`./${defaultName}`);
253+
setDownloadPath(
254+
getDefaultDownloadPath(
255+
resource.name,
256+
resource.id,
257+
resource.content_type,
258+
),
259+
);
254260
setShowDownloadPrompt(true);
255261
break;
256262
case "delete":

0 commit comments

Comments
 (0)