Skip to content

feat: add 'wherobots files my-files' commands for the user-files API - #37

Merged
zongsizhang merged 8 commits into
mainfrom
files-my-files-commands
Sep 23, 2026
Merged

zongsizhang merged 8 commits into
mainfrom
files-my-files-commands

Conversation

@zongsizhang

@zongsizhang zongsizhang commented Sep 23, 2026 •

Copy link
Copy Markdown
Contributor

What this delivers

A new hand-written command group, wherobots files my-files, for the per-user Files area (the user-files API under /storage/{storage_id}/...):
ls, mkdir, upload, download, cat, mv, rm, rmdir — single-file operations only.

Examples

# List a folder (no argument lists the root; --output json is also available)
$ wherobots files my-files ls reports
TYPE    SIZE    MODIFIED              NAME
folder  -       -                     2026/
file    1.2 MB  2026-09-20T14:03:11Z  summary.csv

# Create a nested folder; each missing level is created in turn
$ wherobots files my-files mkdir reports/2026/q3
Created folder reports/2026/q3

# Upload: local file first, then the remote path (a trailing "/" keeps the local name)
$ wherobots files my-files upload ./q3.csv reports/2026/q3/
Uploaded ./q3.csv to reports/2026/q3/q3.csv

# Download (without a local path it is saved under its own name in the current folder)
$ wherobots files my-files download reports/2026/q3/q3.csv ./q3-copy.csv
Downloaded reports/2026/q3/q3.csv to ./q3-copy.csv

# Print a file to stdout
$ wherobots files my-files cat reports/2026/q3/q3.csv

# Rename a file within its folder
$ wherobots files my-files mv reports/2026/q3/q3.csv q3-final.csv
Renamed reports/2026/q3/q3.csv to q3-final.csv

# Delete one file
$ wherobots files my-files rm reports/2026/q3/q3-final.csv
Deleted reports/2026/q3/q3-final.csv

# Delete an empty folder; a non-empty one is refused
$ wherobots files my-files rmdir reports/2026/q3
Deleted folder reports/2026/q3
$ wherobots files my-files rmdir reports/2026
folder is not empty: reports/2026/ (use --recursive to delete it and everything in it)

# Delete a folder and everything in it (-r for short)
$ wherobots files my-files rmdir --recursive reports/2026
Deleted folder reports/2026

# Use another region instead of the organization's default
$ wherobots files my-files --region aws-us-west-2 ls

The three error kinds:

$ wherobots files my-files --region aws-eu-west-1 ls
Files is not enabled for my-files in region aws-eu-west-1

$ wherobots files my-files ls
not signed in (the API answered 401) — run `wherobots auth login`, or set WHEROBOTS_API_KEY to a valid API key

$ wherobots files my-files cat reports/q4.csv
no such file or folder: reports/q4.csv

Details

  • Drive abstraction (internal/files/drive.go): today one drive, my-files, resolved to user_files::<region>. A future shared drive only needs a new kind and its storage-id mapping; all operations live on one drive type (internal/files/ops.go).
  • The Files {path} param keeps /: each segment is percent-encoded separately, never %2F (BuildRequestMultiSegment in internal/executor/request.go). This applies only to the Files commands; plain BuildRequest, used by the dynamic api ... commands, still escapes / as %2F.
  • Download follows the 307 itself and streams from the presigned S3 URL without any API key / auth header (internal/executor/download.go). It writes to a temporary file and moves it into place only on success; Ctrl-C cleans up.
  • Listing always sends an explicit limit and paginates by cursor; the folder's own marker entry is not shown.
  • mkdir of a nested path creates each level one at a time, client side.
  • mv renames within the same folder only; a target in another folder is refused before any request.
  • rmdir refuses a non-empty folder unless --recursive / -r is given.
  • Errors distinguish "Files not enabled for drive/region", "not authenticated", and "no such file".
  • Region comes from --region, else the organization's defaultRegion. No STS / POST /storage/credentials.
  • README and AGENTS.md updated.

Look hardest at

  • internal/files/ops.go DeleteDir: without --recursive, refuses on a non-empty next_page or any named entry; a marker-only single page with no next_page is treated as empty. The check and the delete are separate requests, so content added in between is deleted too.
  • Mkdir treats a 409 as "this level already exists" — assumed from the API, not confirmed against the server — then lists the parent and fails only if the name is a file and not also a folder.
  • --dry-run still issues GET /organization to resolve the region when --region isn't given (documented in README).

Left out

Recursive/bulk transfer, streaming uploads beyond the existing 500 MB cap, cross-folder moves, 5xx retries, confirmation prompts.

Verified

make fmt, make test, make build, go vet ./... all pass. Tests use mocked HTTP only; no live credentials.

🤖 Generated with Claude Code

