@beomsukoh/zotero-cli 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Beomsu Koh
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,117 @@
1
+ # zotero-cli
2
+
3
+ Pure CLI wrapper for the Zotero API. Supports both the local desktop API (read-only, no auth) and the web API (full CRUD with API key).
4
+
5
+ Same architecture as [devonthink-cli](https://github.com/GoBeromsu/devonthink-cli): CommandModule pattern, port/adapter, typed error hierarchy. Zero runtime dependencies.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ npm install -g @goberomsu/zotero-cli
11
+ ```
12
+
13
+ ## Quick Start
14
+
15
+ ```bash
16
+ # List items (local Zotero API, no auth needed)
17
+ zt items --limit 10
18
+
19
+ # Search
20
+ zt search "machine learning"
21
+
22
+ # Get a specific item
23
+ zt get ABCD1234
24
+
25
+ # List collections
26
+ zt collections
27
+
28
+ # Export as BibTeX
29
+ zt export --format bibtex
30
+
31
+ # Get item template for creating a journal article
32
+ zt template journalArticle
33
+
34
+ # Create an item
35
+ zt create:item --type journalArticle --title "My Paper" --doi "10.1234/example"
36
+
37
+ # Attach a PDF to an item
38
+ zt attach ./paper.pdf --key ABCD1234
39
+
40
+ # Get full-text content
41
+ zt fulltext ABCD1234
42
+
43
+ # Show available item types
44
+ zt types
45
+
46
+ # Show fields for a specific type
47
+ zt types journalArticle
48
+ ```
49
+
50
+ ## Configuration
51
+
52
+ | Variable | Default | Description |
53
+ |---|---|---|
54
+ | `ZOTERO_BASE_URL` | `http://localhost:23119/api` | API base URL |
55
+ | `ZOTERO_API_KEY` | — | API key (required for writes) |
56
+ | `ZOTERO_USER_ID` | `0` | User ID (`0` = local API) |
57
+
58
+ **Local mode** (reads only, Zotero desktop must be running):
59
+ ```bash
60
+ zt items --limit 10
61
+ ```
62
+
63
+ **Web mode** (full CRUD):
64
+ ```bash
65
+ export ZOTERO_API_KEY=your_key_here
66
+ export ZOTERO_USER_ID=12345
67
+ export ZOTERO_BASE_URL=https://api.zotero.org
68
+ ```
69
+
70
+ ## Commands
71
+
72
+ ### Core
73
+
74
+ | Command | Description |
75
+ |---|---|
76
+ | `zt items` | List items (`--collection`, `--top`, `--tag`, `--type`, `--limit`, `--sort`) |
77
+ | `zt collections` | List collections (`--parent`) |
78
+ | `zt get <key>` | Get item by key (`--children` for child items) |
79
+ | `zt search <query>` | Search items (`--qmode`, `--tag`, `--type`) |
80
+ | `zt tags` | List tags |
81
+ | `zt libraries` | List group libraries |
82
+ | `zt delete --key <key>` | Delete item or collection (`--collection` flag) |
83
+ | `zt attach <file> --key <key>` | Attach file to item (`--content-type`) |
84
+ | `zt export --format <fmt>` | Export items (bibtex, ris, csljson, csv, tei, etc.) |
85
+ | `zt fulltext <key>` | Get full-text content |
86
+
87
+ ### Create
88
+
89
+ | Command | Description |
90
+ |---|---|
91
+ | `zt create:item` | Create item from JSON or `--type`/`--title`/`--doi` options |
92
+ | `zt create:collection <name>` | Create collection (`--parent` for nesting) |
93
+
94
+ ### Lookup
95
+
96
+ | Command | Description |
97
+ |---|---|
98
+ | `zt types [itemType]` | List item types, or fields for a specific type |
99
+ | `zt template <itemType>` | Get JSON template for creating items |
100
+
101
+ ### Export Formats
102
+
103
+ `bibtex`, `biblatex`, `ris`, `csljson`, `csv`, `tei`, `wikipedia`, `mods`, `refer`
104
+
105
+ ## Architecture
106
+
107
+ ```
108
+ zt <command> [options]
109
+ -> CommandRegistry -> CommandModule.parse() -> CommandModule.execute()
110
+ -> ZoteroPort (application boundary)
111
+ -> HttpZoteroAdapter
112
+ -> Zotero Local API (localhost:23119) or Web API (api.zotero.org)
113
+ ```
114
+
115
+ ## License
116
+
117
+ MIT
@@ -0,0 +1,332 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { createHash } from "node:crypto";
3
+ import { basename } from "node:path";
4
+ import { NotFoundError, ConflictError, ExternalToolError, } from "../../application/errors.js";
5
+ export class HttpZoteroAdapter {
6
+ http;
7
+ baseUrl;
8
+ apiKey;
9
+ userId;
10
+ constructor(http, baseUrl, apiKey, userId) {
11
+ this.http = http;
12
+ this.baseUrl = baseUrl.replace(/\/+$/, "");
13
+ this.apiKey = apiKey;
14
+ this.userId = userId;
15
+ }
16
+ // ── Query methods ──────────────────────────────────────────────
17
+ async listItems(library, collectionKey, options) {
18
+ const path = collectionKey
19
+ ? `${this.libraryPrefix(library)}/collections/${collectionKey}/items`
20
+ : `${this.libraryPrefix(library)}/items`;
21
+ const url = this.buildUrl(path, this.searchParams(options));
22
+ return this.get(url);
23
+ }
24
+ async listTopItems(library, collectionKey, options) {
25
+ const path = collectionKey
26
+ ? `${this.libraryPrefix(library)}/collections/${collectionKey}/items/top`
27
+ : `${this.libraryPrefix(library)}/items/top`;
28
+ const url = this.buildUrl(path, this.searchParams(options));
29
+ return this.get(url);
30
+ }
31
+ async getItem(library, itemKey) {
32
+ const url = this.buildUrl(`${this.libraryPrefix(library)}/items/${itemKey}`);
33
+ return this.get(url);
34
+ }
35
+ async getItemChildren(library, itemKey, options) {
36
+ const url = this.buildUrl(`${this.libraryPrefix(library)}/items/${itemKey}/children`, this.listParams(options));
37
+ return this.get(url);
38
+ }
39
+ async listCollections(library, parentKey, options) {
40
+ const path = parentKey
41
+ ? `${this.libraryPrefix(library)}/collections/${parentKey}/collections`
42
+ : `${this.libraryPrefix(library)}/collections`;
43
+ const url = this.buildUrl(path, this.listParams(options));
44
+ return this.get(url);
45
+ }
46
+ async getCollection(library, collectionKey) {
47
+ const url = this.buildUrl(`${this.libraryPrefix(library)}/collections/${collectionKey}`);
48
+ return this.get(url);
49
+ }
50
+ async listTags(library, options) {
51
+ const url = this.buildUrl(`${this.libraryPrefix(library)}/tags`, this.listParams(options));
52
+ return this.get(url);
53
+ }
54
+ async listLibraries() {
55
+ const id = this.userId ?? "0";
56
+ const url = this.buildUrl(`/users/${id}/groups`);
57
+ return this.get(url);
58
+ }
59
+ async searchItems(library, query, options) {
60
+ const params = this.searchParams(options);
61
+ params.set("q", query);
62
+ const url = this.buildUrl(`${this.libraryPrefix(library)}/items`, params);
63
+ return this.get(url);
64
+ }
65
+ // ── Mutation methods ───────────────────────────────────────────
66
+ async createItem(library, data) {
67
+ const url = this.buildUrl(`${this.libraryPrefix(library)}/items`);
68
+ return this.post(url, data);
69
+ }
70
+ async updateItem(library, itemKey, data, version) {
71
+ const url = this.buildUrl(`${this.libraryPrefix(library)}/items/${itemKey}`);
72
+ return this.patch(url, data, version);
73
+ }
74
+ async deleteItem(library, itemKey, version) {
75
+ const url = this.buildUrl(`${this.libraryPrefix(library)}/items/${itemKey}`);
76
+ const response = await this.http.request("DELETE", url, {
77
+ headers: this.headers({ "If-Unmodified-Since-Version": String(version) }),
78
+ });
79
+ return this.parseResponse(response);
80
+ }
81
+ async createCollection(library, data) {
82
+ const url = this.buildUrl(`${this.libraryPrefix(library)}/collections`);
83
+ return this.post(url, data);
84
+ }
85
+ async deleteCollection(library, collectionKey, version) {
86
+ const url = this.buildUrl(`${this.libraryPrefix(library)}/collections/${collectionKey}`);
87
+ const response = await this.http.request("DELETE", url, {
88
+ headers: this.headers({ "If-Unmodified-Since-Version": String(version) }),
89
+ });
90
+ return this.parseResponse(response);
91
+ }
92
+ async addToCollection(library, itemKey, collectionKey) {
93
+ const item = (await this.getItem(library, itemKey));
94
+ const data = item["data"];
95
+ const version = item["version"] ?? 0;
96
+ const collections = Array.isArray(data?.["collections"])
97
+ ? [...data["collections"]]
98
+ : [];
99
+ if (!collections.includes(collectionKey)) {
100
+ collections.push(collectionKey);
101
+ }
102
+ return this.updateItem(library, itemKey, { collections }, version);
103
+ }
104
+ async removeFromCollection(library, itemKey, collectionKey) {
105
+ const item = (await this.getItem(library, itemKey));
106
+ const data = item["data"];
107
+ const version = item["version"] ?? 0;
108
+ const collections = Array.isArray(data?.["collections"])
109
+ ? data["collections"].filter((k) => k !== collectionKey)
110
+ : [];
111
+ return this.updateItem(library, itemKey, { collections }, version);
112
+ }
113
+ // ── Schema/template methods (global, not library-scoped) ─────
114
+ async getItemTypes() {
115
+ const url = "https://api.zotero.org/itemTypes";
116
+ return this.get(url);
117
+ }
118
+ async getItemTemplate(itemType) {
119
+ const url = `https://api.zotero.org/items/new?itemType=${encodeURIComponent(itemType)}`;
120
+ return this.get(url);
121
+ }
122
+ async getItemTypeFields(itemType) {
123
+ const url = `https://api.zotero.org/itemTypeFields?itemType=${encodeURIComponent(itemType)}`;
124
+ return this.get(url);
125
+ }
126
+ async getItemTypeCreatorTypes(itemType) {
127
+ const url = `https://api.zotero.org/itemTypeCreatorTypes?itemType=${encodeURIComponent(itemType)}`;
128
+ return this.get(url);
129
+ }
130
+ // ── Full-text ───────────────────────────────────────────────────
131
+ async getFullText(library, itemKey) {
132
+ const url = this.buildUrl(`${this.libraryPrefix(library)}/items/${itemKey}/fulltext`);
133
+ return this.get(url);
134
+ }
135
+ // ── Export ──────────────────────────────────────────────────────
136
+ async exportItems(library, format, options) {
137
+ const params = this.searchParams(options);
138
+ params.set("format", format);
139
+ const url = this.buildUrl(`${this.libraryPrefix(library)}/items`, params);
140
+ const response = await this.http.request("GET", url, {
141
+ headers: this.headers(),
142
+ });
143
+ if (response.status >= 400) {
144
+ throw new ExternalToolError(`Zotero API error (${response.status}): ${response.body || "unknown error"}`);
145
+ }
146
+ return response.body;
147
+ }
148
+ // ── File operations ────────────────────────────────────────────
149
+ async uploadAttachment(library, parentItemKey, filePath, contentType) {
150
+ // 1. Get the attachment template
151
+ const template = (await this.getItemTemplate("attachment&linkMode=imported_file"));
152
+ // 2. Create the attachment item
153
+ const fileName = basename(filePath);
154
+ const attachmentData = {
155
+ ...template,
156
+ parentItem: parentItemKey,
157
+ title: fileName,
158
+ contentType,
159
+ };
160
+ const createUrl = this.buildUrl(`${this.libraryPrefix(library)}/items`);
161
+ const createResponse = await this.http.request("POST", createUrl, {
162
+ headers: this.headers({ "Content-Type": "application/json" }),
163
+ body: JSON.stringify([attachmentData]),
164
+ });
165
+ const createResult = await this.parseResponse(createResponse);
166
+ const successful = createResult?.["successful"];
167
+ const created = successful?.["0"];
168
+ const attachmentKey = created?.["key"] ??
169
+ created?.["data"]?.["key"];
170
+ if (!attachmentKey) {
171
+ throw new ExternalToolError(`Failed to create attachment item: ${JSON.stringify(createResult)}`);
172
+ }
173
+ // 3. Read the file, compute md5 and size
174
+ const fileBuffer = await readFile(filePath);
175
+ const md5 = createHash("md5").update(fileBuffer).digest("hex");
176
+ const fileSize = fileBuffer.length;
177
+ const mtime = Date.now();
178
+ // 4. Get upload authorization
179
+ const authUrl = this.buildUrl(`${this.libraryPrefix(library)}/items/${attachmentKey}/file`);
180
+ const authBody = new URLSearchParams({
181
+ md5,
182
+ filename: fileName,
183
+ filesize: String(fileSize),
184
+ mtime: String(mtime),
185
+ }).toString();
186
+ const authResponse = await this.http.request("POST", authUrl, {
187
+ headers: this.headers({
188
+ "Content-Type": "application/x-www-form-urlencoded",
189
+ "If-None-Match": "*",
190
+ }),
191
+ body: authBody,
192
+ });
193
+ const authResult = (await this.parseResponse(authResponse));
194
+ // 5. If file already exists on server, we're done
195
+ if (authResult["exists"] === 1) {
196
+ return { exists: true, key: attachmentKey };
197
+ }
198
+ // 6. Upload the file to the storage service
199
+ const uploadUrl = authResult["url"];
200
+ const prefix = authResult["prefix"] ?? "";
201
+ const suffix = authResult["suffix"] ?? "";
202
+ const uploadContentType = authResult["contentType"];
203
+ const uploadKey = authResult["uploadKey"];
204
+ // Build the upload body: prefix + file content + suffix
205
+ const prefixBuf = Buffer.from(prefix, "utf-8");
206
+ const suffixBuf = Buffer.from(suffix, "utf-8");
207
+ const uploadBody = Buffer.concat([prefixBuf, fileBuffer, suffixBuf]);
208
+ const uploadResponse = await this.http.request("POST", uploadUrl, {
209
+ headers: { "Content-Type": uploadContentType },
210
+ body: uploadBody.toString("binary"),
211
+ });
212
+ if (uploadResponse.status >= 400) {
213
+ throw new ExternalToolError(`File upload failed (${uploadResponse.status}): ${uploadResponse.body || "unknown error"}`);
214
+ }
215
+ // 7. Register the upload
216
+ const registerBody = new URLSearchParams({ upload: uploadKey }).toString();
217
+ const registerResponse = await this.http.request("POST", authUrl, {
218
+ headers: this.headers({
219
+ "Content-Type": "application/x-www-form-urlencoded",
220
+ "If-None-Match": "*",
221
+ }),
222
+ body: registerBody,
223
+ });
224
+ await this.parseResponse(registerResponse);
225
+ return { success: true, key: attachmentKey };
226
+ }
227
+ async getFileUrl(library, itemKey) {
228
+ const url = this.buildUrl(`${this.libraryPrefix(library)}/items/${itemKey}/file`);
229
+ return url;
230
+ }
231
+ // ── Helpers ────────────────────────────────────────────────────
232
+ libraryPrefix(library) {
233
+ return `/${library.type === "group" ? "groups" : "users"}/${library.id}`;
234
+ }
235
+ buildUrl(path, params) {
236
+ const base = `${this.baseUrl}${path}`;
237
+ if (params && Array.from(params).length > 0) {
238
+ return `${base}?${params.toString()}`;
239
+ }
240
+ return base;
241
+ }
242
+ headers(extra) {
243
+ const h = {
244
+ "Zotero-API-Version": "3",
245
+ ...extra,
246
+ };
247
+ if (this.apiKey) {
248
+ h["Zotero-API-Key"] = this.apiKey;
249
+ }
250
+ return h;
251
+ }
252
+ async parseResponse(response) {
253
+ if (response.status === 204) {
254
+ return null;
255
+ }
256
+ if (response.status === 404) {
257
+ throw new NotFoundError(`Resource not found: ${response.body || "unknown"}`);
258
+ }
259
+ if (response.status === 409 || response.status === 412) {
260
+ throw new ConflictError(`Conflict: ${response.body || "resource version mismatch"}`);
261
+ }
262
+ if (response.status >= 400) {
263
+ throw new ExternalToolError(`Zotero API error (${response.status}): ${response.body || "unknown error"}`);
264
+ }
265
+ if (!response.body) {
266
+ return null;
267
+ }
268
+ try {
269
+ return JSON.parse(response.body);
270
+ }
271
+ catch {
272
+ throw new ExternalToolError(`Failed to parse Zotero API response: ${response.body.slice(0, 200)}`);
273
+ }
274
+ }
275
+ listParams(options) {
276
+ const params = new URLSearchParams();
277
+ if (!options)
278
+ return params;
279
+ if (options.limit !== undefined)
280
+ params.set("limit", String(options.limit));
281
+ if (options.start !== undefined)
282
+ params.set("start", String(options.start));
283
+ if (options.sort)
284
+ params.set("sort", options.sort);
285
+ if (options.direction)
286
+ params.set("direction", options.direction);
287
+ if (options.format)
288
+ params.set("format", options.format);
289
+ return params;
290
+ }
291
+ searchParams(options) {
292
+ const params = this.listParams(options);
293
+ if (!options)
294
+ return params;
295
+ if (options.query)
296
+ params.set("q", options.query);
297
+ if (options.qmode)
298
+ params.set("qmode", options.qmode);
299
+ if (options.tag) {
300
+ for (const t of options.tag) {
301
+ params.append("tag", t);
302
+ }
303
+ }
304
+ if (options.itemType)
305
+ params.set("itemType", options.itemType);
306
+ return params;
307
+ }
308
+ // ── HTTP verb shortcuts ────────────────────────────────────────
309
+ async get(url) {
310
+ const response = await this.http.request("GET", url, {
311
+ headers: this.headers(),
312
+ });
313
+ return this.parseResponse(response);
314
+ }
315
+ async post(url, data) {
316
+ const response = await this.http.request("POST", url, {
317
+ headers: this.headers({ "Content-Type": "application/json" }),
318
+ body: JSON.stringify(data),
319
+ });
320
+ return this.parseResponse(response);
321
+ }
322
+ async patch(url, data, version) {
323
+ const response = await this.http.request("PATCH", url, {
324
+ headers: this.headers({
325
+ "Content-Type": "application/json",
326
+ "If-Unmodified-Since-Version": String(version),
327
+ }),
328
+ body: JSON.stringify(data),
329
+ });
330
+ return this.parseResponse(response);
331
+ }
332
+ }
@@ -0,0 +1,8 @@
1
+ export class ConsoleOutputAdapter {
2
+ write(message) {
3
+ process.stdout.write(`${message}\n`);
4
+ }
5
+ writeError(message) {
6
+ process.stderr.write(`${message}\n`);
7
+ }
8
+ }
@@ -0,0 +1,42 @@
1
+ export class AppError extends Error {
2
+ code;
3
+ exitCode;
4
+ constructor(message, code, exitCode) {
5
+ super(message);
6
+ this.name = new.target.name;
7
+ this.code = code;
8
+ this.exitCode = exitCode;
9
+ }
10
+ }
11
+ export class ValidationError extends AppError {
12
+ constructor(message) {
13
+ super(message, "VALIDATION_ERROR", 2);
14
+ }
15
+ }
16
+ export class NotFoundError extends AppError {
17
+ constructor(message) {
18
+ super(message, "NOT_FOUND", 3);
19
+ }
20
+ }
21
+ export class ConflictError extends AppError {
22
+ constructor(message) {
23
+ super(message, "CONFLICT", 4);
24
+ }
25
+ }
26
+ export class ExternalToolError extends AppError {
27
+ constructor(message) {
28
+ super(message, "EXTERNAL_TOOL_ERROR", 5);
29
+ }
30
+ }
31
+ export function toExitCode(error) {
32
+ if (error instanceof AppError) {
33
+ return error.exitCode;
34
+ }
35
+ return 1;
36
+ }
37
+ export function toErrorMessage(error) {
38
+ if (error instanceof Error) {
39
+ return error.message;
40
+ }
41
+ return String(error);
42
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1 @@
1
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,11 @@
1
+ #!/usr/bin/env node
2
+ import { createRuntime } from "./compositionRoot.js";
3
+ import { toErrorMessage, toExitCode } from "./application/errors.js";
4
+ export async function runCli(argv) {
5
+ const runtime = createRuntime();
6
+ await runtime.registry.run(argv, runtime);
7
+ }
8
+ runCli(process.argv.slice(2)).catch((error) => {
9
+ process.stderr.write(`${toErrorMessage(error)}\n`);
10
+ process.exit(toExitCode(error));
11
+ });
@@ -0,0 +1,44 @@
1
+ import { ValidationError } from "../application/errors.js";
2
+ const CATEGORY_ORDER = ["Core", "Property", "Create", "Lookup", "Other"];
3
+ export class CommandRegistry {
4
+ commands = new Map();
5
+ register(command) {
6
+ this.commands.set(command.name, command);
7
+ }
8
+ help() {
9
+ const lines = ["Usage: zt <command> [options]", ""];
10
+ const byCategory = new Map();
11
+ for (const command of this.commands.values()) {
12
+ const list = byCategory.get(command.category) ?? [];
13
+ list.push(command);
14
+ byCategory.set(command.category, list);
15
+ }
16
+ for (const category of CATEGORY_ORDER) {
17
+ const commands = byCategory.get(category);
18
+ if (!commands || commands.length === 0)
19
+ continue;
20
+ lines.push(`${category}:`);
21
+ for (const command of commands.sort((a, b) => a.name.localeCompare(b.name))) {
22
+ lines.push(` ${command.name.padEnd(24)} ${command.description}`);
23
+ }
24
+ lines.push("");
25
+ }
26
+ return lines.join("\n").trimEnd();
27
+ }
28
+ async run(argv, context) {
29
+ const [commandName, ...rest] = argv;
30
+ if (!commandName || commandName === "help" || commandName === "--help") {
31
+ context.output.write(this.help());
32
+ return;
33
+ }
34
+ const command = this.commands.get(commandName);
35
+ if (!command)
36
+ throw new ValidationError(`Unknown command: ${commandName}`);
37
+ if (rest.includes("--help")) {
38
+ context.output.write(command.help(context));
39
+ return;
40
+ }
41
+ const input = command.parse(rest, context);
42
+ await command.execute(input, context);
43
+ }
44
+ }
@@ -0,0 +1,49 @@
1
+ import { ValidationError } from "../application/errors.js";
2
+ import { assertNoMissingOptionValues, assertNoUnknownOptions, getOption, parseArgs } from "../utils/args.js";
3
+ import { renderJson, resolveLibrary } from "./helpers.js";
4
+ export class AttachCommand {
5
+ name = "attach";
6
+ category = "Core";
7
+ description = "Attach a file to an existing item.";
8
+ help() {
9
+ return [
10
+ "Usage:",
11
+ " zt attach <file-path> --key <parentItemKey>",
12
+ "",
13
+ "Upload and attach a file to an existing Zotero item.",
14
+ "",
15
+ "Options:",
16
+ " --key <parentItemKey> Parent item key (required)",
17
+ " --group <id> Library group ID",
18
+ " --content-type <mime> MIME type (default: application/pdf)",
19
+ "",
20
+ "Examples:",
21
+ ' zt attach paper.pdf --key ABC12345',
22
+ ' zt attach image.png --key ABC12345 --content-type image/png',
23
+ ' zt attach paper.pdf --key ABC12345 --group 123456'
24
+ ].join("\n");
25
+ }
26
+ parse(argv, context) {
27
+ const parsed = parseArgs(argv);
28
+ assertNoUnknownOptions(parsed, ["key", "group", "content-type"]);
29
+ assertNoMissingOptionValues(parsed, ["key", "group", "content-type"]);
30
+ const library = resolveLibrary(parsed, context?.config ?? { baseUrl: "" });
31
+ const key = getOption(parsed, "key");
32
+ const filePath = parsed.positionals[0];
33
+ const contentType = getOption(parsed, "content-type") ?? "application/pdf";
34
+ if (!filePath) {
35
+ throw new ValidationError("attach requires a file path. Usage: zt attach <file-path> --key <parentItemKey>");
36
+ }
37
+ if (parsed.positionals.length > 1) {
38
+ throw new ValidationError("attach accepts at most one positional argument (file path).");
39
+ }
40
+ if (!key) {
41
+ throw new ValidationError("attach requires --key <parentItemKey>.");
42
+ }
43
+ return { library, key, filePath, contentType };
44
+ }
45
+ async execute(input, context) {
46
+ const result = await context.zotero.uploadAttachment(input.library, input.key, input.filePath, input.contentType);
47
+ context.output.write(renderJson(result));
48
+ }
49
+ }