Metadata-Version: 2.5
Name: almirah-app
Version: 0.1.0
Summary: Household documents filed and found in the owner's own Google Drive, driven by Claude Code skills through a JSON CLI.
Project-URL: Repository, https://github.com/nmohideen/almirah-2
Author: Nizam Mohideen
License-Expression: MIT
License-File: LICENSE
Classifier: Development Status :: 4 - Beta
Classifier: Environment :: Console
Classifier: Intended Audience :: Developers
Classifier: Operating System :: MacOS
Classifier: Operating System :: Microsoft :: Windows
Classifier: Programming Language :: Python :: 3.14
Classifier: Topic :: Office/Business
Requires-Python: <3.15,>=3.14
Requires-Dist: filelock==4.2.4
Requires-Dist: google-api-python-client==2.201.0
Requires-Dist: google-auth-oauthlib==1.5.0
Requires-Dist: google-auth==2.62.0
Requires-Dist: platformdirs==4.13.5
Requires-Dist: pypdfium2==5.14.0
Requires-Dist: tzdata==2026.5; sys_platform == 'win32'
Description-Content-Type: text/markdown

# Almirah

Your documents, filed and found in your own Google Drive, driven by Claude Code skills through the `almirah` JSON CLI.

## Beta quick start

For a developer trying Almirah on macOS or Windows with Google Drive and a coding agent (Claude Code, or Codex or another agent that reads `~/.agents/skills`).

1. **Install uv** (once). macOS: `brew install uv`, or `curl -LsSf https://astral.sh/uv/install.sh | sh`. Windows (PowerShell): `powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"`. Open a new terminal afterwards so `uv` is on the path; `uv --version` should say 0.12 or later.
2. **Install Almirah**: `uv tool install almirah-app`, then `almirah --version`. If the command is not found, run `uv tool update-shell` and open a new terminal.
3. **Install the skills**: `almirah skills install` copies them into `~/.claude/skills` and `~/.agents/skills` (`%USERPROFILE%\.claude\skills` on Windows) for each agent you have; add `--target claude`, `agents` or `all` to choose. Nothing is copied by installing the package, so until this step your agent has no Almirah skills. Restart the agent so it loads them.
4. **Set up your almirah**: in your agent say "set up almirah", or run `almirah setup` yourself. A browser window opens: Google warns that it has not verified the app (see "Signing in to Google" below); choose Advanced, Go to Almirah, and approve. Setup creates the `Almirah` folder in your Drive and refreshes the skills.
5. **Use it**: drop a PDF or photo in a folder and ask your agent to file it ("file this invoice"), then ask "find the invoice from ...", or "what is due".
6. **Upgrade**: `uv tool upgrade almirah-app`. The skills update themselves on the next command; restart your agent session to load the new skill text. `almirah skills status` shows what is installed.
7. **Report a problem** to the person who gave you Almirah: the command you ran, the JSON it printed (`error.code` and `error.message`; it holds no document text), your OS, and `almirah --version`. Do not send `token.json` or anything from the config folder.

To remove everything: `uv tool uninstall almirah-app`, delete the config and cache folders (see "Where things live"), and the skill folders as under "Remove the skills".

## Install

