feat(zotero): add CLI-Anything harness for Zotero desktop

This commit is contained in:
zhiwuyazhe_fjr
2026-03-27 12:39:26 +08:00
parent ddba3dbe3e
commit 7a0f5cc849
36 changed files with 8691 additions and 1 deletions

4
.gitignore vendored
View File

@@ -39,6 +39,7 @@
!/inkscape/
!/audacity/
!/libreoffice/
!/zotero/
!/mubu/
!/obs-studio/
!/kdenlive/
@@ -68,6 +69,8 @@
/audacity/.*
/libreoffice/*
/libreoffice/.*
/zotero/*
/zotero/.*
/mubu/*
/mubu/.*
/obs-studio/*
@@ -107,6 +110,7 @@
!/inkscape/agent-harness/
!/audacity/agent-harness/
!/libreoffice/agent-harness/
!/zotero/agent-harness/
!/mubu/agent-harness/
!/obs-studio/agent-harness/
!/kdenlive/agent-harness/

View File

@@ -633,6 +633,13 @@ Each application received complete, production-ready CLI interfaces — not demo
<td align="center">✅ 158</td>
</tr>
<tr>
<td align="center"><strong>📚 <a href="zotero/agent-harness/">Zotero</a></strong></td>
<td>Reference Management</td>
<td><code>cli-anything-zotero</code></td>
<td>Local SQLite + connector + Local API</td>
<td align="center">✅ <a href="zotero/agent-harness/">New</a></td>
</tr>
<tr>
<td align="center"><strong>📝 <a href="mubu/agent-harness/">Mubu</a></strong></td>
<td>Knowledge Management &amp; Outlining</td>
<td><code>cli-anything-mubu</code></td>
@@ -827,6 +834,7 @@ cli-anything/
├── 🎵 audacity/agent-harness/ # Audacity CLI (161 tests)
├── 🌐 browser/agent-harness/ # Browser CLI (DOMShell MCP, new)
├── 📄 libreoffice/agent-harness/ # LibreOffice CLI (158 tests)
├── 📚 zotero/agent-harness/ # Zotero CLI (new, write import support)
├── 📝 mubu/agent-harness/ # Mubu CLI (96 tests)
├── 📹 obs-studio/agent-harness/ # OBS Studio CLI (153 tests)
├── 🎞️ kdenlive/agent-harness/ # Kdenlive CLI (155 tests)

View File

@@ -525,6 +525,13 @@ CLI-Anything 适用于任何有代码库的软件 —— 不限领域,不限
<td align="center">✅ 158</td>
</tr>
<tr>
<td align="center"><strong>📚 <a href="zotero/agent-harness/">Zotero</a></strong></td>
<td>文献管理与引用</td>
<td><code>cli-anything-zotero</code></td>
<td>Local SQLite + connector + Local API</td>
<td align="center">✅ <a href="zotero/agent-harness/">新增</a></td>
</tr>
<tr>
<td align="center"><strong>📹 OBS Studio</strong></td>
<td>直播与录制</td>
<td><code>cli-anything-obs-studio</code></td>
@@ -668,6 +675,7 @@ cli-anything/
├── ✏️ inkscape/agent-harness/ # Inkscape CLI202 项测试)
├── 🎵 audacity/agent-harness/ # Audacity CLI161 项测试)
├── 📄 libreoffice/agent-harness/ # LibreOffice CLI158 项测试)
├── 📚 zotero/agent-harness/ # Zotero CLI新增支持文献导入
├── 📹 obs-studio/agent-harness/ # OBS Studio CLI153 项测试)
├── 🎞️ kdenlive/agent-harness/ # Kdenlive CLI155 项测试)
├── 🎬 shotcut/agent-harness/ # Shotcut CLI154 项测试)

View File

@@ -2,7 +2,7 @@
"meta": {
"repo": "https://github.com/HKUDS/CLI-Anything",
"description": "CLI-Hub — Agent-native stateful CLI interfaces for softwares, codebases, and Web Services",
"updated": "2026-03-18"
"updated": "2026-03-26"
},
"clis": [
{
@@ -173,6 +173,20 @@
"contributor": "CLI-Anything-Team",
"contributor_url": "https://github.com/HKUDS/CLI-Anything"
},
{
"name": "zotero",
"display_name": "Zotero",
"version": "0.1.0",
"description": "Reference management via local Zotero SQLite, connector, and Local API",
"requires": "Zotero desktop app",
"homepage": "https://www.zotero.org",
"install_cmd": "pip install git+https://github.com/HKUDS/CLI-Anything.git#subdirectory=zotero/agent-harness",
"entry_point": "cli-anything-zotero",
"skill_md": "zotero/agent-harness/cli_anything/zotero/skills/SKILL.md",
"category": "office",
"contributor": "zhiwuyazhe_fjr",
"contributor_url": "https://github.com/zhiwuyazhe_fjr"
},
{
"name": "mubu",
"display_name": "Mubu",

View File

@@ -0,0 +1,460 @@
# Zotero: Project-Specific Analysis and Operator Guide
## Current Capability Snapshot
### Stable and Supported
- import literature into a specific collection through official connector flows
- attach local or downloaded PDFs during the same import session
- inspect libraries, collections, items, attachments, tags, styles, and saved searches
- find items by keyword or exact title
- read child notes under an item
- add a child note to an existing item through official connector save flows
- export RIS, BibTeX, BibLaTeX, CSL JSON, CSV, MODS, and Refer
- render citations and bibliography entries through Zotero's own CSL engine
- route stable read/search/export flows across both user and group libraries
- build LLM-ready structured context for one item
- optionally call OpenAI directly for analysis
### Experimental Local Enhancements
- create a collection by writing directly to `zotero.sqlite`
- add an existing top-level item to another collection
- move an existing top-level item between collections
These experimental commands are intentionally not presented as official Zotero
API capabilities. They exist as local power-user tooling with explicit safety
guards.
### Still Out of Scope
- snapshot capture
- arbitrary existing-item attachment upload outside the current import session
- word-processor transaction integration
- privileged JavaScript execution inside Zotero
- standalone note creation
- group-library write support for experimental SQLite operations
## Architecture Summary
This harness treats Zotero as a layered desktop system:
1. SQLite for local inventory and offline reads
2. connector endpoints for GUI-aware state and official write flows
3. Local API endpoints for live search, CSL rendering, and translator-backed export
4. experimental CLI-only SQLite writes for a few local library-management tasks
The default rule is conservative:
- use official Zotero surfaces whenever they exist
- do not reimplement translators or citeproc
- isolate non-official writes behind explicit `--experimental`
## Source Anchors
The implementation is derived from the installed Zotero source under:
```text
C:\Program Files\Zotero
```
Primary anchors:
- `app/omni.ja`
- `chrome/content/zotero/xpcom/server/server_localAPI.js`
- `chrome/content/zotero/xpcom/server/server_connector.js`
- `chrome/content/zotero/xpcom/server/server_connectorIntegration.js`
- `chrome/content/zotero/xpcom/server/saveSession.js`
- `chrome/content/zotero/modules/commandLineOptions.mjs`
- `defaults/preferences/zotero.js`
Important constants from Zotero 7.0.32:
- default HTTP port: `23119`
- Local API pref default: `extensions.zotero.httpServer.localAPI.enabled = false`
- connector liveness endpoint: `/connector/ping`
- selected collection endpoint: `/connector/getSelectedCollection`
- official connector write endpoints used here:
- `/connector/import`
- `/connector/saveItems`
- `/connector/saveAttachment`
- `/connector/updateSession`
## Backend Responsibilities
### SQLite
Used for:
- libraries
- collection listing, lookup, and tree building
- top-level item inventory
- child notes, attachments, and annotations
- tag lookup
- saved-search metadata
- style inventory
- experimental local collection writes
Behavior notes:
- regular inspection uses `mode=ro&immutable=1`
- no write path is shared with normal stable commands
- experimental writes open a separate transaction-only writable connection
### Connector
Used for:
- liveness
- selected collection detection
- file import
- JSON item import
- import-time attachment upload through the same connector save session
- child note creation
- session retargeting and post-save tagging
Behavior notes:
- Zotero must be running
- write behavior depends on the live desktop app state
- import-time PDF attachment upload is limited to items created in the same connector session
- `note add` inherits connector constraints and therefore expects the GUI to be on the same library as the parent item
### Local API
Used for:
- keyword item search
- citation rendering
- bibliography rendering
- export
- saved-search execution
Behavior notes:
- Zotero must be running
- Local API must be enabled in `user.js` or `prefs.js`
- stable read/search/export commands automatically switch between user and group Local API routes
- there is no fake local fallback for citeproc or translator export
### OpenAI
Used for:
- optional `item analyze`
Behavior notes:
- requires `OPENAI_API_KEY`
- requires explicit `--model`
- recommended stable interface remains `item context`
## How To Enable Local API
### Recommended CLI Path
```bash
cli-anything-zotero --json app enable-local-api
cli-anything-zotero --json app enable-local-api --launch
```
What this does:
- resolves the active Zotero profile
- writes `extensions.zotero.httpServer.localAPI.enabled=true` into `user.js`
- reports whether the pref was already enabled
- optionally launches Zotero and verifies connector and Local API readiness
### Manual Path
Add this line to the active profile's `user.js`:
```js
user_pref("extensions.zotero.httpServer.localAPI.enabled", true);
```
Then restart Zotero.
### Verification
Use either:
```bash
cli-anything-zotero --json app status
cli-anything-zotero --json app ping
```
`app status` should show:
- `local_api_enabled_configured: true`
- `local_api_available: true` once Zotero is running
## Workflow Map
### Import Into a Specific Collection
Use:
- `import file <path> --collection <ref>`
- `import json <path> --collection <ref>`
- `import file <path> --attachments-manifest <manifest.json>`
- `import json <path>` with inline per-item `attachments`
Backend:
- connector
Officiality:
- official Zotero write flow
- attachment phase uses official `/connector/saveAttachment` in the same session
### Find One Paper
Use:
- `item find <query>`
- `item find <full-title> --exact-title`
- `item get <item-id-or-key>`
Backend:
- Local API for live keyword search
- SQLite for exact title or offline fallback
### Read One Collection
Use:
- `collection find <query>`
- `collection get <ref>`
- `collection items <ref>`
Backend:
- SQLite
### Read Notes for a Paper
Use:
- `item notes <item-ref>`
- `note get <note-ref>`
Backend:
- SQLite
### Add a Note to a Paper
Use:
- `note add <item-ref> --text ...`
- `note add <item-ref> --file ... --format markdown`
Backend:
- connector `/connector/saveItems`
### Export or Analyze a Paper
Use:
- `item export`
- `item citation`
- `item bibliography`
- `item context`
- `item analyze`
Backends:
- Local API for export/citation/bibliography
- SQLite plus optional Local API enrichment for `item context`
- OpenAI for `item analyze`
### Re-file Existing Items
Use:
- `collection create ... --experimental`
- `item add-to-collection ... --experimental`
- `item move-to-collection ... --experimental`
Backend:
- experimental direct SQLite writes
## Command Reference
| Command | Purpose | Requires Zotero Running | Backend | Notes |
|---|---|---:|---|---|
| `app status` | Show runtime paths and backend availability | No | discovery | Includes profile, data dir, SQLite path, connector, Local API |
| `app version` | Show harness and Zotero version | No | discovery | Uses install metadata |
| `app launch` | Launch Zotero and wait for liveness | No | executable + connector | Waits for Local API too when configured |
| `app enable-local-api` | Enable Local API in `user.js` | No | prefs write | Safe, idempotent helper |
| `app ping` | Check connector liveness | Yes | connector | Only connector, not Local API |
| `collection list` | List collections | No | SQLite | Uses current library context |
| `collection find <query>` | Find collections by name | No | SQLite | Good for recovering keys/IDs |
| `collection tree` | Show nested collection structure | No | SQLite | Parent-child hierarchy |
| `collection get <ref>` | Read one collection | No | SQLite | Accepts ID or key |
| `collection items <ref>` | Read items in one collection | No | SQLite | Top-level items only |
| `collection use-selected` | Save GUI-selected collection into session | Yes | connector | Uses `/connector/getSelectedCollection` |
| `collection create <name> --experimental` | Create a collection locally | No, Zotero must be closed | experimental SQLite | Automatic backup + transaction |
| `item list` | List top-level items | No | SQLite | Children are excluded |
| `item find <query>` | Find papers by keyword | Recommended | Local API + SQLite | Falls back to SQLite title search |
| `item find <title> --exact-title` | Exact title lookup | No | SQLite | Stable offline path |
| `item get <ref>` | Read one item | No | SQLite | Returns fields, creators, tags |
| `item children <ref>` | Read all child records | No | SQLite | Includes notes, attachments, annotations |
| `item notes <ref>` | Read child notes only | No | SQLite | Purpose-built note listing |
| `item attachments <ref>` | Read attachment children | No | SQLite | Resolves `storage:` paths |
| `item file <ref>` | Resolve one attachment file | No | SQLite | Returns first child attachment for regular items |
| `item export <ref> --format <fmt>` | Translator-backed export | Yes | Local API | Zotero handles the export |
| `item citation <ref>` | CSL citation render | Yes | Local API | Supports style, locale, linkwrap |
| `item bibliography <ref>` | CSL bibliography render | Yes | Local API | Supports style, locale, linkwrap |
| `item context <ref>` | Build structured LLM-ready context | Optional | SQLite + optional Local API | Recommended stable AI interface |
| `item analyze <ref>` | Send context to OpenAI | API key required | OpenAI + local context | Model must be explicit |
| `item add-to-collection ... --experimental` | Append collection membership | No, Zotero must be closed | experimental SQLite | Does not remove existing memberships |
| `item move-to-collection ... --experimental` | Move item between collections | No, Zotero must be closed | experimental SQLite | Requires explicit sources or `--all-other-collections` |
| `note get <ref>` | Read one note | No | SQLite | Accepts note item ID or key |
| `note add <item-ref>` | Add a child note | Yes | connector | Parent item must be top-level |
| `search list` | List saved searches | No | SQLite | Metadata only |
| `search get <ref>` | Read one saved search definition | No | SQLite | Includes stored conditions |
| `search items <ref>` | Execute a saved search | Yes | Local API | Live command |
| `tag list` | List tags | No | SQLite | Includes item counts |
| `tag items <tag>` | Read items under one tag | No | SQLite | Tag string or tag ID |
| `style list` | Read installed CSL styles | No | local data dir | Parses local `.csl` files |
| `import file <path>` | Import through Zotero translators | Yes | connector | Supports optional `--attachments-manifest` sidecar |
| `import json <path>` | Save official connector JSON items | Yes | connector | Supports inline per-item `attachments` descriptors |
| `session *` | Persist current context | No | local state | REPL/session helper commands |
## Item Search Behavior
### `item find`
Primary behavior:
- when Local API is available, query the library-aware Zotero route:
- `/api/users/0/...` for the local user library
- `/api/groups/<libraryID>/...` for group libraries
- resolve Local API result keys back through SQLite so results always include local `itemID` and `key`
Fallback behavior:
- if Local API is unavailable or returns nothing useful, SQLite title search is used
- `--exact-title` always uses SQLite exact matching
Reference behavior:
- numeric IDs remain globally valid
- bare keys are accepted when they match exactly one library
- if a bare key matches multiple libraries, the CLI raises an ambiguity error and asks the caller to set `session use-library <id>`
### Why `item get` and `item find` Are Separate
- `item get` is precise lookup by `itemID` or `key`
- `item find` is discovery by keyword or title
This keeps lookup stable and makes scripting more predictable.
## Notes Model
### `item notes`
- entrypoint for listing note children under one paper
- returns notes only, not attachments or annotations
### `note get`
- reads one note record directly
- good for follow-up scripting when you already have a note key from `item notes`
### `note add`
- only child notes are supported in this harness version
- standalone notes are intentionally left out
- `text` and `markdown` are converted to safe HTML before submit
- `html` is accepted as-is
## LLM and Analysis Model
### Recommended Stable Interface: `item context`
`item context` is the portable interface. It aggregates:
- item fields
- creators and tags
- attachments
- optional notes
- optional exports such as BibTeX and CSL JSON
- optional DOI and URL links
- a prompt-ready `prompt_context`
This is the recommended command if the caller already has its own LLM stack.
### Optional Direct Interface: `item analyze`
`item analyze` layers model calling on top of `item context`.
Design choices:
- requires `OPENAI_API_KEY`
- requires explicit `--model`
- does not hide missing-context uncertainty
- remains optional, not the only AI path
## Experimental SQLite Write Model
### Why It Exists
Zotero's official HTTP surfaces cover import and note save well, but do not expose
general-purpose collection creation and arbitrary re-filing of existing items.
This harness adds a narrow experimental SQLite write path.
### Guardrails
- `--experimental` is mandatory
- Zotero must be closed
- the database is backed up before each write
- each operation runs in a single transaction
- rollback occurs on failure
- only the local user library is supported
### Semantics
`item add-to-collection`:
- append-only
- keeps all current collection memberships
`item move-to-collection`:
- first ensures target membership exists
- then removes memberships from `--from` collections or from all others when `--all-other-collections` is used
- does not delete implicitly without explicit source selection
## SQLite Tables Used
| CLI Area | Zotero Tables |
|---|---|
| Libraries | `libraries` |
| Collections | `collections`, `collectionItems` |
| Items | `items`, `itemTypes` |
| Fields and titles | `itemData`, `itemDataValues`, `fields` |
| Creators | `creators`, `itemCreators` |
| Tags | `tags`, `itemTags` |
| Notes | `itemNotes` |
| Attachments | `itemAttachments` |
| Annotations | `itemAnnotations` |
| Searches | `savedSearches`, `savedSearchConditions` |
## Limitations
- `item analyze` depends on external OpenAI credentials and network access
- `search items`, `item export`, `item citation`, and `item bibliography` require Local API
- `note add` depends on connector behavior and active GUI library context
- experimental SQLite write commands are local power features, not stable Zotero APIs
- no `saveSnapshot`
- import-time PDF attachment upload is supported, but arbitrary existing-item attachment upload is still out of scope
- no word-processor integration transaction client
- no privileged JavaScript execution inside Zotero

View File

@@ -0,0 +1,498 @@
# Zotero CLI Harness
`cli-anything-zotero` is an agent-native CLI for Zotero desktop. It does not
reimplement Zotero. Instead, it composes Zotero's real local surfaces:
- SQLite for offline, read-only inventory
- connector endpoints for GUI state and official write flows
- Local API for citation, bibliography, export, and live search
## What It Is Good For
This harness is designed for practical daily Zotero workflows:
- import a RIS/BibTeX/JSON record into a chosen collection
- attach local or downloaded PDFs during the same import session
- find a paper by keyword or full title
- inspect one collection or one paper in detail
- read child notes and attachments
- add a child note to an existing item
- export BibTeX or CSL JSON for downstream tools
- generate structured context for an LLM
- optionally call OpenAI directly for analysis
- inspect, search, and export from both the local user library and group libraries
- experimentally create collections or re-file existing items when Zotero is closed
## Requirements
- Python 3.10+
- Zotero desktop installed
- a local Zotero profile and data directory
The Windows-first validation target for this harness is:
```text
C:\Program Files\Zotero
```
## Install
```bash
cd zotero/agent-harness
py -m pip install -e .
```
If `cli-anything-zotero` is not recognized afterwards, your Python Scripts
directory is likely not on `PATH`. You can still use:
```bash
py -m cli_anything.zotero --help
```
## Local API
Some commands require Zotero's Local API. Zotero 7 keeps it disabled by default.
Enable it from the CLI:
```bash
cli-anything-zotero --json app enable-local-api
cli-anything-zotero --json app enable-local-api --launch
```
Or manually add this to the active profile's `user.js`:
```js
user_pref("extensions.zotero.httpServer.localAPI.enabled", true);
```
Then restart Zotero.
## Quickstart
```bash
cli-anything-zotero --json app status
cli-anything-zotero --json collection list
cli-anything-zotero --json item list --limit 10
cli-anything-zotero --json item find "embodied intelligence" --limit 5
cli-anything-zotero
```
## Library Context
- stable read, search, export, citation, bibliography, and saved-search execution work for both the local user library and group libraries
- `session use-library 1` and `session use-library L1` are equivalent and persist the normalized `libraryID`
- if a bare key matches multiple libraries, the CLI raises an ambiguity error and asks you to set `session use-library <id>` before retrying
- experimental direct SQLite write commands remain limited to the local user library
## Workflow Guide
### 1. Import Literature Into a Specific Collection
Use Zotero's official connector write path.
```bash
cli-anything-zotero --json import file .\paper.ris --collection COLLAAAA --tag review
cli-anything-zotero --json import json .\items.json --collection COLLAAAA --tag imported
cli-anything-zotero --json import file .\paper.ris --collection COLLAAAA --attachments-manifest .\attachments.json
cli-anything-zotero --json import json .\items-with-pdf.json --collection COLLAAAA --attachment-timeout 90
```
`import json` supports a harness-private inline `attachments` array on each item:
```json
[
{
"itemType": "journalArticle",
"title": "Embodied Intelligence Paper",
"attachments": [
{ "path": "C:\\papers\\embodied.pdf", "title": "PDF" },
{ "url": "https://example.org/embodied.pdf", "title": "Publisher PDF", "delay_ms": 500 }
]
}
]
```
`import file` supports the same attachment descriptors through a sidecar manifest:
```json
[
{
"index": 0,
"expected_title": "Embodied Intelligence Paper",
"attachments": [
{ "path": "C:\\papers\\embodied.pdf", "title": "PDF" }
]
}
]
```
Attachment behavior:
- attachments are uploaded only for items created in the current import session
- local files and downloaded URLs must pass PDF magic-byte validation
- duplicate attachment descriptors for the same imported item are skipped idempotently
- if metadata import succeeds but one or more attachments fail, the command returns JSON with `status: "partial_success"` and exits non-zero
When Zotero is running, target resolution is:
1. explicit `--collection`
2. current session collection
3. current GUI-selected collection
4. user library
Backend:
- connector
Zotero must be running:
- yes
### 2. Find a Collection
```bash
cli-anything-zotero --json collection find "robotics"
```
Use this when you remember a folder name but not its key or ID.
Backend:
- SQLite
Zotero must be running:
- no
### 3. Find a Paper by Keyword or Full Title
```bash
cli-anything-zotero --json item find "foundation model"
cli-anything-zotero --json item find "A Very Specific Paper Title" --exact-title
cli-anything-zotero --json item find "vision" --collection COLLAAAA --limit 10
```
Behavior:
- default mode prefers Local API search and falls back to SQLite title search when needed
- when Local API is used, the harness automatically switches between `/api/users/0/...` and `/api/groups/<libraryID>/...`
- `--exact-title` forces exact title matching through SQLite
- results include `itemID` and `key`, so you can pass them directly to `item get`
- if a bare key is duplicated across libraries, set `session use-library <id>` to disambiguate follow-up commands
Backend:
- Local API first
- SQLite fallback
Zotero must be running:
- recommended for keyword search
- not required for exact-title search
### 4. Read a Collection or One Item
```bash
cli-anything-zotero --json collection items COLLAAAA
cli-anything-zotero --json item get REG12345
cli-anything-zotero --json item attachments REG12345
cli-anything-zotero --json item file REG12345
```
Typical use:
- read the papers under a collection
- inspect a single paper's fields, creators, and tags
- resolve the local PDF path for downstream processing
Backend:
- SQLite
Zotero must be running:
- no
### 5. Read Notes for a Paper
```bash
cli-anything-zotero --json item notes REG12345
cli-anything-zotero --json note get NOTEKEY
```
Responsibilities:
- `item notes` lists only child notes for the paper
- `note get` reads the full content of one note by item ID or key
Backend:
- SQLite
Zotero must be running:
- no
### 6. Add a Child Note to a Paper
```bash
cli-anything-zotero --json note add REG12345 --text "Key takeaway: ..."
cli-anything-zotero --json note add REG12345 --file .\summary.md --format markdown
```
Behavior:
- always creates a child note attached to the specified paper
- `text` and `markdown` are converted to safe HTML before save
- `html` is passed through as-is
Important connector note:
- Zotero must be running
- the Zotero UI must currently be on the same library as the parent item
Backend:
- connector `/connector/saveItems`
### 7. Export BibTeX, CSL JSON, and Citations
```bash
cli-anything-zotero --json item export REG12345 --format bibtex
cli-anything-zotero --json item export REG12345 --format csljson
cli-anything-zotero --json item citation REG12345 --style apa --locale en-US
cli-anything-zotero --json item bibliography REG12345 --style apa --locale en-US
```
These commands automatically use the correct Local API scope for user and group libraries.
Supported export formats:
- `ris`
- `bibtex`
- `biblatex`
- `csljson`
- `csv`
- `mods`
- `refer`
Backend:
- Local API
Zotero must be running:
- yes
### 8. Produce LLM-Ready Context
```bash
cli-anything-zotero --json item context REG12345 --include-notes --include-links --include-bibtex
```
This command is the stable, model-independent path for AI workflows. It returns:
- item metadata and fields
- attachments and local file paths
- optional notes
- optional BibTeX and CSL JSON
- optional DOI and URL links
- a `prompt_context` text block you can send to any LLM
Backend:
- SQLite
- optional Local API when BibTeX or CSL JSON export is requested
### 9. Ask OpenAI to Analyze a Paper
```bash
set OPENAI_API_KEY=...
cli-anything-zotero --json item analyze REG12345 --question "What is this paper's likely contribution?" --model gpt-5.4-mini --include-notes
```
Behavior:
- builds the same structured context as `item context`
- adds links automatically
- sends the question and context to the OpenAI Responses API
Requirements:
- `OPENAI_API_KEY`
- explicit `--model`
Recommended usage:
- use `item context` when you want portable data
- use `item analyze` when you want an in-CLI answer
### 10. Experimental Collection Refactoring
These commands write directly to `zotero.sqlite` and are intentionally marked
experimental.
```bash
cli-anything-zotero --json collection create "New Topic" --parent COLLAAAA --experimental
cli-anything-zotero --json item add-to-collection REG12345 COLLBBBB --experimental
cli-anything-zotero --json item move-to-collection REG67890 COLLAAAA --from COLLBBBB --experimental
cli-anything-zotero --json item move-to-collection REG67890 COLLAAAA --all-other-collections --experimental
```
Safety rules:
- Zotero must be closed
- `--experimental` is mandatory
- the harness automatically backs up `zotero.sqlite` before the write
- commands run in a single transaction and roll back on failure
- only the local user library is supported for these experimental commands
Semantics:
- `add-to-collection` only appends a collection membership
- `move-to-collection` adds the target collection and removes memberships from the specified sources
Backend:
- experimental direct SQLite writes
## Command Groups
### `app`
| Command | Purpose | Requires Zotero Running | Backend |
|---|---|---:|---|
| `status` | Show executable, profile, data dir, SQLite path, connector state, and Local API state | No | discovery + probes |
| `version` | Show package version and Zotero version | No | discovery |
| `launch` | Start Zotero and wait for liveness | No | executable + connector |
| `enable-local-api` | Enable the Local API in `user.js`, optionally launch and verify | No | profile prefs |
| `ping` | Check `/connector/ping` | Yes | connector |
### `collection`
| Command | Purpose | Requires Zotero Running | Backend |
|---|---|---:|---|
| `list` | List collections in the current library | No | SQLite |
| `find <query>` | Find collections by name | No | SQLite |
| `tree` | Show nested collection structure | No | SQLite |
| `get <ref>` | Read one collection by ID or key | No | SQLite |
| `items <ref>` | Read the items under one collection | No | SQLite |
| `use-selected` | Persist the currently selected GUI collection | Yes | connector |
| `create <name> --experimental` | Create a collection locally with backup protection | No, Zotero must be closed | experimental SQLite |
### `item`
| Command | Purpose | Requires Zotero Running | Backend |
|---|---|---:|---|
| `list` | List top-level regular items | No | SQLite |
| `find <query>` | Find papers by keyword or full title | Recommended | Local API + SQLite |
| `get <ref>` | Read a single item by ID or key | No | SQLite |
| `children <ref>` | Read notes, attachments, and annotations under an item | No | SQLite |
| `notes <ref>` | Read only child notes under an item | No | SQLite |
| `attachments <ref>` | Read attachment metadata and resolved paths | No | SQLite |
| `file <ref>` | Resolve one attachment file path | No | SQLite |
| `export <ref> --format <fmt>` | Export one item through Zotero translators | Yes | Local API |
| `citation <ref>` | Render one citation | Yes | Local API |
| `bibliography <ref>` | Render one bibliography entry | Yes | Local API |
| `context <ref>` | Build structured, LLM-ready context | Optional | SQLite + optional Local API |
| `analyze <ref>` | Send item context to OpenAI for analysis | Yes for exports only; API key required | OpenAI + local context |
| `add-to-collection <item> <collection> --experimental` | Append a collection membership | No, Zotero must be closed | experimental SQLite |
| `move-to-collection <item> <collection> --experimental` | Move an item between collections | No, Zotero must be closed | experimental SQLite |
### `note`
| Command | Purpose | Requires Zotero Running | Backend |
|---|---|---:|---|
| `get <ref>` | Read one note by ID or key | No | SQLite |
| `add <item-ref>` | Create a child note under an item | Yes | connector |
### `search`
| Command | Purpose | Requires Zotero Running | Backend |
|---|---|---:|---|
| `list` | List saved searches | No | SQLite |
| `get <ref>` | Read one saved search definition | No | SQLite |
| `items <ref>` | Execute one saved search | Yes | Local API |
### `tag`
| Command | Purpose | Requires Zotero Running | Backend |
|---|---|---:|---|
| `list` | List tags and item counts | No | SQLite |
| `items <tag>` | Read items carrying a tag | No | SQLite |
### `style`
| Command | Purpose | Requires Zotero Running | Backend |
|---|---|---:|---|
| `list` | Read installed CSL styles | No | SQLite data dir |
### `import`
| Command | Purpose | Requires Zotero Running | Backend |
|---|---|---:|---|
| `file <path>` | Import RIS/BibTeX/BibLaTeX/Refer and other translator-supported text files | Yes | connector |
| `json <path>` | Save official Zotero connector item JSON | Yes | connector |
### `session`
`session` keeps current library, collection, item, and command history for the
REPL and one-shot commands.
## REPL
Run without a subcommand to enter the stateful REPL:
```bash
cli-anything-zotero
```
Useful builtins:
- `help`
- `exit`
- `current-library`
- `current-collection`
- `current-item`
- `use-library <id-or-Lid>`
- `use-collection <id-or-key>`
- `use-item <id-or-key>`
- `use-selected`
- `status`
- `history`
- `state-path`
## Testing
```bash
py -m pip install -e .
py -m pytest cli_anything/zotero/tests/test_core.py -v
py -m pytest cli_anything/zotero/tests/test_cli_entrypoint.py -v
py -m pytest cli_anything/zotero/tests/test_agent_harness.py -v
py -m pytest cli_anything/zotero/tests/test_full_e2e.py -v -s
py -m pytest cli_anything/zotero/tests/ -v --tb=no
set CLI_ANYTHING_FORCE_INSTALLED=1
py -m pytest cli_anything/zotero/tests/test_cli_entrypoint.py -v
py -m pytest cli_anything/zotero/tests/test_full_e2e.py -v -s
```
Opt-in live write tests:
```bash
set CLI_ANYTHING_ZOTERO_ENABLE_WRITE_E2E=1
set CLI_ANYTHING_ZOTERO_IMPORT_TARGET=<collection-key-or-id>
py -m pytest cli_anything/zotero/tests/test_full_e2e.py -v -s
```
## Limitations
- `item analyze` depends on `OPENAI_API_KEY` and an explicit model name
- `search items`, `item export`, `item citation`, and `item bibliography` require Local API
- `note add` depends on connector behavior and therefore expects the Zotero UI to be on the same library as the parent item
- experimental collection write commands are intentionally not presented as stable Zotero APIs
- no `saveSnapshot`
- import-time PDF attachments are supported, but arbitrary existing-item attachment upload is still out of scope
- no word-processor integration transaction client
- no privileged JavaScript execution inside Zotero

View File

@@ -0,0 +1,5 @@
"""cli-anything-zotero package."""
__all__ = ["__version__"]
__version__ = "0.1.0"

View File

@@ -0,0 +1,5 @@
from cli_anything.zotero.zotero_cli import entrypoint
if __name__ == "__main__":
raise SystemExit(entrypoint())

View File

@@ -0,0 +1 @@
"""Core modules for cli-anything-zotero."""

View File

@@ -0,0 +1,166 @@
from __future__ import annotations
import os
from typing import Any
from cli_anything.zotero.core import notes as notes_core
from cli_anything.zotero.core.catalog import get_item, item_attachments
from cli_anything.zotero.core.discovery import RuntimeContext
from cli_anything.zotero.core import rendering
from cli_anything.zotero.utils import openai_api
def _creator_line(item: dict[str, Any]) -> str:
creators = item.get("creators") or []
if not creators:
return ""
parts = []
for creator in creators:
full_name = " ".join(part for part in [creator.get("firstName"), creator.get("lastName")] if part)
if not full_name:
full_name = str(creator.get("creatorID", ""))
parts.append(full_name)
return ", ".join(parts)
def _link_payload(item: dict[str, Any]) -> dict[str, str]:
fields = item.get("fields") or {}
links: dict[str, str] = {}
url = fields.get("url")
doi = fields.get("DOI") or fields.get("doi")
if url:
links["url"] = str(url)
if doi:
links["doi"] = str(doi)
links["doi_url"] = f"https://doi.org/{doi}"
return links
def _prompt_context(payload: dict[str, Any]) -> str:
item = payload["item"]
fields = item.get("fields") or {}
lines = [
f"Title: {item.get('title') or ''}",
f"Item Key: {item.get('key') or ''}",
f"Item Type: {item.get('typeName') or ''}",
]
creator_line = _creator_line(item)
if creator_line:
lines.append(f"Creators: {creator_line}")
for field_name in sorted(fields):
if field_name == "title":
continue
value = fields.get(field_name)
if value not in (None, ""):
lines.append(f"{field_name}: {value}")
links = payload.get("links") or {}
if links:
lines.append("Links:")
for key, value in links.items():
lines.append(f"- {key}: {value}")
attachments = payload.get("attachments") or []
if attachments:
lines.append("Attachments:")
for attachment in attachments:
lines.append(
f"- {attachment.get('title') or attachment.get('key')}: "
f"{attachment.get('resolvedPath') or attachment.get('path') or '<missing>'}"
)
notes = payload.get("notes") or []
if notes:
lines.append("Notes:")
for note in notes:
lines.append(f"- {note.get('title') or note.get('key')}: {note.get('noteText') or note.get('notePreview')}")
exports = payload.get("exports") or {}
if exports:
lines.append("Exports:")
for fmt, content in exports.items():
lines.append(f"[{fmt}]")
lines.append(content)
return "\n".join(lines).strip()
def build_item_context(
runtime: RuntimeContext,
ref: str | int | None,
*,
include_notes: bool = False,
include_bibtex: bool = False,
include_csljson: bool = False,
include_links: bool = False,
session: dict[str, Any] | None = None,
) -> dict[str, Any]:
item = get_item(runtime, ref, session=session)
attachments = item_attachments(runtime, item["key"], session=session)
notes: list[dict[str, Any]] = []
if include_notes:
notes = notes_core.get_item_notes(runtime, item["key"], session=session)
exports: dict[str, str] = {}
if include_bibtex:
exports["bibtex"] = rendering.export_item(runtime, item["key"], "bibtex", session=session)["content"]
if include_csljson:
exports["csljson"] = rendering.export_item(runtime, item["key"], "csljson", session=session)["content"]
payload = {
"item": item,
"attachments": attachments,
"notes": notes,
"exports": exports,
"links": _link_payload(item) if include_links else {},
}
payload["prompt_context"] = _prompt_context(payload)
return payload
def analyze_item(
runtime: RuntimeContext,
ref: str | int | None,
*,
question: str,
model: str,
include_notes: bool = False,
include_bibtex: bool = False,
include_csljson: bool = False,
session: dict[str, Any] | None = None,
) -> dict[str, Any]:
api_key = os.environ.get("OPENAI_API_KEY", "").strip()
if not api_key:
raise RuntimeError("OPENAI_API_KEY is not set. Use `item context` for model-independent output or configure the API key.")
context_payload = build_item_context(
runtime,
ref,
include_notes=include_notes,
include_bibtex=include_bibtex,
include_csljson=include_csljson,
include_links=True,
session=session,
)
input_text = (
"Use the Zotero item context below to answer the user's question.\n\n"
f"Question:\n{question.strip()}\n\n"
f"Context:\n{context_payload['prompt_context']}"
)
response = openai_api.create_text_response(
api_key=api_key,
model=model,
instructions=(
"You are analyzing a Zotero bibliographic record. Stay grounded in the provided context. "
"If the context is missing an answer, say so explicitly."
),
input_text=input_text,
)
return {
"itemKey": context_payload["item"]["key"],
"model": model,
"question": question,
"answer": response["answer"],
"responseID": response["response_id"],
"context": context_payload,
}

View File

@@ -0,0 +1,252 @@
from __future__ import annotations
import xml.etree.ElementTree as ET
from pathlib import Path
from typing import Any
from cli_anything.zotero.core.discovery import RuntimeContext
from cli_anything.zotero.utils import zotero_http, zotero_sqlite
def _require_sqlite(runtime: RuntimeContext) -> Path:
sqlite_path = runtime.environment.sqlite_path
if not sqlite_path.exists():
raise FileNotFoundError(f"Zotero SQLite database not found: {sqlite_path}")
return sqlite_path
def resolve_library_id(runtime: RuntimeContext, library_ref: str | int | None) -> int | None:
if library_ref is None:
return None
sqlite_path = _require_sqlite(runtime)
library = zotero_sqlite.resolve_library(sqlite_path, library_ref)
if not library:
raise RuntimeError(f"Library not found: {library_ref}")
return int(library["libraryID"])
def _default_library(runtime: RuntimeContext, session: dict[str, Any] | None = None) -> int:
session = session or {}
current_library_id = resolve_library_id(runtime, session.get("current_library"))
if current_library_id is not None:
return current_library_id
library_id = zotero_sqlite.default_library_id(_require_sqlite(runtime))
if library_id is None:
raise RuntimeError("No Zotero libraries found in the local database")
return library_id
def local_api_scope(runtime: RuntimeContext, library_id: int) -> str:
library = zotero_sqlite.resolve_library(_require_sqlite(runtime), library_id)
if not library:
raise RuntimeError(f"Library not found: {library_id}")
if library["type"] == "user":
return "/api/users/0"
if library["type"] == "group":
return f"/api/groups/{int(library['libraryID'])}"
raise RuntimeError(f"Unsupported library type for Zotero Local API: {library['type']}")
def list_libraries(runtime: RuntimeContext) -> list[dict[str, Any]]:
return zotero_sqlite.fetch_libraries(_require_sqlite(runtime))
def list_collections(runtime: RuntimeContext, session: dict[str, Any] | None = None) -> list[dict[str, Any]]:
return zotero_sqlite.fetch_collections(_require_sqlite(runtime), library_id=_default_library(runtime, session))
def find_collections(runtime: RuntimeContext, query: str, *, limit: int = 20, session: dict[str, Any] | None = None) -> list[dict[str, Any]]:
return zotero_sqlite.find_collections(_require_sqlite(runtime), query, library_id=_default_library(runtime, session), limit=limit)
def collection_tree(runtime: RuntimeContext, session: dict[str, Any] | None = None) -> list[dict[str, Any]]:
return zotero_sqlite.build_collection_tree(list_collections(runtime, session=session))
def get_collection(runtime: RuntimeContext, ref: str | int | None, session: dict[str, Any] | None = None) -> dict[str, Any]:
session = session or {}
resolved = ref if ref is not None else session.get("current_collection")
if resolved is None:
raise RuntimeError("Collection reference required or set it in session first")
collection = zotero_sqlite.resolve_collection(
_require_sqlite(runtime),
resolved,
library_id=resolve_library_id(runtime, session.get("current_library")),
)
if not collection:
raise RuntimeError(f"Collection not found: {resolved}")
return collection
def collection_items(runtime: RuntimeContext, ref: str | int | None, session: dict[str, Any] | None = None) -> list[dict[str, Any]]:
collection = get_collection(runtime, ref, session=session)
return zotero_sqlite.fetch_items(_require_sqlite(runtime), library_id=int(collection["libraryID"]), collection_id=int(collection["collectionID"]))
def use_selected_collection(runtime: RuntimeContext) -> dict[str, Any]:
if not runtime.connector_available:
raise RuntimeError(f"Zotero connector is not available: {runtime.connector_message}")
return zotero_http.get_selected_collection(runtime.environment.port)
def list_items(runtime: RuntimeContext, session: dict[str, Any] | None = None, limit: int | None = None) -> list[dict[str, Any]]:
return zotero_sqlite.fetch_items(_require_sqlite(runtime), library_id=_default_library(runtime, session), limit=limit)
def find_items(
runtime: RuntimeContext,
query: str,
*,
collection_ref: str | None = None,
limit: int = 20,
exact_title: bool = False,
session: dict[str, Any] | None = None,
) -> list[dict[str, Any]]:
sqlite_path = _require_sqlite(runtime)
collection = None
if collection_ref:
collection = get_collection(runtime, collection_ref, session=session)
library_id = int(collection["libraryID"]) if collection else _default_library(runtime, session)
if not exact_title and runtime.local_api_available:
scope = local_api_scope(runtime, library_id)
path = f"{scope}/collections/{collection['key']}/items/top" if collection else f"{scope}/items/top"
payload = zotero_http.local_api_get_json(
runtime.environment.port,
path,
params={"format": "json", "q": query, "limit": limit},
)
results: list[dict[str, Any]] = []
for record in payload if isinstance(payload, list) else []:
key = record.get("key") if isinstance(record, dict) else None
if not key:
continue
resolved = zotero_sqlite.resolve_item(sqlite_path, key, library_id=library_id)
if resolved:
results.append(resolved)
if results:
return results[:limit]
collection_id = int(collection["collectionID"]) if collection else None
return zotero_sqlite.find_items_by_title(
sqlite_path,
query,
library_id=library_id,
collection_id=collection_id,
limit=limit,
exact_title=exact_title,
)
def get_item(runtime: RuntimeContext, ref: str | int | None, session: dict[str, Any] | None = None) -> dict[str, Any]:
session = session or {}
resolved = ref if ref is not None else session.get("current_item")
if resolved is None:
raise RuntimeError("Item reference required or set it in session first")
item = zotero_sqlite.resolve_item(
_require_sqlite(runtime),
resolved,
library_id=resolve_library_id(runtime, session.get("current_library")),
)
if not item:
raise RuntimeError(f"Item not found: {resolved}")
return item
def item_children(runtime: RuntimeContext, ref: str | int | None, session: dict[str, Any] | None = None) -> list[dict[str, Any]]:
item = get_item(runtime, ref, session=session)
return zotero_sqlite.fetch_item_children(_require_sqlite(runtime), item["itemID"])
def item_notes(runtime: RuntimeContext, ref: str | int | None, session: dict[str, Any] | None = None) -> list[dict[str, Any]]:
item = get_item(runtime, ref, session=session)
return zotero_sqlite.fetch_item_notes(_require_sqlite(runtime), item["itemID"])
def item_attachments(runtime: RuntimeContext, ref: str | int | None, session: dict[str, Any] | None = None) -> list[dict[str, Any]]:
item = get_item(runtime, ref, session=session)
attachments = zotero_sqlite.fetch_item_attachments(_require_sqlite(runtime), item["itemID"])
for attachment in attachments:
attachment["resolvedPath"] = zotero_sqlite.resolve_attachment_real_path(attachment, runtime.environment.data_dir)
return attachments
def item_file(runtime: RuntimeContext, ref: str | int | None, session: dict[str, Any] | None = None) -> dict[str, Any]:
item = get_item(runtime, ref, session=session)
target = item
if item["typeName"] != "attachment":
attachments = item_attachments(runtime, item["itemID"])
if not attachments:
raise RuntimeError(f"No attachment file found for item: {item['key']}")
target = attachments[0]
resolved_path = zotero_sqlite.resolve_attachment_real_path(target, runtime.environment.data_dir)
return {
"itemID": target["itemID"],
"key": target["key"],
"title": target.get("title", ""),
"contentType": target.get("contentType"),
"path": target.get("attachmentPath"),
"resolvedPath": resolved_path,
"exists": bool(resolved_path and Path(resolved_path).exists()),
}
def list_searches(runtime: RuntimeContext, session: dict[str, Any] | None = None) -> list[dict[str, Any]]:
return zotero_sqlite.fetch_saved_searches(_require_sqlite(runtime), library_id=_default_library(runtime, session))
def get_search(runtime: RuntimeContext, ref: str | int | None, session: dict[str, Any] | None = None) -> dict[str, Any]:
if ref is None:
raise RuntimeError("Search reference required")
session = session or {}
search = zotero_sqlite.resolve_saved_search(
_require_sqlite(runtime),
ref,
library_id=resolve_library_id(runtime, session.get("current_library")),
)
if not search:
raise RuntimeError(f"Saved search not found: {ref}")
return search
def search_items(runtime: RuntimeContext, ref: str | int | None, session: dict[str, Any] | None = None) -> Any:
if not runtime.local_api_available:
raise RuntimeError("search items requires the Zotero Local API to be running and enabled")
search = get_search(runtime, ref, session=session)
scope = local_api_scope(runtime, int(search["libraryID"]))
return zotero_http.local_api_get_json(
runtime.environment.port,
f"{scope}/searches/{search['key']}/items",
params={"format": "json"},
)
def list_tags(runtime: RuntimeContext, session: dict[str, Any] | None = None) -> list[dict[str, Any]]:
return zotero_sqlite.fetch_tags(_require_sqlite(runtime), library_id=_default_library(runtime, session))
def tag_items(runtime: RuntimeContext, tag_ref: str | int, session: dict[str, Any] | None = None) -> list[dict[str, Any]]:
return zotero_sqlite.fetch_tag_items(_require_sqlite(runtime), tag_ref, library_id=_default_library(runtime, session))
def list_styles(runtime: RuntimeContext) -> list[dict[str, Any]]:
styles_dir = runtime.environment.styles_dir
if not styles_dir.exists():
return []
styles: list[dict[str, Any]] = []
for path in sorted(styles_dir.glob("*.csl")):
try:
root = ET.parse(path).getroot()
except ET.ParseError:
styles.append({"path": str(path), "id": None, "title": path.stem, "valid": False})
continue
style_id = None
title = None
for element in root.iter():
tag = element.tag.split("}", 1)[-1]
if tag == "id" and style_id is None:
style_id = (element.text or "").strip() or None
if tag == "title" and title is None:
title = (element.text or "").strip() or None
styles.append({"path": str(path), "id": style_id, "title": title or path.stem, "valid": True})
return styles

View File

@@ -0,0 +1,87 @@
from __future__ import annotations
import subprocess
from dataclasses import dataclass
from typing import Any, Optional
from cli_anything.zotero.utils import zotero_http, zotero_paths
@dataclass
class RuntimeContext:
environment: zotero_paths.ZoteroEnvironment
backend: str
connector_available: bool
connector_message: str
local_api_available: bool
local_api_message: str
def to_status_payload(self) -> dict[str, Any]:
payload = self.environment.to_dict()
payload.update(
{
"backend": self.backend,
"connector_available": self.connector_available,
"connector_message": self.connector_message,
"local_api_available": self.local_api_available,
"local_api_message": self.local_api_message,
}
)
return payload
def build_runtime_context(*, backend: str = "auto", data_dir: str | None = None, profile_dir: str | None = None, executable: str | None = None) -> RuntimeContext:
environment = zotero_paths.build_environment(
explicit_data_dir=data_dir,
explicit_profile_dir=profile_dir,
explicit_executable=executable,
)
connector_available, connector_message = zotero_http.connector_is_available(environment.port)
local_api_available, local_api_message = zotero_http.local_api_is_available(environment.port)
return RuntimeContext(
environment=environment,
backend=backend,
connector_available=connector_available,
connector_message=connector_message,
local_api_available=local_api_available,
local_api_message=local_api_message,
)
def launch_zotero(runtime: RuntimeContext, wait_timeout: int = 30) -> dict[str, Any]:
executable = runtime.environment.executable
if executable is None:
raise RuntimeError("Zotero executable could not be resolved")
if not executable.exists():
raise FileNotFoundError(f"Zotero executable not found: {executable}")
process = subprocess.Popen([str(executable)], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
connector_ready = zotero_http.wait_for_endpoint(
runtime.environment.port,
"/connector/ping",
timeout=wait_timeout,
ready_statuses=(200,),
)
local_api_ready = False
if runtime.environment.local_api_enabled_configured:
local_api_ready = zotero_http.wait_for_endpoint(
runtime.environment.port,
"/api/",
timeout=wait_timeout,
headers={"Zotero-API-Version": zotero_http.LOCAL_API_VERSION},
ready_statuses=(200,),
)
return {
"action": "launch",
"pid": process.pid,
"connector_ready": connector_ready,
"local_api_ready": local_api_ready,
"wait_timeout": wait_timeout,
"executable": str(executable),
}
def ensure_live_api_enabled(profile_dir: Optional[str] = None) -> Optional[str]:
environment = zotero_paths.build_environment(explicit_profile_dir=profile_dir)
path = zotero_paths.ensure_local_api_enabled(environment.profile_dir)
return str(path) if path else None

View File

@@ -0,0 +1,175 @@
from __future__ import annotations
from typing import Any
from cli_anything.zotero.core.discovery import RuntimeContext
from cli_anything.zotero.utils import zotero_sqlite
def _require_offline(runtime: RuntimeContext) -> None:
if runtime.connector_available:
raise RuntimeError("Experimental SQLite write commands require Zotero to be closed")
def _session_library_id(session: dict[str, Any] | None = None) -> int | None:
session = session or {}
current_library = session.get("current_library")
if current_library is None:
return None
return zotero_sqlite.normalize_library_ref(current_library)
def _require_user_library(runtime: RuntimeContext, library_id: int) -> None:
library = zotero_sqlite.resolve_library(runtime.environment.sqlite_path, library_id)
if not library:
raise RuntimeError(f"Library not found: {library_id}")
if library["type"] != "user":
raise RuntimeError("Experimental SQLite write commands currently support only the local user library")
def _user_library_id(runtime: RuntimeContext, library_ref: str | None, session: dict[str, Any] | None = None) -> int:
session = session or {}
candidate = library_ref or session.get("current_library")
if candidate:
library_id = zotero_sqlite.normalize_library_ref(candidate)
else:
library_id = zotero_sqlite.default_library_id(runtime.environment.sqlite_path)
if library_id is None:
raise RuntimeError("No Zotero libraries found")
libraries = zotero_sqlite.fetch_libraries(runtime.environment.sqlite_path)
library = next((entry for entry in libraries if int(entry["libraryID"]) == int(library_id)), None)
if not library:
raise RuntimeError(f"Library not found: {library_id}")
if library["type"] != "user":
raise RuntimeError("Experimental SQLite write commands currently support only the local user library")
return int(library_id)
def create_collection(
runtime: RuntimeContext,
name: str,
*,
parent_ref: str | None = None,
library_ref: str | None = None,
session: dict[str, Any] | None = None,
) -> dict[str, Any]:
_require_offline(runtime)
parent = None
if parent_ref:
parent = zotero_sqlite.resolve_collection(
runtime.environment.sqlite_path,
parent_ref,
library_id=_session_library_id(session),
)
if not parent:
raise RuntimeError(f"Parent collection not found: {parent_ref}")
library_id = int(parent["libraryID"]) if parent else _user_library_id(runtime, library_ref, session=session)
if parent and library_ref is not None and library_id != _user_library_id(runtime, library_ref, session=session):
raise RuntimeError("Parent collection and explicit library do not match")
created = zotero_sqlite.create_collection_record(
runtime.environment.sqlite_path,
name=name,
library_id=library_id,
parent_collection_id=int(parent["collectionID"]) if parent else None,
)
created["action"] = "collection_create"
created["experimental"] = True
return created
def add_item_to_collection(
runtime: RuntimeContext,
item_ref: str,
collection_ref: str,
*,
session: dict[str, Any] | None = None,
) -> dict[str, Any]:
_require_offline(runtime)
library_id = _session_library_id(session)
item = zotero_sqlite.resolve_item(runtime.environment.sqlite_path, item_ref, library_id=library_id)
if not item:
raise RuntimeError(f"Item not found: {item_ref}")
if item.get("parentItemID") is not None:
raise RuntimeError("Only top-level items can be added directly to collections")
_require_user_library(runtime, int(item["libraryID"]))
collection = zotero_sqlite.resolve_collection(runtime.environment.sqlite_path, collection_ref, library_id=library_id)
if not collection:
raise RuntimeError(f"Collection not found: {collection_ref}")
if int(item["libraryID"]) != int(collection["libraryID"]):
raise RuntimeError("Item and collection must belong to the same library")
result = zotero_sqlite.add_item_to_collection_record(
runtime.environment.sqlite_path,
item_id=int(item["itemID"]),
collection_id=int(collection["collectionID"]),
)
result.update(
{
"action": "item_add_to_collection",
"experimental": True,
"itemKey": item["key"],
"collectionKey": collection["key"],
}
)
return result
def move_item_to_collection(
runtime: RuntimeContext,
item_ref: str,
collection_ref: str,
*,
from_refs: list[str] | tuple[str, ...] | None = None,
all_other_collections: bool = False,
session: dict[str, Any] | None = None,
) -> dict[str, Any]:
_require_offline(runtime)
if not from_refs and not all_other_collections:
raise RuntimeError("Provide `from_refs` or set `all_other_collections=True`")
library_id = _session_library_id(session)
item = zotero_sqlite.resolve_item(runtime.environment.sqlite_path, item_ref, library_id=library_id)
if not item:
raise RuntimeError(f"Item not found: {item_ref}")
if item.get("parentItemID") is not None:
raise RuntimeError("Only top-level items can be moved directly between collections")
_require_user_library(runtime, int(item["libraryID"]))
target = zotero_sqlite.resolve_collection(runtime.environment.sqlite_path, collection_ref, library_id=library_id)
if not target:
raise RuntimeError(f"Target collection not found: {collection_ref}")
if int(item["libraryID"]) != int(target["libraryID"]):
raise RuntimeError("Item and target collection must belong to the same library")
current_memberships = zotero_sqlite.fetch_item_collections(runtime.environment.sqlite_path, item["itemID"])
current_by_id = {int(collection["collectionID"]): collection for collection in current_memberships}
if all_other_collections:
source_collection_ids = [collection_id for collection_id in current_by_id if collection_id != int(target["collectionID"])]
else:
source_collection_ids = []
for ref in from_refs or []:
collection = zotero_sqlite.resolve_collection(runtime.environment.sqlite_path, ref, library_id=library_id)
if not collection:
raise RuntimeError(f"Source collection not found: {ref}")
source_collection_ids.append(int(collection["collectionID"]))
result = zotero_sqlite.move_item_between_collections_record(
runtime.environment.sqlite_path,
item_id=int(item["itemID"]),
target_collection_id=int(target["collectionID"]),
source_collection_ids=source_collection_ids,
)
result.update(
{
"action": "item_move_to_collection",
"experimental": True,
"itemKey": item["key"],
"targetCollectionKey": target["key"],
"sourceCollectionIDs": source_collection_ids,
}
)
return result

View File

@@ -0,0 +1,664 @@
from __future__ import annotations
import hashlib
import json
import re
import time
import urllib.error
import urllib.parse
import urllib.request
import uuid
from pathlib import Path
from typing import Any
from cli_anything.zotero.core.discovery import RuntimeContext
from cli_anything.zotero.utils import zotero_http, zotero_sqlite
_TREE_VIEW_ID_RE = re.compile(r"^[LC]\d+$")
_PDF_MAGIC = b"%PDF-"
_ATTACHMENT_RESULT_CREATED = "created"
_ATTACHMENT_RESULT_FAILED = "failed"
_ATTACHMENT_RESULT_SKIPPED = "skipped_duplicate"
def _require_connector(runtime: RuntimeContext) -> None:
if not runtime.connector_available:
raise RuntimeError(f"Zotero connector is not available: {runtime.connector_message}")
def _read_text_file(path: Path) -> str:
for encoding in ("utf-8", "utf-8-sig", "utf-16", "latin-1"):
try:
return path.read_text(encoding=encoding)
except UnicodeDecodeError:
continue
return path.read_text(errors="replace")
def _read_json_items(path: Path) -> list[dict[str, Any]]:
try:
payload = json.loads(path.read_text(encoding="utf-8"))
except json.JSONDecodeError as exc:
raise RuntimeError(f"Invalid JSON import file: {path}: {exc}") from exc
if isinstance(payload, dict):
payload = payload.get("items")
if not isinstance(payload, list):
raise RuntimeError("JSON import expects an array of official Zotero connector item objects")
normalized: list[dict[str, Any]] = []
for index, item in enumerate(payload, start=1):
if not isinstance(item, dict):
raise RuntimeError(f"JSON import item {index} is not an object")
copied = dict(item)
copied.setdefault("id", f"cli-anything-zotero-{index}")
normalized.append(copied)
return normalized
def _read_json_payload(path: Path, *, label: str) -> Any:
try:
return json.loads(path.read_text(encoding="utf-8"))
except json.JSONDecodeError as exc:
raise RuntimeError(f"Invalid JSON {label}: {path}: {exc}") from exc
def _default_user_library_target(runtime: RuntimeContext) -> str:
sqlite_path = runtime.environment.sqlite_path
if sqlite_path.exists():
library_id = zotero_sqlite.default_library_id(sqlite_path)
if library_id is not None:
return f"L{library_id}"
return "L1"
def _session_library_id(session: dict[str, Any] | None) -> int | None:
session = session or {}
current_library = session.get("current_library")
if current_library is None:
return None
return zotero_sqlite.normalize_library_ref(current_library)
def _resolve_target(runtime: RuntimeContext, collection_ref: str | None, session: dict[str, Any] | None = None) -> dict[str, Any]:
session = session or {}
session_library_id = _session_library_id(session)
if collection_ref:
if _TREE_VIEW_ID_RE.match(collection_ref):
kind = "library" if collection_ref.startswith("L") else "collection"
return {"treeViewID": collection_ref, "source": "explicit", "kind": kind}
collection = zotero_sqlite.resolve_collection(
runtime.environment.sqlite_path,
collection_ref,
library_id=session_library_id,
)
if not collection:
raise RuntimeError(f"Collection not found: {collection_ref}")
return {
"treeViewID": f"C{collection['collectionID']}",
"source": "explicit",
"kind": "collection",
"collectionID": collection["collectionID"],
"collectionKey": collection["key"],
"collectionName": collection["collectionName"],
"libraryID": collection["libraryID"],
}
current_collection = session.get("current_collection")
if current_collection:
if _TREE_VIEW_ID_RE.match(str(current_collection)):
kind = "library" if str(current_collection).startswith("L") else "collection"
return {"treeViewID": str(current_collection), "source": "session", "kind": kind}
collection = zotero_sqlite.resolve_collection(
runtime.environment.sqlite_path,
current_collection,
library_id=session_library_id,
)
if collection:
return {
"treeViewID": f"C{collection['collectionID']}",
"source": "session",
"kind": "collection",
"collectionID": collection["collectionID"],
"collectionKey": collection["key"],
"collectionName": collection["collectionName"],
"libraryID": collection["libraryID"],
}
if runtime.connector_available:
selected = zotero_http.get_selected_collection(runtime.environment.port)
if selected.get("id") is not None:
return {
"treeViewID": f"C{selected['id']}",
"source": "selected",
"kind": "collection",
"collectionID": selected["id"],
"collectionName": selected.get("name"),
"libraryID": selected.get("libraryID"),
"libraryName": selected.get("libraryName"),
}
return {
"treeViewID": f"L{selected['libraryID']}",
"source": "selected",
"kind": "library",
"libraryID": selected.get("libraryID"),
"libraryName": selected.get("libraryName"),
}
return {
"treeViewID": _default_user_library_target(runtime),
"source": "user_library",
"kind": "library",
}
def _normalize_tags(tags: list[str] | tuple[str, ...]) -> list[str]:
return [tag.strip() for tag in tags if tag and tag.strip()]
def _session_id(prefix: str) -> str:
return f"{prefix}-{uuid.uuid4().hex}"
def _normalize_attachment_int(value: Any, *, name: str, minimum: int) -> int:
try:
normalized = int(value)
except (TypeError, ValueError) as exc:
raise RuntimeError(f"Attachment `{name}` must be an integer") from exc
if normalized < minimum:
comparator = "greater than or equal to" if minimum == 0 else f"at least {minimum}"
raise RuntimeError(f"Attachment `{name}` must be {comparator}")
return normalized
def _normalize_attachment_descriptor(
raw: Any,
*,
index_label: str,
attachment_label: str,
default_delay_ms: int,
default_timeout: int,
) -> dict[str, Any]:
if not isinstance(raw, dict):
raise RuntimeError(f"{index_label} {attachment_label} must be an object")
has_path = "path" in raw and raw.get("path") not in (None, "")
has_url = "url" in raw and raw.get("url") not in (None, "")
if has_path == has_url:
raise RuntimeError(f"{index_label} {attachment_label} must include exactly one of `path` or `url`")
title = str(raw.get("title") or "PDF").strip() or "PDF"
delay_ms = _normalize_attachment_int(raw.get("delay_ms", default_delay_ms), name="delay_ms", minimum=0)
timeout = _normalize_attachment_int(raw.get("timeout", default_timeout), name="timeout", minimum=1)
if has_path:
source = str(raw["path"]).strip()
if not source:
raise RuntimeError(f"{index_label} {attachment_label} path must not be empty")
return {
"source_type": "file",
"source": source,
"title": title,
"delay_ms": delay_ms,
"timeout": timeout,
}
source = str(raw["url"]).strip()
if not source:
raise RuntimeError(f"{index_label} {attachment_label} url must not be empty")
return {
"source_type": "url",
"source": source,
"title": title,
"delay_ms": delay_ms,
"timeout": timeout,
}
def _extract_inline_attachment_plans(
items: list[dict[str, Any]],
*,
default_delay_ms: int,
default_timeout: int,
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
stripped_items: list[dict[str, Any]] = []
plans: list[dict[str, Any]] = []
for index, item in enumerate(items):
copied = dict(item)
raw_attachments = copied.pop("attachments", [])
if raw_attachments in (None, []):
stripped_items.append(copied)
continue
if not isinstance(raw_attachments, list):
raise RuntimeError(f"JSON import item {index + 1} attachments must be an array")
normalized = [
_normalize_attachment_descriptor(
descriptor,
index_label=f"JSON import item {index + 1}",
attachment_label=f"attachment {attachment_index + 1}",
default_delay_ms=default_delay_ms,
default_timeout=default_timeout,
)
for attachment_index, descriptor in enumerate(raw_attachments)
]
plans.append({"index": index, "attachments": normalized})
stripped_items.append(copied)
return stripped_items, plans
def _read_attachment_manifest(
path: Path,
*,
default_delay_ms: int,
default_timeout: int,
) -> list[dict[str, Any]]:
payload = _read_json_payload(path, label="attachment manifest")
if not isinstance(payload, list):
raise RuntimeError("Attachment manifest expects an array of {index, attachments} objects")
manifest: list[dict[str, Any]] = []
seen_indexes: set[int] = set()
for entry_index, entry in enumerate(payload, start=1):
label = f"manifest entry {entry_index}"
if not isinstance(entry, dict):
raise RuntimeError(f"{label} must be an object")
if "index" not in entry:
raise RuntimeError(f"{label} is missing required `index`")
index = _normalize_attachment_int(entry["index"], name="index", minimum=0)
if index in seen_indexes:
raise RuntimeError(f"{label} reuses import index {index}")
seen_indexes.add(index)
attachments = entry.get("attachments")
if not isinstance(attachments, list):
raise RuntimeError(f"{label} attachments must be an array")
normalized = [
_normalize_attachment_descriptor(
descriptor,
index_label=label,
attachment_label=f"attachment {attachment_index + 1}",
default_delay_ms=default_delay_ms,
default_timeout=default_timeout,
)
for attachment_index, descriptor in enumerate(attachments)
]
expected_title = entry.get("expected_title")
if expected_title is not None and not isinstance(expected_title, str):
raise RuntimeError(f"{label} expected_title must be a string")
manifest.append(
{
"index": index,
"expected_title": expected_title,
"attachments": normalized,
}
)
return manifest
def _item_title(item: dict[str, Any]) -> str | None:
for field in ("title", "bookTitle", "publicationTitle"):
value = item.get(field)
if value:
return str(value)
return None
def _normalize_url_for_dedupe(url: str) -> str:
parsed = urllib.parse.urlsplit(url.strip())
normalized_path = parsed.path or "/"
return urllib.parse.urlunsplit((parsed.scheme.lower(), parsed.netloc.lower(), normalized_path, parsed.query, ""))
def _attachment_result(
*,
item_index: int,
parent_connector_id: Any,
descriptor: dict[str, Any],
status: str,
error: str | None = None,
) -> dict[str, Any]:
payload = {
"item_index": item_index,
"parent_connector_id": parent_connector_id,
"source_type": descriptor["source_type"],
"source": descriptor["source"],
"title": descriptor["title"],
"status": status,
}
if error is not None:
payload["error"] = error
return payload
def _attachment_summary(results: list[dict[str, Any]]) -> dict[str, Any]:
return {
"planned_count": len(results),
"created_count": sum(1 for result in results if result["status"] == _ATTACHMENT_RESULT_CREATED),
"failed_count": sum(1 for result in results if result["status"] == _ATTACHMENT_RESULT_FAILED),
"skipped_count": sum(1 for result in results if result["status"] == _ATTACHMENT_RESULT_SKIPPED),
}
def _ensure_pdf_bytes(content: bytes, *, source: str) -> None:
if not content.startswith(_PDF_MAGIC):
raise RuntimeError(f"Attachment source is not a PDF: {source}")
def _read_local_pdf(path_text: str) -> tuple[bytes, str]:
path = Path(path_text).expanduser()
if not path.exists():
raise FileNotFoundError(f"Attachment file not found: {path}")
resolved = path.resolve()
content = resolved.read_bytes()
_ensure_pdf_bytes(content, source=str(resolved))
return content, resolved.as_uri()
def _download_remote_pdf(url: str, *, delay_ms: int, timeout: int) -> bytes:
if delay_ms:
time.sleep(delay_ms / 1000)
request = urllib.request.Request(url, headers={"Accept": "application/pdf,application/octet-stream;q=0.9,*/*;q=0.1"})
try:
with urllib.request.urlopen(request, timeout=timeout) as response:
status = getattr(response, "status", response.getcode())
if int(status) != 200:
raise RuntimeError(f"Attachment download returned HTTP {status}: {url}")
content = response.read()
except urllib.error.HTTPError as exc:
raise RuntimeError(f"Attachment download returned HTTP {exc.code}: {url}") from exc
except urllib.error.URLError as exc:
raise RuntimeError(f"Attachment download failed for {url}: {exc.reason}") from exc
_ensure_pdf_bytes(content, source=url)
return content
def _perform_attachment_upload(
runtime: RuntimeContext,
*,
session_id: str,
connector_items: list[dict[str, Any]],
plans: list[dict[str, Any]],
) -> tuple[dict[str, Any], list[dict[str, Any]]]:
results: list[dict[str, Any]] = []
seen_by_item: dict[str, dict[str, set[str]]] = {}
for plan in plans:
item_index = int(plan["index"])
attachments = list(plan.get("attachments") or [])
imported_item = connector_items[item_index] if 0 <= item_index < len(connector_items) else None
expected_title = plan.get("expected_title")
if imported_item is None:
message = f"Import returned no item at index {item_index}"
results.extend(
_attachment_result(
item_index=item_index,
parent_connector_id=None,
descriptor=descriptor,
status=_ATTACHMENT_RESULT_FAILED,
error=message,
)
for descriptor in attachments
)
continue
imported_title = _item_title(imported_item)
if expected_title is not None and imported_title != expected_title:
message = (
f"Imported item title mismatch at index {item_index}: "
f"expected {expected_title!r}, got {imported_title!r}"
)
results.extend(
_attachment_result(
item_index=item_index,
parent_connector_id=imported_item.get("id"),
descriptor=descriptor,
status=_ATTACHMENT_RESULT_FAILED,
error=message,
)
for descriptor in attachments
)
continue
parent_connector_id = imported_item.get("id")
if not parent_connector_id:
message = f"Imported item at index {item_index} did not include a connector id"
results.extend(
_attachment_result(
item_index=item_index,
parent_connector_id=None,
descriptor=descriptor,
status=_ATTACHMENT_RESULT_FAILED,
error=message,
)
for descriptor in attachments
)
continue
dedupe_state = seen_by_item.setdefault(
str(parent_connector_id),
{"paths": set(), "urls": set(), "hashes": set()},
)
for descriptor in attachments:
try:
if descriptor["source_type"] == "file":
canonical_path = str(Path(descriptor["source"]).expanduser().resolve())
if canonical_path in dedupe_state["paths"]:
results.append(
_attachment_result(
item_index=item_index,
parent_connector_id=parent_connector_id,
descriptor=descriptor,
status=_ATTACHMENT_RESULT_SKIPPED,
)
)
continue
content, metadata_url = _read_local_pdf(descriptor["source"])
else:
normalized_url = _normalize_url_for_dedupe(descriptor["source"])
if normalized_url in dedupe_state["urls"]:
results.append(
_attachment_result(
item_index=item_index,
parent_connector_id=parent_connector_id,
descriptor=descriptor,
status=_ATTACHMENT_RESULT_SKIPPED,
)
)
continue
content = _download_remote_pdf(
descriptor["source"],
delay_ms=int(descriptor["delay_ms"]),
timeout=int(descriptor["timeout"]),
)
metadata_url = descriptor["source"]
content_hash = hashlib.sha256(content).hexdigest()
if content_hash in dedupe_state["hashes"]:
results.append(
_attachment_result(
item_index=item_index,
parent_connector_id=parent_connector_id,
descriptor=descriptor,
status=_ATTACHMENT_RESULT_SKIPPED,
)
)
continue
zotero_http.connector_save_attachment(
runtime.environment.port,
session_id=session_id,
parent_item_id=parent_connector_id,
title=descriptor["title"],
url=metadata_url,
content=content,
timeout=int(descriptor["timeout"]),
)
dedupe_state["hashes"].add(content_hash)
if descriptor["source_type"] == "file":
dedupe_state["paths"].add(canonical_path)
else:
dedupe_state["urls"].add(normalized_url)
results.append(
_attachment_result(
item_index=item_index,
parent_connector_id=parent_connector_id,
descriptor=descriptor,
status=_ATTACHMENT_RESULT_CREATED,
)
)
except Exception as exc:
results.append(
_attachment_result(
item_index=item_index,
parent_connector_id=parent_connector_id,
descriptor=descriptor,
status=_ATTACHMENT_RESULT_FAILED,
error=str(exc),
)
)
return _attachment_summary(results), results
def enable_local_api(
runtime: RuntimeContext,
*,
launch: bool = False,
wait_timeout: int = 30,
) -> dict[str, Any]:
profile_dir = runtime.environment.profile_dir
if profile_dir is None:
raise RuntimeError("Active Zotero profile could not be resolved")
before = runtime.environment.local_api_enabled_configured
written_path = runtime.environment.profile_dir / "user.js"
from cli_anything.zotero.utils import zotero_paths # local import to avoid cycle
zotero_paths.ensure_local_api_enabled(profile_dir)
payload = {
"profile_dir": str(profile_dir),
"user_js_path": str(written_path),
"already_enabled": before,
"enabled": True,
"launched": False,
"connector_ready": runtime.connector_available,
"local_api_ready": runtime.local_api_available,
}
if launch:
from cli_anything.zotero.core import discovery # local import to avoid cycle
refreshed = discovery.build_runtime_context(
backend=runtime.backend,
data_dir=str(runtime.environment.data_dir),
profile_dir=str(profile_dir),
executable=str(runtime.environment.executable) if runtime.environment.executable else None,
)
launch_payload = discovery.launch_zotero(refreshed, wait_timeout=wait_timeout)
payload.update(
{
"launched": True,
"launch": launch_payload,
"connector_ready": launch_payload["connector_ready"],
"local_api_ready": launch_payload["local_api_ready"],
}
)
return payload
def import_file(
runtime: RuntimeContext,
path: str | Path,
*,
collection_ref: str | None = None,
tags: list[str] | tuple[str, ...] = (),
session: dict[str, Any] | None = None,
attachments_manifest: str | Path | None = None,
attachment_delay_ms: int = 0,
attachment_timeout: int = 60,
) -> dict[str, Any]:
_require_connector(runtime)
source_path = Path(path).expanduser()
if not source_path.exists():
raise FileNotFoundError(f"Import file not found: {source_path}")
content = _read_text_file(source_path)
manifest_path = Path(attachments_manifest).expanduser() if attachments_manifest is not None else None
plans = (
_read_attachment_manifest(
manifest_path,
default_delay_ms=attachment_delay_ms,
default_timeout=attachment_timeout,
)
if manifest_path is not None
else []
)
session_id = _session_id("import-file")
imported = zotero_http.connector_import_text(runtime.environment.port, content, session_id=session_id)
target = _resolve_target(runtime, collection_ref, session=session)
normalized_tags = _normalize_tags(list(tags))
zotero_http.connector_update_session(
runtime.environment.port,
session_id=session_id,
target=target["treeViewID"],
tags=normalized_tags,
)
attachment_summary, attachment_results = _perform_attachment_upload(
runtime,
session_id=session_id,
connector_items=imported,
plans=plans,
)
return {
"action": "import_file",
"path": str(source_path),
"status": "partial_success" if attachment_summary["failed_count"] else "success",
"sessionID": session_id,
"target": target,
"tags": normalized_tags,
"imported_count": len(imported),
"items": imported,
"attachment_summary": attachment_summary,
"attachment_results": attachment_results,
}
def import_json(
runtime: RuntimeContext,
path: str | Path,
*,
collection_ref: str | None = None,
tags: list[str] | tuple[str, ...] = (),
session: dict[str, Any] | None = None,
attachment_delay_ms: int = 0,
attachment_timeout: int = 60,
) -> dict[str, Any]:
_require_connector(runtime)
source_path = Path(path).expanduser()
if not source_path.exists():
raise FileNotFoundError(f"Import JSON file not found: {source_path}")
items = _read_json_items(source_path)
items, plans = _extract_inline_attachment_plans(
items,
default_delay_ms=attachment_delay_ms,
default_timeout=attachment_timeout,
)
session_id = _session_id("import-json")
zotero_http.connector_save_items(runtime.environment.port, items, session_id=session_id)
target = _resolve_target(runtime, collection_ref, session=session)
normalized_tags = _normalize_tags(list(tags))
zotero_http.connector_update_session(
runtime.environment.port,
session_id=session_id,
target=target["treeViewID"],
tags=normalized_tags,
)
attachment_summary, attachment_results = _perform_attachment_upload(
runtime,
session_id=session_id,
connector_items=items,
plans=plans,
)
return {
"action": "import_json",
"path": str(source_path),
"status": "partial_success" if attachment_summary["failed_count"] else "success",
"sessionID": session_id,
"target": target,
"tags": normalized_tags,
"submitted_count": len(items),
"items": [
{
"id": item.get("id"),
"itemType": item.get("itemType"),
"title": item.get("title") or item.get("bookTitle") or item.get("publicationTitle"),
}
for item in items
],
"attachment_summary": attachment_summary,
"attachment_results": attachment_results,
}

