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.
@@ -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 { join } from "node:path"
2
- import { homedir } from "node:os"
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: OPENCODE_CONFIG_DIR,
26
-
20
+ CONFIG_DIR: join(homedir(), ".config", "opencode"),
21
+
27
22
  /** Snippets directory */
28
- SNIPPETS_DIR: join(OPENCODE_CONFIG_DIR, "snippet"),
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;