Requires [uv](https://docs.astral.sh/uv/) 0.12 or later, on macOS or Windows (Linux works, and is what CI also runs; uv fetches the Python it needs, 3.14). The package is
`almirah-app`; the command it installs is `almirah`.

```sh
uv tool install almirah-app
```

If you installed an earlier checkout with `uv tool install --editable`, remove it first, or the new install stops at
`Executable already exists: almirah`:

```sh
uv tool uninstall almirah
```

The `almirah-setup` skill walks you through everything below inside your coding agent. The steps are here too. The examples
pipe JSON into a command with `echo '...' |`, which works in a POSIX shell; in PowerShell, or anywhere else, write the JSON to a
file as UTF-8 and give it as `--input-file <file>` (a command with no input needs neither). A file named `almirah-*.json` in the temp folder is deleted once it has been read; give yours another name.
PowerShell 5.1 writes UTF-16 or ANSI unless told otherwise:

```powershell
Set-Content -Encoding utf8 in.json '{"root_id": "<folder id>"}'
almirah setup --input-file in.json
```

### Where things live

| | macOS | Windows | Linux |
|---|---|---|---|
| Config (login, `config.json`, audit log) | `~/Library/Application Support/almirah` | `%LOCALAPPDATA%\almirah` | `~/.config/almirah` |
| Cache (records cache, journal, staging) | `~/Library/Caches/almirah` | `%LOCALAPPDATA%\almirah\Cache` | `~/.cache/almirah` |
| Skills for Claude Code | `~/.claude/skills` | `%USERPROFILE%\.claude\skills` | `~/.claude/skills` |
| Skills for Codex and others | `~/.agents/skills` | `%USERPROFILE%\.agents\skills` | `~/.agents/skills` |

`ALMIRAH_CONFIG_DIR` and `ALMIRAH_CACHE_DIR` override the first two. An install made before the native places existed is moved
there once, on the first run, after every file is checked.

### Update

```sh
uv tool upgrade almirah-app
```

The skills follow by themselves: the first command after an upgrade (not `setup`, `skills install`, `skills status`, `schema`, `--version` or help, which skip it) copies the new skills into every folder that `almirah skills install` or `setup` recorded. A failed copy is one line on stderr and never fails the command; a source checkout (version `0+unknown`) is never synced. The agent session that is already running keeps the old skill text until it starts again. `almirah skills status` shows each folder's version beside the running one, and changes nothing.

## Signing in to Google

Almirah ships with a shared Google sign-in (an OAuth client built into each release), so there is no Google Cloud step: `almirah setup` opens a browser window and you approve access.

- **"Google hasn't verified this app".** If Google says the shared client is not verified (verification is a review Almirah has not asked for), choose **Advanced**, then **Go to Almirah (unsafe)**. The warning is about the app's review status, not about your data.
- **What you grant.** Access to the files Almirah creates plus the files you choose in the Drive window (the `drive.file` scope), not your whole Drive. An almirah made under the earlier scope keeps working and nothing is recreated, but you sign in once more: the old login is refused with a plain message and `almirah login` makes a new one. The code refuses to read or change anything outside the Almirah folder (see "The Almirah boundary" below); it never shares anything and never deletes (it trashes).
- **Where the login lives.** `token.json` in the config folder. It records the client that made it, so after a client change the next command says the login has expired and `almirah login` makes a new one.
- **Limit.** An unverified client allows 100 people in total. Beyond that, or if Google changes the rules, Almirah will move to a verified client.

### Using your own Google client instead

Use it if this build has no shared client (a build from a checkout has none), or you prefer your own. A file named `client_secret.json` in the config folder always wins over the shared client.

1. In the [Google Cloud console](https://console.cloud.google.com/), create (or pick) a project and **enable the Google Drive API**.
2. Configure the **OAuth consent screen**: user type **External**; add the `drive.file` scope only; then set the publishing status to **In production**. Leave the app **unverified** (personal-use exemption). This avoids the 7-day refresh-token expiry of Testing mode.
3. Create an **OAuth client ID** of type **Desktop app** and download its JSON.
4. Save it as `client_secret.json` in the config folder (see the table above), then run `almirah login`.

## Set up your almirah

```sh
almirah setup
```

If you are not logged in yet, a browser window opens first; approve access (the `drive.file` scope; see "Signing in to Google"). Setup then:

- creates a folder named `Almirah` in My Drive (pick another name with `{"root_name": "Family documents"}` as input), holding `_records/` (an empty `almirah.json` at version 0) and `_inbox/`, nothing in Drive is tagged;
- writes their Drive IDs to `config.json` in the config folder (mode 0600 where the OS has modes);
- **copies** the skills (each `almirah-*` skill and `_almirah_shared`, the shared procedure and wording the skills read as `../_almirah_shared/...` from their own folder) into the skill folder of each agent whose folder exists (`~/.claude` for Claude Code, `~/.agents` for Codex; Claude Code's when neither does, so on a Codex-only machine pass `--target agents` to `skills install` or `{"skills_target": "agents"}` to setup) (`claude`, `agents`, `all` or `detect`, default `detect`). The copies always overwrite what is there; nothing is linked. To copy without setting up an almirah, run `almirah skills install --target claude|agents|all`.

Running it again uses the configured almirah: nothing is created, and anything missing (say `_inbox/`) is made again. If a run fails part way, run it again; it uses and completes what the failed run left. It never overwrites an existing `almirah.json`.

Every command finds your almirah's files through `config.json`. A missing ID is never looked up: the command refuses `not_found` and says to run `almirah setup`. Setup, when it is given an existing Almirah folder, finds `_records/`, `almirah.json` and `_inbox/` by their exact names, and refuses `storage_failed` if two have one name.

### On a new laptop

Install, then point setup at the existing Almirah folder. Its ID is the part of the folder's Drive URL after `/folders/`. Write the JSON below to a file and pass it:

```sh
almirah setup --input-file root.json   # root.json holds {"root_id": "<folder id>"}
```

### Remove the skills

Delete the `almirah-*` folders and `_almirah_shared` from the agent's skill folder by hand (check `_almirah_shared` holds only Almirah's files: `_almirah_shared/.almirah-version` lists them), and remove the target from `skills_targets` in `config.json` (in the config folder; your agent is not allowed to edit it) so they are not copied back. `almirah skills status` lists only the recorded targets, so it prints none before an install. A small `.almirah-skills.lock` file stays in the skill folder; delete it too.

An install from before the packaged skills left links into the old checkout in those folders: run `almirah skills install --target claude` (or `agents`) once and they are replaced by copies.

## Log in again

```sh
almirah login
```

The token is saved to `token.json` in the config folder (mode 0600 where the OS has modes). If a command reports `auth_expired` (for example Google refused the refresh with `invalid_grant`), run `almirah login` again.

## Command schemas

`almirah schema <cmd>` prints a command's input and output JSON Schema (for example `almirah schema smoke`).

## How the skills talk

The skills speak to the owner in plain language about documents and folders: no commands, record ids or field names, and the owner is never asked to run a command (the skill runs it). `skills/_almirah_shared/language.md` holds the wording: how Almirah is presented, the term table (`_inbox`, `_superseded` and the Ledger spreadsheet are named, since the owner finds them in Drive) and the plain version of each error. Each skill carries the hard rules inline and reads the guide with the agent's file-reading tool.

Check it with `uv run python -m tests.skills.run_live <skill|all> [--no-bash] [--show]`: it drives `claude -p` against your real almirah and scans everything said with `tests/skills/leakcheck.py`. It needs your logged-in `claude`, and `file` and `reorg` really change it (a dummy invoice, `python -m tests.skills.drop_inbox` for inbox files, a folder renamed and back).

## Filing

You normally file through the `almirah-file` skill ("file this PDF"): it reads the document, proposes its type, date, provider, folder and name, and files it on one call; the model, not the CLI, decides whether to ask you first when it is doubtful. The commands it drives are below, each with one example input on stdin; `almirah schema <cmd>` lists every field. Every write takes the `vault_version` the last read returned as `base_version`, and `--dry-run` on a write returns what it would do and writes nothing.

- `prepare` is what the skill runs first: `inspect` and `records --for-proposal` from one read of your almirah. It returns the file's format, MIME type, size and MD5, the record already holding it (if any), every record, and the filing context, all from the local cache with no Drive call when it is warm.
- A PDF is read by probe, not whole: `prepare` also returns `pdf` (its real page count and pages 1 and 2, each `{page, label, chars, text, image, truncated}` with the label `text-ok`, `garbled` or `image-only`; text for a text-ok page, the path of a rendered PNG for the others). `almirah pages` with `{"local_path", "first", "last"}` returns more, never past page 10 (`invalid_value`; also for an image or a PDF the probe cannot read). The probe reads PDFs with pypdfium2 (pinned at 5.14.0, installed with Almirah; macOS 13 or later), so nothing else needs installing; if a PDF cannot be opened `prepare` still succeeds with `pdf.available` false and the skill reads pages 1 and 2 with the Read tool.

  ```sh
  echo '{"local_path": "/Users/me/Downloads/invoice.pdf"}' | almirah prepare
  ```

- `inspect` reads a local PDF, PNG or JPEG: its format, MIME type, size and MD5, refusing an unsupported, empty or password-protected file, and reports a record that already holds it.

  ```sh
  echo '{"local_path": "/Users/me/Downloads/invoice.pdf"}' | almirah inspect
  ```

- `records` lists every record, with its status (`current` or `superseded`) and Drive view link. With `--for-proposal` it adds what a folder proposal needs: the folders, learned corrections, and the vocabulary; candidate folder keys given are checked against the taxonomy.

  ```sh
  echo '{"candidates": ["F011"]}' | almirah records --for-proposal
  ```

- `file` files one local document on one call: it takes no `auto`, `confirmed` or `proposal_hash`, and its reply has no `routine` verdict. `--dry-run` is an optional preview: the masked record, final name (a clash with a record in the folder is proposed as `Name (2).pdf` in `name_adjusted`; the real run refuses `invalid_name`), path, `new_folders`, `learned`, `purpose_changes` and `superseded`. Names are unique among your almirah's records; the target folder is not listed in Drive. `"with_context": true` returns `records` and `context` as `prepare` does, from the records just committed, so a batch runs `prepare` once. `"defer_ledger": true` skips the post-commit Ledger refresh for a batch; the `ledger` command is then the end-of-batch step (one call). The Ledger Sheet's ID is saved in `config.json` as `ledger_id` (a new Sheet is made and its ID saved when it is missing or trashed), so a refresh is one request with the content, with no listing of `_records/` and no tag or version check.

  A renewal is filed with `"supersedes": "<the old document's id>"`. The dry run's `superseded` shows the predecessor and where it moves: into a `_superseded` folder inside its own folder (made the first time one is needed) and kept for good, while the new record carries `supersedes`. The old document's record is untouched; its status is derived from the new record. The predecessor must exist and not already be superseded, and its name must be free in that `_superseded` folder.

  ```sh
  echo '{"base_version": 3, "local_path": "/Users/me/Downloads/invoice.pdf", "md5": "<from inspect>", "type": "Invoice", "new_type": null, "date": "2026-03-14", "provider": "Amazon", "folder": "Purchases", "name": "Amazon - Laptop invoice - 2026-03"}' | almirah file --dry-run
  ```

- `ledger` renders `almirah.json` into the ledger Sheet in `_records/`: one row per document with its path, status, key facts and an open link. It updates the Sheet whose ID `config.json` saves in place, or makes a new one (and saves its ID) when there is none or it is gone; it always writes, with no staleness check, and the Sheet carries no tag. It is not an operation: no `base_version`, no version bump. Every write (`file`, ...) already runs it after committing, so you rarely need to; run it to repair a ledger that is missing or was trashed, or whenever you want it regenerated. No almirah command reads the ledger: household members open it in the Drive app (`_records/Ledger`) and follow a row's `open link`. If the regeneration after a write fails, the write still succeeded and the reply carries a warning; run `almirah ledger` to fix it.

  ```sh
  echo '{}' | almirah ledger
  ```

Concurrent saves: there is one owner on one device, so nothing coordinates two writers. A write reads `almirah.json`, runs its Drive steps, checks once that `almirah.json` is still what it read (its MD5), and then writes it at the next version; if the version it was given is behind, or the check finds a change, it refuses `stale` and the skill re-reads and retries. A write that lands between that check and the commit is not detected. Every Drive step is repeat-safe, so a save that failed part way is finished by running the same input again; nothing is undone, and `error.state_left` lists the steps that landed.

## Find and ask

Through the `almirah-find` and `almirah-ask` skills ("find my Samsung fridge warranty", "how much did I spend on insurance in 2025?"). They drive two read-only commands:

- `find-verify` checks that records' files are in Drive before a link is given: for each record id, `present` (the file exists and is not trashed), `verified` (it is also in the folder, under the name and with the MD5 the record holds), `issues` (`missing`, `trashed`, `replaced`, `moved`, `renamed`) and the Drive view `link` when the file is present. One metadata read per record, no downloads.

  ```sh
  echo '{"ids": ["D0007"]}' | almirah find-verify
  ```

- `query` computes the numbers: it filters the records (`scope` `current` or `all`, a `folder` with its sub-folders, `types`, `provider`, `person` (a name: the folder a document sits in under Identity, Health, Education or Work, else its `For` detail), `date_from`/`date_to`, `key_date_from`/`key_date_to` and `key_date_kinds`, where a date bound may be `today`, `today+30` or `today-7`), optionally groups them (`group_by` `type`, `provider`, `folder`, `person`, `year`, `financial_year` or `key_date_kind`) and aggregates (`sum`, `count` or `list`). It echoes `today`, cites every record it used (`cited`), lists the ones it left out and why (`excluded`: no date, no key date, no amount), and states what it cannot answer (`answerable: false` for a sum with no amounts, never a zero). Totals across currencies are given in your almirah's `base_currency`, converted per record with the `rates` you pass (`{"pair": "USD/INR", "rate": "83.25", "date": "2026-10-03", "source": "…"}`), each rate used echoed back; an unset base currency is refused `invalid_value` (set it once with `almirah set`) and a missing rate `missing_rate`. Nothing is estimated.

  ```sh
  echo '{"filter": {"scope": "all", "types": ["Health insurance policy"], "date_from": "2025-01-01", "date_to": "2025-12-31"}, "aggregate": "sum"}' | almirah query
  ```

## What is due

Through the `almirah-due` skill ("what is expiring?"), or directly:

```sh
echo '{}' | almirah due
```

`due` lists the key dates (renewal, expiry, warranty end, cover end) of current documents: `overdue` first and separately, the most overdue first, then `upcoming` within the lead time by days remaining. It echoes `today`, the local date in your almirah's time zone, and `lead_days` (30 unless you changed `due_lead_days` with `set`; `{"lead_days": 90}` looks further ahead for one call). A document due today is due in 0 days, not overdue. A superseded document, and one in a retired folder or under one, never appears.

## Reorganise your almirah

Through the `almirah-reorg` skill ("rename the Purchases folder", "split Home by property"). Your almirah is reorganised only through Almirah: each change is one confirmed operation that leaves Drive, the records and the taxonomy in agreement, or changes none of them.

```sh
echo '{"base_version": 12, "op": "rename_folder", "folder": "F011", "name": "Shopping"}' | almirah reorg --dry-run
```

`reorg` takes an `op` and the fields it needs (a field an op does not use is refused; `almirah schema reorg` lists all of them):

| `op` | fields | what it does |
|---|---|---|
| `rename_folder` | `folder`, `name` | renames the Drive folder and every path below it |
| `move_folder` | `folder`, `parent` or `top_level` | moves it (and its `_superseded` folder) into another folder or to the top |
| `rename_doc` | `id`, `name` | renames the file; the extension stays |
| `move_doc` | `id`, `to_folder` | moves a current document to another folder |
| `set_type` | `id`, `type`, `new_type` | changes the type (an identity type needs the document to sit in a person's folder) |
| `set_date` | `id`, `date` | changes the date |
| `set_values` | `id`, `values` | corrects a filed document's `provider`, `amount` `{value, currency}`, `key_date` with `key_date_kind` and `details` `{label: {value, kind}}`: null clears one, a key not given is kept, `details` given replaces the set. A person is changed with `set_type` and the same type |
| `retire` / `unretire` | `folder` | retires a folder and so its sub-folders: its documents leave `due` and the current scope of `query` |
| `set_purpose` | `folder`, `purpose` | rewrites its one-sentence purpose |
| `set_rule` | `folder`, `rule`, `assignments` | changes how the folder splits and, for each assignment `{id, subject, purpose}`, creates the sub-folders and moves the documents in one operation (Home switching to one folder per property) |

`--dry-run` writes nothing and returns the `summary`, every affected record's `effects` (its path before and after, and the fields that change), the Drive `actions` in order, the `new_folders` it would create, the documents that would stop being current (`leaving_due`) and a `proposal_hash`. The real run needs `"confirmed": true` and that same hash, and refuses `confirmation_required` otherwise. A superseded document is not moved on its own (it stays in its folder's `_superseded`, which moves with the folder); a name already taken in the destination, in your almirah or in Drive, is refused `invalid_name`, never changed silently. A Drive rename or move the owner already made by hand is accepted without a Drive call, which is how the records catch up with it. Every operation is one version of `almirah.json`, and the ledger is regenerated.

## Review

Through the `almirah-review` skill ("review my almirah"). `review` checks Drive against the records on demand, in one pass of listings with **no downloads**, and moves and changes nothing:

```sh
echo '{}' | almirah review
```

It reports what is filed (by folder, type and person), and one `finding` for each thing that differs, each with a `next_action` (`text`, and the `command` and `input` that do it where one does): `record_missing` (file gone or trashed), `record_differs` (`moved`, `renamed`, `replaced`), `record_superseded_misplaced` (a current record whose file sits in a `_superseded` folder), `folder_renamed`, `folder_moved`, `folder_missing`, and `ledger` (the Sheet is missing: `almirah ledger`). Whether the ledger is present comes with it, and so does `not_seen`: a plain sentence that files added by hand in Drive, or never chosen in the Drive window, are not seen. `_records/` is excluded from every check but the ledger's. Nothing is applied until it is confirmed.

- `rerecord` takes a file moved, renamed or re-uploaded by hand into the records: `observe` is what review saw (`parent_drive_id`, `name`, `md5`), the file with that name and content in that Drive folder becomes the record's file (a re-upload is followed by its content), its folder, name and MD5 are recorded and nothing in Drive changes, as one `rerecord` operation. A changed MD5 needs `values_confirmed` (the values still describe the new content); a folder your almirah does not track is `not_found` (move the file back in Drive). `--dry-run` shows the path before and after and the `proposal_hash`; the real run needs `confirmed: true` and that hash.

  ```sh
  echo '{"base_version": 12, "id": "D0007", "observe": {"parent_drive_id": "<folder id>", "name": "Deed.pdf", "md5": "<md5>"}}' | almirah rerecord --dry-run
  ```

Almirah keeps no backup of `almirah.json` and cannot rebuild it: if the records or `_records/` are lost, restoring them is up to Drive's trash and version history or a backup you keep.

## File from Drive

Documents that already sit in Drive are filed through the `almirah-file` skill ("file from Drive", "file what the family dropped"). Almirah can only file what you choose in the Drive window: it never counts, lists or announces what waits in Drive. The Inbox (`_inbox/`, made by setup) is a plain folder the family drops files into from any device, with no naming rules; the Drive window cannot start in it, so you open it there.

- `from-drive` opens the Drive window and waits until you have chosen. The files you choose are the batch, saved in a plan so a run that is interrupted can be continued with `from-drive --resume`, without opening the window again. Each chosen file is checked once from Drive's metadata, with no download, and falls into a group: `ready`, `already_filed` (its checksum is already a record) or `unreadable` (a folder, a type other than PDF, PNG or JPEG, empty, no checksum, Almirah's own file, a file Drive no longer has or that is trashed, a file Drive could not read, or a file whose bytes changed after classification). The reply gives the `route` (`none`, `file` for fewer than 3 ready files, `import` for 3 or more) and downloads the ready files into `staging/` in the cache folder. Each entry says whether it is in the Inbox (`in_inbox`). One use of the window is one batch. The window lets you choose up to 100 files at a time (Google's limit): more than 100 files go in several uses of the window, one batch each (finish the batch, clean up, open the window again). Metadata reads and downloads run from `workers` parallel threads (1 to 16, default 6; `{"workers": 1}` fetches one at a time), which is what keeps a hundred files from taking minutes one after another. The `import-decide` and `file-bulk` limits are internal: the skill splits a batch to fit them. On both routes, an `already_filed` entry that is in the Inbox is cleared with `remove-inbox-copy` (the import's `inbox-clean` does not cover it, since it is not an import row); one that is not in the Inbox is left where it is.

  ```sh
  almirah from-drive
  ```

- `file` with `drive_id` (beside the staged `local_path`) files a chosen document exactly as a laptop one: the same bytes give the same record and stored file. For a file directly in `_inbox/`, the Inbox copy is cleared after the commit: trashed, or unlinked from `_inbox/` when a household member owns it, or left with a warning if Drive allows neither; `inbox_copy` says which. A file from anywhere else in Drive is never changed (it is checked against its live checksum first) and `inbox_copy` is null. The report says the originals were left where they are.

- `remove-inbox-copy` clears an Inbox file whose content is already filed, as one operation; a file whose content is not filed is refused.

  ```sh
  echo '{"base_version": 12, "drive_id": "<inbox file id>"}' | almirah remove-inbox-copy
  ```

- `import-start` with `drive_ids` stages the chosen files as one batch for the import (see "Import a large load"). The `drive_ids` must be ready, staged files of the saved `from-drive` batch, otherwise they are refused (`from-drive --resume` stages them). Any number is one batch (three is only the `route` threshold `from-drive` reports), and `drive_ids` and `paths` cannot be combined.

- `inbox-clean` is remove-only: after an import batch is filed it clears the Inbox copies of what the batch filed and reports `originals_left`, the chosen files from elsewhere in Drive that were left where they are. Nothing is held or moved aside.

A file that cannot be filed (unreadable, empty, locked, declined) stays where it is and is named in the report with its reason; nothing is recorded about it, and you choose it again when it is fixed.

## Import a large load

For a first-time load or any large pile (100 to 1,000 documents or so) the `almirah-import` skill ("import my documents from this folder") brings documents in a batch of about 150 at a time. It does the work on this laptop first and pushes only a confirmed batch to Drive, as one commit.

1. **Stage.** `import-start` copies the sources into `imports/<id>/` in the cache folder and writes `plan.json`, one row per document: local files and folders, or the Drive files you chose in the Drive window (`drive_ids`, one batch). A source's folder names are kept as hints and never decide a folder. One import at a time.
2. **Preflight.** `import-preflight` classifies each document (ready, already filed, a copy of another, unreadable) and estimates the work; above about 1,000 ready documents it warns and suggests one source folder at a time. The owner is asked once to go ahead.
3. **Read.** `import-rows` groups a batch's rows and the skill starts a worker (a subagent on the session's model) per group of up to 10; each follows `skills/_almirah_shared/extract-one.md` and records facts with `import-facts`, which checks and masks them (AD-10). The skill's own context never holds a document.
4. **Decide.** From the digest `import-context` makes (folders, learned corrections, types, each document's facts and renewal candidates), `skills/_almirah_shared/decide-chunk.md` chooses each document's type, folder, name and renewal, parking the doubtful as "waiting for you", with `import-decide`. Every decision is planned against your almirah with no Drive call.
5. **Review and correct.** `import-review` writes a static `review.html` (no script, nothing external). The owner says what to change in the chat, by row number; `import-correct` applies row corrections and rules (a selector over what documents say, one change) and `learn` sentences, each planned against your almirah and rejected with a reason when the filing would refuse it. A re-written page marks the rows that changed.
6. **Approve and file.** `import-approve` records a hash of the batch's plan; any correction clears it. `file-bulk` with `import_chunk` files the batch only as approved: uploads run in parallel, then one `almirah.json` commit and one Ledger refresh. A filing stopped part way is repeated and sends only what had not landed. The filed rows become `committed`; the next batch is read only once the one before is finished, so a rule or learned sentence from one batch is known by the next.
7. **Clear the Inbox copies.** After a batch is filed, `inbox-clean` (remove-only) removes the Inbox copies of what the batch filed (or your almirah already held) and reports `originals_left`, the chosen files from elsewhere in Drive that stay where they are. It acts only on files the plan names that are still where and what they were (a checksum match; a file dropped after staging is never touched), removes the copies from parallel workers, and a repeat does what is left. Files that cannot be filed and rows the owner left out stay where they are. The owner says yes once at the start (or says to keep the copies: `keep`). `import-status` reports `cleanup_pending`, and the next batch does not start before the cleanup of the one before it.
8. **Summary.** Each batch ends with a brief chat summary (filed by folder, left out and why, still waiting, batches remaining, and for an inbox run what was tidied). Nothing is written to Drive for it.

Chosen Drive files are a source like any other; the Inbox copies of what was filed are cleared afterwards, and nothing is held or moved aside. `import-status` resumes an import after a closed session: the plan says where each batch stands. `import-discard` removes only the staged copies on this laptop. Sources and a Drive source folder are never changed, and only Inbox copies are cleared.

## The Almirah boundary

Almirah's login reaches only the files it created and the files the owner chooses in the Drive window, and the code makes sure it never reads or changes anything outside the Almirah folder. Several independent layers each refuse on their own (architecture AD-13a); a refusal is the error `outside_root`, and the skills tell the owner in plain words that Almirah stopped to protect files outside the Almirah folder.

- **The root is checked first.** Before any other call, every command checks that the configured root is the Almirah folder: a live folder, never My Drive or the `root` alias, holding the `_records/` and `almirah.json` the config names. A check that passed is remembered for ten minutes (`root-verified.json` in the cache folder), for exactly those IDs.
- **Every call is checked by where the file hangs.** `GuardedStorage` (in `ports/`) wraps the Drive adapter, and the fake in tests, and walks each file's parents up to the root before a read or a write. It reads only a file's ID, type and parents, fails closed, refuses shortcuts and files with more than one parent, and walks fresh for trash, move and unlink. A read outside the Almirah folder reads as "not found" (a file moved out by hand is simply missing); a write outside is refused. Unlinking is allowed only for a file the owner does not own.
- **The wire allows only what the app needs.** `adapters/drive_policy.py` sits under every Drive call and refuses, before sending, anything but reads, creating a file in the Almirah folder, and updating a file inside it (name, content, trashing, moving between its folders); it never sends a tag. There is no delete, no sharing, no emptying the trash, no copy, no shared drive.
- **A refusal stops the command.** The first refusal, or a file the Drive returns outside the Almirah folder, makes the rest of the command read-only: it may tidy (trash, rename, move back) but not create or rewrite.
- **Nothing is deleted.** Almirah only trashes, so Drive's 30-day trash and revisions can undo anything. Every write that lands, and every refusal, is appended (IDs only, no names) to `audit.jsonl` in the config folder.
- **The login is the scope.** The token carries `drive.file` only, so Google itself limits it to files Almirah made and files the owner chose. It is saved as `token.json` in the config folder (mode 0600 where the OS has modes). To keep an agent away from it, deny your agent's reads of the config folder; this is optional hardening, and a deny rule is not a security boundary.

The tests hold the boundary to a decoy tree: a lookalike Almirah folder, another folder and a shared file sit outside the Almirah folder in the fake Drive, and every operation, with a storage failure at every step, must leave them byte for byte as they were. `almirah smoke --guard` does the same on the real Drive, with a sentinel folder the owner makes outside the Almirah folder (any folder holding one file):

```sh
echo '{"sentinel_id": "<folder id>"}' | almirah smoke --guard
```

It tries every write method and every read on the sentinel, and raw delete, share, copy, rename, trash and move requests through the wire check, and fails unless each one is refused and the sentinel's modified time and files are as they were. It makes one folder inside the Almirah folder to prove a legitimate write still works, and trashes it again.

## Smoke check (dev only)

Create a throwaway folder in Drive, copy its ID from the URL, then:

```sh
echo '{"folder_id": "<folder id>"}' | almirah smoke
```

The folder is used as the Almirah folder: the Drive adapter refuses any write outside it. The smoke drives every Storage call once and times each one:

- creates a sub-folder and uploads a small file into it, then reads its metadata, lists the sub-folder and downloads it, checking Drive's `md5Checksum` against the locally computed MD5;
- replaces its content, uploads a CSV converted to a Sheet and updates it with conversion (the Sheet keeps its ID);
- renames the file, moves it and trashes it;
- uploads a 500-record `almirah.json`, downloads and parses it, and rewrites it;
- builds a small almirah and writes the ledger Sheet twice, each Drive call timed on its own: the first creates the Sheet, the second updates the same Sheet in place (same ID);
- tries to rename the folder itself and to unlink an owned file from it, both of which must be refused;
- trashes everything it created, even when a step fails (the error then names the step).

```json
{"ok": true, "vault_version": null, "data": {"calls": [{"call": "generate_ids", "ms": 212.4}, {"call": "create_folder", "ms": 480.9}, …], "within_budget": true, "record_count": 500}, "warnings": []}
```

The latency budget is 3 s per call at 500 records. A call at or over it does not fail the smoke: `within_budget` is `false` and each such call is listed in `warnings`.

## Development

```sh
uv sync --locked
uv run pytest
```

Tests never touch Drive. `core`, `app`, `ports` and the transport allowlist are held to 100% branch coverage.

To run the CLI from a checkout, `uv run almirah ...`, **first** point it away from your real setup, or it reads your login and, for `setup` and `skills install`, writes your real agent skill folders:

```sh
export HOME=$(mktemp -d) ALMIRAH_CONFIG_DIR=$(mktemp -d) ALMIRAH_CACHE_DIR=$(mktemp -d)
```

(On Windows set `USERPROFILE` as well.) `uv run python -m tests.skills.run_live` is the exception: it drives your real almirah on purpose.

## Maintainers: rotating the shared Google client

The shared client is two repository secrets, `ALMIRAH_CLIENT_ID` and `ALMIRAH_CLIENT_SECRET`, written into the package by `release/write_client.py` during a release build and never committed. Rotate it when the secret leaks, Google asks, or the 100-user cap is near.

1. In the dedicated Cloud project, create a new Desktop OAuth client. Keep the old one until the new release is out.
2. Replace the two repository secrets with the new values.
3. Release a new version (tag `vX.Y.Z`). The pipeline writes the new client into that wheel.
4. Tell users to run `uv tool upgrade almirah-app`. Their next command says the login has expired (the token records the client that made it); `almirah login` then signs in with the new client. A user who has their own `client_secret.json` is not affected.
5. Delete the old client in the Cloud console once the old release is no longer in use.

Revisit when the user count nears 100 or Google changes the rules: either apply for verification of the consent screen, or ask users to bring their own client (the override above).