View File

@@ -0,0 +1,170 @@
from __future__ import annotations
import html
import re
import uuid
from pathlib import Path
from typing import Any
from cli_anything.zotero.core.catalog import get_item
from cli_anything.zotero.core.discovery import RuntimeContext
from cli_anything.zotero.utils import zotero_http, zotero_sqlite
def _require_connector(runtime: RuntimeContext) -> None:
if not runtime.connector_available:
raise RuntimeError(f"Zotero connector is not available: {runtime.connector_message}")
def get_note(runtime: RuntimeContext, ref: str | int | None, session: dict[str, Any] | None = None) -> dict[str, Any]:
if ref is None:
raise RuntimeError("Note reference required")
session = session or {}
library_id = session.get("current_library")
note = zotero_sqlite.resolve_item(
runtime.environment.sqlite_path,
ref,
library_id=zotero_sqlite.normalize_library_ref(library_id) if library_id is not None else None,
)
if not note:
raise RuntimeError(f"Note not found: {ref}")
if note["typeName"] != "note":
raise RuntimeError(f"Item is not a note: {ref}")
return note
def get_item_notes(runtime: RuntimeContext, ref: str | int | None, session: dict[str, Any] | None = None) -> list[dict[str, Any]]:
parent_item = get_item(runtime, ref, session=session)
return zotero_sqlite.fetch_item_notes(runtime.environment.sqlite_path, parent_item["itemID"])
def _html_paragraphs(text: str) -> str:
paragraphs = [segment.strip() for segment in text.replace("\r\n", "\n").replace("\r", "\n").split("\n\n") if segment.strip()]
if not paragraphs:
paragraphs = [text.strip()]
rendered = []
for paragraph in paragraphs:
escaped = html.escape(paragraph).replace("\n", "<br/>")
rendered.append(f"<p>{escaped}</p>")
return "".join(rendered)
def _simple_markdown_to_safe_html(text: str) -> str:
lines = text.replace("\r\n", "\n").replace("\r", "\n").split("\n")
rendered: list[str] = []
in_list = False
paragraph: list[str] = []
def flush_paragraph() -> None:
nonlocal paragraph
if not paragraph:
return
rendered.append(f"<p>{_render_markdown_inline(' '.join(paragraph))}</p>")
paragraph = []
def flush_list() -> None:
nonlocal in_list
if in_list:
rendered.append("</ul>")
in_list = False
for raw_line in lines:
line = raw_line.rstrip()
if not line.strip():
flush_paragraph()
flush_list()
continue
if line.startswith(("- ", "* ")):
flush_paragraph()
if not in_list:
rendered.append("<ul>")
in_list = True
rendered.append(f"<li>{_render_markdown_inline(line[2:].strip())}</li>")
continue
match = re.match(r"^(#{1,6})\s+(.*)$", line)
if match:
flush_paragraph()
flush_list()
level = len(match.group(1))
rendered.append(f"<h{level}>{_render_markdown_inline(match.group(2).strip())}</h{level}>")
continue
flush_list()
paragraph.append(line.strip())
flush_paragraph()
flush_list()
return "".join(rendered)
def _render_markdown_inline(text: str) -> str:
escaped = html.escape(text)
escaped = re.sub(r"`([^`]+)`", r"<code>\1</code>", escaped)
escaped = re.sub(r"\*\*([^*]+)\*\*", r"<strong>\1</strong>", escaped)
escaped = re.sub(r"\*([^*]+)\*", r"<em>\1</em>", escaped)
return escaped
def _normalize_note_html(content: str, fmt: str) -> str:
fmt = fmt.lower()
if fmt == "html":
return content
if fmt == "markdown":
return _simple_markdown_to_safe_html(content)
if fmt == "text":
return _html_paragraphs(content)
raise RuntimeError(f"Unsupported note format: {fmt}")
def add_note(
runtime: RuntimeContext,
item_ref: str | int,
*,
text: str | None = None,
file_path: str | Path | None = None,
fmt: str = "text",
session: dict[str, Any] | None = None,
) -> dict[str, Any]:
_require_connector(runtime)
if (text is None and file_path is None) or (text is not None and file_path is not None):
raise RuntimeError("Provide exactly one of `text` or `file_path`")
parent_item = get_item(runtime, item_ref, session=session)
if parent_item["typeName"] in {"note", "attachment", "annotation"}:
raise RuntimeError("Child notes can only be attached to top-level bibliographic items")
selected = zotero_http.get_selected_collection(runtime.environment.port)
selected_library_id = selected.get("libraryID")
if selected_library_id is not None and int(selected_library_id) != int(parent_item["libraryID"]):
raise RuntimeError(
"note add requires Zotero to have the same library selected as the parent item. "
"Switch the Zotero UI to that library and retry."
)
if file_path is not None:
content = Path(file_path).expanduser().read_text(encoding="utf-8")
else:
content = text or ""
note_html = _normalize_note_html(content, fmt)
session_id = f"note-add-{uuid.uuid4().hex}"
zotero_http.connector_save_items(
runtime.environment.port,
[
{
"id": session_id,
"itemType": "note",
"note": note_html,
"parentItem": parent_item["key"],
}
],
session_id=session_id,
)
return {
"action": "note_add",
"sessionID": session_id,
"parentItemKey": parent_item["key"],
"parentItemID": parent_item["itemID"],
"format": fmt,
"notePreview": zotero_sqlite.note_preview(note_html),
"selectedLibraryID": selected_library_id,
}

