Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,47 @@ Notable changes to zotio. Format follows [Keep a Changelog](https://keepachangel

## [Unreleased]

### Added

- **`zotio desktop status` reports whether Zotero desktop is running.** It
reads the profile lock of every discovered Zotero profile (an fcntl lock on
`.parentlock` on macOS and Linux; an exclusive `parent.lock` handle on
Windows) and sends one ping to the local connector. `running` means the
process is up (lock held, or the connector answered); `connector_reachable`
means the connector accepts requests now, which imports need. `state` is
`ready`; `starting` (lock held, connector silent, lock younger than the
2-minute startup window); `busy` (past the window, the connector port
accepted the connection but did not answer this check — one silent check
is not a hang, since a large sync can hold Zotero's main thread for
seconds); `connector_off` (past the window, every address of the
connector host refused repeated connects — a hung Zotero's listener can
refuse some, so one refused ping is not enough); or `stopped`. `unresponsive` is reported only by
`desktop wait`, after a sustained stall. The lock age is the lock file's modification time, which
Zotero resets when it takes the lock (`profiles[].lock_since`). It exits 0
whatever it finds.
- **`zotio desktop wait` blocks until Zotero's connector accepts requests.**
It returns at once if the connector already answers. While Zotero is closed
it sleeps on filesystem notifications for the profile and data directories
instead of polling, and probes only after a change; while Zotero is starting
it re-checks the connector on a capped backoff. Past the startup window a
connector that cannot take a request is re-checked every 20s with a 10s
ping; only 60s without an answer, across 3 or more checks, returns exit 15:
`unresponsive` if any check found a listener on the connector port,
`connector_off` if none did (`stalled_since` set). Any answer in between
ends the wait as ready. Exit 15 carries the
state as `outcome`, because nothing on disk announces a recovery.
`--timeout` bounds the wait (exit 14, `outcome: "timeout"`); no
discoverable profile exits 9 (`outcome: "no_profile"`); `--watch-stdin`
exits when a supervisor's pipe closes. It is hidden from the MCP surface
because it blocks. The notifications come from the new dependency
`github.com/fsnotify/fsnotify` (BSD-3-Clause; inotify, kqueue,
ReadDirectoryChangesW), whose only dependency, `golang.org/x/sys`, was
already linked.
- **Exit code 14: a bounded wait timed out.** Nothing failed; wait again.
- **Exit code 15: Zotero desktop is open but its connector cannot take
requests** (`desktop wait`: hung, or connector disabled). Tell the user;
waiting longer does not help until something changes.

## [0.27.0] — 2026-09-23

### Changed — breaking
Expand Down
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -308,7 +308,7 @@ format = "obsidian" # or "logseq"
collection:KEY tag:NAME query:TEXT item:KEY saved-search:KEY (needs live desktop)
```

**Exit codes:** `0` ok · `2` usage · `3` not-found · `4` auth · `5` API · `7` rate-limited · `9` precondition/setup (including *another writer holds the lock* — retry) · `10` config · `11` quality-gate failed · `12` freshness-gate failed · `13` degraded — incomplete: part of a read was unreadable, or part of a batched write was rejected after other elements succeeded. Output is not guaranteed; read the reported failures and reconcile before retrying.
**Exit codes:** `0` ok · `2` usage · `3` not-found · `4` auth · `5` API · `7` rate-limited · `9` precondition/setup (including *another writer holds the lock* — retry) · `10` config · `11` quality-gate failed · `12` freshness-gate failed · `13` degraded — incomplete: part of a read was unreadable, or part of a batched write was rejected after other elements succeeded. Output is not guaranteed; read the reported failures and reconcile before retrying. · `14` timed out — a bounded wait (`desktop wait --timeout`) ended before Zotero's connector answered; wait again. · `15` Zotero open but stuck — `desktop wait` found Zotero running past its startup window with a connector that does not answer or is off; tell the user.

---

Expand Down Expand Up @@ -471,6 +471,7 @@ Also available: `--csv`, `--plain`, `--quiet`, `--compact`, and `--deliver stdou

```bash
zotio doctor # config, credentials, connectivity, cache freshness, writability
zotio desktop status # is Zotero desktop running, and does its connector accept imports?
```

- **`doctor: connection refused`** — open Zotero desktop and enable **Settings → Advanced → "Allow other applications to communicate with Zotero."**
Expand Down Expand Up @@ -502,7 +503,7 @@ zotio which "export bibtex for a collection"
<details>
<summary>Top-level commands</summary>

`agent-context` · `analytics` · `annotations` · `attachments` · `auth` · `capabilities` · `collections` · `completion` · `creators` · `demo` · `doctor` · `export` · `feedback` · `groups` · `import` · `init` · `items` · `journal` · `library` · `profile` · `reading-list` · `schema` · `search` · `searches` · `sync` · `tags` · `tail` · `vault` · `version` · `watch` · `which` · `workflow`
`agent-context` · `analytics` · `annotations` · `attachments` · `auth` · `capabilities` · `collections` · `completion` · `creators` · `demo` · `desktop` · `doctor` · `export` · `feedback` · `groups` · `import` · `init` · `items` · `journal` · `library` · `profile` · `reading-list` · `schema` · `search` · `searches` · `sync` · `tags` · `tail` · `vault` · `version` · `watch` · `which` · `workflow`

</details>

Expand Down
5 changes: 4 additions & 1 deletion SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ The curated feature set. `zotio which "<goal>"` resolves natural-language querie
- **`export snapshot`** — Reproducible, resumable full-library JSONL export with a lockfile (key, version, content hash) — diff lockfiles to prove what changed between handoffs, and take one before any bulk write the journal cannot reverse.
- **`watch`** — Periodic incremental syncs (`--interval`, `--once`); `--health` diffs library health between cycles and reports new findings to stdout or a webhook.
- **`workflow run`** — Run a declarative multi-step spec (JSON) in-process with per-step status and continue-on-error — replaces brittle shell chains.
- **`desktop status` / `desktop wait`** — Is Zotero desktop running, and does its connector accept imports (`connector_reachable`)? `wait` blocks on filesystem events, not a poll, until the connector answers (exit 0); it exits 15 when Zotero is open and its connector has not answered for 60s (`unresponsive`, or `connector_off` if nothing listens); a single silent check is only `busy`, and 14 at `--timeout`.
- **`init`** — Guided first run (detect Zotero, check the local API and explain how to enable it, set key, first sync, health check); agent-safe under `--no-input` (unmet steps exit 9 with a step report).

### Reading workflow
Expand Down Expand Up @@ -284,9 +285,11 @@ Explicit flags always win over profile values; profile values win over defaults.
| 7 | Rate limited (wait and retry) |
| 9 | Precondition unmet, or a writer lock is held |
| 10 | Config error |
| 11 | Quality gate failed (`--fail-on`, `--fail-on-unknown`) |
| 11 | Quality gate failed (`library health --fail-on`, `items bibcheck --fail-on`/`--fail-on-unknown`) |
| 12 | Stale data |
| 13 | Incomplete — part succeeded, part was rejected; reconcile before retrying |
| 14 | Timed out — `desktop wait --timeout` ended before Zotero's connector answered; wait again |
| 15 | Zotero is open but its connector is hung or off (`desktop wait`); tell the user rather than wait |

## Argument Parsing

Expand Down
30 changes: 30 additions & 0 deletions THIRD_PARTY_LICENSES.txt
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,36 @@ SOFTWARE.

<http://www.opensource.org/licenses/mit-license.php>

================================================================================
github.com/fsnotify/fsnotify v1.10.1
LICENSE

Copyright © 2012 The Go Authors. All rights reserved.
Copyright © fsnotify Authors. All rights reserved.

Redistribution and use in source and binary forms, with or without modification,
are permitted provided that the following conditions are met:

* Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above copyright notice, this
list of conditions and the following disclaimer in the documentation and/or
other materials provided with the distribution.
* Neither the name of Google Inc. nor the names of its contributors may be used
to endorse or promote products derived from this software without specific
prior written permission.

THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

================================================================================
github.com/gofrs/flock v0.13.1
LICENSE
Expand Down
26 changes: 26 additions & 0 deletions dev/zotero-api-coverage.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,28 @@ of coverage now.
created, leaving a childless item and no reason. A locally scanned PDF has no
web source, so callers must fall back to the file's own `file://` URI;
`connector.SaveAttachment` rejects an empty one up front so the failure is named.
- **Zotero's process and its connector come up at different times.** Measured
2026-09-24 against Zotero 7 on macOS: the process takes an fcntl write lock on
`<profile>/.parentlock` (F_GETLK from another process names the Zotero PID)
about 3s after launch, creates `zotero.sqlite-wal`/`-shm` in the data directory
about 4s after launch, and `/connector/ping` answers 200 a few seconds later,
only while Zotero runs. Windows builds hold `parent.lock` open with share mode 0
and delete-on-close instead. Only the connector answering proves an import can
proceed; `desktop status` reports both signals and `desktop wait` sleeps on
filesystem events for the profile and data directories until the connector
answers (`internal/desktop`). Mozilla's lock open truncates `.parentlock`, so
its mtime is the lock time (measured: 3s after process start on a file created
years earlier); past a 2-minute startup window a silent connector is reported
as `busy` for one check, `unresponsive` only after 60s of silence across 3+
checks (seen live the same day, with the window's accessibility tree also
failing), or `connector_off` (refused). The connector runs on Zotero's main
thread, so a long sync can stall it for seconds without a hang.
The hung listener is also why refusal is weak evidence: Zotero listened on
127.0.0.1 only, `[::1]` refused, and Go reports the first address's error, so
a ping said "connection refused" for a held port; and 127.0.0.1 itself reset
or refused some back-to-back connects. `connector_off` therefore needs every
address to refuse repeated dials (`desktop.ListeningOn`), and in `wait` the
whole stall span.
- **Schema/type endpoints are global**, served under `/api` directly, NOT under the
`/users|groups/<id>` library prefix the configured base URL carries:
`/api/itemTypes`, `/api/itemFields`, `/api/itemTypeFields`,
Expand Down Expand Up @@ -177,6 +199,10 @@ Run this when a new Zotero version ships, or periodically:

## Last reviewed

- **2026-09-24** — against the running Zotero 7 desktop on macOS. Confirmed
that a read-only descriptor's F_GETLK on `.parentlock` reports the Zotero PID
while it runs, recorded the lock/WAL/connector start order under Invariants,
and added `desktop status` and `desktop wait` on those signals.
- **2026-08-17** — against the live Zotero 7 desktop connector. Established that
`POST /connector/saveAttachment` cannot target an existing library item
(session-local ids only; `500` live, `400 SESSION_NOT_FOUND` otherwise) and that
Expand Down
2 changes: 2 additions & 0 deletions docs/reference/capabilities.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ The machine-readable registry every command is classified against — read vs. w
| `creators audit fix` | write | `web_api` | | `synced_store`, `web_api_key` |
| `creators rename` | other | | | |
| `demo` | read | | | |
| `desktop status` | read | | | |
| `desktop wait` | read | | | |
| `doctor` | introspect | | | |
| `export` | other | | | |
| `export snapshot` | read | | | |
Expand Down
155 changes: 155 additions & 0 deletions docs/reference/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -719,6 +719,161 @@ zotio demo [flags]
| --- | --- | --- | --- |
| `--reset` | `bool` | `false` | Delete and re-seed the demo library (also removes demo.db) |

## `zotio desktop`

Report whether Zotero desktop is running, or wait for it to start

```
zotio desktop
```

### `zotio desktop status`

Report whether Zotero desktop is running and its connector accepts requests

Report whether Zotero desktop is running and whether its connector accepts
requests. Cheap and local: it reads the profile lock of every discovered
Zotero profile and sends one ping to the local connector. It exits 0 whatever
it finds; read the fields, not the exit code.

Two signals, reported separately:

running Zotero's process is up: another process holds the
profile lock (.parentlock via fcntl on macOS and Linux,
parent.lock opened exclusively on Windows), or the
connector answered.
connector_reachable GET <connector>/ping answered 200 within 3s during this
check. Imports and every other connector write need
this.

state:
ready the connector answers.
starting the lock is held, the connector does not answer yet, and the
lock is younger than the 2-minute startup window. Zotero
takes the lock about 3s after launch and its connector
listens a few seconds later.
busy the lock is older than the startup window and the connector
port accepted the connection but did not answer in this
check (or answered with an error). One silent check is not
a hang: a large sync can hold Zotero's main thread for
seconds.
unresponsive desktop wait only: the connector stayed silent for at least
60s across 3 or more checks (each allowed 10s): Zotero is
open but not responding.
connector_off the lock is older than the startup window and nothing
listens on the connector port (every address refused
repeated connects): the connector is disabled (Settings ->
Advanced -> "Allow other applications to communicate with
Zotero") or on another port.
stopped neither signal holds.

The lock age comes from the lock file's modification time, which Zotero
resets when it takes the lock (profiles[].lock_since). evidence names the
strongest signal: connector, profile_lock, none.

Profiles are discovered from profiles.ini in the platform's Zotero directory
(ZOTERO_PROFILE_DIR pins one); data_dir comes from the profile's prefs.js.

```
zotio desktop status
```

Examples:

```bash
zotio desktop status
zotio desktop status --agent
```

### `zotio desktop wait`

Block until Zotero desktop's connector accepts requests

Block until Zotero desktop's connector accepts requests, then print the
status that proved it. If the connector already answers, it returns at once.

While Zotero is closed nothing runs on a timer: the command sleeps on
filesystem notifications for the Zotero profile and data directories and
probes only after a change. While Zotero is starting, the connector is
re-checked on a capped backoff (250ms up to 2s), because it listens a few
seconds after the lock and its start writes no file. When the startup window
passes, a connector that cannot take a request is re-checked every 20s with a
10s ping. Any answer ends the wait as ready. 60s without one, across 3 or more
checks, ends it with exit 15: unresponsive if any check found a listener on
the connector port, connector_off if none did (a hung Zotero's listener also
refuses some connects, so one refusal proves nothing). Exit 15 means Zotero is open
but stuck: nothing on disk announces a recovery, so the caller tells the user
and decides when to wait again. While Zotero is starting or busy, filesystem
events cause no extra checks.

Two signals, reported separately:

running Zotero's process is up: another process holds the
profile lock (.parentlock via fcntl on macOS and Linux,
parent.lock opened exclusively on Windows), or the
connector answered.
connector_reachable GET <connector>/ping answered 200 within 3s during this
check. Imports and every other connector write need
this.

state:
ready the connector answers.
starting the lock is held, the connector does not answer yet, and the
lock is younger than the 2-minute startup window. Zotero
takes the lock about 3s after launch and its connector
listens a few seconds later.
busy the lock is older than the startup window and the connector
port accepted the connection but did not answer in this
check (or answered with an error). One silent check is not
a hang: a large sync can hold Zotero's main thread for
seconds.
unresponsive desktop wait only: the connector stayed silent for at least
60s across 3 or more checks (each allowed 10s): Zotero is
open but not responding.
connector_off the lock is older than the startup window and nothing
listens on the connector port (every address refused
repeated connects): the connector is disabled (Settings ->
Advanced -> "Allow other applications to communicate with
Zotero") or on another port.
stopped neither signal holds.

The lock age comes from the lock file's modification time, which Zotero
resets when it takes the lock (profiles[].lock_since). evidence names the
strongest signal: connector, profile_lock, none.

Exit codes and the JSON outcome field:
0 outcome "ready": the connector answers.
15 outcome "unresponsive" or "connector_off" (the state): Zotero runs past
its startup window and its connector cannot take requests.
14 outcome "timeout": --timeout passed first; wait again.
9 outcome "no_profile" (no Zotero profile found and the connector does not
answer) or "watch_failed" (the directories could not be watched).
10 the configured base URL is not a local Zotero, so there is no connector
to wait for.
1 interrupted (SIGINT/SIGTERM) or stdin closed under --watch-stdin; nothing
is printed on stdout.

--timeout replaces the global request timeout for this command; a connector
ping is bounded to 3s, or 10s for the stall re-checks past the startup window.

```
zotio desktop wait [flags]
```

Examples:

```bash
zotio desktop wait
zotio desktop wait --agent --timeout 6h
# Supervised: exit when the supervisor's pipe closes
zotio desktop wait --agent --watch-stdin --timeout 1h
```

| Flag | Type | Default | Description |
| --- | --- | --- | --- |
| `--timeout` | `duration` | `0s` | Give up after this long and exit 14, e.g. 30m or 6h (0 = wait indefinitely) |
| `--watch-stdin` | `bool` | `false` | Exit when stdin reaches end of file (for supervisors that hold a pipe open); input is discarded |

## `zotio doctor`

Check CLI health
Expand Down
Loading
Loading