opencode-session-search 0.1.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/CHANGELOG.md +10 -0
- package/LICENSE +21 -0
- package/README.md +133 -0
- package/dist/index.js +12668 -0
- package/index.ts +425 -0
- package/package.json +47 -0
- package/scripts/install-local.sh +15 -0
package/index.ts
ADDED
|
@@ -0,0 +1,425 @@
|
|
|
1
|
+
import { type Plugin, tool } from "@opencode-ai/plugin";
|
|
2
|
+
import { Database } from "bun:sqlite";
|
|
3
|
+
import { spawnSync } from "node:child_process";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
|
|
6
|
+
const FALLBACK_DB_PATH = join(
|
|
7
|
+
process.env.HOME || "",
|
|
8
|
+
".local",
|
|
9
|
+
"share",
|
|
10
|
+
"opencode",
|
|
11
|
+
"opencode.db",
|
|
12
|
+
);
|
|
13
|
+
|
|
14
|
+
const resolveDbPath = () => {
|
|
15
|
+
if (process.env.OPENCODE_DB_PATH) {
|
|
16
|
+
return process.env.OPENCODE_DB_PATH;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
try {
|
|
20
|
+
const result = spawnSync("opencode", ["db", "path"], {
|
|
21
|
+
encoding: "utf8",
|
|
22
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
if (result.status === 0) {
|
|
26
|
+
const path = (result.stdout || "").trim();
|
|
27
|
+
if (path) return path;
|
|
28
|
+
}
|
|
29
|
+
} catch {
|
|
30
|
+
// fall through to fallback path
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return FALLBACK_DB_PATH;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const DEFAULT_DB_PATH = resolveDbPath();
|
|
37
|
+
|
|
38
|
+
const openReadonlyDb = () => {
|
|
39
|
+
try {
|
|
40
|
+
return {
|
|
41
|
+
db: new Database(DEFAULT_DB_PATH, { readonly: true, create: false, strict: true }),
|
|
42
|
+
error: null,
|
|
43
|
+
};
|
|
44
|
+
} catch (error) {
|
|
45
|
+
return {
|
|
46
|
+
db: null,
|
|
47
|
+
error: error instanceof Error ? error.message : String(error),
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const escapeLike = (value: string) =>
|
|
53
|
+
value.replace(/\\/g, "\\\\").replace(/%/g, "\\%").replace(/_/g, "\\_");
|
|
54
|
+
|
|
55
|
+
const SEARCH_CONFIG = {
|
|
56
|
+
roles: ["user", "assistant"],
|
|
57
|
+
defaultSessions: 6,
|
|
58
|
+
maxSessions: 12,
|
|
59
|
+
snippetsPerSession: 2,
|
|
60
|
+
snippetLength: 220,
|
|
61
|
+
sinceHours: 24 * 180,
|
|
62
|
+
} as const;
|
|
63
|
+
|
|
64
|
+
const TRANSCRIPT_CONFIG = {
|
|
65
|
+
roles: ["user", "assistant"],
|
|
66
|
+
defaultLimit: 80,
|
|
67
|
+
maxLimit: 120,
|
|
68
|
+
maxCharsPerEntry: 600,
|
|
69
|
+
includeEmpty: false,
|
|
70
|
+
} as const;
|
|
71
|
+
|
|
72
|
+
type SessionMetaRow = {
|
|
73
|
+
id: string;
|
|
74
|
+
title: string | null;
|
|
75
|
+
directory: string | null;
|
|
76
|
+
slug: string | null;
|
|
77
|
+
time_created: number;
|
|
78
|
+
time_updated: number;
|
|
79
|
+
worktree: string | null;
|
|
80
|
+
project_name: string | null;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
type SessionSearchInput = {
|
|
84
|
+
query: string;
|
|
85
|
+
limitSessions?: number;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const toSafeLimitSessions = (value: unknown) => {
|
|
89
|
+
const parsed = Number(value);
|
|
90
|
+
if (!Number.isFinite(parsed)) return SEARCH_CONFIG.defaultSessions;
|
|
91
|
+
const integer = Math.trunc(parsed);
|
|
92
|
+
if (integer < 1) return 1;
|
|
93
|
+
if (integer > SEARCH_CONFIG.maxSessions) return SEARCH_CONFIG.maxSessions;
|
|
94
|
+
return integer;
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
export const runSessionSearch = (input: SessionSearchInput) => {
|
|
98
|
+
const term = String(input.query || "").trim();
|
|
99
|
+
|
|
100
|
+
if (!term) {
|
|
101
|
+
return {
|
|
102
|
+
query: term,
|
|
103
|
+
sessions: [],
|
|
104
|
+
stats: {
|
|
105
|
+
totalSessions: 0,
|
|
106
|
+
totalMatches: 0,
|
|
107
|
+
},
|
|
108
|
+
error: {
|
|
109
|
+
code: "INVALID_QUERY",
|
|
110
|
+
message: "query must contain at least one non-whitespace character",
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const { db, error } = openReadonlyDb();
|
|
116
|
+
|
|
117
|
+
if (!db) {
|
|
118
|
+
return {
|
|
119
|
+
query: term,
|
|
120
|
+
sessions: [],
|
|
121
|
+
stats: {
|
|
122
|
+
totalSessions: 0,
|
|
123
|
+
totalMatches: 0,
|
|
124
|
+
},
|
|
125
|
+
error: {
|
|
126
|
+
code: "DB_OPEN_FAILED",
|
|
127
|
+
message: "Unable to open OpenCode history database",
|
|
128
|
+
details: error,
|
|
129
|
+
dbPath: DEFAULT_DB_PATH,
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
try {
|
|
135
|
+
const textExpr = "json_extract(p.data, '$.text')";
|
|
136
|
+
const words = term.toLowerCase().split(/\s+/).filter(Boolean);
|
|
137
|
+
|
|
138
|
+
const where: string[] = [
|
|
139
|
+
"json_extract(p.data, '$.type') = 'text'",
|
|
140
|
+
`${textExpr} IS NOT NULL`,
|
|
141
|
+
"json_extract(m.data, '$.role') IN ('user','assistant')",
|
|
142
|
+
];
|
|
143
|
+
|
|
144
|
+
const cutoff = Date.now() - SEARCH_CONFIG.sinceHours * 60 * 60 * 1000;
|
|
145
|
+
const params: any[] = [];
|
|
146
|
+
|
|
147
|
+
for (const word of words) {
|
|
148
|
+
where.push("lower(json_extract(p.data, '$.text')) LIKE ? ESCAPE '\\'");
|
|
149
|
+
params.push(`%${escapeLike(word)}%`);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
where.push("p.time_created >= ?");
|
|
153
|
+
params.push(cutoff);
|
|
154
|
+
|
|
155
|
+
const sessionSql = `
|
|
156
|
+
SELECT
|
|
157
|
+
s.id AS session_id,
|
|
158
|
+
s.title AS title,
|
|
159
|
+
s.directory AS directory,
|
|
160
|
+
COUNT(*) AS match_count,
|
|
161
|
+
MAX(p.time_created) AS last_match_ms
|
|
162
|
+
FROM part p
|
|
163
|
+
JOIN message m ON m.id = p.message_id
|
|
164
|
+
JOIN session s ON s.id = p.session_id
|
|
165
|
+
WHERE ${where.join(" AND ")}
|
|
166
|
+
GROUP BY s.id
|
|
167
|
+
ORDER BY COUNT(*) DESC, MAX(p.time_created) DESC
|
|
168
|
+
LIMIT ?
|
|
169
|
+
`;
|
|
170
|
+
|
|
171
|
+
const limitSessions = toSafeLimitSessions(input.limitSessions);
|
|
172
|
+
const sessionRows = db.query(sessionSql).all(...params, limitSessions);
|
|
173
|
+
const totalMatches = sessionRows.reduce((sum: number, row: any) => sum + Number(row.match_count || 0), 0);
|
|
174
|
+
|
|
175
|
+
const snippetSql = `
|
|
176
|
+
SELECT
|
|
177
|
+
p.session_id AS session_id,
|
|
178
|
+
p.time_created AS time_created,
|
|
179
|
+
json_extract(m.data, '$.role') AS role,
|
|
180
|
+
substr(${textExpr}, 1, ?) AS snippet
|
|
181
|
+
FROM part p
|
|
182
|
+
JOIN message m ON m.id = p.message_id
|
|
183
|
+
JOIN session s ON s.id = p.session_id
|
|
184
|
+
WHERE ${where.join(" AND ")}
|
|
185
|
+
AND p.session_id = ?
|
|
186
|
+
ORDER BY p.time_created DESC
|
|
187
|
+
LIMIT ?
|
|
188
|
+
`;
|
|
189
|
+
|
|
190
|
+
const baseSnippetParams = [...params];
|
|
191
|
+
const sessions = sessionRows.map((row: any) => {
|
|
192
|
+
const snippetRows = db
|
|
193
|
+
.query(snippetSql)
|
|
194
|
+
.all(SEARCH_CONFIG.snippetLength, ...baseSnippetParams, row.session_id, SEARCH_CONFIG.snippetsPerSession)
|
|
195
|
+
.map((snippet: any) => ({
|
|
196
|
+
time: new Date(Number(snippet.time_created)).toISOString(),
|
|
197
|
+
role: snippet.role,
|
|
198
|
+
text: snippet.snippet,
|
|
199
|
+
}));
|
|
200
|
+
|
|
201
|
+
return {
|
|
202
|
+
sessionId: row.session_id,
|
|
203
|
+
title: row.title,
|
|
204
|
+
directory: row.directory,
|
|
205
|
+
matchCount: Number(row.match_count || 0),
|
|
206
|
+
lastMatch: new Date(Number(row.last_match_ms)).toISOString(),
|
|
207
|
+
snippets: snippetRows,
|
|
208
|
+
};
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
const suggestedTranscriptCalls = sessions.slice(0, 3).map((session: any) => ({
|
|
212
|
+
tool: "session-transcript",
|
|
213
|
+
args: {
|
|
214
|
+
sessionId: session.sessionId,
|
|
215
|
+
limit: 60,
|
|
216
|
+
order: "asc",
|
|
217
|
+
},
|
|
218
|
+
}));
|
|
219
|
+
|
|
220
|
+
return {
|
|
221
|
+
query: term,
|
|
222
|
+
filters: {
|
|
223
|
+
roles: [...SEARCH_CONFIG.roles],
|
|
224
|
+
sinceHours: SEARCH_CONFIG.sinceHours,
|
|
225
|
+
snippetsPerSession: SEARCH_CONFIG.snippetsPerSession,
|
|
226
|
+
snippetLength: SEARCH_CONFIG.snippetLength,
|
|
227
|
+
},
|
|
228
|
+
stats: {
|
|
229
|
+
totalSessions: sessions.length,
|
|
230
|
+
totalMatches,
|
|
231
|
+
},
|
|
232
|
+
sessions,
|
|
233
|
+
nextStep: {
|
|
234
|
+
message:
|
|
235
|
+
"If you need full context, always ask the user to pick a session with the question tool (first option is recommended), then call session-transcript for the selected sessionId.",
|
|
236
|
+
suggestedCalls: suggestedTranscriptCalls,
|
|
237
|
+
suggestedQuestionCall: {
|
|
238
|
+
tool: "question",
|
|
239
|
+
args: {
|
|
240
|
+
questions: [
|
|
241
|
+
{
|
|
242
|
+
header: "Pick session",
|
|
243
|
+
question: "Which session should I open for full transcript context?",
|
|
244
|
+
options: sessions.slice(0, 8).map((session: any, index: number) => ({
|
|
245
|
+
label: `${session.sessionId.slice(0, 24)}${index === 0 ? "*" : ""}`,
|
|
246
|
+
description:
|
|
247
|
+
session.title || session.directory || `matchCount=${session.matchCount}`,
|
|
248
|
+
})),
|
|
249
|
+
multiple: false,
|
|
250
|
+
},
|
|
251
|
+
],
|
|
252
|
+
},
|
|
253
|
+
},
|
|
254
|
+
},
|
|
255
|
+
};
|
|
256
|
+
} finally {
|
|
257
|
+
db.close();
|
|
258
|
+
}
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
export const SessionHistoryPlugin: Plugin = async () => {
|
|
262
|
+
return {
|
|
263
|
+
tool: {
|
|
264
|
+
"session-search": tool({
|
|
265
|
+
description:
|
|
266
|
+
"Read-only search over local opencode chat history. Returns matching sessions and snippets. Use the session-transcript tool to fetch the full conversation context.",
|
|
267
|
+
args: {
|
|
268
|
+
query: tool.schema
|
|
269
|
+
.string()
|
|
270
|
+
.trim()
|
|
271
|
+
.min(1)
|
|
272
|
+
.max(200)
|
|
273
|
+
.describe("Text to search for in chat history."),
|
|
274
|
+
limitSessions: tool.schema
|
|
275
|
+
.number()
|
|
276
|
+
.int()
|
|
277
|
+
.min(1)
|
|
278
|
+
.max(SEARCH_CONFIG.maxSessions)
|
|
279
|
+
.default(SEARCH_CONFIG.defaultSessions)
|
|
280
|
+
.describe("Maximum number of matching sessions to return."),
|
|
281
|
+
},
|
|
282
|
+
async execute(args: any) {
|
|
283
|
+
return JSON.stringify(runSessionSearch(args));
|
|
284
|
+
},
|
|
285
|
+
}),
|
|
286
|
+
|
|
287
|
+
"session-transcript": tool({
|
|
288
|
+
description:
|
|
289
|
+
"Read-only transcript reconstruction for a specific opencode session. Use after session-search to fetch full conversational context.",
|
|
290
|
+
args: {
|
|
291
|
+
sessionId: tool.schema
|
|
292
|
+
.string()
|
|
293
|
+
.min(5)
|
|
294
|
+
.describe("Session ID to reconstruct transcript from (for example, ses_xxx)."),
|
|
295
|
+
limit: tool.schema
|
|
296
|
+
.number()
|
|
297
|
+
.int()
|
|
298
|
+
.min(1)
|
|
299
|
+
.max(TRANSCRIPT_CONFIG.maxLimit)
|
|
300
|
+
.default(TRANSCRIPT_CONFIG.defaultLimit)
|
|
301
|
+
.describe("Maximum transcript entries returned."),
|
|
302
|
+
order: tool.schema
|
|
303
|
+
.enum(["asc", "desc"])
|
|
304
|
+
.default("asc")
|
|
305
|
+
.describe("Chronological or reverse-chronological ordering."),
|
|
306
|
+
},
|
|
307
|
+
async execute(args: any) {
|
|
308
|
+
const { db, error } = openReadonlyDb();
|
|
309
|
+
|
|
310
|
+
if (!db) {
|
|
311
|
+
return JSON.stringify({
|
|
312
|
+
sessionId: args.sessionId,
|
|
313
|
+
found: false,
|
|
314
|
+
error: {
|
|
315
|
+
code: "DB_OPEN_FAILED",
|
|
316
|
+
message: "Unable to open OpenCode history database",
|
|
317
|
+
details: error,
|
|
318
|
+
dbPath: DEFAULT_DB_PATH,
|
|
319
|
+
},
|
|
320
|
+
entries: [],
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
try {
|
|
325
|
+
const sessionMeta = db
|
|
326
|
+
.query(
|
|
327
|
+
`
|
|
328
|
+
SELECT
|
|
329
|
+
s.id,
|
|
330
|
+
s.title,
|
|
331
|
+
s.directory,
|
|
332
|
+
s.slug,
|
|
333
|
+
s.time_created,
|
|
334
|
+
s.time_updated,
|
|
335
|
+
p.worktree,
|
|
336
|
+
p.name AS project_name
|
|
337
|
+
FROM session s
|
|
338
|
+
LEFT JOIN project p ON p.id = s.project_id
|
|
339
|
+
WHERE s.id = ?
|
|
340
|
+
LIMIT 1
|
|
341
|
+
`,
|
|
342
|
+
)
|
|
343
|
+
.get(args.sessionId) as SessionMetaRow | null;
|
|
344
|
+
|
|
345
|
+
if (!sessionMeta) {
|
|
346
|
+
return JSON.stringify({
|
|
347
|
+
sessionId: args.sessionId,
|
|
348
|
+
found: false,
|
|
349
|
+
error: "Session not found",
|
|
350
|
+
entries: [],
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const where: string[] = [
|
|
355
|
+
"p.session_id = ?",
|
|
356
|
+
"json_extract(p.data, '$.type') = 'text'",
|
|
357
|
+
"json_extract(m.data, '$.role') IN ('user','assistant')",
|
|
358
|
+
];
|
|
359
|
+
const params: any[] = [args.sessionId];
|
|
360
|
+
|
|
361
|
+
if (!TRANSCRIPT_CONFIG.includeEmpty) {
|
|
362
|
+
where.push("json_extract(p.data, '$.text') IS NOT NULL");
|
|
363
|
+
where.push("length(trim(json_extract(p.data, '$.text'))) > 0");
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
const orderSql = args.order === "desc" ? "DESC" : "ASC";
|
|
367
|
+
const sql = `
|
|
368
|
+
SELECT
|
|
369
|
+
p.id AS part_id,
|
|
370
|
+
p.message_id AS message_id,
|
|
371
|
+
p.time_created AS time_created,
|
|
372
|
+
json_extract(m.data, '$.role') AS role,
|
|
373
|
+
substr(json_extract(p.data, '$.text'), 1, ?) AS text
|
|
374
|
+
FROM part p
|
|
375
|
+
JOIN message m ON m.id = p.message_id
|
|
376
|
+
WHERE ${where.join(" AND ")}
|
|
377
|
+
ORDER BY p.time_created ${orderSql}
|
|
378
|
+
LIMIT ?
|
|
379
|
+
`;
|
|
380
|
+
|
|
381
|
+
const rows = db.query(sql).all(TRANSCRIPT_CONFIG.maxCharsPerEntry, ...params, args.limit);
|
|
382
|
+
|
|
383
|
+
const entries = rows.map((row: any) => ({
|
|
384
|
+
partId: row.part_id,
|
|
385
|
+
messageId: row.message_id,
|
|
386
|
+
timeMs: Number(row.time_created),
|
|
387
|
+
time: new Date(Number(row.time_created)).toISOString(),
|
|
388
|
+
role: row.role,
|
|
389
|
+
text: row.text,
|
|
390
|
+
}));
|
|
391
|
+
|
|
392
|
+
return JSON.stringify({
|
|
393
|
+
sessionId: sessionMeta.id,
|
|
394
|
+
found: true,
|
|
395
|
+
session: {
|
|
396
|
+
title: sessionMeta.title,
|
|
397
|
+
slug: sessionMeta.slug,
|
|
398
|
+
directory: sessionMeta.directory,
|
|
399
|
+
projectName: sessionMeta.project_name,
|
|
400
|
+
projectWorktree: sessionMeta.worktree,
|
|
401
|
+
timeCreated: new Date(Number(sessionMeta.time_created)).toISOString(),
|
|
402
|
+
timeUpdated: new Date(Number(sessionMeta.time_updated)).toISOString(),
|
|
403
|
+
},
|
|
404
|
+
filters: {
|
|
405
|
+
roles: [...TRANSCRIPT_CONFIG.roles],
|
|
406
|
+
order: args.order,
|
|
407
|
+
includeEmpty: TRANSCRIPT_CONFIG.includeEmpty,
|
|
408
|
+
maxCharsPerEntry: TRANSCRIPT_CONFIG.maxCharsPerEntry,
|
|
409
|
+
},
|
|
410
|
+
stats: {
|
|
411
|
+
entriesReturned: entries.length,
|
|
412
|
+
limit: args.limit,
|
|
413
|
+
},
|
|
414
|
+
entries,
|
|
415
|
+
});
|
|
416
|
+
} finally {
|
|
417
|
+
db.close();
|
|
418
|
+
}
|
|
419
|
+
},
|
|
420
|
+
}),
|
|
421
|
+
},
|
|
422
|
+
};
|
|
423
|
+
};
|
|
424
|
+
|
|
425
|
+
export default SessionHistoryPlugin;
|
package/package.json
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "opencode-session-search",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "OpenCode plugin for searching and retrieving chat history",
|
|
5
|
+
"main": "./dist/index.js",
|
|
6
|
+
"exports": "./dist/index.js",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"packageManager": "bun@1.3.9",
|
|
9
|
+
"files": [
|
|
10
|
+
"dist",
|
|
11
|
+
"index.ts",
|
|
12
|
+
"README.md",
|
|
13
|
+
"CHANGELOG.md",
|
|
14
|
+
"LICENSE",
|
|
15
|
+
"scripts/install-local.sh"
|
|
16
|
+
],
|
|
17
|
+
"scripts": {
|
|
18
|
+
"build": "bun build ./index.ts --outfile ./dist/index.js --target bun --format esm",
|
|
19
|
+
"typecheck": "bunx tsc --noEmit",
|
|
20
|
+
"install:local": "bash scripts/install-local.sh",
|
|
21
|
+
"test": "bun run typecheck",
|
|
22
|
+
"prepublishOnly": "bun run build && bun run typecheck"
|
|
23
|
+
},
|
|
24
|
+
"keywords": [
|
|
25
|
+
"opencode",
|
|
26
|
+
"plugin",
|
|
27
|
+
"history",
|
|
28
|
+
"search",
|
|
29
|
+
"chat"
|
|
30
|
+
],
|
|
31
|
+
"author": "Arthur Tyukayev",
|
|
32
|
+
"license": "MIT",
|
|
33
|
+
"repository": "github:arthurtyukayev/opencode-session-history",
|
|
34
|
+
"bugs": "https://github.com/arthurtyukayev/opencode-session-history/issues",
|
|
35
|
+
"homepage": "https://github.com/arthurtyukayev/opencode-session-history#readme",
|
|
36
|
+
"engines": {
|
|
37
|
+
"bun": ">=1.0.0"
|
|
38
|
+
},
|
|
39
|
+
"peerDependencies": {
|
|
40
|
+
"@opencode-ai/plugin": "^1.0.0",
|
|
41
|
+
"bun": "^1.0.0"
|
|
42
|
+
},
|
|
43
|
+
"devDependencies": {
|
|
44
|
+
"@types/bun": "latest",
|
|
45
|
+
"typescript": "^5.0.0"
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
|
|
3
|
+
set -euo pipefail
|
|
4
|
+
|
|
5
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
6
|
+
PLUGIN_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
|
7
|
+
|
|
8
|
+
TARGET_DIR="${1:-$HOME/.config/opencode/plugins/history}"
|
|
9
|
+
|
|
10
|
+
mkdir -p "$TARGET_DIR"
|
|
11
|
+
|
|
12
|
+
cp "$PLUGIN_ROOT/index.ts" "$TARGET_DIR/index.ts"
|
|
13
|
+
|
|
14
|
+
echo "Installed index.ts to: $TARGET_DIR"
|
|
15
|
+
echo "Restart OpenCode to load the updated plugin."
|