View File

@@ -0,0 +1,98 @@
from __future__ import annotations
from typing import Any
from cli_anything.zotero.core.catalog import get_item, local_api_scope
from cli_anything.zotero.core.discovery import RuntimeContext
from cli_anything.zotero.utils import zotero_http
SUPPORTED_EXPORT_FORMATS = ("ris", "bibtex", "biblatex", "csljson", "csv", "mods", "refer")
def _require_local_api(runtime: RuntimeContext) -> None:
if not runtime.local_api_available:
raise RuntimeError(
"Zotero Local API is not available. Start Zotero and enable "
"`extensions.zotero.httpServer.localAPI.enabled` first."
)
def _resolve_item(runtime: RuntimeContext, ref: str | int | None, session: dict[str, Any] | None = None) -> dict[str, Any]:
item = get_item(runtime, ref, session=session)
return item
def export_item(runtime: RuntimeContext, ref: str | int | None, fmt: str, session: dict[str, Any] | None = None) -> dict[str, Any]:
_require_local_api(runtime)
if fmt not in SUPPORTED_EXPORT_FORMATS:
raise RuntimeError(f"Unsupported export format: {fmt}")
item = _resolve_item(runtime, ref, session=session)
key = str(item["key"])
scope = local_api_scope(runtime, int(item["libraryID"]))
body = zotero_http.local_api_get_text(runtime.environment.port, f"{scope}/items/{key}", params={"format": fmt})
return {"itemKey": key, "libraryID": int(item["libraryID"]), "format": fmt, "content": body}
def citation_item(
runtime: RuntimeContext,
ref: str | int | None,
*,
style: str | None = None,
locale: str | None = None,
linkwrap: bool = False,
session: dict[str, Any] | None = None,
) -> dict[str, Any]:
_require_local_api(runtime)
item = _resolve_item(runtime, ref, session=session)
key = str(item["key"])
params: dict[str, Any] = {"format": "json", "include": "citation"}
if style:
params["style"] = style
if locale:
params["locale"] = locale
if linkwrap:
params["linkwrap"] = "1"
scope = local_api_scope(runtime, int(item["libraryID"]))
payload = zotero_http.local_api_get_json(runtime.environment.port, f"{scope}/items/{key}", params=params)
citation = payload.get("citation") if isinstance(payload, dict) else (payload[0].get("citation") if payload else None)
return {
"itemKey": key,
"libraryID": int(item["libraryID"]),
"style": style,
"locale": locale,
"linkwrap": linkwrap,
"citation": citation,
}
def bibliography_item(
runtime: RuntimeContext,
ref: str | int | None,
*,
style: str | None = None,
locale: str | None = None,
linkwrap: bool = False,
session: dict[str, Any] | None = None,
) -> dict[str, Any]:
_require_local_api(runtime)
item = _resolve_item(runtime, ref, session=session)
key = str(item["key"])
params: dict[str, Any] = {"format": "json", "include": "bib"}
if style:
params["style"] = style
if locale:
params["locale"] = locale
if linkwrap:
params["linkwrap"] = "1"
scope = local_api_scope(runtime, int(item["libraryID"]))
payload = zotero_http.local_api_get_json(runtime.environment.port, f"{scope}/items/{key}", params=params)
bibliography = payload.get("bib") if isinstance(payload, dict) else (payload[0].get("bib") if payload else None)
return {
"itemKey": key,
"libraryID": int(item["libraryID"]),
"style": style,
"locale": locale,
"linkwrap": linkwrap,
"bibliography": bibliography,
}

View File