zongsizhang and others added 2 commits September 22, 2026 19:52
Add a curated `files` -> `my-files` command group (ls, mkdir, upload,
download, cat, mv, rm, rmdir) over the /storage/{storage_id}/... file
routes, with the drive model in internal/files so a future shared drive
only adds a drive kind and a command.

- Path params keep "/": each segment is escaped separately, which also
  fixes the generated `api storage ...` commands for nested paths.
- Downloads resolve the 307 without following it and fetch the
  presigned URL with no auth or client headers.
- Listing always sends limit=1000 and follows next_page.
- Nested mkdir creates each level in order; 409 means it exists.
- 401/404 map to "not signed in", "Files is not enabled for my-files
  in region X" and "no such file or folder".
- rmdir refuses a non-empty folder unless --recursive.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… lookup

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@zongsizhang
zongsizhang requested a review from a team as a code owner September 23, 2026 02:55
`upload <local-file> <remote-path>`, matching cp/scp/aws s3 cp.
The previous `<remote-path> <local-file>` order was a mistake.
`download` is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Comment thread internal/commands/files.go Outdated
Comment thread internal/executor/download.go
Comment thread internal/commands/files.go
zongsizhang and others added 2 commits September 22, 2026 23:31
- download over an existing file keeps its permissions; a new file gets
  0666 less the umask instead of a fixed 0644
- the presigned GET sends Accept-Encoding: identity so Go does not gunzip
  a Content-Encoding: gzip object
- main runs on a context cancelled by SIGINT/SIGTERM, so an interrupted
  transfer unwinds through cleanup and leaves no .partial file

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The global signal context in main also cancelled job-runs poll requests,
so Ctrl-C during `job-runs logs --follow` printed "context canceled" and
exited 1 instead of "Detached.". main is back on context.Background();
upload, download and cat now wrap their own context with
signal.NotifyContext, released after the first signal.

TestFilesDownloadInterruptLeavesNoFile sends a real SIGINT mid-download
and checks the .partial file is removed (unix only).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@salty-hambot salty-hambot Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed by Salty Hambot 🤖🧂 — rubric mode

Verdict: ⚠️ concerns

Dimension Verdict Notes
correctness ⚠️ concerns ls surfaces the folder self-marker as a phantom / entry, which DeleteDir already filters but List does not.
security ✅ pass Presigned download/upload strip Wherobots credentials (tested); slash-escaping change only affects the caller's own request path.
privacy ✅ pass No PII handling; transfers go straight to presigned storage with no auth or client headers.
reliability ⚠️ concerns Non-recursive rmdir's emptiness check remains a TOCTOU before a recursive server delete (already documented, prior thread 3 unresolved).
scalability ✅ pass Listing paginates with an explicit limit and a hard page cap; no regressions.
observability ✅ pass Dry-run curl output is thorough and the rmdir delete is now flagged as conditional.
clarity/maintainability ✅ pass Clean drive/ops separation with well-documented helpers; the parseEntries marker inconsistency is the main readability snag.
test quality ⚠️ concerns Broad coverage of pagination, dry-run, permissions, and credential-stripping, but no test asserts ls of a folder-marker-only listing filters the phantom entry.

Prior threads mostly landed — the mv guard, slash-escaping scope, and rmdir dry-run caveat all have code and tests now. One genuinely new snag: ls still hands back the folder self-marker as a phantom / entry, even though rmdir already knows to ignore it. Filter it once and this is good to go.

Prior findings: ✅ 4 resolved · ⚠️ 1 still open

1 finding(s) posted.
💰 Review cost: $3.3401 · 758.1k in / 10.9k out tokens · ⏱️ 2m21s
💬 To request a re-review, comment @salty-hambot review

Comment thread internal/executor/request.go
Comment thread internal/files/ops.go Outdated
Comment thread internal/files/ops.go
Comment thread internal/files/ops.go Outdated
Comment thread internal/files/ops.go
zongsizhang and others added 2 commits September 22, 2026 23:48
- Keep "/" unescaped only in the Files {path} param (BuildRequestMultiSegment);
  dynamic API commands escape "/" as %2F again, as on main.
- mv refuses a folder source (trailing "/") before any request.
- mkdir: after a 409, list the parent and fail if the name is a file.
- rmdir: document that the emptiness check is not atomic; dry-run notes
  that the DELETE is contingent on the folder being empty.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
After a 409, requireNotFile returned the file error on the first non-folder
match. S3-style storage can list both a file b and a folder b/; the folder
exists, so mkdir now continues when any match is a folder.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Comment thread internal/files/ops.go
List returned the folder's own empty-name marker as an entry, so ls of an
empty folder showed a nameless folder. parseEntries now skips it, and
DeleteDir's emptiness check relies on that.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@zongsizhang
zongsizhang requested a review from a team September 23, 2026 06:57
@zongsizhang
zongsizhang merged commit 94202af into main Sep 23, 2026
3 checks passed
@zongsizhang
zongsizhang deleted the files-my-files-commands branch September 23, 2026 18:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

2 participants