opencode-snippets 1.1.2 → 1.3.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/README.md +69 -79
- package/index.ts +64 -17
- package/package.json +12 -5
- package/src/commands.ts +336 -0
- package/src/constants.ts +9 -14
- package/src/expander.test.ts +466 -0
- package/src/expander.ts +47 -37
- package/src/loader.test.ts +261 -0
- package/src/loader.ts +234 -56
- package/src/logger.test.ts +136 -0
- package/src/logger.ts +95 -95
- package/src/notification.ts +29 -0
- package/src/shell.ts +30 -24
- package/src/types.ts +19 -7
package/src/commands.ts
ADDED
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
import { PATHS } from "./constants.js";
|
|
2
|
+
import { createSnippet, deleteSnippet, listSnippets, reloadSnippets } from "./loader.js";
|
|
3
|
+
import { logger } from "./logger.js";
|
|
4
|
+
import { sendIgnoredMessage } from "./notification.js";
|
|
5
|
+
import type { SnippetRegistry } from "./types.js";
|
|
6
|
+
|
|
7
|
+
/** Marker error to indicate command was handled */
|
|
8
|
+
const COMMAND_HANDLED_MARKER = "__SNIPPETS_COMMAND_HANDLED__";
|
|
9
|
+
|
|
10
|
+
interface CommandContext {
|
|
11
|
+
client: any;
|
|
12
|
+
sessionId: string;
|
|
13
|
+
args: string[];
|
|
14
|
+
rawArguments: string;
|
|
15
|
+
snippets: SnippetRegistry;
|
|
16
|
+
projectDir?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Creates the command execute handler for the snippets command
|
|
21
|
+
*/
|
|
22
|
+
export function createCommandExecuteHandler(
|
|
23
|
+
client: any,
|
|
24
|
+
snippets: SnippetRegistry,
|
|
25
|
+
projectDir?: string,
|
|
26
|
+
) {
|
|
27
|
+
return async (input: { command: string; sessionID: string; arguments: string }) => {
|
|
28
|
+
if (input.command !== "snippet") return;
|
|
29
|
+
|
|
30
|
+
const args = input.arguments.split(/\s+/).filter(Boolean);
|
|
31
|
+
const subcommand = args[0]?.toLowerCase() || "help";
|
|
32
|
+
|
|
33
|
+
const ctx: CommandContext = {
|
|
34
|
+
client,
|
|
35
|
+
sessionId: input.sessionID,
|
|
36
|
+
args: args.slice(1),
|
|
37
|
+
rawArguments: input.arguments,
|
|
38
|
+
snippets,
|
|
39
|
+
projectDir,
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
try {
|
|
43
|
+
switch (subcommand) {
|
|
44
|
+
case "add":
|
|
45
|
+
case "create":
|
|
46
|
+
case "new":
|
|
47
|
+
await handleAddCommand(ctx);
|
|
48
|
+
break;
|
|
49
|
+
case "delete":
|
|
50
|
+
case "remove":
|
|
51
|
+
case "rm":
|
|
52
|
+
await handleDeleteCommand(ctx);
|
|
53
|
+
break;
|
|
54
|
+
case "list":
|
|
55
|
+
case "ls":
|
|
56
|
+
await handleListCommand(ctx);
|
|
57
|
+
break;
|
|
58
|
+
case "help":
|
|
59
|
+
default:
|
|
60
|
+
await handleHelpCommand(ctx);
|
|
61
|
+
break;
|
|
62
|
+
}
|
|
63
|
+
} catch (error) {
|
|
64
|
+
if (error instanceof Error && error.message === COMMAND_HANDLED_MARKER) {
|
|
65
|
+
throw error;
|
|
66
|
+
}
|
|
67
|
+
logger.error("Command execution failed", {
|
|
68
|
+
subcommand,
|
|
69
|
+
error: error instanceof Error ? error.message : String(error),
|
|
70
|
+
});
|
|
71
|
+
await sendIgnoredMessage(
|
|
72
|
+
ctx.client,
|
|
73
|
+
ctx.sessionId,
|
|
74
|
+
`Error: ${error instanceof Error ? error.message : String(error)}`,
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Signal that command was handled
|
|
79
|
+
throw new Error(COMMAND_HANDLED_MARKER);
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Handle /snippet add <name> ["content"] [--project] [--alias=<alias>] [--desc=<description>]
|
|
85
|
+
*/
|
|
86
|
+
async function handleAddCommand(ctx: CommandContext): Promise<void> {
|
|
87
|
+
const { client, sessionId, args, rawArguments, snippets, projectDir } = ctx;
|
|
88
|
+
|
|
89
|
+
if (args.length === 0) {
|
|
90
|
+
await sendIgnoredMessage(
|
|
91
|
+
client,
|
|
92
|
+
sessionId,
|
|
93
|
+
'Usage: /snippet add <name> ["content"] [options]\n\n' +
|
|
94
|
+
"Adds a new snippet. Defaults to global directory.\n\n" +
|
|
95
|
+
"Examples:\n" +
|
|
96
|
+
" /snippet add greeting\n" +
|
|
97
|
+
' /snippet add bye "see you later"\n' +
|
|
98
|
+
' /snippet add hi "hello there" --aliases hello,hey\n' +
|
|
99
|
+
' /snippet add fix "fix imports" --project\n\n' +
|
|
100
|
+
"Options:\n" +
|
|
101
|
+
" --project Add to project directory (.opencode/snippet/)\n" +
|
|
102
|
+
" --aliases X,Y,Z Add aliases (comma-separated)\n" +
|
|
103
|
+
' --desc "..." Add a description',
|
|
104
|
+
);
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const name = args[0];
|
|
109
|
+
|
|
110
|
+
// Extract quoted content from raw arguments
|
|
111
|
+
// Match content between quotes after the subcommand and name
|
|
112
|
+
const quotedMatch = rawArguments.match(/(?:add|create|new)\s+\S+\s+"([^"]+)"/i);
|
|
113
|
+
const content = quotedMatch ? quotedMatch[1] : "";
|
|
114
|
+
|
|
115
|
+
const isProject = args.includes("--project");
|
|
116
|
+
const aliases: string[] = [];
|
|
117
|
+
let description: string | undefined;
|
|
118
|
+
|
|
119
|
+
// Parse arguments with --param value syntax
|
|
120
|
+
for (let i = 1; i < args.length; i++) {
|
|
121
|
+
const arg = args[i];
|
|
122
|
+
|
|
123
|
+
// Handle --aliases
|
|
124
|
+
if (arg === "--aliases") {
|
|
125
|
+
const nextArg = args[i + 1];
|
|
126
|
+
if (nextArg && !nextArg.startsWith("--")) {
|
|
127
|
+
const values = nextArg
|
|
128
|
+
.split(",")
|
|
129
|
+
.map((s) => s.trim())
|
|
130
|
+
.filter(Boolean);
|
|
131
|
+
aliases.push(...values);
|
|
132
|
+
i++; // Skip the value arg
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
// Handle --desc or --description
|
|
136
|
+
else if (arg === "--desc" || arg === "--description") {
|
|
137
|
+
const nextArg = args[i + 1];
|
|
138
|
+
if (nextArg && !nextArg.startsWith("--")) {
|
|
139
|
+
description = nextArg;
|
|
140
|
+
i++; // Skip the value arg
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Default to global, --project puts it in project directory
|
|
146
|
+
const targetDir = isProject ? projectDir : undefined;
|
|
147
|
+
const location = isProject && projectDir ? "project" : "global";
|
|
148
|
+
|
|
149
|
+
try {
|
|
150
|
+
const filePath = await createSnippet(name, content, { aliases, description }, targetDir);
|
|
151
|
+
|
|
152
|
+
// Reload snippets
|
|
153
|
+
await reloadSnippets(snippets, projectDir);
|
|
154
|
+
|
|
155
|
+
let message = `Added ${location} snippet: ${name}\nFile: ${filePath}`;
|
|
156
|
+
if (content) {
|
|
157
|
+
message += `\nContent: "${truncate(content, 50)}"`;
|
|
158
|
+
} else {
|
|
159
|
+
message += "\n\nEdit the file to add your snippet content.";
|
|
160
|
+
}
|
|
161
|
+
if (aliases.length > 0) {
|
|
162
|
+
message += `\nAliases: ${aliases.join(", ")}`;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
await sendIgnoredMessage(client, sessionId, message);
|
|
166
|
+
} catch (error) {
|
|
167
|
+
await sendIgnoredMessage(
|
|
168
|
+
client,
|
|
169
|
+
sessionId,
|
|
170
|
+
`Failed to add snippet: ${error instanceof Error ? error.message : String(error)}`,
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Handle /snippet delete <name>
|
|
177
|
+
*/
|
|
178
|
+
async function handleDeleteCommand(ctx: CommandContext): Promise<void> {
|
|
179
|
+
const { client, sessionId, args, snippets, projectDir } = ctx;
|
|
180
|
+
|
|
181
|
+
if (args.length === 0) {
|
|
182
|
+
await sendIgnoredMessage(
|
|
183
|
+
client,
|
|
184
|
+
sessionId,
|
|
185
|
+
"Usage: /snippet delete <name>\n\nDeletes a snippet by name. " +
|
|
186
|
+
"Project snippets are checked first, then global.",
|
|
187
|
+
);
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const name = args[0];
|
|
192
|
+
|
|
193
|
+
const deletedPath = await deleteSnippet(name, projectDir);
|
|
194
|
+
|
|
195
|
+
if (deletedPath) {
|
|
196
|
+
// Reload snippets
|
|
197
|
+
await reloadSnippets(snippets, projectDir);
|
|
198
|
+
|
|
199
|
+
await sendIgnoredMessage(
|
|
200
|
+
client,
|
|
201
|
+
sessionId,
|
|
202
|
+
`Deleted snippet: #${name}\nRemoved: ${deletedPath}`,
|
|
203
|
+
);
|
|
204
|
+
} else {
|
|
205
|
+
await sendIgnoredMessage(
|
|
206
|
+
client,
|
|
207
|
+
sessionId,
|
|
208
|
+
`Snippet not found: #${name}\n\nUse /snippet list to see available snippets.`,
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Maximum characters for snippet content preview */
|
|
214
|
+
const MAX_CONTENT_PREVIEW_LENGTH = 200;
|
|
215
|
+
/** Maximum characters for aliases display */
|
|
216
|
+
const MAX_ALIASES_LENGTH = 50;
|
|
217
|
+
/** Divider line */
|
|
218
|
+
const DIVIDER = "────────────────────────────────────────────────";
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Truncate text with ellipsis if it exceeds maxLength
|
|
222
|
+
*/
|
|
223
|
+
function truncate(text: string, maxLength: number): string {
|
|
224
|
+
if (text.length <= maxLength) return text;
|
|
225
|
+
return text.slice(0, maxLength - 3) + "...";
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Format aliases for display, truncating if needed
|
|
230
|
+
*/
|
|
231
|
+
function formatAliases(aliases: string[]): string {
|
|
232
|
+
if (aliases.length === 0) return "";
|
|
233
|
+
|
|
234
|
+
const joined = aliases.join(", ");
|
|
235
|
+
if (joined.length <= MAX_ALIASES_LENGTH) {
|
|
236
|
+
return ` (aliases: ${joined})`;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Truncate and show count
|
|
240
|
+
const truncated = truncate(joined, MAX_ALIASES_LENGTH - 10);
|
|
241
|
+
return ` (aliases: ${truncated} +${aliases.length})`;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Format a single snippet for display
|
|
246
|
+
*/
|
|
247
|
+
function formatSnippetEntry(s: { name: string; content: string; aliases: string[] }): string {
|
|
248
|
+
const header = `${s.name}${formatAliases(s.aliases)}`;
|
|
249
|
+
const content = truncate(s.content.trim(), MAX_CONTENT_PREVIEW_LENGTH);
|
|
250
|
+
|
|
251
|
+
return `${header}\n${DIVIDER}\n${content || "(empty)"}`;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Handle /snippet list
|
|
256
|
+
*/
|
|
257
|
+
async function handleListCommand(ctx: CommandContext): Promise<void> {
|
|
258
|
+
const { client, sessionId, snippets, projectDir } = ctx;
|
|
259
|
+
|
|
260
|
+
const snippetList = listSnippets(snippets);
|
|
261
|
+
|
|
262
|
+
if (snippetList.length === 0) {
|
|
263
|
+
await sendIgnoredMessage(
|
|
264
|
+
client,
|
|
265
|
+
sessionId,
|
|
266
|
+
"No snippets found.\n\n" +
|
|
267
|
+
`Global snippets: ${PATHS.SNIPPETS_DIR}\n` +
|
|
268
|
+
(projectDir
|
|
269
|
+
? `Project snippets: ${projectDir}/.opencode/snippet/`
|
|
270
|
+
: "No project directory detected.") +
|
|
271
|
+
"\n\nUse /snippet add <name> to add a new snippet.",
|
|
272
|
+
);
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const lines: string[] = [];
|
|
277
|
+
|
|
278
|
+
// Group by source
|
|
279
|
+
const globalSnippets = snippetList.filter((s) => s.source === "global");
|
|
280
|
+
const projectSnippets = snippetList.filter((s) => s.source === "project");
|
|
281
|
+
|
|
282
|
+
if (globalSnippets.length > 0) {
|
|
283
|
+
lines.push(`── Global (${PATHS.SNIPPETS_DIR}) ──`, "");
|
|
284
|
+
for (const s of globalSnippets) {
|
|
285
|
+
lines.push(formatSnippetEntry(s), "");
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
if (projectSnippets.length > 0) {
|
|
290
|
+
lines.push(`── Project (${projectDir}/.opencode/snippet/) ──`, "");
|
|
291
|
+
for (const s of projectSnippets) {
|
|
292
|
+
lines.push(formatSnippetEntry(s), "");
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
await sendIgnoredMessage(client, sessionId, lines.join("\n").trimEnd());
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Handle /snippet help
|
|
301
|
+
*/
|
|
302
|
+
async function handleHelpCommand(ctx: CommandContext): Promise<void> {
|
|
303
|
+
const { client, sessionId } = ctx;
|
|
304
|
+
|
|
305
|
+
const helpText = `Snippets Command - Manage text snippets
|
|
306
|
+
|
|
307
|
+
Usage: /snippet <command> [options]
|
|
308
|
+
|
|
309
|
+
Commands:
|
|
310
|
+
add <name> ["content"] [options]
|
|
311
|
+
--project Add to project directory (default: global)
|
|
312
|
+
--aliases X,Y,Z Add aliases (comma-separated)
|
|
313
|
+
--desc "..." Add a description
|
|
314
|
+
|
|
315
|
+
delete <name> Delete a snippet
|
|
316
|
+
list List all available snippets
|
|
317
|
+
help Show this help message
|
|
318
|
+
|
|
319
|
+
Snippet Locations:
|
|
320
|
+
Global: ~/.config/opencode/snippet/
|
|
321
|
+
Project: <project>/.opencode/snippet/
|
|
322
|
+
|
|
323
|
+
Usage in messages:
|
|
324
|
+
Type #snippet-name to expand a snippet inline.
|
|
325
|
+
Snippets can reference other snippets recursively.
|
|
326
|
+
|
|
327
|
+
Examples:
|
|
328
|
+
/snippet add greeting
|
|
329
|
+
/snippet add bye "see you later"
|
|
330
|
+
/snippet add hi "hello there" --aliases hello,hey
|
|
331
|
+
/snippet add fix "fix imports" --project
|
|
332
|
+
/snippet delete old-snippet
|
|
333
|
+
/snippet list`;
|
|
334
|
+
|
|
335
|
+
await sendIgnoredMessage(client, sessionId, helpText);
|
|
336
|
+
}
|
package/src/constants.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { join } from "node:path";
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Regular expression patterns used throughout the plugin
|
|
@@ -7,26 +7,21 @@ import { homedir } from "node:os"
|
|
|
7
7
|
export const PATTERNS = {
|
|
8
8
|
/** Matches hashtags like #snippet-name */
|
|
9
9
|
HASHTAG: /#([a-z0-9\-_]+)/gi,
|
|
10
|
-
|
|
10
|
+
|
|
11
11
|
/** Matches shell commands like !`command` */
|
|
12
12
|
SHELL_COMMAND: /!`([^`]+)`/g,
|
|
13
|
-
} as const
|
|
14
|
-
|
|
15
|
-
/**
|
|
16
|
-
* OpenCode configuration directory
|
|
17
|
-
*/
|
|
18
|
-
export const OPENCODE_CONFIG_DIR = join(homedir(), ".config", "opencode")
|
|
13
|
+
} as const;
|
|
19
14
|
|
|
20
15
|
/**
|
|
21
16
|
* File system paths
|
|
22
17
|
*/
|
|
23
18
|
export const PATHS = {
|
|
24
19
|
/** OpenCode configuration directory */
|
|
25
|
-
CONFIG_DIR:
|
|
26
|
-
|
|
20
|
+
CONFIG_DIR: join(homedir(), ".config", "opencode"),
|
|
21
|
+
|
|
27
22
|
/** Snippets directory */
|
|
28
|
-
SNIPPETS_DIR: join(
|
|
29
|
-
} as const
|
|
23
|
+
SNIPPETS_DIR: join(join(homedir(), ".config", "opencode"), "snippet"),
|
|
24
|
+
} as const;
|
|
30
25
|
|
|
31
26
|
/**
|
|
32
27
|
* Plugin configuration
|
|
@@ -34,4 +29,4 @@ export const PATHS = {
|
|
|
34
29
|
export const CONFIG = {
|
|
35
30
|
/** File extension for snippet files */
|
|
36
31
|
SNIPPET_EXTENSION: ".md",
|
|
37
|
-
} as const
|
|
32
|
+
} as const;
|