@xiaoqiong0v0/opencode-file-tool 1.0.2

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.
Files changed (3) hide show
  1. package/README.md +47 -0
  2. package/file-tool.js +397 -0
  3. package/package.json +25 -0
package/README.md ADDED
@@ -0,0 +1,47 @@
1
+ # opencode-file-tool
2
+
3
+ OpenCode 文件缓存与图片分析插件。自动缓存用户粘贴的文件,通过多模态模型分析图片,绕过主模型不支持视觉的限制。
4
+
5
+ ## 安装
6
+
7
+ ```bash
8
+ # 在 opencode 配置目录安装依赖
9
+ cd ~/.config/opencode
10
+ npm install @xiaoqiong0v0/opencode-file-tool @xiaoqiong0v0/opencode-plugin-logger
11
+ ```
12
+
13
+ 然后在 `opencode.json` 的 `plugin` 数组添加:
14
+
15
+ ```json
16
+ "plugin": ["@xiaoqiong0v0/opencode-file-tool"]
17
+ ```
18
+
19
+ 重启 OpenCode 后,首次使用 `file_tool set-provider <模型名>` 配置视觉模型。
20
+
21
+ ## 功能
22
+
23
+ - **文件缓存** — 粘贴图片时自动缓存到 `~/.opencode/plugins-cache/{sessionId}/`
24
+ - **图片分析** — 通过 `analyze_image file_id:N` 用视觉模型分析
25
+ - **主/子会话隔离** — 缓存按会话独立存储,子会话完成自动清理
26
+ - **主会话回退** — 子会话可通过 `list-cache main` 读取主会话缓存
27
+
28
+ ## 工具
29
+
30
+ | 工具 | 说明 |
31
+ |------|------|
32
+ | `file_tool list-provider` | 列出可用模型提供者 |
33
+ | `file_tool set-provider <model>` | 切换视觉分析模型 |
34
+ | `file_tool list-cache [all\|N\|main\|main N]` | 查看缓存文件列表 |
35
+ | `analyze_image file_id:N` | 用视觉模型分析指定图片 |
36
+
37
+ ## 配置
38
+
39
+ `~/.config/opencode/file-tool.jsonc` 在首次启动时自动生成,也可手动编辑。
40
+
41
+ ## 依赖
42
+
43
+ - `@xiaoqiong0v0/opencode-plugin-logger` — 文件日志库
44
+
45
+ ## GitHub
46
+
47
+ https://github.com/xiaoqiong0v0/opencode-file-tool
package/file-tool.js ADDED
@@ -0,0 +1,397 @@
1
+ import { tool } from "@opencode-ai/plugin"
2
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, rmSync } from "node:fs"
3
+ import createLogger from "@xiaoqiong0v0/opencode-plugin-logger"
4
+
5
+ import { rm } from "node:fs/promises"
6
+ import { join } from "node:path"
7
+
8
+ const CONFIG_DIR = process.env.HOME || process.env.USERPROFILE
9
+ const CONFIG_PATH = join(CONFIG_DIR, ".config/opencode/file-tool.jsonc")
10
+ const OPENCODE_CONFIG = join(CONFIG_DIR, ".config/opencode/opencode.json")
11
+ const CACHE_DIR = join(CONFIG_DIR, ".opencode/plugins-cache")
12
+ const CMD_DIR = join(CONFIG_DIR, ".config/opencode/command")
13
+
14
+ const log = createLogger("file-tool")
15
+
16
+ // === 全局配置 ===
17
+ let _cfg = null
18
+ const FILE_TOOL_CFG_SAMPLE = `{
19
+ // 视觉分析模型(provider/modelId),file_tool set-provider 切换
20
+ "model": "",
21
+ "maxTokens": 4096,
22
+ "timeout": 60000,
23
+ "maxFileSizeMB": 20,
24
+ // 缓存消息数量上限,超过则删除最早的
25
+ "maxCacheMessages": 3,
26
+ // 工具提示语言:zh=中文, en=English
27
+ "lang": "en"
28
+ }
29
+ `
30
+ const CMD_ZH = `---
31
+ description: 切换视觉分析模型
32
+ ---
33
+ 直接调用 file_tool 工具,不要委托给其他 agent。
34
+ 没有参数默认传递:\`list-provider\`,列出可选择模型提供者。
35
+ 使用 \`set-provider <模型名>\` 切换模型。
36
+ 使用 \`list-cache\` 查看缓存文件列表。
37
+ `
38
+ const CMD_EN = `---
39
+ description: Switch vision analysis model
40
+ ---
41
+ Call file_tool directly, don't delegate to other agents.
42
+ Default: \`list-provider\` to list available model providers.
43
+ Use \`set-provider <model>\` to switch models.
44
+ Use \`list-cache\` to view cached files.
45
+ `
46
+
47
+ let MAX_CACHE_MSGS = 3
48
+ let LANG = "en"
49
+
50
+ function loadCfg() {
51
+ if (!existsSync(CONFIG_PATH)) {
52
+ try { writeFileSync(CONFIG_PATH, FILE_TOOL_CFG_SAMPLE, "utf-8") } catch {}
53
+ }
54
+ const raw = existsSync(CONFIG_PATH) ? readJsonc(CONFIG_PATH) : {}
55
+ _cfg = resolveConfig(raw)
56
+ MAX_CACHE_MSGS = (raw.maxCacheMessages > 0) ? raw.maxCacheMessages : 3
57
+ LANG = raw.lang || "en"
58
+ // 自动生成 command 定义
59
+ const cmdLang = raw.lang || "en"
60
+ const content = cmdLang === "en" ? CMD_EN : CMD_ZH
61
+ if (!existsSync(CMD_DIR)) mkdirSync(CMD_DIR, { recursive: true })
62
+ const cmdFile = join(CMD_DIR, "file-tool.md")
63
+ if (!existsSync(cmdFile)) {
64
+ writeFileSync(cmdFile, content, "utf-8")
65
+ } else {
66
+ const existing = readFileSync(cmdFile, "utf-8")
67
+ if (existing === CMD_ZH || existing === CMD_EN) {
68
+ if (existing !== content) writeFileSync(cmdFile, content, "utf-8")
69
+ }
70
+ }
71
+ return _cfg
72
+ }
73
+
74
+ function reloadCfg() { loadCfg() }
75
+
76
+ loadCfg()
77
+
78
+ const TX = {
79
+ file_not_found: { zh: "文件不存在: {path}", en: "File not found: {path}" },
80
+ file_id_not_found: { zh: "文件ID不存在: {id}", en: "File ID not found: {id}" },
81
+ file_data_not_found: { zh: "文件数据不存在: {id}", en: "File data not found: {id}" },
82
+ not_an_image: { zh: "不是图片文件: {name} ({mime})", en: "Not an image: {name} ({mime})" },
83
+ unsupported_source: { zh: "不支持的图片来源: {source}", en: "Unsupported source: {source}" },
84
+ describe_image: { zh: "请详细描述这张图片({name})的内容", en: "Describe this image ({name})" },
85
+ current_model: { zh: "当前模型: {model}\n可用模型:\n{list}", en: "Current model: {model}\nAvailable models:\n{list}" },
86
+ model_not_set: { zh: "未设置", en: "not set" },
87
+ model_switched: { zh: "视觉模型已切换为: {model}", en: "Vision model set to: {model}" },
88
+ specify_model: { zh: "请指定模型名", en: "Specify a model name" },
89
+ unknown_cmd: { zh: "未知命令: {cmd}\n可用: list-provider, set-provider <model>, list-cache [all|N|main|main N]", en: "Unknown command: {cmd}\nAvailable: list-provider, set-provider <model>, list-cache [all|N|main|main N]" },
90
+ config_error: { zh: "请在 file-tool.jsonc 中配置 model (provider/modelId) 或 apiKey+apiBaseUrl+model", en: "Set model (provider/modelId) or apiKey+apiBaseUrl+model in file-tool.jsonc" },
91
+ meta_failed: { zh: "分析失败", en: "Failed" },
92
+ meta_skip: { zh: "跳过", en: "Skip" },
93
+ meta_not_found: { zh: "文件不存在", en: "Not found" },
94
+ meta_image: { zh: "图片", en: "Image" },
95
+ meta_error: { zh: "分析出错", en: "Error" },
96
+ no_cache: { zh: "[] (无缓存)", en: "[] (no cache)" },
97
+ }
98
+
99
+ const T = (key, params) => {
100
+ const t = (TX[key] || { zh: key, en: key })[LANG]
101
+ if (!params) return t
102
+ return Object.entries(params).reduce((s, [k, v]) => s.replace(`{${k}}`, v), t)
103
+ }
104
+
105
+ const DESC = {
106
+ analyze_image: {
107
+ zh: "用多模态模型分析图片。先调 file_tool list-cache 拿到文件ID,再用 file_id:N 分析。",
108
+ en: "Analyze images with multimodal model. Call file_tool list-cache first to get file IDs, then use file_id:N.",
109
+ },
110
+ file_tool: {
111
+ zh: "文件缓存管理。当你在上下文中看到 [Image N] 或收到 Cannot read 图片错误时,立即调 list-cache 获取文件ID,再用 analyze_image file_id:N 分析。",
112
+ en: "File cache manager. When you see [Image N] or a Cannot read image error, call list-cache to get file IDs, then use analyze_image file_id:N.",
113
+ },
114
+ file_tool_args: {
115
+ zh: "list-cache, list-cache main, list-provider, set-provider <model>",
116
+ en: "list-cache, list-cache main, list-provider, set-provider <model>",
117
+ },
118
+ analyze_args_source: { zh: "file_path=file_id:N", en: "file_path=file_id:N" },
119
+ analyze_args_data: { zh: "file_id:N 或 base64", en: "file_id:N or base64" },
120
+ analyze_args_prompt: { zh: "分析提示", en: "prompt" },
121
+ }
122
+
123
+ function getCfg() {
124
+ if (_cfg) return _cfg
125
+ const raw = existsSync(CONFIG_PATH) ? readJsonc(CONFIG_PATH) : {}
126
+ _cfg = resolveConfig(raw)
127
+ return _cfg
128
+ }
129
+
130
+ function resolveConfig(fileConfig) {
131
+ const model = fileConfig.model
132
+ if (!model) throw new Error(T("config_error"))
133
+ if (fileConfig.apiKey && fileConfig.apiBaseUrl) {
134
+ const mId = model.includes("/") ? model.split("/").pop() : model
135
+ return { apiKey: fileConfig.apiKey, baseURL: fileConfig.apiBaseUrl, modelId: mId, maxTokens: fileConfig.maxTokens || 4096, timeout: fileConfig.timeout || 60000 }
136
+ }
137
+ if (model.includes("/")) {
138
+ const [provider, modelId] = model.split("/")
139
+ try {
140
+ const raw = readFileSync(OPENCODE_CONFIG, "utf-8")
141
+ const oc = JSON.parse(raw)
142
+ const prov = oc.provider?.[provider]
143
+ if (prov?.options?.apiKey && prov?.options?.baseURL)
144
+ return { apiKey: prov.options.apiKey, baseURL: prov.options.baseURL, modelId, maxTokens: fileConfig.maxTokens || 4096, timeout: fileConfig.timeout || 60000 }
145
+ } catch {}
146
+ }
147
+ throw new Error(`无法解析模型配置: ${model}。请在 file-tool.jsonc 中配置 model (provider/modelId) 或 apiKey+apiBaseUrl+model`)
148
+ }
149
+
150
+ function readJsonc(path) {
151
+ const raw = readFileSync(path, "utf-8").replace(/\/\/.*$/gm, "").replace(/\/\*[\s\S]*?\*\//g, "")
152
+ return JSON.parse(raw)
153
+ }
154
+
155
+ async function callVisionApi(imageUrl, prompt) {
156
+ const cfg = getCfg()
157
+ const resp = await fetch(`${cfg.baseURL}/chat/completions`, {
158
+ method: "POST",
159
+ headers: { Authorization: `Bearer ${cfg.apiKey}`, "Content-Type": "application/json" },
160
+ body: JSON.stringify({ model: cfg.modelId, messages: [{ role: "user", content: [{ type: "text", text: prompt || "请详细描述这张图片的内容,返回格式: [文件名] 描述" }, { type: "image_url", image_url: { url: imageUrl } }] }], max_tokens: cfg.maxTokens }),
161
+ signal: AbortSignal.timeout(cfg.timeout),
162
+ })
163
+ if (!resp.ok) throw new Error(`API ${resp.status}: ${(await resp.text().catch(() => "unknown")).slice(0, 200)}`)
164
+ const data = await resp.json()
165
+ const msg = data.choices?.[0]?.message
166
+ return msg?.content || msg?.reasoning_content || "(空)"
167
+ }
168
+
169
+ // ====== 会话栈 ======
170
+ const SessionStack = {
171
+ _stack: ["default"],
172
+ _main: "default",
173
+ push(id) {
174
+ if (this._stack.length === 1 && this._stack[0] === "default") {
175
+ this._main = id
176
+ }
177
+ this._stack.push(id)
178
+ },
179
+ remove(id) {
180
+ const idx = this._stack.indexOf(id)
181
+ if (idx >= 0) this._stack.splice(idx)
182
+ if (this._stack.length === 0) this._stack.push("default")
183
+ },
184
+ get current() { return this._stack[this._stack.length - 1] },
185
+ get main() {
186
+ try { const v = readFileSync(join(CACHE_DIR, ".main-session"), "utf-8").trim(); if (v) return v } catch {}
187
+ return this._main && this._main !== "default" ? this._main : "default"
188
+ },
189
+ }
190
+
191
+ // ====== 文件缓存:~/.opencode/plugins-cache/{sessionId}/files.json ======
192
+ function sessionDir(sid) { return join(CACHE_DIR, sid) }
193
+
194
+ function filesDir(sid) { const d = join(sessionDir(sid), "files"); if (!existsSync(d)) mkdirSync(d, { recursive: true }); return d }
195
+
196
+ function readSession(sid) {
197
+ try { return JSON.parse(readFileSync(join(sessionDir(sid), "files.json"), "utf-8")) }
198
+ catch { return { nextId: 1, files: {}, messages: [] } }
199
+ }
200
+
201
+ function writeSession(sid, data) {
202
+ const dir = sessionDir(sid)
203
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
204
+ // 超出上限时异步删除最早的消息及文件
205
+ const msgs = data.messages || []
206
+ if (msgs.length > MAX_CACHE_MSGS) {
207
+ const expired = msgs.splice(0, msgs.length - MAX_CACHE_MSGS)
208
+ for (const msg of expired) {
209
+ for (const fid of (msg.fileIds || [])) {
210
+ delete data.files[fid]
211
+ const path = join(dir, "files", fid + ".b64")
212
+ rm(path, { force: true })
213
+ .then(() => {
214
+ log.info(`${sid}: Deleted file ${path}`)
215
+ })
216
+ .catch((err) => {
217
+ log.error(`${sid}: Failed to delete file ${path}`, err)
218
+ })
219
+ }
220
+ }
221
+ }
222
+ writeFileSync(join(dir, "files.json"), JSON.stringify(data, null, 2))
223
+ }
224
+
225
+ function writeFileData(sid, fid, url) {
226
+ // url 格式: "data:image/png;base64,iVBOR...",只存 base64 部分
227
+ const b64 = url.replace(/^data:\w+\/\w+;base64,/, "")
228
+ writeFileSync(join(filesDir(sid), fid + ".b64"), b64, "utf-8")
229
+ }
230
+
231
+ function readFileData(sid, fid) {
232
+ try {
233
+ const b64 = readFileSync(join(filesDir(sid), fid + ".b64"), "utf-8")
234
+ const meta = readSession(sid).files[fid]
235
+ return `data:${meta?.mime || "image/png"};base64,${b64}`
236
+ } catch {
237
+ // 当前会话没有,尝试主会话
238
+ try {
239
+ const mainSid = SessionStack.main
240
+ if (mainSid !== sid) return readFileData(mainSid, fid)
241
+ } catch {}
242
+ return null
243
+ }
244
+ }
245
+
246
+ function deleteSession(sid) {
247
+ const dir = sessionDir(sid)
248
+ if (existsSync(dir)) rmSync(dir, { recursive: true, force: true })
249
+ }
250
+
251
+
252
+ export const FileTool = async () => {
253
+ log.loaded()
254
+ return {
255
+ event: async ({ event }) => {
256
+ if (event.type === "session.created" && event.properties?.sessionID)
257
+ SessionStack.push(event.properties.sessionID)
258
+ if (event.type === "session.deleted" && event.properties?.sessionID) {
259
+ deleteSession(event.properties.sessionID)
260
+ SessionStack.remove(event.properties.sessionID)
261
+ }
262
+ if (event.type === "message.part.updated" && event.properties?.part?.type === "file" && (event.properties.part.mime || "").startsWith("image/")) {
263
+ const part = event.properties.part
264
+ const fn = part.filename || part.name || ""
265
+ if (fn) {
266
+ const sid = event.properties.sessionID || SessionStack.current
267
+ // 首次获取到真实会话ID时更新栈并记录主会话ID
268
+ if (sid && SessionStack.current === "default" && sid !== "default") {
269
+ SessionStack._stack = [sid]
270
+ SessionStack._main = sid
271
+ try { writeFileSync(join(CACHE_DIR, ".main-session"), sid, "utf-8") } catch {}
272
+ }
273
+ // 首次贴图也记录主会话ID(适配主会话未触发session.created的场景)
274
+ if (sid && !existsSync(join(CACHE_DIR, ".main-session"))) {
275
+ try { writeFileSync(join(CACHE_DIR, ".main-session"), sid, "utf-8") } catch {}
276
+ }
277
+ const data = readSession(sid)
278
+ const fid = data.nextId++
279
+ const msgId = part.messageID || ""
280
+ // 添加到文件映射
281
+ data.files[fid] = { id: fid, filename: fn, mime: part.mime, msgId }
282
+ writeFileData(sid, fid, part.url || "")
283
+ // 按消息分组
284
+ const msgs = data.messages
285
+ const last = msgs[msgs.length - 1]
286
+ if (last && last.msgId === msgId) {
287
+ last.fileIds.push(fid)
288
+ } else {
289
+ msgs.push({ msgId, fileIds: [fid] })
290
+ }
291
+ writeSession(sid, data)
292
+ }
293
+ }
294
+ },
295
+
296
+ tool: {
297
+ analyze_image: tool({
298
+ description: DESC.analyze_image[LANG],
299
+ args: {
300
+ source: tool.schema.enum(["file_path", "base64"]).describe(DESC.analyze_args_source[LANG]),
301
+ data: tool.schema.string().describe(DESC.analyze_args_data[LANG]),
302
+ prompt: tool.schema.string().optional().describe(DESC.analyze_args_prompt[LANG]),
303
+ },
304
+ execute: async ({ source, data, prompt }, context) => {
305
+ let imageUrl, fileName = ""
306
+ if (source === "file_path" && data.startsWith("file_id:")) {
307
+ const fid = parseInt(data.slice(8), 10)
308
+ const store = readSession(context.sessionID)
309
+ let file = store.files[fid]
310
+ if (!file && context.sessionID !== SessionStack.main) {
311
+ const mainStore = readSession(SessionStack.main)
312
+ file = mainStore.files[fid]
313
+ }
314
+ if (!file) { context.metadata?.({ title: T("meta_failed") }); return T("file_id_not_found", { id: fid }) }
315
+ if (!file.mime.startsWith("image/")) { context.metadata?.({ title: T("meta_skip") }); return T("not_an_image", { name: file.filename, mime: file.mime }) }
316
+ fileName = file.filename
317
+ imageUrl = readFileData(context.sessionID, fid)
318
+ if (!imageUrl) { context.metadata?.({ title: T("meta_failed") }); return T("file_data_not_found", { id: fid }) }
319
+ prompt = prompt || T("describe_image", { name: fileName })
320
+ } else if (source === "file_path") {
321
+ if (!existsSync(data)) {
322
+ const tryPath = join(context.directory, data)
323
+ if (existsSync(tryPath)) data = tryPath
324
+ }
325
+ if (!existsSync(data)) { context.metadata?.({ title: T("meta_not_found") }); return T("file_not_found", { path: data }) }
326
+ const ext = data.split(".").pop().toLowerCase()
327
+ const mime = { png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", bmp: "image/bmp", gif: "image/gif", webp: "image/webp" }[ext] || "image/png"
328
+ fileName = data.split(/[/\\]/).pop() || ""
329
+ imageUrl = `data:${mime};base64,${readFileSync(data).toString("base64")}`
330
+ } else if (source === "base64") {
331
+ imageUrl = `data:image/png;base64,${data.replace(/^data:image\/\w+;base64,/, "")}`
332
+ } else { return T("unsupported_source", { source }) }
333
+ try {
334
+ const result = await callVisionApi(imageUrl, prompt)
335
+ context.metadata?.({ title: `[Vision] ${fileName || T("meta_image")}`, metadata: { sessionID: context.sessionID, messageID: context.messageID } })
336
+ return "[Vision] " + result
337
+ } catch (e) { context.metadata?.({ title: T("meta_error") }); return `[Vision Error] ${e.message}` }
338
+ },
339
+ }),
340
+
341
+ file_tool: tool({
342
+ description: DESC.file_tool[LANG],
343
+ args: { command: tool.schema.string().describe(DESC.file_tool_args[LANG]) },
344
+ execute: async ({ command }, context) => {
345
+ const cmd = command.trim()
346
+ if (cmd === "list-provider") {
347
+ const cfg = existsSync(CONFIG_PATH) ? readJsonc(CONFIG_PATH) : {}
348
+ const models = []
349
+ const oc = JSON.parse(readFileSync(OPENCODE_CONFIG, "utf-8"))
350
+ for (const [pName, pVal] of Object.entries(oc.provider || {}))
351
+ for (const mId of Object.keys(pVal.models || {}))
352
+ models.push(`${pName}/${mId}`)
353
+ return T("current_model", {
354
+ model: cfg.model || T("model_not_set"),
355
+ list: models.map(m => " " + m).join("\n"),
356
+ })
357
+ }
358
+ if (cmd.startsWith("set-provider ")) {
359
+ const model = cmd.slice(13).trim()
360
+ if (!model) return T("specify_model")
361
+ const cfg = existsSync(CONFIG_PATH) ? readJsonc(CONFIG_PATH) : {}
362
+ cfg.model = model; delete cfg.apiKey; delete cfg.apiBaseUrl
363
+ writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2))
364
+ reloadCfg()
365
+ return T("model_switched", { model })
366
+ }
367
+ if (cmd === "list-cache" || cmd.startsWith("list-cache ")) {
368
+ const arg = cmd === "list-cache" ? "1" : cmd.slice(11).trim()
369
+ let targetSid = context.sessionID
370
+ let limit = arg
371
+ if (arg === "main") { targetSid = SessionStack.main; limit = "1" }
372
+ if (arg.startsWith("main ")) { targetSid = SessionStack.main; limit = arg.slice(5).trim() }
373
+ const data = readSession(targetSid)
374
+ const msgs = data.messages || []
375
+ if (msgs.length === 0) return `${targetSid}: ${T("no_cache")}`
376
+ let count = msgs.length
377
+ if (limit !== "all") {
378
+ const n = parseInt(limit, 10)
379
+ if (!isNaN(n) && n > 0) count = Math.min(n, count)
380
+ }
381
+ const show = msgs.slice(-count)
382
+ let out = `${targetSid}:\n`
383
+ for (const msg of show) {
384
+ out += ` msg_${msg.msgId.slice(-8)}:\n`
385
+ for (const fid of msg.fileIds) {
386
+ const f = data.files[fid]
387
+ if (f) out += ` ${f.filename}: ${f.id}\n`
388
+ }
389
+ }
390
+ return out.trim()
391
+ }
392
+ return T("unknown_cmd", { cmd })
393
+ },
394
+ }),
395
+ },
396
+ }
397
+ }
package/package.json ADDED
@@ -0,0 +1,25 @@
1
+ {
2
+ "name": "@xiaoqiong0v0/opencode-file-tool",
3
+ "version": "1.0.2",
4
+ "type": "module",
5
+ "description": "File cache & image analysis plugin for OpenCode. Auto-caches pasted images, analyzes via multimodal model.",
6
+ "files": [
7
+ "file-tool.js",
8
+ "README.md"
9
+ ],
10
+ "exports": {
11
+ ".": "./file-tool.js"
12
+ },
13
+ "repository": {
14
+ "type": "git",
15
+ "url": "git+https://github.com/xiaoqiong0v0/opencode-file-tool.git"
16
+ },
17
+ "homepage": "https://github.com/xiaoqiong0v0/opencode-file-tool#readme",
18
+ "bugs": {
19
+ "url": "https://github.com/xiaoqiong0v0/opencode-file-tool/issues"
20
+ },
21
+ "license": "MIT",
22
+ "dependencies": {
23
+ "@xiaoqiong0v0/opencode-plugin-logger": "^1.0.0"
24
+ }
25
+ }