@@ -0,0 +1,111 @@
from __future__ import annotations
import json
import os
from pathlib import Path
from typing import Any
COMMAND_HISTORY_LIMIT = 50
STATE_DIR_ENV = "CLI_ANYTHING_ZOTERO_STATE_DIR"
APP_NAME = "cli-anything-zotero"
def session_state_dir() -> Path:
override = os.environ.get(STATE_DIR_ENV, "").strip()
if override:
return Path(override).expanduser()
return Path.home() / ".config" / APP_NAME
def session_state_path() -> Path:
return session_state_dir() / "session.json"
def default_session_state() -> dict[str, Any]:
return {"current_library": None, "current_collection": None, "current_item": None, "command_history": []}
def load_session_state() -> dict[str, Any]:
path = session_state_path()
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (FileNotFoundError, json.JSONDecodeError):
return default_session_state()
history = [item for item in data.get("command_history", []) if isinstance(item, str)]
return {
"current_library": data.get("current_library"),
"current_collection": data.get("current_collection"),
"current_item": data.get("current_item"),
"command_history": history[-COMMAND_HISTORY_LIMIT:],
}
def locked_save_json(path: Path, data: dict[str, Any]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
try:
handle = open(path, "r+", encoding="utf-8")
except FileNotFoundError:
handle = open(path, "w", encoding="utf-8")
with handle:
locked = False
try:
import fcntl
fcntl.flock(handle.fileno(), fcntl.LOCK_EX)
locked = True
except (ImportError, OSError):
pass
try:
handle.seek(0)
handle.truncate()
json.dump(data, handle, ensure_ascii=False, indent=2)
handle.flush()
finally:
if locked:
fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
def save_session_state(session: dict[str, Any]) -> None:
locked_save_json(
session_state_path(),
{
"current_library": session.get("current_library"),
"current_collection": session.get("current_collection"),
"current_item": session.get("current_item"),
"command_history": list(session.get("command_history", []))[-COMMAND_HISTORY_LIMIT:],
},
)
def append_command_history(command_line: str) -> None:
command_line = command_line.strip()
if not command_line:
return
session = load_session_state()
history = list(session.get("command_history", []))
history.append(command_line)
session["command_history"] = history[-COMMAND_HISTORY_LIMIT:]
save_session_state(session)
def build_session_payload(session: dict[str, Any]) -> dict[str, Any]:
history = list(session.get("command_history", []))
return {
"current_library": session.get("current_library"),
"current_collection": session.get("current_collection"),
"current_item": session.get("current_item"),
"state_path": str(session_state_path()),
"history_count": len(history),
}
def expand_repl_aliases_with_state(argv: list[str], session: dict[str, Any]) -> list[str]:
aliases = {"@library": session.get("current_library"), "@collection": session.get("current_collection"), "@item": session.get("current_item")}
expanded: list[str] = []
for token in argv:
if token in aliases and aliases[token]:
expanded.append(str(aliases[token]))
else:
expanded.append(token)
return expanded

View File

@@ -0,0 +1,243 @@
---
name: >-
cli-anything-zotero
description: >-
CLI harness for Zotero.
---
# cli-anything-zotero
`cli-anything-zotero` is an agent-native CLI for Zotero desktop. It does not reimplement Zotero. Instead, it composes Zotero's real local surfaces:
## Installation
```bash
pip install -e .
```
## Entry Points
```bash
cli-anything-zotero
python -m cli_anything.zotero
```
## Command Groups
### App
Application and runtime inspection commands.
| Command | Description |
|---------|-------------|
| `status` | Execute `status`. |
| `version` | Execute `version`. |
| `launch` | Execute `launch`. |
| `enable-local-api` | Execute `enable-local-api`. |
| `ping` | Execute `ping`. |
### Collection
Collection inspection and selection commands.
| Command | Description |
|---------|-------------|
| `list` | Execute `list`. |
| `find` | Execute `find`. |
| `tree` | Execute `tree`. |
| `get` | Execute `get`. |
| `items` | Execute `items`. |
| `use-selected` | Execute `use-selected`. |
### Item
Item inspection and rendering commands.
| Command | Description |
|---------|-------------|
| `list` | Execute `list`. |
| `find` | Execute `find`. |
| `get` | Execute `get`. |
| `children` | Execute `children`. |
| `notes` | Execute `notes`. |
| `attachments` | Execute `attachments`. |
| `file` | Execute `file`. |
| `citation` | Execute `citation`. |
| `bibliography` | Execute `bibliography`. |
| `context` | Execute `context`. |
| `analyze` | Execute `analyze`. |
| `add-to-collection` | Execute `add-to-collection`. |
| `move-to-collection` | Execute `move-to-collection`. |
### Search
Saved-search inspection commands.
| Command | Description |
|---------|-------------|
| `list` | Execute `list`. |
| `get` | Execute `get`. |
| `items` | Execute `items`. |
### Tag
Tag inspection commands.
| Command | Description |
|---------|-------------|
| `list` | Execute `list`. |
| `items` | Execute `items`. |
### Style
Installed CSL style inspection commands.
| Command | Description |
|---------|-------------|
| `list` | Execute `list`. |
### Import
Official Zotero import and write commands.
| Command | Description |
|---------|-------------|
| `file` | Execute `file`. |
| `json` | Execute `json`. |
### Note
Read and add child notes.
| Command | Description |
|---------|-------------|
| `get` | Execute `get`. |
### Session
Session and REPL context commands.
| Command | Description |
|---------|-------------|
| `status` | Execute `status`. |
| `use-library` | Execute `use-library`. |
| `use-collection` | Execute `use-collection`. |
| `use-item` | Execute `use-item`. |
| `use-selected` | Execute `use-selected`. |
| `clear-library` | Execute `clear-library`. |
| `clear-collection` | Execute `clear-collection`. |
| `clear-item` | Execute `clear-item`. |
| `history` | Execute `history`. |
## Examples
### Runtime Status
Inspect Zotero paths and backend availability.
```bash
cli-anything-zotero app status --json
```
### Read Selected Collection
Persist the collection selected in the Zotero GUI.
```bash
cli-anything-zotero collection use-selected --json
```
### Render Citation
Render a citation using Zotero's Local API.
```bash
cli-anything-zotero item citation <item-key> --style apa --locale en-US --json
```
### Add Child Note
Create a child note under an existing Zotero item.
```bash
cli-anything-zotero note add <item-key> --text "Key takeaway" --json
```
### Build LLM Context
Assemble structured context for downstream model analysis.
```bash
cli-anything-zotero item context <item-key> --include-notes --include-links --json
```
## Version
0.1.0

View File

@@ -0,0 +1,307 @@
# Zotero CLI Harness - Test Documentation
## Test Inventory
| File | Focus | Coverage |
|---|---|---|
| `test_core.py` | Path discovery, SQLite inspection, library-aware resolution, note/context/analyze helpers, experimental SQLite writes | Unit + mocked HTTP |
| `test_cli_entrypoint.py` | CLI help, REPL entry, subprocess behavior, fake connector/Local API/OpenAI flows, group-library routing | Installed/subprocess behavior |
| `test_agent_harness.py` | Packaging, harness structure, skill generation | Packaging integrity |
| `test_full_e2e.py` | Real Zotero runtime, safe read workflows, opt-in write flows | Live validation |
## Unit Test Plan
### Path Discovery
- resolve profile root from explicit path and environment
- parse `profiles.ini`
- parse `prefs.js` and `user.js`
- resolve custom `extensions.zotero.dataDir`
- fall back to `~/Zotero`
- resolve executable and version
- detect Local API pref state
### SQLite Inspection
- libraries
- collections and collection tree
- title search and collection search
- items, notes, attachments, and annotations
- item fields, creators, and tags
- saved searches and conditions
- tag-linked item lookup
- attachment real-path resolution
- duplicate key resolution across user and group libraries
### Context, Notes, and Analysis
- `item find` Local API preference and SQLite fallback
- group-library Local API scope selection
- `item notes` and `note get`
- `note add` payload construction for text and markdown
- `item context` aggregation of links, notes, and exports
- `item analyze` OpenAI request path and missing API key errors
### Experimental SQLite Writes
- `collection create`
- `item add-to-collection`
- `item move-to-collection`
- backup creation
- transaction commit/rollback behavior
- Zotero-running guard
- local user-library-only restriction
### Import Core
- `app enable-local-api` idempotency
- connector-required guard for write commands
- `import file` raw-text handoff
- `import json` parsing and validation
- inline `attachments` extraction and stripping for `import json`
- `--attachments-manifest` parsing and index/title validation for `import file`
- local-file and URL PDF validation, including magic-byte acceptance when `Content-Type` is wrong
- partial-success attachment reporting and non-zero exit semantics
- duplicate attachment skipping within the same import request
- session-target fallback chain
- repeatable tag propagation to `updateSession`
## CLI / Subprocess Plan
- root `--help`
- default REPL entry
- REPL help text
- `app status --json`
- `app enable-local-api --json`
- `collection list/find --json`
- `item get/find/notes/context --json`
- `note get/add --json`
- `item analyze --json` against a fake OpenAI-compatible endpoint
- group-library `item find/export/citation/bibliography/search items` routing
- `session use-library L<id>` normalization
- force-installed subprocess resolution via `CLI_ANYTHING_FORCE_INSTALLED=1`
- `import json` with inline local and URL PDF attachments
- `import file` with `--attachments-manifest`
- partial-success import attachment failures returning non-zero
- experimental collection write commands against an isolated SQLite copy
## Live E2E Plan
### Non-Mutating
- `app ping`
- `collection use-selected`
- `collection tree/get/items`
- `item list/get/find/attachments/file`
- `item notes`
- `note get`
- `tag list/items`
- `search list/get/items` when saved searches exist
- `session use-collection/use-item`
- `style list`
- `item context`
- `item citation`
- `item bibliography`
- `item export --format ris|bibtex|csljson`
### Mutating
- `import file`
- `import json`
- `import json` with inline local PDF attachment
- `note add`
These write tests are opt-in only and require:
- `CLI_ANYTHING_ZOTERO_ENABLE_WRITE_E2E=1`
- `CLI_ANYTHING_ZOTERO_IMPORT_TARGET=<collection-key-or-id>`
Experimental SQLite write commands are intentionally **not** executed against the
real Zotero library. They are tested only against isolated SQLite copies.
## Test Results
Validation completed on 2026-03-27.
### Machine / Runtime
- OS: Windows
- Python: 3.13.5
- Zotero executable: `C:\Program Files\Zotero\zotero.exe`
- Zotero version: `7.0.32`
- Active profile: `C:\Users\Lenovo\AppData\Roaming\Zotero\Zotero\Profiles\38ay0ldk.default`
- Active data dir: `D:\Study\科研\论文`
- HTTP port: `23119`
- Local API state during validation: enabled and available
### Product Validation Commands
```powershell
py -m pip install -e .
py -m pytest cli_anything/zotero/tests/test_core.py -v
py -m pytest cli_anything/zotero/tests/test_cli_entrypoint.py -v
py -m pytest cli_anything/zotero/tests/test_agent_harness.py -v
py -m pytest cli_anything/zotero/tests/test_full_e2e.py -v -s
py -m pytest cli_anything/zotero/tests/ -v --tb=no
$env:CLI_ANYTHING_FORCE_INSTALLED=1
py -m pytest cli_anything/zotero/tests/test_cli_entrypoint.py -v
py -m pytest cli_anything/zotero/tests/test_full_e2e.py -v -s
cli-anything-zotero --json app status
cli-anything-zotero --json collection find "具身"
cli-anything-zotero --json item find "embodied intelligence" --limit 5
cli-anything-zotero --json item context PB98EI9N --include-links
cli-anything-zotero --json note get <note-key>
```
### Real Zotero Results
- `app status --json` reported:
- `connector_available: true`
- `local_api_available: true`
- `local_api_enabled_configured: true`
- `collection use-selected --json` returned the live GUI selection from the running Zotero window
- `item find` succeeded on a live library item through the Local API search path
- `item context` produced structured item metadata and prompt-ready text on a real library item
- `item notes` and `note get` succeeded when a real item with child notes was available
- `item citation`, `item bibliography`, and `item export` all succeeded on a real regular item
- export validation succeeded:
- RIS contained `TY -`
- BibTeX contained `@`
- CSL JSON parsed successfully
### Write-Test Policy Result
- mocked connector write-path tests for `import file`, `import json`, import-time PDF attachments, and `note add` passed
- subprocess tests for the same write paths, including inline and manifest attachment flows, passed against fake local services
- mocked group-library Local API routing passed for `item find`, `item export`, `item citation`, `item bibliography`, and `search items`
- installed-command subprocess checks passed with `CLI_ANYTHING_FORCE_INSTALLED=1`
- real write-import, live import-with-attachment, and live note-add E2E remain opt-in by default
- experimental SQLite write commands were validated only on isolated local SQLite copies
### Pytest Results
```text
py -m pytest cli_anything/zotero/tests/ -v --tb=no
============================= test session starts =============================
platform win32 -- Python 3.13.5, pytest-8.4.2, pluggy-1.6.0 -- C:\Users\Lenovo\AppData\Local\Programs\Python\Python313\python.exe
cachedir: .pytest_cache
rootdir: C:\Users\Lenovo\Desktop\CLI-Anything\zotero\agent-harness
configfile: pyproject.toml
plugins: anyio-4.9.0
collecting ... collected 82 items
cli_anything/zotero/tests/test_agent_harness.py::AgentHarnessPackagingTests::test_required_files_exist PASSED [ 1%]
cli_anything/zotero/tests/test_agent_harness.py::AgentHarnessPackagingTests::test_setup_reports_expected_name PASSED [ 2%]
cli_anything/zotero/tests/test_agent_harness.py::AgentHarnessPackagingTests::test_setup_reports_expected_version PASSED [ 3%]
cli_anything/zotero/tests/test_agent_harness.py::AgentHarnessPackagingTests::test_skill_generator_regenerates_skill PASSED [ 4%]
cli_anything/zotero/tests/test_cli_entrypoint.py::CliEntrypointTests::test_app_enable_local_api_json PASSED [ 6%]
cli_anything/zotero/tests/test_cli_entrypoint.py::CliEntrypointTests::test_app_status_json PASSED [ 7%]
cli_anything/zotero/tests/test_cli_entrypoint.py::CliEntrypointTests::test_collection_find_json PASSED [ 8%]
cli_anything/zotero/tests/test_cli_entrypoint.py::CliEntrypointTests::test_collection_list_json PASSED [ 9%]
cli_anything/zotero/tests/test_cli_entrypoint.py::CliEntrypointTests::test_default_entrypoint_starts_repl PASSED [ 10%]
cli_anything/zotero/tests/test_cli_entrypoint.py::CliEntrypointTests::test_dispatch_uses_requested_prog_name PASSED [ 12%]
cli_anything/zotero/tests/test_cli_entrypoint.py::CliEntrypointTests::test_experimental_collection_write_commands PASSED [ 13%]
cli_anything/zotero/tests/test_cli_entrypoint.py::CliEntrypointTests::test_force_installed_mode_requires_real_command PASSED [ 14%]
cli_anything/zotero/tests/test_cli_entrypoint.py::CliEntrypointTests::test_group_library_routes_use_group_scope PASSED [ 15%]
cli_anything/zotero/tests/test_cli_entrypoint.py::CliEntrypointTests::test_help_renders_groups PASSED [ 17%]
cli_anything/zotero/tests/test_cli_entrypoint.py::CliEntrypointTests::test_import_file_subprocess PASSED [ 18%]
cli_anything/zotero/tests/test_cli_entrypoint.py::CliEntrypointTests::test_import_file_subprocess_with_attachment_manifest PASSED [ 19%]
cli_anything/zotero/tests/test_cli_entrypoint.py::CliEntrypointTests::test_import_json_subprocess PASSED [ 20%]
cli_anything/zotero/tests/test_cli_entrypoint.py::CliEntrypointTests::test_import_json_subprocess_duplicate_attachment_is_idempotent PASSED [ 21%]
cli_anything/zotero/tests/test_cli_entrypoint.py::CliEntrypointTests::test_import_json_subprocess_partial_success_returns_nonzero PASSED [ 23%]
cli_anything/zotero/tests/test_cli_entrypoint.py::CliEntrypointTests::test_import_json_subprocess_with_inline_file_attachment PASSED [ 24%]
cli_anything/zotero/tests/test_cli_entrypoint.py::CliEntrypointTests::test_import_json_subprocess_with_url_attachment PASSED [ 25%]
cli_anything/zotero/tests/test_cli_entrypoint.py::CliEntrypointTests::test_item_context_and_analyze PASSED [ 26%]
cli_anything/zotero/tests/test_cli_entrypoint.py::CliEntrypointTests::test_item_find_and_notes_json PASSED [ 28%]
cli_anything/zotero/tests/test_cli_entrypoint.py::CliEntrypointTests::test_item_get_json PASSED [ 29%]
cli_anything/zotero/tests/test_cli_entrypoint.py::CliEntrypointTests::test_note_get_and_add PASSED [ 30%]
cli_anything/zotero/tests/test_cli_entrypoint.py::CliEntrypointTests::test_repl_help_text_mentions_builtins PASSED [ 31%]
cli_anything/zotero/tests/test_cli_entrypoint.py::CliEntrypointTests::test_session_status_json PASSED [ 32%]
cli_anything/zotero/tests/test_cli_entrypoint.py::CliEntrypointTests::test_session_use_library_normalizes_tree_view_library_ref PASSED [ 34%]
cli_anything/zotero/tests/test_core.py::PathDiscoveryTests::test_build_environment_accepts_env_profile_dir_pointing_to_profile PASSED [ 35%]
cli_anything/zotero/tests/test_core.py::PathDiscoveryTests::test_build_environment_falls_back_to_home_zotero PASSED [ 36%]
cli_anything/zotero/tests/test_core.py::PathDiscoveryTests::test_build_environment_uses_active_profile_and_data_dir_pref PASSED [ 37%]
cli_anything/zotero/tests/test_core.py::PathDiscoveryTests::test_ensure_local_api_enabled_writes_user_js PASSED [ 39%]
cli_anything/zotero/tests/test_core.py::SQLiteInspectionTests::test_cross_library_unique_key_still_resolves_without_session_context PASSED [ 40%]
cli_anything/zotero/tests/test_core.py::SQLiteInspectionTests::test_duplicate_key_resolution_requires_library_context PASSED [ 41%]
cli_anything/zotero/tests/test_core.py::SQLiteInspectionTests::test_experimental_sqlite_write_helpers PASSED [ 42%]
cli_anything/zotero/tests/test_core.py::SQLiteInspectionTests::test_fetch_collections_and_tree PASSED [ 43%]
cli_anything/zotero/tests/test_core.py::SQLiteInspectionTests::test_fetch_item_children_and_attachments PASSED [ 45%]
cli_anything/zotero/tests/test_core.py::SQLiteInspectionTests::test_fetch_libraries PASSED [ 46%]
cli_anything/zotero/tests/test_core.py::SQLiteInspectionTests::test_fetch_saved_searches_and_tags PASSED [ 47%]
cli_anything/zotero/tests/test_core.py::SQLiteInspectionTests::test_find_collections_and_items_and_notes PASSED [ 48%]
cli_anything/zotero/tests/test_core.py::SQLiteInspectionTests::test_resolve_item_includes_fields_creators_tags PASSED [ 50%]
cli_anything/zotero/tests/test_core.py::SessionTests::test_expand_repl_aliases PASSED [ 51%]
cli_anything/zotero/tests/test_core.py::SessionTests::test_normalize_library_ref_accepts_plain_and_tree_view_ids PASSED [ 52%]
cli_anything/zotero/tests/test_core.py::SessionTests::test_save_and_load_session_state PASSED [ 53%]
cli_anything/zotero/tests/test_core.py::HttpUtilityTests::test_build_runtime_context_reports_unavailable_services PASSED [ 54%]
cli_anything/zotero/tests/test_core.py::HttpUtilityTests::test_catalog_style_list_parses_csl PASSED [ 56%]
cli_anything/zotero/tests/test_core.py::HttpUtilityTests::test_wait_for_endpoint_requires_explicit_ready_status PASSED [ 57%]
cli_anything/zotero/tests/test_core.py::ImportCoreTests::test_enable_local_api_reports_idempotent_state PASSED [ 58%]
cli_anything/zotero/tests/test_core.py::ImportCoreTests::test_import_file_manifest_index_out_of_range_and_missing_connector_id_fail_cleanly PASSED [ 59%]
cli_anything/zotero/tests/test_core.py::ImportCoreTests::test_import_file_manifest_partial_success_records_attachment_failures PASSED [ 60%]
cli_anything/zotero/tests/test_core.py::ImportCoreTests::test_import_file_manifest_title_mismatch_marks_attachment_failure PASSED [ 62%]
cli_anything/zotero/tests/test_core.py::ImportCoreTests::test_import_file_posts_raw_text_and_explicit_tree_view_target PASSED [ 63%]
cli_anything/zotero/tests/test_core.py::ImportCoreTests::test_import_json_duplicate_inline_attachments_are_skipped PASSED [ 64%]
cli_anything/zotero/tests/test_core.py::ImportCoreTests::test_import_json_rejects_invalid_inline_attachment_schema PASSED [ 65%]
cli_anything/zotero/tests/test_core.py::ImportCoreTests::test_import_json_rejects_invalid_json PASSED [ 67%]
cli_anything/zotero/tests/test_core.py::ImportCoreTests::test_import_json_strips_inline_attachments_and_uploads_local_pdf PASSED [ 68%]
cli_anything/zotero/tests/test_core.py::ImportCoreTests::test_import_json_url_attachment_uses_delay_and_default_timeout PASSED [ 69%]
cli_anything/zotero/tests/test_core.py::ImportCoreTests::test_import_json_uses_session_collection_and_tags PASSED [ 70%]
cli_anything/zotero/tests/test_core.py::ImportCoreTests::test_import_requires_connector PASSED [ 71%]
cli_anything/zotero/tests/test_core.py::WorkflowCoreTests::test_collection_find_and_item_find_sqlite_fallback PASSED [ 73%]
cli_anything/zotero/tests/test_core.py::WorkflowCoreTests::test_collection_scoped_item_find_prefers_local_api PASSED [ 74%]
cli_anything/zotero/tests/test_core.py::WorkflowCoreTests::test_experimental_commands_require_closed_zotero_and_update_db_copy PASSED [ 75%]
cli_anything/zotero/tests/test_core.py::WorkflowCoreTests::test_group_library_local_api_scope_and_search_routes PASSED [ 76%]
cli_anything/zotero/tests/test_core.py::WorkflowCoreTests::test_item_analyze_requires_api_key_and_uses_openai PASSED [ 78%]
cli_anything/zotero/tests/test_core.py::WorkflowCoreTests::test_item_context_aggregates_exports_and_links PASSED [ 79%]
cli_anything/zotero/tests/test_core.py::WorkflowCoreTests::test_item_notes_and_note_get PASSED [ 80%]
cli_anything/zotero/tests/test_core.py::WorkflowCoreTests::test_note_add_builds_child_note_payload PASSED [ 81%]
cli_anything/zotero/tests/test_core.py::WorkflowCoreTests::test_rendering_uses_group_library_local_api_scope PASSED [ 82%]
cli_anything/zotero/tests/test_core.py::OpenAIUtilityTests::test_extract_text_from_response_payload PASSED [ 84%]
cli_anything/zotero/tests/test_full_e2e.py::ZoteroFullE2E::test_attachment_inventory_commands PASSED [ 85%]
cli_anything/zotero/tests/test_full_e2e.py::ZoteroFullE2E::test_collection_detail_commands PASSED [ 86%]
cli_anything/zotero/tests/test_full_e2e.py::ZoteroFullE2E::test_collection_use_selected PASSED [ 87%]
cli_anything/zotero/tests/test_full_e2e.py::ZoteroFullE2E::test_connector_ping PASSED [ 89%]
cli_anything/zotero/tests/test_full_e2e.py::ZoteroFullE2E::test_item_citation_bibliography_and_exports PASSED [ 90%]
cli_anything/zotero/tests/test_full_e2e.py::ZoteroFullE2E::test_item_find_and_context_commands PASSED [ 91%]
cli_anything/zotero/tests/test_full_e2e.py::ZoteroFullE2E::test_note_inventory_commands PASSED [ 92%]
cli_anything/zotero/tests/test_full_e2e.py::ZoteroFullE2E::test_opt_in_import_json_with_inline_attachment SKIPPED [ 93%]
cli_anything/zotero/tests/test_full_e2e.py::ZoteroFullE2E::test_opt_in_note_add_command SKIPPED [ 95%]
cli_anything/zotero/tests/test_full_e2e.py::ZoteroFullE2E::test_opt_in_write_import_commands SKIPPED [ 96%]
cli_anything/zotero/tests/test_full_e2e.py::ZoteroFullE2E::test_search_detail_commands SKIPPED [ 97%]
cli_anything/zotero/tests/test_full_e2e.py::ZoteroFullE2E::test_sqlite_inventory_commands PASSED [ 98%]
cli_anything/zotero/tests/test_full_e2e.py::ZoteroFullE2E::test_tag_and_session_commands PASSED [100%]
================== 78 passed, 4 skipped in 108.48s (0:01:48) ==================
cli_anything/zotero/tests/test_core.py::WorkflowCoreTests::test_item_notes_and_note_get PASSED [ 78%]
cli_anything/zotero/tests/test_core.py::WorkflowCoreTests::test_note_add_builds_child_note_payload PASSED [ 79%]
cli_anything/zotero/tests/test_core.py::WorkflowCoreTests::test_rendering_uses_group_library_local_api_scope PASSED [ 81%]
cli_anything/zotero/tests/test_core.py::OpenAIUtilityTests::test_extract_text_from_response_payload PASSED [ 82%]
cli_anything/zotero/tests/test_full_e2e.py::ZoteroFullE2E::test_attachment_inventory_commands PASSED [ 84%]
cli_anything/zotero/tests/test_full_e2e.py::ZoteroFullE2E::test_collection_detail_commands PASSED [ 85%]
cli_anything/zotero/tests/test_full_e2e.py::ZoteroFullE2E::test_collection_use_selected PASSED [ 86%]
cli_anything/zotero/tests/test_full_e2e.py::ZoteroFullE2E::test_connector_ping PASSED [ 88%]
cli_anything/zotero/tests/test_full_e2e.py::ZoteroFullE2E::test_item_citation_bibliography_and_exports PASSED [ 89%]
cli_anything/zotero/tests/test_full_e2e.py::ZoteroFullE2E::test_item_find_and_context_commands PASSED [ 91%]
cli_anything/zotero/tests/test_full_e2e.py::ZoteroFullE2E::test_note_inventory_commands PASSED [ 92%]
cli_anything/zotero/tests/test_full_e2e.py::ZoteroFullE2E::test_opt_in_note_add_command SKIPPED [ 94%]
cli_anything/zotero/tests/test_full_e2e.py::ZoteroFullE2E::test_opt_in_write_import_commands SKIPPED [ 95%]
cli_anything/zotero/tests/test_full_e2e.py::ZoteroFullE2E::test_search_detail_commands SKIPPED [ 97%]
cli_anything/zotero/tests/test_full_e2e.py::ZoteroFullE2E::test_sqlite_inventory_commands PASSED [ 98%]
cli_anything/zotero/tests/test_full_e2e.py::ZoteroFullE2E::test_tag_and_session_commands PASSED [100%]
================== 66 passed, 3 skipped in 87.81s (0:01:27) ===================
```
### Notes
- SQLite inspection uses a read-only immutable connection so local reads continue to work while Zotero is open.
- bare key lookup is library-aware: unique keys resolve automatically, while duplicate keys require `session use-library <id>`.
- stable Local API read/export routes are validated for both `/api/users/0/...` and `/api/groups/<libraryID>/...`.
- experimental collection write commands require Zotero to be closed, require `--experimental`, and create a timestamped backup before each write.
- `item context` is the recommended model-independent AI interface.
- `item analyze` is covered by mocked OpenAI-compatible subprocess tests, not by live external API calls.

View File

@@ -0,0 +1 @@
"""Tests for cli-anything-zotero."""

View File

@@ -0,0 +1,705 @@
from __future__ import annotations
import json
import re
import sqlite3
import threading
from contextlib import closing, contextmanager
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import parse_qs, unquote, urlparse
def sample_pdf_bytes(label: str = "sample") -> bytes:
body = f"""%PDF-1.4
1 0 obj
<< /Type /Catalog /Pages 2 0 R >>
endobj
2 0 obj
<< /Type /Pages /Count 1 /Kids [3 0 R] >>
endobj
3 0 obj
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 4 0 R >>
endobj
4 0 obj
<< /Length 44 >>
stream
BT /F1 12 Tf 32 120 Td ({label}) Tj ET
endstream
endobj
trailer
<< /Root 1 0 R >>
%%EOF
"""
return body.encode("utf-8")
def create_sample_environment(base: Path) -> dict[str, Path]:
profile_root = base / "AppData" / "Roaming" / "Zotero" / "Zotero"
profile_dir = profile_root / "Profiles" / "test.default"
data_dir = base / "ZoteroData"
install_dir = base / "Program Files" / "Zotero"
storage_dir = data_dir / "storage" / "ATTACHKEY"
styles_dir = data_dir / "styles"
translators_dir = data_dir / "translators"
profile_dir.mkdir(parents=True, exist_ok=True)
storage_dir.mkdir(parents=True, exist_ok=True)
styles_dir.mkdir(parents=True, exist_ok=True)
translators_dir.mkdir(parents=True, exist_ok=True)
install_dir.mkdir(parents=True, exist_ok=True)
profiles_ini = """[Profile0]
Name=default
IsRelative=1
Path=Profiles/test.default
Default=1
[General]
StartWithLastProfile=1
Version=2
"""
(profile_root / "profiles.ini").write_text(profiles_ini, encoding="utf-8")
data_dir_pref = str(data_dir).replace("\\", "\\\\")
prefs_js = (
'user_pref("extensions.zotero.useDataDir", true);\n'
f'user_pref("extensions.zotero.dataDir", "{data_dir_pref}");\n'
'user_pref("extensions.zotero.httpServer.port", 23119);\n'
'user_pref("extensions.zotero.httpServer.localAPI.enabled", false);\n'
)
(profile_dir / "prefs.js").write_text(prefs_js, encoding="utf-8")
application_ini = """[App]
Vendor=Zotero
Name=Zotero
Version=7.0.32
BuildID=20260114201345
"""
(install_dir / "app").mkdir(exist_ok=True)
(install_dir / "app" / "application.ini").write_text(application_ini, encoding="utf-8")
(install_dir / "zotero.exe").write_text("", encoding="utf-8")
sqlite_path = data_dir / "zotero.sqlite"
conn = sqlite3.connect(sqlite_path)
try:
cur = conn.cursor()
cur.executescript(
"""
CREATE TABLE libraries (libraryID INTEGER PRIMARY KEY, type TEXT, editable INTEGER, filesEditable INTEGER, version INTEGER, storageVersion INTEGER, lastSync INTEGER, archived INTEGER);
CREATE TABLE itemTypes (itemTypeID INTEGER PRIMARY KEY, typeName TEXT, templateItemTypeID INTEGER, display INTEGER);
CREATE TABLE items (itemID INTEGER PRIMARY KEY, itemTypeID INTEGER, dateAdded TEXT, dateModified TEXT, clientDateModified TEXT, libraryID INTEGER, key TEXT, version INTEGER, synced INTEGER);
CREATE TABLE fields (fieldID INTEGER PRIMARY KEY, fieldName TEXT, fieldFormatID INTEGER);
CREATE TABLE itemDataValues (valueID INTEGER PRIMARY KEY, value TEXT);
CREATE TABLE itemData (itemID INTEGER, fieldID INTEGER, valueID INTEGER);
CREATE TABLE creators (creatorID INTEGER PRIMARY KEY, firstName TEXT, lastName TEXT, fieldMode INTEGER);
CREATE TABLE itemCreators (itemID INTEGER, creatorID INTEGER, creatorTypeID INTEGER, orderIndex INTEGER);
CREATE TABLE tags (tagID INTEGER PRIMARY KEY, name TEXT);
CREATE TABLE itemTags (itemID INTEGER, tagID INTEGER, type INTEGER);
CREATE TABLE collections (collectionID INTEGER PRIMARY KEY, collectionName TEXT, parentCollectionID INTEGER, clientDateModified TEXT, libraryID INTEGER, key TEXT, version INTEGER, synced INTEGER);
CREATE TABLE collectionItems (collectionID INTEGER, itemID INTEGER, orderIndex INTEGER);
CREATE TABLE itemNotes (itemID INTEGER PRIMARY KEY, parentItemID INTEGER, note TEXT, title TEXT);
CREATE TABLE itemAttachments (itemID INTEGER PRIMARY KEY, parentItemID INTEGER, linkMode INTEGER, contentType TEXT, charsetID INTEGER, path TEXT, syncState INTEGER, storageModTime INTEGER, storageHash TEXT, lastProcessedModificationTime INTEGER);
CREATE TABLE itemAnnotations (itemID INTEGER PRIMARY KEY, parentItemID INTEGER, type INTEGER, authorName TEXT, text TEXT, comment TEXT, color TEXT, pageLabel TEXT, sortIndex TEXT, position TEXT, isExternal INTEGER);
CREATE TABLE savedSearches (savedSearchID INTEGER PRIMARY KEY, savedSearchName TEXT, clientDateModified TEXT, libraryID INTEGER, key TEXT, version INTEGER, synced INTEGER);
CREATE TABLE savedSearchConditions (savedSearchID INTEGER, searchConditionID INTEGER, condition TEXT, operator TEXT, value TEXT, required INTEGER);
CREATE UNIQUE INDEX items_library_key ON items(libraryID, key);
CREATE UNIQUE INDEX collections_library_key ON collections(libraryID, key);
CREATE UNIQUE INDEX saved_searches_library_key ON savedSearches(libraryID, key);
"""
)
cur.executemany(
"INSERT INTO libraries VALUES (?, ?, 1, 1, 1, 1, 0, 0)",
[(1, "user"), (2, "group")],
)
cur.executemany(
"INSERT INTO itemTypes VALUES (?, ?, NULL, 1)",
[(1, "journalArticle"), (2, "attachment"), (3, "note")],
)
cur.executemany(
"INSERT INTO items VALUES (?, ?, '2026-01-01', '2026-01-02', '2026-01-02', ?, ?, 1, 1)",
[
(1, 1, 1, "REG12345"),
(2, 2, 1, "ATTACHKEY"),
(3, 3, 1, "NOTEKEY"),
(4, 1, 1, "REG67890"),
(5, 1, 2, "GROUPKEY"),
(6, 1, 1, "DUPITEM1"),
(7, 1, 2, "DUPITEM1"),
(8, 2, 1, "LINKATT1"),
],
)
cur.executemany("INSERT INTO fields VALUES (?, ?, 0)", [(1, "title"), (2, "DOI"), (3, "url")])
cur.executemany(
"INSERT INTO itemDataValues VALUES (?, ?)",
[
(1, "Sample Title"),
(2, "Second Item"),
(3, "10.1000/sample"),
(4, "https://example.com/paper"),
(5, "Group Title"),
(6, "User Duplicate Title"),
(7, "Group Duplicate Title"),
],
)
cur.executemany(
"INSERT INTO itemData VALUES (?, ?, ?)",
[(1, 1, 1), (4, 1, 2), (1, 2, 3), (1, 3, 4), (5, 1, 5), (6, 1, 6), (7, 1, 7)],
)
cur.executemany(
"INSERT INTO creators VALUES (?, ?, ?, 0)",
[(1, "Ada", "Lovelace"), (2, "Grace", "Hopper")],
)
cur.executemany("INSERT INTO itemCreators VALUES (?, ?, 1, 0)", [(1, 1), (5, 2)])
cur.executemany("INSERT INTO tags VALUES (?, ?)", [(1, "sample-tag"), (2, "group-tag")])
cur.executemany("INSERT INTO itemTags VALUES (?, ?, 0)", [(1, 1), (4, 1), (5, 2)])
cur.executemany(
"INSERT INTO collections VALUES (?, ?, ?, '2026-01-02', ?, ?, 1, 1)",
[
(1, "Sample Collection", None, 1, "COLLAAAA"),
(2, "Archive Collection", None, 1, "COLLBBBB"),
(3, "Nested Collection", 1, 1, "COLLCCCC"),
(4, "User Duplicate Collection", None, 1, "DUPCOLL1"),
(10, "Group Collection", None, 2, "GCOLLAAA"),
(11, "Group Duplicate Collection", None, 2, "DUPCOLL1"),
],
)
cur.executemany(
"INSERT INTO collectionItems VALUES (?, ?, ?)",
[(1, 1, 0), (1, 4, 1), (2, 4, 0), (4, 6, 0), (10, 5, 0), (11, 7, 0)],
)
cur.execute("INSERT INTO itemNotes VALUES (3, 1, '<div>Example note</div>', 'Example note')")
cur.execute(
"INSERT INTO itemAttachments VALUES (2, 1, 0, 'application/pdf', NULL, 'storage:paper.pdf', 0, 0, '', 0)"
)
cur.execute(
"INSERT INTO itemAttachments VALUES (8, 4, 2, 'application/pdf', NULL, 'file:///C:/Users/Public/linked.pdf', 0, 0, '', 0)"
)
cur.executemany(
"INSERT INTO savedSearches VALUES (?, ?, '2026-01-02', ?, ?, 1, 1)",
[
(1, "Important", 1, "SEARCHKEY"),
(2, "User Duplicate Search", 1, "DUPSEARCH"),
(3, "Group Search", 2, "GSEARCHKEY"),
(4, "Group Duplicate Search", 2, "DUPSEARCH"),
],
)
cur.executemany(
"INSERT INTO savedSearchConditions VALUES (?, 1, 'title', 'contains', ?, 1)",
[(1, "Sample"), (2, "Duplicate"), (3, "Group"), (4, "Duplicate")],
)
conn.commit()
finally:
conn.close()
(storage_dir / "paper.pdf").write_bytes(sample_pdf_bytes("sample"))
(styles_dir / "sample-style.csl").write_text(
"""<style xmlns="http://purl.org/net/xbiblio/csl" version="1.0">
<info>
<title>Sample Style</title>
<id>http://www.zotero.org/styles/sample-style</id>
</info>
</style>
""",
encoding="utf-8",
)
return {
"profile_root": profile_root,
"profile_dir": profile_dir,
"data_dir": data_dir,
"sqlite_path": sqlite_path,
"install_dir": install_dir,
"executable": install_dir / "zotero.exe",
"styles_dir": styles_dir,
}
def _next_id(conn: sqlite3.Connection, table: str, column: str) -> int:
row = conn.execute(f"SELECT COALESCE(MAX({column}), 0) + 1 AS next_id FROM {table}").fetchone()
assert row is not None
return int(row["next_id"])
def _item_type_id(conn: sqlite3.Connection, type_name: str) -> int:
row = conn.execute("SELECT itemTypeID FROM itemTypes WHERE typeName = ?", (type_name,)).fetchone()
if row:
return int(row["itemTypeID"])
fallback = conn.execute("SELECT itemTypeID FROM itemTypes WHERE typeName = 'journalArticle'").fetchone()
assert fallback is not None
return int(fallback["itemTypeID"])
def _field_id(conn: sqlite3.Connection, field_name: str) -> int:
row = conn.execute("SELECT fieldID FROM fields WHERE fieldName = ?", (field_name,)).fetchone()
if row:
return int(row["fieldID"])
field_id = _next_id(conn, "fields", "fieldID")
conn.execute("INSERT INTO fields VALUES (?, ?, 0)", (field_id, field_name))
return field_id
def _set_item_field(conn: sqlite3.Connection, item_id: int, field_name: str, value: str) -> None:
value_id = _next_id(conn, "itemDataValues", "valueID")
conn.execute("INSERT INTO itemDataValues VALUES (?, ?)", (value_id, value))
conn.execute("INSERT INTO itemData VALUES (?, ?, ?)", (item_id, _field_id(conn, field_name), value_id))
def _item_key(prefix: str, item_id: int) -> str:
return f"{prefix}{item_id:05d}"
def _safe_pdf_filename(source_url: str) -> str:
parsed = urlparse(source_url)
candidate = Path(unquote(parsed.path or "")).name or "attachment.pdf"
candidate = re.sub(r"[^A-Za-z0-9._-]+", "-", candidate).strip("-") or "attachment.pdf"
if not candidate.lower().endswith(".pdf"):
candidate += ".pdf"
return candidate
def _split_ris_records(content: str) -> list[str]:
records: list[str] = []
current: list[str] = []
for line in content.splitlines():
current.append(line)
if line.startswith("ER -"):
record = "\n".join(current).strip()
if record:
records.append(record)
current = []
if current:
record = "\n".join(current).strip()
if record:
records.append(record)
return records or [content]
def _ris_title(record: str) -> str:
match = re.search(r"(?m)^TI - (.+)$", record)
return match.group(1).strip() if match else "Imported Sample"
@contextmanager
def fake_zotero_http_server(
*,
local_api_root_status: int = 200,
sqlite_path: Path | str | None = None,
data_dir: Path | str | None = None,
):
calls: list[dict[str, object]] = []
sqlite_file = Path(sqlite_path) if sqlite_path is not None else None
zotero_data_dir = Path(data_dir) if data_dir is not None else None
sessions: dict[str, dict[str, object]] = {}
def db_connect() -> sqlite3.Connection:
if sqlite_file is None:
raise RuntimeError("sqlite_path is required for this fake server operation")
conn = sqlite3.connect(sqlite_file)
conn.row_factory = sqlite3.Row
return conn
def create_top_level_item(
item_payload: dict[str, object],
*,
connector_id: str,
library_id: int = 1,
) -> dict[str, object]:
if sqlite_file is None:
return {"connector_id": connector_id}
with closing(db_connect()) as conn:
item_id = _next_id(conn, "items", "itemID")
key = _item_key("IMP", item_id)
item_type = str(item_payload.get("itemType") or "journalArticle")
title = str(item_payload.get("title") or item_payload.get("bookTitle") or item_payload.get("publicationTitle") or "")
item_type_id = _item_type_id(conn, item_type)
conn.execute(
"INSERT INTO items VALUES (?, ?, '2026-03-27', '2026-03-27', '2026-03-27', ?, ?, 1, 1)",
(item_id, item_type_id, library_id, key),
)
if title:
_set_item_field(conn, item_id, "title", title)
conn.commit()
return {
"connector_id": connector_id,
"itemID": item_id,
"key": key,
"title": title,
"libraryID": library_id,
"itemType": item_type,
}
def create_note_item(item_payload: dict[str, object], *, connector_id: str) -> dict[str, object]:
if sqlite_file is None:
return {"connector_id": connector_id}
parent_key = str(item_payload.get("parentItem") or "")
note_html = str(item_payload.get("note") or "")
with closing(db_connect()) as conn:
parent = conn.execute("SELECT itemID, libraryID FROM items WHERE key = ?", (parent_key,)).fetchone()
if parent is None:
raise RuntimeError(f"Unknown parent item for note: {parent_key}")
item_id = _next_id(conn, "items", "itemID")
key = _item_key("NOT", item_id)
conn.execute(
"INSERT INTO items VALUES (?, ?, '2026-03-27', '2026-03-27', '2026-03-27', ?, ?, 1, 1)",
(item_id, _item_type_id(conn, "note"), int(parent["libraryID"]), key),
)
conn.execute(
"INSERT INTO itemNotes VALUES (?, ?, ?, ?)",
(item_id, int(parent["itemID"]), note_html, "Imported note"),
)
conn.commit()
return {
"connector_id": connector_id,
"itemID": item_id,
"key": key,
"title": "Imported note",
"libraryID": int(parent["libraryID"]),
"itemType": "note",
}
def create_attachment_item(*, parent_item_id: int, title: str, source_url: str, content: bytes) -> dict[str, object]:
if sqlite_file is None or zotero_data_dir is None:
return {"title": title, "url": source_url}
with closing(db_connect()) as conn:
parent = conn.execute("SELECT libraryID FROM items WHERE itemID = ?", (parent_item_id,)).fetchone()
if parent is None:
raise RuntimeError(f"Unknown parent item id: {parent_item_id}")
attachment_id = _next_id(conn, "items", "itemID")
attachment_key = _item_key("ATT", attachment_id)
filename = _safe_pdf_filename(source_url)
storage_dir = zotero_data_dir / "storage" / attachment_key
storage_dir.mkdir(parents=True, exist_ok=True)
(storage_dir / filename).write_bytes(content)
conn.execute(
"INSERT INTO items VALUES (?, ?, '2026-03-27', '2026-03-27', '2026-03-27', ?, ?, 1, 1)",
(attachment_id, _item_type_id(conn, "attachment"), int(parent["libraryID"]), attachment_key),
)
_set_item_field(conn, attachment_id, "title", title)
_set_item_field(conn, attachment_id, "url", source_url)
conn.execute(
"INSERT INTO itemAttachments VALUES (?, ?, 1, 'application/pdf', NULL, ?, 0, 0, '', 0)",
(attachment_id, parent_item_id, f"storage:{filename}"),
)
conn.commit()
return {
"itemID": attachment_id,
"key": attachment_key,
"path": str(storage_dir / filename),
}
def apply_session_update(session_id: str, target: str, tags_text: str) -> None:
if sqlite_file is None:
return
session = sessions.get(session_id)
if not session:
return
item_ids = [entry["itemID"] for entry in session["items"].values() if entry.get("itemID")]
if not item_ids:
return
collection_id: int | None = None
if target.startswith("C") and target[1:].isdigit():
collection_id = int(target[1:])
tags = [tag.strip() for tag in tags_text.split(",") if tag.strip()]
with closing(db_connect()) as conn:
if collection_id is not None:
order_index = int(
conn.execute(
"SELECT COALESCE(MAX(orderIndex), -1) + 1 AS next_order FROM collectionItems WHERE collectionID = ?",
(collection_id,),
).fetchone()["next_order"]
)
for item_id in item_ids:
exists = conn.execute(
"SELECT 1 FROM collectionItems WHERE collectionID = ? AND itemID = ?",
(collection_id, item_id),
).fetchone()
if exists is None:
conn.execute("INSERT INTO collectionItems VALUES (?, ?, ?)", (collection_id, item_id, order_index))
order_index += 1
for tag in tags:
row = conn.execute("SELECT tagID FROM tags WHERE name = ?", (tag,)).fetchone()
if row is None:
tag_id = _next_id(conn, "tags", "tagID")
conn.execute("INSERT INTO tags VALUES (?, ?)", (tag_id, tag))
else:
tag_id = int(row["tagID"])
for item_id in item_ids:
exists = conn.execute(
"SELECT 1 FROM itemTags WHERE itemID = ? AND tagID = ?",
(item_id, tag_id),
).fetchone()
if exists is None:
conn.execute("INSERT INTO itemTags VALUES (?, ?, 0)", (item_id, tag_id))
conn.commit()
class Handler(BaseHTTPRequestHandler):
def _json_response(self, status: int, payload) -> None:
body = json.dumps(payload).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def _binary_response(self, status: int, payload: bytes, *, content_type: str) -> None:
self.send_response(status)
self.send_header("Content-Type", content_type)
self.send_header("Content-Length", str(len(payload)))
self.end_headers()
self.wfile.write(payload)
def _text_response(self, status: int, payload: str) -> None:
body = payload.encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "text/plain; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def log_message(self, format, *args): # noqa: A003
return
def _item_response(self, item_key: str, query: dict[str, list[str]]) -> None:
fmt = query.get("format", [""])[0]
include = query.get("include", [""])[0]
if fmt == "json" and include == "citation":
self._json_response(200, {"citation": f"({item_key} citation)"})
return
if fmt == "json" and include == "bib":
self._json_response(200, {"bib": f"{item_key} bibliography"})
return
if fmt == "ris":
self._text_response(200, f"TY - JOUR\nID - {item_key}\nER - \n")
return
if fmt == "bibtex":
self._text_response(200, f"@article{{{item_key.lower()}}}\n")
return
if fmt == "csljson":
self._text_response(200, json.dumps([{"id": item_key}], ensure_ascii=False))
return
self._json_response(200, {"key": item_key})
def do_GET(self): # noqa: N802
calls.append({"method": "GET", "path": self.path})
parsed = urlparse(self.path)
path = parsed.path
query = parse_qs(parsed.query)
if path.startswith("/connector/ping"):
self.send_response(200)
self.send_header("Content-Length", "0")
self.end_headers()
return
if path == "/downloads/sample.pdf":
self._binary_response(200, sample_pdf_bytes("download"), content_type="application/pdf")
return
if path == "/downloads/wrong-content-type.pdf":
self._binary_response(200, sample_pdf_bytes("download"), content_type="text/plain")
return
if path == "/downloads/not-pdf":
self._binary_response(200, b"not-a-pdf", content_type="text/plain")
return
if path == "/downloads/missing.pdf":
self._text_response(404, "missing")
return
if path.startswith("/api/users/0/items/top"):
self._json_response(
200,
[
{
"key": "REG12345",
"data": {
"title": "Sample Title",
},
}
],
)
return
if path.startswith("/api/users/0/collections/COLLAAAA/items/top"):
self._json_response(
200,
[
{
"key": "REG12345",
"data": {
"title": "Sample Title",
},
}
],
)
return
if path.startswith("/api/groups/2/items/top"):
self._json_response(200, [{"key": "GROUPKEY", "data": {"title": "Group Title"}}])
return
if path.startswith("/api/groups/2/collections/GCOLLAAA/items/top"):
self._json_response(200, [{"key": "GROUPKEY", "data": {"title": "Group Title"}}])
return
if path.startswith("/api/groups/2/searches/GSEARCHKEY/items"):
self._json_response(200, [{"key": "GROUPKEY"}])
return
if path.startswith("/api/users/0/searches/SEARCHKEY/items"):
self._json_response(200, [{"key": "REG12345"}])
return
if path.startswith("/api/users/0/items/REG12345"):
self._item_response("REG12345", query)
return
if path.startswith("/api/groups/2/items/GROUPKEY"):
self._item_response("GROUPKEY", query)
return
if path.startswith("/api/"):
self.send_response(local_api_root_status)
self.send_header("Content-Length", "0")
self.end_headers()
return
self.send_response(404)
self.end_headers()
def do_POST(self): # noqa: N802
length = int(self.headers.get("Content-Length", "0"))
body = self.rfile.read(length)
decoded_body = body.decode("utf-8", errors="replace")
metadata_header = self.headers.get("X-Metadata")
call = {
"method": "POST",
"path": self.path,
"body": decoded_body,
}
if metadata_header:
try:
call["metadata"] = json.loads(metadata_header)
except json.JSONDecodeError:
call["metadata"] = metadata_header
if self.path.startswith("/connector/saveAttachment"):
call["body_length"] = len(body)
call["content_type"] = self.headers.get("Content-Type")
calls.append(call)
if self.path.startswith("/connector/getSelectedCollection"):
self._json_response(
200,
{
"libraryID": 1,
"libraryName": "My Library",
"libraryEditable": True,
"filesEditable": True,
"editable": True,
"id": 1,
"name": "Sample Collection",
"targets": [{"id": "L1", "name": "My Library", "filesEditable": True, "level": 0}],
},
)
return
if self.path.startswith("/connector/import"):
parsed = urlparse(self.path)
session_id = parse_qs(parsed.query).get("session", [""])[0]
sessions.setdefault(session_id, {"items": {}})
imported_items: list[dict[str, object]] = []
for index, record in enumerate(_split_ris_records(decoded_body), start=1):
connector_id = f"imported-{index}"
title = _ris_title(record)
item_info = create_top_level_item(
{
"itemType": "journalArticle",
"title": title,
},
connector_id=connector_id,
)
sessions[session_id]["items"][connector_id] = item_info
imported_items.append(
{
"id": connector_id,
"itemType": "journalArticle",
"title": title,
}
)
self._json_response(201, imported_items)
return
if self.path.startswith("/connector/saveItems"):
payload = json.loads(decoded_body or "{}")
session_id = str(payload.get("sessionID") or "")
sessions.setdefault(session_id, {"items": {}})
for item in payload.get("items", []):
connector_id = str(item.get("id") or f"connector-{len(sessions[session_id]['items']) + 1}")
if str(item.get("itemType") or "") == "note" and item.get("parentItem"):
item_info = create_note_item(item, connector_id=connector_id)
else:
item_info = create_top_level_item(item, connector_id=connector_id)
sessions[session_id]["items"][connector_id] = item_info
self.send_response(201)
self.send_header("Content-Length", "0")
self.end_headers()
return
if self.path.startswith("/connector/updateSession"):
payload = json.loads(decoded_body or "{}")
apply_session_update(
str(payload.get("sessionID") or ""),
str(payload.get("target") or ""),
str(payload.get("tags") or ""),
)
self._json_response(200, {})
return
if self.path.startswith("/connector/saveAttachment"):
try:
metadata = json.loads(metadata_header or "{}")
except json.JSONDecodeError:
self._json_response(400, {"error": "invalid metadata"})
return
session_id = str(metadata.get("sessionID") or "")
parent_connector_id = str(metadata.get("parentItemID") or "")
session = sessions.get(session_id)
if session is None:
self._json_response(400, {"error": "unknown session"})
return
parent = session["items"].get(parent_connector_id)
if parent is None:
self._json_response(400, {"error": "unknown parent connector id"})
return
try:
attachment = create_attachment_item(
parent_item_id=int(parent["itemID"]),
title=str(metadata.get("title") or "PDF"),
source_url=str(metadata.get("url") or ""),
content=body,
)
except RuntimeError as exc:
self._json_response(400, {"error": str(exc)})
return
self._json_response(201, attachment)
return
if self.path.startswith("/v1/responses"):
self._json_response(
200,
{
"id": "resp_fake",
"output": [
{
"type": "message",
"content": [
{
"type": "output_text",
"text": "Analysis text",
}
],
}
],
},
)
return
self.send_response(404)
self.end_headers()
server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
yield {"port": server.server_address[1], "calls": calls, "sessions": sessions}
finally:
server.shutdown()
server.server_close()
thread.join(timeout=5)

View File

@@ -0,0 +1,55 @@
from __future__ import annotations
import subprocess
import sys
import unittest
from pathlib import Path
HARNESS_ROOT = Path(__file__).resolve().parents[3]
class AgentHarnessPackagingTests(unittest.TestCase):
def test_required_files_exist(self):
required = [
HARNESS_ROOT / "setup.py",
HARNESS_ROOT / "pyproject.toml",
HARNESS_ROOT / "ZOTERO.md",
HARNESS_ROOT / "skill_generator.py",
HARNESS_ROOT / "templates" / "SKILL.md.template",
HARNESS_ROOT / "cli_anything" / "zotero" / "README.md",
HARNESS_ROOT / "cli_anything" / "zotero" / "zotero_cli.py",
HARNESS_ROOT / "cli_anything" / "zotero" / "utils" / "repl_skin.py",
HARNESS_ROOT / "cli_anything" / "zotero" / "skills" / "SKILL.md",
HARNESS_ROOT / "cli_anything" / "zotero" / "tests" / "TEST.md",
]
for path in required:
self.assertTrue(path.is_file(), msg=f"missing required file: {path}")
def test_setup_reports_expected_name(self):
result = subprocess.run([sys.executable, str(HARNESS_ROOT / "setup.py"), "--name"], cwd=HARNESS_ROOT, capture_output=True, text=True)
self.assertEqual(result.returncode, 0, msg=result.stderr)
self.assertEqual(result.stdout.strip(), "cli-anything-zotero")
def test_setup_reports_expected_version(self):
result = subprocess.run([sys.executable, str(HARNESS_ROOT / "setup.py"), "--version"], cwd=HARNESS_ROOT, capture_output=True, text=True)
self.assertEqual(result.returncode, 0, msg=result.stderr)
self.assertEqual(result.stdout.strip(), "0.1.0")
def test_skill_generator_regenerates_skill(self):
output_path = HARNESS_ROOT / "tmp-SKILL.md"
try:
result = subprocess.run(
[sys.executable, str(HARNESS_ROOT / "skill_generator.py"), str(HARNESS_ROOT), "--output", str(output_path)],
cwd=HARNESS_ROOT,
capture_output=True,
text=True,
)
self.assertEqual(result.returncode, 0, msg=result.stderr)
content = output_path.read_text(encoding="utf-8")
self.assertIn("cli-anything-zotero", content)
self.assertIn("## Command Groups", content)
self.assertIn("### App", content)
self.assertIn("### Item", content)
finally:
output_path.unlink(missing_ok=True)

View File

@@ -0,0 +1,410 @@
from __future__ import annotations
import json
import os
import shutil
import subprocess
import sys
import sysconfig
import tempfile
import unittest
from pathlib import Path
from unittest import mock
from cli_anything.zotero.tests._helpers import create_sample_environment, fake_zotero_http_server, sample_pdf_bytes
from cli_anything.zotero.zotero_cli import dispatch, repl_help_text
REPO_ROOT = Path(__file__).resolve().parents[4]
def resolve_cli() -> list[str]:
force_installed = os.environ.get("CLI_ANYTHING_FORCE_INSTALLED", "").strip() == "1"
installed = shutil.which("cli-anything-zotero")
if installed:
return [installed]
scripts_dir = Path(sysconfig.get_path("scripts"))
for candidate in (scripts_dir / "cli-anything-zotero.exe", scripts_dir / "cli-anything-zotero"):
if candidate.exists():
return [str(candidate)]
if force_installed:
raise RuntimeError("cli-anything-zotero not found in PATH. Install it with: py -m pip install -e .")
return [sys.executable, "-m", "cli_anything.zotero"]
def uses_module_fallback(cli_base: list[str]) -> bool:
return len(cli_base) >= 3 and cli_base[1] == "-m"
class CliEntrypointTests(unittest.TestCase):
CLI_BASE = resolve_cli()
def setUp(self) -> None:
self.tmpdir = tempfile.TemporaryDirectory()
self.addCleanup(self.tmpdir.cleanup)
self.env_paths = create_sample_environment(Path(self.tmpdir.name))
def run_cli(self, args, input_text=None, extra_env=None):
env = os.environ.copy()
if uses_module_fallback(self.CLI_BASE):
env["PYTHONPATH"] = str(REPO_ROOT / "zotero" / "agent-harness") + os.pathsep + env.get("PYTHONPATH", "")
env["ZOTERO_PROFILE_DIR"] = str(self.env_paths["profile_dir"])
env["ZOTERO_DATA_DIR"] = str(self.env_paths["data_dir"])
env["ZOTERO_EXECUTABLE"] = str(self.env_paths["executable"])
env["ZOTERO_HTTP_PORT"] = "23191"
env["CLI_ANYTHING_ZOTERO_STATE_DIR"] = str(Path(self.tmpdir.name) / "state")
if extra_env:
env.update(extra_env)
return subprocess.run(self.CLI_BASE + args, input=input_text, capture_output=True, text=True, env=env)
def test_help_renders_groups(self):
result = self.run_cli(["--help"])
self.assertEqual(result.returncode, 0, msg=result.stderr)
self.assertIn("collection", result.stdout)
self.assertIn("item", result.stdout)
self.assertIn("import", result.stdout)
self.assertIn("note", result.stdout)
self.assertIn("session", result.stdout)
def test_dispatch_uses_requested_prog_name(self):
result = dispatch(["--help"], prog_name="cli-anything-zotero")
self.assertEqual(result, 0)
def test_force_installed_mode_requires_real_command(self):
with tempfile.TemporaryDirectory() as tmpdir:
with mock.patch.dict("os.environ", {"CLI_ANYTHING_FORCE_INSTALLED": "1"}, clear=False):
with mock.patch("shutil.which", return_value=None):
with mock.patch("sysconfig.get_path", return_value=tmpdir):
with self.assertRaises(RuntimeError):
resolve_cli()
def test_repl_help_text_mentions_builtins(self):
self.assertIn("use-selected", repl_help_text())
self.assertIn("current-item", repl_help_text())
def test_default_entrypoint_starts_repl(self):
result = self.run_cli([], input_text="exit\n")
self.assertEqual(result.returncode, 0, msg=result.stderr)
self.assertIn("cli-anything-zotero", result.stdout)
def test_app_status_json(self):
result = self.run_cli(["--json", "app", "status"])
self.assertEqual(result.returncode, 0, msg=result.stderr)
self.assertIn('"sqlite_exists": true', result.stdout)
def test_app_enable_local_api_json(self):
result = self.run_cli(["--json", "app", "enable-local-api"])
self.assertEqual(result.returncode, 0, msg=result.stderr)
self.assertIn('"enabled": true', result.stdout)
self.assertIn('"already_enabled": false', result.stdout)
def test_collection_list_json(self):
result = self.run_cli(["--json", "collection", "list"])
self.assertEqual(result.returncode, 0, msg=result.stderr)
self.assertIn("Sample Collection", result.stdout)
def test_collection_find_json(self):
result = self.run_cli(["--json", "collection", "find", "sample"])
self.assertEqual(result.returncode, 0, msg=result.stderr)
self.assertIn("COLLAAAA", result.stdout)
def test_item_get_json(self):
result = self.run_cli(["--json", "item", "get", "REG12345"])
self.assertEqual(result.returncode, 0, msg=result.stderr)
self.assertIn("Sample Title", result.stdout)
def test_item_find_and_notes_json(self):
with fake_zotero_http_server() as server:
result = self.run_cli(
["--json", "item", "find", "Sample", "--collection", "COLLAAAA"],
extra_env={"ZOTERO_HTTP_PORT": str(server["port"])},
)
self.assertEqual(result.returncode, 0, msg=result.stderr)
self.assertIn("REG12345", result.stdout)
notes_result = self.run_cli(["--json", "item", "notes", "REG12345"])
self.assertEqual(notes_result.returncode, 0, msg=notes_result.stderr)
self.assertIn("Example note", notes_result.stdout)
def test_note_get_and_add(self):
result = self.run_cli(["--json", "note", "get", "NOTEKEY"])
self.assertEqual(result.returncode, 0, msg=result.stderr)
self.assertIn("Example note", result.stdout)
with fake_zotero_http_server() as server:
add_result = self.run_cli(
["--json", "note", "add", "REG12345", "--text", "A new note", "--format", "text"],
extra_env={"ZOTERO_HTTP_PORT": str(server["port"])},
)
self.assertEqual(add_result.returncode, 0, msg=add_result.stderr)
self.assertIn('"action": "note_add"', add_result.stdout)
def test_item_context_and_analyze(self):
result = self.run_cli(["--json", "item", "context", "REG12345", "--include-notes", "--include-links"])
self.assertEqual(result.returncode, 0, msg=result.stderr)
self.assertIn('"prompt_context"', result.stdout)
self.assertIn('"doi_url"', result.stdout)
with fake_zotero_http_server() as server:
analyze_result = self.run_cli(
["--json", "item", "analyze", "REG12345", "--question", "Summarize", "--model", "gpt-test"],
extra_env={
"OPENAI_API_KEY": "test-key",
"CLI_ANYTHING_ZOTERO_OPENAI_URL": f"http://127.0.0.1:{server['port']}/v1/responses",
},
)
self.assertEqual(analyze_result.returncode, 0, msg=analyze_result.stderr)
self.assertIn('"answer": "Analysis text"', analyze_result.stdout)
def test_session_status_json(self):
self.run_cli(["session", "use-item", "REG12345"])
result = self.run_cli(["--json", "session", "status"])
self.assertEqual(result.returncode, 0, msg=result.stderr)
self.assertIn('"current_item": "REG12345"', result.stdout)
def test_session_use_library_normalizes_tree_view_library_ref(self):
result = self.run_cli(["--json", "session", "use-library", "L2"])
self.assertEqual(result.returncode, 0, msg=result.stderr)
self.assertIn('"current_library": 2', result.stdout)
def test_group_library_routes_use_group_scope(self):
with fake_zotero_http_server() as server:
extra_env = {"ZOTERO_HTTP_PORT": str(server["port"])}
use_library = self.run_cli(["--json", "session", "use-library", "L2"], extra_env=extra_env)
self.assertEqual(use_library.returncode, 0, msg=use_library.stderr)
find_result = self.run_cli(
["--json", "item", "find", "Group", "--collection", "GCOLLAAA"],
extra_env=extra_env,
)
self.assertEqual(find_result.returncode, 0, msg=find_result.stderr)
self.assertIn("GROUPKEY", find_result.stdout)
export_result = self.run_cli(["--json", "item", "export", "GROUPKEY", "--format", "ris"], extra_env=extra_env)
self.assertEqual(export_result.returncode, 0, msg=export_result.stderr)
self.assertIn("GROUPKEY", export_result.stdout)
citation_result = self.run_cli(
["--json", "item", "citation", "GROUPKEY", "--style", "apa", "--locale", "en-US"],
extra_env=extra_env,
)
self.assertEqual(citation_result.returncode, 0, msg=citation_result.stderr)
self.assertIn("citation", citation_result.stdout)
bibliography_result = self.run_cli(
["--json", "item", "bibliography", "GROUPKEY", "--style", "apa", "--locale", "en-US"],
extra_env=extra_env,
)
self.assertEqual(bibliography_result.returncode, 0, msg=bibliography_result.stderr)
self.assertIn("bibliography", bibliography_result.stdout)
search_result = self.run_cli(["--json", "search", "items", "GSEARCHKEY"], extra_env=extra_env)
self.assertEqual(search_result.returncode, 0, msg=search_result.stderr)
self.assertIn("GROUPKEY", search_result.stdout)
get_paths = [entry["path"] for entry in server["calls"] if entry["method"] == "GET"]
self.assertTrue(any("/api/groups/2/collections/GCOLLAAA/items/top" in path for path in get_paths))
self.assertTrue(any("/api/groups/2/items/GROUPKEY?format=ris" in path for path in get_paths))
self.assertTrue(any("/api/groups/2/items/GROUPKEY?format=json&include=citation" in path for path in get_paths))
self.assertTrue(any("/api/groups/2/items/GROUPKEY?format=json&include=bib" in path for path in get_paths))
self.assertTrue(any("/api/groups/2/searches/GSEARCHKEY/items?format=json" in path for path in get_paths))
def test_import_file_subprocess(self):
import_path = Path(self.tmpdir.name) / "sample.ris"
import_path.write_text("TY - JOUR\nTI - Imported Sample\nER - \n", encoding="utf-8")
with fake_zotero_http_server() as server:
result = self.run_cli(
["--json", "import", "file", str(import_path), "--collection", "COLLAAAA", "--tag", "alpha"],
extra_env={"ZOTERO_HTTP_PORT": str(server["port"])},
)
self.assertEqual(result.returncode, 0, msg=result.stderr)
self.assertIn('"action": "import_file"', result.stdout)
self.assertIn('"treeViewID": "C1"', result.stdout)
def test_import_json_subprocess(self):
import_path = Path(self.tmpdir.name) / "items.json"
import_path.write_text('[{"itemType": "journalArticle", "title": "Imported JSON"}]', encoding="utf-8")
with fake_zotero_http_server() as server:
result = self.run_cli(
["--json", "import", "json", str(import_path), "--collection", "COLLAAAA", "--tag", "beta"],
extra_env={"ZOTERO_HTTP_PORT": str(server["port"])},
)
self.assertEqual(result.returncode, 0, msg=result.stderr)
self.assertIn('"action": "import_json"', result.stdout)
self.assertIn('"submitted_count": 1', result.stdout)
def test_import_json_subprocess_with_inline_file_attachment(self):
pdf_path = Path(self.tmpdir.name) / "inline.pdf"
pdf_path.write_bytes(sample_pdf_bytes("subprocess-inline"))
import_path = Path(self.tmpdir.name) / "items-with-attachment.json"
title = "Imported JSON Attachment"
import_path.write_text(
json.dumps(
[
{
"itemType": "journalArticle",
"title": title,
"attachments": [{"path": str(pdf_path)}],
}
]
),
encoding="utf-8",
)
with fake_zotero_http_server(sqlite_path=self.env_paths["sqlite_path"], data_dir=self.env_paths["data_dir"]) as server:
result = self.run_cli(
["--json", "import", "json", str(import_path), "--collection", "COLLAAAA"],
extra_env={"ZOTERO_HTTP_PORT": str(server["port"])},
)
self.assertEqual(result.returncode, 0, msg=result.stderr)
self.assertIn('"created_count": 1', result.stdout)
find_result = self.run_cli(["--json", "item", "find", title, "--exact-title"])
self.assertEqual(find_result.returncode, 0, msg=find_result.stderr)
imported_items = json.loads(find_result.stdout)
self.assertTrue(imported_items)
imported_item_id = str(imported_items[0]["itemID"])
attachments_result = self.run_cli(["--json", "item", "attachments", imported_item_id])
self.assertEqual(attachments_result.returncode, 0, msg=attachments_result.stderr)
attachments = json.loads(attachments_result.stdout)
self.assertTrue(attachments)
self.assertTrue(attachments[0].get("resolvedPath", "").endswith(".pdf"))
file_result = self.run_cli(["--json", "item", "file", imported_item_id])
self.assertEqual(file_result.returncode, 0, msg=file_result.stderr)
item_file = json.loads(file_result.stdout)
self.assertTrue(item_file.get("exists"))
self.assertTrue(item_file.get("resolvedPath", "").endswith(".pdf"))
def test_import_json_subprocess_with_url_attachment(self):
title = "Imported URL Attachment"
import_path = Path(self.tmpdir.name) / "items-with-url.json"
with fake_zotero_http_server(sqlite_path=self.env_paths["sqlite_path"], data_dir=self.env_paths["data_dir"]) as server:
import_path.write_text(
json.dumps(
[
{
"itemType": "journalArticle",
"title": title,
"attachments": [{"url": f"http://127.0.0.1:{server['port']}/downloads/sample.pdf"}],
}
]
),
encoding="utf-8",
)
result = self.run_cli(
["--json", "import", "json", str(import_path), "--collection", "COLLAAAA"],
extra_env={"ZOTERO_HTTP_PORT": str(server["port"])},
)
attachment_calls = [entry for entry in server["calls"] if entry["path"].startswith("/connector/saveAttachment")]
self.assertEqual(result.returncode, 0, msg=result.stderr)
self.assertIn('"created_count": 1', result.stdout)
self.assertEqual(len(attachment_calls), 1)
self.assertEqual(attachment_calls[0]["metadata"]["url"], f"http://127.0.0.1:{server['port']}/downloads/sample.pdf")
def test_import_file_subprocess_with_attachment_manifest(self):
ris_path = Path(self.tmpdir.name) / "manifest-import.ris"
ris_path.write_text("TY - JOUR\nTI - Imported Manifest Attachment\nER - \n", encoding="utf-8")
pdf_path = Path(self.tmpdir.name) / "manifest.pdf"
pdf_path.write_bytes(sample_pdf_bytes("manifest"))
manifest_path = Path(self.tmpdir.name) / "attachments-manifest.json"
manifest_path.write_text(
json.dumps([{"index": 0, "attachments": [{"path": str(pdf_path)}]}]),
encoding="utf-8",
)
with fake_zotero_http_server(sqlite_path=self.env_paths["sqlite_path"], data_dir=self.env_paths["data_dir"]) as server:
result = self.run_cli(
[
"--json",
"import",
"file",
str(ris_path),
"--collection",
"COLLAAAA",
"--attachments-manifest",
str(manifest_path),
],
extra_env={"ZOTERO_HTTP_PORT": str(server["port"])},
)
self.assertEqual(result.returncode, 0, msg=result.stderr)
self.assertIn('"created_count": 1', result.stdout)
def test_import_json_subprocess_partial_success_returns_nonzero(self):
pdf_path = Path(self.tmpdir.name) / "partial.pdf"
pdf_path.write_bytes(sample_pdf_bytes("partial"))
missing_path = Path(self.tmpdir.name) / "missing.pdf"
import_path = Path(self.tmpdir.name) / "partial-items.json"
import_path.write_text(
json.dumps(
[
{
"itemType": "journalArticle",
"title": "Imported Partial",
"attachments": [
{"path": str(pdf_path)},
{"path": str(missing_path)},
],
}
]
),
encoding="utf-8",
)
with fake_zotero_http_server(sqlite_path=self.env_paths["sqlite_path"], data_dir=self.env_paths["data_dir"]) as server:
result = self.run_cli(
["--json", "import", "json", str(import_path), "--collection", "COLLAAAA"],
extra_env={"ZOTERO_HTTP_PORT": str(server["port"])},
)
self.assertEqual(result.returncode, 1, msg=result.stderr)
self.assertIn('"status": "partial_success"', result.stdout)
self.assertIn('"failed_count": 1', result.stdout)
def test_import_json_subprocess_duplicate_attachment_is_idempotent(self):
pdf_path = Path(self.tmpdir.name) / "duplicate.pdf"
pdf_path.write_bytes(sample_pdf_bytes("duplicate"))
import_path = Path(self.tmpdir.name) / "duplicate-items.json"
import_path.write_text(
json.dumps(
[
{
"itemType": "journalArticle",
"title": "Imported Duplicate Attachment",
"attachments": [{"path": str(pdf_path)}, {"path": str(pdf_path)}],
}
]
),
encoding="utf-8",
)
with fake_zotero_http_server(sqlite_path=self.env_paths["sqlite_path"], data_dir=self.env_paths["data_dir"]) as server:
result = self.run_cli(
["--json", "import", "json", str(import_path), "--collection", "COLLAAAA"],
extra_env={"ZOTERO_HTTP_PORT": str(server["port"])},
)
attachment_calls = [entry for entry in server["calls"] if entry["path"].startswith("/connector/saveAttachment")]
self.assertEqual(result.returncode, 0, msg=result.stderr)
self.assertIn('"skipped_count": 1', result.stdout)
self.assertEqual(len(attachment_calls), 1)
def test_experimental_collection_write_commands(self):
create = self.run_cli(["--json", "collection", "create", "Created By CLI", "--experimental"])
self.assertEqual(create.returncode, 0, msg=create.stderr)
self.assertIn('"action": "collection_create"', create.stdout)
add = self.run_cli(["--json", "item", "add-to-collection", "REG12345", "COLLBBBB", "--experimental"])
self.assertEqual(add.returncode, 0, msg=add.stderr)
self.assertIn('"action": "item_add_to_collection"', add.stdout)
move = self.run_cli(
[
"--json",
"item",
"move-to-collection",
"REG67890",
"COLLAAAA",
"--from",
"COLLBBBB",
"--experimental",
]
)
self.assertEqual(move.returncode, 0, msg=move.stderr)
self.assertIn('"action": "item_move_to_collection"', move.stdout)

View File

@@ -0,0 +1,683 @@
from __future__ import annotations
import json
import tempfile
import unittest
from pathlib import Path
from unittest import mock
from cli_anything.zotero.core import analysis, catalog, discovery, experimental, imports as imports_mod, notes as notes_mod, rendering, session as session_mod
from cli_anything.zotero.tests._helpers import create_sample_environment, fake_zotero_http_server, sample_pdf_bytes
from cli_anything.zotero.utils import openai_api, zotero_http, zotero_paths, zotero_sqlite
class PathDiscoveryTests(unittest.TestCase):
def test_build_environment_uses_active_profile_and_data_dir_pref(self):
with tempfile.TemporaryDirectory() as tmpdir:
env = create_sample_environment(Path(tmpdir))
runtime_env = zotero_paths.build_environment(
explicit_profile_dir=str(env["profile_root"]),
explicit_executable=str(env["executable"]),
)
self.assertEqual(runtime_env.profile_dir, env["profile_dir"])
self.assertEqual(runtime_env.data_dir, env["data_dir"])
self.assertEqual(runtime_env.sqlite_path, env["sqlite_path"])
self.assertEqual(runtime_env.version, "7.0.32")
def test_build_environment_accepts_env_profile_dir_pointing_to_profile(self):
with tempfile.TemporaryDirectory() as tmpdir:
env = create_sample_environment(Path(tmpdir))
with mock.patch.dict("os.environ", {"ZOTERO_PROFILE_DIR": str(env["profile_dir"])}, clear=False):
runtime_env = zotero_paths.build_environment(
explicit_executable=str(env["executable"]),
explicit_data_dir=str(env["data_dir"]),
)
self.assertEqual(runtime_env.profile_dir, env["profile_dir"])
def test_build_environment_falls_back_to_home_zotero(self):
with tempfile.TemporaryDirectory() as tmpdir:
profile_root = Path(tmpdir) / "AppData" / "Roaming" / "Zotero" / "Zotero"
profile_dir = profile_root / "Profiles" / "test.default"
profile_dir.mkdir(parents=True, exist_ok=True)
(profile_root / "profiles.ini").write_text("[Profile0]\nName=default\nIsRelative=1\nPath=Profiles/test.default\nDefault=1\n", encoding="utf-8")
(profile_dir / "prefs.js").write_text("", encoding="utf-8")
home = Path(tmpdir) / "Home"
(home / "Zotero").mkdir(parents=True, exist_ok=True)
with mock.patch("cli_anything.zotero.utils.zotero_paths.Path.home", return_value=home):
runtime_env = zotero_paths.build_environment(explicit_profile_dir=str(profile_root))
self.assertEqual(runtime_env.data_dir, home / "Zotero")
def test_ensure_local_api_enabled_writes_user_js(self):
with tempfile.TemporaryDirectory() as tmpdir:
env = create_sample_environment(Path(tmpdir))
path = zotero_paths.ensure_local_api_enabled(env["profile_dir"])
self.assertIsNotNone(path)
self.assertIn('extensions.zotero.httpServer.localAPI.enabled', path.read_text(encoding="utf-8"))
class SQLiteInspectionTests(unittest.TestCase):
def setUp(self) -> None:
self.tmpdir = tempfile.TemporaryDirectory()
self.addCleanup(self.tmpdir.cleanup)
self.env = create_sample_environment(Path(self.tmpdir.name))
def test_fetch_libraries(self):
libraries = zotero_sqlite.fetch_libraries(self.env["sqlite_path"])
self.assertEqual(len(libraries), 2)
self.assertEqual([entry["type"] for entry in libraries], ["user", "group"])
def test_fetch_collections_and_tree(self):
collections = zotero_sqlite.fetch_collections(self.env["sqlite_path"], library_id=1)
self.assertIn("Sample Collection", [entry["collectionName"] for entry in collections])
tree = zotero_sqlite.build_collection_tree(collections)
self.assertIn("Sample Collection", [entry["collectionName"] for entry in tree])
def test_resolve_item_includes_fields_creators_tags(self):
item = zotero_sqlite.resolve_item(self.env["sqlite_path"], "REG12345")
self.assertEqual(item["title"], "Sample Title")
self.assertEqual(item["fields"]["title"], "Sample Title")
self.assertEqual(item["creators"][0]["lastName"], "Lovelace")
self.assertEqual(item["tags"][0]["name"], "sample-tag")
def test_fetch_item_children_and_attachments(self):
children = zotero_sqlite.fetch_item_children(self.env["sqlite_path"], "REG12345")
self.assertEqual(len(children), 2)
attachments = zotero_sqlite.fetch_item_attachments(self.env["sqlite_path"], "REG12345")
self.assertEqual(len(attachments), 1)
resolved = zotero_sqlite.resolve_attachment_real_path(attachments[0], self.env["data_dir"])
self.assertTrue(str(resolved).endswith("paper.pdf"))
linked_attachments = zotero_sqlite.fetch_item_attachments(self.env["sqlite_path"], "REG67890")
self.assertEqual(len(linked_attachments), 1)
linked_resolved = zotero_sqlite.resolve_attachment_real_path(linked_attachments[0], self.env["data_dir"])
self.assertEqual(linked_resolved, "C:\\Users\\Public\\linked.pdf")
def test_duplicate_key_resolution_requires_library_context(self):
with self.assertRaises(zotero_sqlite.AmbiguousReferenceError):
zotero_sqlite.resolve_item(self.env["sqlite_path"], "DUPITEM1")
with self.assertRaises(zotero_sqlite.AmbiguousReferenceError):
zotero_sqlite.resolve_collection(self.env["sqlite_path"], "DUPCOLL1")
with self.assertRaises(zotero_sqlite.AmbiguousReferenceError):
zotero_sqlite.resolve_saved_search(self.env["sqlite_path"], "DUPSEARCH")
user_item = zotero_sqlite.resolve_item(self.env["sqlite_path"], "DUPITEM1", library_id=1)
group_item = zotero_sqlite.resolve_item(self.env["sqlite_path"], "DUPITEM1", library_id=2)
self.assertEqual(user_item["title"], "User Duplicate Title")
self.assertEqual(group_item["title"], "Group Duplicate Title")
group_collection = zotero_sqlite.resolve_collection(self.env["sqlite_path"], "DUPCOLL1", library_id=2)
self.assertEqual(group_collection["collectionName"], "Group Duplicate Collection")
group_search = zotero_sqlite.resolve_saved_search(self.env["sqlite_path"], "DUPSEARCH", library_id=2)
self.assertEqual(group_search["savedSearchName"], "Group Duplicate Search")
def test_cross_library_unique_key_still_resolves_without_session_context(self):
group_item = zotero_sqlite.resolve_item(self.env["sqlite_path"], "GROUPKEY")
self.assertEqual(group_item["libraryID"], 2)
group_collection = zotero_sqlite.resolve_collection(self.env["sqlite_path"], "GCOLLAAA")
self.assertEqual(group_collection["libraryID"], 2)
def test_fetch_saved_searches_and_tags(self):
searches = zotero_sqlite.fetch_saved_searches(self.env["sqlite_path"], library_id=1)
self.assertEqual(searches[0]["savedSearchName"], "Important")
tags = zotero_sqlite.fetch_tags(self.env["sqlite_path"], library_id=1)
self.assertEqual(tags[0]["name"], "sample-tag")
items = zotero_sqlite.fetch_tag_items(self.env["sqlite_path"], "sample-tag", library_id=1)
self.assertGreaterEqual(len(items), 1)
def test_find_collections_and_items_and_notes(self):
collections = zotero_sqlite.find_collections(self.env["sqlite_path"], "collection", library_id=1, limit=10)
self.assertGreaterEqual(len(collections), 2)
self.assertIn("Archive Collection", [entry["collectionName"] for entry in collections])
fuzzy_items = zotero_sqlite.find_items_by_title(self.env["sqlite_path"], "Sample", library_id=1, limit=10)
self.assertEqual(fuzzy_items[0]["key"], "REG12345")
exact_items = zotero_sqlite.find_items_by_title(self.env["sqlite_path"], "Sample Title", library_id=1, exact_title=True, limit=10)
self.assertEqual(exact_items[0]["itemID"], 1)
notes = zotero_sqlite.fetch_item_notes(self.env["sqlite_path"], "REG12345")
self.assertEqual(notes[0]["typeName"], "note")
self.assertEqual(notes[0]["noteText"], "Example note")
def test_experimental_sqlite_write_helpers(self):
created = zotero_sqlite.create_collection_record(self.env["sqlite_path"], name="Created Here", library_id=1, parent_collection_id=1)
self.assertEqual(created["collectionName"], "Created Here")
self.assertTrue(Path(created["backupPath"]).exists())
added = zotero_sqlite.add_item_to_collection_record(self.env["sqlite_path"], item_id=1, collection_id=2)
self.assertTrue(Path(added["backupPath"]).exists())
moved = zotero_sqlite.move_item_between_collections_record(
self.env["sqlite_path"],
item_id=4,
target_collection_id=1,
source_collection_ids=[2],
)
self.assertTrue(Path(moved["backupPath"]).exists())
memberships = zotero_sqlite.fetch_item_collections(self.env["sqlite_path"], 4)
self.assertEqual([membership["collectionID"] for membership in memberships], [1])
class SessionTests(unittest.TestCase):
def test_save_and_load_session_state(self):
with tempfile.TemporaryDirectory() as tmpdir:
with mock.patch.dict("os.environ", {"CLI_ANYTHING_ZOTERO_STATE_DIR": tmpdir}, clear=False):
state = session_mod.default_session_state()
state["current_item"] = "REG12345"
session_mod.save_session_state(state)
loaded = session_mod.load_session_state()
self.assertEqual(loaded["current_item"], "REG12345")
def test_expand_repl_aliases(self):
state = {"current_library": "1", "current_collection": "2", "current_item": "REG12345"}
expanded = session_mod.expand_repl_aliases_with_state(["item", "get", "@item", "@collection"], state)
self.assertEqual(expanded, ["item", "get", "REG12345", "2"])
def test_normalize_library_ref_accepts_plain_and_tree_view_ids(self):
self.assertEqual(zotero_sqlite.normalize_library_ref("1"), 1)
self.assertEqual(zotero_sqlite.normalize_library_ref("L1"), 1)
self.assertEqual(zotero_sqlite.normalize_library_ref(2), 2)
class HttpUtilityTests(unittest.TestCase):
def test_build_runtime_context_reports_unavailable_services(self):
with tempfile.TemporaryDirectory() as tmpdir:
env = create_sample_environment(Path(tmpdir))
prefs_path = env["profile_dir"] / "prefs.js"
prefs_text = prefs_path.read_text(encoding="utf-8").replace("23119", "23191")
prefs_path.write_text(prefs_text, encoding="utf-8")
runtime = discovery.build_runtime_context(
data_dir=str(env["data_dir"]),
profile_dir=str(env["profile_dir"]),
executable=str(env["executable"]),
)
self.assertFalse(runtime.connector_available)
self.assertFalse(runtime.local_api_available)
def test_catalog_style_list_parses_csl(self):
with tempfile.TemporaryDirectory() as tmpdir:
env = create_sample_environment(Path(tmpdir))
runtime = discovery.build_runtime_context(
data_dir=str(env["data_dir"]),
profile_dir=str(env["profile_dir"]),
executable=str(env["executable"]),
)
styles = catalog.list_styles(runtime)
self.assertEqual(styles[0]["title"], "Sample Style")
def test_wait_for_endpoint_requires_explicit_ready_status(self):
with fake_zotero_http_server(local_api_root_status=403) as server:
ready = zotero_http.wait_for_endpoint(
server["port"],
"/api/",
timeout=1,
poll_interval=0.05,
headers={"Zotero-API-Version": zotero_http.LOCAL_API_VERSION},
)
self.assertFalse(ready)
with fake_zotero_http_server(local_api_root_status=200) as server:
ready = zotero_http.wait_for_endpoint(
server["port"],
"/api/",
timeout=1,
poll_interval=0.05,
headers={"Zotero-API-Version": zotero_http.LOCAL_API_VERSION},
)
self.assertTrue(ready)
class ImportCoreTests(unittest.TestCase):
def setUp(self) -> None:
self.tmpdir = tempfile.TemporaryDirectory()
self.addCleanup(self.tmpdir.cleanup)
self.env = create_sample_environment(Path(self.tmpdir.name))
self.runtime = discovery.build_runtime_context(
data_dir=str(self.env["data_dir"]),
profile_dir=str(self.env["profile_dir"]),
executable=str(self.env["executable"]),
)
def test_enable_local_api_reports_idempotent_state(self):
payload = imports_mod.enable_local_api(self.runtime)
self.assertTrue(payload["enabled"])
self.assertFalse(payload["already_enabled"])
self.assertTrue(Path(payload["user_js_path"]).exists())
refreshed = discovery.build_runtime_context(
data_dir=str(self.env["data_dir"]),
profile_dir=str(self.env["profile_dir"]),
executable=str(self.env["executable"]),
)
second = imports_mod.enable_local_api(refreshed)
self.assertTrue(second["already_enabled"])
def test_import_json_uses_session_collection_and_tags(self):
json_path = Path(self.tmpdir.name) / "items.json"
json_path.write_text('[{"itemType": "journalArticle", "title": "Imported"}]', encoding="utf-8")
with mock.patch.object(self.runtime, "connector_available", True):
with mock.patch("cli_anything.zotero.utils.zotero_http.connector_save_items") as save_items:
with mock.patch("cli_anything.zotero.utils.zotero_http.connector_update_session") as update_session:
payload = imports_mod.import_json(
self.runtime,
json_path,
tags=["alpha", "beta"],
session={"current_collection": "COLLAAAA"},
)
save_items.assert_called_once()
submitted_items = save_items.call_args.args[1]
self.assertEqual(submitted_items[0]["title"], "Imported")
self.assertTrue(submitted_items[0]["id"].startswith("cli-anything-zotero-"))
update_session.assert_called_once()
self.assertEqual(update_session.call_args.kwargs["target"], "C1")
self.assertEqual(update_session.call_args.kwargs["tags"], ["alpha", "beta"])
self.assertEqual(payload["submitted_count"], 1)
self.assertEqual(payload["target"]["treeViewID"], "C1")
def test_import_file_posts_raw_text_and_explicit_tree_view_target(self):
ris_path = Path(self.tmpdir.name) / "sample.ris"
ris_path.write_text("TY - JOUR\nTI - Imported Title\nER - \n", encoding="utf-8")
with mock.patch.object(self.runtime, "connector_available", True):
with mock.patch("cli_anything.zotero.utils.zotero_http.connector_import_text", return_value=[{"title": "Imported Title"}]) as import_text:
with mock.patch("cli_anything.zotero.utils.zotero_http.connector_update_session") as update_session:
payload = imports_mod.import_file(
self.runtime,
ris_path,
collection_ref="C99",
tags=["imported"],
)
import_text.assert_called_once()
self.assertIn("Imported Title", import_text.call_args.args[1])
update_session.assert_called_once()
self.assertEqual(update_session.call_args.kwargs["target"], "C99")
self.assertEqual(payload["imported_count"], 1)
def test_import_json_strips_inline_attachments_and_uploads_local_pdf(self):
pdf_path = Path(self.tmpdir.name) / "inline.pdf"
pdf_path.write_bytes(sample_pdf_bytes("inline"))
json_path = Path(self.tmpdir.name) / "items.json"
json_path.write_text(
'[{"itemType": "journalArticle", "title": "Imported", "attachments": [{"path": "%s"}]}]' % str(pdf_path).replace("\\", "\\\\"),
encoding="utf-8",
)
with mock.patch.object(self.runtime, "connector_available", True):
with mock.patch("cli_anything.zotero.utils.zotero_http.connector_save_items") as save_items:
with mock.patch("cli_anything.zotero.utils.zotero_http.connector_update_session"):
with mock.patch("cli_anything.zotero.utils.zotero_http.connector_save_attachment") as save_attachment:
payload = imports_mod.import_json(
self.runtime,
json_path,
attachment_timeout=91,
)
submitted_items = save_items.call_args.args[1]
self.assertNotIn("attachments", submitted_items[0])
self.assertEqual(payload["attachment_summary"]["created_count"], 1)
self.assertEqual(payload["status"], "success")
save_attachment.assert_called_once()
self.assertEqual(save_attachment.call_args.kwargs["parent_item_id"], submitted_items[0]["id"])
self.assertEqual(save_attachment.call_args.kwargs["timeout"], 91)
self.assertTrue(save_attachment.call_args.kwargs["url"].startswith("file:///"))
self.assertTrue(save_attachment.call_args.kwargs["content"].startswith(b"%PDF-"))
def test_import_json_url_attachment_uses_delay_and_default_timeout(self):
json_path = Path(self.tmpdir.name) / "items.json"
with fake_zotero_http_server() as server:
json_path.write_text(
json.dumps(
[
{
"itemType": "journalArticle",
"title": "Imported URL",
"attachments": [
{
"url": f"http://127.0.0.1:{server['port']}/downloads/wrong-content-type.pdf",
"delay_ms": 10,
}
],
}
]
),
encoding="utf-8",
)
with mock.patch.object(self.runtime, "connector_available", True):
with mock.patch("cli_anything.zotero.utils.zotero_http.connector_save_items"):
with mock.patch("cli_anything.zotero.utils.zotero_http.connector_update_session"):
with mock.patch("cli_anything.zotero.utils.zotero_http.connector_save_attachment") as save_attachment:
with mock.patch("cli_anything.zotero.core.imports.time.sleep") as sleep:
payload = imports_mod.import_json(
self.runtime,
json_path,
attachment_timeout=47,
)
sleep.assert_called_once_with(0.01)
save_attachment.assert_called_once()
self.assertEqual(save_attachment.call_args.kwargs["timeout"], 47)
self.assertEqual(payload["attachment_summary"]["created_count"], 1)
def test_import_json_duplicate_inline_attachments_are_skipped(self):
pdf_path = Path(self.tmpdir.name) / "duplicate.pdf"
pdf_path.write_bytes(sample_pdf_bytes("duplicate"))
json_path = Path(self.tmpdir.name) / "items.json"
json_path.write_text(
json.dumps(
[
{
"itemType": "journalArticle",
"title": "Imported Duplicate",
"attachments": [
{"path": str(pdf_path)},
{"path": str(pdf_path)},
],
}
]
),
encoding="utf-8",
)
with mock.patch.object(self.runtime, "connector_available", True):
with mock.patch("cli_anything.zotero.utils.zotero_http.connector_save_items"):
with mock.patch("cli_anything.zotero.utils.zotero_http.connector_update_session"):
with mock.patch("cli_anything.zotero.utils.zotero_http.connector_save_attachment") as save_attachment:
payload = imports_mod.import_json(self.runtime, json_path)
save_attachment.assert_called_once()
self.assertEqual(payload["attachment_summary"]["created_count"], 1)
self.assertEqual(payload["attachment_summary"]["skipped_count"], 1)
self.assertEqual(payload["attachment_results"][1]["status"], "skipped_duplicate")
def test_import_json_rejects_invalid_inline_attachment_schema(self):
json_path = Path(self.tmpdir.name) / "invalid-attachments.json"
json_path.write_text(
json.dumps(
[
{
"itemType": "journalArticle",
"title": "Broken",
"attachments": [{"path": "a.pdf", "url": "https://example.com/a.pdf"}],
}
]
),
encoding="utf-8",
)
with mock.patch.object(self.runtime, "connector_available", True):
with self.assertRaises(RuntimeError):
imports_mod.import_json(self.runtime, json_path)
def test_import_file_manifest_partial_success_records_attachment_failures(self):
ris_path = Path(self.tmpdir.name) / "sample.ris"
ris_path.write_text("TY - JOUR\nTI - Imported Title\nER - \n", encoding="utf-8")
pdf_path = Path(self.tmpdir.name) / "manifest.pdf"
pdf_path.write_bytes(sample_pdf_bytes("manifest"))
manifest_path = Path(self.tmpdir.name) / "attachments.json"
manifest_path.write_text(
json.dumps(
[
{
"index": 0,
"attachments": [
{"path": str(pdf_path)},
{"path": str(Path(self.tmpdir.name) / "missing.pdf")},
],
}
]
),
encoding="utf-8",
)
with mock.patch.object(self.runtime, "connector_available", True):
with mock.patch(
"cli_anything.zotero.utils.zotero_http.connector_import_text",
return_value=[{"id": "imported-1", "title": "Imported Title"}],
):
with mock.patch("cli_anything.zotero.utils.zotero_http.connector_update_session"):
with mock.patch("cli_anything.zotero.utils.zotero_http.connector_save_attachment") as save_attachment:
payload = imports_mod.import_file(
self.runtime,
ris_path,
attachments_manifest=manifest_path,
)
save_attachment.assert_called_once()
self.assertEqual(payload["status"], "partial_success")
self.assertEqual(payload["attachment_summary"]["created_count"], 1)
self.assertEqual(payload["attachment_summary"]["failed_count"], 1)
self.assertIn("Attachment file not found", payload["attachment_results"][1]["error"])
def test_import_file_manifest_title_mismatch_marks_attachment_failure(self):
ris_path = Path(self.tmpdir.name) / "sample.ris"
ris_path.write_text("TY - JOUR\nTI - Imported Title\nER - \n", encoding="utf-8")
pdf_path = Path(self.tmpdir.name) / "manifest.pdf"
pdf_path.write_bytes(sample_pdf_bytes("manifest"))
manifest_path = Path(self.tmpdir.name) / "attachments.json"
manifest_path.write_text(
json.dumps(
[
{
"index": 0,
"expected_title": "Different Title",
"attachments": [{"path": str(pdf_path)}],
}
]
),
encoding="utf-8",
)
with mock.patch.object(self.runtime, "connector_available", True):
with mock.patch(
"cli_anything.zotero.utils.zotero_http.connector_import_text",
return_value=[{"id": "imported-1", "title": "Imported Title"}],
):
with mock.patch("cli_anything.zotero.utils.zotero_http.connector_update_session"):
with mock.patch("cli_anything.zotero.utils.zotero_http.connector_save_attachment") as save_attachment:
payload = imports_mod.import_file(
self.runtime,
ris_path,
attachments_manifest=manifest_path,
)
save_attachment.assert_not_called()
self.assertEqual(payload["status"], "partial_success")
self.assertIn("title mismatch", payload["attachment_results"][0]["error"])
def test_import_file_manifest_index_out_of_range_and_missing_connector_id_fail_cleanly(self):
ris_path = Path(self.tmpdir.name) / "sample.ris"
ris_path.write_text("TY - JOUR\nTI - Imported Title\nER - \n", encoding="utf-8")
pdf_path = Path(self.tmpdir.name) / "manifest.pdf"
pdf_path.write_bytes(sample_pdf_bytes("manifest"))
manifest_path = Path(self.tmpdir.name) / "attachments.json"
manifest_path.write_text(
json.dumps(
[
{"index": 1, "attachments": [{"path": str(pdf_path)}]},
{"index": 0, "attachments": [{"path": str(pdf_path)}]},
]
),
encoding="utf-8",
)
with mock.patch.object(self.runtime, "connector_available", True):
with mock.patch(
"cli_anything.zotero.utils.zotero_http.connector_import_text",
return_value=[{"title": "Imported Title"}],
):
with mock.patch("cli_anything.zotero.utils.zotero_http.connector_update_session"):
with mock.patch("cli_anything.zotero.utils.zotero_http.connector_save_attachment") as save_attachment:
payload = imports_mod.import_file(
self.runtime,
ris_path,
attachments_manifest=manifest_path,
)
save_attachment.assert_not_called()
self.assertEqual(payload["attachment_summary"]["failed_count"], 2)
self.assertIn("index 1", payload["attachment_results"][0]["error"])
self.assertIn("did not include a connector id", payload["attachment_results"][1]["error"])
def test_import_json_rejects_invalid_json(self):
json_path = Path(self.tmpdir.name) / "bad.json"
json_path.write_text("{not-valid", encoding="utf-8")
with mock.patch.object(self.runtime, "connector_available", True):
with self.assertRaises(RuntimeError):
imports_mod.import_json(self.runtime, json_path)
def test_import_requires_connector(self):
json_path = Path(self.tmpdir.name) / "items.json"
json_path.write_text("[]", encoding="utf-8")
with mock.patch.object(self.runtime, "connector_available", False):
with self.assertRaises(RuntimeError):
imports_mod.import_json(self.runtime, json_path)
class WorkflowCoreTests(unittest.TestCase):
def setUp(self) -> None:
self.tmpdir = tempfile.TemporaryDirectory()
self.addCleanup(self.tmpdir.cleanup)
self.env = create_sample_environment(Path(self.tmpdir.name))
self.runtime = discovery.build_runtime_context(
data_dir=str(self.env["data_dir"]),
profile_dir=str(self.env["profile_dir"]),
executable=str(self.env["executable"]),
)
def test_collection_find_and_item_find_sqlite_fallback(self):
collections = catalog.find_collections(self.runtime, "sample", limit=10)
self.assertEqual(collections[0]["key"], "COLLAAAA")
with mock.patch.object(self.runtime, "local_api_available", False):
items = catalog.find_items(self.runtime, "Sample", limit=10, session={})
self.assertEqual(items[0]["key"], "REG12345")
exact = catalog.find_items(self.runtime, "Sample Title", exact_title=True, limit=10, session={})
self.assertEqual(exact[0]["itemID"], 1)
def test_collection_scoped_item_find_prefers_local_api(self):
with mock.patch.object(self.runtime, "local_api_available", True):
with mock.patch("cli_anything.zotero.utils.zotero_http.local_api_get_json", return_value=[{"key": "REG12345"}]) as local_api:
items = catalog.find_items(self.runtime, "Sample", collection_ref="COLLAAAA", limit=5, session={})
local_api.assert_called_once()
self.assertEqual(items[0]["key"], "REG12345")
def test_group_library_local_api_scope_and_search_routes(self):
self.assertEqual(catalog.local_api_scope(self.runtime, 1), "/api/users/0")
self.assertEqual(catalog.local_api_scope(self.runtime, 2), "/api/groups/2")
with mock.patch.object(self.runtime, "local_api_available", True):
with mock.patch("cli_anything.zotero.utils.zotero_http.local_api_get_json", return_value=[{"key": "GROUPKEY"}]) as local_api:
items = catalog.find_items(
self.runtime,
"Group",
collection_ref="GCOLLAAA",
limit=5,
session={"current_library": 2},
)
self.assertEqual(items[0]["libraryID"], 2)
self.assertIn("/api/groups/2/collections/GCOLLAAA/items/top", local_api.call_args.args[1])
with mock.patch.object(self.runtime, "local_api_available", True):
with mock.patch("cli_anything.zotero.utils.zotero_http.local_api_get_json", return_value=[{"key": "GROUPKEY"}]) as local_api:
payload = catalog.search_items(self.runtime, "GSEARCHKEY", session={"current_library": 2})
self.assertEqual(payload[0]["key"], "GROUPKEY")
self.assertIn("/api/groups/2/searches/GSEARCHKEY/items", local_api.call_args.args[1])
def test_item_notes_and_note_get(self):
item_notes = catalog.item_notes(self.runtime, "REG12345")
self.assertEqual(len(item_notes), 1)
self.assertEqual(item_notes[0]["notePreview"], "Example note")
note = notes_mod.get_note(self.runtime, "NOTEKEY")
self.assertEqual(note["noteText"], "Example note")
def test_note_add_builds_child_note_payload(self):
with mock.patch.object(self.runtime, "connector_available", True):
with mock.patch("cli_anything.zotero.utils.zotero_http.get_selected_collection", return_value={"libraryID": 1}):
with mock.patch("cli_anything.zotero.utils.zotero_http.connector_save_items") as save_items:
payload = notes_mod.add_note(
self.runtime,
"REG12345",
text="# Heading\n\nA **bold** note",
fmt="markdown",
)
save_items.assert_called_once()
submitted = save_items.call_args.args[1][0]
self.assertEqual(submitted["itemType"], "note")
self.assertEqual(submitted["parentItem"], "REG12345")
self.assertIn("<h1>", submitted["note"])
self.assertEqual(payload["parentItemKey"], "REG12345")
def test_item_context_aggregates_exports_and_links(self):
with mock.patch.object(self.runtime, "local_api_available", True):
with mock.patch("cli_anything.zotero.core.rendering.export_item", side_effect=[{"content": "@article{sample}"}, {"content": '{"id":"sample"}'}]):
payload = analysis.build_item_context(
self.runtime,
"REG12345",
include_notes=True,
include_bibtex=True,
include_csljson=True,
include_links=True,
)
self.assertEqual(payload["links"]["doi_url"], "https://doi.org/10.1000/sample")
self.assertIn("bibtex", payload["exports"])
self.assertIn("Notes:", payload["prompt_context"])
def test_item_analyze_requires_api_key_and_uses_openai(self):
with mock.patch.dict("os.environ", {"OPENAI_API_KEY": ""}, clear=False):
with self.assertRaises(RuntimeError):
analysis.analyze_item(self.runtime, "REG12345", question="Summarize", model="gpt-test")
with mock.patch.dict("os.environ", {"OPENAI_API_KEY": "test-key"}, clear=False):
with mock.patch("cli_anything.zotero.core.analysis.build_item_context", return_value={"item": {"key": "REG12345"}, "prompt_context": "Title: Sample"}):
with mock.patch("cli_anything.zotero.utils.openai_api.create_text_response", return_value={"response_id": "resp_123", "answer": "Analysis", "raw": {}}) as create_response:
payload = analysis.analyze_item(self.runtime, "REG12345", question="Summarize", model="gpt-test")
create_response.assert_called_once()
self.assertEqual(payload["answer"], "Analysis")
def test_experimental_commands_require_closed_zotero_and_update_db_copy(self):
with mock.patch.object(self.runtime, "connector_available", True):
with self.assertRaises(RuntimeError):
experimental.create_collection(self.runtime, "Blocked")
with mock.patch.object(self.runtime, "connector_available", False):
created = experimental.create_collection(self.runtime, "Created")
self.assertEqual(created["action"], "collection_create")
added = experimental.add_item_to_collection(self.runtime, "REG12345", "COLLBBBB")
self.assertEqual(added["action"], "item_add_to_collection")
moved = experimental.move_item_to_collection(
self.runtime,
"REG67890",
"COLLAAAA",
from_refs=["COLLBBBB"],
)
self.assertEqual(moved["action"], "item_move_to_collection")
def test_rendering_uses_group_library_local_api_scope(self):
with mock.patch.object(self.runtime, "local_api_available", True):
with mock.patch("cli_anything.zotero.utils.zotero_http.local_api_get_text", return_value="TY - JOUR\nER - \n") as get_text:
export_payload = rendering.export_item(self.runtime, "GROUPKEY", "ris", session={"current_library": 2})
self.assertEqual(export_payload["libraryID"], 2)
self.assertIn("/api/groups/2/items/GROUPKEY", get_text.call_args.args[1])
class OpenAIUtilityTests(unittest.TestCase):
def test_extract_text_from_response_payload(self):
payload = {
"id": "resp_1",
"output": [
{
"type": "message",
"content": [
{"type": "output_text", "text": "Hello world"},
],
}
],
}
result = openai_api._extract_text(payload)
self.assertEqual(result, "Hello world")

View File

@@ -0,0 +1,351 @@
from __future__ import annotations
import json
import os
import shutil
import subprocess
import sys
import sysconfig
import tempfile
import unittest
import uuid
from pathlib import Path
from cli_anything.zotero.core import discovery
from cli_anything.zotero.tests._helpers import sample_pdf_bytes
from cli_anything.zotero.utils import zotero_paths, zotero_sqlite
REPO_ROOT = Path(__file__).resolve().parents[4]
def resolve_cli() -> list[str]:
force_installed = os.environ.get("CLI_ANYTHING_FORCE_INSTALLED", "").strip() == "1"
installed = shutil.which("cli-anything-zotero")
if installed:
return [installed]
scripts_dir = Path(sysconfig.get_path("scripts"))
for candidate in (scripts_dir / "cli-anything-zotero.exe", scripts_dir / "cli-anything-zotero"):
if candidate.exists():
return [str(candidate)]
if force_installed:
raise RuntimeError("cli-anything-zotero not found in PATH. Install it with: py -m pip install -e .")
return [sys.executable, "-m", "cli_anything.zotero"]
def uses_module_fallback(cli_base: list[str]) -> bool:
return len(cli_base) >= 3 and cli_base[1] == "-m"
ENVIRONMENT = zotero_paths.build_environment()
HAS_LOCAL_DATA = ENVIRONMENT.sqlite_exists
def choose_regular_item() -> dict | None:
if not HAS_LOCAL_DATA:
return None
items = zotero_sqlite.fetch_items(ENVIRONMENT.sqlite_path, library_id=zotero_sqlite.default_library_id(ENVIRONMENT.sqlite_path), limit=50)
for item in items:
if item["typeName"] not in {"attachment", "note"} and item.get("title"):
return item
return None
def choose_item_with_attachment() -> dict | None:
if not HAS_LOCAL_DATA:
return None
library_id = zotero_sqlite.default_library_id(ENVIRONMENT.sqlite_path)
items = zotero_sqlite.fetch_items(ENVIRONMENT.sqlite_path, library_id=library_id, limit=100)
for item in items:
if item["typeName"] in {"attachment", "note", "annotation"}:
continue
attachments = zotero_sqlite.fetch_item_attachments(ENVIRONMENT.sqlite_path, item["itemID"])
if attachments:
return item
return None
def choose_item_with_note() -> dict | None:
if not HAS_LOCAL_DATA:
return None
library_id = zotero_sqlite.default_library_id(ENVIRONMENT.sqlite_path)
items = zotero_sqlite.fetch_items(ENVIRONMENT.sqlite_path, library_id=library_id, limit=100)
for item in items:
if item["typeName"] in {"attachment", "note", "annotation"}:
continue
notes = zotero_sqlite.fetch_item_notes(ENVIRONMENT.sqlite_path, item["itemID"])
if notes:
return item
return None
SAMPLE_ITEM = choose_regular_item()
ATTACHMENT_SAMPLE_ITEM = choose_item_with_attachment()
NOTE_SAMPLE_ITEM = choose_item_with_note()
def choose_collection() -> dict | None:
if not HAS_LOCAL_DATA:
return None
collections = zotero_sqlite.fetch_collections(ENVIRONMENT.sqlite_path, library_id=zotero_sqlite.default_library_id(ENVIRONMENT.sqlite_path))
return collections[0] if collections else None
def choose_tag_name() -> str | None:
if not HAS_LOCAL_DATA:
return None
tags = zotero_sqlite.fetch_tags(ENVIRONMENT.sqlite_path, library_id=zotero_sqlite.default_library_id(ENVIRONMENT.sqlite_path))
return tags[0]["name"] if tags else None
SAMPLE_COLLECTION = choose_collection()
SAMPLE_TAG = choose_tag_name()
SEARCHES = zotero_sqlite.fetch_saved_searches(ENVIRONMENT.sqlite_path, library_id=zotero_sqlite.default_library_id(ENVIRONMENT.sqlite_path)) if HAS_LOCAL_DATA else []
SAMPLE_SEARCH = SEARCHES[0] if SEARCHES else None
@unittest.skipUnless(HAS_LOCAL_DATA, "Local Zotero data directory not found")
class ZoteroFullE2E(unittest.TestCase):
CLI_BASE = resolve_cli()
@classmethod
def setUpClass(cls) -> None:
discovery.ensure_live_api_enabled()
runtime = discovery.build_runtime_context()
if not runtime.connector_available:
discovery.launch_zotero(runtime, wait_timeout=45)
cls.runtime = discovery.build_runtime_context()
def run_cli(self, args):
env = os.environ.copy()
if uses_module_fallback(self.CLI_BASE):
env["PYTHONPATH"] = str(REPO_ROOT / "zotero" / "agent-harness") + os.pathsep + env.get("PYTHONPATH", "")
return subprocess.run(self.CLI_BASE + args, capture_output=True, text=True, env=env, timeout=60)
def run_cli_with_retry(self, args, retries: int = 2):
last = None
for _ in range(retries):
last = self.run_cli(args)
if last.returncode == 0:
return last
return last
def test_sqlite_inventory_commands(self):
result = self.run_cli(["--json", "collection", "list"])
self.assertEqual(result.returncode, 0, msg=result.stderr)
self.assertIn("collectionName", result.stdout)
result = self.run_cli(["--json", "item", "list"])
self.assertEqual(result.returncode, 0, msg=result.stderr)
self.assertIn("itemID", result.stdout)
result = self.run_cli(["--json", "style", "list"])
self.assertEqual(result.returncode, 0, msg=result.stderr)
self.assertIn("title", result.stdout)
result = self.run_cli(["--json", "search", "list"])
self.assertEqual(result.returncode, 0, msg=result.stderr)
@unittest.skipUnless(SAMPLE_ITEM is not None, "No regular Zotero item found")
def test_item_find_and_context_commands(self):
assert SAMPLE_ITEM is not None
title = zotero_sqlite.resolve_item(ENVIRONMENT.sqlite_path, SAMPLE_ITEM["itemID"])["title"]
query = title.split()[0]
item_find = self.run_cli(["--json", "item", "find", query, "--limit", "5"])
self.assertEqual(item_find.returncode, 0, msg=item_find.stderr)
self.assertIn(SAMPLE_ITEM["key"], item_find.stdout)
exact_find = self.run_cli(["--json", "item", "find", title, "--exact-title"])
self.assertEqual(exact_find.returncode, 0, msg=exact_find.stderr)
self.assertIn(SAMPLE_ITEM["key"], exact_find.stdout)
context_result = self.run_cli(["--json", "item", "context", str(SAMPLE_ITEM["itemID"]), "--include-links"])
self.assertEqual(context_result.returncode, 0, msg=context_result.stderr)
self.assertIn('"prompt_context"', context_result.stdout)
@unittest.skipUnless(ATTACHMENT_SAMPLE_ITEM is not None, "No Zotero item with attachments found")
def test_attachment_inventory_commands(self):
assert ATTACHMENT_SAMPLE_ITEM is not None
attachments = self.run_cli(["--json", "item", "attachments", str(ATTACHMENT_SAMPLE_ITEM["itemID"])])
self.assertEqual(attachments.returncode, 0, msg=attachments.stderr)
attachment_data = json.loads(attachments.stdout)
self.assertTrue(attachment_data)
self.assertTrue(attachment_data[0].get("resolvedPath"))
item_file = self.run_cli(["--json", "item", "file", str(ATTACHMENT_SAMPLE_ITEM["itemID"])])
self.assertEqual(item_file.returncode, 0, msg=item_file.stderr)
item_file_data = json.loads(item_file.stdout)
self.assertTrue(item_file_data.get("exists"))
self.assertTrue(item_file_data.get("resolvedPath"))
@unittest.skipUnless(NOTE_SAMPLE_ITEM is not None, "No Zotero item with notes found")
def test_note_inventory_commands(self):
assert NOTE_SAMPLE_ITEM is not None
item_notes = self.run_cli(["--json", "item", "notes", str(NOTE_SAMPLE_ITEM["itemID"])])
self.assertEqual(item_notes.returncode, 0, msg=item_notes.stderr)
item_notes_data = json.loads(item_notes.stdout)
self.assertTrue(item_notes_data)
note_key = item_notes_data[0]["key"]
note_get = self.run_cli(["--json", "note", "get", note_key])
self.assertEqual(note_get.returncode, 0, msg=note_get.stderr)
self.assertIn(note_key, note_get.stdout)
def test_connector_ping(self):
result = self.run_cli(["--json", "app", "ping"])
self.assertEqual(result.returncode, 0, msg=result.stderr)
self.assertIn('"connector_available": true', result.stdout)
def test_collection_use_selected(self):
result = self.run_cli(["--json", "collection", "use-selected"])
self.assertEqual(result.returncode, 0, msg=result.stderr)
self.assertIn("libraryID", result.stdout)
@unittest.skipUnless(SAMPLE_COLLECTION is not None, "No Zotero collection found")
def test_collection_detail_commands(self):
collection_key = SAMPLE_COLLECTION["key"]
tree = self.run_cli(["--json", "collection", "tree"])
self.assertEqual(tree.returncode, 0, msg=tree.stderr)
self.assertIn("children", tree.stdout)
collection_get = self.run_cli(["--json", "collection", "get", collection_key])
self.assertEqual(collection_get.returncode, 0, msg=collection_get.stderr)
self.assertIn(collection_key, collection_get.stdout)
collection_items = self.run_cli(["--json", "collection", "items", collection_key])
self.assertEqual(collection_items.returncode, 0, msg=collection_items.stderr)
@unittest.skipUnless(SAMPLE_TAG is not None, "No Zotero tag found")
def test_tag_and_session_commands(self):
tag_items = self.run_cli(["--json", "tag", "items", SAMPLE_TAG])
self.assertEqual(tag_items.returncode, 0, msg=tag_items.stderr)
self.assertIn("itemID", tag_items.stdout)
if SAMPLE_COLLECTION is not None:
session_collection = self.run_cli(["--json", "session", "use-collection", SAMPLE_COLLECTION["key"]])
self.assertEqual(session_collection.returncode, 0, msg=session_collection.stderr)
self.assertIn('"current_collection"', session_collection.stdout)
if SAMPLE_ITEM is not None:
session_item = self.run_cli(["--json", "session", "use-item", str(SAMPLE_ITEM["itemID"])])
self.assertEqual(session_item.returncode, 0, msg=session_item.stderr)
self.assertIn(f'"current_item": "{SAMPLE_ITEM["itemID"]}"', session_item.stdout)
@unittest.skipUnless(SAMPLE_SEARCH is not None, "No Zotero saved search found")
def test_search_detail_commands(self):
assert SAMPLE_SEARCH is not None
search_get = self.run_cli(["--json", "search", "get", str(SAMPLE_SEARCH["savedSearchID"])])
self.assertEqual(search_get.returncode, 0, msg=search_get.stderr)
self.assertIn(SAMPLE_SEARCH["key"], search_get.stdout)
search_items = self.run_cli(["--json", "search", "items", str(SAMPLE_SEARCH["savedSearchID"])])
self.assertEqual(search_items.returncode, 0, msg=search_items.stderr)
@unittest.skipUnless(os.environ.get("CLI_ANYTHING_ZOTERO_ENABLE_WRITE_E2E") == "1", "Write E2E disabled")
def test_opt_in_write_import_commands(self):
target = os.environ.get("CLI_ANYTHING_ZOTERO_IMPORT_TARGET", "").strip()
self.assertTrue(target, "CLI_ANYTHING_ZOTERO_IMPORT_TARGET must be set when write E2E is enabled")
with tempfile.TemporaryDirectory() as tmpdir:
ris_path = Path(tmpdir) / "import.ris"
ris_path.write_text("TY - JOUR\nTI - CLI Anything Write E2E RIS\nER - \n", encoding="utf-8")
ris_result = self.run_cli(["--json", "import", "file", str(ris_path), "--collection", target, "--tag", "cli-anything-e2e"])
self.assertEqual(ris_result.returncode, 0, msg=ris_result.stderr)
self.assertIn('"action": "import_file"', ris_result.stdout)
json_path = Path(tmpdir) / "import.json"
json_path.write_text(
json.dumps([{"itemType": "journalArticle", "title": "CLI Anything Write E2E JSON"}], ensure_ascii=False),
encoding="utf-8",
)
json_result = self.run_cli(["--json", "import", "json", str(json_path), "--collection", target, "--tag", "cli-anything-e2e"])
self.assertEqual(json_result.returncode, 0, msg=json_result.stderr)
self.assertIn('"action": "import_json"', json_result.stdout)
@unittest.skipUnless(os.environ.get("CLI_ANYTHING_ZOTERO_ENABLE_WRITE_E2E") == "1", "Write E2E disabled")
def test_opt_in_import_json_with_inline_attachment(self):
target = os.environ.get("CLI_ANYTHING_ZOTERO_IMPORT_TARGET", "").strip()
self.assertTrue(target, "CLI_ANYTHING_ZOTERO_IMPORT_TARGET must be set when write E2E is enabled")
with tempfile.TemporaryDirectory() as tmpdir:
title = f"CLI Anything Attachment E2E {uuid.uuid4().hex[:8]}"
pdf_path = Path(tmpdir) / "inline-e2e.pdf"
pdf_path.write_bytes(sample_pdf_bytes("live-e2e"))
json_path = Path(tmpdir) / "import-attachment.json"
json_path.write_text(
json.dumps(
[
{
"itemType": "journalArticle",
"title": title,
"attachments": [{"path": str(pdf_path)}],
}
],
ensure_ascii=False,
),
encoding="utf-8",
)
import_result = self.run_cli(
["--json", "import", "json", str(json_path), "--collection", target, "--tag", "cli-anything-e2e"]
)
self.assertEqual(import_result.returncode, 0, msg=import_result.stderr)
self.assertIn('"created_count": 1', import_result.stdout)
find_result = self.run_cli_with_retry(["--json", "item", "find", title, "--exact-title"], retries=4)
self.assertEqual(find_result.returncode, 0, msg=find_result.stderr)
imported_items = json.loads(find_result.stdout)
self.assertTrue(imported_items)
imported_item_id = str(imported_items[0]["itemID"])
attachments_result = self.run_cli_with_retry(["--json", "item", "attachments", imported_item_id], retries=4)
self.assertEqual(attachments_result.returncode, 0, msg=attachments_result.stderr)
attachments = json.loads(attachments_result.stdout)
self.assertTrue(attachments)
self.assertTrue(any((attachment.get("resolvedPath") or "").lower().endswith(".pdf") for attachment in attachments))
item_file_result = self.run_cli_with_retry(["--json", "item", "file", imported_item_id], retries=4)
self.assertEqual(item_file_result.returncode, 0, msg=item_file_result.stderr)
item_file = json.loads(item_file_result.stdout)
self.assertTrue(item_file.get("exists"))
self.assertTrue((item_file.get("resolvedPath") or "").lower().endswith(".pdf"))
@unittest.skipUnless(os.environ.get("CLI_ANYTHING_ZOTERO_ENABLE_WRITE_E2E") == "1", "Write E2E disabled")
@unittest.skipUnless(SAMPLE_ITEM is not None, "No regular Zotero item found")
def test_opt_in_note_add_command(self):
assert SAMPLE_ITEM is not None
result = self.run_cli(["--json", "note", "add", str(SAMPLE_ITEM["itemID"]), "--text", "CLI Anything write note"])
self.assertEqual(result.returncode, 0, msg=result.stderr)
self.assertIn('"action": "note_add"', result.stdout)
@unittest.skipUnless(SAMPLE_ITEM is not None, "No regular Zotero item found for export/citation tests")
def test_item_citation_bibliography_and_exports(self):
assert SAMPLE_ITEM is not None
item_ref = str(SAMPLE_ITEM["itemID"])
citation = self.run_cli_with_retry(["--json", "item", "citation", item_ref, "--style", "apa", "--locale", "en-US"])
self.assertEqual(citation.returncode, 0, msg=citation.stderr)
citation_data = json.loads(citation.stdout)
self.assertTrue(citation_data.get("citation"))
bibliography = self.run_cli_with_retry(["--json", "item", "bibliography", item_ref, "--style", "apa", "--locale", "en-US"])
self.assertEqual(bibliography.returncode, 0, msg=bibliography.stderr)
bibliography_data = json.loads(bibliography.stdout)
self.assertTrue(bibliography_data.get("bibliography"))
ris = self.run_cli_with_retry(["--json", "item", "export", item_ref, "--format", "ris"])
self.assertEqual(ris.returncode, 0, msg=ris.stderr)
ris_data = json.loads(ris.stdout)
self.assertIn("TY -", ris_data["content"])
bibtex = self.run_cli_with_retry(["--json", "item", "export", item_ref, "--format", "bibtex"])
self.assertEqual(bibtex.returncode, 0, msg=bibtex.stderr)
bibtex_data = json.loads(bibtex.stdout)
self.assertIn("@", bibtex_data["content"])
csljson = self.run_cli_with_retry(["--json", "item", "export", item_ref, "--format", "csljson"])
self.assertEqual(csljson.returncode, 0, msg=csljson.stderr)
csljson_data = json.loads(csljson.stdout)
parsed = json.loads(csljson_data["content"])
self.assertTrue(parsed)

View File

@@ -0,0 +1 @@
"""Utility modules for cli-anything-zotero."""

View File

@@ -0,0 +1,70 @@
from __future__ import annotations
import json
import os
import urllib.error
import urllib.request
from typing import Any
DEFAULT_RESPONSES_API_URL = "https://api.openai.com/v1/responses"
def _extract_text(response_payload: dict[str, Any]) -> str:
output_text = response_payload.get("output_text")
if isinstance(output_text, str) and output_text.strip():
return output_text.strip()
parts: list[str] = []
for item in response_payload.get("output", []) or []:
if not isinstance(item, dict):
continue
for content in item.get("content", []) or []:
if not isinstance(content, dict):
continue
text = content.get("text")
if isinstance(text, str) and text.strip():
parts.append(text.strip())
return "\n\n".join(parts).strip()
def create_text_response(
*,
api_key: str,
model: str,
instructions: str,
input_text: str,
timeout: int = 60,
) -> dict[str, Any]:
responses_url = os.environ.get("CLI_ANYTHING_ZOTERO_OPENAI_URL", "").strip() or DEFAULT_RESPONSES_API_URL
payload = {
"model": model,
"instructions": instructions,
"input": input_text,
}
request = urllib.request.Request(
responses_url,
data=json.dumps(payload).encode("utf-8"),
headers={
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
},
method="POST",
)
try:
with urllib.request.urlopen(request, timeout=timeout) as response:
response_payload = json.loads(response.read().decode("utf-8"))
except urllib.error.HTTPError as exc:
body = exc.read().decode("utf-8", errors="replace")
raise RuntimeError(f"OpenAI Responses API returned HTTP {exc.code}: {body}") from exc
except urllib.error.URLError as exc:
raise RuntimeError(f"OpenAI Responses API request failed: {exc}") from exc
answer = _extract_text(response_payload)
if not answer:
raise RuntimeError("OpenAI Responses API returned no text output")
return {
"response_id": response_payload.get("id"),
"answer": answer,
"raw": response_payload,
}

View File

@@ -0,0 +1,521 @@
"""cli-anything REPL Skin — Unified terminal interface for all CLI harnesses.
Copy this file into your CLI package at:
cli_anything/<software>/utils/repl_skin.py
Usage:
from cli_anything.<software>.utils.repl_skin import ReplSkin
skin = ReplSkin("shotcut", version="1.0.0")
skin.print_banner() # auto-detects skills/SKILL.md inside the package
prompt_text = skin.prompt(project_name="my_video.mlt", modified=True)
skin.success("Project saved")
skin.error("File not found")
skin.warning("Unsaved changes")
skin.info("Processing 24 clips...")
skin.status("Track 1", "3 clips, 00:02:30")
skin.table(headers, rows)
skin.print_goodbye()
"""
import os
import sys
# ── ANSI color codes (no external deps for core styling) ──────────────
_RESET = "\033[0m"
_BOLD = "\033[1m"
_DIM = "\033[2m"
_ITALIC = "\033[3m"
_UNDERLINE = "\033[4m"
# Brand colors
_CYAN = "\033[38;5;80m" # cli-anything brand cyan
_CYAN_BG = "\033[48;5;80m"
_WHITE = "\033[97m"
_GRAY = "\033[38;5;245m"
_DARK_GRAY = "\033[38;5;240m"
_LIGHT_GRAY = "\033[38;5;250m"
# Software accent colors — each software gets a unique accent
_ACCENT_COLORS = {
"gimp": "\033[38;5;214m", # warm orange
"blender": "\033[38;5;208m", # deep orange
"inkscape": "\033[38;5;39m", # bright blue
"audacity": "\033[38;5;33m", # navy blue
"libreoffice": "\033[38;5;40m", # green
"obs_studio": "\033[38;5;55m", # purple
"kdenlive": "\033[38;5;69m", # slate blue
"shotcut": "\033[38;5;35m", # teal green
}
_DEFAULT_ACCENT = "\033[38;5;75m" # default sky blue
# Status colors
_GREEN = "\033[38;5;78m"
_YELLOW = "\033[38;5;220m"
_RED = "\033[38;5;196m"
_BLUE = "\033[38;5;75m"
_MAGENTA = "\033[38;5;176m"
# ── Brand icon ────────────────────────────────────────────────────────
# The cli-anything icon: a small colored diamond/chevron mark
_ICON = f"{_CYAN}{_BOLD}{_RESET}"
_ICON_SMALL = f"{_CYAN}{_RESET}"
# ── Box drawing characters ────────────────────────────────────────────
_H_LINE = ""
_V_LINE = ""
_TL = ""
_TR = ""
_BL = ""
_BR = ""
_T_DOWN = ""
_T_UP = ""
_T_RIGHT = ""
_T_LEFT = ""
_CROSS = ""
def _strip_ansi(text: str) -> str:
"""Remove ANSI escape codes for length calculation."""
import re
return re.sub(r"\033\[[^m]*m", "", text)
def _visible_len(text: str) -> int:
"""Get visible length of text (excluding ANSI codes)."""
return len(_strip_ansi(text))
class ReplSkin:
"""Unified REPL skin for cli-anything CLIs.
Provides consistent branding, prompts, and message formatting
across all CLI harnesses built with the cli-anything methodology.
"""
def __init__(self, software: str, version: str = "1.0.0",
history_file: str | None = None, skill_path: str | None = None):
"""Initialize the REPL skin.
Args:
software: Software name (e.g., "gimp", "shotcut", "blender").
version: CLI version string.
history_file: Path for persistent command history.
Defaults to ~/.cli-anything-<software>/history
skill_path: Path to the SKILL.md file for agent discovery.
Auto-detected from the package's skills/ directory if not provided.
Displayed in banner for AI agents to know where to read skill info.
"""
self.software = software.lower().replace("-", "_")
self.display_name = software.replace("_", " ").title()
self.version = version
# Auto-detect skill path from package layout:
# cli_anything/<software>/utils/repl_skin.py (this file)
# cli_anything/<software>/skills/SKILL.md (target)
if skill_path is None:
from pathlib import Path
_auto = Path(__file__).resolve().parent.parent / "skills" / "SKILL.md"
if _auto.is_file():
skill_path = str(_auto)
self.skill_path = skill_path
self.accent = _ACCENT_COLORS.get(self.software, _DEFAULT_ACCENT)
# History file
if history_file is None:
from pathlib import Path
hist_dir = Path.home() / f".cli-anything-{self.software}"
hist_dir.mkdir(parents=True, exist_ok=True)
self.history_file = str(hist_dir / "history")
else:
self.history_file = history_file
# Detect terminal capabilities
self._color = self._detect_color_support()
def _detect_color_support(self) -> bool:
"""Check if terminal supports color."""
if os.environ.get("NO_COLOR"):
return False
if os.environ.get("CLI_ANYTHING_NO_COLOR"):
return False
if not hasattr(sys.stdout, "isatty"):
return False
return sys.stdout.isatty()
def _c(self, code: str, text: str) -> str:
"""Apply color code if colors are supported."""
if not self._color:
return text
return f"{code}{text}{_RESET}"
# ── Banner ────────────────────────────────────────────────────────
def print_banner(self):
"""Print the startup banner with branding."""
inner = 54
def _box_line(content: str) -> str:
"""Wrap content in box drawing, padding to inner width."""
pad = inner - _visible_len(content)
vl = self._c(_DARK_GRAY, _V_LINE)
return f"{vl}{content}{' ' * max(0, pad)}{vl}"
top = self._c(_DARK_GRAY, f"{_TL}{_H_LINE * inner}{_TR}")
bot = self._c(_DARK_GRAY, f"{_BL}{_H_LINE * inner}{_BR}")
# Title: ◆ cli-anything · Shotcut
icon = self._c(_CYAN + _BOLD, "")
brand = self._c(_CYAN + _BOLD, "cli-anything")
dot = self._c(_DARK_GRAY, "·")
name = self._c(self.accent + _BOLD, self.display_name)
title = f" {icon} {brand} {dot} {name}"
ver = f" {self._c(_DARK_GRAY, f' v{self.version}')}"
tip = f" {self._c(_DARK_GRAY, ' Type help for commands, quit to exit')}"
empty = ""
# Skill path for agent discovery
skill_line = None
if self.skill_path:
skill_icon = self._c(_MAGENTA, "")
skill_label = self._c(_DARK_GRAY, " Skill:")
skill_path_display = self._c(_LIGHT_GRAY, self.skill_path)
skill_line = f" {skill_icon} {skill_label} {skill_path_display}"
print(top)
print(_box_line(title))
print(_box_line(ver))
if skill_line:
print(_box_line(skill_line))
print(_box_line(empty))
print(_box_line(tip))
print(bot)
print()
# ── Prompt ────────────────────────────────────────────────────────
def prompt(self, project_name: str = "", modified: bool = False,
context: str = "") -> str:
"""Build a styled prompt string for prompt_toolkit or input().
Args:
project_name: Current project name (empty if none open).
modified: Whether the project has unsaved changes.
context: Optional extra context to show in prompt.
Returns:
Formatted prompt string.
"""
parts = []
# Icon
if self._color:
parts.append(f"{_CYAN}{_RESET} ")
else:
parts.append("> ")
# Software name
parts.append(self._c(self.accent + _BOLD, self.software))
# Project context
if project_name or context:
ctx = context or project_name
mod = "*" if modified else ""
parts.append(f" {self._c(_DARK_GRAY, '[')}")
parts.append(self._c(_LIGHT_GRAY, f"{ctx}{mod}"))
parts.append(self._c(_DARK_GRAY, ']'))
parts.append(self._c(_GRAY, " "))
return "".join(parts)
def prompt_tokens(self, project_name: str = "", modified: bool = False,
context: str = ""):
"""Build prompt_toolkit formatted text tokens for the prompt.
Use with prompt_toolkit's FormattedText for proper ANSI handling.
Returns:
list of (style, text) tuples for prompt_toolkit.
"""
accent_hex = _ANSI_256_TO_HEX.get(self.accent, "#5fafff")
tokens = []
tokens.append(("class:icon", ""))
tokens.append(("class:software", self.software))
if project_name or context:
ctx = context or project_name
mod = "*" if modified else ""
tokens.append(("class:bracket", " ["))
tokens.append(("class:context", f"{ctx}{mod}"))
tokens.append(("class:bracket", "]"))
tokens.append(("class:arrow", " "))
return tokens
def get_prompt_style(self):
"""Get a prompt_toolkit Style object matching the skin.
Returns:
prompt_toolkit.styles.Style
"""
try:
from prompt_toolkit.styles import Style
except ImportError:
return None
accent_hex = _ANSI_256_TO_HEX.get(self.accent, "#5fafff")
return Style.from_dict({
"icon": "#5fdfdf bold", # cyan brand color
"software": f"{accent_hex} bold",
"bracket": "#585858",
"context": "#bcbcbc",
"arrow": "#808080",
# Completion menu
"completion-menu.completion": "bg:#303030 #bcbcbc",
"completion-menu.completion.current": f"bg:{accent_hex} #000000",
"completion-menu.meta.completion": "bg:#303030 #808080",
"completion-menu.meta.completion.current": f"bg:{accent_hex} #000000",
# Auto-suggest
"auto-suggest": "#585858",
# Bottom toolbar
"bottom-toolbar": "bg:#1c1c1c #808080",
"bottom-toolbar.text": "#808080",
})
# ── Messages ──────────────────────────────────────────────────────
def success(self, message: str):
"""Print a success message with green checkmark."""
icon = self._c(_GREEN + _BOLD, "")
print(f" {icon} {self._c(_GREEN, message)}")
def error(self, message: str):
"""Print an error message with red cross."""
icon = self._c(_RED + _BOLD, "")
print(f" {icon} {self._c(_RED, message)}", file=sys.stderr)
def warning(self, message: str):
"""Print a warning message with yellow triangle."""
icon = self._c(_YELLOW + _BOLD, "")
print(f" {icon} {self._c(_YELLOW, message)}")
def info(self, message: str):
"""Print an info message with blue dot."""
icon = self._c(_BLUE, "")
print(f" {icon} {self._c(_LIGHT_GRAY, message)}")
def hint(self, message: str):
"""Print a subtle hint message."""
print(f" {self._c(_DARK_GRAY, message)}")
def section(self, title: str):
"""Print a section header."""
print()
print(f" {self._c(self.accent + _BOLD, title)}")
print(f" {self._c(_DARK_GRAY, _H_LINE * len(title))}")
# ── Status display ────────────────────────────────────────────────
def status(self, label: str, value: str):
"""Print a key-value status line."""
lbl = self._c(_GRAY, f" {label}:")
val = self._c(_WHITE, f" {value}")
print(f"{lbl}{val}")
def status_block(self, items: dict[str, str], title: str = ""):
"""Print a block of status key-value pairs.
Args:
items: Dict of label -> value pairs.
title: Optional title for the block.
"""
if title:
self.section(title)
max_key = max(len(k) for k in items) if items else 0
for label, value in items.items():
lbl = self._c(_GRAY, f" {label:<{max_key}}")
val = self._c(_WHITE, f" {value}")
print(f"{lbl}{val}")
def progress(self, current: int, total: int, label: str = ""):
"""Print a simple progress indicator.
Args:
current: Current step number.
total: Total number of steps.
label: Optional label for the progress.
"""
pct = int(current / total * 100) if total > 0 else 0
bar_width = 20
filled = int(bar_width * current / total) if total > 0 else 0
bar = "" * filled + "" * (bar_width - filled)
text = f" {self._c(_CYAN, bar)} {self._c(_GRAY, f'{pct:3d}%')}"
if label:
text += f" {self._c(_LIGHT_GRAY, label)}"
print(text)
# ── Table display ─────────────────────────────────────────────────
def table(self, headers: list[str], rows: list[list[str]],
max_col_width: int = 40):
"""Print a formatted table with box-drawing characters.
Args:
headers: Column header strings.
rows: List of rows, each a list of cell strings.
max_col_width: Maximum column width before truncation.
"""
if not headers:
return
# Calculate column widths
col_widths = [min(len(h), max_col_width) for h in headers]
for row in rows:
for i, cell in enumerate(row):
if i < len(col_widths):
col_widths[i] = min(
max(col_widths[i], len(str(cell))), max_col_width
)
def pad(text: str, width: int) -> str:
t = str(text)[:width]
return t + " " * (width - len(t))
# Header
header_cells = [
self._c(_CYAN + _BOLD, pad(h, col_widths[i]))
for i, h in enumerate(headers)
]
sep = self._c(_DARK_GRAY, f" {_V_LINE} ")
header_line = f" {sep.join(header_cells)}"
print(header_line)
# Separator
sep_parts = [self._c(_DARK_GRAY, _H_LINE * w) for w in col_widths]
sep_line = self._c(_DARK_GRAY, f" {'───'.join([_H_LINE * w for w in col_widths])}")
print(sep_line)
# Rows
for row in rows:
cells = []
for i, cell in enumerate(row):
if i < len(col_widths):
cells.append(self._c(_LIGHT_GRAY, pad(str(cell), col_widths[i])))
row_sep = self._c(_DARK_GRAY, f" {_V_LINE} ")
print(f" {row_sep.join(cells)}")
# ── Help display ──────────────────────────────────────────────────
def help(self, commands: dict[str, str]):
"""Print a formatted help listing.
Args:
commands: Dict of command -> description pairs.
"""
self.section("Commands")
max_cmd = max(len(c) for c in commands) if commands else 0
for cmd, desc in commands.items():
cmd_styled = self._c(self.accent, f" {cmd:<{max_cmd}}")
desc_styled = self._c(_GRAY, f" {desc}")
print(f"{cmd_styled}{desc_styled}")
print()
# ── Goodbye ───────────────────────────────────────────────────────
def print_goodbye(self):
"""Print a styled goodbye message."""
print(f"\n {_ICON_SMALL} {self._c(_GRAY, 'Goodbye!')}\n")
# ── Prompt toolkit session factory ────────────────────────────────
def create_prompt_session(self):
"""Create a prompt_toolkit PromptSession with skin styling.
Returns:
A configured PromptSession, or None if prompt_toolkit unavailable.
"""
try:
from prompt_toolkit import PromptSession
from prompt_toolkit.history import FileHistory
from prompt_toolkit.auto_suggest import AutoSuggestFromHistory
from prompt_toolkit.formatted_text import FormattedText
style = self.get_prompt_style()
session = PromptSession(
history=FileHistory(self.history_file),
auto_suggest=AutoSuggestFromHistory(),
style=style,
enable_history_search=True,
)
return session
except ImportError:
return None
def get_input(self, pt_session, project_name: str = "",
modified: bool = False, context: str = "") -> str:
"""Get input from user using prompt_toolkit or fallback.
Args:
pt_session: A prompt_toolkit PromptSession (or None).
project_name: Current project name.
modified: Whether project has unsaved changes.
context: Optional context string.
Returns:
User input string (stripped).
"""
if pt_session is not None:
from prompt_toolkit.formatted_text import FormattedText
tokens = self.prompt_tokens(project_name, modified, context)
return pt_session.prompt(FormattedText(tokens)).strip()
else:
raw_prompt = self.prompt(project_name, modified, context)
return input(raw_prompt).strip()
# ── Toolbar builder ───────────────────────────────────────────────
def bottom_toolbar(self, items: dict[str, str]):
"""Create a bottom toolbar callback for prompt_toolkit.
Args:
items: Dict of label -> value pairs to show in toolbar.
Returns:
A callable that returns FormattedText for the toolbar.
"""
def toolbar():
from prompt_toolkit.formatted_text import FormattedText
parts = []
for i, (k, v) in enumerate(items.items()):
if i > 0:
parts.append(("class:bottom-toolbar.text", ""))
parts.append(("class:bottom-toolbar.text", f" {k}: "))
parts.append(("class:bottom-toolbar", v))
return FormattedText(parts)
return toolbar
# ── ANSI 256-color to hex mapping (for prompt_toolkit styles) ─────────
_ANSI_256_TO_HEX = {
"\033[38;5;33m": "#0087ff", # audacity navy blue
"\033[38;5;35m": "#00af5f", # shotcut teal
"\033[38;5;39m": "#00afff", # inkscape bright blue
"\033[38;5;40m": "#00d700", # libreoffice green
"\033[38;5;55m": "#5f00af", # obs purple
"\033[38;5;69m": "#5f87ff", # kdenlive slate blue
"\033[38;5;75m": "#5fafff", # default sky blue
"\033[38;5;80m": "#5fd7d7", # brand cyan
"\033[38;5;208m": "#ff8700", # blender deep orange
"\033[38;5;214m": "#ffaf00", # gimp warm orange
}

View File

@@ -0,0 +1,230 @@
from __future__ import annotations
import json
import time
import urllib.error
import urllib.parse
import urllib.request
from dataclasses import dataclass
from typing import Any, Optional
LOCAL_API_VERSION = "3"
@dataclass
class HttpResponse:
status: int
headers: dict[str, str]
body: str
def json(self) -> Any:
return json.loads(self.body)
def _build_url(port: int, path: str, params: Optional[dict[str, Any]] = None) -> str:
if not path.startswith("/"):
path = "/" + path
url = f"http://127.0.0.1:{port}{path}"
if params:
pairs: list[tuple[str, str]] = []
for key, value in params.items():
if value is None:
continue
if isinstance(value, (list, tuple)):
for entry in value:
pairs.append((key, str(entry)))
else:
pairs.append((key, str(value)))
if pairs:
url += "?" + urllib.parse.urlencode(pairs, doseq=True)
return url
def request(
port: int,
path: str,
*,
method: str = "GET",
params: Optional[dict[str, Any]] = None,
payload: Optional[dict[str, Any]] = None,
data: bytes | str | None = None,
headers: Optional[dict[str, str]] = None,
timeout: int = 5,
) -> HttpResponse:
request_headers = {"Accept": "*/*"}
if headers:
request_headers.update(headers)
if payload is not None and data is not None:
raise ValueError("payload and data are mutually exclusive")
body_data: bytes | None = None
if payload is not None:
request_headers.setdefault("Content-Type", "application/json")
body_data = json.dumps(payload).encode("utf-8")
elif data is not None:
body_data = data.encode("utf-8") if isinstance(data, str) else data
req = urllib.request.Request(
_build_url(port, path, params=params),
data=body_data,
headers=request_headers,
method=method,
)
try:
with urllib.request.urlopen(req, timeout=timeout) as response:
body = response.read().decode("utf-8", errors="replace")
return HttpResponse(response.getcode(), {k: v for k, v in response.headers.items()}, body)
except urllib.error.HTTPError as exc:
body = exc.read().decode("utf-8", errors="replace")
return HttpResponse(exc.code, {k: v for k, v in exc.headers.items()}, body)
except urllib.error.URLError as exc:
raise RuntimeError(f"HTTP request failed for {path}: {exc}") from exc
def connector_ping(port: int, timeout: int = 3) -> HttpResponse:
return request(port, "/connector/ping", timeout=timeout)
def connector_is_available(port: int, timeout: int = 3) -> tuple[bool, str]:
try:
response = connector_ping(port, timeout=timeout)
except RuntimeError as exc:
return False, str(exc)
if response.status == 200:
return True, "connector available"
return False, f"connector returned HTTP {response.status}"
def get_selected_collection(port: int, timeout: int = 5) -> dict[str, Any]:
response = request(port, "/connector/getSelectedCollection", method="POST", payload={}, timeout=timeout)
if response.status != 200:
raise RuntimeError(f"connector/getSelectedCollection returned HTTP {response.status}: {response.body}")
return response.json()
def connector_import_text(port: int, content: str, *, session_id: str | None = None, timeout: int = 20) -> list[dict[str, Any]]:
params = {"session": session_id} if session_id else None
response = request(port, "/connector/import", method="POST", params=params, data=content, timeout=timeout)
if response.status != 201:
raise RuntimeError(f"connector/import returned HTTP {response.status}: {response.body}")
parsed = response.json()
return parsed if isinstance(parsed, list) else [parsed]
def connector_save_items(port: int, items: list[dict[str, Any]], *, session_id: str, timeout: int = 20) -> None:
response = request(
port,
"/connector/saveItems",
method="POST",
payload={"sessionID": session_id, "items": items},
timeout=timeout,
)
if response.status != 201:
raise RuntimeError(f"connector/saveItems returned HTTP {response.status}: {response.body}")
def connector_save_attachment(
port: int,
*,
session_id: str,
parent_item_id: str | int,
title: str,
url: str,
content: bytes,
timeout: int = 60,
) -> dict[str, Any]:
response = request(
port,
"/connector/saveAttachment",
method="POST",
data=content,
headers={
"Content-Type": "application/pdf",
"X-Metadata": json.dumps(
{
"sessionID": session_id,
"parentItemID": str(parent_item_id),
"title": title,
"url": url,
}
),
},
timeout=timeout,
)
if response.status not in (200, 201):
raise RuntimeError(f"connector/saveAttachment returned HTTP {response.status}: {response.body}")
return response.json() if response.body else {}
def connector_update_session(
port: int,
*,
session_id: str,
target: str,
tags: list[str] | tuple[str, ...] | None = None,
timeout: int = 15,
) -> dict[str, Any]:
response = request(
port,
"/connector/updateSession",
method="POST",
payload={
"sessionID": session_id,
"target": target,
"tags": ", ".join(tag for tag in (tags or []) if str(tag).strip()),
},
timeout=timeout,
)
if response.status != 200:
raise RuntimeError(f"connector/updateSession returned HTTP {response.status}: {response.body}")
return response.json() if response.body else {}
def local_api_root(port: int, timeout: int = 3) -> HttpResponse:
return request(port, "/api/", headers={"Zotero-API-Version": LOCAL_API_VERSION}, timeout=timeout)
def local_api_is_available(port: int, timeout: int = 3) -> tuple[bool, str]:
try:
response = local_api_root(port, timeout=timeout)
except RuntimeError as exc:
return False, str(exc)
if response.status == 200:
return True, "local API available"
if response.status == 403:
return False, "local API disabled"
return False, f"local API returned HTTP {response.status}"
def wait_for_endpoint(
port: int,
path: str,
*,
timeout: int = 30,
poll_interval: float = 0.5,
headers: Optional[dict[str, str]] = None,
ready_statuses: tuple[int, ...] = (200,),
) -> bool:
deadline = time.time() + timeout
while time.time() < deadline:
try:
response = request(port, path, headers=headers, timeout=3)
if response.status in ready_statuses:
return True
except RuntimeError:
pass
time.sleep(poll_interval)
return False
def local_api_get_json(port: int, path: str, params: Optional[dict[str, Any]] = None, timeout: int = 10) -> Any:
response = request(port, path, params=params, headers={"Zotero-API-Version": LOCAL_API_VERSION, "Accept": "application/json"}, timeout=timeout)
if response.status != 200:
raise RuntimeError(f"Local API returned HTTP {response.status} for {path}: {response.body}")
return response.json()
def local_api_get_text(port: int, path: str, params: Optional[dict[str, Any]] = None, timeout: int = 15) -> str:
response = request(port, path, params=params, headers={"Zotero-API-Version": LOCAL_API_VERSION}, timeout=timeout)
if response.status != 200:
raise RuntimeError(f"Local API returned HTTP {response.status} for {path}: {response.body}")
return response.body

View File

@@ -0,0 +1,298 @@
from __future__ import annotations
import configparser
import os
import re
import shutil
from dataclasses import asdict, dataclass
from pathlib import Path
from typing import Mapping, Optional
DATA_DIR_PREF = "extensions.zotero.dataDir"
USE_DATA_DIR_PREF = "extensions.zotero.useDataDir"
LOCAL_API_PREF = "extensions.zotero.httpServer.localAPI.enabled"
HTTP_PORT_PREF = "extensions.zotero.httpServer.port"
@dataclass
class ZoteroEnvironment:
executable: Optional[Path]
executable_exists: bool
install_dir: Optional[Path]
version: str
profile_root: Path
profile_dir: Optional[Path]
data_dir: Path
data_dir_exists: bool
sqlite_path: Path
sqlite_exists: bool
styles_dir: Path
styles_exists: bool
storage_dir: Path
storage_exists: bool
translators_dir: Path
translators_exists: bool
port: int
local_api_enabled_configured: bool
def to_dict(self) -> dict:
data = asdict(self)
for key, value in data.items():
if isinstance(value, Path):
data[key] = str(value)
return data
def candidate_profile_roots(env: Mapping[str, str] | None = None, home: Path | None = None) -> list[Path]:
env = env or os.environ
home = home or Path.home()
candidates: list[Path] = []
def add(path: Path | str | None) -> None:
if not path:
return
candidate = Path(path).expanduser()
if candidate not in candidates:
candidates.append(candidate)
appdata = env.get("APPDATA")
if appdata:
add(Path(appdata) / "Zotero" / "Zotero")
add(home / "AppData" / "Roaming" / "Zotero" / "Zotero")
add(home / "Library" / "Application Support" / "Zotero")
add(home / ".zotero" / "zotero")
return candidates
def find_profile_root(explicit_profile_dir: str | None = None, env: Mapping[str, str] | None = None) -> Path:
env = env or os.environ
if explicit_profile_dir:
explicit = Path(explicit_profile_dir).expanduser()
if explicit.name == "profiles.ini":
return explicit.parent
if (explicit / "profiles.ini").exists():
return explicit
if (explicit.parent / "profiles.ini").exists():
return explicit.parent
return explicit
env_profile = env.get("ZOTERO_PROFILE_DIR", "").strip()
if env_profile:
return find_profile_root(env_profile, env=env)
for candidate in candidate_profile_roots(env=env):
if (candidate / "profiles.ini").exists():
return candidate
return candidate_profile_roots(env=env)[0]
def read_profiles_ini(profile_root: Path) -> configparser.ConfigParser:
config = configparser.ConfigParser()
path = profile_root / "profiles.ini"
if path.exists():
config.read(path, encoding="utf-8")
return config
def find_active_profile(profile_root: Path) -> Optional[Path]:
config = read_profiles_ini(profile_root)
ordered_sections = [section for section in config.sections() if section.lower().startswith("profile")]
for section in ordered_sections:
if config.get(section, "Default", fallback="0").strip() != "1":
continue
return _profile_path_from_section(profile_root, config, section)
for section in ordered_sections:
candidate = _profile_path_from_section(profile_root, config, section)
if candidate is not None:
return candidate
return None
def _profile_path_from_section(profile_root: Path, config: configparser.ConfigParser, section: str) -> Optional[Path]:
path_value = config.get(section, "Path", fallback="").strip()
if not path_value:
return None
is_relative = config.get(section, "IsRelative", fallback="1").strip() == "1"
return (profile_root / path_value).resolve() if is_relative else Path(path_value).expanduser()
def _read_pref_file(path: Path) -> str:
if not path.exists():
return ""
for encoding in ("utf-8", "utf-8-sig", "latin-1"):
try:
return path.read_text(encoding=encoding)
except UnicodeDecodeError:
continue
return path.read_text(errors="replace")
def _decode_pref_string(raw: str) -> str:
return raw.replace("\\\\", "\\").replace('\\"', '"')
def read_pref(profile_dir: Path | None, pref_name: str) -> Optional[str]:
if profile_dir is None:
return None
pattern = re.compile(rf'user_pref\("{re.escape(pref_name)}",\s*(.+?)\);')
for filename in ("user.js", "prefs.js"):
text = _read_pref_file(profile_dir / filename)
for line in text.splitlines():
match = pattern.search(line)
if not match:
continue
raw = match.group(1).strip()
if raw in {"true", "false"}:
return raw
if raw.startswith('"') and raw.endswith('"'):
return _decode_pref_string(raw[1:-1])
return raw
return None
def find_data_dir(profile_dir: Path | None, explicit_data_dir: str | None = None, env: Mapping[str, str] | None = None) -> Path:
env = env or os.environ
if explicit_data_dir:
return Path(explicit_data_dir).expanduser()
env_data_dir = env.get("ZOTERO_DATA_DIR", "").strip()
if env_data_dir:
return Path(env_data_dir).expanduser()
if profile_dir is not None:
use_data_dir = read_pref(profile_dir, USE_DATA_DIR_PREF)
pref_data_dir = read_pref(profile_dir, DATA_DIR_PREF)
if use_data_dir == "true" and pref_data_dir:
candidate = Path(pref_data_dir).expanduser()
if candidate.exists():
return candidate
return Path.home() / "Zotero"
def find_executable(explicit_executable: str | None = None, env: Mapping[str, str] | None = None) -> Optional[Path]:
env = env or os.environ
if explicit_executable:
return Path(explicit_executable).expanduser()
env_executable = env.get("ZOTERO_EXECUTABLE", "").strip()
if env_executable:
return Path(env_executable).expanduser()
for name in ("zotero", "zotero.exe"):
path = shutil.which(name)
if path:
return Path(path)
candidates = [
Path(r"C:\Program Files\Zotero\zotero.exe"),
Path(r"C:\Program Files (x86)\Zotero\zotero.exe"),
Path("/Applications/Zotero.app/Contents/MacOS/zotero"),
Path("/usr/lib/zotero/zotero"),
Path("/usr/local/bin/zotero"),
]
for candidate in candidates:
if candidate.exists():
return candidate
return candidates[0]
def find_install_dir(executable: Optional[Path]) -> Optional[Path]:
if executable is None:
return None
return executable.parent
def get_version(install_dir: Optional[Path]) -> str:
if install_dir is None:
return "unknown"
candidates = [install_dir / "app" / "application.ini", install_dir / "application.ini"]
for candidate in candidates:
if not candidate.exists():
continue
text = _read_pref_file(candidate)
match = re.search(r"^Version=(.+)$", text, re.MULTILINE)
if match:
return match.group(1).strip()
return "unknown"
def get_http_port(profile_dir: Path | None, env: Mapping[str, str] | None = None) -> int:
env = env or os.environ
env_port = env.get("ZOTERO_HTTP_PORT", "").strip()
if env_port:
try:
return int(env_port)
except ValueError:
pass
pref_port = read_pref(profile_dir, HTTP_PORT_PREF)
if pref_port:
try:
return int(pref_port)
except ValueError:
pass
return 23119
def is_local_api_enabled(profile_dir: Path | None) -> bool:
return read_pref(profile_dir, LOCAL_API_PREF) == "true"
def build_environment(
explicit_data_dir: str | None = None,
explicit_profile_dir: str | None = None,
explicit_executable: str | None = None,
env: Mapping[str, str] | None = None,
) -> ZoteroEnvironment:
env = env or os.environ
profile_root = find_profile_root(explicit_profile_dir=explicit_profile_dir, env=env)
env_profile_dir = env.get("ZOTERO_PROFILE_DIR", "").strip()
explicit_or_env_profile = explicit_profile_dir or env_profile_dir or None
profile_dir = (
Path(explicit_or_env_profile).expanduser()
if explicit_or_env_profile and (Path(explicit_or_env_profile) / "prefs.js").exists()
else find_active_profile(profile_root)
)
executable = find_executable(explicit_executable=explicit_executable, env=env)
install_dir = find_install_dir(executable)
data_dir = find_data_dir(profile_dir, explicit_data_dir=explicit_data_dir, env=env)
sqlite_path = data_dir / "zotero.sqlite"
styles_dir = data_dir / "styles"
storage_dir = data_dir / "storage"
translators_dir = data_dir / "translators"
return ZoteroEnvironment(
executable=executable,
executable_exists=bool(executable and executable.exists()),
install_dir=install_dir,
version=get_version(install_dir),
profile_root=profile_root,
profile_dir=profile_dir,
data_dir=data_dir,
data_dir_exists=data_dir.exists(),
sqlite_path=sqlite_path,
sqlite_exists=sqlite_path.exists(),
styles_dir=styles_dir,
styles_exists=styles_dir.exists(),
storage_dir=storage_dir,
storage_exists=storage_dir.exists(),
translators_dir=translators_dir,
translators_exists=translators_dir.exists(),
port=get_http_port(profile_dir, env=env),
local_api_enabled_configured=is_local_api_enabled(profile_dir),
)
def ensure_local_api_enabled(profile_dir: Path | None) -> Optional[Path]:
if profile_dir is None:
return None
user_js = profile_dir / "user.js"
existing = _read_pref_file(user_js)
line = 'user_pref("extensions.zotero.httpServer.localAPI.enabled", true);'
if line not in existing:
content = existing.rstrip()
if content:
content += "\n"
content += line + "\n"
user_js.write_text(content, encoding="utf-8")
return user_js

View File

@@ -0,0 +1,743 @@
from __future__ import annotations
import html
import os
import random
import re
import shutil
import sqlite3
from contextlib import closing
from datetime import datetime, timezone
from pathlib import Path, PureWindowsPath
from typing import Any, Optional
from urllib.parse import unquote, urlparse
KEY_ALPHABET = "23456789ABCDEFGHIJKLMNPQRSTUVWXYZ"
NOTE_PREVIEW_LENGTH = 160
_TAG_RE = re.compile(r"<[^>]+>")
class AmbiguousReferenceError(RuntimeError):
"""Raised when a bare Zotero key matches records in multiple libraries."""
def connect_readonly(sqlite_path: Path | str) -> sqlite3.Connection:
path = Path(sqlite_path).resolve()
if not path.exists():
raise FileNotFoundError(f"Zotero database not found: {path}")
uri = f"file:{path.as_posix()}?mode=ro&immutable=1"
connection = sqlite3.connect(uri, uri=True, timeout=1.0)
connection.row_factory = sqlite3.Row
return connection
def connect_writable(sqlite_path: Path | str) -> sqlite3.Connection:
path = Path(sqlite_path).resolve()
if not path.exists():
raise FileNotFoundError(f"Zotero database not found: {path}")
connection = sqlite3.connect(path, timeout=30.0)
connection.row_factory = sqlite3.Row
return connection
def _as_dicts(rows: list[sqlite3.Row]) -> list[dict[str, Any]]:
return [dict(row) for row in rows]
def _is_numeric_ref(value: Any) -> bool:
try:
int(str(value))
return True
except (TypeError, ValueError):
return False
def normalize_library_ref(library_ref: str | int) -> int:
text = str(library_ref).strip()
if not text:
raise RuntimeError("Library reference must not be empty")
upper = text.upper()
if upper.startswith("L") and upper[1:].isdigit():
return int(upper[1:])
if text.isdigit():
return int(text)
raise RuntimeError(f"Unsupported library reference: {library_ref}")
def _timestamp_text() -> str:
return datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
def generate_object_key(length: int = 8) -> str:
chooser = random.SystemRandom()
return "".join(chooser.choice(KEY_ALPHABET) for _ in range(length))
def backup_database(sqlite_path: Path | str) -> Path:
source = Path(sqlite_path).resolve()
timestamp = datetime.now(timezone.utc).strftime("%Y%m%d%H%M%S")
backup = source.with_name(f"{source.stem}.backup-{timestamp}{source.suffix}")
shutil.copy2(source, backup)
return backup
def note_html_to_text(note_html: str | None) -> str:
if not note_html:
return ""
text = re.sub(r"(?i)<br\s*/?>", "\n", note_html)
text = re.sub(r"(?i)</p\s*>", "\n\n", text)
text = re.sub(r"(?i)</div\s*>", "\n", text)
text = _TAG_RE.sub("", text)
text = html.unescape(text)
text = text.replace("\r\n", "\n").replace("\r", "\n")
text = re.sub(r"\n{3,}", "\n\n", text)
return text.strip()
def note_preview(note_html: str | None, limit: int = NOTE_PREVIEW_LENGTH) -> str:
text = note_html_to_text(note_html)
if len(text) <= limit:
return text
return text[: max(0, limit - 1)].rstrip() + ""
def fetch_libraries(sqlite_path: Path | str) -> list[dict[str, Any]]:
with closing(connect_readonly(sqlite_path)) as conn:
rows = conn.execute(
"""
SELECT libraryID, type, editable, filesEditable, version, storageVersion, lastSync, archived
FROM libraries
ORDER BY libraryID
"""
).fetchall()
return _as_dicts(rows)
def resolve_library(sqlite_path: Path | str, ref: str | int) -> Optional[dict[str, Any]]:
library_id = normalize_library_ref(ref)
with closing(connect_readonly(sqlite_path)) as conn:
row = conn.execute(
"""
SELECT libraryID, type, editable, filesEditable, version, storageVersion, lastSync, archived
FROM libraries
WHERE libraryID = ?
""",
(library_id,),
).fetchone()
return dict(row) if row else None
def default_library_id(sqlite_path: Path | str) -> Optional[int]:
libraries = fetch_libraries(sqlite_path)
if not libraries:
return None
for library in libraries:
if library["type"] == "user":
return int(library["libraryID"])
return int(libraries[0]["libraryID"])
def fetch_collections(sqlite_path: Path | str, library_id: int | None = None) -> list[dict[str, Any]]:
with closing(connect_readonly(sqlite_path)) as conn:
rows = conn.execute(
"""
SELECT
c.collectionID,
c.key,
c.collectionName,
c.parentCollectionID,
c.libraryID,
c.version,
COUNT(ci.itemID) AS itemCount
FROM collections c
LEFT JOIN collectionItems ci ON ci.collectionID = c.collectionID
WHERE (? IS NULL OR c.libraryID = ?)
GROUP BY c.collectionID, c.key, c.collectionName, c.parentCollectionID, c.libraryID, c.version
ORDER BY c.collectionName COLLATE NOCASE
""",
(library_id, library_id),
).fetchall()
return _as_dicts(rows)
def find_collections(sqlite_path: Path | str, query: str, *, library_id: int | None = None, limit: int = 20) -> list[dict[str, Any]]:
query = query.strip()
if not query:
return []
needle = query.lower()
like_query = f"%{needle}%"
prefix_query = f"{needle}%"
with closing(connect_readonly(sqlite_path)) as conn:
rows = conn.execute(
"""
SELECT
c.collectionID,
c.key,
c.collectionName,
c.parentCollectionID,
c.libraryID,
c.version,
COUNT(ci.itemID) AS itemCount
FROM collections c
LEFT JOIN collectionItems ci ON ci.collectionID = c.collectionID
WHERE (? IS NULL OR c.libraryID = ?) AND LOWER(c.collectionName) LIKE ?
GROUP BY c.collectionID, c.key, c.collectionName, c.parentCollectionID, c.libraryID, c.version
ORDER BY
CASE
WHEN LOWER(c.collectionName) = ? THEN 0
WHEN LOWER(c.collectionName) LIKE ? THEN 1
ELSE 2
END,
INSTR(LOWER(c.collectionName), ?),
c.collectionName COLLATE NOCASE,
c.collectionID
LIMIT ?
""",
(library_id, library_id, like_query, needle, prefix_query, needle, int(limit)),
).fetchall()
return _as_dicts(rows)
def build_collection_tree(collections: list[dict[str, Any]]) -> list[dict[str, Any]]:
by_id: dict[int, dict[str, Any]] = {}
roots: list[dict[str, Any]] = []
for collection in collections:
node = {**collection, "children": []}
by_id[int(collection["collectionID"])] = node
for collection in collections:
node = by_id[int(collection["collectionID"])]
parent_id = collection["parentCollectionID"]
if parent_id is None:
roots.append(node)
continue
parent = by_id.get(int(parent_id))
if parent is None:
roots.append(node)
else:
parent["children"].append(node)
return roots
def _ambiguous_reference(ref: str | int, kind: str, rows: list[sqlite3.Row]) -> None:
libraries = sorted({int(row["libraryID"]) for row in rows if "libraryID" in row.keys()})
library_text = ", ".join(f"L{library_id}" for library_id in libraries) or "multiple libraries"
raise AmbiguousReferenceError(
f"Ambiguous {kind} reference: {ref}. Matches found in {library_text}. "
"Set the library with `session use-library <id>` and retry."
)
def resolve_collection(sqlite_path: Path | str, ref: str | int, *, library_id: int | None = None) -> Optional[dict[str, Any]]:
with closing(connect_readonly(sqlite_path)) as conn:
if _is_numeric_ref(ref):
row = conn.execute(
"SELECT collectionID, key, collectionName, parentCollectionID, libraryID, version FROM collections WHERE collectionID = ?",
(int(ref),),
).fetchone()
else:
params: list[Any] = [str(ref)]
sql = "SELECT collectionID, key, collectionName, parentCollectionID, libraryID, version FROM collections WHERE key = ?"
if library_id is not None:
sql += " AND libraryID = ?"
params.append(int(library_id))
sql += " ORDER BY libraryID, collectionID"
rows = conn.execute(sql, params).fetchall()
if not rows:
return None
if len(rows) > 1 and library_id is None:
_ambiguous_reference(ref, "collection", rows)
row = rows[0]
return dict(row) if row else None
def fetch_item_collections(sqlite_path: Path | str, ref: str | int) -> list[dict[str, Any]]:
item = resolve_item(sqlite_path, ref)
if not item:
return []
with closing(connect_readonly(sqlite_path)) as conn:
rows = conn.execute(
"""
SELECT c.collectionID, c.key, c.collectionName, c.parentCollectionID, c.libraryID
FROM collectionItems ci
JOIN collections c ON c.collectionID = ci.collectionID
WHERE ci.itemID = ?
ORDER BY c.collectionName COLLATE NOCASE, c.collectionID
""",
(int(item["itemID"]),),
).fetchall()
return _as_dicts(rows)
def _fetch_item_fields(conn: sqlite3.Connection, item_id: int) -> dict[str, Any]:
rows = conn.execute(
"""
SELECT f.fieldName, v.value
FROM itemData d
JOIN fields f ON f.fieldID = d.fieldID
JOIN itemDataValues v ON v.valueID = d.valueID
WHERE d.itemID = ?
ORDER BY f.fieldName COLLATE NOCASE
""",
(item_id,),
).fetchall()
return {row["fieldName"]: row["value"] for row in rows}
def _fetch_item_creators(conn: sqlite3.Connection, item_id: int) -> list[dict[str, Any]]:
rows = conn.execute(
"""
SELECT c.creatorID, c.firstName, c.lastName, c.fieldMode, ic.creatorTypeID, ic.orderIndex
FROM itemCreators ic
JOIN creators c ON c.creatorID = ic.creatorID
WHERE ic.itemID = ?
ORDER BY ic.orderIndex
""",
(item_id,),
).fetchall()
return _as_dicts(rows)
def _fetch_item_tags(conn: sqlite3.Connection, item_id: int) -> list[dict[str, Any]]:
rows = conn.execute(
"""
SELECT t.tagID, t.name, it.type
FROM itemTags it
JOIN tags t ON t.tagID = it.tagID
WHERE it.itemID = ?
ORDER BY t.name COLLATE NOCASE
""",
(item_id,),
).fetchall()
return _as_dicts(rows)
def _base_item_select() -> str:
return """
SELECT
i.itemID,
i.key,
i.libraryID,
i.itemTypeID,
it.typeName,
i.dateAdded,
i.dateModified,
i.version,
COALESCE(
(
SELECT v.value
FROM itemData d
JOIN fields f ON f.fieldID = d.fieldID
JOIN itemDataValues v ON v.valueID = d.valueID
WHERE d.itemID = i.itemID AND f.fieldName = 'title'
LIMIT 1
),
n.title,
''
) AS title,
n.parentItemID AS noteParentItemID,
n.note AS noteContent,
a.parentItemID AS attachmentParentItemID,
an.parentItemID AS annotationParentItemID,
an.text AS annotationText,
an.comment AS annotationComment,
a.linkMode,
a.contentType,
a.path AS attachmentPath
FROM items i
JOIN itemTypes it ON it.itemTypeID = i.itemTypeID
LEFT JOIN itemNotes n ON n.itemID = i.itemID
LEFT JOIN itemAttachments a ON a.itemID = i.itemID
LEFT JOIN itemAnnotations an ON an.itemID = i.itemID
"""
def _normalize_item(conn: sqlite3.Connection, row: sqlite3.Row, include_related: bool = False) -> dict[str, Any]:
item = dict(row)
item["fields"] = _fetch_item_fields(conn, int(row["itemID"])) if include_related else {}
item["creators"] = _fetch_item_creators(conn, int(row["itemID"])) if include_related else []
item["tags"] = _fetch_item_tags(conn, int(row["itemID"])) if include_related else []
item["isAttachment"] = row["typeName"] == "attachment"
item["isNote"] = row["typeName"] == "note"
item["isAnnotation"] = row["typeName"] == "annotation"
item["parentItemID"] = row["attachmentParentItemID"] or row["noteParentItemID"] or row["annotationParentItemID"]
item["noteText"] = note_html_to_text(row["noteContent"])
item["notePreview"] = note_preview(row["noteContent"])
return item
def fetch_items(
sqlite_path: Path | str,
*,
library_id: int | None = None,
collection_id: int | None = None,
parent_item_id: int | None = None,
tag: str | None = None,
limit: int | None = None,
) -> list[dict[str, Any]]:
where = ["1=1"]
params: list[Any] = []
if library_id is not None:
where.append("i.libraryID = ?")
params.append(library_id)
if collection_id is not None:
where.append("EXISTS (SELECT 1 FROM collectionItems ci WHERE ci.itemID = i.itemID AND ci.collectionID = ?)")
params.append(collection_id)
if parent_item_id is None:
where.append("COALESCE(a.parentItemID, n.parentItemID, an.parentItemID) IS NULL")
else:
where.append("COALESCE(a.parentItemID, n.parentItemID, an.parentItemID) = ?")
params.append(parent_item_id)
if tag is not None:
where.append(
"""
EXISTS (
SELECT 1
FROM itemTags it2
JOIN tags t2 ON t2.tagID = it2.tagID
WHERE it2.itemID = i.itemID AND (t2.name = ? OR t2.tagID = ?)
)
"""
)
params.extend([tag, int(tag) if _is_numeric_ref(tag) else -1])
sql = _base_item_select() + f"\nWHERE {' AND '.join(where)}\nORDER BY i.dateModified DESC, i.itemID DESC"
if limit is not None:
sql += f"\nLIMIT {int(limit)}"
with closing(connect_readonly(sqlite_path)) as conn:
rows = conn.execute(sql, params).fetchall()
return [_normalize_item(conn, row, include_related=False) for row in rows]
def find_items_by_title(
sqlite_path: Path | str,
query: str,
*,
library_id: int | None = None,
collection_id: int | None = None,
limit: int = 20,
exact_title: bool = False,
) -> list[dict[str, Any]]:
query = query.strip()
if not query:
return []
title_expr = """
LOWER(
COALESCE(
(
SELECT v.value
FROM itemData d
JOIN fields f ON f.fieldID = d.fieldID
JOIN itemDataValues v ON v.valueID = d.valueID
WHERE d.itemID = i.itemID AND f.fieldName = 'title'
LIMIT 1
),
n.title,
''
)
)
"""
where = ["1=1"]
params: list[Any] = []
if library_id is not None:
where.append("i.libraryID = ?")
params.append(library_id)
if collection_id is not None:
where.append("EXISTS (SELECT 1 FROM collectionItems ci WHERE ci.itemID = i.itemID AND ci.collectionID = ?)")
params.append(collection_id)
where.append("COALESCE(a.parentItemID, n.parentItemID, an.parentItemID) IS NULL")
if exact_title:
where.append(f"{title_expr} = ?")
params.append(query.lower())
else:
where.append(f"{title_expr} LIKE ?")
params.append(f"%{query.lower()}%")
sql = (
"SELECT * FROM ("
+ _base_item_select()
+ f"\nWHERE {' AND '.join(where)}\n) AS base\n"
+ """
ORDER BY
CASE
WHEN LOWER(title) = ? THEN 0
WHEN LOWER(title) LIKE ? THEN 1
ELSE 2
END,
INSTR(LOWER(title), ?),
dateModified DESC,
itemID DESC
LIMIT ?
"""
)
params.extend([query.lower(), f"{query.lower()}%", query.lower(), int(limit)])
with closing(connect_readonly(sqlite_path)) as conn:
rows = conn.execute(sql, params).fetchall()
return [_normalize_item(conn, row, include_related=False) for row in rows]
def resolve_item(sqlite_path: Path | str, ref: str | int, *, library_id: int | None = None) -> Optional[dict[str, Any]]:
params: list[Any]
if _is_numeric_ref(ref):
where = "i.itemID = ?"
params = [int(ref)]
else:
where = "i.key = ?"
params = [str(ref)]
if library_id is not None:
where += " AND i.libraryID = ?"
params.append(int(library_id))
with closing(connect_readonly(sqlite_path)) as conn:
rows = conn.execute(_base_item_select() + f"\nWHERE {where}\nORDER BY i.libraryID, i.itemID", params).fetchall()
if not rows:
return None
if len(rows) > 1 and library_id is None and not _is_numeric_ref(ref):
_ambiguous_reference(ref, "item", rows)
return _normalize_item(conn, rows[0], include_related=True)
def fetch_item_children(sqlite_path: Path | str, ref: str | int) -> list[dict[str, Any]]:
item = resolve_item(sqlite_path, ref)
if not item:
return []
return fetch_items(sqlite_path, parent_item_id=int(item["itemID"]))
def fetch_item_notes(sqlite_path: Path | str, ref: str | int) -> list[dict[str, Any]]:
children = fetch_item_children(sqlite_path, ref)
return [child for child in children if child["typeName"] == "note"]
def fetch_item_attachments(sqlite_path: Path | str, ref: str | int) -> list[dict[str, Any]]:
children = fetch_item_children(sqlite_path, ref)
return [child for child in children if child["typeName"] == "attachment"]
def resolve_attachment_real_path(item: dict[str, Any], data_dir: Path | str) -> Optional[str]:
raw_path = item.get("attachmentPath")
if not raw_path:
return None
raw_path = str(raw_path)
data_dir = Path(data_dir)
if raw_path.startswith("storage:"):
filename = raw_path.split(":", 1)[1]
return str((data_dir / "storage" / item["key"] / filename).resolve())
if raw_path.startswith("file://"):
parsed = urlparse(raw_path)
decoded_path = unquote(parsed.path)
if parsed.netloc and parsed.netloc.lower() != "localhost":
unc_path = f"\\\\{parsed.netloc}{decoded_path.replace('/', '\\')}"
return str(PureWindowsPath(unc_path))
if re.match(r"^/[A-Za-z]:", decoded_path):
return str(PureWindowsPath(decoded_path.lstrip("/")))
return decoded_path if os.name != "nt" else str(PureWindowsPath(decoded_path))
path = Path(raw_path)
if path.is_absolute():
return str(path)
return str((data_dir / raw_path).resolve())
def fetch_saved_searches(sqlite_path: Path | str, library_id: int | None = None) -> list[dict[str, Any]]:
with closing(connect_readonly(sqlite_path)) as conn:
rows = conn.execute(
"""
SELECT savedSearchID, savedSearchName, clientDateModified, libraryID, key, version
FROM savedSearches
WHERE (? IS NULL OR libraryID = ?)
ORDER BY savedSearchName COLLATE NOCASE
""",
(library_id, library_id),
).fetchall()
searches = _as_dicts(rows)
for search in searches:
condition_rows = conn.execute(
"""
SELECT searchConditionID, condition, operator, value, required
FROM savedSearchConditions
WHERE savedSearchID = ?
ORDER BY searchConditionID
""",
(search["savedSearchID"],),
).fetchall()
search["conditions"] = _as_dicts(condition_rows)
return searches
def resolve_saved_search(sqlite_path: Path | str, ref: str | int, *, library_id: int | None = None) -> Optional[dict[str, Any]]:
searches = fetch_saved_searches(sqlite_path, library_id=library_id)
if _is_numeric_ref(ref):
for search in searches:
if str(search["savedSearchID"]) == str(ref):
return search
return None
matches = [search for search in searches if search["key"] == str(ref)]
if not matches:
return None
if len(matches) > 1 and library_id is None:
libraries = sorted({int(search["libraryID"]) for search in matches})
library_text = ", ".join(f"L{library_id_value}" for library_id_value in libraries)
raise AmbiguousReferenceError(
f"Ambiguous saved search reference: {ref}. Matches found in {library_text}. "
"Set the library with `session use-library <id>` and retry."
)
return matches[0]
def fetch_tags(sqlite_path: Path | str, library_id: int | None = None) -> list[dict[str, Any]]:
with closing(connect_readonly(sqlite_path)) as conn:
rows = conn.execute(
"""
SELECT t.tagID, t.name, COUNT(it.itemID) AS itemCount
FROM tags t
JOIN itemTags it ON it.tagID = t.tagID
JOIN items i ON i.itemID = it.itemID
WHERE (? IS NULL OR i.libraryID = ?)
GROUP BY t.tagID, t.name
ORDER BY t.name COLLATE NOCASE
""",
(library_id, library_id),
).fetchall()
return _as_dicts(rows)
def fetch_tag_items(sqlite_path: Path | str, tag_ref: str | int, library_id: int | None = None) -> list[dict[str, Any]]:
tag_name: str | None = None
with closing(connect_readonly(sqlite_path)) as conn:
if _is_numeric_ref(tag_ref):
row = conn.execute("SELECT name FROM tags WHERE tagID = ?", (int(tag_ref),)).fetchone()
else:
row = conn.execute("SELECT name FROM tags WHERE name = ?", (str(tag_ref),)).fetchone()
if row:
tag_name = row["name"]
if tag_name is None:
return []
return fetch_items(sqlite_path, library_id=library_id, tag=tag_name)
def create_collection_record(
sqlite_path: Path | str,
*,
name: str,
library_id: int,
parent_collection_id: int | None = None,
) -> dict[str, Any]:
if not name.strip():
raise RuntimeError("Collection name must not be empty")
backup_path = backup_database(sqlite_path)
timestamp = _timestamp_text()
with closing(connect_writable(sqlite_path)) as conn:
try:
conn.execute("BEGIN IMMEDIATE")
cursor = conn.execute(
"""
INSERT INTO collections (
collectionName,
parentCollectionID,
clientDateModified,
libraryID,
key,
version,
synced
)
VALUES (?, ?, ?, ?, ?, 0, 0)
""",
(name.strip(), parent_collection_id, timestamp, int(library_id), generate_object_key()),
)
collection_id = int(cursor.lastrowid)
conn.commit()
except Exception:
conn.rollback()
raise
created = resolve_collection(sqlite_path, collection_id)
assert created is not None
created["backupPath"] = str(backup_path)
return created
def add_item_to_collection_record(
sqlite_path: Path | str,
*,
item_id: int,
collection_id: int,
) -> dict[str, Any]:
backup_path = backup_database(sqlite_path)
with closing(connect_writable(sqlite_path)) as conn:
try:
conn.execute("BEGIN IMMEDIATE")
existing = conn.execute(
"SELECT 1 FROM collectionItems WHERE collectionID = ? AND itemID = ?",
(int(collection_id), int(item_id)),
).fetchone()
created = False
order_index = None
if not existing:
row = conn.execute(
"SELECT COALESCE(MAX(orderIndex), -1) + 1 AS nextIndex FROM collectionItems WHERE collectionID = ?",
(int(collection_id),),
).fetchone()
order_index = int(row["nextIndex"]) if row else 0
conn.execute(
"INSERT INTO collectionItems (collectionID, itemID, orderIndex) VALUES (?, ?, ?)",
(int(collection_id), int(item_id), order_index),
)
created = True
conn.commit()
except Exception:
conn.rollback()
raise
return {
"backupPath": str(backup_path),
"created": created,
"collectionID": int(collection_id),
"itemID": int(item_id),
"orderIndex": order_index,
}
def move_item_between_collections_record(
sqlite_path: Path | str,
*,
item_id: int,
target_collection_id: int,
source_collection_ids: list[int],
) -> dict[str, Any]:
backup_path = backup_database(sqlite_path)
with closing(connect_writable(sqlite_path)) as conn:
try:
conn.execute("BEGIN IMMEDIATE")
existing = conn.execute(
"SELECT 1 FROM collectionItems WHERE collectionID = ? AND itemID = ?",
(int(target_collection_id), int(item_id)),
).fetchone()
added_to_target = False
if not existing:
row = conn.execute(
"SELECT COALESCE(MAX(orderIndex), -1) + 1 AS nextIndex FROM collectionItems WHERE collectionID = ?",
(int(target_collection_id),),
).fetchone()
next_index = int(row["nextIndex"]) if row else 0
conn.execute(
"INSERT INTO collectionItems (collectionID, itemID, orderIndex) VALUES (?, ?, ?)",
(int(target_collection_id), int(item_id), next_index),
)
added_to_target = True
removed = 0
for source_collection_id in source_collection_ids:
if int(source_collection_id) == int(target_collection_id):
continue
cursor = conn.execute(
"DELETE FROM collectionItems WHERE collectionID = ? AND itemID = ?",
(int(source_collection_id), int(item_id)),
)
removed += int(cursor.rowcount)
conn.commit()
except Exception:
conn.rollback()
raise
return {
"backupPath": str(backup_path),
"itemID": int(item_id),
"targetCollectionID": int(target_collection_id),
"removedCount": removed,
"addedToTarget": added_to_target,
}

View File

@@ -0,0 +1,984 @@
from __future__ import annotations
import json
import shlex
import sys
from typing import Any
import click
from cli_anything.zotero import __version__
from cli_anything.zotero.core import analysis, catalog, discovery, experimental, imports, notes, rendering, session as session_mod
from cli_anything.zotero.utils.repl_skin import ReplSkin
try:
from prompt_toolkit.output.win32 import NoConsoleScreenBufferError
except Exception: # pragma: no cover - platform-specific import guard
NoConsoleScreenBufferError = RuntimeError
CONTEXT_SETTINGS = {"ignore_unknown_options": False}
def _stdout_encoding() -> str:
return getattr(sys.stdout, "encoding", None) or "utf-8"
def _can_encode_for_stdout(text: str) -> bool:
try:
text.encode(_stdout_encoding())
except UnicodeEncodeError:
return False
return True
def _safe_text_for_stdout(text: str) -> str:
if _can_encode_for_stdout(text):
return text
return text.encode(_stdout_encoding(), errors="backslashreplace").decode(_stdout_encoding())
def _json_text(data: Any) -> str:
text = json.dumps(data, ensure_ascii=False, indent=2)
if _can_encode_for_stdout(text):
return text
return json.dumps(data, ensure_ascii=True, indent=2)
def root_json_output(ctx: click.Context | None) -> bool:
if ctx is None:
return False
root = ctx.find_root()
if root is None or root.obj is None:
return False
return bool(root.obj.get("json_output"))
def current_runtime(ctx: click.Context) -> discovery.RuntimeContext:
root = ctx.find_root()
assert root is not None
root.ensure_object(dict)
cached = root.obj.get("runtime")
config = root.obj.get("config", {})
if cached is None:
cached = discovery.build_runtime_context(
backend=config.get("backend", "auto"),
data_dir=config.get("data_dir"),
profile_dir=config.get("profile_dir"),
executable=config.get("executable"),
)
root.obj["runtime"] = cached
return cached
def current_session() -> dict[str, Any]:
return session_mod.load_session_state()
def emit(ctx: click.Context | None, data: Any, *, message: str = "") -> None:
if root_json_output(ctx):
click.echo(_json_text(data))
return
if isinstance(data, str):
click.echo(_safe_text_for_stdout(data))
return
if message:
click.echo(_safe_text_for_stdout(message))
if isinstance(data, list):
for item in data:
if isinstance(item, dict):
click.echo(_json_text(item))
else:
click.echo(_safe_text_for_stdout(str(item)))
if not data:
click.echo("[]")
return
if isinstance(data, dict):
click.echo(_json_text(data))
return
click.echo(_safe_text_for_stdout(str(data)))
def _print_collection_tree(nodes: list[dict[str, Any]], level: int = 0) -> None:
prefix = " " * level
for node in nodes:
click.echo(f"{prefix}- {node['collectionName']} [{node['collectionID']}]")
_print_collection_tree(node.get("children", []), level + 1)
def _require_experimental_flag(enabled: bool, command_name: str) -> None:
if not enabled:
raise click.ClickException(
f"`{command_name}` is experimental and writes directly to zotero.sqlite. "
"Pass --experimental to continue."
)
def _normalize_session_library(runtime: discovery.RuntimeContext, library_ref: str) -> int:
try:
library_id = catalog.resolve_library_id(runtime, library_ref)
except RuntimeError as exc:
raise click.ClickException(str(exc)) from exc
if library_id is None:
raise click.ClickException("Library reference required")
return library_id
def _import_exit_code(payload: dict[str, Any]) -> int:
return 1 if payload.get("status") == "partial_success" else 0
@click.group(context_settings=CONTEXT_SETTINGS, invoke_without_command=True)
@click.option("--json", "json_output", is_flag=True, help="Emit machine-readable JSON.")
@click.option("--backend", type=click.Choice(["auto", "sqlite", "api"]), default="auto", show_default=True)
@click.option("--data-dir", default=None, help="Explicit Zotero data directory.")
@click.option("--profile-dir", default=None, help="Explicit Zotero profile directory.")
@click.option("--executable", default=None, help="Explicit Zotero executable path.")
@click.pass_context
def cli(ctx: click.Context, json_output: bool, backend: str, data_dir: str | None, profile_dir: str | None, executable: str | None) -> int:
"""Agent-native Zotero CLI using SQLite, connector, and Local API backends."""
ctx.ensure_object(dict)
ctx.obj["json_output"] = json_output
ctx.obj["config"] = {
"backend": backend,
"data_dir": data_dir,
"profile_dir": profile_dir,
"executable": executable,
}
if ctx.invoked_subcommand is None:
return run_repl()
return 0
@cli.group()
def app() -> None:
"""Application and runtime inspection commands."""
@app.command("status")
@click.pass_context
def app_status(ctx: click.Context) -> int:
runtime = current_runtime(ctx)
emit(ctx, runtime.to_status_payload())
return 0
@app.command("version")
@click.pass_context
def app_version(ctx: click.Context) -> int:
runtime = current_runtime(ctx)
payload = {"package_version": __version__, "zotero_version": runtime.environment.version}
emit(ctx, payload if root_json_output(ctx) else runtime.environment.version)
return 0
@app.command("launch")
@click.option("--wait-timeout", default=30, show_default=True, type=int)
@click.pass_context
def app_launch(ctx: click.Context, wait_timeout: int) -> int:
runtime = current_runtime(ctx)
payload = discovery.launch_zotero(runtime, wait_timeout=wait_timeout)
ctx.find_root().obj["runtime"] = None
emit(ctx, payload)
return 0
@app.command("enable-local-api")
@click.option("--launch", "launch_after_enable", is_flag=True, help="Launch Zotero and verify connector + Local API after enabling.")
@click.option("--wait-timeout", default=30, show_default=True, type=int)
@click.pass_context
def app_enable_local_api(ctx: click.Context, launch_after_enable: bool, wait_timeout: int) -> int:
payload = imports.enable_local_api(current_runtime(ctx), launch=launch_after_enable, wait_timeout=wait_timeout)
ctx.find_root().obj["runtime"] = None
emit(ctx, payload)
return 0
@app.command("ping")
@click.pass_context
def app_ping(ctx: click.Context) -> int:
runtime = current_runtime(ctx)
if not runtime.connector_available:
raise click.ClickException(runtime.connector_message)
emit(ctx, {"connector_available": True, "message": runtime.connector_message})
return 0
@cli.group()
def collection() -> None:
"""Collection inspection and selection commands."""
@collection.command("list")
@click.pass_context
def collection_list(ctx: click.Context) -> int:
emit(ctx, catalog.list_collections(current_runtime(ctx), session=current_session()))
return 0
@collection.command("find")
@click.argument("query")
@click.option("--limit", default=20, show_default=True, type=int)
@click.pass_context
def collection_find_command(ctx: click.Context, query: str, limit: int) -> int:
emit(ctx, catalog.find_collections(current_runtime(ctx), query, limit=limit, session=current_session()))
return 0
@collection.command("tree")
@click.pass_context
def collection_tree_command(ctx: click.Context) -> int:
tree = catalog.collection_tree(current_runtime(ctx), session=current_session())
if root_json_output(ctx):
emit(ctx, tree)
else:
_print_collection_tree(tree)
return 0
@collection.command("get")
@click.argument("ref", required=False)
@click.pass_context
def collection_get(ctx: click.Context, ref: str | None) -> int:
emit(ctx, catalog.get_collection(current_runtime(ctx), ref, session=current_session()))
return 0
@collection.command("items")
@click.argument("ref", required=False)
@click.pass_context
def collection_items_command(ctx: click.Context, ref: str | None) -> int:
emit(ctx, catalog.collection_items(current_runtime(ctx), ref, session=current_session()))
return 0
def _persist_selected_collection(selected: dict[str, Any]) -> dict[str, Any]:
state = current_session()
state["current_library"] = selected.get("libraryID")
state["current_collection"] = selected.get("id")
session_mod.save_session_state(state)
return state
@collection.command("use-selected")
@click.pass_context
def collection_use_selected(ctx: click.Context) -> int:
selected = catalog.use_selected_collection(current_runtime(ctx))
_persist_selected_collection(selected)
session_mod.append_command_history("collection use-selected")
emit(ctx, selected)
return 0
@collection.command("create")
@click.argument("name")
@click.option("--parent", "parent_ref", default=None, help="Parent collection ID or key.")
@click.option("--library", "library_ref", default=None, help="Library ID or treeView ID (user library only).")
@click.option("--experimental", "experimental_mode", is_flag=True, help="Acknowledge experimental direct SQLite write mode.")
@click.pass_context
def collection_create_command(
ctx: click.Context,
name: str,
parent_ref: str | None,
library_ref: str | None,
experimental_mode: bool,
) -> int:
_require_experimental_flag(experimental_mode, "collection create")
emit(
ctx,
experimental.create_collection(
current_runtime(ctx),
name,
parent_ref=parent_ref,
library_ref=library_ref,
session=current_session(),
),
)
return 0
@cli.group()
def item() -> None:
"""Item inspection and rendering commands."""
@item.command("list")
@click.option("--limit", default=20, show_default=True, type=int)
@click.pass_context
def item_list(ctx: click.Context, limit: int) -> int:
emit(ctx, catalog.list_items(current_runtime(ctx), session=current_session(), limit=limit))
return 0
@item.command("find")
@click.argument("query")
@click.option("--collection", "collection_ref", default=None, help="Collection ID or key scope.")
@click.option("--limit", default=20, show_default=True, type=int)
@click.option("--exact-title", is_flag=True, help="Use exact title matching via SQLite.")
@click.pass_context
def item_find_command(
ctx: click.Context,
query: str,
collection_ref: str | None,
limit: int,
exact_title: bool,
) -> int:
emit(
ctx,
catalog.find_items(
current_runtime(ctx),
query,
collection_ref=collection_ref,
limit=limit,
exact_title=exact_title,
session=current_session(),
),
)
return 0
@item.command("get")
@click.argument("ref", required=False)
@click.pass_context
def item_get(ctx: click.Context, ref: str | None) -> int:
emit(ctx, catalog.get_item(current_runtime(ctx), ref, session=current_session()))
return 0
@item.command("children")
@click.argument("ref", required=False)
@click.pass_context
def item_children_command(ctx: click.Context, ref: str | None) -> int:
emit(ctx, catalog.item_children(current_runtime(ctx), ref, session=current_session()))
return 0
@item.command("notes")
@click.argument("ref", required=False)
@click.pass_context
def item_notes_command(ctx: click.Context, ref: str | None) -> int:
emit(ctx, catalog.item_notes(current_runtime(ctx), ref, session=current_session()))
return 0
@item.command("attachments")
@click.argument("ref", required=False)
@click.pass_context
def item_attachments_command(ctx: click.Context, ref: str | None) -> int:
emit(ctx, catalog.item_attachments(current_runtime(ctx), ref, session=current_session()))
return 0
@item.command("file")
@click.argument("ref", required=False)
@click.pass_context
def item_file_command(ctx: click.Context, ref: str | None) -> int:
emit(ctx, catalog.item_file(current_runtime(ctx), ref, session=current_session()))
return 0
@item.command("export")
@click.argument("ref", required=False)
@click.option("--format", "fmt", type=click.Choice(list(rendering.SUPPORTED_EXPORT_FORMATS)), required=True)
@click.pass_context
def item_export(ctx: click.Context, ref: str | None, fmt: str) -> int:
payload = rendering.export_item(current_runtime(ctx), ref, fmt, session=current_session())
emit(ctx, payload if root_json_output(ctx) else payload["content"])
return 0
@item.command("citation")
@click.argument("ref", required=False)
@click.option("--style", default=None)
@click.option("--locale", default=None)
@click.option("--linkwrap", is_flag=True)
@click.pass_context
def item_citation(ctx: click.Context, ref: str | None, style: str | None, locale: str | None, linkwrap: bool) -> int:
payload = rendering.citation_item(current_runtime(ctx), ref, style=style, locale=locale, linkwrap=linkwrap, session=current_session())
emit(ctx, payload if root_json_output(ctx) else (payload.get("citation") or ""))
return 0
@item.command("bibliography")
@click.argument("ref", required=False)
@click.option("--style", default=None)
@click.option("--locale", default=None)
@click.option("--linkwrap", is_flag=True)
@click.pass_context
def item_bibliography(ctx: click.Context, ref: str | None, style: str | None, locale: str | None, linkwrap: bool) -> int:
payload = rendering.bibliography_item(current_runtime(ctx), ref, style=style, locale=locale, linkwrap=linkwrap, session=current_session())
emit(ctx, payload if root_json_output(ctx) else (payload.get("bibliography") or ""))
return 0
@item.command("context")
@click.argument("ref", required=False)
@click.option("--include-notes", is_flag=True)
@click.option("--include-bibtex", is_flag=True)
@click.option("--include-csljson", is_flag=True)
@click.option("--include-links", is_flag=True)
@click.pass_context
def item_context_command(
ctx: click.Context,
ref: str | None,
include_notes: bool,
include_bibtex: bool,
include_csljson: bool,
include_links: bool,
) -> int:
payload = analysis.build_item_context(
current_runtime(ctx),
ref,
include_notes=include_notes,
include_bibtex=include_bibtex,
include_csljson=include_csljson,
include_links=include_links,
session=current_session(),
)
emit(ctx, payload if root_json_output(ctx) else payload["prompt_context"])
return 0
@item.command("analyze")
@click.argument("ref", required=False)
@click.option("--question", required=True)
@click.option("--model", required=True)
@click.option("--include-notes", is_flag=True)
@click.option("--include-bibtex", is_flag=True)
@click.option("--include-csljson", is_flag=True)
@click.pass_context
def item_analyze_command(
ctx: click.Context,
ref: str | None,
question: str,
model: str,
include_notes: bool,
include_bibtex: bool,
include_csljson: bool,
) -> int:
payload = analysis.analyze_item(
current_runtime(ctx),
ref,
question=question,
model=model,
include_notes=include_notes,
include_bibtex=include_bibtex,
include_csljson=include_csljson,
session=current_session(),
)
emit(ctx, payload if root_json_output(ctx) else payload["answer"])
return 0
@item.command("add-to-collection")
@click.argument("item_ref")
@click.argument("collection_ref")
@click.option("--experimental", "experimental_mode", is_flag=True, help="Acknowledge experimental direct SQLite write mode.")
@click.pass_context
def item_add_to_collection_command(ctx: click.Context, item_ref: str, collection_ref: str, experimental_mode: bool) -> int:
_require_experimental_flag(experimental_mode, "item add-to-collection")
emit(ctx, experimental.add_item_to_collection(current_runtime(ctx), item_ref, collection_ref, session=current_session()))
return 0
@item.command("move-to-collection")
@click.argument("item_ref")
@click.argument("collection_ref")
@click.option("--from", "from_refs", multiple=True, help="Source collection ID or key. Repeatable.")
@click.option("--all-other-collections", is_flag=True, help="Remove the item from all other collections after adding the target.")
@click.option("--experimental", "experimental_mode", is_flag=True, help="Acknowledge experimental direct SQLite write mode.")
@click.pass_context
def item_move_to_collection_command(
ctx: click.Context,
item_ref: str,
collection_ref: str,
from_refs: tuple[str, ...],
all_other_collections: bool,
experimental_mode: bool,
) -> int:
_require_experimental_flag(experimental_mode, "item move-to-collection")
emit(
ctx,
experimental.move_item_to_collection(
current_runtime(ctx),
item_ref,
collection_ref,
from_refs=list(from_refs),
all_other_collections=all_other_collections,
session=current_session(),
),
)
return 0
@cli.group()
def search() -> None:
"""Saved-search inspection commands."""
@search.command("list")
@click.pass_context
def search_list(ctx: click.Context) -> int:
emit(ctx, catalog.list_searches(current_runtime(ctx), session=current_session()))
return 0
@search.command("get")
@click.argument("ref")
@click.pass_context
def search_get(ctx: click.Context, ref: str) -> int:
emit(ctx, catalog.get_search(current_runtime(ctx), ref, session=current_session()))
return 0
@search.command("items")
@click.argument("ref")
@click.pass_context
def search_items_command(ctx: click.Context, ref: str) -> int:
emit(ctx, catalog.search_items(current_runtime(ctx), ref, session=current_session()))
return 0
@cli.group()
def tag() -> None:
"""Tag inspection commands."""
@tag.command("list")
@click.pass_context
def tag_list(ctx: click.Context) -> int:
emit(ctx, catalog.list_tags(current_runtime(ctx), session=current_session()))
return 0
@tag.command("items")
@click.argument("tag_ref")
@click.pass_context
def tag_items_command(ctx: click.Context, tag_ref: str) -> int:
emit(ctx, catalog.tag_items(current_runtime(ctx), tag_ref, session=current_session()))
return 0
@cli.group()
def style() -> None:
"""Installed CSL style inspection commands."""
@style.command("list")
@click.pass_context
def style_list(ctx: click.Context) -> int:
emit(ctx, catalog.list_styles(current_runtime(ctx)))
return 0
@cli.group("import")
def import_group() -> None:
"""Official Zotero import and write commands."""
@import_group.command("file")
@click.argument("path")
@click.option("--collection", "collection_ref", default=None, help="Collection ID, key, or treeViewID target.")
@click.option("--tag", "tags", multiple=True, help="Tag to apply after import. Repeatable.")
@click.option("--attachments-manifest", default=None, help="Optional JSON manifest describing attachments for imported records.")
@click.option("--attachment-delay-ms", default=0, show_default=True, type=int, help="Default delay before each URL attachment download.")
@click.option("--attachment-timeout", default=60, show_default=True, type=int, help="Default timeout in seconds for attachment download/upload.")
@click.pass_context
def import_file_command(
ctx: click.Context,
path: str,
collection_ref: str | None,
tags: tuple[str, ...],
attachments_manifest: str | None,
attachment_delay_ms: int,
attachment_timeout: int,
) -> int:
payload = imports.import_file(
current_runtime(ctx),
path,
collection_ref=collection_ref,
tags=list(tags),
session=current_session(),
attachments_manifest=attachments_manifest,
attachment_delay_ms=attachment_delay_ms,
attachment_timeout=attachment_timeout,
)
emit(ctx, payload)
return _import_exit_code(payload)
@import_group.command("json")
@click.argument("path")
@click.option("--collection", "collection_ref", default=None, help="Collection ID, key, or treeViewID target.")
@click.option("--tag", "tags", multiple=True, help="Tag to apply after import. Repeatable.")
@click.option("--attachment-delay-ms", default=0, show_default=True, type=int, help="Default delay before each URL attachment download.")
@click.option("--attachment-timeout", default=60, show_default=True, type=int, help="Default timeout in seconds for attachment download/upload.")
@click.pass_context
def import_json_command(
ctx: click.Context,
path: str,
collection_ref: str | None,
tags: tuple[str, ...],
attachment_delay_ms: int,
attachment_timeout: int,
) -> int:
payload = imports.import_json(
current_runtime(ctx),
path,
collection_ref=collection_ref,
tags=list(tags),
session=current_session(),
attachment_delay_ms=attachment_delay_ms,
attachment_timeout=attachment_timeout,
)
emit(ctx, payload)
return _import_exit_code(payload)
@cli.group()
def note() -> None:
"""Read and add child notes."""
@note.command("get")
@click.argument("ref")
@click.pass_context
def note_get_command(ctx: click.Context, ref: str) -> int:
payload = notes.get_note(current_runtime(ctx), ref, session=current_session())
emit(ctx, payload if root_json_output(ctx) else (payload.get("noteText") or payload.get("noteContent") or ""))
return 0
@note.command("add")
@click.argument("item_ref")
@click.option("--text", default=None, help="Inline note content.")
@click.option("--file", "file_path", default=None, help="Read note content from a file.")
@click.option("--format", "fmt", type=click.Choice(["text", "markdown", "html"]), default="text", show_default=True)
@click.pass_context
def note_add_command(
ctx: click.Context,
item_ref: str,
text: str | None,
file_path: str | None,
fmt: str,
) -> int:
emit(
ctx,
notes.add_note(
current_runtime(ctx),
item_ref,
text=text,
file_path=file_path,
fmt=fmt,
session=current_session(),
),
)
return 0
@cli.group()
def session() -> None:
"""Session and REPL context commands."""
@session.command("status")
@click.pass_context
def session_status(ctx: click.Context) -> int:
emit(ctx, session_mod.build_session_payload(current_session()))
return 0
@session.command("use-library")
@click.argument("library_ref")
@click.pass_context
def session_use_library(ctx: click.Context, library_ref: str) -> int:
state = current_session()
state["current_library"] = _normalize_session_library(current_runtime(ctx), library_ref)
session_mod.save_session_state(state)
session_mod.append_command_history(f"session use-library {library_ref}")
emit(ctx, session_mod.build_session_payload(state))
return 0
@session.command("use-collection")
@click.argument("collection_ref")
@click.pass_context
def session_use_collection(ctx: click.Context, collection_ref: str) -> int:
state = current_session()
state["current_collection"] = collection_ref
session_mod.save_session_state(state)
session_mod.append_command_history(f"session use-collection {collection_ref}")
emit(ctx, session_mod.build_session_payload(state))
return 0
@session.command("use-item")
@click.argument("item_ref")
@click.pass_context
def session_use_item(ctx: click.Context, item_ref: str) -> int:
state = current_session()
state["current_item"] = item_ref
session_mod.save_session_state(state)
session_mod.append_command_history(f"session use-item {item_ref}")
emit(ctx, session_mod.build_session_payload(state))
return 0
@session.command("use-selected")
@click.pass_context
def session_use_selected(ctx: click.Context) -> int:
selected = catalog.use_selected_collection(current_runtime(ctx))
state = _persist_selected_collection(selected)
session_mod.append_command_history("session use-selected")
emit(ctx, {"selected": selected, "session": session_mod.build_session_payload(state)})
return 0
@session.command("clear-library")
@click.pass_context
def session_clear_library(ctx: click.Context) -> int:
state = current_session()
state["current_library"] = None
session_mod.save_session_state(state)
session_mod.append_command_history("session clear-library")
emit(ctx, session_mod.build_session_payload(state))
return 0
@session.command("clear-collection")
@click.pass_context
def session_clear_collection(ctx: click.Context) -> int:
state = current_session()
state["current_collection"] = None
session_mod.save_session_state(state)
session_mod.append_command_history("session clear-collection")
emit(ctx, session_mod.build_session_payload(state))
return 0
@session.command("clear-item")
@click.pass_context
def session_clear_item(ctx: click.Context) -> int:
state = current_session()
state["current_item"] = None
session_mod.save_session_state(state)
session_mod.append_command_history("session clear-item")
emit(ctx, session_mod.build_session_payload(state))
return 0
@session.command("history")
@click.option("--limit", default=10, show_default=True, type=int)
@click.pass_context
def session_history(ctx: click.Context, limit: int) -> int:
emit(ctx, {"history": current_session().get("command_history", [])[-limit:]})
return 0
def repl_help_text() -> str:
return """Interactive REPL for cli-anything-zotero
Builtins:
help Show this help
exit, quit Leave the REPL
current-library Show the current library reference
current-collection Show the current collection reference
current-item Show the current item reference
use-library <ref> Persist current library
use-collection <ref> Persist current collection
use-item <ref> Persist current item
use-selected Read and persist the collection selected in Zotero
clear-library Clear current library
clear-collection Clear current collection
clear-item Clear current item
status Show current session status
history [limit] Show recent command history
state-path Show the session state file path
"""
def _handle_repl_builtin(argv: list[str], skin: ReplSkin) -> tuple[bool, int]:
if not argv:
return True, 0
cmd = argv[0]
state = current_session()
if cmd in {"exit", "quit"}:
return True, 1
if cmd == "help":
click.echo(repl_help_text())
return True, 0
if cmd == "current-library":
click.echo(f"Current library: {state.get('current_library') or '<unset>'}")
return True, 0
if cmd == "current-collection":
click.echo(f"Current collection: {state.get('current_collection') or '<unset>'}")
return True, 0
if cmd == "current-item":
click.echo(f"Current item: {state.get('current_item') or '<unset>'}")
return True, 0
if cmd == "status":
click.echo(json.dumps(session_mod.build_session_payload(state), ensure_ascii=False, indent=2))
return True, 0
if cmd == "history":
limit = 10
if len(argv) > 1:
try:
limit = max(1, int(argv[1]))
except ValueError:
skin.warning(f"history limit must be an integer: {argv[1]}")
return True, 0
click.echo(json.dumps({"history": state.get("command_history", [])[-limit:]}, ensure_ascii=False, indent=2))
return True, 0
if cmd == "state-path":
click.echo(str(session_mod.session_state_path()))
return True, 0
if cmd == "use-library" and len(argv) > 1:
library_ref = " ".join(argv[1:])
try:
state["current_library"] = _normalize_session_library(discovery.build_runtime_context(), library_ref)
except click.ClickException as exc:
skin.error(exc.format_message())
return True, 0
session_mod.save_session_state(state)
session_mod.append_command_history(f"use-library {library_ref}")
click.echo(f"Current library: {state['current_library']}")
return True, 0
if cmd == "use-collection" and len(argv) > 1:
state["current_collection"] = " ".join(argv[1:])
session_mod.save_session_state(state)
session_mod.append_command_history(f"use-collection {' '.join(argv[1:])}")
click.echo(f"Current collection: {state['current_collection']}")
return True, 0
if cmd == "use-item" and len(argv) > 1:
state["current_item"] = " ".join(argv[1:])
session_mod.save_session_state(state)
session_mod.append_command_history(f"use-item {' '.join(argv[1:])}")
click.echo(f"Current item: {state['current_item']}")
return True, 0
if cmd == "clear-library":
state["current_library"] = None
session_mod.save_session_state(state)
click.echo("Current library cleared.")
return True, 0
if cmd == "clear-collection":
state["current_collection"] = None
session_mod.save_session_state(state)
click.echo("Current collection cleared.")
return True, 0
if cmd == "clear-item":
state["current_item"] = None
session_mod.save_session_state(state)
click.echo("Current item cleared.")
return True, 0
if cmd == "use-selected":
try:
runtime = discovery.build_runtime_context()
selected = catalog.use_selected_collection(runtime)
except Exception as exc:
skin.error(str(exc))
return True, 0
_persist_selected_collection(selected)
session_mod.append_command_history("use-selected")
click.echo(json.dumps(selected, ensure_ascii=False, indent=2))
return True, 0
return False, 0
def _supports_fancy_repl_output() -> bool:
is_tty = getattr(sys.stdout, "isatty", lambda: False)()
if not is_tty:
return False
encoding = getattr(sys.stdout, "encoding", None) or "utf-8"
try:
"▸↑⊙﹞".encode(encoding)
except UnicodeEncodeError:
return False
return True
def _safe_print_banner(skin: ReplSkin) -> None:
if not _supports_fancy_repl_output():
click.echo("cli-anything-zotero REPL")
click.echo(f"Skill: {skin.skill_path}")
click.echo("Type help for commands, quit to exit")
return
try:
skin.print_banner()
except UnicodeEncodeError:
click.echo("cli-anything-zotero REPL")
click.echo(f"Skill: {skin.skill_path}")
click.echo("Type help for commands, quit to exit")
def _safe_print_goodbye(skin: ReplSkin) -> None:
if not _supports_fancy_repl_output():
click.echo("Goodbye!")
return
try:
skin.print_goodbye()
except UnicodeEncodeError:
click.echo("Goodbye!")
def run_repl() -> int:
skin = ReplSkin("zotero", version=__version__)
prompt_session = None
try:
prompt_session = skin.create_prompt_session()
except NoConsoleScreenBufferError:
prompt_session = None
_safe_print_banner(skin)
while True:
try:
if prompt_session is None:
line = input("zotero> ").strip()
else:
line = skin.get_input(prompt_session).strip()
except EOFError:
click.echo()
_safe_print_goodbye(skin)
return 0
except KeyboardInterrupt:
click.echo()
continue
if not line:
continue
try:
argv = shlex.split(line)
except ValueError as exc:
skin.error(f"parse error: {exc}")
continue
handled, control = _handle_repl_builtin(argv, skin)
if handled:
if control == 1:
_safe_print_goodbye(skin)
return 0
continue
expanded = session_mod.expand_repl_aliases_with_state(argv, current_session())
result = dispatch(expanded)
if result not in (0, None):
skin.warning(f"command exited with status {result}")
else:
session_mod.append_command_history(line)
@cli.command("repl")
def repl_command() -> int:
"""Start the interactive REPL."""
return run_repl()
def dispatch(argv: list[str] | None = None, prog_name: str | None = None) -> int:
args = list(sys.argv[1:] if argv is None else argv)
try:
result = cli.main(args=args, prog_name=prog_name or "cli-anything-zotero", standalone_mode=False)
except click.exceptions.Exit as exc:
return int(exc.exit_code)
except click.ClickException as exc:
exc.show()
return int(exc.exit_code)
return int(result or 0)
def entrypoint(argv: list[str] | None = None) -> int:
return dispatch(argv, prog_name=sys.argv[0])

View File

@@ -0,0 +1,3 @@
[build-system]
requires = ["setuptools>=61"]
build-backend = "setuptools.build_meta"

View File

@@ -0,0 +1,67 @@
from __future__ import annotations
import sys
from pathlib import Path
PACKAGE_NAME = "cli-anything-zotero"
PACKAGE_VERSION = "0.1.0"
def _handle_metadata_query(argv: list[str]) -> bool:
if len(argv) != 2:
return False
if argv[1] == "--name":
print(PACKAGE_NAME)
return True
if argv[1] == "--version":
print(PACKAGE_VERSION)
return True
return False
if __name__ == "__main__" and _handle_metadata_query(sys.argv):
raise SystemExit(0)
from setuptools import find_namespace_packages, setup
ROOT = Path(__file__).parent
README = ROOT / "cli_anything" / "zotero" / "README.md"
LONG_DESCRIPTION = README.read_text(encoding="utf-8") if README.exists() else ""
setup(
name=PACKAGE_NAME,
version=PACKAGE_VERSION,
author="cli-anything contributors",
author_email="",
description="Agent-native CLI harness for Zotero using SQLite, connector, and Local API backends",
long_description=LONG_DESCRIPTION,
long_description_content_type="text/markdown",
url="https://github.com/HKUDS/CLI-Anything",
packages=find_namespace_packages(include=["cli_anything.*"]),
python_requires=">=3.10",
install_requires=[
"click>=8.0.0",
"prompt-toolkit>=3.0.0",
],
extras_require={
"dev": [
"pytest>=7.0.0",
"pytest-cov>=4.0.0",
],
},
entry_points={
"console_scripts": [
"cli-anything-zotero=cli_anything.zotero.zotero_cli:entrypoint",
],
},
package_data={
"cli_anything.zotero": ["README.md"],
"cli_anything.zotero.skills": ["SKILL.md"],
"cli_anything.zotero.tests": ["TEST.md"],
},
include_package_data=True,
zip_safe=False,
)

View File

@@ -0,0 +1,239 @@
"""
SKILL.md Generator for CLI-Anything harnesses.
"""
from __future__ import annotations
import argparse
import re
from dataclasses import dataclass, field
from pathlib import Path
from typing import Optional
def _format_display_name(name: str) -> str:
return name.replace("_", " ").replace("-", " ").title()
@dataclass
class CommandInfo:
name: str
description: str
@dataclass
class CommandGroup:
name: str
description: str
commands: list[CommandInfo] = field(default_factory=list)
@dataclass
class Example:
title: str
description: str
code: str
@dataclass
class SkillMetadata:
skill_name: str
skill_description: str
software_name: str
skill_intro: str
version: str
command_groups: list[CommandGroup] = field(default_factory=list)
examples: list[Example] = field(default_factory=list)
def extract_intro_from_readme(content: str) -> str:
lines = content.splitlines()
intro: list[str] = []
seen_title = False
for line in lines:
stripped = line.strip()
if not stripped:
if seen_title and intro:
break
continue
if stripped.startswith("# "):
seen_title = True
continue
if stripped.startswith("##"):
break
if seen_title:
intro.append(stripped)
return " ".join(intro) or "Agent-native CLI interface."
def extract_version_from_setup(setup_path: Path) -> str:
content = setup_path.read_text(encoding="utf-8")
match = re.search(r'PACKAGE_VERSION\s*=\s*["\']([^"\']+)["\']', content)
if match:
return match.group(1)
match = re.search(r'version\s*=\s*["\']([^"\']+)["\']', content)
return match.group(1) if match else "1.0.0"
def extract_commands_from_cli(cli_path: Path) -> list[CommandGroup]:
content = cli_path.read_text(encoding="utf-8")
groups: list[CommandGroup] = []
group_name_by_function: dict[str, str] = {}
group_pattern = (
r'@cli\.group(?:\(([^)]*)\))?'
r'(?:\s*@[\w.]+(?:\([^)]*\))?)*'
r'\s*def\s+(\w+)\([^)]*\)'
r'(?:\s*->\s*[^:]+)?'
r':\s*'
r'(?:"""([\s\S]*?)"""|\'\'\'([\s\S]*?)\'\'\')?'
)
for match in re.finditer(group_pattern, content):
decorator_args = match.group(1) or ""
func_name = match.group(2)
doc = (match.group(3) or match.group(4) or "").strip()
explicit_name = re.search(r'["\']([^"\']+)["\']', decorator_args)
name = explicit_name.group(1) if explicit_name else func_name.replace("_", " ")
display_name = name.replace("-", " ").title()
group_name_by_function[func_name] = display_name
groups.append(CommandGroup(name=display_name, description=doc or f"Commands for {name}."))
command_pattern = (
r'@(\w+)\.command(?:\(([^)]*)\))?'
r'(?:\s*@[\w.]+(?:\([^)]*\))?)*'
r'\s*def\s+(\w+)\([^)]*\)'
r'(?:\s*->\s*[^:]+)?'
r':\s*'
r'(?:"""([\s\S]*?)"""|\'\'\'([\s\S]*?)\'\'\')?'
)
for match in re.finditer(command_pattern, content):
group_func = match.group(1)
decorator_args = match.group(2) or ""
func_name = match.group(3)
doc = (match.group(4) or match.group(5) or "").strip()
explicit_name = re.search(r'["\']([^"\']+)["\']', decorator_args)
cmd_name = explicit_name.group(1) if explicit_name else func_name.replace("_", "-")
title = group_name_by_function.get(group_func, group_func.replace("_", " ").replace("-", " ").title())
for group in groups:
if group.name == title:
group.commands.append(CommandInfo(cmd_name, doc or f"Execute `{cmd_name}`."))
break
return groups
def generate_examples(software_name: str) -> list[Example]:
return [
Example("Runtime Status", "Inspect Zotero paths and backend availability.", f"cli-anything-{software_name} app status --json"),
Example("Read Selected Collection", "Persist the collection selected in the Zotero GUI.", f"cli-anything-{software_name} collection use-selected --json"),
Example("Render Citation", "Render a citation using Zotero's Local API.", f"cli-anything-{software_name} item citation <item-key> --style apa --locale en-US --json"),
Example("Add Child Note", "Create a child note under an existing Zotero item.", f"cli-anything-{software_name} note add <item-key> --text \"Key takeaway\" --json"),
Example("Build LLM Context", "Assemble structured context for downstream model analysis.", f"cli-anything-{software_name} item context <item-key> --include-notes --include-links --json"),
]
def extract_cli_metadata(harness_path: str) -> SkillMetadata:
harness_root = Path(harness_path)
cli_root = harness_root / "cli_anything"
software_dir = next(path for path in cli_root.iterdir() if path.is_dir() and (path / "__init__.py").exists())
software_name = software_dir.name
intro = extract_intro_from_readme((software_dir / "README.md").read_text(encoding="utf-8"))
version = extract_version_from_setup(harness_root / "setup.py")
groups = extract_commands_from_cli(software_dir / f"{software_name}_cli.py")
return SkillMetadata(
skill_name=f"cli-anything-{software_name}",
skill_description=f"CLI harness for {_format_display_name(software_name)}.",
software_name=software_name,
skill_intro=intro,
version=version,
command_groups=groups,
examples=generate_examples(software_name),
)
def generate_skill_md_simple(metadata: SkillMetadata) -> str:
lines = [
"---",
"name: >-",
f" {metadata.skill_name}",
"description: >-",
f" {metadata.skill_description}",
"---",
"",
f"# {metadata.skill_name}",
"",
metadata.skill_intro,
"",
"## Installation",
"",
"```bash",
"pip install -e .",
"```",
"",
"## Entry Points",
"",
"```bash",
f"cli-anything-{metadata.software_name}",
f"python -m cli_anything.{metadata.software_name}",
"```",
"",
"## Command Groups",
"",
]
for group in metadata.command_groups:
lines.extend([f"### {group.name}", "", group.description, "", "| Command | Description |", "|---------|-------------|"])
for cmd in group.commands:
lines.append(f"| `{cmd.name}` | {cmd.description} |")
lines.append("")
lines.extend(["## Examples", ""])
for example in metadata.examples:
lines.extend([f"### {example.title}", "", example.description, "", "```bash", example.code, "```", ""])
lines.extend(["## Version", "", metadata.version, ""])
return "\n".join(lines)
def generate_skill_md(metadata: SkillMetadata, template_path: Optional[str] = None) -> str:
try:
from jinja2 import Environment, FileSystemLoader
except ImportError:
return generate_skill_md_simple(metadata)
template = Path(template_path) if template_path else Path(__file__).parent / "templates" / "SKILL.md.template"
if not template.exists():
return generate_skill_md_simple(metadata)
env = Environment(loader=FileSystemLoader(template.parent))
tpl = env.get_template(template.name)
return tpl.render(
skill_name=metadata.skill_name,
skill_description=metadata.skill_description,
software_name=metadata.software_name,
skill_intro=metadata.skill_intro,
version=metadata.version,
command_groups=[
{"name": group.name, "description": group.description, "commands": [{"name": c.name, "description": c.description} for c in group.commands]}
for group in metadata.command_groups
],
examples=[{"title": ex.title, "description": ex.description, "code": ex.code} for ex in metadata.examples],
)
def generate_skill_file(harness_path: str, output_path: Optional[str] = None, template_path: Optional[str] = None) -> str:
metadata = extract_cli_metadata(harness_path)
content = generate_skill_md(metadata, template_path=template_path)
output = Path(output_path) if output_path else Path(harness_path) / "cli_anything" / metadata.software_name / "skills" / "SKILL.md"
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(content, encoding="utf-8")
return str(output)
def main(argv: Optional[list[str]] = None) -> int:
parser = argparse.ArgumentParser(description="Generate SKILL.md for a CLI-Anything harness")
parser.add_argument("harness_path")
parser.add_argument("-o", "--output", default=None)
parser.add_argument("-t", "--template", default=None)
args = parser.parse_args(argv)
print(generate_skill_file(args.harness_path, output_path=args.output, template_path=args.template))
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,53 @@
---
name: >-
{{ skill_name }}
description: >-
{{ skill_description }}
---
# {{ skill_name }}
{{ skill_intro }}
## Installation
```bash
pip install -e .
```
## Entry Points
```bash
cli-anything-{{ software_name }}
python -m cli_anything.{{ software_name }}
```
## Command Groups
{% for group in command_groups %}
### {{ group.name }}
{{ group.description }}
| Command | Description |
|---------|-------------|
{% for cmd in group.commands %}
| `{{ cmd.name }}` | {{ cmd.description }} |
{% endfor %}
{% endfor %}
## Examples
{% for example in examples %}
### {{ example.title }}
{{ example.description }}
```bash
{{ example.code }}
```
{% endfor %}
## Version
{{ version }}