# Drive Integration
omni-dev exposes access to the Google Drive v3 API through the `omni-dev
drive` command tree — search, read a file's metadata or content, find
duplicates, rename a file, move it between folders, and create/upload/edit
file content, trash individual files, and restore them. `drive.readonly`
(the default scope) is enough for
search/read/dedupe/sync; rename/move need the opt-in `drive.metadata` scope
(`drive auth login --write`), the narrowest write scope Google offers — it
covers `files.update` on `name`/`parents`/`trashed`, with no file-content access
at all. Content mutation needs a broader grant still: `--write-file`
(`drive.file`, app-created files only) or `--write-full` (the unrestricted
`drive` scope, needed to edit any pre-existing file). `drive trash` and
`drive untrash` use metadata write access and a separate local `trash`
permission. Permanent deletion and share/permission-mutation are absent.
**Move is security-gated.** Moving a file can change who can see it — Drive
resolves a file's effective visibility from both direct permissions on the
file and permissions inherited from its parent folder chain, and moving a
file changes that chain. `drive move` refuses any move that would change
visibility **by default**; three independent `--allow-*` flags opt in. See
[Move](#move) and [ADR-0070](adrs/adr-0070.md) for the full design.
**Create/upload/edit/trash/untrash are gated by a second, independent, local
permission system.** Google's OAuth scopes are all-or-nothing across your
*entire* Drive — there's no way to grant "write access to just this
folder." `write_permissions` rules in `settings.json` are omni-dev's own
policy layer filling that gap: read defaults open, every write defaults
**refused everywhere** until a rule explicitly grants it for that folder.
Both the OAuth scope and the local gate must allow an operation — neither
alone is sufficient. See [Write permissions](#write-permissions) and
[ADR-0071](adrs/adr-0071.md) for the full design.
The [MCP tool surface](mcp.md#drive-15-tools) includes file, Docs and Sheets
reads, Docs replace/append, Sheets write/append/clear, and `drive_lease_acquire`.
The content write tools use the same operator rules, leases, freshness checks
and audit paths as the CLI. Per-call `account` selects credentials, rules and
native backup folder together. Preview first (`dry_run: true`), acquire a backup
lease through the device-owner prompt, then supply the token to the write.
Operator headless/biometrics policy applies unchanged; no tool can set it.
Plain Drive writes, Docs/Sheets create and typed structure/format/delete/protection
operations, and lease restore/release/prune remain CLI-only. The MCP reference
explains refusal statuses, stale-lease renewal and token recovery after a timeout.
New to this integration? Follow the
[Drive Quickstart](drive-quickstart.md) for a linear, zero-to-first-search
walkthrough — this page is the topic-by-topic reference.
## Table of Contents
1. [Prerequisites](#prerequisites)
2. [Authentication](#authentication)
3. [Multiple accounts](#multiple-accounts)
4. [Output formats](#output-formats)
5. [Search](#search)
6. [Read](#read)
7. [Duplicate detection](#duplicate-detection)
8. [Sync](#sync)
9. [Rename](#rename)
10. [Move](#move)
11. [Write permissions](#write-permissions)
12. [Create](#create)
13. [Upload](#upload)
14. [Edit](#edit)
15. [Trash and restore](#trash-and-restore)
16. [Lease](#lease)
17. [Sheets](#sheets)
18. [Docs](#docs)
19. [Slides](#slides)
20. [Rate limits and retry behaviour](#rate-limits-and-retry-behaviour)
21. [Troubleshooting](#troubleshooting)
22. [See also](#see-also)
## Prerequisites
`drive.readonly` is a Google **restricted scope** — an application
distributed to third parties that requests it must pass a Google CASA
security assessment with annual recertification. omni-dev doesn't carry
that burden, so **each user creates their own Google Cloud OAuth2 client**
— the same model as [Gmail](gmail.md#prerequisites):
1. Create (or reuse) a project in the [Google Cloud console].
2. Enable the **Google Drive API** for that project.
3. Create an OAuth2 client of type **Desktop app** (not "Web
application" — the loopback-redirect flow below requires it).
4. Note the client's **Client ID** and **Client secret**.
5. When you run `drive auth login` below, Google's consent screen lists
Drive as its own separate permission tick-box, distinct from the basic
profile/email checkboxes it also requests. **Explicitly tick it.**
Leaving it unticked makes login fail immediately with an error naming
the scopes Google actually granted — no Drive scope at all — instead of
writing an unusable refresh token to `settings.json`. See
[Troubleshooting](#no-drive-scope-was-granted) for the exact error.
**Prominent callout:** a freshly created OAuth2 client's consent screen
defaults to **Testing** publishing status. In that status, Google expires
issued refresh tokens after **7 days**, so `omni-dev drive auth login` will
need to be re-run weekly until you push the project to **In production**
(no Google verification review is required below 100 test users for a
self-scoped read-only request). See [Troubleshooting](#invalid_grant) for
the error this produces.
To go to **In production**: OAuth consent screen → **Publish App**. This
by itself does not trigger a verification review — the next time you (or
any of your up-to-100 test users) sign in, Google shows an "unverified
app" interstitial; click **Advanced → Go to `<your project>` (unsafe)**
to proceed. That warning is expected and permanent for a project like
this one — it's not a sign anything is misconfigured, and it's the
tradeoff for not taking on CASA. **Don't upload a logo** on the Branding
page: Google requires a full verification review (including CASA for
restricted scopes like `drive`/`drive.readonly`) before it will display a
logo, so uploading one moves your project onto that track even though
you never asked for a review. Branding fields otherwise (app name,
support email) don't trigger it.
A second, Drive-only OAuth2 client/consent screen is perfectly fine — the
`drive` settings block is wholly independent of `gmail`'s (see
[Multiple accounts](#multiple-accounts) and [ADR-0069](adrs/adr-0069.md)).
Reusing the *same* Google Cloud project with both the Gmail and Drive APIs
enabled on one OAuth client is equally valid. It's your choice either way —
omni-dev doesn't impose either shape.
[Google Cloud console]: https://console.cloud.google.com/
## Authentication
### Environment variables
| Variable | Purpose | Default |
|-----------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------|
| `DRIVE_CLIENT_ID` | OAuth2 client id from your own Google Cloud project (required). | _none_ |
| `DRIVE_CLIENT_SECRET` | OAuth2 client secret for the same client (required). Also accepts a `DRIVE_CLIENT_SECRET_FILE` companion (below). | _none_ |
| `DRIVE_REFRESH_TOKEN` | Written by `drive auth login`; not meant to be hand-set. Also accepts a `DRIVE_REFRESH_TOKEN_FILE` companion (below). | _none_ |
| `DRIVE_SCOPE` | Written by `drive auth login`; records the granted scope(s) — any combination of `drive.readonly`, `drive.metadata` (`--write`), `drive.file` (`--write-file`), and `drive` (`--write-full`) — so `auth status` can report it without a network call. | _none_ |
| `DRIVE_API_URL` | Explicit API base URL; overrides the real `www.googleapis.com` host entirely. Use for a proxy or a forced egress gateway. | _unset_ |
Unlike Gmail, there is **no `drive auth import`** — no
`client_secret.json`-import path exists for Drive. `DRIVE_CLIENT_ID`/
`DRIVE_CLIENT_SECRET` can only reach `drive auth login` two ways: set them
by hand (in your shell profile, or in `~/.omni-dev/settings.json`'s `env`
map), or leave them unset and `drive auth login` prompts for them
interactively — the client id echoes normally, the secret does not.
`DRIVE_CLIENT_SECRET` and `DRIVE_REFRESH_TOKEN` also accept `_FILE`
companions (`DRIVE_CLIENT_SECRET_FILE`, `DRIVE_REFRESH_TOKEN_FILE`) naming
an absolute path to a file holding the secret, instead of the value itself —
the Docker/Kubernetes secrets convention. The file must be a regular file that is either yours and owner-only (`chmod 600`), or owned by root and not writable by others (the shape Kubernetes and Docker secrets take), and setting both `NAME`
and `NAME_FILE` in the same place (both exported, or both in the same settings.json map) is an error. See [ADR-0089](adrs/adr-0089.md).
Unlike Gmail, there is no other meaning for `DRIVE_CLIENT_SECRET_FILE` to
collide with.
Both also accept a `_COMMAND` companion that fetches the secret on demand from
a password manager or keychain; see [secret-commands.md](secret-commands.md).
`drive auth login` refuses, before opening a browser, when the map it would write
to holds one for the secret it would replace.
### Interactive setup
```bash
$ omni-dev drive auth login
DRIVE_CLIENT_ID is not set. Create an OAuth2 client id in Google Cloud Console (see docs/adrs/adr-0069.md) and set DRIVE_CLIENT_ID, or paste it here.
Client id: 123456789-abc.apps.googleusercontent.com
Client secret:
Credentials saved to ~/.omni-dev/settings.json
Granted scope: https://www.googleapis.com/auth/drive.readonly
Run `omni-dev drive auth status` to verify.
```
This opens a browser to Google's consent screen via a loopback OAuth2
authorization-code + PKCE flow (see [ADR-0063](adrs/adr-0063.md), inherited
unchanged by [ADR-0069](adrs/adr-0069.md)); once you approve, the refresh
token is written to `~/.omni-dev/settings.json`. By default this requests
only `drive.readonly`. Three independent flags request more, combinable
freely in one call:
| Flag | Scope requested | Needed for |
|----------------|------------------------|------------------------------------------------------------------------------------------------------|
| `--write` | `drive.metadata` | `drive rename`/`drive move` |
| `--write-file` | `drive.file` | `drive create`/`drive upload`, and `drive edit` on files `omni-dev` itself created |
| `--write-full` | `drive` (unrestricted) | `drive edit` on any pre-existing file — the largest privilege grant this integration ever requests |
```bash
$ omni-dev drive auth login --write --write-file --write-full
```
Every flag requests its scope *alongside* `drive.readonly`, never as a
replacement — none of `drive.metadata`/`drive.file`/`drive` alone grants
read access, so `search`/`read` still need `drive.readonly` too. Google's
consent screen lists each as a separate permission tick-box; tick all that
apply to the flags you passed. Re-run `drive auth login` with more flags at
any time to upgrade an existing login — Google's `prompt=consent` re-issues
a fresh refresh token with the broader grant.
`--write-file` alone cannot edit a file that already existed in your Drive
before `omni-dev` touched it — Google restricts `drive.file` to files this
app itself created via that scope. `drive edit` on any pre-existing file
needs `--write-full`, the only scope that can. Requesting `--write-full` is
a significant privilege escalation (unrestricted read/write over your
*entire* Drive) — the [Write permissions](#write-permissions) gate below is
what bounds it to specific folders in practice.
### Verifying credentials
```bash
$ omni-dev drive auth status
Checking Drive authentication...
Authenticated as: user@example.com
Granted scope: drive.readonly
```
After a `--write --write-file` login, this instead reports `Granted scope:
drive.readonly, drive.metadata, drive.file` — every granted scope, listed
in the order shown in the [Interactive setup](#interactive-setup) table
above. This calls `about.get`, a live network call.
Pass `--all` to report every configured named account (see
[Multiple accounts](#multiple-accounts)) in one call instead of just the
resolved one:
```bash
$ omni-dev drive auth status --all
== work ==
Checking Drive authentication...
Authenticated as: alice@work.com
Granted scope: drive.readonly, drive.metadata
== personal ==
Checking Drive authentication...
Authenticated as: alice@gmail.com
Granted scope: drive.readonly
```
`--all` degenerates to the single-account output above when no named
accounts are configured. Each successful check also backfills that
account's cached `email_address` in `settings.json` if it isn't already
set (never used for authentication itself — only for the browser-profile
targeting below) — an explicit value, whether you set it by hand or a
previous check backfilled it, is never overwritten.
### Removing credentials
```bash
$ omni-dev drive auth logout
Drive credentials removed from ~/.omni-dev/settings.json
```
Idempotent: if no credentials are configured, it prints
`No Drive credentials were configured.` and exits successfully. Removes
the resolved account (see [Multiple accounts](#multiple-accounts) below) —
pass `--account NAME` to target a specific named account.
## Multiple accounts
`--profile` (see [Prerequisites](#prerequisites) and
[ADR-0045](adrs/adr-0045.md)) selects a whole credential bundle — Atlassian,
Datadog, the Claude API key, Gmail, *and* Drive all at once. That's the
wrong tool for "I just want a second Drive account while everything else
about my environment stays the same," so Drive accounts are a second,
independent axis: named entries in a `drive` block of
`~/.omni-dev/settings.json`, selected per invocation via an `--account
NAME` flag or the `OMNI_DEV_DRIVE_ACCOUNT` environment variable (AWS-CLI
style, mirroring `--profile`). `--account` is scoped to the `drive`
command tree — usable after the `drive` subcommand name, but not before it,
since it isn't a CLI-wide flag (this also keeps it from colliding with
Snowflake's own unrelated `snowflake ... --account`). See
[ADR-0069](adrs/adr-0069.md) for the full design rationale, and
[ADR-0066](adrs/adr-0066.md) for the Gmail precedent it applies unchanged.
Unlike Gmail, **there is no `drive account import-legacy`** — Drive is a
brand-new feature with no pre-existing single-account credential state to
migrate from. An installation with no configured `drive` accounts simply
starts `Unconfigured`; that's the normal starting state, not a
compatibility shim.
### Configuring accounts
Create a second (or subsequent) account the same way you configured the
first, adding `--account NAME`:
```bash
$ omni-dev drive auth login --account personal
```
`--account` need not already exist — `auth login` is how an account comes
into existence. Every other Drive command (`search`, `read`, `auth
status`, `auth logout`) also accepts `--account NAME` to target a specific
account, and every `drive_*` MCP tool accepts the equivalent `account`
parameter.
### Managing accounts
```bash
$ omni-dev drive account list
NAME EMAIL SCOPE DEFAULT
personal alice@gmail.com https://www.googleapis.com/auth/drive.readonly
work alice@work.com https://www.googleapis.com/auth/drive.readonly *
$ omni-dev drive account set-default work
Default Drive account set to 'work'.
```
`drive account list` reads only `settings.json` — no network call, no
secret ever rendered. With no accounts configured, it prints
`No named Drive accounts configured. Run \`omni-dev drive auth login
--account <name>\` to create one.`
### Resolution order
When a command runs, the account it uses is resolved in this order:
1. A literal `DRIVE_CLIENT_ID`/`DRIVE_CLIENT_SECRET`/`DRIVE_REFRESH_TOKEN`
set directly in the process environment bypasses account resolution
entirely — a scripting/CI convenience, not a migration path (there's
nothing to migrate).
2. `--account NAME` / `OMNI_DEV_DRIVE_ACCOUNT`, if set, selects that named
account. An unknown name is a hard error listing the accounts that
*are* configured — never a silent fallback to the wrong account.
3. No explicit account, with one or more named accounts configured: the
configured default (`drive account set-default`) if it still names a
real account, else the sole account if exactly one is configured, else
a hard error naming both remedies (`pass --account or run
\`drive account set-default <name>\``).
4. No named accounts configured at all: falls through to the literal-env
values above, or a clear "not configured, run `drive auth login`"
error if those are absent too.
### Keeping an account's secrets in files
A named account's `client_secret` and `refresh_token` can live in files
instead of in `settings.json`. Replace either field with its `_file`
companion, which holds an absolute path to a file whose contents are the
secret ([#2008](https://github.com/rust-works/omni-dev/issues/2008)):
```json
"drive": {
"accounts": {
"work": {
"client_id": "123456789-abc.apps.googleusercontent.com",
"client_secret_file": "/Users/me/.secrets/google-oauth-client-secret",
"refresh_token_file": "/Users/me/.secrets/drive-work-refresh-token"
}
}
}
```
The file rules are the ones `DRIVE_REFRESH_TOKEN_FILE` uses
([ADR-0089](adrs/adr-0089.md)). The path must be absolute. The file must be
a regular file, and either yours and owner-only (`chmod 600`) or owned by
root and not writable by others. One trailing newline is ignored. Setting a
field and its `_file` on the same account is an error that names both keys.
When `drive auth login` writes a new secret to an account whose entry already has
the `_file` field, the value goes **into that file** and the plain field is
removed from `settings.json`. Accounts without a `_file` field are written
exactly as before. The file rules are:
- A file that already holds the same value is left alone, so one shared file
or a root-owned read-only mount keeps working.
- Any other file is replaced atomically with a new owner-only file, but only
if the file-reading rules above would accept it (an empty file is also
fine). A file with loose permissions, a file owned by someone else, a
dangling symlink or a file holding JSON is refused and left untouched. The
file's directory must already exist.
- A `client_secret_file` holding another value is not replaced when the
login's client id differs from the account's `client_id`. That file most
likely belongs to another OAuth client's accounts.
- Every file is checked before any is written, and `settings.json` is
written last, so a refused write changes nothing.
Several accounts that use one Google Cloud OAuth client can point their
`client_secret_file` at the **same** file, so the secret is kept in one
place. To rotate it, write the new secret to that file. Then, for each
account, run `drive auth login --account <name>` with the client secret
supplied through `DRIVE_CLIENT_SECRET_FILE` set to that same path, or
pasted at the prompt. Each login writes the account's new refresh token
into its own `refresh_token_file`, and leaves the shared file alone, since
it already holds that value.
`drive auth logout` removes the account's entry from `settings.json`. It
does not delete the files the entry named, because they may be shared or
not yours to delete.
### Browser profile targeting
With several named accounts, `drive auth login` opening whatever profile
your default browser happens to be on means you have to switch Google
identities by hand on the consent screen — easy to get wrong, and it can
land the refresh token on the wrong account entirely. Two escape hatches,
both configured per account in `settings.json`'s `drive.accounts.<name>`
and both opt-in — inherited by field from Gmail's
([ADR-0067](adrs/adr-0067.md)), since the browser-targeting UX is
orthogonal to which Google API is being authorized:
**Manual — `browser_command`.** An explicit launch command, with `{url}`
substituted for the authorization URL (or appended, if no `{url}`
placeholder is present). Takes precedence over automatic resolution below.
Works for any browser, not just Chrome:
```json
"drive": {
"accounts": {
"jky.greens": {
"browser_command": "open -na \"Google Chrome\" --args --profile-directory=\"Profile 7\" {url}"
}
}
}
```
**Automatic — `chrome_profile_from_email`.** Set this `true` alongside
`email_address` (see [Verifying credentials](#verifying-credentials) above
— set it by hand, or let `drive auth status --all` backfill it after a
first login) and `drive auth login` looks up which local Chrome profile is
signed into that address, launching the authorization URL targeting it
instead of the OS default browser:
```json
"drive": {
"accounts": {
"jky.greens": {
"email_address": "jky.greens@example.com",
"chrome_profile_from_email": true
}
}
}
```
Chrome-only for now (no Chromium/Brave/Edge support yet — use
`browser_command` for those). Resolution reads Chrome's own `Local State`
file and never guesses: zero matching profiles or more than one profile
signed into the same address both fall back to the OS default browser
rather than picking one — resolution failure is always a fallback, never a
login failure.
## Output formats
Every subcommand that renders a list or record (`search`, `read`, `dedupe`,
`rename`, `move`, `create`, `upload`, `edit`, `account list`,
`permissions show`/`check`, `sheets info`, `sheets read`) accepts
`-o <format>` (`table` / `json` / `yaml` / `yamls` / `jsonl`, default `table`) — the same convention as every
other `omni-dev` domain (see [ADR-0046](adrs/adr-0046.md)). `auth login`/
`auth logout`/`auth status`/`account set-default` print a fixed
human-readable status line instead and have no `-o` flag. `--out-file`
exists only on `drive read --content` — metadata always renders via
`-o/--output`. One command reads `table` unusually: `drive sheets read`
renders CSV for it, since a grid of cells is what a spreadsheet range *is*
(see [Sheets](#sheets)).
## Search
```bash
$ omni-dev drive search "name contains 'report'"
$ omni-dev drive search "mimeType = 'application/vnd.google-apps.folder'" --limit 20
$ omni-dev drive search "'1AbCdEfGhIjKlMnOpQrStUvWxYz' in parents"
```
The query is passed **verbatim** to `files.list`'s `q` parameter — omni-dev
does not reinterpret it. It's [Drive's own query language], not Gmail's
search syntax: `name contains 'report'`, `'<folder-id>' in parents`
(browsing a folder's contents is just a query, not a separate subcommand),
`mimeType = 'application/vnd.google-apps.folder'`, and operators can be
combined with `and`/`or`. `--limit 0` fetches every match up to a 10,000
hard cap, auto-paginating underneath (1,000 results per page).
Unlike `gmail search`, there is **no `--enrich`/concurrency split**:
`files.list` returns full metadata (id/name/mimeType/modifiedTime/size/
md5Checksum/sha1Checksum/sha256Checksum/...) per hit in one call via the
`fields` parameter, so there's no separate hydration step to opt into.
Every search also sends
`supportsAllDrives=true` and `includeItemsFromAllDrives=true`
unconditionally — results aren't silently scoped to My Drive only; there's
no flag to control this because there's no reason to turn it off.
[Drive's own query language]: https://developers.google.com/workspace/drive/api/guides/search-files
## Read
```bash
$ omni-dev drive read 1AbCdEfGhIjKlMnOpQrStUvWxYz
$ omni-dev drive read 1AbCdEfGhIjKlMnOpQrStUvWxYz --content
$ omni-dev drive read 1AbCdEfGhIjKlMnOpQrStUvWxYz --content --out-file report.pdf
$ omni-dev drive read 1AbCdEfGhIjKlMnOpQrStUvWxYz --content --verify --out-file report.pdf
$ omni-dev drive read <google-doc-id> --content
$ omni-dev drive read <google-sheet-id> --content --export-mime-type text/csv
```
Without `--content`, `drive read` returns metadata only:
`Id`/`Name`/`MimeType`/`Size`/`Modified`/`Parents`/`WebViewLink`/
`Md5Checksum`/`Sha1Checksum`/`Sha256Checksum` (optional fields shown only
if present). Pass `--content` to fetch the file's actual bytes instead:
- **Regular files** (PDFs, images, plain text, ...) are downloaded as-is
via `alt=media`.
- **Google-native files** (Docs/Sheets/Slides/Forms/Drawings/...) have no
raw bytes — they're exported via `/export?mimeType=...`. Default export
MIME types: Google Docs → `text/markdown`, Google Sheets → `text/csv`
(first sheet only — Drive's export API has no multi-sheet CSV format),
Google Slides → `text/plain`. Every other Google-native type (Forms,
Drawings, Apps Script, Sites, ...) has no safe default — omitting
`--export-mime-type` for one of these errors out, naming the file's
actually-supported export MIME types (from `exportLinks`) so you know
what to pass.
- **Folders and shortcuts** are rejected with an actionable error rather
than silently returning nothing — see
[Troubleshooting](#reading-a-folder-or-shortcuts-content).
Without `--out-file`, texty content (`text/*` or `application/json`) that
decodes as valid UTF-8 prints directly to stdout; anything else refuses
with `refusing to print binary content ... use --out-file`. `--out-file`
writes the bytes to disk instead and prints a short confirmation
(`Saved N bytes to <path> (mimeType: ...).`) — the only place `--out-file`
is valid; passing it without `--content` is a hard error.
**Size caps:** `files.export` inherits Drive's own **10 MB** export cap
server-side (surfaces as an ordinary API error if a Google-native file is
too large to export). Raw `alt=media` downloads are capped client-side at
**500 MB** via the response's declared `Content-Length` — a download
whose length exceeds that is refused before any bytes are buffered into
memory. A missing `Content-Length` (e.g. chunked encoding) passes through
unchecked.
**Content hashes:** `md5Checksum`/`sha1Checksum`/`sha256Checksum` are
present only for binary-content files — absent for folders and
Google-native documents, which have no fixed byte content to hash. `md5`
has the broadest historical coverage (sha1/sha256 were added to the Drive
API later, so a very old, untouched file may carry only `md5`). These
fields aren't shown by `drive search`'s table renderer; use `-o
json`/`-o yaml`/`-o jsonl` to see them there. `drive read`'s table output
shows them directly (see above).
**Verifying downloaded content:** pass `--content --verify` to locally
recompute the SHA-256 checksum of the downloaded bytes and check it
against Drive's reported `sha256Checksum`, printing a one-line
confirmation on success. Fails clearly on a mismatch or on a file with no
`sha256Checksum` reported. Only supported for regular (non-Google-native)
files — Drive never returns a checksum for exported content, so
`--verify` on a Google-native file errors immediately rather than
exporting first.
## Duplicate detection
```bash
$ omni-dev drive dedupe "'1AbCdEfGhIjKlMnOpQrStUvWxYz' in parents"
$ omni-dev drive dedupe "name contains 'invoice'" --limit 0 -o json
```
`drive dedupe` reuses the same bulk-search path as `drive search` —
`files.list` already returns `md5Checksum` per hit, so finding duplicates
needs no per-file follow-up call. It groups the query's results by
`md5Checksum` (the broadest-coverage checksum field — see [Content
hashes](#read) above), keeping only groups with 2 or more files; a file
with no checksum (a folder or Google-native document) is skipped
entirely. The query argument and `--limit` behave exactly like `drive
search`'s.
Table output columns: `HASH | COUNT | FILES`, with `FILES` a comma-joined
`name (id)` list. An empty result prints `No duplicate files found.`. Pass
`-o json`/`-o yaml`/`-o jsonl` for machine-readable output instead.
Grouping is currently fixed to `md5Checksum` — there's no `--by` flag to
choose `sha1Checksum`/`sha256Checksum` instead.
## Sync
```bash
omni-dev drive sync FOLDER_ID --dest ./reference-docs
omni-dev drive sync FOLDER_ID --dest ./reference-docs --dry-run -o json
omni-dev drive sync FOLDER_ID --dest ./reference-docs --verify
omni-dev drive sync FOLDER_ID --dest ./pdf-copies --export-mime-type application/pdf
```
`sync` recursively mirrors one Drive folder's children into a local directory.
It uses only `drive.readonly`; no Drive content or metadata is changed. The
positional argument is a folder ID, rather than a search query. Shortcuts are
skipped, cycles and repeated IDs are visited once, and trashed items are excluded.
An incomplete page, repeated pagination token, listing failure, or a folder reaching the 10,000-item
listing cap aborts discovery before local writes.
The first run requires an empty (or nonexistent) destination. Later runs use
`<DIR>/.omni-dev-sync.json`; keep that file with the mirror. A different root,
unsupported manifest version, or invalid path in the manifest is refused.
The version-1 JSON records the root folder ID and a `files` map keyed by Drive
ID, including folders. Entries contain `rel_path`, `remote_name`, `mime_type`,
`export_mime_type`, `modified_time`, `md5`, `sha256`, `size`, and `pending`.
`orphan_paths` reserves old paths left behind by renames and export changes.
Files and the manifest are replaced atomically; the manifest is checkpointed
before replacing content (marked `pending`) and after each successful item.
An interrupted pending write is retried, never treated as unchanged.
Unchanged binaries are skipped when their MD5 matches the manifest (falling back
to a present `modifiedTime` when MD5 is absent). Native exports use a present
`modifiedTime` and matching export MIME. Path or MIME changes, missing local
files, and absent change markers trigger a download. This trusts the manifest
and does not hash local content by default. `--verify` checks both newly
downloaded and skipped binary bytes against Drive's SHA-256; missing checksums
or mismatches fail that file. Native exports have no Drive checksum and continue
without verification. Defaults match `drive read --content`: Docs → `.md`,
Sheets → `.csv` (**first sheet only**), Slides → `.txt`. An explicit export MIME
applies to every native file; unsupported native types without one fail individually.
Binary downloads retain the 500 MiB cap; native exports retain Drive's 10 MB cap.
Remote names are sanitized into portable, bounded path segments. Unsafe and
empty names get safe replacements. Native exports receive an extension matching
the export MIME (unknown MIME types use `.export`). Sibling collisions, including
case differences, receive numeric suffixes in Drive-ID order. The manifest name
and all historical paths are reserved. New collisions do not displace existing
owners. A local file without ownership in the manifest is never overwritten;
pre-existing symlinks in the destination or output paths are refused.
**One-way mirror:** local changes to owned files can be overwritten when Drive
changes. Remote removals, moves, renames and export changes leave old local copies
in place and report them as `orphaned`. Nothing is deleted, and nothing is uploaded.
Use a destination dedicated to this mirror and serialize sync runs; do not modify
the destination concurrently. Symlink checks protect against pre-existing links,
not a hostile process swapping filesystem paths while a run is in progress.
The report lists `created`, `updated`, `skipped`, `failed` and `orphaned` items and
totals, in the usual table/JSON/YAML formats. Individual failures do not stop
other downloads; the command prints the report and exits non-zero if any item
failed. `--dry-run` reports planned create/update counts without downloading
content or writing directories/files/manifest. With `--verify` it may read and
check existing binary copies. Downloads are sequential and reuse client retries.
Query mode, pruning, concurrent downloads and an MCP sync tool are follow-ups.
## Rename
```bash
$ omni-dev drive rename 1AbCdEfGhIjKlMnOpQrStUvWxYz "Q3 Report (final)"
Renamed: Q3 Report -> Q3 Report (final) (1AbCdEfGhIjKlMnOpQrStUvWxYz)
$ omni-dev drive rename 1AbCdEfGhIjKlMnOpQrStUvWxYz "Q3 Report (final)" --dry-run
Would rename: Q3 Report -> Q3 Report (final) (1AbCdEfGhIjKlMnOpQrStUvWxYz)
```
An empty or whitespace-only new name is refused locally — before any API
call, `--dry-run` included — because Drive treats an empty `name` as "no
change" and would otherwise let a no-op read as a successful rename. The
outcome reports the name Drive actually stored, not the string you asked
for: if Drive normalised or ignored it, a `Note: requested … but Drive
stored …` line goes to stderr and `-o json`/`yaml` output gains a
`requested_name` field (omitted when the two match). Renaming a file to the
name it already has prints `Already named: …` instead of `Renamed: a -> a`
(`--dry-run` prints `Would not rename: already named …`). If Drive answers
200 but keeps the old name although a different one was asked for, the
command fails (`Drive accepted the request but kept the name …`) rather
than reporting success; the request log records that attempt with status
`ignored`, and a same-name rename with status `unchanged`.
Renaming only ever touches a file's `name` field — it never changes
`parents`, so it can never change who can see the file (Drive resolves
visibility from direct permissions plus permissions inherited from the
parent folder chain; renaming doesn't touch either). There is nothing to
gate, unlike [Move](#move): `drive rename` always proceeds, subject only to
the ordinary API/auth failures below.
Requires the `drive.metadata` scope (`drive auth login --write`). Without
it, the rename fails with an actionable hint:
```
Error: Drive API request failed: HTTP 403: Insufficient Permission (reason: insufficientPermissions)
Run `omni-dev drive auth login --write` to grant the drive.metadata scope needed for rename/move
```
Every rename attempt — success or failure — is written to the
[request log](log.md) as a `kind: "drivemutation"` record, tagged
`service: "drive"`, carrying the file id, name, and outcome status. This is
a hard invariant, not a best-effort convenience: logging happens inside the
rename engine itself, not the CLI layer, so it holds for every current and
future caller.
## Move
```bash
$ omni-dev drive move 1AbCdEfGhIjKlMnOpQrStUvWxYz --to 1FolderIdGoesHere
STATUS NAME DETAIL
moved Q3 Report (final)
$ omni-dev drive move 1AbCd... 1Efgh... --to 1FolderId --dry-run
STATUS NAME DETAIL
would-move Q3 Report (final)
blocked Confidential Salary Data visibility increase (--allow-visibility-increase); adds user:external@partner.com
```
Moving a file can change **who can see it**: Drive resolves a file's
effective visibility from direct permissions on the file *plus* permissions
inherited from its parent folder chain, and moving a file changes that
chain. `drive move` computes the exact visibility diff a move would cause
and, by default, **refuses any move that would change visibility** — an
increase (new principals gain access) or a decrease (existing principals
lose access) either one. Three independent opt-in flags, none implying the
others:
- `--allow-visibility-increase` — proceed even if the move would grant new
principals access.
- `--allow-visibility-decrease` — proceed even if the move would revoke
existing principals' access.
- `--allow-drive-boundary-crossing` — proceed even if the move crosses a My
Drive / Shared Drive boundary (independent of the visibility diff — a
boundary crossing can block a move that changes nobody's *access*, only
which Drive the file lives in).
**Bulk moves skip only the unsafe files, never fail the whole batch.**
`drive move ID1 ID2 ID3... --to FOLDER` shares one destination across every
file id given; a file whose move is blocked is reported as `blocked` and
left where it is, while every other file in the same call still moves. Pass
multiple file ids to move them all into the same folder in one call;
different files to different destinations needs separate calls.
A file already in the destination folder is reported `already-in-folder`
and never touched (no `permissions.list` call is even made for it). A
folder being moved gets a loud warning — its own visibility is evaluated,
but v1 does not recurse into a moved folder's contents, so their visibility
is not:
```
Warning: 'Old Projects' is a folder — its own visibility was evaluated, but its contents' visibility was not (folder moves don't recurse in v1).
```
**No interactive confirmation, `--dry-run` or not.** `--dry-run` plus the
`--allow-*` flags are the entire gate — an interactive-by-default confirm
would hang (or be silently force-skipped) over a future MCP caller, and
every flag passed is already captured in the request log's `command_line`.
`--dry-run` never calls the mutating `files.update` endpoint; the same
`permissions.list` reads back the exact plan a real run would act on.
**Exit code is always 0** as long as the command mechanically completed —
individual `blocked`/`failed` outcomes live in the table/JSON output, not
the exit code (the same convention `worktree push` uses). Check the output
if scripting against this.
Requires the `drive.metadata` scope (`drive auth login --write`), same as
[Rename](#rename) — see its [troubleshooting
entry](#insufficientpermissions-on-rename-or-move) for the actionable hint
on a 403.
Every move attempt — moved, blocked, already-in-folder, or failed — is
written to the [request log](log.md) as a `kind: "drivemutation"` record.
A `blocked` record carries the specific `added_principals`/
`removed_principals` that triggered it, so a refusal is fully auditable
even though no API call was made:
```bash
$ omni-dev log --query 'kind:drivemutation status:blocked'
```
**Known limitation — shadowed grants.** Drive's API doesn't expose whether
a principal's access on a file is direct or inherited (that split is only
populated for Shared Drive items, not My Drive files), so `drive move`
derives it by subtraction. If a principal has *both* a direct grant on the
file *and* inherited access via its current parent, the subtraction can't
tell them apart — a move that only removes the parent-inherited grant is
reported as revoking that principal's access, even though their direct
grant means they actually keep it. This is a **safe failure direction**: it
can only produce an unnecessary `--allow-visibility-decrease` requirement,
never a missed visibility increase. See
[ADR-0070](adrs/adr-0070.md) for the full algorithm.
## Write permissions
`drive create`/`drive upload`/`drive edit`, and `drive sheets
write`/`append`/`clear`/`create`/`add-sheet`/`rename-sheet`/`insert-rows`/
`insert-columns`/`insert-range`/`move-rows`/`move-columns` (below), need a much broader OAuth grant
than rename/move — `--write-file`/`--write-full` — but Google's
scopes are all-or-nothing across your whole Drive. There's no way to tell
Google "only let this credential write inside folder X." So `omni-dev` adds
its own, independent, local policy layer on top: an allow/deny rule list
in `settings.json` — scoped to a folder, or to a single file — evaluated
**before** any mutating API call is attempted, regardless of what the OAuth
scope would technically permit.
**Default policy** — what applies when no configured rule names an
operation anywhere in a target's ancestor chain:
| Operation | Default | Granted to |
|---------------------|---------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `read` | allow | `search`, `read`, `dedupe` (not yet enforced) |
| `create` | deny | `create`, `sheets create` |
| `upload` | deny | `upload` |
| `edit` | deny | `edit` — raw file content only |
| `sheets-write` | deny | `sheets write`, `sheets append`, `sheets clear`, `sheets find-replace`, `sheets trim-whitespace` — cell values; `sheets text-to-columns` (also needs `sheets-structure`); `sheets sort-range` (also needs `sheets-structure`); `sheets randomize-range` (also needs `sheets-structure`); `sheets set-basic-filter --sort-by` (also needs `sheets-structure`); `sheets add-pivot-table` (also needs `sheets-structure`), `delete-pivot-table`, `sheets auto-fill`; `cut-paste`/`copy-paste`/`paste-data` with a value-only `--paste-type` (`cut-paste` also needs `sheets-structure` always; `copy-paste`/`paste-data` also need it for `--paste-type normal`) |
| `sheets-structure` | deny | `sheets add-sheet`, `rename-sheet`, `insert-rows`, `insert-columns`, `insert-range`, `move-rows`, `move-columns`, `duplicate-sheet`, `reorder-sheet`, `hide-sheet`, `show-sheet`, `update-sheet-properties`, `update-workbook-properties`, `format-cells`, `update-borders`, `merge-cells`, `unmerge-cells`, `auto-resize-dimension`, `update-dimension-properties`, `set-data-validation`, `clear-data-validation`, `set-developer-metadata`, `delete-developer-metadata`, `set-basic-filter` (with `--sort-by`, also needs `sheets-write`), `clear-basic-filter`, `add-filter-view`, `update-filter-view`, `delete-filter-view`, `add-conditional-format`, `update-conditional-format`, `delete-conditional-format`, `add-named-range`, `update-named-range`, `delete-named-range`, `add-chart`, `update-chart`, `delete-chart`, `add-slicer`, `update-slicer`, `delete-slicer`, `move-chart`, `move-slicer`, `update-chart-border`, `add-pivot-table` (also needs `sheets-write`), `text-to-columns` (also needs `sheets-write`), `sort-range` (also needs `sheets-write`), `randomize-range` (also needs `sheets-write`), `add-banding`, `update-banding`, `delete-banding`, `add-dimension-group`, `update-dimension-group`, `delete-dimension-group`, `cut-paste` (always, alongside `sheets-write`), `copy-paste`/`paste-data` with `--paste-type format` (alone) or `--paste-type normal` (also needs `sheets-write`) |
| `sheets-delete` | deny | `sheets delete-sheet`, `delete-rows`, `delete-columns`, `delete-range`, `delete-duplicates` |
| `sheets-protection` | deny | `sheets protect-range`, `update-protection`, `unprotect-range` |
| `docs-write` | deny | `docs replace`, `docs append`, `docs insert`, `docs replace-named-range-content` with nonempty text |
| `slides-write` | deny | `slides replace` |
| `trash` | deny | `trash`, `untrash` (individual files only) |
| `docs-delete` | deny | `docs delete` (anchor-addressed), `docs replace-named-range-content` with empty text — content removal |
| `docs-format` | deny | `docs text-style` / `docs paragraph-style` — anchor-addressed formatting |
| `docs-structure` | deny | `docs insert-table`, `docs insert-table-row`, `docs insert-table-column` — empty structural additions; `docs create-named-range`, `docs delete-named-range` — named-range metadata only |
| `docs-table-delete` | deny | `docs delete-table-row`, `docs delete-table-column` — removes content in one dimension; separate consent |
There is no "enabled: true" flag — an absent or empty rule list already
means "deny every write everywhere," via this table alone, which *is* the
disabled state.
**`sheets-write` is deliberately separate from `edit`.** Writing cells is a
content mutation, so folding it into `edit` would have been the obvious
choice — but every `allow: ["edit"]` rule that exists today was written when
`drive edit` refused every Google-native document outright. Reusing `edit`
would have retroactively turned those rules into cell-write permission with
no config change and no re-consent. If you want a folder's existing `edit`
grant to cover Sheets too, add `sheets-write` to it explicitly. See
[ADR-0073](adrs/adr-0073.md) §3.
**`sheets-structure` is separate from `sheets-write` for the same reason.**
Every `allow: ["sheets-write"]` rule that exists today was written when
structural edits were impossible, so reusing it would have turned those
rules into permission to restructure a workbook — again with no config
change and no re-consent. Granting one does not grant the other; name both
if you want both. See [ADR-0075](adrs/adr-0075.md) §1.
**`sheets-delete` is separate from `sheets-structure`, for the same reason
again.** Every `allow: ["sheets-structure"]` rule that exists today was
written when deletion was impossible, so reusing it would have turned those
rules into permission to destroy data — again with no config change and no
re-consent. Granting `sheets-structure` does not grant `sheets-delete`; name
both if you want both. See [ADR-0077](adrs/adr-0077-sheets-deletion-via-batchupdate.md).
**`sheets-structure` also covers formatting, data validation, duplicating a
sheet, and reorder/hide** (issue #1643, [ADR-0078](adrs/adr-0078.md)). None
of that destroys data — `merge-cells` is the one request that discards
non-top-left values, and its `--dry-run` (and real run) names every cell
that would be lost before it happens (past 200 the rendered line elides
the rest; `-o json` keeps them all, issue #1999) — so it earns the same
operation as the original four verbs rather than a new one.
**`sheets-structure` also covers developer-metadata management, restricted
to `DOCUMENT` visibility** (issue #1795, [ADR-0081](adrs/adr-0081.md) §4).
`createDeveloperMetadata`/`updateDeveloperMetadata`/`deleteDeveloperMetadata`
attach or remove key/value annotations on the spreadsheet, a sheet, a row
or a column — none of that is grid data and none of it is a permission
change, so it earns the same operation as everything else here rather than
a new one. The surface reads and writes `DOCUMENT`-visibility metadata
only; `PROJECT`-visibility metadata belongs to whatever OAuth client
created it and is never reachable through this tool. `delete-developer-metadata`
reads back and reports the key, value and location of every entry it would
remove before deleting it, the same preview pattern as `merge-cells`.
`search-developer-metadata` is read-only and ungated, like
`list-protections` below.
**Conditional formatting joins `sheets-structure` too** (issue #1793,
[ADR-0081](adrs/adr-0081.md) §1) — presentational, destroys no data, exactly
like `format-cells`/`set-data-validation`. `list-conditional-formats` is a
plain read and needs no grant, the same as `list-protections`.
**`sheets-structure` also covers named-range add/update/delete** (issue
#1796, [ADR-0081](adrs/adr-0081.md) §2). A named range is a label over a
region, not grid data, so `delete-named-range` leaves every cell's stored
value and formula text untouched — even though a cell formula referencing
the removed name starts evaluating to `#REF!` (conditional formatting,
data validation and chart references are not scanned). That effect is
mitigated the same way `merge-cells`' data loss is: `delete-named-range`'s
`--dry-run` (and real run) scans the workbook's cell formulas for the name
and reports the count and A1 locations of every reference before it
deletes. `drive sheets list-named-ranges` is a plain read and needs no
grant, the same as `list-protections`.
**`sheets-structure` also covers banded ranges — `add-banding`/
`update-banding`/`delete-banding`** (issue #1832, [ADR-0082](adrs/adr-0082-banded-ranges.md)).
A banded range is alternating row/column color applied to a range —
presentation, not data — so `delete-banding` destroys nothing. `drive
sheets list-bandings` is a plain read and needs no grant, the same as
`list-protections`.
**`sheets-structure` also covers dimension groups — `add-dimension-group`/
`update-dimension-group`/`delete-dimension-group`** (issue #1833,
[ADR-0084](adrs/adr-0084-dimension-groups.md)). A dimension group is the
collapsible +/- outline over a row or column span — presentation, not
data — so `delete-dimension-group` destroys nothing. `drive sheets
list-dimension-groups` is a plain read and needs no grant, the same as
`list-bandings`.
**`drive sheets read-cell-format` needs no grant either** (issue #1878): it
reports a range's `userEnteredFormat`, note and data validation rule, and
is the tool that answers whether an above verb's gate should also cover
`sheets-structure` per this section's own "live-verified to move or write
formatting" rule.
**`sheets-protection` is separate from `sheets-structure`, and the reason is
different in kind from every split above.** A protected range is a
*permission* inside the document — who may edit, not what the sheet
contains. `update-protection` can widen who may edit, and
`unprotect-range` removes a guard someone deliberately placed. Folding
either into `sheets-structure` would let a grant meant for "may
reformat/validate/restructure this workbook" silently double as "may also
change who can edit it." See [ADR-0078](adrs/adr-0078.md) §2.
`drive sheets list-protections` is a plain read and needs no grant, the same
as `sheets info`.
**`docs-write` is separate from all four**, and the same argument runs
again. An `allow: ["edit"]` rule predates Docs being reachable through this
tool at all; an `allow: ["sheets-write"]`, `allow: ["sheets-structure"]`,
`allow: ["sheets-delete"]` or `allow: ["sheets-protection"]` rule was
written when the same was true, and all four are about *cells or
who-may-edit-them*, so letting any of them govern prose would make the
config vocabulary say something untrue. Grant `docs-write` explicitly. See
[ADR-0076](adrs/adr-0076.md) §2.
Each write operation is independent in both directions: `docs-write` confers
no cell writes, no structural sheet edits, no destructive sheet edits, no
protection changes, and no raw-content edits either.
Rules live per Drive account, since a folder id only means something inside
the one Drive it came from. A rule keys on **either** a `folder_id` or a
`file_id` — exactly one, never both:
```jsonc
{
"drive": {
"accounts": {
"work": {
"write_permissions": {
"rules": [
{ "folder_id": "1AbC...AiWorkspace", "recursive": true, "allow": ["create", "upload", "edit"] },
{ "folder_id": "1XyZ...DropZone", "recursive": false, "allow": ["create"] },
{ "folder_id": "1Scr...Scratch", "recursive": true, "allow": ["edit"], "require_lease": false },
{ "folder_id": "1Sen...Confidential", "recursive": true, "deny": ["read"] },
// File rules — for a file you can't reach with a folder rule.
{ "file_id": "1Sh4r3d...QuarterlyPlan", "allow": ["sheets-write"] },
{ "file_id": "1Sh4r3d...SignedContract", "deny": ["edit", "sheets-write"] }
]
}
}
}
}
}
```
- `folder_id` — Drive's own canonical folder id, not a path (Drive names
aren't unique, and files can have multiple parents). Find one with
[`drive permissions lookup-folder`](#drive-permissions-lookup-folder)
below.
- `file_id` — Drive's own canonical **file** id, matched against the target
itself. See [Granting a file shared with you](#granting-a-file-shared-with-you).
- `recursive` — when `true`, the rule also matches every descendant of
`folder_id`, not just the folder itself. Only valid on a `folder_id`
rule: a file has no descendants, so `recursive: true` alongside a
`file_id` is a configuration error rather than a no-op.
- `allow`/`deny` — any of `read`, `create`, `upload`, `edit`,
`sheets-write`, `sheets-structure`, `sheets-delete`, `sheets-protection`,
`docs-write`, `trash`, `docs-delete`, `docs-format`, `slides-write`,
`docs-structure`, `docs-table-delete`. A `deny`
entry for `read` is schema-ready today for a future `search`/`read`/
`dedupe` enforcement fast-follow (not wired up yet — see
[ADR-0071](adrs/adr-0071.md) §11); the write operations are enforced now.
- `require_lease` — whether a write this rule decides also *needs* a valid
Drive write lease (`--lease`, [ADR-0080](adrs/adr-0080.md); see
[Lease](#lease)). Defaults to `true`; set `false` to relax it for a
specific folder or file — this skips **both** the backup and the Touch ID
prompt, not just one of them. It does not change what a lease token
*means*: a `--lease` presented anyway is still validated, consumed and
audited exactly as it would be under a requiring rule, and refused if
it's expired, wrong-file, or stale. Orthogonal to `allow`/`deny`: the
lease is checked in addition to this gate's verdict, never instead of it.
A rule that names neither key, names both, or puts `recursive: true` on a
`file_id` is a **settings load error**, not a silently-ignored rule — and
the failure is closed: a settings file that fails to parse yields no rules
at all, so every write is refused until it is fixed.
**Resolution**: a `file_id` rule naming the target is checked first, at
what is effectively **depth −1** — strictly more specific than any folder
rule. Otherwise, for the target's ancestor chain (the folder itself at
depth 0, then its parent, grandparent, …), the closest matching rule wins;
if rules at the same depth disagree, `deny` wins. No matching rule anywhere
falls through to the default policy table above.
Because a file rule is closer than every folder rule, it wins in **both**
directions: a file `deny` overrides a recursive folder `allow`, and a file
`allow` overrides a folder `deny`. That includes the multi-parent case —
"`deny` wins across parents" is a tie-break among a target's several
parents, which are peers of each other; a file rule is not one of them.
`drive create`/`drive upload`/`drive sheets create` resolve the chain from
`--parent`, so a `file_id` rule never applies to them: their target is a
folder, and the file being created has no id yet. `drive edit` and every
`drive sheets` mutating verb resolve from the target file's *current*
parent(s) — unioned across every current parent for a legacy multi-parent
file, with `deny` winning if any parent disagrees — but only after the
`file_id` lookup has come up empty.
#### Granting a file shared with you
`files.get` returns only the parents **this account can see**. A file
shared with you by link or email is not in a folder you can see, so it
comes back with no parents at all — which means no `folder_id` rule you
could write would ever apply to it, and before file rules there was no way
to permit writing to it short of moving it into your own Drive.
A `file_id` rule is the fix. Grab the id out of the URL
(`https://docs.google.com/spreadsheets/d/<id>/edit`) and name it directly:
```jsonc
{ "file_id": "1Sh4r3d...QuarterlyPlan", "allow": ["sheets-write"] }
```
Confirm it with `drive permissions check <id> --operation sheets-write`,
which reports `decided by: rule on file <id>`. When no rule applies, that
same command prints a `note:` line saying the target has no visible parent
— that is the signal to reach for a `file_id` rule rather than hunting for
a folder rule bug.
### Diagnostics
Three read-only subcommands, none of which can ever mutate anything —
useful for authoring and debugging rules before relying on them.
#### `drive permissions show`
```bash
$ omni-dev drive permissions show
SCOPE TARGET_ID RECURSIVE LEASE ALLOW DENY
folder 1AbC...AiWorkspace true true create,edit,upload -
folder 1XyZ...DropZone false false create -
file 1Sh4r3d...QuarterlyPlan - true sheets-write -
```
`RECURSIVE` shows `-` rather than `false` for a file rule: the column has
no meaning there. `LEASE` (ADR-0080 §13) renders `require_lease` directly —
`false` means writes matching that rule skip the write-lease's Touch ID
prompt and backup requirement (below).
Reads only `settings.json` — no network call. With no rules configured, it
explains that every write is refused everywhere and points at the
`write_permissions.rules` key above.
#### `drive permissions lookup-folder`
```bash
$ omni-dev drive permissions lookup-folder "Workspace"
ID NAME PATH
1AbC...AiWorkspace Workspace My Drive/Team/Workspace
```
Searches by name and resolves each hit's full root-to-leaf path (via the
same ancestor-chain walk the gate itself uses), so you can tell apart
same-named folders in different locations before pasting an id into
config.
#### `drive permissions check`
```bash
$ omni-dev drive permissions check 1AbC...AiWorkspace --operation create
target: 1AbC...AiWorkspace
operation: create
verdict: allow
decided by: rule on folder 1AbC...AiWorkspace (depth 0)
$ omni-dev drive permissions check 1Sh4r3d...QuarterlyPlan --operation sheets-write
target: 1Sh4r3d...QuarterlyPlan
operation: sheets-write
verdict: allow
decided by: rule on file 1Sh4r3d...QuarterlyPlan
$ omni-dev drive permissions check 1Unknown...Shared --operation sheets-write
target: 1Unknown...Shared
operation: sheets-write
verdict: deny
decided by: default policy (no matching rule)
note: this target has no parent folder visible to this account, so no
folder_id rule can apply — grant it with a file_id rule instead
```
Evaluates the real configured rules against a real target and operation —
the exact functions `create`/`upload`/`edit`/`sheets write` themselves
call, so this diagnostic can never drift from actual enforcement. Accepts
either a folder id (checked directly) or a file id (its own `file_id`
rules first, then its current parent(s), matching `edit`'s own semantics).
The `note:` line is the one this command exists for: it appears only on a
`deny` against a target with no visible parent, which is the single case
where no `folder_id` rule could ever help. It is gated on the verdict as
well, because `read` defaults to allow on an empty ancestor chain — a
link-shared target checked for `read` is *permitted*, and advice on how to
grant it would read as a refusal that isn't one.
`-o json` adds `decided_by_file_id` and `evaluated_via` (`"file-rule"`,
`"folder-chain"` or `"no-visible-parents"`) alongside the existing
`decided_by_folder_id`/`decided_by_depth`, which keep their exact meaning —
a file id never appears in the folder field.
## Create
```bash
$ omni-dev drive create --name "Notes.txt" --parent 1AbC...AiWorkspace
Created: Notes.txt (1NewFileIdHere) in 1AbC...AiWorkspace
$ omni-dev drive create --name "Notes.txt" --parent 1AbC...AiWorkspace --dry-run
Would create: Notes.txt in 1AbC...AiWorkspace
$ omni-dev drive create --name "Reports" --parent 1AbC...AiWorkspace --folder
Created: Reports (1NewFolderIdHere) in 1AbC...AiWorkspace
```
Creates a new file (metadata only — no content; see [Upload](#upload) to
push local content in) or, with `--folder`, a new folder. `--mime-type`
sets the content type for a plain file (default
`application/octet-stream`); it conflicts with `--folder`, which always
creates `application/vnd.google-apps.folder`.
Gated by [Write permissions](#write-permissions) against `--parent` —
refused before any `files.create` call if no rule allows `create` there.
`--dry-run` classifies against the exact same gate a real run would,
without ever calling `files.create`:
```bash
$ omni-dev drive create --name "x" --parent 1Sen...Confidential --dry-run
Blocked: x in 1Sen...Confidential
refused by default policy (no matching rule)
```
Requires the `drive.file` or `drive` scope (`drive auth login --write-file`
or `--write-full`); without either, the call fails with an actionable hint
naming both flags. Every real attempt — created, blocked, or failed — is
written to the [request log](log.md#what-gets-recorded) as a `kind:
"drivemutation"` record, even when the gate refused before any API call
was made; `--dry-run` previews are never logged.
## Upload
```bash
$ omni-dev drive upload ./report.pdf --parent 1AbC...AiWorkspace
Uploaded: report.pdf (1NewFileIdHere) in 1AbC...AiWorkspace
$ omni-dev drive upload ./report.pdf --parent 1AbC...AiWorkspace --name "Q3 Report.pdf" --dry-run
Would upload: Q3 Report.pdf in 1AbC...AiWorkspace
```
Uploads local content as a new file — everything [Create](#create) does,
plus reading a local file's bytes. `--name` defaults to the local file's
own name; `--mime-type` defaults to `application/octet-stream`.
**5 MB size cap.** Drive's simple (non-resumable) upload endpoint —
the only one this command uses — caps request bodies at 5 MB. The local
file is stat'd and refused *before* it's ever read into memory if it's too
large, so this fires identically whether or not `--dry-run` is set:
```bash
$ omni-dev drive upload ./huge-video.mp4 --parent 1AbC...AiWorkspace
Error: refusing to upload 83886080 bytes (limit: 5242880 bytes); Drive's simple upload endpoint caps requests at 5 MB — larger content needs resumable upload, not supported by `drive upload`/`drive edit` yet
```
Larger content needs Drive's chunked resumable-upload protocol, not
supported by this command in v1 (an explicit, documented boundary — see
[ADR-0071](adrs/adr-0071.md) §10 — not a silent gap).
Same gate, scope requirement, and logging behavior as [Create](#create).
## Edit
```bash
$ omni-dev drive lease acquire 1ExistingFileId
lease-abc123...
Backed up to /home/user/.local/state/omni-dev/drive-backups/20260911T000000Z-1ExistingFileId-report.pdf (expires 2026-09-11 00:30:00 UTC)
$ omni-dev drive edit 1ExistingFileId --content ./new-report.pdf --lease lease-abc123...
Edited: 1ExistingFileId
$ cat ./new-report.pdf | omni-dev drive edit 1ExistingFileId --content - --lease lease-abc123...
Edited: 1ExistingFileId
$ omni-dev drive edit 1ExistingFileId --content ./new-report.pdf --dry-run
Would edit: 1ExistingFileId
```
Replaces an existing file's raw content. `--content` accepts a local path,
or `-` to read from stdin (bounded at the same 5 MB cap — an
unbounded pipe is never buffered past the limit before being refused).
**Requires a Drive write lease** (`--lease`, [ADR-0080](adrs/adr-0080.md)),
unless the deciding write-permission rule sets `require_lease: false` — see
[Lease](#lease) below. Never needed with `--dry-run`.
**Gated differently from create/upload.** Since there's no `--parent` to
check, the gate evaluates the target's *current* parent folder(s) instead
— unioned across every parent for a legacy multi-parent file, with `deny`
winning if any parent disagrees (see [Write
permissions](#write-permissions) above). An orphan file with no parent
falls straight to the default policy (refused).
**Google-native documents are refused outright, before the gate even
runs:**
```bash
$ omni-dev drive edit 1SomeGoogleDocId --content ./file.txt
Refused: 1SomeGoogleDocId is a Google-native document (Docs/Sheets/Slides/...) — no raw content to replace
```
A Docs/Sheets/Slides file has no fixed byte content a raw media `PATCH` can
replace — editing one is a Docs-API/Sheets-API problem, out of scope here
(the same deferral [ADR-0069](adrs/adr-0069.md) already made for Docs
export).
**Scope depends on the file's origin.** `--write-file` (`drive.file`) is
enough only if `omni-dev` itself created the target via `drive
create`/`drive upload`; any other pre-existing file needs the unrestricted
`--write-full`. A 403 names both flags, since the client has no cheap way
to tell which a given file id needs:
```
Error: Drive API request failed: HTTP 403: Insufficient Permission (reason: insufficientPermissions)
Run `omni-dev drive auth login --write-file` if this file was created by omni-dev, or `--write-full` to edit any pre-existing file's content, then retry
```
Same request-log behavior as [Create](#create)/[Upload](#upload).
## Trash and restore
Trash an individual file, or restore it before Drive purges it:
```bash
omni-dev drive trash FILE_ID --dry-run
omni-dev drive trash FILE_ID -o json
omni-dev drive untrash FILE_ID
```
Both commands require metadata write access (`drive auth login --write`) and
an explicit `trash` grant in the selected account's `write_permissions.rules`:
```json
{"folder_id": "FOLDER_ID", "allow": ["trash"]}
```
For a file shared without visible parents, use a `file_id` rule instead.
Existing `create`, `edit` and `sheets-delete` grants do not permit trash.
The same file-ID/current-parent rules apply when restoring; changing or removing
a grant after trash can block untrash. Folder targets are refused for both verbs
because changing a folder's trashed state affects descendants and could bypass
their deny rules. Native Docs/Sheets/Slides and binary files are supported.
A shortcut targets the shortcut itself.
Trash and restore are lease-exempt: no lease ledger or Touch ID prompt, even
with `require_lease: true`. Drive normally purges trashed files after 30 days,
and the owner can empty Trash sooner. Trash is not an indefinite backup, and
collaborators may still access a trashed file before permanent deletion.
Real attempts (including refusals and no-ops) use the existing best-effort
Drive mutation log; dry runs do not write mutation records.
An already-trashed target or an already-restored target is a gated no-op.
A dry run checks local permission but does not prove Google will allow the PATCH.
In My Drive the file owner must perform trash; shared drives require appropriate
organizer/file organizer rights. API permission or scope errors appear as
`failed` outcomes. A file in a trashed parent may remain effectively trashed
when restored; the result reports failure if Drive does not confirm the requested
state. These commands never restore the parent folder. Permanent delete
and recursive folder teardown are not implemented. See
[Google's trash/restore reference](https://developers.google.com/workspace/drive/api/guides/delete)
and [ADR-0092](adrs/adr-0092.md).
If a Docs/Sheets create succeeds but seeding fails, its result includes the new
file ID and an explicit `drive trash` cleanup hint. The create grant does not
authorize automatic rollback.
## Lease
```bash
$ omni-dev drive lease acquire 1ExistingFileId
lease-abc123...
Backed up to /home/user/.local/state/omni-dev/drive-backups/20260911T000000Z-1ExistingFileId-report.pdf (expires 2026-09-11 00:30:00 UTC)
```
Before `drive edit` can write, it needs a **lease**: a token bound to a
mandatory pre-write backup and the file's current Drive `version`
([ADR-0080](adrs/adr-0080.md)). Acquiring one prompts for **device-owner
authentication** — Touch ID, or the account password when biometrics are
unavailable — so an agent can obtain its own lease, but a human is
provably present at the moment consent is given. The prompt is a real
system dialog rendered by macOS itself; there is no way to answer it from
a script or a PTY.
**`drive lease acquire` works against both binary files and native
documents** (see the fidelity split below). `--lease` is required by every
content-mutating write verb: `drive edit`; `drive sheets`
`write`/`append`/`clear`, `add-sheet`/`rename-sheet`/`insert-rows`/
`insert-columns`/`move-rows`/`move-columns`/`duplicate-sheet`/`reorder-sheet`/`hide-sheet`/
`show-sheet`/`update-sheet-properties`/`update-workbook-properties`,
`delete-sheet`/`delete-rows`/`delete-columns`/`delete-range`,
`format-cells`/`merge-cells`/`unmerge-cells`/`update-borders`/
`update-dimension-properties`/`auto-resize-columns`,
`set-data-validation`/`clear-data-validation`,
`protect-range`/`update-protection`/`unprotect-range`,
`set-basic-filter`/`clear-basic-filter`/`add-filter-view`/
`update-filter-view`/`delete-filter-view`, and
`add-chart`/`update-chart`/`delete-chart`/`add-slicer`/`update-slicer`/
`delete-slicer`/`move-chart`/`move-slicer`/`update-chart-border`; and
`drive docs replace`/`append`/`insert`/`delete`. `--dry-run` never needs one on any of them.
**The backup fidelity splits by file type** ([ADR-0080](adrs/adr-0080.md)
§3). A binary file backs up as **bytes on this machine**, named
`<YYYYMMDDTHHMMSSZ>-<fileId>-<name>` under `--backup-dir` (default
`<state dir>/omni-dev/drive-backups`) — UTC, seconds precision, the file id
first since Drive names collide and may contain `/`. A Google-native
document (Docs/Sheets/Slides) has no bytes to back up this way, so it
backs up instead as a **lossless Drive-side copy** (`files.copy`) into the
account's configured `lease_backup_folder_id` — restorable by a human in
the Drive UI even without this tool. Configure it in `settings.json`:
```jsonc
{ "drive": { "accounts": { "work": { "lease_backup_folder_id": "1BaCkup...Folder" } } } }
```
Without it, a native-document target is refused outright, before
authenticating at all — there is nowhere configured to put the copy:
```bash
$ omni-dev drive lease acquire 1SomeGoogleSheetId
Refused: this is a Google-native document (Doc/Sheet/Slide) and no backup folder is configured for this account — set `lease_backup_folder_id` in settings.json to enable leasing native documents
```
**The backup is proven to match the recorded `version` before any token is
minted** ([ADR-0080](adrs/adr-0080.md) §2). A collaborator can edit the
file while its backup is being taken — for a large binary file that window
is the whole download — and neither backup kind can report which revision
it captured. So a byte backup's own SHA-256 is compared against the
`sha256Checksum` Drive reports for the file afterwards, and a native
document's `version` is read immediately before *and* after its
`files.copy` and must agree. If the proof fails the acquisition refuses
(status `refused-concurrent-change`), the backup is discarded, and no
lease exists — re-run `acquire` once the file is quiet (a native
document's moved `version` is first retried automatically; see below):
```bash
$ omni-dev drive lease acquire 1ExistingFileId
Refused: the file changed while its backup was being taken: Drive reports checksum 9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08 at version 8, but the bytes backed up hash to 2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824. No lease was minted and the backup was discarded — retry.
```
A rename, move or permission change mid-backup bumps `version` without
touching the bytes, so a byte backup with a checksum is *not* refused for
it; a native document, having no checksum to compare, conservatively is.
Because a native document's `version` can move for reasons that are not
edits — Drive's `version` counts "every change made to the file on the
server, even those not visible to the user" — a moved `version` does not
refuse straight away. The discarded copy is replaced by a fresh one, up to
three attempts in all, after the single authentication prompt; only if
every attempt sees the `version` move does the acquisition refuse, saying
so:
```bash
$ omni-dev drive lease acquire 1SomeGoogleSheetId
Refused: the file's version changed across its backup on each of 3 attempts (last: version 12 immediately before, 13 immediately after). A version that keeps moving may be changing for reasons unrelated to edits, so waiting for the file to be quiet may not help. No lease was minted and the backups were discarded.
```
A checksum mismatch is not retried: it proves the content itself moved,
and each attempt would re-download the whole file.
**The token is an identifier, not a bearer credential** — safe to log or
paste, since a write under it still needs this account's own OAuth
credentials and folder-permission grant. Present it via `--lease`:
```bash
$ omni-dev drive edit 1ExistingFileId --content ./new-report.pdf --lease lease-abc123...
```
A refusal names what to do next:
| Refusal | Meaning |
|---|---|
| `requires a Drive write lease` | No `--lease` was presented, and the deciding write-permission rule requires one. |
| `expired, released, or unknown` | The token doesn't resolve to a live lease — it expired, was never valid, or the ledger doesn't recognise it. Acquire a new one. |
| `acquired for a different file` | The token is bound to a different file id than the one being edited. |
| `changed since the lease was acquired` | The file's Drive `version` moved since the lease was taken out (or last written under) — someone or something else edited it. Acquire a fresh lease against the current version before writing. |
The last three rows are reachable even under a `require_lease: false` rule
if a `--lease` is presented anyway — that rule opts out of *needing* one, not
of validating one that shows up (see `require_lease` above).
**Expiry is absolute and never extends.** `--expiry-minutes` (default 30)
is fixed at acquisition; a write under the lease never resets it. The only
way to get a fresh window is a fresh `drive lease acquire` — which means a
fresh authentication prompt. A lease is otherwise multi-use: each
successful write refreshes its recorded `version`, so a second write under
the same lease is checked against the file's state *after* the first, not
the original backup point.
**At most one live lease per file.** Acquiring a lease on a file that
already has an unexpired one returns that lease's existing token instead of
minting a second, independent one — two independently-checked leases on the
same file could otherwise each pass their own staleness check against a
version the other's write had already moved past, letting the second
writer silently clobber the first's change. Wait for the existing lease to
expire (or write under it) before a fresh acquisition mints a new one.
**`--biometrics-only`** requires Touch ID specifically, failing outright
rather than falling back to the account password — for operators who want
no keyboard-answerable prompt at all, at the cost of needing Touch ID
hardware. The default policy (device-owner authentication) works on every
Mac.
**A folder rule can opt out** with `require_lease: false` in
`write_permissions.rules` (default `true`), which skips both the backup
and the authentication prompt for that folder — see [Write
permissions](#write-permissions). This opts the folder out of *needing* a
lease, not out of the lease mechanism entirely: a `--lease` presented on a
write to that folder anyway is still validated, consumed, and audited —
including refusing an expired, wrong-file, or stale token — exactly as it
would be under a requiring rule.
**Global settings** (ADR-0080 §13) let this machine's operator set defaults
for `--expiry-minutes`, `--backup-dir`, and `--biometrics-only` without
passing them on every invocation, plus the headless opt-out below. They live
in a top-level `lease` block, sibling of `drive`:
```jsonc
{
"lease": {
"default_expiry_minutes": 60,
"backup_dir": "/Users/alice/drive-backups",
"biometrics_only": true,
"allow_headless": false
}
}
```
Each also has an env var, and every setting resolves in the same order:
the CLI flag, if given, wins outright; then the env var; then the
`settings.json` field; then the built-in default.
| Setting | Flag | Env var | Default |
|------------------|---------------------|----------------------------------------|--------------------------------------|
| Lease expiry | `--expiry-minutes` | `OMNI_DEV_DRIVE_LEASE_EXPIRY_MINUTES` | 30 |
| Backup directory | `--backup-dir` | `OMNI_DEV_DRIVE_LEASE_BACKUP_DIR` | `<state dir>/omni-dev/drive-backups` |
| Auth policy | `--biometrics-only` | `OMNI_DEV_DRIVE_LEASE_BIOMETRICS_ONLY` | device-owner |
| Headless opt-out | `--allow-headless` | `OMNI_DEV_DRIVE_LEASE_ALLOW_HEADLESS` | off (fails closed) |
For `biometrics_only`/`allow_headless`, any layer that opts in wins — there
is no way to force one back off from a lower layer once it is set.
**Off-macOS and headless**, `drive lease acquire` fails closed by default:
no authenticator is available, so no lease can ever be acquired there, and
every gated write refuses in turn. This is deliberate (ADR-0080 §8) — a TTY
prompt would let a script answer on the human's behalf, defeating the
point. An operator can explicitly waive this with `--allow-headless`, the
`OMNI_DEV_DRIVE_LEASE_ALLOW_HEADLESS` env var, or `lease.allow_headless` in
`settings.json` — the acquisition then proceeds with no human ever
prompted, and the resulting lease (and its audit record) is marked as
having used the waiver, so it stays visible after the fact.
"Headless" means no prompt can reach a human at all: off-macOS, or a macOS
session with no graphical access (an SSH login, a background launchd job,
CI). A `drive lease acquire` running in a plain SSH session refuses
immediately as `unavailable` instead of showing a prompt on the Mac's own
screen, which macOS would otherwise do, to whoever happens to be sitting
there. The check follows the process's security session, not who is typing:
a command inside a tmux/screen server started at the console and attached
over SSH, or one launched into the console session with `osascript`, still
prompts on the console. Treat it as a guard against accidental remote
prompts, not a security boundary. The waiver never
covers an **attended** Mac whose chosen policy cannot be met right now,
such as Touch ID locked out after failed attempts, not enrolled, absent, or
suspended by a closed lid under `biometrics_only`, or no passcode set.
Those still refuse as `unavailable` with `allow_headless` set, and the
message says the opt-out does not apply. Fix the underlying cause, or drop
`biometrics_only` so the password fallback can answer.
### Restore
```bash
$ omni-dev drive lease restore lease-abc123...
lease-def456...
Restored. Backed up the pre-restore content to /home/user/.local/state/omni-dev/drive-backups/20260912T000000Z-1ExistingFileId-report.pdf (expires 2026-09-12 00:30:00 UTC)
```
`drive lease restore <TOKEN>` restores a file from the backup a lease
recorded, closing the recovery gap [ADR-0077](adrs/adr-0077-sheets-deletion-via-batchupdate.md)
§5 admitted: `<TOKEN>` names the **backup** lease — the one whose row
records where the content to restore from lives — not a lease presented to
authorise this write. It locates the backup and authorises nothing itself;
restore mints its own fresh lease internally (Touch ID, a backup of the
file's *current* state, a new ledger row) before ever writing, so the
restore is itself reversible by the same verb, and prints the new token for
exactly that reason. One command, one prompt — the same `--backup-dir`/
`--expiry-minutes`/`--biometrics-only` flags `drive lease acquire` takes
apply to this fresh lease. The backup lease's `<TOKEN>` works whether it has
expired or not — an expired-but-kept row is the expected common case,
since a restore is almost always wanted after the fact, once a bad write
has been noticed — and whether it is still **live** or not: the fresh lease
*supersedes* it, releasing its row in the same locked ledger write that
records the new one, so restoring the moment a bad write is noticed never
refuses itself by naming the very token you passed (issue #1685). The file
is covered by exactly one live lease throughout — the backup lease until
the instant the fresh one replaces it — and a denied or failed prompt
leaves the backup lease exactly as it was. Releasing it costs nothing a
successful restore had not already spent: the restore write moves the
file's Drive `version` on under the *fresh* token, so the backup lease
would fail the staleness check on any later write regardless. Its row and
backup are kept, and it can be restored from again.
**Binary files restore in full**, by re-uploading the backed-up bytes —
verified against the backup's recorded SHA-256 first, so a backup that has
been corrupted or tampered with on disk since it was taken is never
silently written back to Drive.
**A spreadsheet with exactly one sheet deleted since the backup restores
that sheet**, via `spreadsheets.sheets.copyTo` from the backup spreadsheet
into the live one — the one typed native-document path ADR-0080 §10 names
worth building. Detection is structural: `restore` diffs the backup's and
the live spreadsheet's sheet-id sets (Drive's `files.copy` preserves
internal sheet ids verbatim), and restores the one id present in the backup
but missing live. It also renames the restored sheet back to its original
title when that title is currently free:
```bash
$ omni-dev drive lease restore lease-native789...
lease-def456...
Restored sheet 'Q3 Numbers' (id 1481923) back into spreadsheet 1SpreadsheetId. Backed up the pre-restore content to Drive copy 1FreshBackupCopyId (expires 2026-09-12 00:30:00 UTC)
```
**Restoring the same sheet backup twice is refused, not repeated.**
`copyTo` gives the restored sheet a *fresh* id, so the backup sheet's own id
stays missing from the live spreadsheet and the structural diff above would
happily fire again — silently adding another "Copy of …" every run. The
ledger row records the id each restore creates, and a re-run is refused while
that sheet is still there, before the authentication prompt and before the
fresh backup copy:
```bash
$ omni-dev drive lease restore lease-native789...
Refused: this backup's deleted sheet was already restored on 2026-09-12 00:00:00 UTC into spreadsheet 1SpreadsheetId as 'Q3 Numbers' (id 1481923), which is still there — restoring again would only add a second copy. Delete that sheet first if you do want another one. No fresh lease was minted, no Touch ID was spent.
```
The check keys on that sheet still being live, not on the backup merely
having been restored from before — so if the restored sheet is deleted
*again*, re-running restores it again as normal.
**Everything else native has no typed restore path.** Zero or more than one
sheet missing (nothing to restore this way, or ambiguous — this never
guesses), a Docs/Slides backup, or anything below whole-sheet granularity
(a deleted row/column/range) all fall back to reporting the backup's
location — restorable today by a human via the Drive UI:
```bash
$ omni-dev drive lease restore lease-native789...
No typed restore path exists for this backup yet — it is a Drive copy at 1BackupCopyFileId you can restore from by hand in the Drive UI
```
**The folder write-permission gate still applies.** Restore mints its own
lease, but that is a *third*, independent check alongside OAuth scope and
the write-permission gate — never a substitute for either: a write-blocked
folder refuses a restore the same way it refuses any other write.
**A backup over the 5 MB simple-upload cap can't be restored yet**, for the
same reason a byte upload that large is refused elsewhere in this CLI (see
"5 MB size cap" above) — the restore write goes through that same capped
endpoint. Refused up front from the backup's own recorded size, before any
network call, so no Touch ID prompt is spent on a restore that could never
have succeeded:
```bash
$ omni-dev drive lease restore lease-large123...
Refused: this backup is 83886080 bytes, over Drive's 5 MB simple-upload limit — restoring it is not supported yet (no fresh lease was minted, no Touch ID was spent)
```
**A file that became native since the backup was taken is also refused.**
If the target at `file_id` was binary when its backup was recorded but has
since been replaced by a Google-native document, restore refuses the write
rather than PATCHing raw bytes into it — checked immediately before the
write, since the account may have a `lease_backup_folder_id` configured
that would otherwise let the internal fresh lease acquire successfully
against the now-native file.
**The backup lease's own row is marked once restored from**, kept (never
dropped) alongside the fresh lease's new row — both remain findable by
token in the ledger and in `audit.jsonl`, which records both tokens on a
restore (`lease_id` the fresh one, `restored_from_lease_id` the backup one
read from), and the supersede on the internal acquire's own record
(`superseded_lease_id`) — see [docs/log.md](log.md#audit-log).
**If the restore write fails after the fresh lease was minted**, that fresh
token is still printed — it is real and live (Touch ID was answered, a
backup taken, a ledger row written) and must not be lost. Present it to
`--lease` for an ordinary write, but **do not restore from it**: its backup
is the file's *pre-restore* content, which is exactly what you were undoing.
Because it now covers the file, a second `restore` against the original
backup token is refused until it is stood down, which is what
[`drive lease release`](#release) is for:
```bash
$ omni-dev drive lease restore lease-abc123...
lease-def456...
Failed: the write-permission gate no longer allows this write, re-checked after the fresh lease's authentication prompt
A fresh lease was minted before the failure and is still live (expires 2026-09-12 00:30:00 UTC) — present it to `--lease` for an ordinary write.
Do not restore from it: its backup is this file's pre-restore content, which is what you were undoing.
To retry the restore, stand it down first:
omni-dev drive lease release lease-def456...
omni-dev drive lease restore <the original backup token>
```
A `restore` can otherwise only be refused by a live lease that is *not* the
one you passed — one acquired independently, or minted by an earlier
restore attempt whose write failed. Its token is printed so you can present
it to `--lease`, release it, or wait for it to expire.
### Release
```bash
$ omni-dev drive lease release lease-def456...
Released lease-def456... (covered file 1ExistingFileId, would have expired 2026-09-12 00:30:00 UTC). Its backup is kept — `omni-dev drive lease restore lease-def456...` still works.
```
`drive lease release <TOKEN>` ends a lease's write window early, without
waiting for it to expire — the counterpart to the absolute expiry `acquire`
fixes, which can be up to 24 hours away. It **prompts for nothing and makes
no Drive call**: releasing only ever *reduces* what a token can do, so
spending a Touch ID prompt to give up authority would be backwards (and
would leave a headless installation unable to stand a lease down at all).
It is a pure ledger mutation, and the one `drive lease` verb that needs no
Drive client — it is dispatched before credentials are even resolved, so it
works after `drive auth logout` or for an `--account` with none configured.
**The backup is kept.** Release ends the lease's authority to write, not its
usefulness: `drive lease restore <TOKEN>` looks a row up by token and never
requires it to be live, so a released lease's content stays recoverable
until [`drive lease prune`](#prune) drops the row and its backup together.
One knock-on effect worth knowing: a *live* row never enters `prune
--max-size`'s budget, so releasing a lease before its expiry adds its local
backup bytes to that budget straight away — bounded by the window the lease
had left. It cannot cost the released row its own backup in favour of an
*expired* one: candidates sort newest-`expires_at`-first, and a released
row's expiry is still in the future, so every expired row is evicted before
it. Only another early-released row can outrank it, by expiring later.
`--older-than` is unaffected: it compares `expires_at`, which release never
moves.
An already-expired or already-released token is reported rather than
silently re-stamped, so an earlier release's timestamp is never overwritten:
```bash
$ omni-dev drive lease release lease-def456...
Nothing to do: lease lease-def456... is not live — it was already released on 2026-09-12 00:05:00 UTC. Its backup is unaffected and still restorable.
```
Every attempt writes a best-effort `audit.jsonl` record (`verdict:
"released"`/`"release-not-live"`/`"release-no-such-token"`/`"failed"`,
`lease_id` the token presented) — see [docs/log.md](log.md#audit-log).
### Prune
```bash
$ omni-dev drive lease prune --older-than 30d --dry-run
$ omni-dev drive lease prune --older-than 30d
```
`drive lease prune` bounds the ledger's and the backup directory/folder's
otherwise-unbounded growth ([ADR-0080](adrs/adr-0080.md) Consequences,
#1678) by dropping expired rows together with the backups they point at.
It mirrors [`omni-dev log prune`](log.md#omni-dev-log-prune)'s shape:
| Flag | Effect |
|------|--------|
| `--older-than <DUR>` | Drop non-live rows whose expiry is strictly before this relative window (`7d`, `24h`, `2w`). A row expiring exactly at the cutoff survives. |
| `--max-size <SIZE>` | After age pruning, additionally drop the oldest-expiring survivors until their local backup bytes total at most `<SIZE>` (`10mb`, `512kb`, or a bare byte count). A Drive-copy backup counts as zero local bytes, so it's only reachable through `--older-than`. |
| `--dry-run` | Report what would be removed without deleting/trashing any backup or modifying the ledger. |
At least one of `--older-than`/`--max-size` is required. A **live** lease
(unexpired and unreleased) is never a removal candidate regardless of
either bound — pruning can never invalidate a lease a write is still
relying on. `--max-size` always keeps at least the single
most-recently-expired row's backup, even if it alone exceeds the budget.
A second case is exempted from the `--max-size` budget the same way a
Drive-copy backup is (issue #1768): a row released because
[`drive lease restore`](#restore) superseded it (ADR-0080 §10) stays
exempt for as long as the restore that used it as its source never
actually completed — the write failed after the fresh lease was
minted (`FreshLeaseButWriteFailed`). Such a row remains the
file's only real backup, while the superseding lease's own backup is a
pre-restore snapshot nothing ever wrote over and is comparatively
worthless — and the superseding lease's `expires_at` is always later,
since it was minted after the row it replaced. Without the exemption, a
tight budget could compete the two by raw `expires_at` and evict the
wanted row in favor of the useless one. A row released by a *successful*
restore keeps competing normally, since its content is live again and
the superseding lease's own backup is now meaningful too; so does a row
released by a plain `drive lease release` (the case discussed
[above](#release)). Like the Drive-copy case, an exempt row is reachable
only through `--older-than`.
A row and the backup it points at are always dropped **together, never one
without the other**: a byte backup is deleted from local disk, a
Drive-copy backup is moved to Drive Trash (recoverable by hand for ~30
days via the Drive UI) — and the ledger row is dropped, with that removal
persisted to disk, only once its own backup has been cleared (or found
already gone). Persistence happens one row at a time, not batched across
the whole run, so an interrupted prune (a crash, a killed process) can
leave at most the one row it was working on inconsistent with its
already-cleared backup — never the rest of the run. The ledger lock
itself is likewise taken only per row, not for the whole run — but each
row now holds it across *both* that row's backup deletion/trash call and
its ledger removal, secured *before* the backup is touched (issue #1687):
a lock collision on one row is therefore fully recoverable (neither the
backup nor the row has been touched yet) and simply leaves that row for a
future prune, rather than the old failure mode of deleting a backup and
then being unable to record its row as gone. The trade-off is that a large
batch can now hold the lock for a row's full Drive API round trip, not
just its local disk I/O — a concurrent leased write queues behind it
rather than failing outright (see [Concurrent access](#concurrent-access)
below), but a very large prune run can make one wait noticeably longer. A
backup deletion/trash failure for one row (e.g. a transient Drive error),
or a failure to lock the ledger for one row, is logged and skips just that
row, leaving it for a future prune run, rather than failing the whole
command.
### Concurrent access
Two overlapping `drive lease acquire`/write/prune/restore invocations
against the same ledger are serialized by an advisory lock — a
`flock(2)` on a persistent `<ledger-path>.lock` sibling file, kernel-
released on process death, so a crashed or killed holder never leaves a
stale lock. **Never delete this lock file by hand** — it is not a marker
of anything being wrong, and nothing in this codebase ever advises
deleting it. (This holds on Unix; on non-Unix platforms, where `flock`
isn't available, the lock falls back to the older create-and-delete
marker scheme, so a crashed holder there can still leave a stale lock.) A leased write, `drive lease acquire` and `drive lease release`
each wait for a busy lock rather than failing outright (printing a
one-line notice while they do), up to
`OMNI_DEV_LEASE_LOCK_WAIT_SECS` (default: four times the HTTP read
timeout, since a held lock can span several sequential Drive calls, e.g.
`drive lease restore`'s copy-then-edit-then-rename sequence). The lock
is **ledger-global**, not per-file: a write to one file and a concurrent
write to a *different* file still serialize against each other, they
just wait instead of hard-failing. `drive lease prune` is the exception:
it does not wait, and a row whose lock it cannot take is simply left
for a future prune.
```bash
$ omni-dev drive lease prune --older-than 30d
Removed 12 lease(s); kept 4 (3 trashed Drive backup(s), 0 failure(s), freed 8241203 bytes of local backups).
```
Every removal attempt — successful or failed — writes its own best-effort
`audit.jsonl` record (`verdict: "pruned"` or `"prune-failed"`, the latter
carrying the underlying error), the same fail-open posture `drive lease
acquire`'s own audit trail uses, so `omni-dev log --audit` can always
answer "why is this backup gone" for a specific lease. This is distinct
from `audit.jsonl` itself being out of scope *as a pruning target*: the
file is append-only forensic history by design
([ADR-0080](adrs/adr-0080.md) §11) and `drive lease prune` never rotates
or deletes its content, the same exemption it has from
`OMNI_DEV_LOG_DISABLE` and `omni-dev log prune`'s own rotation — see
[docs/log.md](log.md#audit-log).
### Exit codes
`acquire`, `restore` and `release` each exit **`0`** when the caller ends
up holding what they asked for, and **`1`** for every refusal, denial or
failure — under every `-o` format, not just `table`. This is narrower than
[Move](#move)'s "exit code is always 0, check the output" convention: a
move is a batch operation with one outcome per file, so no single exit
code could ever represent all of them, while a single `acquire`/`restore`/
`release` call has exactly one outcome, so its exit code can name it
(issue #1775).
The `0` outcomes include two idempotent-reuse cases, not just the obvious
ones:
| Command | Exits `0` |
|-----------|-------------------------------|
| `acquire` | `Acquired`, and `AlreadyLeased` (a live lease already covers the file — its own token is returned for reuse) |
| `restore` | `Restored`, `RestoredSheet` |
| `release` | `Released`, and `NotLive` (the token names a lease that was already expired or released — nothing to do, not a failure) |
Everything else exits `1`. Two outcomes are easy to misjudge from their
name or their payload alone:
- **`restore`'s own `AlreadyLeased` is *not* one of the `0` cases above**,
despite sharing a name with `acquire`'s. It means some *other*, unrelated
live lease blocked this restore — nothing was restored — and names that
lease's token so it can be presented to `--lease`, released, or waited
out; see [Restore](#restore).
- **`FreshLeaseButWriteFailed` still prints a real, usable token** — Touch
ID was answered, a backup taken, a ledger row written — but exits `1`
regardless, because the restore write itself did not go through.
A script that only checks the exit code — `omni-dev drive lease acquire
"$ID" > /tmp/out || exit 1` — can now rely on it; one that also wants the
lease token or the refusal detail still reads the output as before.
## Sheets
`drive sheets` reads and writes the *cells* of a Google Sheet through the
Sheets v4 API (issue #1589, [ADR-0073](adrs/adr-0073.md)), and edits its
*structure* (issue #1613, [ADR-0075](adrs/adr-0075.md)). The Drive API
cannot do either: it treats a Sheet as an opaque native document with no notion of a range,
a row or a cell. In particular, `drive read --content` on a Sheet exports **the
first sheet only**, because Drive's export API has no multi-sheet CSV format —
`drive sheets read` is the way to get the rest.
No new login flag is needed. Reading works with the `drive.readonly` scope
every account already has.
#### Applied, but the reply could not be read
Every mutating `drive sheets` verb that goes through `spreadsheets.batchUpdate`
can report a third outcome besides `changed` and `failed` (issue #2021):
```
Applied, but the reply could not be read: add column chart in 'Budget' — do not retry; check the spreadsheet first (Failed to parse Sheets batchUpdate response: …)
```
It means Google answered **2xx**, so the change **was made**, but omni-dev could
not parse the reply body. It is not a failure: retrying would repeat the change
(a second chart, a second protected range, another split). Open the spreadsheet
to confirm, and use the matching `list-*` verb to find any id the reply would
have carried (an added chart's id, a new sheet's id, a replacement count). For
verbs that are safe to repeat (the formatting verbs such as `format-cells`, the
data-validation verbs, `sort-range`, `trim-whitespace` and `auto-fill`) the message says `check the spreadsheet to
confirm` instead of `do not retry`.
In JSON the result's `status` is `applied-reply-unreadable`, carrying `summary`
and `detail` plus everything the verb's `changed` result knows before the
request is sent (the range, any cells it overwrote or discarded, warnings and
the lease backup); only what the lost reply held, such as a new object's id or
a replacement count, is missing. The exit code is unchanged, as it is for
`failed`. The
`drivemutation` record has the same status, with `error` set to `detail` and
`fields_changed` to `summary`. A lease held for the write is refreshed as after
any successful write, so the next write under it is not refused as stale. A
non-2xx response, or a transport failure before any status arrives, is still
`failed`: in that case omni-dev cannot say whether the change was made.
#### `drive sheets info`
Shows the workbook title and the sheets (tabs) it contains, with each grid
sheet's allocated dimensions. Hidden sheets are listed and marked, not omitted.
```bash
$ omni-dev drive sheets info 1AbC_dEfGhIjKlMnOpQrStUvWxYz
Id: 1AbC_dEfGhIjKlMnOpQrStUvWxYz
Title: 2026 Budget
Sheets: 3
Q1 (1000x26)
Q2 (1000x26)
Notes [hidden]
```
`Locale:`/`Time zone:`/`Recalculation:`/`Iterative calculation:` lines
appear between `Title:` and `Sheets:` whenever Sheets reports that workbook
property — the read side of `update-workbook-properties` (issue #1836,
[ADR-0086](adrs/adr-0086-workbook-properties.md)):
```bash
$ omni-dev drive sheets info 1AbC_dEfGhIjKlMnOpQrStUvWxYz
Id: 1AbC_dEfGhIjKlMnOpQrStUvWxYz
Title: 2026 Budget
Locale: en_US
Time zone: America/New_York
Recalculation: HOUR
Iterative calculation: on (max 50 iterations, threshold 0.01)
Sheets: 3
Q1 (1000x26)
Q2 (1000x26)
Notes [hidden]
```
#### `drive sheets read`
With neither `--range` nor `--sheet`, reads **every** sheet: one
`spreadsheets.get` for the tab list, then `values.batchGet` for the data.
```bash
$ omni-dev drive sheets read 1AbC_dEfGhIjKlMnOpQrStUvWxYz
# Q1
Region,Revenue
North,1200
South,950
# Q2
Region,Revenue
North,1310
```
Narrow it with `--sheet` (a tab title), `--range` (an A1 range), or both:
```bash
omni-dev drive sheets read <ID> --sheet 'Q1'
omni-dev drive sheets read <ID> --range 'A1:B10'
omni-dev drive sheets read <ID> --sheet 'My Sheet' --range 'A1:B10'
omni-dev drive sheets read <ID> --range "'My Sheet'!A:A"
```
`--range` may carry its own `Sheet!` prefix. Passing `--sheet` *as well as* a
prefixed `--range` is an error rather than a precedence rule, since the two can
disagree and guessing would read the wrong sheet. Sheet titles are always
quoted internally, so titles containing spaces, apostrophes or `!` need no
special handling — and a sheet literally titled `A1` is unambiguous.
Unbounded and open-ended ranges are passed through untouched (`A:A`, `1:2`,
`A5:A`, a bare sheet name, or a defined name). `omni-dev` deliberately does not
validate A1 grammar client-side; the server is authoritative and returns a
clearer error than a local guess would.
**Output formats.** The default `-o table` emits CSV, which is what a grid of
cells is. When more than one sheet is read, each block is preceded by a
`# <title>` comment line and separated by a blank line.
Two differences between CSV and the structured formats are worth knowing:
- **CSV pads rows; JSON/YAML do not.** The API truncates trailing empty cells
from each row, so rows come back ragged. CSV pads each row to the widest row
in that sheet, because a ragged CSV is malformed. `-o json` and `-o yaml`
preserve the raggedness, which is the truthful shape.
- **CSV emits cell content verbatim.** Cell values are content, not chrome, so
they are not stripped of control characters — a multi-line cell survives
intact as a properly quoted CSV field. Sheet *titles*, which are rendered as
chrome, are sanitised.
`-o json`/`-o yaml` emit an ordered **list** of `{title, values}` objects
rather than a `{title: rows}` map, so workbook order is preserved:
```bash
omni-dev drive sheets read <ID> -o json
```
**`--render`** controls how the API renders each cell:
| Value | Meaning |
|---------------|-----------------------------------------------------------|
| `formatted` | Locale-formatted strings as displayed in the UI (default) |
| `unformatted` | Raw typed values — JSON numbers and booleans, not strings |
| `formula` | The formula text (`=SUM(A1:A3)`) rather than its result |
`unformatted` is usually what you want when feeding the output to something
that will do arithmetic on it; `formatted` matches what `drive read --content`
already produces for a Sheet.
#### `drive sheets write` / `append` / `clear`
Writing cells is gated by the folder [write permissions](#write-permissions)
under the **`sheets-write`** operation, and needs the `drive.file` or `drive`
scope (`drive auth login --write-file` / `--write-full`). `drive.file` reaches
only Sheets `omni-dev` itself created; a pre-existing Sheet needs
`--write-full`.
```bash
# Overwrite a range from a CSV file
omni-dev drive sheets write <ID> --range 'A1:B10' --values ./cells.csv
# Append rows after the end of a table, from stdin
printf 'North,1200\nSouth,950\n' | omni-dev drive sheets append <ID> --range 'A:B' --values -
# Clear a range's values, leaving formatting intact
omni-dev drive sheets clear <ID> --range 'Q1!A2:B100'
```
**Always dry-run first.** `--dry-run` reports the gate verdict *and* the
parsed dimensions, which is how you catch a transposed or ragged input before
it lands:
```bash
$ omni-dev drive sheets write <ID> --range 'A1:B10' --values ./cells.csv --dry-run
Would write: 10 row(s) x 2 column(s) into A1:B10 of '2026 Budget'
```
With `-o json`, the result's `status` names the verb: `would-write` (a
`write`/`append` dry run, with its `rows`/`columns`), `would-clear` (a
`clear` dry run, which has no dimensions), and `written`, `appended` or
`cleared` for a real run.
A dry run never calls the values endpoint and writes no request-log record,
matching `create`/`upload`/`edit`. When the range names a sheet (through
`--sheet` or a `Sheet!` prefix), both a dry run and a real run first read the
spreadsheet's sheet titles, and refuse an unknown sheet with the list of
available ones.
**`--values`** takes a file path or `-` for stdin. CSV by default; JSON (an
array of arrays) when the path ends in `.json` or `--values-format json` is
given. `.tsv` or `--values-format tsv` selects tab-separated values with the
same quoting and blank-row preservation as CSV. Ragged rows are preserved rather than padded — padding would write
empty strings over cells you never mentioned. The first CSV row is **data,
not a header**. Blank lines are rows too: each input line maps to exactly
one sheet row, written as a single empty cell, so a blank line in the
middle of pasted data doesn't shift every later row up by one.
**`--input` is the one option whose wrong value silently mangles data:**
- **`user-entered`** (default) — parse each value as if typed into the UI:
`=SUM(A1:A3)` becomes a formula, `2026-09-06` a date, `1,234` a number.
- **`raw`** — store every value verbatim as text; a leading `=` stays literal
rather than becoming a formula.
Neither errors on the "wrong" choice — you get formulas you meant as text, or
text you meant as formulas. The dry run echoes nothing about this, so decide
it deliberately.
**Refusals you may see**, each distinct from a rule denial:
- *not a Google Sheet* — the id points at something else. Checked before the
gate; the operation is meaningless rather than disallowed.
- *is a shortcut* — shortcuts are never followed. Resolve the target
spreadsheet's id and use that.
- *no parent folder visible to this account* — the Sheet was shared with you
by link or email and is not in a folder you can see, so it has no ancestor
chain and no `folder_id` rule could ever grant it. Grant it directly with a
`file_id` rule instead — see
["Granting a file shared with you"](#granting-a-file-shared-with-you). A
`file_id` rule only satisfies the local gate, though: writing to a Sheet
`omni-dev` didn't create also needs the `--write-full` scope, since
`--write-file` (`drive.file`) only reaches files `omni-dev` itself
created.
**Writing a cell drops its rich-text runs, even for identical text.**
`sheets write` sends `values.update`, and the API replaces
`userEnteredValue` as a plain value without carrying over `textFormatRuns`
(mixed bold/italic/link formatting within a cell's text) — measured for
both `--input user-entered` and `--input raw`, and even when the new value
is byte-identical to the old one. Cell-level `userEnteredFormat`
(background, number format, borders, notes, data-validation rules) is
untouched. Rich-text runs are part of a cell's *value*, not its *format*
([ADR-0083](adrs/adr-0083.md) §5) — that's what keeps this consistent with
`sheets-write`'s "never changes a cell's format" guarantee even though a
round-trip through `sheets read` → edit → `sheets write` silently loses
mixed formatting. `sheets append` sends the same request shape and
presumably behaves the same on an existing cell, but that hasn't been
measured. See [#1877](https://github.com/rust-works/omni-dev/issues/1877).
Exit code is 0 whether the write succeeded, was blocked, or failed — inspect
the output, not `$?`. The exception is a local input error caught before the
request is built (malformed CSV or JSON `--values`, an unreadable `--values`
file, an option combination the command rejects): that exits 1, like any
other argument error. An *empty* `--values` is a refusal, so it exits 0.
#### `drive sheets find-replace`
`find-replace` is also gated by `sheets-write` ([ADR-0083](adrs/adr-0083.md)
§1). It requires `--find`, `--replacement`, and exactly one scope: `--range`,
`--whole-sheet --sheet`, or `--all-sheets`.
```bash
# Replace in one range; an empty replacement removes matches.
omni-dev drive sheets find-replace <ID> --range 'Q1!A2:B100' \
--find 'draft' --replacement 'final'
# Search every sheet, including formulas, with Java-regex syntax.
omni-dev drive sheets find-replace <ID> --all-sheets --search-by-regex \
--include-formulas --find 'FY([0-9]+)' --replacement '202$1'
```
`--match-case`, `--match-entire-cell`, `--search-by-regex`, and
`--include-formulas` map directly to the Sheets request. Formula inclusion
adds formula cells to the search; Sheets has no formulas-only mode. A dry run
reports the resolved scope, the search and replacement terms, and every
modifier, without reading cells or estimating matches, because Sheets
determines matching semantics and counts. A real run reports values,
formulas, rows, sheets, and occurrences changed; one cell can contain
several changed occurrences.
#### `drive sheets sort-range`
Reorders rows in a fully bounded range by one or more column keys. Gated
under **both `sheets-write` and `sheets-structure`** (issue #1842, #1870,
[ADR-0083](adrs/adr-0083.md) §§3, 5).
ADR-0083 §3 proposed `sheets-write` alone — the request only permutes the
named range's cells, which is strictly less than clearing or replacing that
same range under an existing `sheets-write` grant — and §5 made that
provisional on live verification, same as `randomize-range`. The live run
(2026-09-22) settled it the other way: sorting a probe range whose rows
carried formatting, notes and a data-validation rule moved all three with
each row, and a relative formula inside the range moved with its row with
its reference rewritten to match (`=B3*2` becomes `=B2*2` when its row
moves up one). Formatting, notes and validation rules are
`sheets-structure`'s own subject matter, so the union is the honest gate.
`sort-range` shipped first (#1842) on `sheets-write` alone, before this was
measured. Neither half opens it alone, and a refusal names the half that
was missing. Cells in the same rows but outside the selected columns do not
move.
```bash
# Sort first by column 0 ascending, then column 2 descending.
omni-dev drive sheets sort-range <ID> --sheet Q1 --range A2:D100 \
--sort-by 0:asc --sort-by 2:desc
# Preview the gate and request; this never reads values or calls batchUpdate.
omni-dev drive sheets sort-range <ID> --sheet Q1 --range A2:D100 \
--sort-by 0:asc --dry-run
```
Each `--sort-by` is `COLUMN:asc|desc`; repeat it in precedence order. The
range must be bounded (`A2:D100`, not `A:A` or `2:2`). v1 supports
column-value order only: Sheets color-based criteria are intentionally not
exposed yet.
`--dry-run` does not predict the after-order. It names the range and sort
keys, and compares the selected columns with the sheet's allocated width.
This is a conservative warning: the metadata does not say which columns
actually contain data. Sorting only part of a wider table can detach records
from their other columns; formulas and references outside the range may then
observe a different row's value. Sort keys use absolute, zero-based sheet
column indexes and must fall inside the selected range.
#### `drive sheets randomize-range`
Shuffles the row order within a fully bounded range into an order chosen by
the server — the resulting order is not caller-specified or predictable.
Gated under **both `sheets-write` and `sheets-structure`** (issue #1845,
[ADR-0083](adrs/adr-0083.md) §§3, 5, 6).
ADR-0083 §3 proposed `sheets-write` alone — a reorder permutes values
within a caller-named range, which a `sheets-write` grant could already
replace or clear — and §5 made that provisional on live verification,
same as `text-to-columns`. The live run settled it the other way:
reordering a probe row that carried bold+pink formatting, a note and a
data-validation rule moved all three with the row, and a relative formula
inside the range moved with its row with its reference rewritten to match
(`=B3*2` became `=B2*2` once its row landed on row 2, so it keeps pointing
at its own row). Formatting, notes and validation rules are
`sheets-structure`'s own subject matter, so the union is the honest gate.
Neither half opens it alone, and a refusal names the half that was
missing.
```bash
omni-dev drive sheets randomize-range <ID> --sheet Q1 --range A2:D100
# Preview the gate and request; this never reads values or calls batchUpdate.
omni-dev drive sheets randomize-range <ID> --sheet Q1 --range A2:D100 --dry-run
```
The range must be bounded (`A2:D100`, not `A:A` or `2:2`) — `sort-range`'s
own requirement.
**The resulting order can never be previewed, and this crate never sees it
even after a real run.** `randomizeRange` carries no response object, and
which order the server settles on is entirely its own choice:
```
$ omni-dev drive sheets randomize-range <ID> --sheet Q1 --range A2:D100 --dry-run
Would randomize the row order of 'Q1'!A2:D100 in 'Budget'
the resulting order is chosen by the server and cannot be previewed or reported; the previous row order is not preserved
references outside the range may observe values from a different row after randomizing
```
`--dry-run` leads with the range-width-vs-sheet's-allocated-width
record-integrity caveat when the selected columns are narrower than the
sheet, the same conservative warning `sort-range` gives: the metadata does
not say which columns actually contain data, so reordering only part of a
wider table can detach a record's other columns — measured live: cells in
the same rows but outside the selected columns do not move. The smaller
"references outside the range may see a different row's value" caveat
follows it. Never a cell's contents.
#### `drive sheets auto-fill`
Extends a series from source cells into an adjacent destination, using
Sheets' own pattern-detection heuristics (dates, numbers, days-of-week, or
whatever pattern the source cells show). Also gated under **`sheets-write`**
(issue #1840, [ADR-0083](adrs/adr-0083.md) §1) — it writes ordinary cell
content, exactly what a `sheets clear` followed by a `sheets write` of the
same range could already do under that grant.
Exactly one of `--range`/`--source` is required, mirroring the API's own
`autoFill` oneof:
```bash
# --range: names the whole region. Sheets examines it and decides for
# itself which cells are the source and which are filled, so the count
# --dry-run reports is only an upper bound on what will be overwritten.
omni-dev drive sheets auto-fill <ID> --sheet Q1 --range A1:A10 --dry-run
# --source/--dimension/--fill-length: an explicit source, extended by a
# caller-chosen length and direction. --fill-length may be negative, which
# fills backward (up or left) instead of forward (down or right).
omni-dev drive sheets auto-fill <ID> --sheet Q1 --source A1:A3 \
--dimension rows --fill-length 7
# Fills using the alternate series Sheets would not otherwise choose (e.g.
# a copy instead of a linear progression for a plain numeric run).
omni-dev drive sheets auto-fill <ID> --sheet Q1 --source A1:A2 \
--dimension rows --fill-length 5 --alternate-series
```
**The filled values can never be previewed, and this crate never sees them
even after a real run.** `autoFill` carries no response object, and which
values it writes is entirely Sheets' own series-detection heuristic —
`--dry-run` (and the real run) instead report the destination range and the
count and A1 locations of the non-blank cells within it that would be (or
were) overwritten, never their values:
```
$ omni-dev drive sheets auto-fill <ID> --sheet Q1 --source A1:A3 \
--dimension rows --fill-length 7 --dry-run
Would auto-fill 'Q1'!A4:A10 from source 'Q1'!A1:A3, extending 7 row(s) down in 'Budget'
2 non-blank cell(s) would be overwritten: A4, A6
the filled values are computed by Sheets' own series detection and are never reported, before or after the request
```
The real run prints the same shape in the past tense (`Applied: …`,
`2 non-blank cell(s) were overwritten: …`). With `--range`, the count is
prefixed `up to`, since Sheets picks the source/destination split itself
and some of the listed cells are the source:
```
$ omni-dev drive sheets auto-fill <ID> --sheet Q1 --range A1:A10 --dry-run
Would auto-fill within 'Q1'!A1:A10 (Sheets decides which cells are the source and which are filled) in 'Budget'
up to 3 non-blank cell(s) would be overwritten: A1, A2, A3
the filled values are computed by Sheets' own series detection and are never reported, before or after the request
```
Past 50 addresses the rendered line elides the remainder (`… and N more`);
the `-o json` outcome keeps the full list, and the count in the sentence
stays exact either way (issue #1880).
Both `--range` and `--source` require a **fully bounded** range (`A1:D10`,
not `A:A` or `5:5`) — `merge-cells`' own requirement — since neither the
destination arithmetic nor the preview read has a fixed extent to work
from otherwise.
**Verified live (#1937): a destination that runs past the sheet's current
row/column count is clipped, not grown or refused** — the opposite of
`copy-paste`/`cut-paste`/`paste-data` (below), which do grow the grid.
`--dry-run` and the real run both report the *applied* (clipped)
destination as the headline range, plus the unclipped destination that was
requested when the two differ. The `extending N row(s)/column(s)` count in
that same head line is the applied span too (`994`, not the `--fill-length
1000` that was asked for) — the unclipped length is named only in the
caveat line's requested range, not as a second count:
```
$ omni-dev drive sheets auto-fill <ID> --sheet AF --source G5:G6 \
--dimension rows --fill-length 1000 --dry-run
Would auto-fill 'AF'!G7:G1000 from source 'AF'!G5:G6, extending 994 row(s) down in 'Budget'
no non-blank cells in the destination
the destination runs past the sheet's current extent; auto-fill clips to it, so only 'AF'!G7:G1000 would be filled — 'AF'!G7:G1006 was requested
the filled values are computed by Sheets' own series detection and are never reported, before or after the request
```
The real run prints the same shape in the past tense. The `-o json` outcome
carries the clipped range as `destination` and the unclipped one as
`requested_destination`, a key omitted entirely when the two match — the
common case, so the JSON shape is unchanged for a destination that fits.
The verb still never prepends a request to grow the sheet first, and it
never rewrites `--fill-length` to match what the pre-read predicts — the
`batchUpdate` request goes out exactly as given; clipping is only this
crate's read of what the API does with it.
A destination that lies **wholly** past the grid on some axis is refused
before any lease is checked or the request is sent — under `--dry-run` and
a real run alike, since nothing would be written:
```
$ omni-dev drive sheets auto-fill <ID> --sheet AF --source G999:G1000 \
--dimension rows --fill-length 20
Refused: 'AF'!G1001:G1020 lies wholly past 'Budget''s current grid (1000 rows x 26 columns); auto-fill does not grow the sheet. Grow it first (e.g. `sheets append`, `sheets insert-rows`/`insert-columns`), or choose a destination within the grid.
```
An axis the sheet reports no `gridProperties` for (a metadata gap this
crate has otherwise never observed live) is left alone: no clipping and no
refusal, matching the pre-#1937 behaviour for that case.
#### `drive sheets text-to-columns`
Splits a single column's delimited text across the adjacent columns to its
right. Gated under **both `sheets-write` and `sheets-structure`** (issue
#1843, [ADR-0083](adrs/adr-0083.md) §§1, 5).
ADR-0083 §1 proposed `sheets-write` alone — the split writes ordinary cell
content, exactly what a `sheets clear` followed by a `sheets write` of the
same span could already do under that grant — and §5 made that provisional
on live verification. The live run settled it the other way: splitting a
**bold, pink** source column left every spill cell bold and pink, where
they had been unformatted. A `sheets-write` grant does not confer
formatting, so the union is the honest gate. Neither half opens it alone,
and a refusal names the half that was missing.
`--source` must resolve to a **fully bounded, single column** — the API's
own "must span exactly one column" constraint, plus this v1's own
requirement that it not be open-ended (`A2:A100`, not `A:A`):
```bash
omni-dev drive sheets text-to-columns <ID> --sheet Q1 --source A2:A100 \
--delimiter comma
# A custom separator:
omni-dev drive sheets text-to-columns <ID> --sheet Q1 --source A2:A100 \
--delimiter custom --custom-delimiter '|'
# Let Sheets detect the separator itself:
omni-dev drive sheets text-to-columns <ID> --sheet Q1 --source A2:A100 \
--delimiter auto --dry-run
```
**How many columns the split needs, and the values it writes, can never be
previewed, before or after the request.** `textToColumns` carries no
response object, and the split is entirely Sheets' own splitting
heuristic — `--dry-run` (and the real run) instead report a local
upper-bound width, computed by a naive, non-quote-aware split of the
source's current values, and the count and A1 locations of the non-blank
cells within that upper-bound span that would be (or were) overwritten,
never their values or the split pieces:
```
$ omni-dev drive sheets text-to-columns <ID> --sheet Q1 --source A2:A4 \
--delimiter comma --dry-run
Would split 'Q1'!A2:A4 on comma into up to 3 column(s), spill 'Q1'!B2:C4 in 'Budget'
up to 1 non-blank cell(s) would be overwritten: B3
the number of columns the split needs, and the values it writes, are computed by Sheets' own splitting and are never reported, before or after the request; the count above is a local upper-bound estimate only
```
The source column itself is excluded from the reported overwrite list: its
content is what the split reads, not a cell the request overwrites. The
overwrite count is **always** an upper bound (unlike `auto-fill`, where
only `--range` is): the API decides for itself how many columns each row's
split needs, and a quoted delimiter or a run of consecutive separators can
make the local split wider than the real one.
Past 50 addresses the rendered line elides the remainder (`… and N more`);
the `-o json` outcome keeps the full list, and the count in the sentence
stays exact either way (issue #1880).
`--delimiter auto` is different, and **not** in a way a caveat alone
covers. For every other delimiter the local split uses the same separator
the API is told to use, so it can only over-count. Under `auto` the
separator is Sheets' own choice, and this crate can only guess it by
trying comma, semicolon, period and space. Sheets' detection is **not**
confined to those four — a live run split a **tab**-separated column
under `auto` with none of the four present — so an `auto` preview can
under-report as well as over-report.
Because of that, an `auto` run never prints an all-clear. Where another
delimiter would say "no non-blank cells in the spill columns", `auto`
says nothing and carries its caveat instead; and where the local split
finds no separator at all, the summary reports the spill span as unknown
rather than claiming no row spills:
```
$ omni-dev drive sheets text-to-columns <ID> --sheet Q1 --source A2:A4 \
--delimiter auto --dry-run
Would split 'Q1'!A2:A4 on an auto-detected separator (none detected in the preview, so the spill span is unknown) in 'Budget'
--delimiter auto lets Sheets detect the separator itself, and it detects separators this preview does not try (a tab-separated column splits under auto, though none of comma, semicolon, period or space appears in it) — so for auto the width above and the cells listed are a guess in both directions, not a bound
the number of columns the split needs, and the values it writes, are computed by Sheets' own splitting and are never reported, before or after the request; the count above is a local upper-bound estimate only
```
A spill that runs past the sheet's current column count is **not refused
client-side**: `sheets append` already grows the grid under `sheets-write`,
so `text-to-columns` follows the same rule. The summary carries a caveat
instead, and the verb never prepends a request to grow the sheet first.
Measured live: splitting `Z60` on a 26-column sheet grew it to 28 columns
rather than erroring.
Two more behaviours were measured against a live workbook, since the API
documents none of them:
- **Consecutive delimiters do not collapse.** `f,,g` splits into three
columns, the middle one empty — the naive local split agrees exactly.
Runs of spaces behave the same way under `--delimiter space`.
- **Quoted delimiters are honoured, and cells past the real width are
left alone.** `q,"x,y",z` splits into three columns, not the four the
local split counts, which is why the reported count is an upper bound;
and a column beyond the widest row's real width keeps its old value,
while cells *within* that width are cleared even on rows that need
fewer.
#### `drive sheets trim-whitespace`
Trims whitespace in every cell of a range, or of a whole sheet. Gated under
**`sheets-write`** (issue #1844, [ADR-0083](adrs/adr-0083.md) §1): it
rewrites ordinary cell content in place, doing nothing a `sheets clear`
followed by a `sheets write` of the same range could not already do under
that grant.
**Formatting is left alone, measured live** (issue #1877, the
[ADR-0083](adrs/adr-0083.md) §5 check, run 2026-09-27). A trim over cells
carrying a background colour, bold and italic text, a percent number
format, borders, a note and a data-validation rule changed none of them.
Cells in the range that needed no trimming were not touched at all, and a
cell outside the range was unaffected. §5's move to the `sheets-structure`
union therefore does not apply, and the verb stays on `sheets-write` alone.
Rich text inside a cell survives too. The API shifts each formatting run's
offsets to follow the removed characters, so every character that remains
keeps its own formatting: `" rich text "` with *rich* bold and *text*
italic becomes `"rich text"` with the same words formatted the same way.
Formatting on removed whitespace goes with it. When an internal run of
spaces collapses to one, the space that survives is the run's first, with
that space's own formatting. This is only visible if the spaces carried
something you can see, such as an underline. Even so, a trim is gentler than
`sheets write`, which drops a cell's rich-text formatting whenever it writes
the cell, under either `--input` mode and even when the new value is the
same text ([ADR-0083](adrs/adr-0083.md) §5's #1877 addendum).
**What "trims" means**, measured against the live API rather than assumed —
the request reference describes it in one sentence, so each of these was
confirmed by running it:
| input | becomes | |
|---|---|---|
| `" lead-trail "` | `"lead-trail"` | leading/trailing stripped |
| `"a b"` | `"a b"` | **internal runs collapse to one space** |
| `" "` | `""` | an all-whitespace cell becomes blank |
| `" =1+1 "` | `"=1+1"` (still text) | not reinterpreted as a formula |
| `" 12 "` | `"12"` (still text) | not reinterpreted as a number, even under a number format |
| `=A5` (a real formula) | unchanged | formula text is not touched |
The second row is the one to note: this is not a leading/trailing trim, so
a cell can change in the middle. The fourth matters if you store
formula-looking text.
Pass either `--range` or `--whole-sheet`, never both. An open-ended
`--range` (`A:A`) is completed from the sheet's current grid extent, and
`--whole-sheet` needs `--sheet` to say which tab:
```bash
# A bounded range.
omni-dev drive sheets trim-whitespace <ID> --sheet Q1 --range A2:D100
# A whole tab.
omni-dev drive sheets trim-whitespace <ID> --sheet Q1 --whole-sheet
# Preview: reads the range's values, sends no batchUpdate.
omni-dev drive sheets trim-whitespace <ID> --sheet Q1 --range A2:D100 --dry-run
```
**`--dry-run` never claims which cells will change.** Sheets owns the trim
rule, and reproducing it locally would be a preview that disagrees with the
real run — the same reason `find-replace` declines a local match count
([ADR-0083](adrs/adr-0083.md) §6). So the preview reports the count and A1
locations of the range's **non-blank** cells, any of which *may* be
trimmed, and never their values:
```
$ omni-dev drive sheets trim-whitespace <ID> --sheet Sheet1 --range A1:C5 --dry-run
Would trim whitespace in 'Sheet1'!A1:C5 of 'Budget'
13 non-blank cell(s) may be trimmed: A1, B1, C1, A2, B2, C2, A3, B3, C3, A4, C4, A5, C5
```
**That gap is real, not theoretical.** The run above is a live one, and the
real run that followed it reported `5` — the preview named every non-blank
cell in the range because it cannot know which of them the server's rule
will touch. Read the preview as "at most these", never as a list of cells
that will change.
Past 50 addresses the rendered line elides the remainder (`… and N more`);
the `-o json` outcome keeps the full list and marks it with
`candidate_cells_upper_bound: true`. Dry runs write no mutation record.
If an explicit range extends beyond the allocated grid, the preview read
is clipped to existing cells and sets `read_clamped_to_sheet: true` in
JSON. The preview says so; the mutation still sends the requested range
to Sheets, which decides whether that range is valid.
The real run reports the API's own `cellsChangedCount` — an exact count of
what changed — and deliberately does **not** re-read the range, unlike
`auto-fill` and the paste family, whose requests return no count:
```
$ omni-dev drive sheets trim-whitespace <ID> --sheet Sheet1 --range A1:C5
Trimmed whitespace in 5 cell(s) of 'Sheet1'!A1:C5 in 'Budget'
```
A run that changes nothing reports `0` rather than omitting the count — the
API returns the field either way.
#### `drive sheets delete-duplicates`
Removes duplicate row cells within a bounded range. This is
the **only verb in its tranche gated by `sheets-delete`** rather than
`sheets-write` (issue #1844, [ADR-0083](adrs/adr-0083.md) §2): cells
inside the range are removed and its survivors shift up, which is
`delete-range`'s shape, not `clear`'s. A `sheets-write` grant does not
open it, and neither does `sheets-structure`.
```bash
# Compare every column in the range.
omni-dev drive sheets delete-duplicates <ID> --sheet Q1 --range A2:D100
# Compare only columns 0 and 2 (absolute, zero-based, as --sort-by uses).
omni-dev drive sheets delete-duplicates <ID> --sheet Q1 --range A2:D100 \
--comparison-column 0 --comparison-column 2
# Preview: makes no values read and no batchUpdate.
omni-dev drive sheets delete-duplicates <ID> --sheet Q1 --range A2:D100 --dry-run
```
Each `--comparison-column` must fall inside the selected range, and the
range must be fully bounded (`A2:D100`, not `A:A`) — an open-ended range
is refused because it automatically spans every allocated row. A bounded
range can still hold blank rows between data rows. The column check is
this tool's own, made before the request: the API rejects an out-of-range
column too, with `400 INVALID_ARGUMENT: A column used for determining
duplicates is not contained in the range`, so the local refusal only buys
a clearer message and a round trip.
**Content outside the selected rectangle stays in place.** For example,
deduplicating `A2:D100` removes and shifts cells in columns A–D only.
Column E remains on its original rows, so records can become misaligned
if the range is narrower than the data. Both text output and JSON report
this range-only effect; JSON sets
`content_outside_range_untouched: true`.
**The API selects the rows, not the caller.** Every other `sheets-delete`
verb removes cells named by address; here Sheets applies its own equality
rule and removes whatever it finds. Because a wrong local guess would cost
a *row*, `--dry-run` states that rule rather than listing rows it cannot
vouch for ([ADR-0083](adrs/adr-0083.md) §6):
```
$ omni-dev drive sheets delete-duplicates <ID> --sheet Q1 --range A2:D100 --dry-run
Warning: blank rows between data rows duplicate one another, so every such blank row after the first is removed; blank rows after the last data row are left alone
Warning: only cells inside the selected range are removed and shifted up; columns outside it stay in place, so a range narrower than the sheet can misalign records
Would remove duplicate rows from 'Q1'!A2:D100 in 'Budget', comparing every column in the range
the API keeps the first instance of each duplicate and removes the rest; duplicates need not be adjacent, rows differing only in letter case, formatting or formulas still count as duplicates, and rows hidden by a filter are removed along with visible ones
which rows would be removed is decided by the API and cannot be previewed
```
Each clause of that rule was confirmed against the live API, on a
seven-row range comparing every column:
- `["A","1","x"]` was removed as a duplicate of `["a","1","x"]` —
**case is ignored**.
- A second blank row between data rows was removed as a duplicate of the
first — **blank rows between data rows duplicate one another**. Blank
rows *after* the last data row were not: `A1:C10` holding three data rows
and seven trailing blank rows removed none.
- A row identical to row 1 but five rows below it was removed —
**duplicates need not be adjacent**.
- A row differing only in an *uncompared* column was removed when
`--comparison-column` excluded that column, and kept when it didn't.
- A row **hidden by a basic filter** was removed like any other. Worth
dwelling on: the removed row carried a value in a column the filter was
hiding, so a dedupe can delete data the operator could not see on screen
when they ran it.
That blank-row behaviour is the reason there is no `--whole-sheet` scope
here, though `trim-whitespace` has one: blank rows between data rows
duplicate one another, so a whole-sheet dedupe would collapse every
intentional gap in a tab to a single blank row.
The real run reports the API's `duplicatesRemovedCount` and, being a
`sheets-delete` verb, the same recovery tail every destructive verb ends
with — naming the lease's backup when one was taken, and saying plainly
that there is none when the deciding rule set `require_lease: false`:
```
$ omni-dev drive sheets delete-duplicates <ID> --sheet Q1 --range A2:D100 --lease <TOKEN>
Warning: only cells inside the selected range are removed and shifted up; columns outside it stay in place, so a range narrower than the sheet can misalign records
Removed 4 duplicate row(s) from 'Q1'!A2:D100 in 'Budget', comparing every column in the range
the API keeps the first instance of each duplicate and removes the rest; …
this cannot be undone through omni-dev — the lease this write required backed the whole spreadsheet up when it was acquired (Drive copy 1AbC…); run `omni-dev drive lease restore <TOKEN>` to locate it, restore from that copy in the Drive UI, or fall back to Google Drive's own version history
```
#### `drive sheets create`
Creates a spreadsheet, optionally seeded with values. Gated under the
**`create`** operation, not `sheets-write` — the same rule that governs
`drive create`.
```bash
omni-dev drive sheets create --name '2027 Budget' --parent <FOLDER_ID>
omni-dev drive sheets create --name '2027 Budget' --parent <FOLDER_ID> --values ./seed.csv
```
Without `--values` this is shorthand for
`drive create --mime-type application/vnd.google-apps.spreadsheet`, which
does the same thing; the reason it exists is `--values` and being
discoverable inside the `sheets` tree.
**The seeding write is not separately gated.** A folder that grants `create`
but not `sheets-write` can still be seeded: the `create` verdict authorises
the pair. That is safe only because the id being written is always the one
`files.create` just returned inside an already-cleared folder, never
something you supplied — gating it separately would make `--values` unusable
in a create-only folder for no gain. See [ADR-0073](adrs/adr-0073.md) §11.
**If seeding fails after the spreadsheet is created**, you get a *partial
failure* naming the new file id, because there is no `files.delete` anywhere
in this integration and the empty spreadsheet cannot be rolled back
automatically:
```
Partially failed: created '2027 Budget' (1AbC…) in <FOLDER_ID>, but writing
its values failed: … The spreadsheet exists and is empty — it cannot be
rolled back automatically.
```
Delete it yourself if you don't want it.
#### drive sheets add-sheet / rename-sheet / insert-rows / insert-columns / insert-range / move-rows / move-columns / duplicate-sheet / reorder-sheet / hide-sheet / show-sheet
Structural edits — changing the *shape* of a workbook rather than its cell
values. Gated by the separate `sheets-structure` operation (above), so a
folder granted `sheets-write` cannot be restructured without an explicit
additional grant.
Every one of these previews with `--dry-run` first:
```bash
# What would change, and to what — no mutation is attempted.
omni-dev drive sheets insert-rows <ID> --sheet Q2 --at 5 --count 3 --dry-run
```
```
Would insert 3 row(s) before row 5 of 'Q2' (sheetId 118293) in 'Budget'
(500 rows -> 503; existing rows 5-500 shift down)
```
That second line is the point of a structural dry run. An insert's effect
isn't expressible as a range — it shifts everything below it — so the
preview names the resulting dimension *and* the shift, read from the sheet's
real current size rather than assumed.
```bash
# Add a tab. --rows/--columns are optional; omitted takes Sheets' own
# defaults (1000 x 26) rather than a size omni-dev invents.
omni-dev drive sheets add-sheet <ID> --title Q3
omni-dev drive sheets add-sheet <ID> --title Q3 --index 2 --rows 200 --columns 8
# Rename a tab, by its current title.
omni-dev drive sheets rename-sheet <ID> --sheet Q2 --title 'Q2 (final)'
# Insert rows or columns. --at is 1-based and inclusive — the row or column
# number the spreadsheet itself shows — and inserts *before* it.
omni-dev drive sheets insert-rows <ID> --sheet Q2 --at 5 --count 3
omni-dev drive sheets insert-columns <ID> --sheet Q2 --at 2
# Insert empty cells into a bounded rectangle. Existing cells shift down
# (`--shift rows`) or right (`--shift columns`); the bounds are 1-based and
# inclusive, like delete-range.
omni-dev drive sheets insert-range <ID> --sheet Q2 \
--start-row 2 --end-row 4 --start-column 2 --end-column 3 --shift rows
```
`--at 5` puts the new rows above the current row 5. Column A is 1.
`--count` defaults to 1.
`insert-range` keeps the sheet's dimensions unchanged. It uses
`sheets-structure`, unlike its destructive inverse `delete-range`, because it
shifts cells and creates empty ones. Sheets silently drops cells pushed past
the grid edge, so the `--dry-run` preview always calls out that risk.
```bash
# Move a contiguous block of rows or columns. --at/--count name the source
# (1-based inclusive, same as insert-rows/insert-columns); --before is the
# 1-based row/column the block moves in front of, numbered as the sheet
# stands *before* the move — the same numbering --at uses.
omni-dev drive sheets move-rows <ID> --sheet Q2 --at 2 --count 2 --before 6
omni-dev drive sheets move-columns <ID> --sheet Q2 --at 5 --before 1
```
```bash
omni-dev drive sheets move-rows <ID> --sheet Q2 --at 2 --count 2 --before 6 --dry-run
```
```
Would move 2 row(s) 2-3 of 'Q2' (sheetId 118293) in 'Budget' to before row 6
(500 rows unchanged; rows 4-5 shift up to 2-3; moved rows land at 4-5)
```
The dry run's second line, like an insert's, states both the shift and
where the block actually lands. Because `--before` is numbered as the sheet
stood *before* the move, the two directions land differently: an upward move
lands the block exactly on the `--before` number, while a downward move —
like the one above — lands it `count` short, since everything between the
source and `--before` has already shifted up to fill the gap. The CLI's own
validation deliberately does not consult frozen rows/columns or dimension
groups; Sheets applies its own rules to the move.
```bash
# Copy a sheet. --title omitted takes Sheets' own "Copy of X" default;
# --index omitted takes Sheets' own default position — confirmed against
# the live API to be the front of the workbook (index 0), not the end,
# unlike add-sheet. A given --title must not already be in use, including
# by the source sheet itself.
omni-dev drive sheets duplicate-sheet <ID> --sheet Q2 --title 'Q2 (copy)'
# Move a sheet to a new zero-based position among its siblings.
omni-dev drive sheets reorder-sheet <ID> --sheet Q2 --index 0
# Hide/show a tab. Hiding the workbook's last visible sheet is refused —
# Sheets requires at least one to stay visible.
omni-dev drive sheets hide-sheet <ID> --sheet Q2
omni-dev drive sheets show-sheet <ID> --sheet Q2
```
Several refusals are specific to these verbs, and all of them are checked
before anything is written so a `--dry-run` can never promise a change the
real run then fails:
```
Refused: 'Budget' has no sheet titled 'Nope'. Available: 'Q1', 'Q2'
Refused: 'Budget' already has a sheet titled 'Q1'
Refused: --at 502 is past the end of the sheet, which has 500 row(s); the furthest valid position is 501
Refused: --before 502 is past the end of the sheet, which has 500 row(s); the furthest valid position is 501
Refused: --before 3 is inside or immediately after the block being moved (rows 2-3), so nothing would move
```
`hide-sheet` on a sheet that is already hidden, or `show-sheet` on one
that is already visible, sends nothing and reports `Unchanged: sheet 'Q2'
is already hidden in 'Budget'` (JSON `"status":"unchanged"`), in a dry run
and a real run alike, rather than claiming a change.
The last refusal above is unique to `move-rows`/`move-columns`: no other verb has
a destination that can collide with its own source, so only a move can be
refused for moving nothing.
The duplicate-title refusal applies to `rename-sheet` too — renaming a
sheet to a title a *different* sheet already has fails the same way
`add-sheet` does. Renaming a sheet to the title it already has is not a
collision, since it names itself rather than a different sheet.
The *positions* — `--at` on `insert-rows`/`insert-columns` and `--index` on
`add-sheet` — are checked against the workbook's actual current size. `--at`
may name one past the sheet's last row/column (that's a valid append), never
further. The *counts* — `--count`, `--rows`, `--columns` — are checked only
for being positive: the workbook's state implies no upper bound on how much
you may add, so `omni-dev` doesn't invent one, and Sheets remains the
authority on how large a sheet may actually get. A count large enough to
overflow the row/column index space is refused rather than sent.
#### drive sheets update-sheet-properties
Changes a sheet's view properties — frozen rows/columns, tab color,
right-to-left layout, and hidden gridlines — via the same
`updateSheetProperties` request `rename-sheet`/`reorder-sheet`/
`hide-sheet`/`show-sheet` use, but able to set several of them in one call.
Gated by `sheets-structure`, like the rest of this family; see
[ADR-0085](adrs/adr-0085.md).
Every flag is independently optional and only the ones passed go in the
field mask, so a change to one property never disturbs another. At least
one must be given.
```bash
omni-dev drive sheets update-sheet-properties <ID> --sheet Q2 \
--freeze-rows 1 --tab-color '#FF8800'
```
```
Would update sheet 'Q2' (sheetId 118293) in 'Budget': frozen rows 0 -> 1, tab color -> #FF8800
```
Tab color has no bare "unset" value, so clearing it is a separate flag:
```bash
omni-dev drive sheets update-sheet-properties <ID> --sheet Q2 --clear-tab-color
```
`--right-to-left`/`--hide-gridlines` take an explicit `true`/`false` rather
than being bare presence flags, so passing `false` restores the default
instead of the flag being unable to mean anything but "on".
`--tab-color` and `--clear-tab-color` are mutually exclusive. Freezing every
row or column is refused — Sheets requires at least one to stay unfrozen —
and an invalid `#RRGGBB` value is refused before anything is sent:
```
Refused: update-sheet-properties requires at least one property to change
Refused: '#GGGGGG' is not a color; expected 6 hex digits, optionally prefixed with '#' (e.g. #FF8800)
Refused: --freeze-rows 500 would freeze every row; the sheet has 500 row(s), so the most that can be frozen is 499
```
Like every prior verb in this family, tab color is never read back: `sheets
info` and `--dry-run` can state what a sheet's tab color *would become*, but
never what it currently is (ADR-0085 §8).
#### drive sheets update-workbook-properties
Changes workbook-level properties — locale, time zone, automatic
recalculation, and iterative calculation — rather than any one sheet's shape.
The one structural verb with no sheet target: every other verb in this
section names a `--sheet`, this one acts on the workbook itself. Gated by
the same `sheets-structure` operation as the rest of this section
([ADR-0086](adrs/adr-0086-workbook-properties.md)).
Whatever `update-workbook-properties` sets, `drive sheets info` reads back:
its table render gains `Locale:`/`Time zone:`/`Recalculation:`/`Iterative
calculation:` lines, each shown only when Sheets reports the field
(`-o json`/`-o yaml` already carry every workbook property unconditionally).
`spreadsheetTheme` (font family plus a full color palette) is deliberately
out of scope — a large nested type left for a future issue, matching this
crate's established pattern of shipping a documented subset.
```bash
# Set locale and time zone. At least one of --locale/--time-zone/
# --auto-recalc/--iterative-calculation is required.
omni-dev drive sheets update-workbook-properties <ID> --locale en_US --time-zone America/New_York
# How often the workbook recalculates.
omni-dev drive sheets update-workbook-properties <ID> --auto-recalc on-change
# Turn iterative calculation on, optionally with explicit bounds — omitted
# sub-fields take Sheets' own defaults.
omni-dev drive sheets update-workbook-properties <ID> --iterative-calculation on \
--iterative-calculation-max-iterations 50 \
--iterative-calculation-convergence-threshold 0.01
# Turn it back off.
omni-dev drive sheets update-workbook-properties <ID> --iterative-calculation off
```
```bash
omni-dev drive sheets update-workbook-properties <ID> --locale en_US --auto-recalc hour --dry-run
```
```
Would update workbook properties of 'Budget': locale -> 'en_US', auto-recalc -> HOUR
```
Turning iterative calculation on changes what a circular-reference formula
elsewhere in the workbook *evaluates to* — a value effect reached
indirectly, the same shape of concern [ADR-0081](adrs/adr-0081.md) raised
for named-range deletion and [ADR-0086](adrs/adr-0086-workbook-properties.md)
§9 applies here. No cell's formula is itself changed, only what some
formulas compute, which is why this still gates as `sheets-structure` rather
than a data-mutating operation.
`--iterative-calculation-max-iterations`/
`--iterative-calculation-convergence-threshold` are only valid alongside
`--iterative-calculation on`; the API has no boolean "enabled" field, so
`--iterative-calculation off` clears the settings object entirely rather
than writing a "disabled" value into it:
```
Refused: update-workbook-properties needs at least one property to set (--locale, --time-zone, --auto-recalc, --iterative-calculation)
Refused: --iterative-calculation-max-iterations/--iterative-calculation-convergence-threshold require --iterative-calculation on
```
`--time-zone` is checked locally against the IANA time zone database
(embedded in the binary, names are case-sensitive) before any request is
sent, on `--dry-run` too. The check exists because the Sheets API does not
make it: an unrecognised zone is accepted with a 200 and the workbook is
silently reset to `Etc/GMT`, losing its previous zone
([#1938](https://github.com/rust-works/omni-dev/issues/1938)). An unknown
`--locale` needs no local check, since the API rejects it with an HTTP 400:
```
Refused: --time-zone 'Bogus/Zone9' is not a recognised IANA time zone name (for example 'America/New_York' or 'Europe/London'); the Sheets API would silently reset the workbook to 'Etc/GMT' instead of rejecting it
```
#### drive sheets delete-sheet / delete-rows / delete-columns / delete-range
Destructive edits — the same `spreadsheets.batchUpdate` mechanism as above,
but these actually remove data. Gated by the separate `sheets-delete`
operation, **not** `sheets-structure`: a folder granted `sheets-structure`
cannot delete anything without an explicit additional grant, and vice versa.
See [ADR-0077](adrs/adr-0077-sheets-deletion-via-batchupdate.md).
Since issue #1844, [`delete-duplicates`](#drive-sheets-delete-duplicates)
joins them under the same operation — the first capability to do so since
ADR-0077 defined it ([ADR-0083](adrs/adr-0083.md) §2). It differs from the
four above in one way worth knowing before granting `sheets-delete`: they
remove cells you name by address, while it removes rows **Sheets** selects
from the data.
There is still no interactive confirmation and no `--force` anywhere in this
tool, deletion included — the permission gate and an honest `--dry-run` are
the whole consent mechanism, the same as every other write in this
integration. And there is still no raw `spreadsheets.batchUpdate` request
array: every verb, destructive or not, is its own typed command.
```bash
# What would be destroyed — no mutation is attempted.
omni-dev drive sheets delete-rows <ID> --sheet Q2 --at 5 --count 3 --dry-run
```
```
Would delete 3 row(s) 5-7 of 'Q2' (sheetId 118293) in 'Budget'
(500 rows -> 497; existing rows 8-500 shift up; formulas elsewhere in the
workbook that reference the deleted rows may break, which cannot be
checked automatically)
```
That caveat is deliberate and load-bearing: checking whether some other
sheet's formula references what would be deleted would mean reading the
whole workbook's formulas, not just the target's own dimensions, and
`--dry-run` for a destructive verb stays exactly as structural as the
additive one above — no extra `values.get` read, no cell content in its
output or the request log.
```bash
# Delete an entire tab. Cannot be undone through omni-dev.
omni-dev drive sheets delete-sheet <ID> --sheet Q2
# Delete rows or columns. --at is 1-based inclusive, same as insert-rows.
omni-dev drive sheets delete-rows <ID> --sheet Q2 --at 5 --count 3
omni-dev drive sheets delete-columns <ID> --sheet Q2 --at 2
# Delete a rectangular range, shifting what remains up or left to close the
# gap. All four bounds are required — an open-ended span is delete-rows/
# delete-columns's job, not this one's.
omni-dev drive sheets delete-range <ID> --sheet Q2 \
--start-row 2 --end-row 4 --start-column 2 --end-column 3 --shift rows
```
Every real (non-`--dry-run`) delete says how to recover — there is still
no `files.delete` in this integration, and `drive lease restore` (below)
is only a partial undo. The `--lease` these verbs require
([ADR-0080](adrs/adr-0080.md) §9) backed the whole
spreadsheet up as a Drive copy when it was acquired, so that copy — named
by its file id, not Drive's own version history — is the primary recovery
path:
```
Deleted sheet 'Q2' (sheetId 118293) from 'Budget'; this cannot be undone
through omni-dev — the lease this write required backed the whole
spreadsheet up when it was acquired (Drive copy 1AbC…); run `omni-dev
drive lease restore <TOKEN>` — it restores a single deleted sheet
automatically, or otherwise locates the copy to restore from by hand in
the Drive UI — or fall back to Google Drive's own version history
```
Two things the wording is careful about. The copy dates from **acquisition**,
not from immediately before this delete: a lease is multi-use for its
lifetime ([ADR-0080](adrs/adr-0080.md) §5), so earlier writes under the
same token are not in it. And a folder whose deciding rule sets
`require_lease: false` takes no backup at all, so a delete there says
``no lease backup was taken (the deciding write-permission rule sets
`require_lease: false`), so Google Drive's version history is the only
recovery path`` rather than pointing at a copy that does not exist. The
`--output json` outcome carries the same copy as a `backup` field
(`{"kind": "drive_copy", "file_id": …}`), omitted when none was taken.
`drive lease restore` ([ADR-0080](adrs/adr-0080.md) §10) is named here for
what it actually does today: deleting exactly one sheet is the one typed
path it restores automatically, via `spreadsheets.sheets.copyTo` (see
[Restore](#restore) below) — every other shape here (multiple sheets,
rows, columns or a range) it only *locates* the copy for, restoring it
into the live spreadsheet is still a manual Drive-UI copy-back.
The same bounds-checking as `insert-rows`/`insert-columns` applies, inverted:
`--at`/`--count` (or the range bounds) must name rows/columns/cells that
already exist — deletion has no append-boundary case, since everything named
must be real.
#### drive sheets format-cells / update-borders / merge-cells / unmerge-cells / auto-resize-dimension / update-dimension-properties
Cell and border formatting, merging, and row/column sizing. Also gated by
`sheets-structure` — see [ADR-0078](adrs/adr-0078.md).
```bash
# Format cells. At least one property flag is required; the batchUpdate
# fields mask sent is built from exactly the flags given.
omni-dev drive sheets format-cells <ID> --sheet Q2 --range A1:D1 \
--bold true --background '#FFFF00'
# The CellFormat subset now also reaches font family, text rotation,
# hyperlink display type, padding and text direction (#1791) — the union
# --text-rotation-angle/--text-rotation-vertical is mutually exclusive.
omni-dev drive sheets format-cells <ID> --sheet Q2 --range B2:B100 \
--number-format '#,##0.00' --number-format-type currency \
--font-family Arial --padding-top 4 --padding-bottom 4
# Borders: at least one of --top/--bottom/--left/--right/--all/
# --inner-horizontal/--inner-vertical. --all covers only the four outer
# edges; the two inner-grid-line flags need to be named explicitly.
omni-dev drive sheets update-borders <ID> --sheet Q2 --range A1:D1 --all \
--style solid-medium --color '#000000'
# Merging discards every value but the top-left's. --dry-run lists exactly
# which cells and values would be lost — read it before running for real.
omni-dev drive sheets merge-cells <ID> --sheet Q2 --range A1:D1 --dry-run
omni-dev drive sheets unmerge-cells <ID> --sheet Q2 --range A1:D1
# Resize rows/columns. --start/--end are 1-based and inclusive.
omni-dev drive sheets auto-resize-dimension <ID> --sheet Q2 \
--dimension columns --start 1 --end 4
omni-dev drive sheets update-dimension-properties <ID> --sheet Q2 \
--dimension columns --start 1 --end 1 --pixel-size 200
```
```
Would merge (MERGE_ALL), discarding 3 cell(s): B1: old note; C1: 12; D1: draft
```
Past 200 entries the rendered line elides the remainder (`… and N more
(full list in -o json / the drivemutation log's discarded_cells)`); the
exact count in the sentence and the `-o json` outcome's `discarded_cells`
stay complete either way (issue #1999).
A range over 50,000 cells (for example, `A1:ZZ1000`) is refused as
`refused-invalid-range` before anything is read, on `--dry-run` and the
real run alike. This keeps the read and `discarded_cells` bounded without
ever eliding them. There is no flag to exceed the limit, so do a larger
merge in the Sheets UI (issue #2025).
`format-cells` can never write a *value* — it builds a `repeatCell` request
whose payload has no field to put one in, regardless of what flags are
given, so a `sheets-structure` grant that lets you reformat a workbook can
never be used to change what it says.
#### drive sheets set-data-validation / clear-data-validation
Restricts what may be entered into a range. Also gated by
`sheets-structure`.
```bash
# Exactly one condition flag is required.
omni-dev drive sheets set-data-validation <ID> --sheet Q2 --range C2:C100 \
--one-of-list Draft,Final,Archived
omni-dev drive sheets set-data-validation <ID> --sheet Q2 --range D2:D100 \
--number-between 0 100
omni-dev drive sheets set-data-validation <ID> --sheet Q2 --range E2:E100 --checkbox
omni-dev drive sheets set-data-validation <ID> --sheet Q2 --range F2:F100 \
--custom-formula '=F2<=D2'
# Tranche 2 (#1792): a dropdown sourced from a range, numeric comparators,
# text conditions, date conditions (absolute dates only), and blank checks.
omni-dev drive sheets set-data-validation <ID> --sheet Q2 --range G2:G100 \
--one-of-range 'Lists!A1:A10'
omni-dev drive sheets set-data-validation <ID> --sheet Q2 --range H2:H100 \
--number-greater 0
omni-dev drive sheets set-data-validation <ID> --sheet Q2 --range I2:I100 \
--text-contains '@example.com'
omni-dev drive sheets set-data-validation <ID> --sheet Q2 --range J2:J100 \
--date-after 2024-01-01
omni-dev drive sheets set-data-validation <ID> --sheet Q2 --range K2:K100 --not-blank
# --show-warning allows an invalid entry through with a warning instead of
# rejecting it outright (the default).
omni-dev drive sheets clear-data-validation <ID> --sheet Q2 --range C2:C100
```
**A `--range` past the sheet's grid is clamped, not grown.** Sheets applies
the request only to the part of the range inside the sheet's current rows
and columns, without saying so. Both verbs therefore name the clamped range
in the dry run and the real run (`-o json`: `clamped_to`), and refuse a
range lying wholly past the grid, which would change nothing:
```
$ omni-dev drive sheets set-data-validation <ID> --sheet Q2 --range Z1:AA2000 --checkbox --dry-run
Would set data validation (boolean, reject invalid entries) in 'Budget'
the range runs past the sheet's current grid; Sheets clamps it, so this would apply to 'Q2'!Z1:Z1000 only
```
Tranche 1 (#1643) shipped `--one-of-list`, `--number-between`, `--checkbox`,
and `--custom-formula`. Tranche 2 (#1792) added every remaining condition
type addressable with a flat flag: `--one-of-range`; the numeric comparators
(`--number-not-between`, `--number-greater(-eq)`, `--number-less(-eq)`,
`--number-eq`, `--number-not-eq`); the text conditions (`--text-contains`,
`--text-not-contains`, `--text-starts-with`, `--text-ends-with`,
`--text-eq`); the date conditions (`--date-after`, `--date-before`,
`--date-on`, `--date-between` — absolute dates only; Sheets rejects a
relative keyword like `today` in data validation with a bare HTTP 400,
even though `add-conditional-format`/`update-conditional-format` accept
one for the identically-shaped condition); and `--blank`/`--not-blank`.
Still not reachable, a documented cut rather than a silent gap:
`TEXT_IS_EMAIL`, `TEXT_IS_URL`, `DATE_ON_OR_BEFORE`, `DATE_ON_OR_AFTER`,
`DATE_NOT_BETWEEN`, `DATE_IS_VALID`, and every condition type meaningful
only inside a conditional-format rule.
#### drive sheets set-developer-metadata / delete-developer-metadata / search-developer-metadata
Key/value pairs attached to a spreadsheet, sheet, row or column — the
channel other add-ons key their own state on. Also gated by
`sheets-structure` (issue #1795, [ADR-0081](adrs/adr-0081.md) §4).
`--sheet`/`--dimension`/`--start`/`--end` are optional on all three and
compose into one of three locations: none of them means the whole
spreadsheet, `--sheet` alone means the whole sheet, and all four together
mean a single row or column. Sheets rejects a `DimensionRange` spanning
more than one row/column with a bare HTTP 400 ("must represent a single
row or column"), so `--start` and `--end` must name the same 1-based
index — refused locally with a clear message if they don't (issue #1933).
```bash
# Spreadsheet-scoped: no --sheet/--dimension/--start/--end at all.
omni-dev drive sheets set-developer-metadata <ID> --key owner --value team-a
# Sheet-scoped.
omni-dev drive sheets set-developer-metadata <ID> --key owner --value team-a \
--sheet Q2
# Row/column-scoped: --start and --end name the same 1-based row or column.
omni-dev drive sheets set-developer-metadata <ID> --key source --value import \
--sheet Q2 --dimension rows --start 2 --end 2
# Re-running set-developer-metadata with an existing key and location
# updates its value instead of creating a duplicate entry.
omni-dev drive sheets set-developer-metadata <ID> --key owner --value team-b
# --dry-run reports every entry that would be removed before it happens.
omni-dev drive sheets delete-developer-metadata <ID> --key owner --dry-run
omni-dev drive sheets delete-developer-metadata <ID> --key owner
# search-developer-metadata is read-only and ungated. Omit --key and every
# location flag to list every DOCUMENT-visibility entry in the workbook.
omni-dev drive sheets search-developer-metadata <ID>
omni-dev drive sheets search-developer-metadata <ID> --sheet Q2
```
**There is no `--visibility` flag.** `DeveloperMetadata` carries a
visibility of `DOCUMENT` or `PROJECT`; `PROJECT`-visibility metadata
belongs to whatever OAuth client created it, not to this tool, so every
request this surface sends is hardcoded to `DOCUMENT` and every response it
reads is checked against it — there is no way, from the CLI or otherwise,
to reach a `PROJECT`-visibility entry through `drive sheets`.
`delete-developer-metadata` bulk-removes: since a key/location filter can
match more than one entry, the preview (and the real run) lists every entry
it applies to, not just one.
#### drive sheets add-conditional-format / update-conditional-format / delete-conditional-format / list-conditional-formats
Conditional formatting rules — a `BooleanRule` (a condition-triggered
format) or a `GradientRule` (a color scale). Also gated by
`sheets-structure` (issue #1793, [ADR-0081](adrs/adr-0081.md) §1).
Rules are an **ordered list per sheet, addressed by index** — deleting a
rule shifts every later index. `update-conditional-format`/
`delete-conditional-format`'s `--index` is only valid against a snapshot
just read, so run `list-conditional-formats` (a plain, ungated read, like
`list-protections`) immediately before acting to confirm the index is still
current. `--dry-run` on `update`/`delete` echoes the rule *currently* at the
given index alongside the change that would be made, so a stale index is
visible before it's acted on.
```bash
# A BooleanRule: exactly one condition flag, plus at least one of
# --background/--text-color/--bold.
omni-dev drive sheets add-conditional-format <ID> --sheet Q2 --range C2:C100 \
--number-greater 100 --background '#FF0000' --bold true
# A GradientRule: --gradient-min-color/--gradient-max-color, plus an
# optional --gradient-mid-color/--gradient-mid-type/--gradient-mid-value.
omni-dev drive sheets add-conditional-format <ID> --sheet Q2 --range D2:D100 \
--gradient-min-color '#FFFFFF' --gradient-max-color '#00FF00' \
--gradient-mid-color '#FFFF00' --gradient-mid-type percent --gradient-mid-value 50
# A rule can span more than one range — repeat --range.
omni-dev drive sheets add-conditional-format <ID> --sheet Q2 \
--range C2:C100 --range D2:D100 --cell-empty --background '#CCCCCC'
# See what exists, and at what index — a plain, ungated read.
omni-dev drive sheets list-conditional-formats <ID>
# update-conditional-format replaces the whole rule at --index, ranges
# included; it does not move a rule to a different index.
omni-dev drive sheets update-conditional-format <ID> --sheet Q2 --index 0 \
--range C2:C100 --number-greater 200 --background '#FF0000'
omni-dev drive sheets delete-conditional-format <ID> --sheet Q2 --index 1
```
Unlike `set-data-validation`, `--sheet` is required on `add`/`update` (a
rule's ranges must all share one sheet). The condition set is curated the
same way `set-data-validation`'s is, cut to a different boundary: dropdown
types (`ONE_OF_LIST`/`ONE_OF_RANGE`/`CHECKBOX`) don't apply to a format
trigger, so they're absent here; `--cell-empty`/`--cell-not-empty` (Sheets'
`BLANK`/`NOT_BLANK`) are present instead, since they're meaningful only as
a format trigger. Still
not reachable, the same documented cut `set-data-validation` names:
`TEXT_IS_EMAIL`, `TEXT_IS_URL`, `DATE_ON_OR_BEFORE`, `DATE_ON_OR_AFTER`,
`DATE_NOT_BETWEEN`, `DATE_IS_VALID`. `GradientRule`'s two endpoints are
always anchored `MIN`/`MAX`; Sheets also allows an endpoint anchored at an
explicit `NUMBER`/`PERCENT`/`PERCENTILE` value, which is not reachable here.
#### drive sheets add-pivot-table / delete-pivot-table / list-pivot-tables
Pivot tables. Unlike every other capability in this tranche, there is no
`addPivotTable` request — a pivot table is created by `updateCells`
carrying a `pivotTable` in a single anchor cell's `CellData`, and the
server renders the result outward from that anchor, overwriting whatever
values are already there, with an extent the request itself never states
([ADR-0081](adrs/adr-0081.md) §5).
**Gate:** `add-pivot-table` requires **both** the `sheets-write` and
`sheets-structure` write-permission operations to independently resolve
`Allow` against the same target — the first capability in the crate
needing more than one operation. An operator must hold both grants (a
single `allow: ["sheets-write", "sheets-structure"]` folder rule is the
usual shape). `delete-pivot-table` needs `sheets-write` alone: it only ever
clears the anchor's own value, no structural effect.
```bash
# Grant both operations on the folder these spreadsheets live in.
cat >> ~/.omni-dev/settings.json <<'EOF'
{"write_permissions": {"rules": [
{"folder_id": "<FOLDER_ID>", "allow": ["sheets-write", "sheets-structure"]}
]}}
EOF
# Anchor a pivot table at 'Report'!A1, sourced from 'Data'!A1:D1000 (a
# bounded rectangle is required). --row/--column/--value/--filter name a
# 0-based column offset *into the source*, not an absolute sheet column.
# A --row/--column sort order is optional and defaults to asc: the API
# rejects a grouping without one, so a bare `--row 0` is sent as `0:asc`.
omni-dev drive sheets add-pivot-table <ID> --sheet Report --anchor A1 \
--source 'Data!A1:D1000' \
--row 0:asc --value 3:sum
# Multiple groupings, a filter, and a vertical layout.
omni-dev drive sheets add-pivot-table <ID> --sheet Report --anchor D1 \
--source 'Data!A1:D1000' \
--row 0 --column 1:desc --value 3:sum --value 2:counta \
--filter 1:East,West --value-layout vertical --no-totals
# Discover existing pivot tables and their anchors — the one way to find
# the --anchor delete-pivot-table needs.
omni-dev drive sheets list-pivot-tables <ID>
omni-dev drive sheets delete-pivot-table <ID> --sheet Report --anchor A1
```
**`--dry-run` names the anchor, the source, the configuration, and the
anchor's own current content — never the overwritten region.** The server
computes the rendered extent from the source data at creation time, so it
cannot be known until the request is sent (the same "cannot be computed
from the request alone" limitation [ADR-0075](adrs/adr-0075.md) §6 hit for
dimension-shift previews, one size larger). The one fact that *is*
computable is what the anchor cell itself holds before the write — always
overwritten — so `--dry-run` reads and reports it:
```
Would add a pivot table anchored at 'Report'!A1 in 'Budget'
source: 'Data'!A1:D1000
rows: col 0 (asc); columns: none; values: SUM of col 3; layout: HORIZONTAL (default); totals: on
anchor 'Report'!A1 currently: empty
NOTE: the rendered extent is computed by the server from the source data and is
not known until the request is sent; cells right of and below the anchor may be
overwritten.
```
**No `update-pivot-table` verb.** On the wire, replacing a pivot table is
byte-identical to creating one — the field mask simply replaces whatever
`pivotTable` the anchor already holds. Rather than add a verb
indistinguishable from `add` on the wire, `add-pivot-table` refuses an
anchor that already holds a pivot table; delete it first with
`delete-pivot-table`, or choose a different anchor.
**A curated surface, not full API coverage**, matching the rest of this
tranche's stance. Not reachable: `PivotValue`'s `CUSTOM` summarize function
(a formula-driven value rather than a source column); `PivotGroup.groupRule`
(date/number bucketing) and `.valueBucket`/`.valueMetadata` (sort-by-value
and collapsed-group state); the deprecated `criteria` filter form (this
crate always writes the newer `filterSpecs`, and `--filter` only builds its
`visibleValues` allow-list form, not the condition-based one); data-source
pivots; and `PivotValue`'s custom display-name/`calculatedDisplayType`
options beyond the default name. `--value`'s summarize functions are `sum`,
`counta`, `count`, `countunique`, `average`, `max`, `min`, `median`,
`product`, `stdev`, `stdevp`, `var`, `varp`.
#### drive sheets protect-range / update-protection / unprotect-range / list-protections
Protected ranges, gated by the **separate `sheets-protection`** operation —
not `sheets-structure`. See [ADR-0078](adrs/adr-0078.md) §2 for why: a
protected range is a permission inside the document, not a structural
change.
```bash
# Protect a range, or an entire sheet with --whole-sheet.
omni-dev drive sheets protect-range <ID> --sheet Q2 --range A1:A10 \
--description 'Locked headers' --editor teammate@example.com
omni-dev drive sheets protect-range <ID> --sheet Signed --whole-sheet --description Final
# See what's protected — a plain, ungated read.
omni-dev drive sheets list-protections <ID>
# Change or remove an existing protection, resolved by exact range match.
omni-dev drive sheets update-protection <ID> --sheet Q2 --range A1:A10 \
--add-editor another@example.com --remove-editor teammate@example.com
omni-dev drive sheets unprotect-range <ID> --sheet Q2 --range A1:A10
# A whole-sheet protection has no range of its own — --whole-sheet is the
# only way to update-protection/unprotect-range one.
omni-dev drive sheets unprotect-range <ID> --sheet Signed --whole-sheet
```
`update-protection`/`unprotect-range` need the *exact* range (or, with
`--whole-sheet`, the exact sheet) a protection covers — `list-protections`
is how you find it, since Sheets exposes no other user-facing handle. An
ambiguous or non-matching target is refused rather than guessed at. Sheets
has no incremental editor add/remove either: `--add-editor`/
`--remove-editor` compute the full resulting list from the protection's
current editors before sending it.
**Limits.** A whole-workbook read refuses a spreadsheet beyond a fixed sheet
count rather than returning part of it — silently returning half a workbook is
indistinguishable from a workbook that small. Narrow the read with `--sheet` or
`--range` if you hit it.
#### drive sheets set-basic-filter / clear-basic-filter / add-filter-view / update-filter-view / delete-filter-view / list-filter-views
The basic filter and filter views (issue #1794), gated by
`sheets-structure` like formatting and data validation — see
[ADR-0081](adrs/adr-0081.md): a filter hides rows, which is view state, not
data. The two are shaped differently: a sheet has **at most one** basic
filter, so `set-basic-filter` is an upsert and `clear-basic-filter` needs
only `--sheet`; clearing a sheet that has no basic filter sends nothing and
reports `Unchanged: sheet 'Q2' has no basic filter to clear` (JSON
`"status":"unchanged"`). Filter views are **many, named and id-addressed** —
`list-filter-views` is how you discover a view's numeric id, the same way
`list-protections` is for protected ranges. Its table lists each sheet's
basic filter (as a `basic filter: …` row) ahead of that sheet's views, as
the JSON output does. Every `list-*` verb prints a `No <things>.` line
rather than nothing when there is nothing to list.
```bash
# The basic filter — one per sheet.
omni-dev drive sheets set-basic-filter <ID> --sheet Q2 --range A1:D100 \
--sort-by 0:asc --hide-values 1:Discontinued,Returned
omni-dev drive sheets clear-basic-filter <ID> --sheet Q2
# Filter views — many per sheet, addressed by id.
omni-dev drive sheets add-filter-view <ID> --sheet Q2 --range A1:D100 \
--title 'Open only' --hide-values 2:Closed
omni-dev drive sheets list-filter-views <ID>
omni-dev drive sheets update-filter-view <ID> --filter-view-id 3 \
--hide-values 2:Closed,Cancelled
omni-dev drive sheets delete-filter-view <ID> --filter-view-id 3
```
**`set-basic-filter --sort-by` also needs `sheets-write`** (issue #1940).
Unlike a filter view's sort, a basic filter's sort is not view state: Sheets
applies it by physically reordering the filtered range's rows, and the new
order stays after `clear-basic-filter`. That is the same permutation
`randomize-range` performs — each row carries its formatting, notes and
data-validation rules with it — so it resolves the same union of
`sheets-write` and `sheets-structure` ([ADR-0083](adrs/adr-0083.md) §5).
`--dry-run` and the real run carry `sort-range`'s caveats: a warning when
the range is narrower than its sheet (data outside the selected columns
does not move, so records can be separated), and a note that references
outside the range may observe a different row's values. `set-basic-filter`
without `--sort-by`, and `add-filter-view`/`update-filter-view --sort-by`,
stay on `sheets-structure` alone.
`--sort-by`/`--hide-values` take `COLUMN:...` pairs, where `COLUMN` is a
0-based column index (not an A1 letter) — the same indexing the underlying
API uses. `update-filter-view`'s `--sort-by`/`--hide-values` **merge** onto
the view's existing sort order and criteria: a given column's entry is
replaced (or appended, for a new sort column), but every other column's
entry survives untouched. So `--sort-by 1:asc` on a view sorted `[2 desc]`
leaves it sorted `[2 desc, 1 asc]`. A new `--range` with no `--sheet` (and
no `Sheet!` prefix of its own) stays on the view's current sheet.
`--clear-sort` and `--clear-criteria` reset to empty first, so they **clear**
on their own and **replace** when combined with `--sort-by`/`--hide-values`:
`--clear-sort --sort-by 0:asc` on a view sorted `[2 desc, 3 desc]` leaves it
sorted `[0 asc]` alone (issue #1931). How they get there follows from how
Sheets' `updateFilterView` actually behaves, which live testing showed is a
merge, never a replacement, whatever the `fields` mask says:
- **Sort order:** the sort columns sent go first, followed by every
existing sort column not sent. An empty `sortSpecs` changes nothing, and
no request body removes a sort column, even with `fields: "*"`. So
`update-filter-view` always sends the full resulting order — which lands
exactly when it still names every column the view sorts by — and when
the change *drops* a sort column (only `--clear-sort` can), it instead
deletes the view and re-adds it **under the same id** with the full
resulting state, in one atomic `batchUpdate`. The view's
`--filter-view-id` does not change. `--dry-run` and the report say when
this re-creation happens. The re-creation rebuilds the view from the
snapshot read just before it, so a concurrent edit to the same view made
in between is lost. A view bound to a table is re-bound to that table
rather than pinned to its current range (Sheets rejects a view with both),
and passing `--sheet`/`--range` in that case is refused, since it would
unbind the view. A view that reads back with no grid range and no binding
cannot be re-created, so the command refuses unless you pass
`--sheet`/`--range`.
- **Criteria:** each column sent replaces that column's criteria, and a
column not sent is left alone; an empty `criteria` changes nothing. So
`--hide-values` sends only the columns it names, and `--clear-criteria`
sends `{}` for each column the view filters on, which stops it filtering
that column. Sheets keeps a reset column as an empty entry (`"1": {}`)
rather than removing it; `list-filter-views` leaves such columns out of
its text output.
A re-created view keeps its title, range, sort order and criteria, including
criteria this crate does not model (a `condition` set in the Sheets UI, for
example), which are carried over verbatim. A sort by cell colour is not
modelled: any update that sends the sort order (`--sort-by`, `--clear-sort`)
turns it back into a plain sort by value.
**Two things this issue does not cover.** `duplicateFilterView` has no CLI
verb — the issue's own proposed scope omits it, though the API supports it.
And `FilterCriteria` support is `hiddenValues` only: filtering by a boolean
condition (the same vocabulary `set-data-validation` curates) isn't
exposed. Both are documented cuts, not silent gaps.
#### drive sheets add-named-range / update-named-range / delete-named-range / list-named-ranges
Named ranges, gated by `sheets-structure` — including `delete-named-range`.
See [ADR-0081](adrs/adr-0081.md) §2 for why: a named range is a label over a
region, not grid data, so removing one leaves every cell's stored value and
formula text untouched, even though every cell formula referencing the
removed name starts evaluating to `#REF!` (conditional formatting, data
validation and chart references are not scanned by the preview below).
```bash
# Add a named range, or one covering an entire sheet with --whole-sheet.
omni-dev drive sheets add-named-range <ID> --name Prices --sheet Q2 --range B2:B50
omni-dev drive sheets add-named-range <ID> --name AllOfQ2 --sheet Q2 --whole-sheet
# See what's defined — a plain, ungated read.
omni-dev drive sheets list-named-ranges <ID>
# Rename and/or re-point an existing named range, resolved by exact name.
omni-dev drive sheets update-named-range <ID> --name Prices --new-name UnitPrices
omni-dev drive sheets update-named-range <ID> --name Prices --sheet Q3 --range B2:B50
# Or resolve it by id instead — see `--id` below.
omni-dev drive sheets update-named-range <ID> --id id-1 --new-name UnitPrices
# Remove a named range — read --dry-run first.
omni-dev drive sheets delete-named-range <ID> --name Prices --dry-run
omni-dev drive sheets delete-named-range <ID> --name Prices
```
`update-named-range`/`delete-named-range` resolve their target by `--name`
(a case-insensitive *exact* match) or `--id` (an exact match) — exactly one
is required — `list-named-ranges` is how you find either. Sheets enforces
unique names on `add-named-range` but not on `update-named-range`'s
`--new-name` (issue #1932): renaming a range to another's name is not
rejected server-side, so a name can in principle match more than one named
range. `update-named-range` refuses a `--new-name` that collides
(case-insensitively) with a different existing named range before that can
happen; if a workbook already has two ranges sharing a name — from before
this check existed, or from another client — `--name` matching more than
one is refused as ambiguous rather than acting on whichever came first,
naming every matching id. **`--id` is the escape hatch for that ambiguity**
(issue #1975): pass one of the ids the refusal listed to act on that exact
range without needing the Sheets UI. `update-named-range` may rename only,
re-point only, or both — passing neither `--new-name` nor a new range is
refused as nothing to change.
`delete-named-range --dry-run` (and the real run, before mutating) scans
every sheet's *cell* formulas for the name being removed and reports the
count and A1 locations of every reference — never the formula text or a
cell's value — so read it before running for real. It does not scan
conditional-formatting rules, data-validation custom formulas, or chart
source references, which can also name a named range. The break is also
recoverable: re-adding a named range with the same name over the same range
restores every dependent formula to working order, since the name is what
changed,
not the formula text.
Past 50 addresses the rendered line elides the remainder (`… and N more`);
the `-o json` outcome keeps the full list, and the count in the sentence
stays exact either way (issue #1880).
#### drive sheets add-chart / update-chart / delete-chart / list-charts / add-slicer / update-slicer / delete-slicer / list-slicers
Charts and slicers (issue #1797), sharing one module and one delete request
(`deleteEmbeddedObject` removes either, addressed by `objectId` alone).
Every mutating verb — including both deletes — is gated by
`sheets-structure`: see [ADR-0081](adrs/adr-0081.md) §3 for why an
unrecoverable embedded-object removal still sits there rather than under
`sheets-delete` (a chart/slicer is a property of the *sheet*, not the
sheet's grid *data*, the same argument that already covers
`unmerge-cells`/`clear-data-validation`). Both deletes read back and report
the object's spec — type, title, anchor position — before removing it, in
both `--dry-run` and the real run's log record.
**Chart type subset (v1):** `column`, `bar`, `line`, `area`, `scatter`, and
`pie` — the issue's own chosen first cut, the same "curated surface, not
full API coverage" stance `set-data-validation`/`add-conditional-format`
take. `COMBO` and `STEPPED_AREA` basic charts, and every one of the other
~13 chart types (bubble, candlestick, org, histogram, waterfall, treemap,
scorecard, data-source), are documented cuts.
```bash
# A column chart, anchored on the same sheet its data comes from.
omni-dev drive sheets add-chart <ID> --type column --sheet Q1 \
--domain A2:A10 --series B2:B10 --series C2:C10 \
--title 'Revenue by region' --legend bottom --anchor F2
# A pie chart on a brand-new sheet of its own.
omni-dev drive sheets add-chart <ID> --type pie --sheet Q1 \
--domain A2:A10 --series B2:B10 --pie-hole 0.4 --new-sheet
# See what exists, and its numeric id — a plain, ungated read.
omni-dev drive sheets list-charts <ID>
omni-dev drive sheets update-chart <ID> --chart-id 3 --title 'Revenue (final)'
omni-dev drive sheets delete-chart <ID> --chart-id 3
# A slicer over B1:E100, filtering on column D — absolute index 3
# (0 = A), not an offset within the range.
omni-dev drive sheets add-slicer <ID> --sheet Q1 --range B1:E100 \
--column 3 --hide-values Closed,Cancelled --title Status --anchor G2
omni-dev drive sheets list-slicers <ID>
omni-dev drive sheets update-slicer <ID> --slicer-id 4 --hide-values Closed
omni-dev drive sheets delete-slicer <ID> --slicer-id 4
```
**`update-chart`'s crux: no field mask.** Unlike every other `update-*`
verb in this tool, Sheets' `updateChartSpec` replaces a chart's *entire*
spec — there is no way to name "just the title". `update-chart` fetches
the existing spec, refuses it outright if it isn't one of the two
supported kinds (rather than silently discarding, say, an existing
histogram's configuration), refuses a basic↔pie switch (the two shapes
carry domain/series too differently to convert — delete and re-add
instead), and otherwise applies only the flags actually set, preserving
every field this crate doesn't model (styling, `hiddenDimensionStrategy`,
…) exactly as read. `update-slicer` is the opposite case — `updateSlicerSpec`
*does* take a field mask, so only the flags actually set are ever sent.
**`--column` is an absolute 0-based sheet column index, not an A1 letter
and not an offset within `--range`** — `0` is column A wherever the range
starts, which is what Sheets' `SlicerSpec.columnIndex` actually means. For
`--range B2:F8` the valid values are `1`-`5` (B-F): `--column 5` filters
column F, and `--column 0` names column A, outside the range. The column
must fall inside the range's columns — on `update-slicer`, inside the new
`--range` when one is given, else the slicer's existing range, and an
existing `--column` is re-checked against a new `--range` — and anything
else is refused locally, under `--dry-run` too, rather than surfacing as a
live `400`. `FilterCriteria` support is `hiddenValues` only, the same cut
`set-basic-filter`/`add-filter-view` make.
**`--pie-hole` is validated two ways**, on both `add-chart` and
`update-chart`: the value must be `0.0`-`1.0` inclusive, and it is refused
outright on anything but a pie chart (a basic chart's spec has no field for
it, so silently accepting it would be a no-op write). `update-slicer`'s
`--clear-criteria` and `--hide-values` are mutually exclusive — unlike
`update-filter-view`'s `--clear-criteria`/`--hide-values`, which compose
(clear resets, then the new entries layer on top), a slicer's filter
criteria is a single value rather than a per-column map, so there is
nothing for the two to compose *onto*.
**Placement on `add-chart`/`add-slicer`.** `--anchor` (an A1 cell, using
`--sheet` for its prefix when bare) plus optional `--offset-x`/`--offset-y`/
`--width`/`--height` in pixels; a chart may instead take `--new-sheet` to get
a sheet of its own, which conflicts with every position flag. Moving or
resizing an *existing* chart/slicer, and setting a chart's border colour,
are the `move-chart`/`move-slicer`/`update-chart-border` verbs below (issue
#1837) — see that subsection for how they differ from `add-chart`/
`add-slicer`'s own placement flags.
#### drive sheets move-chart / move-slicer / update-chart-border
Three more verbs on the same charts/slicers embedded objects (issue #1837),
gated `sheets-structure` like every other verb in this section.
`move-chart`/`move-slicer` wrap `updateEmbeddedObjectPosition`;
`update-chart-border` wraps `updateEmbeddedObjectBorder`, chart-only — a
slicer carries no `border` field at all.
```bash
# Move a chart to a new anchor cell on the same sheet, resizing it too.
omni-dev drive sheets move-chart <ID> --chart-id 3 --sheet Q1 --anchor F2 --width 480
# Resize without moving: every placement flag is optional, unlike add-chart.
omni-dev drive sheets move-chart <ID> --chart-id 3 --height 300
# Move a chart onto a brand-new sheet of its own.
omni-dev drive sheets move-chart <ID> --chart-id 3 --new-sheet
# A slicer moves the same way, minus --new-sheet — it has no own-sheet
# placement.
omni-dev drive sheets move-slicer <ID> --slicer-id 4 --sheet Q2 --anchor B2
omni-dev drive sheets update-chart-border <ID> --chart-id 3 --color '#4A86E8'
omni-dev drive sheets update-chart-border <ID> --chart-id 3 --clear
```
**Every flag on `move-chart`/`move-slicer` is optional** — unlike `add-chart`,
which requires one of `--anchor`/`--new-sheet` — since "leave it where it is
and only resize" is a valid call; passing none of `--anchor`/`--offset-x`/
`--offset-y`/`--width`/`--height`[/`--new-sheet`] is refused as nothing to
change. `--new-sheet` (`move-chart` only) conflicts with every other
placement flag, the same rule `add-chart` enforces.
**The field mask is rooted at `overlayPosition`, not `newPosition`** — the
API's own rule ("the root `newPosition.overlayPosition` is implied and
should not be specified"), the one request in this tool whose mask isn't
rooted at the request's own payload field (`update-slicer`'s mask, by
contrast, is rooted at `spec`). A move naming only `--width` sends
`"fields": "widthPixels"`, never `"overlayPosition.widthPixels"`. A
`--new-sheet` move sends no `fields` key at all.
**A resize-only move (no `--anchor`) carries the object's *current* anchor
cell forward on the wire**, even though the mask never names it —
`anchorCell` is a required field of `OverlayPosition` on the wire, unlike
the optional offset/width/height fields, so there is no "leave it unset"
value to send instead. A chart currently on its own sheet has no overlay
position to carry forward, so moving it onto a grid requires `--anchor`.
**A cross-sheet move** — `--sheet`/`--anchor` naming a different sheet than
the object is currently on — is exactly like any other move: the
destination sheet's id becomes the object's new `sheet_id` in the outcome
and log record.
**`update-chart-border` is colour-only**: `--color '#RRGGBB'` or `--clear`,
mutually exclusive, one of them required. There is no `--style`/`--width`
flag — the API's `EmbeddedObjectBorder` models neither, unlike a cell's
`Border`. Clearing sends an empty border (`{}`) with the same
`"colorStyle"` mask a set does.
**Confirmed live (issue #1929)**: a chart that never had its border touched
reads back with no `border` field at all, but `--clear` does *not* return a
chart to that state — it reads back with `border` present and an
all-omitted `rgbColor` (proto3 dropping every zero channel). Sending an
explicit `--color 000000` (pure black) reads back **byte-for-byte
identical**: `{"colorStyle": {"rgbColor": {}}}` either way, with no second
field to tell them apart. `list-charts`/`update-chart-border`'s preview
report that shape as `#000000 (or cleared — indistinguishable on read)`
rather than asserting either as fact. Whether the Sheets UI actually paints
a visible black border after `--clear` was not checked — this crate has no
way to inspect the rendered UI, only the wire shape. A border colour read
back from a Sheets UI theme-palette pick, rather than one this crate set,
renders as `theme:<NAME>` instead (issue #2020).
#### drive sheets add-banding / update-banding / delete-banding / list-bandings
Banded ranges — alternating row or column colors — gated by
`sheets-structure`, including `delete-banding`. See
[ADR-0082](adrs/adr-0082-banded-ranges.md): a banding is presentation
applied to a range, so removing one destroys no data, the same reasoning as
`unmerge-cells`/`clear-data-validation`.
```bash
# Add row banding (the default axis) to a range.
omni-dev drive sheets add-banding <ID> --sheet Q1 --range A1:D50 \
--first-band-color '#FFFFFF' --second-band-color '#F3F3F3'
# Add column banding, with a distinct header color.
omni-dev drive sheets add-banding <ID> --sheet Q1 --range A1:D50 \
--axis columns --header-color '#4A86E8' \
--first-band-color '#FFFFFF' --second-band-color '#F3F3F3'
# See what's defined, and its id — a plain, ungated read.
omni-dev drive sheets list-bandings <ID>
# Change a color, or reposition the range, by id.
omni-dev drive sheets update-banding <ID> --banded-range-id 0 --footer-color '#000000'
omni-dev drive sheets update-banding <ID> --banded-range-id 0 --sheet Q1 --range A1:D100
# Remove a banded range — read --dry-run first.
omni-dev drive sheets delete-banding <ID> --banded-range-id 0 --dry-run
omni-dev drive sheets delete-banding <ID> --banded-range-id 0
```
**Colors are `#RRGGBB` only, written via the modern `*ColorStyle` fields.**
The Sheets API's plain `Color` fields (`headerColor`/`firstBandColor`/
`secondBandColor`/`footerColor`) are deprecated in favor of their
`*ColorStyle` counterparts, so this crate never sends them; a theme color
(the `ColorStyle` union's other arm) has no flag surface here either — the
same cut `format-cells` makes. `--first-band-color`/`--second-band-color`
are required on `add-banding`; `--header-color`/`--footer-color` are
optional. A theme color set through the Sheets UI (a built-in
alternating-colors preset, a theme-palette format) is read back and
rendered as `theme:<NAME>`, and `update-banding` preserves it unchanged on
any band a color flag doesn't name (issue #2020).
**One axis per call.** The Sheets API allows a single `BandedRange` to carry
both row and column banding at once; this crate exposes only one, selected
by `--axis` (default `rows`). Wanting both on the same range needs two
separate `add-banding` calls with the same `--range` — the API accepts
overlapping bandings on different axes — or the Sheets UI.
`list-bandings` shows each banded axis's colors next to its id and range,
e.g. `rows=[header=#000000 first=#FFFFFF second=#EEEEEE]`.
**`update-banding`/`delete-banding` are addressed directly by
`--banded-range-id`** — the server-assigned id `list-bandings` discovers,
not resolved by range match. `update-banding` may change the range, the
colors, or both — a new `--range` with no `--sheet` stays on the banded
range's current sheet; passing none of `--sheet`/`--range`/`--header-color`/
`--first-band-color`/`--second-band-color`/`--footer-color` is refused as
nothing to change. A changed color merges onto the selected axis's
*existing* colors — an unset color flag leaves that color untouched — so
`update-banding --header-color '#000000'` alone does not clear the
existing first/second band colors. The exception is an `--axis` the
banded range does not band yet (e.g. `--axis columns` on a range that
bands only rows): there are no existing colors to merge onto, so the
change adds that axis from scratch, and Sheets requires both band colors
for it. Any color flag on such an axis therefore needs both
`--first-band-color` and `--second-band-color`, and is refused locally —
under `--dry-run` too — naming whichever is missing (issue #1935).
#### drive sheets add-dimension-group / update-dimension-group / delete-dimension-group / list-dimension-groups
Dimension groups — the collapsible +/- outline over a row or column span —
gated by `sheets-structure`, including `delete-dimension-group`. See
[ADR-0084](adrs/adr-0084-dimension-groups.md): a group is presentation
applied to a span, so removing one destroys no data, the same reasoning as
`add-banding`/`update-banding`/`delete-banding`.
```bash
# Group rows 5-9 into a collapsible outline.
omni-dev drive sheets add-dimension-group <ID> --sheet Q1 \
--dimension rows --start 5 --end 9
# See what's defined, and each group's depth — a plain, ungated read.
omni-dev drive sheets list-dimension-groups <ID>
# Collapse or expand a group, addressed by its span.
omni-dev drive sheets update-dimension-group <ID> --sheet Q1 \
--dimension rows --start 5 --end 9 --collapsed true
# Remove a group — read --dry-run first.
omni-dev drive sheets delete-dimension-group <ID> --sheet Q1 \
--dimension rows --start 5 --end 9 --dry-run
omni-dev drive sheets delete-dimension-group <ID> --sheet Q1 \
--dimension rows --start 5 --end 9
```
**No client-side depth cap.** `addDimensionGroup`'s request carries only a
span — the server derives the new group's depth from how it overlaps
existing groups on the same axis (depths are 1-based; a lone group is depth
1, a group wholly inside it depth 2), and no maximum nesting depth is
documented anywhere in the Sheets API reference to validate against. Note
that a span which *partially* overlaps an existing group widens that group
to the union of the two spans as well as creating the new, deeper one —
`--dry-run` reports only the span you asked for, so check
`list-dimension-groups` first if an existing group must stay put. This
crate validates only the span itself, against the sheet's current extent
(the same check `auto-resize-dimension` makes) — Sheets stays the
authority on how deeply nested a structure may get, the same stance
ADR-0073 §7 takes for `--count` on `insert-rows`/`insert-columns`. See
[ADR-0084](adrs/adr-0084-dimension-groups.md) §1.
**`update-dimension-group` changes only `--collapsed`**, the only field a
dimension group has beyond its identity — `--collapsed` is required, not
optional. A dimension group carries no id; it is addressed by
`--sheet`/`--dimension`/`--start`/`--end`, disambiguated by an optional
`--depth` when more than one group shares that exact span at different
depths (the API creates this whenever a group is added over a span equal
to an existing one). Omitting `--depth` when the span is genuinely
ambiguous is refused, naming the depths found.
**`delete-dimension-group` requires an exact span match** among the
groups `list-dimension-groups` would show. The API's own partial-span
delete — a span that only partially overlaps an existing group decrements
that group's depth rather than removing anything — is not exposed; use
the Sheets UI for that, or delete the group's exact span and re-add a
narrower one.
#### drive sheets cut-paste / copy-paste / paste-data
Clipboard-style range operations (issue #1839,
[ADR-0083](adrs/adr-0083.md) §4): `cut-paste` moves a range to a
destination cell, clearing the source; `copy-paste` copies a range to a
destination; `paste-data` pastes delimited text into a range as if pasted
from the clipboard.
```bash
# Move A1:B10 on Q1 to D1, clearing the source. --paste-type defaults to
# normal (values, formulas, formats and merges).
omni-dev drive sheets cut-paste <ID> --sheet Q1 \
--source A1:B10 --destination D1 --dry-run
omni-dev drive sheets cut-paste <ID> --sheet Q1 --source A1:B10 --destination D1
# Copy a single cell across a 3x3 block — repeats to fill it, since 3 is a
# multiple of the source's 1x1 size.
omni-dev drive sheets copy-paste <ID> --sheet Q1 \
--source A1 --destination B1:D3 --paste-type values
# Paste a tab-separated block at A1. --paste-type defaults to values, not
# normal, since delimited text carries no formats to add.
omni-dev drive sheets paste-data <ID> --sheet Q1 \
--destination A1 --data-file clip.tsv
omni-dev drive sheets paste-data <ID> --sheet Q1 \
--destination A1 --data "$(printf '1\t2\n3\t4')"
printf '1\t2\n3\t4\n' | omni-dev drive sheets paste-data <ID> --sheet Q1 \
--destination A1 --data-file -
```
**Gate, per `--paste-type` (ADR-0083 §4).** `--paste-type` curates four of
the Sheets API's seven `PasteType` values — `normal`, `values`, `formula`,
`format`; `no-borders`, `data-validation` and `conditional-formatting` are
a deliberate cut, tracked as a follow-up. `values`/`formula` write cell
content alone and need `sheets-write`; `format` writes only presentation
and needs `sheets-structure` alone; `normal` (the default on `cut-paste`/
`copy-paste`) writes both and needs both grants together. **`cut-paste`
always needs both operations, whatever `--paste-type` names**, because its
source is cleared in full — values, formats and merges — regardless of
what gets pasted. `paste-data` defaults to `values`, not `normal`: its
input is delimited text with no formats or merges for `normal` to
add, though `normal` stays selectable and resolves both operations (the
API does not document it as doing anything beyond values on delimited
text). `paste-data` takes its block either literally (`--data <TEXT>`) or
from a file or stdin (`--data-file <PATH|->`); the two are mutually
exclusive and exactly one is required, and an empty block is refused
rather than pasted, as is an empty `--delimiter`. It is `delimiter`-form
only — the Sheets API's `html` paste alternative is not exposed.
`--paste-type values` does not keep pasted text literal: the API still
parses text such as `=1+1` into a formula, as if typed into the UI.
**`--source`/`--destination`, and `--sheet` as their shared default.** A
reference already carrying its own `'Sheet'!` prefix is used as-is — a
source and destination may sit on different sheets, which the API allows;
otherwise `--sheet` supplies the prefix. A `--sheet` that *every* range
self-prefixes past, and which therefore cannot apply to anything, is
refused rather than ignored: silently dropping it would hide a typo in
whichever prefix it was meant to correct. (This is narrower than the
blanket "`--sheet` alongside an already-prefixed range is an error" rule
`sheets read`/`write` follow, because here `--sheet` is a shared default
for two ranges rather than the one range's own sheet — prefixing one end
and leaving the other to `--sheet` is exactly how a cross-sheet paste is
written.) `cut-paste`'s and `paste-data`'s `--destination` must be a
single cell (the pasted block extends from
there); `copy-paste`'s may be a single-cell anchor or a range. Every
source must be a bounded rectangle — an open-ended column or row span
(`A:A`) is refused, the same restriction `merge-cells`/`insert-range`
place on their own ranges.
**`copy-paste --orientation transpose`** swaps the source's rows and
columns before pasting. Changes no gate.
**The written extent, for `copy-paste`.** When the destination is an
exact multiple of the (possibly transposed) source's size on an axis, the
source repeats along that axis to fill it exactly; otherwise the source is
copied once at its own size, spilling past a smaller destination or only
partly filling a larger, non-multiple one — the Sheets API's own
spill/repeat rule. `cut-paste` never spills or repeats: the written region
is always the source's own dimensions, anchored at the destination.
`paste-data`'s extent is an upper bound computed by locally splitting the
block on newlines and `--delimiter` — the API's own row-separator
convention for `pasteData` is undocumented, so this is a preview input,
never sent on the wire. A single *terminating* newline is not counted as a
row (every text file and every `printf` ends with one); a blank line in
the middle, or a second trailing one, is.
**`--dry-run` reports counts and A1 locations, never cell values**
(ADR-0083 §6, ADR-0081 §2's posture — `merge-cells` remains the one
preview in this crate that prints contents). It reports the non-blank
cells within the written extent that would be overwritten; a
presentation-only `--paste-type format` reads no values at all, since it
overwrites none. `cut-paste`'s preview additionally reports the non-blank
cells in the source that will be cleared, as a separate count from the
destination overwrite. Both also name the `--paste-type` (and, for
`copy-paste`, the orientation) that was used, since that decides both what
lands in the destination and which grants were consumed. When the written
extent runs past the sheet's currently allocated rows or columns, the
preview and the real run both carry a caveat — **verified live (#1937): the
Sheets API grows the grid to fit** rather than erroring, for all three
verbs (`copy-paste`/`paste-data` were measured first, 1000x26 → 1001x27;
`cut-paste` confirmed identically, a 10-row sheet growing to 11 rows to fit
a 3-row-tall block landing on rows 8-10) — this tool still never prepends a
structural request to grow the grid first, since that would smuggle
`sheets-structure` into a `sheets-write`-gated batch; there is simply
nothing left to smuggle once growth is the verified outcome. The extent is
reported in full in that case, but the *read* backing the preview is
clipped to the rows and columns the sheet actually has, since `values.get`
refuses a range past the edge ("exceeds grid limits"); cells that don't
exist yet hold nothing to overwrite.
`cut-paste`'s destination is a single *coordinate*, not a range, and that
coordinate is a separate question from the block it anchors: measured
live, the API refuses outright (a plain 400, naming the offending
`GridCoordinate`) when the coordinate itself lies past the sheet's last
existing row or column — only a coordinate that is itself inside the grid,
whose pasted block then spills past the edge, triggers growth. This crate
performs no extent check of its own on the destination coordinate, so that
refusal surfaces as an ordinary `Failed` result, unlike `auto-fill`'s own
client-side refusal for a destination range that lies wholly past the
grid.
Both the destination-overwrite and the cut-paste cleared-source lists
render as one line of comma-separated A1 addresses (issue #1880 — before
it, each address printed on its own line). Past 50 addresses the rendered
line elides the remainder (`… and N more`); the `-o json` outcome keeps
the full list, and the count in the sentence stays exact either way.
#### drive sheets read-cell-format
Reads a range's cell-level formatting back — background color, text format
(bold/italic/strikethrough/underline/color), number format, horizontal
alignment, notes and data validation rules (issue #1878). Read-only and
ungated, like `sheets read` and every `list-*` verb — it discloses no more
than opening the file in the UI does.
It exists to close [ADR-0083](adrs/adr-0083.md) §5's gate-moving question in
tooling rather than by eye: "a verb live-verified to move or write
formatting resolves both `sheets-write` and `sheets-structure`" is an
obligation every grid-mutation verb has, and until this verb existed
nothing under `omni-dev drive sheets` could read a cell's format back to
check it.
```bash
# Snapshot a range's formatting before running a verb under test, then
# again after, and diff the two — the ADR-0083 §5 verification recipe.
omni-dev drive sheets read-cell-format <ID> --sheet Q1 --range A1:D10 -o yaml > before.yaml
omni-dev drive sheets trim-whitespace <ID> --sheet Q1 --range A1:D10
omni-dev drive sheets read-cell-format <ID> --sheet Q1 --range A1:D10 -o yaml > after.yaml
diff before.yaml after.yaml
```
**Reports `userEnteredFormat`, never `effectiveFormat`.** What a sort, fill
or paste physically moves is the user-entered format; `effectiveFormat`
folds in conditional formatting, which follows the *range* rather than the
cell and would give false positives when checking whether a verb moved
formatting.
**Only non-default cells are reported.** A cell present in the requested
range but carrying none of the reported properties is omitted entirely, so
the table output is a compact, diff-friendly list — one line per cell that
actually has something to report (e.g. `B3 bg=#FF0000 bold
number=CURRENCY:"$#,##0" note validation=ONE_OF_LIST`). `-o json`/`-o yaml`
carry the full structured format, including note text (the table output
shows only a note's *presence*, never its content — the same "counts and
locations, never contents" stance ADR-0083 §6 takes for a paste/auto-fill
preview).
**`--range` is required; a whole-workbook or whole-sheet read is out of
scope for v1**, since the underlying `spreadsheets.get` response grows with
the range requested rather than with what's actually populated. A bounded
range (`A1:D10`) over roughly 50,000 cells is refused locally before any
HTTP call. An open-ended range (`A:A`, `5:20`) is let through unchecked at
that point — computing its true extent up front would need its own
metadata fetch — but the same cap is checked again against the response
actually returned, so an oversized result is still refused rather than
processed and printed; it just can't avoid that one request's own cost.
**A theme color is reported by name (`theme:ACCENT1`), never guessed as
black.** The Sheets API's `ColorStyle` union has two arms — an explicit RGB
color, or a theme color set via the UI's "Theme colors" picker — and only
the former carries `rgbColor`; a cell carrying the latter has no `rgbColor`
key to default.
**The four outer edge borders are reported too** (`border=top,left`, the
edge names only — a border's style and color are in `-o json`/`-o yaml`).
A border is still part of `userEnteredFormat`, so leaving it out would let
a verb that moves only borders show as "no formatting changed" and be
wrongly concluded formatting-safe.
**Merges are a documented cut for v1** — `sheets.merges` is a different
mask path entirely (a per-sheet list, not a per-cell property), so
reporting them needs their own field and outcome shape; a non-breaking
follow-up.
## Docs
`drive docs` reads the *structural model* of a Google Doc through the Docs v1
API (issue #1615). Editing is a separate, later phase; today this tree is
read-only.
No new login flag is needed. Reading works with the `drive.readonly` scope
every account already has — the Docs API accepts the Drive scopes, exactly as
the Sheets API does.
### Why this exists alongside `drive read --content`
`drive read --content` already exports a Doc to markdown, and for reading the
*prose* it is the better command. What an export structurally cannot give you
is the **address space**. Every Docs edit is addressed by a numeric index into
the document, and a markdown rendering has no path back to one. So:
- **`drive read --content` is the prose channel.**
- **`drive docs read` is the model channel** — each element's `[start, end)`
index range, its kind, its style, and the document's `revisionId`.
That is also why indices are shown by default rather than behind a flag:
without them this command would just be a worse `drive read --content`.
### Indices are UTF-16 code units
This is the one thing worth internalising before using the output for
anything. Docs indices count **UTF-16 code units**, not characters and not
bytes, and `endIndex` is exclusive. The distinction is invisible in ASCII and
matters the moment a document contains an emoji or a CJK character: `😀` is one
character, two UTF-16 code units and four UTF-8 bytes.
`omni-dev` never computes an index itself — it only reports what the server
sent — so nothing here rounds the difference away silently.
### Tabs
Google Docs supports tabs, and `drive docs` always requests every tab's
content. That is deliberate: a request without it returns only the **first**
tab, in a response shaped identically to a single-tab document, so reading a
third of a document would be indistinguishable from reading all of a small
one. (That is precisely the trap `drive read --content` still has on a Sheet,
where it exports the first sheet only.)
Narrow with `--tab <TAB_ID>` after the fact. An unknown tab id is an error
listing the real ones, never an empty result.
#### `drive docs info`
Shows the document's identity, its revision, its per-tab counts and its
heading outline. Also available as the `drive_docs_info` MCP tool.
```bash
$ omni-dev drive docs info 1AbC_dEfGhIjKlMnOpQrStUvWxYz
Id: 1AbC_dEfGhIjKlMnOpQrStUvWxYz
Title: Design Doc
Revision: ALm37BXk3nQ
Tabs: 2
Named ranges: 1
intro (1 range(s))
Tab: t.0 "Overview" — 12045 chars, 143 paragraphs, 2 tables, 1 section break
HEADING_1 [1..18) Overview
HEADING_2 [220..241) Goals
Tab: t.1 "Appendix" — 890 chars, 12 paragraphs
HEADING_1 [1..12) Appendix
```
Two fields are worth more than they look:
- **`Revision`** is the token an edit has to present so a write against a
document that changed underneath it is refused rather than misapplied.
Nothing else in the CLI surfaces it. When you see
`Revision: (none — read-only access)`, Google withheld it because the account
has no edit access — and a later edit will refuse for that reason.
- **Named ranges** are the *stable* way to name a region. An index shifts on
every insertion; a named range's name does not.
**Body only.** The per-tab counts and heading outline above cover the tab's
**body** alone. Headers, footers and footnotes — which `drive docs read`
fetches and renders (see below) — do not contribute a paragraph, table, or
heading to this command's output. A heading that lives inside a header,
footer or footnote is invisible here even though `drive docs read` on the
same document now shows it. Folding segments into the outline is unstarted
follow-up work.
#### `drive docs read`
One line per structural element, indented by nesting depth. Also available
as the `drive_docs_read` MCP tool.
```bash
$ omni-dev drive docs read 1AbC_dEfGhIjKlMnOpQrStUvWxYz
START END KIND STYLE TEXT
0 1 section-break
1 18 paragraph HEADING_1 Overview
18 220 paragraph NORMAL_TEXT This document describes the approach…
220 241 paragraph HEADING_2 Goals
241 310 table 3x2
243 251 paragraph NORMAL_TEXT Name
252 266 paragraph NORMAL_TEXT Description
```
With more than one tab, each block is preceded by a `# <tabId> <title>` line.
`--suggestions-view default|inline|accepted|without` selects which view of
pending suggestions the text *and the indices* are reported against. It is a
correctness knob rather than a display preference: a document with pending
suggestions has a different index space per view.
**Output formats.** `-o table` (the default) **sanitises** element text,
stripping control characters. This differs from `drive sheets read`, whose CSV
emits cell values verbatim, and the difference is deliberate: CSV is an
interchange format that must round-trip, so stripping there would corrupt real
data, while this table is an orientation view whose entire value is column
alignment — a soft line break or an escape sequence in the text would destroy
it. Use **`-o json`** when you want content: it is the unsanitised channel and
carries every field. **`-o jsonl`** emits **one line per element**, with the
document id, revision and tab id repeated on each, so a single line is
self-describing to `jq`.
**Headers, footers and footnotes.** These live in their own segments,
addressed by `segmentId` rather than an index range, and are always included
whenever a tab has any — there is no flag to opt in or out, the same "always
fetch, never mask" precedent tabs use above. In `-o table` each one renders
as its own block, after the tab's body:
```
## header kix.abc123
0 12 paragraph NORMAL_TEXT Confidential draft
## footnote kix.def456
0 9 paragraph NORMAL_TEXT See intro.
```
In `-o json`/`-o yaml` they appear as `headers`/`footers`/`footnotes` arrays
on each tab, each entry carrying its `segment_id` and `elements`; a tab with
none of a given kind omits that array entirely rather than sending `[]`. In
`-o jsonl` each segment's elements ride the same flat record stream as the
body, with an added `segment: {kind, segment_id}` field (absent for a body
element).
### Editing a document
`drive docs replace` and `drive docs append` mutate text, gated by the
`docs-write` permission (see [Write permissions](#write-permissions)) and
requiring `--write-file` or `--write-full`.
```bash
# Preview first — reports the occurrence count without sending anything
$ omni-dev drive docs replace 1AbC… --search Q3 --replace Q4 --dry-run
Would replace: 7 occurrence(s) in 'Roadmap' (counted from the copy just read)
$ omni-dev drive docs replace 1AbC… --search Q3 --replace Q4
Replaced: 7 occurrence(s) in 'Roadmap'
$ omni-dev drive docs append 1AbC… --text $'\nAppended by omni-dev.'
Appended: 22 char(s) / 22 byte(s) to 'Roadmap'
```
`append` also takes `--text-file <PATH>`, or `--text-file -` for stdin.
#### Every edit is leased against a revision
This is the part worth understanding. `documents.batchUpdate` is addressed
by *index*, and the indices an edit is computed from come from a read that
has already returned. If someone edits the document in between, those
indices still resolve — just against different text. Nothing errors; the
edit simply lands in the wrong place.
So every edit presents the `revisionId` from the read that computed it, and
Google refuses the write if the document has moved:
```
Refused: 'Roadmap' changed since it was read (revision lease ALm37BXk3nQ no
longer current) — nothing was written. Re-run to apply against the current
version.
```
Nothing was written — the batch is atomic. **Re-running is the fix**, and it
is the only one: there is deliberately no flag to force the write through,
because the alternative the API offers rebases your edit over the other
person's changes and reports success on a document nobody has looked at.
See [ADR-0076](adrs/adr-0076.md) §3.
If the account has only read access Google withholds the revision id
entirely, and the edit is refused up front rather than attempted unleased.
#### Things to know
- **`--search` is a literal substring, never a regex**, and matching is
**case-sensitive by default** — which inverts the API's own default. Under
Google's default, `--search it` also rewrites `It` and `IT`, in a verb with
no undo. Use `--ignore-case` when you want that.
- **`--dry-run`'s occurrence count is an estimate.** It is counted over the
body text this command read, while the server matches over its own view —
a match can span a styling boundary, or sit in a header, footer or
footnote, which `drive docs read` now fetches (see above) but this count
does not yet include. The count never decides anything: a count of zero
still sends the request, because reporting "nothing to do" from an
estimate would be wrong exactly when the estimate is. The real run
reports the server's own number.
- **`replace` spans every tab; `append` lands in the first.** That asymmetry
is the Docs API's, confirmed against it directly, and it is why the preview
counts across all tabs.
- **`append` adds no separator.** Appending `hello` to a document ending
`world` gives `worldhello`. Include a leading newline if you want one.
- **Content deletion has separate consent.** `docs delete` requires
`docs-delete`; replacing text with nothing (`--replace ""`) retains its
existing `docs-write` semantics.
#### drive docs text-style / paragraph-style
Apply bounded formatting to a unique `--match TEXT`, or an inclusive
`--from TEXT --to TEXT` range. Both commands require the separate `docs-format`
permission; `docs-write`, `docs-delete`, and `edit` grants do not permit them.
The operation defaults to deny and requires a Drive lease unless the deciding
rule explicitly sets `require_lease: false`. Preview with `--dry-run` first.
```bash
omni-dev drive docs text-style <ID> --match 'Important' --bold true --dry-run
omni-dev drive docs text-style <ID> --from 'Start' --to 'End' --italic false --underline true --lease <TOKEN>
omni-dev drive docs paragraph-style <ID> --match 'Summary' --named-style heading1 --dry-run
omni-dev drive docs paragraph-style <ID> --match 'Summary' --alignment center --lease <TOKEN>
```
Text properties are `--bold`, `--italic`, `--underline`, and `--strikethrough`,
each taking an explicit `true` or `false`. Paragraph properties are
`--alignment start|center|end|justified` and `--named-style
normal-text|title|subtitle|heading1|heading2|heading3|heading4|heading5|heading6`.
At least one property is required. The field mask contains exactly the supplied
properties; omitted properties are not reset. There is no arbitrary JSON,
user-provided mask, numeric range, or wildcard reset surface.
Anchors use the same uniqueness, case sensitivity (`--ignore-case` opts into
Unicode simple folding), UTF-16 indices, tab identity, and conservative structural
boundaries as insertion/deletion. The permission gate runs before fetching the
Doc. Each call resolves against its own `SUGGESTIONS_INLINE` snapshot and sends
one typed request with that response's mandatory `requiredRevisionId`. The
existing Drive lease checks also apply. Missing revisions, ambiguity, unsafe
ranges, and stale revisions refuse the operation without retry.
Paragraph styling affects every whole paragraph overlapping the anchors.
Its preview expands to those full paragraph boundaries, including trailing
newlines, and validates content outside the anchor text too: pending content
suggestions, inline objects, and index gaps anywhere in an affected paragraph
are refused. Styling a body's final newline is safe; deleting it is prohibited.
Text styling addresses the anchored character range. Google may extend it to
adjacent newlines and apply matching text style to bullets for fully contained
list paragraphs; these are API effects, not separate bullet-edit requests.
Pending content suggestions on adjacent newlines are refused too. Dry runs and successful
results report the range, tab, affected paragraph/scalar/byte counts, explicit
style values and derived field mask, without document prose. These counts
represent affected content, not inserted or removed text. Google can apply
related/inherited formatting changes, especially when setting a named paragraph
style; previews describe the requested range and properties rather than a
rendered before/after document. As with other Docs writes, inspect structured
`status` (`would-format`, `formatted`, or a refusal) rather than exit code alone.
#### drive docs insert / delete
Insert text next to one unique anchor in tab bodies or an existing segment,
or delete a unique match or inclusive anchor range. Insertion uses `docs-write`; deletion requires a separate
`docs-delete` grant. Each defaults to requiring `--lease` unless the deciding
rule opts out; both support the same output formats and `--dry-run`.
```bash
omni-dev drive docs insert <ID> --after 'Summary' --text ' (updated)' --dry-run
omni-dev drive docs insert <ID> --before 'Conclusion' --text-file note.txt --lease <TOKEN>
omni-dev drive docs delete <ID> --match 'obsolete sentence' --dry-run
omni-dev drive docs delete <ID> --from 'Start marker' --to 'End marker' --lease <TOKEN>
omni-dev drive docs insert <ID> --segment-id <HEADER_ID> --after 'Title' --text ' (updated)' --dry-run
omni-dev drive docs delete <ID> --segment-id <FOOTNOTE_ID> --tab-id <TAB_ID> --match 'obsolete' --lease <TOKEN>
```
`--from`/`--to` removes from the start of the first anchor through the end of
the second, including both anchors. Each must be unique and ordered in the same
tab and segment or table cell. Anchors are literal, case-sensitive by default;
`--ignore-case` uses Unicode simple case folding. Overlapping matches count
separately, so `aa` in `aaa` is ambiguous and refused. `--text-file -` reads
insertion text from stdin.
Without selectors, the resolver searches all tab bodies and table cells, including
child tabs. `--segment-id` selects an existing header, footer or footnote by its
map key (shown by `docs read`); it never creates a segment. The ID must identify
exactly one segment across tabs and all three families. Use `--tab-id` to
narrow a repeated ID to one tab; this flag requires `--segment-id`. Empty IDs,
missing segments and ambiguous identities are refused before anchor matching.
Within the selected segment, each anchor must be unique, including matches in
its table cells. Unselected content does not participate in matching or index
validation. Legacy top-level segments are supported without `--tab-id`.
An anchor may span adjacent formatting runs within one paragraph, but cannot
span paragraph breaks, images or other inline objects. A deletion range may
span ordinary paragraphs, but cannot cross table/cell boundaries, structural
elements, non-text inline content or pending insertion/deletion suggestions.
The last newline of a body, header, footer, footnote or cell, and newlines
immediately before structural elements, are preserved. Tables of contents
remain outside this surface. As the Docs API merges paragraphs, a cross-paragraph deletion can also affect paragraph
styles, lists, positioned objects and bookmarks attached to those paragraphs.
Dry runs and successful outcomes report the same resolved UTF-16 range (empty
for insertion), tab, selected segment ID and kind (when present), paragraph
count, Unicode scalar count and UTF-8 byte count. Segment indices are relative
to that segment and may begin at zero.
Insertion counts exclude the control and BMP private-use characters that the
Docs API strips; input with nothing remaining is refused before any request.
Indices come from the invocation's own inline document snapshot, whose revision
is required by the write. There is no numeric `--index` flag or automatic retry
against a newer revision. Missing or inconsistent index metadata, no match,
ambiguity and unsafe ranges produce a `refused-anchor` result with a typed
reason. These refusals and stale revisions make no content change. The CLI's
existing structured-result convention retains exit code 0; inspect `status`.
For removing every occurrence, `docs replace --search TEXT --replace ''` retains
its existing `docs-write` semantics. Granting `docs-write` does not grant the new
`docs delete` verb. See [ADR-0094](adrs/adr-0094.md).
#### drive docs create-bullets / delete-bullets
Apply bullets or numbering, or remove list formatting, on complete paragraphs
selected by unique body anchors. Both verbs require `docs-write`; a `docs-delete`
grant alone cannot authorize them. Removing bullets preserves prose and retains
visual nesting as paragraph indentation. Creation converts leading tab characters
into nesting levels and removes those tabs as part of the formatting operation.
```bash
omni-dev drive docs create-bullets <ID> --match 'Action items' --preset bullet-disc-circle-square --dry-run
omni-dev drive docs create-bullets <ID> --from 'First item' --to 'Last item' --preset numbered-decimal-alpha-roman --lease <TOKEN>
omni-dev drive docs delete-bullets <ID> --match 'Action items' --dry-run
omni-dev drive docs delete-bullets <ID> --from 'First item' --to 'Last item' --lease <TOKEN>
```
`--match` selects its containing paragraph, including text outside the match.
`--from` / `--to` select every paragraph from the first anchor's paragraph through
the last anchor's paragraph, inclusively. Anchors must be unique across all tab
bodies, including nested tabs and table cells. Matching defaults to case-sensitive;
`--ignore-case` uses Unicode simple case folding. Ranges must stay in one body or
table cell and contain contiguous, fully indexed plain text. Objects, structural
gaps, reversed anchors and pending content suggestions anywhere in a selected
paragraph are refused. Formatting may include a body's or cell's final newline,
which remains intact. Headers, footers, footnotes and tables of contents are
outside this selection surface.
Creation requires `--preset`, one of the 15 concrete Google bullet and numbering
presets listed in `create-bullets --help`. There is no unspecified preset, custom
glyph, raw request or numeric index option. Google may join the selected paragraphs
to the immediately preceding list when its preset matches. Removing formatting
does not restore tabs previously removed by creation or reset indentation.
Dry runs return `would-format-list`; successful writes return `list-formatted`.
Both contain `edit` with the **pre-write** UTF-16 range, tab identity, paragraph
count and `leading_tabs_removed`; `preset` is the Google preset for creation and
null for removal. Tab counts are zero for removal. Creation shifts later indices
by the number of tabs removed; returned indices are not reusable write addresses.
The preview does not reconstruct list IDs, numbering or resulting indentation.
No anchors or paragraph prose appear in these metadata fields.
Each verb sends exactly one typed request under the same inline snapshot's
mandatory revision check and the existing optional Drive ledger lease. No secondary
request, rebasing or automatic retry is used. The permission gate runs before any
Docs read. Existing `--dry-run`, `--lease` and output formats apply; inspect the
structured `status` for refusals, as for other Docs writes. Mutation logs retain
only existing decision/revision/status metadata under `docs-create-bullets` or
`docs-delete-bullets`.
These effects follow the [Google Docs list request reference](https://developers.google.com/workspace/docs/api/reference/rest/v1/documents/request#CreateParagraphBulletsRequest).
#### drive docs table edits
Table additions require `docs-structure`; row/column deletion requires separate
`docs-table-delete` consent. Existing `docs-write`, `docs-delete`, `edit` and
Sheets grants authorize neither. Both require a backup `--lease` unless the
deciding operator rule explicitly sets `require_lease: false`. Preview first;
a preview does not authorize a subsequent write.
```bash
omni-dev drive docs insert-table <ID> --after 'Summary' --rows 2 --columns 3 --dry-run
omni-dev drive docs insert-table-row <ID> --cell 'Unique heading' --after --lease <TOKEN>
omni-dev drive docs insert-table-column <ID> --cell 'Unique heading' --before --dry-run
omni-dev drive docs delete-table-row <ID> --cell 'Obsolete entry' --dry-run
omni-dev drive docs delete-table-column <ID> --cell 'Obsolete heading' --lease <TOKEN>
```
`insert-table` creates an empty grid next to a unique literal anchor in an
ordinary top-level body paragraph. Dimensions must be positive, with at most
10,000 cells. Google inserts a newline before the table; the preview reports
this additional effect. The insertion point is a UTF-16 body index in the
resolved tab, and the table starts one unit after that point. The paragraph's
existing protected closing newline is retained.
Dimension verbs select the cell containing unique literal `--cell` text within
one paragraph. `--before` means above a row or left of a column; `--after` means
below or right. They add or remove exactly one dimension. Deletion removes all
content in the selected row/column, rather than just the anchor match. The
final row or column cannot be deleted, because the API would remove the entire
table. Grant these operations explicitly by file or folder:
```json
{"file_id": "<document id>", "allow": ["docs-structure", "docs-table-delete"]}
```
Only rectangular, unmerged top-level body tables are supported. Nested tables,
merged cells, generated tables of contents, header/footer/footnote targets,
malformed indices and pending suggestions affecting the table are refused.
Anchors are case-sensitive by default; `--ignore-case` uses Unicode simple case
folding. Empty, missing or ambiguous anchors fail closed across all body tabs.
There is no raw index, raw batch or seeded-table input.
The preview reports tab identity, insertion/table-start index, reference
row/column, before/after grid dimensions, insertion direction and the new-table
newline without returning document prose. `-o json|yaml|yamls|jsonl` retains the
tagged `would-edit-table`, `edited-table` or `refused-table` outcome. Permission
refusal precedes any Docs content fetch. Each invocation reads an inline
snapshot and sends one typed request with its `requiredRevisionId`; it never
rebases. Use another invocation to freshly resolve coordinates after an edit.
`stale-revision` requires rereading through the complete engine;
`refused-lease-stale` requires a fresh authorized backup lease. Audit logging
and lease refresh use the existing Docs write path. These verbs are CLI-only.
Wire shapes and newline/whole-table deletion rules follow the
[Google Docs request reference](https://developers.google.com/workspace/docs/api/reference/rest/v1/documents/request#InsertTableRequest).
#### drive docs create-named-range / delete-named-range / replace-named-range-content
Named-range metadata is governed by the default-deny **`docs-structure`**
permission, the same grant that authorizes empty table additions, so one
`docs-structure` rule covers both families. `create-named-range` adds a label
and `delete-named-range` removes that label; metadata deletion leaves every
character intact. Neither `docs-write`, `docs-delete`, `edit` nor Sheets
permissions grants `docs-structure`.
`replace-named-range-content` changes the text under **`docs-write`**, or
**`docs-delete`** when the supplied replacement is explicitly empty. A nonempty
replacement consisting entirely of characters Docs strips is refused, so it
cannot silently become deletion under a write grant.
All three verbs require explicit `--tab` and `--segment`. Use a server tab ID
(including a nested child tab), and `body` or an existing header/footer/footnote
segment ID. `--tab legacy` is accepted only when the response has no tabs and
uses the legacy top-level body/segments. Missing or duplicate tab/segment IDs
are refused; nothing silently defaults to the first tab. JSON/YAML from
`docs read` includes `tabs[].named_ranges` with the stable IDs and complete
spans. The table and JSONL read formats continue to show structural elements.
```bash
# Read current server indices, tab/segment identities and named-range IDs.
omni-dev drive docs read <ID> -o json
# Name a plain-text span using inclusive start/exclusive end UTF-16 indices.
omni-dev drive docs create-named-range <ID> --tab <TAB_ID> --segment body \
--name Summary --start-index 4 --end-index 11 --dry-run
omni-dev drive docs create-named-range <ID> --tab <TAB_ID> --segment body \
--name Summary --start-index 4 --end-index 11 --lease <TOKEN>
# Remove metadata only, by stable ID. The text remains intact.
omni-dev drive docs delete-named-range <ID> --tab <TAB_ID> --segment body \
--id <RANGE_ID> --dry-run
# Replace a header span, or remove the body span's content explicitly.
omni-dev drive docs replace-named-range-content <ID> --tab <TAB_ID> \
--segment <HEADER_ID> --id <RANGE_ID> --text-file replacement.txt --dry-run
omni-dev drive docs replace-named-range-content <ID> --tab <TAB_ID> \
--segment body --id <RANGE_ID> --text '' --lease <TOKEN>
```
Creation and replacement are deliberately bounded to one contiguous plain-text
span inside one paragraph, including a paragraph in a table cell. They reject
surrogate splits, paragraph newlines, structural gaps, inline objects,
equations, generated tables of contents and pending content suggestions.
Indices are server UTF-16 code units, not UTF-8 bytes or Unicode scalar counts.
Names must contain 1–256 UTF-16 units; duplicate names are allowed by Google.
Deletion and replacement accept only a stable ID, never a name that would fan
out over multiple labels. The ID must resolve uniquely in the selected tab,
and every span must belong to the selected segment. Metadata deletion can
remove a discontinuous label, but replacement refuses it: Google's
`replaceNamedRangeContent` replaces only its first span and deletes the rest.
See the [Google Docs request reference](https://developers.google.com/workspace/docs/api/reference/rest/v1/documents/request#ReplaceNamedRangeContentRequest).
Each real invocation resolves the target again from an inline-suggestions
snapshot, builds exactly one typed request and sends its `requiredRevisionId`.
The revision pins the segment validation even though delete/replace have no
wire segment selector; `tabsCriteria` restricts them to the selected tab.
A numeric creation span refers to the current invocation's snapshot: an earlier
read or dry run does not reserve those indices, so preview again after edits.
Dry runs require the permission but no backup lease and write nothing. Real
writes require the usual backup lease unless an operator rule explicitly
exempts it, retain write-ahead auditing and refresh the ledger after success.
`stale-revision` and `refused-lease-stale` remain separate refusal paths.
Structured previews distinguish `create-metadata`, `delete-metadata`,
`replace-content` and `delete-content`; table previews show the scoped UTF-16
spans (at most 50 rendered, with the complete list in structured output).
Previews and audit records contain metadata and counts rather than document
prose or the supplied name; creation returns the server-assigned ID when
present in its reply. These commands support table, JSON, YAML and JSONL
outcomes. This tranche is CLI-only; it adds no MCP mutation tool or raw batch
surface.
#### `drive docs create`
Creates a Google Doc, optionally seeded with text. Gated by the `create`
operation, not `docs-write`.
```bash
$ omni-dev drive docs create --name "Q4 Plan" --parent 1FoLdEr… --text "Draft."
Created: 'Q4 Plan' (1NeW…) in 1FoLdEr…, seeded with 6 char(s)
```
`--text-file <PATH>` (or `-` for stdin) is the alternative to `--text`.
The seed is **not** separately gated under `docs-write` in the normal case:
routing it through the write engine would re-check `docs-write` against the
new file's parents, which defaults to deny, so `--text` would create an empty
document and then report itself blocked on every folder that grants only
`create`. The `create` verdict authorises the pair — which is defensible only
because the id being written is one this same invocation just created inside
an already-cleared folder. An **explicit** `deny: ["docs-write"]` on that
folder is a deliberate signal and *does* block the seed, before anything is
created.
If creation succeeds but seeding fails, the result says so and names the new
document's id:
```
Partially failed: created 'Q4 Plan' (1NeW…) in 1FoLdEr…, but seeding its text
failed: … The document exists and is empty — it cannot be rolled back
automatically.
```
There is no `files.delete` anywhere in this integration, so an empty document
cannot be cleaned up automatically and must never be reported as a plain
failure that leaves something you can't find. Delete it yourself if you don't
want it.
## Slides
`drive slides` reads the object graph of a presentation through the Slides v1
API and replaces text on ordinary slides. It shares Drive accounts and OAuth
sessions. Enable the Google Slides API in the OAuth application's Cloud project.
Use `drive auth login --write-file` or `--write-full` for replacement, and grant
`slides-write` explicitly; `edit`, `docs-write` and `sheets-write` do not grant it.
```bash
omni-dev drive slides info PRESENTATION_ID
omni-dev drive slides read PRESENTATION_ID -o json
omni-dev drive slides read PRESENTATION_ID --slide SLIDE_OBJECT_ID -o jsonl
omni-dev drive slides replace PRESENTATION_ID --search 'Q3' --replace 'Q4' --dry-run
omni-dev drive lease acquire PRESENTATION_ID
omni-dev drive slides replace PRESENTATION_ID --search 'Q3' --replace 'Q4' --lease TOKEN
omni-dev drive slides replace PRESENTATION_ID --search 'Q3' --replace 'Q4' --slide SLIDE_OBJECT_ID --lease TOKEN
```
Presentation IDs are the `/d/<ID>/` segment of a Slides URL. `info` lists ordinary
slide IDs, page dimensions and the editor-only revision ID. `read` emits one row
per shape, image, group, unknown element or table cell, recursively walking groups.
Rows carry a one-based slide position plus slide/page/element IDs. Cells carry
zero-based grid coordinates. Speaker notes have a separate `notes` kind and notes
page ID. Repeat `--slide` to select several ordinary slides. JSONL emits one object
row per line; JSON/YAML include presentation identity/title/revision and rows.
Table output sanitizes terminal controls; structured output preserves text.
Unlike `drive read --content`'s text export, these commands expose object IDs.
No numeric text index is accepted or computed. Full unmasked presentations are
fetched before filtering, with a 64 MiB response cap; `--slide` does not reduce
fetch size. Layouts and masters are fetched but not included in element output.
Replacement is literal and case-sensitive by default. Use `--ignore-case` for
case-insensitive matching; an empty `--replace ''` removes matched text. Every
request explicitly names ordinary-slide page IDs, including when no filter is
supplied. Notes, layouts and masters are excluded. Unknown/non-slide IDs and empty
decks are refused. Each invocation sends one request, under the revision obtained
from its own preceding read; there is no force or unleased revision path.
Dry-run counts are snapshot estimates per shape/cell, joining runs within each
text object and never matching across objects. Unicode case matching may differ
from the server. Counts never suppress writes: even a zero estimate sends the
request on a real run, and the server reports the actual changed count. Dry runs
still require a `slides-write` grant but consume no Drive lease.
A real write also requires a Drive lease unless its deciding rule has
`require_lease: false`. Native lease acquisition needs `native_backup_folder_id`;
Slides backups are copied decks with no typed restore command yet. A volunteered
lease is checked even when the rule makes it optional. See [Lease](#lease).
A stale revision is refused atomically. Re-run against a fresh read. Unknown
revision-error wording may appear as `failed` with Google's diagnostic; exact
Slides stale-error wording has not been verified live. `applied-response-unreadable`
means HTTP success with an unreadable reply: inspect the deck before retrying.
Mutation logs contain metadata/counts, never searched/replacement prose.
Object deletion, adding slides, styling, index insertion and MCP are deferred.
See [ADR-0093](adrs/adr-0093.md).
## Rate limits and retry behaviour
Drive signals quota exhaustion two ways: a plain **HTTP 429**, and **HTTP
403** with `reason: userRateLimitExceeded` specifically — not any 403 with
a `reason` (e.g. `insufficientPermissions` is also a 403 and is never
retried, since retrying a permission error just wastes the backoff window
before failing anyway). Both retry through the shared driver
(`retry_if`/`retry_429`, `src/utils/http.rs`) with the same
`Retry-After`-then-exponential-backoff schedule. Unlike Gmail's client,
Drive's retry match does **not** also cover the bare `rateLimitExceeded`
reason string — that's confirmed for Gmail but not (yet) confirmed for
Drive against [Drive's error-handling guide]; it'll widen if testing
surfaces a real case.
`search` auto-paginates when `--limit 0` is passed (or any `--limit`
larger than the 1,000-per-page cap), capped at **10,000 records** per
invocation.
[Drive's error-handling guide]: https://developers.google.com/workspace/drive/api/guides/handle-errors
## Troubleshooting
### Sync destination refused
If sync reports “manifest belongs to a different Drive folder”, choose a separate
`--dest` for that root. If the destination is non-empty without a manifest, use an
empty directory; do not fabricate a manifest to claim unrelated files. Resolve
symlink/path errors without pointing the mirror at unrelated local content.
### Credentials not configured
```
Error: Drive credentials not configured. Run `omni-dev drive auth login`
```
Means `DRIVE_CLIENT_ID`, `DRIVE_CLIENT_SECRET`, or `DRIVE_REFRESH_TOKEN` is
missing from both the environment and `settings.json`. Run
`omni-dev drive auth login` — it prompts for the first two if they're
still absent; the third is written by `auth login` itself.
### `invalid_grant`
Google's `invalid_grant` response is identical for two different causes;
`drive auth login`/token-refresh distinguish which call failed and give a
tailored message:
```
Error: Failed to obtain a Drive access token
Caused by: Google rejected the request (invalid_grant): this almost always means either (1) your Drive OAuth client is in "Testing" publishing status, where refresh tokens expire after 7 days — publish it to "In production" in Google Cloud Console to avoid this, or (2) access was revoked. Run `omni-dev drive auth login` again to re-authenticate.
```
(during a refresh — by far the most common cause, the 7-day testing-mode
expiry described in [Prerequisites](#prerequisites)), or:
```
Error: Google rejected the request (invalid_grant): the authorization code was invalid, already used, expired (codes are single-use and valid only a few minutes), or the PKCE code_verifier did not match the code_challenge sent at the start of login. Run `omni-dev drive auth login` again.
```
(during the initial code exchange, right after approving the consent
screen). Either way, re-run `omni-dev drive auth login`, or push your OAuth
client to "In production" in Google Cloud Console to stop the 7-day
expiry recurring.
### `access_denied`
```
Error: Google denied the authorization request: access_denied
```
You (or another user) clicked "Cancel" on Google's consent screen, or your
OAuth client's test-user allowlist doesn't include the account you tried to
authorize (a Testing-mode consent screen only allows explicitly added test
users). Re-run `omni-dev drive auth login` and either approve the prompt or
add the account under **OAuth consent screen → Test users** in Google Cloud
Console.
### Could not start the local OAuth callback listener
```
Error: Failed to start the local OAuth callback listener
```
The loopback listener binds an OS-assigned ephemeral port, so this should
be rare. The one common cause is a stale process from a previously
interrupted `drive auth login` holding a socket resource open — retry,
and if it persists, check for a leftover `omni-dev` process.
### Timed out waiting for the browser sign-in callback
```
Error: Timed out after 120s waiting for the browser sign-in callback; re-run `omni-dev drive auth login`
```
Nothing hit the loopback callback within 120 seconds — most often because
the consent screen was left open too long, or the browser never opened
(see below). Just re-run `omni-dev drive auth login`.
### Browser did not open
`drive auth login` opens your default browser automatically. If it fails
to open (e.g. over SSH, or in a headless environment), the authorization
URL is printed to the terminal for you to open manually — no CLI flag is
needed to force this fallback; it's the same code path.
If it opens the *wrong* browser profile (mixing up which named account
lands on which Google identity), see [Browser profile
targeting](#browser-profile-targeting) above.
### No Drive scope was granted
```
Error: Google did not grant the drive.readonly scope (received: openid, email, profile).
On the consent screen, tick the Drive permission — restricted scopes are
not granted by default. Re-run `omni-dev drive auth login`.
```
Cause: the consent screen's Drive permission tick-box (see
[Prerequisites](#prerequisites)) was left unticked, so Google granted only
`openid`/`email`/`profile` — no Drive scope at all. `auth login` rejects
this immediately, naming the scopes Google actually granted, and writes
nothing to `settings.json`. Fix: re-run `omni-dev drive auth login` and
tick the Drive permission this time.
### Reading a folder or shortcut's content
```
Error: '<name>' is a folder; folders have no content to read — use `drive search` to list what it contains
```
```
Error: '<name>' is a shortcut; `drive read --content` doesn't follow shortcuts to their target file — resolve the target file's id and read that instead
```
`drive read --content` refuses both up front rather than returning an
empty or misleading response. For a folder, list its contents with
`drive search "'<folder-id>' in parents"`. For a shortcut, `drive read
<shortcut-id>` (metadata only, no `--content`) shows what it points at;
resolve that id and read it directly.
### `refusing to load N bytes into memory`
```
Error: refusing to load 734003200 bytes into memory (limit: 524288000 bytes); ...
```
The file's declared size exceeds the 500 MB `alt=media` download cap (see
[Read](#read)). There's no override flag — very large files aren't a fit
for this command today.
### `insufficientPermissions` on rename (or move)
```
Error: Drive API request failed: HTTP 403: Insufficient Permission (reason: insufficientPermissions)
Run `omni-dev drive auth login --write` to grant the drive.metadata scope needed for rename/move
```
The active credentials only carry `drive.readonly` — there is no
client-side check before the call, so this surfaces from Google's own 403.
Re-run `omni-dev drive auth login --write` to upgrade the grant (see
[Interactive setup](#interactive-setup)), then retry.
### `insufficientPermissions` on create/upload/edit
```
Error: Drive API request failed: HTTP 403: Insufficient Permission (reason: insufficientPermissions)
Run `omni-dev drive auth login --write-file` (or `--write-full`) to grant the scope needed to create files/folders and upload content
```
Same shape as the rename/move hint above, but for `create`/`upload` (needs
`--write-file` or `--write-full`) or `edit` (needs `--write-file` if
`omni-dev` created the file, `--write-full` for any pre-existing one — see
[Edit](#edit)). Re-run `drive auth login` with the named flag(s), then
retry.
### `Blocked` — refused by the write-permission gate
```bash
$ omni-dev drive create --name "x" --parent 1Sen...Confidential
Blocked: x in 1Sen...Confidential
refused by default policy (no matching rule)
```
This is not an error — the command exits 0, same as a `Blocked` move (see
[Move](#move)). No `files.create`/`files.update` call was ever made. Run
`drive permissions check <id> --operation <op>` to see exactly which rule
(if any) decided the refusal, and [Write
permissions](#write-permissions) to add a rule that allows it.
A refusal naming a rule says which kind decided it — `refused by rule on
folder <id> (depth 2)` or `refused by rule on file <id>`. A file rule has
no depth because it matches the target itself, and it beats every folder
rule (see [Resolution](#write-permissions)).
### `Refused: … has no parent folder visible to this account`
```bash
$ omni-dev drive sheets write 1Sh4r3d...Plan --range 'A1' --values data.csv
Refused: 'Quarterly Plan' has no parent folder visible to this account, so no
folder rule can apply to it. This is normal for a Sheet shared by link or
email. Grant it by id instead: add {"file_id": "<spreadsheet id>", "allow":
["sheets-write"]} to write_permissions.rules.
```
`files.get` returns only the parents **this account** can see, and a file
shared with you by link or email is not in a folder you can see — so it
arrives with none, and the gate has no ancestor chain to evaluate.
This is deliberately *not* reported as an ordinary `Blocked`: there is no
`folder_id` rule you could write that would change it, so telling you to
fix your folder rules would send you hunting for a bug that isn't there.
The fix is a `file_id` rule — see [Granting a file shared with
you](#granting-a-file-shared-with-you). `drive edit` reports the same way
for a shared binary file.
If you would rather not grant by id, the alternative still works: add the
file to a folder in your own Drive and grant that folder.
### `Refused: … changed since it was read (revision lease … no longer current)`
```bash
$ omni-dev drive docs replace 1AbC… --search Q3 --replace Q4
Refused: 'Roadmap' changed since it was read (revision lease ALm37BXk3nQ no
longer current) — nothing was written. Re-run to apply against the current
version.
```
Someone edited the document between the read that computed this edit and the
write that would have applied it. **Nothing was written** — the request is
atomic, so the document is exactly as the other person left it.
**Re-running is the fix**, and it is the only one. There is deliberately no
flag to force the write through: the Docs API's alternative rebases your edit
on top of the other person's changes and reports success, which would mean
`omni-dev` editing a document nobody had looked at. See
[ADR-0076](adrs/adr-0076.md) §3 and [Every edit is leased against a
revision](#every-edit-is-leased-against-a-revision).
If it happens repeatedly, the document is being actively edited; `--dry-run`
first to see what your change would touch.
`drive slides replace` also asserts its freshly read revision. A Slides
`stale-revision` outcome means no change was applied; re-run against the current
deck. Unrecognized error wording remains `failed` with the server diagnostic.
See [Slides](#slides).
### `Refused: … returned no revision id`
```bash
$ omni-dev drive docs replace 1AbC… --search Q3 --replace Q4
Refused: 'Roadmap' returned no revision id, which Google sends only to
callers with edit access — so this write cannot be leased against a known
version. Request edit access, or check the account in use.
```
This also applies to `drive slides replace`.
The account can *read* the document but not edit it. Google signals that by
omitting the revision id, and rather than attempt a write that would fail
anyway — or worse, write without a lease — the edit is refused up front.
Two things to check: whether the account actually has edit access to the
document, and whether `--account` is selecting the account you meant (see
[Multiple accounts](#multiple-accounts)). Note this is distinct from a
`Blocked`, which is *omni-dev's* own gate refusing, and from an
`insufficientPermissions` error, which is the OAuth scope being too narrow.
### No default export format for a Google-native file
```
Error: '<name>' (mimeType: application/vnd.google-apps.form) has no default export format; pass --export-mime-type. Supported export MIME types: application/pdf, application/zip
```
Only Docs/Sheets/Slides have a safe default export MIME type (see
[Read](#read)). Pass one of the listed `--export-mime-type` values.
## See also
- [Drive Quickstart](drive-quickstart.md) — a linear, zero-to-first-search
walkthrough for first-time setup.
- [Gmail Integration](gmail.md) — the sibling Google integration; shares
the same named-account/OAuth2 storage pattern.
- [ADR-0069](adrs/adr-0069.md) — the Drive-specific named-account store and
original read-only OAuth2 client design, and why it deliberately
duplicates rather than shares code with Gmail's.
- [ADR-0070](adrs/adr-0070.md) — reverses ADR-0069 §2 to add rename/move:
the additive `drive.metadata` scope, the visibility-diff algorithm behind
`move`'s safety gate, and the three-flag opt-in model.
- [ADR-0071](adrs/adr-0071.md) — extends ADR-0069/ADR-0070 to add
`create`/`upload`/`edit`: the `--write-file`/`--write-full` scope tiers,
the [write-permission gate](#write-permissions) and its resolution
algorithm, and why both layers are independently required.
- [ADR-0073](adrs/adr-0073.md) — extends ADR-0069/0070/0071 to add the
Sheets v4 API: the shared transport core behind a second Google host, the
separate `sheets-write` gate operation and why reusing `edit` was
rejected, and the CSV/JSON rendering rules.
- [ADR-0075](adrs/adr-0075.md) — extends ADR-0073 with structural edits via
`spreadsheets.batchUpdate`: the separate `sheets-structure` gate
operation, why the surface is typed verbs with no raw request
passthrough, and why deletion is deferred rather than gated.
- [ADR-0077](adrs/adr-0077-sheets-deletion-via-batchupdate.md) — the
deferred deletion design pass: the separate `sheets-delete` gate
operation, why the typed-verbs-only property survives deletion too, and
why there is still no interactive confirmation or `--force` for the most
dangerous operation in the tree.
- [ADR-0078](adrs/adr-0078.md) — the remaining `spreadsheets.batchUpdate`
surface: formatting, data validation and protected ranges. Why formatting
and data validation join the existing `sheets-structure` operation while
protected ranges get their own `sheets-protection` operation instead
(a permission change inside the document, not a structural one), and
`merge-cells`' `--dry-run` honesty requirement for the one request here
that discards data.
- [ADR-0081](adrs/adr-0081.md) — the gate mapping for the second Sheets
capability tranche (issue #1663), settled once rather than per issue:
named-range add/update/delete join `sheets-structure`, including why
`delete-named-range` stays there rather than joining `sheets-delete`, and
the mandatory referencing-formula preview that mitigates it.
- [ADR-0063](adrs/adr-0063.md) — the OAuth2 authorization-code + PKCE
design, refresh-token-only persistence, and bring-your-own Google Cloud
project rationale ADR-0069 applies unchanged.
- [ADR-0066](adrs/adr-0066.md) — the named-account store behind
[Multiple accounts](#multiple-accounts), and why it's orthogonal to
`--profile`.
- MCP tools — planned, not yet available; tracked by
[issue #1525](https://github.com/rust-works/omni-dev/issues/1525).
- [Drive API documentation](https://developers.google.com/workspace/drive/api/reference/rest/v3) — upstream reference.