opencode-snippets 1.2.0 β†’ 1.4.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 CHANGED
@@ -2,11 +2,16 @@
2
2
 
3
3
  ✨ **Instant inline text expansion for OpenCode** - Type `#snippet` anywhere in your message and watch it transform.
4
4
 
5
+ > [!TIP]
6
+ > **Share Your Snippets!**
7
+ > Got a snippet that saves you time? Share yours or steal ideas from the community!
8
+ > Browse and contribute in [GitHub Discussions](https://github.com/JosXa/opencode-snippets/discussions/categories/snippets).
9
+
5
10
  ## Why Snippets?
6
11
 
7
12
  As developers, we DRY (Don't Repeat Yourself) our code. We extract functions, create libraries, compose modules. Why should our prompts be any different?
8
13
 
9
- Stop copy-pasting the same instructions into every message. Snippets bring software engineering principles to prompt engineering:
14
+ Stop copy-pasting (or worse, *typing* 🀒) the same instructions into every message. Snippets bring software engineering principles to prompt engineering:
10
15
 
11
16
  - πŸ”„ **DRY** - Write once, reuse everywhere
12
17
  - 🧩 **Composability** - Build complex prompts from simple pieces
@@ -29,7 +34,7 @@ Snippets work like `@file` mentions - natural, inline, composable.
29
34
 
30
35
  Snippets compose with each other and with slash commands. Reference `#snippets` anywhere - in your messages, in slash commands, even inside other snippets:
31
36
 
32
- **Example: Extending snippets with logic**
37
+ **Example: Extending commands with snippets**
33
38
 
34
39
  `~/.config/opencode/command/commit-and-push.md`:
35
40
  ```markdown
@@ -37,6 +42,7 @@ Snippets compose with each other and with slash commands. Reference `#snippets`
37
42
  description: Create a git commit and push to remote
38
43
  ---
39
44
  Please create a git commit with the current changes and push to the remote repository.
45
+ #use-conventional-commits
40
46
 
41
47
  Here is the current git status:
42
48
  !`git status`
@@ -44,10 +50,11 @@ Here is the current git status:
44
50
  Here are the staged changes:
45
51
  !`git diff --cached`
46
52
 
47
- #conventional-commits
48
53
  #project-context
49
54
  ```
50
55
 
56
+ You could also make "current git status and staged changes" a shell-enabled snippet of its own.
57
+
51
58
  **Example: Snippets composing snippets**
52
59
 
53
60
  `~/.config/opencode/snippet/code-standards.md`:
@@ -101,10 +108,7 @@ Ask clarifying questions if anything is ambiguous.
101
108
 
102
109
  **3. Use it anywhere:**
103
110
 
104
- ```
105
- Refactor this function. Think step by step. Double-check your work before making changes.
106
- Ask clarifying questions if anything is ambiguous.
107
- ```
111
+ https://github.com/user-attachments/assets/d31b69b5-cc7a-4208-9f6e-71c1a278536a
108
112
 
109
113
  ## Where to Store Snippets
110
114
 
@@ -186,6 +190,48 @@ I reference I reference I reference ... (15 times) ... I reference #self
186
190
 
187
191
  This generous limit supports complex snippet hierarchies while preventing infinite loops.
188
192
 
193
+ ### Prepend and Append Blocks
194
+
195
+ For long reference material that would break your writing flow, use `<append>` blocks to place content at the end of your message:
196
+
197
+ ```markdown
198
+ ---
199
+ aliases: jira-mcp
200
+ ---
201
+ Jira MCP server
202
+ <append>
203
+ ## Jira MCP Usage
204
+
205
+ Use these custom field mappings when creating issues:
206
+ - customfield_16570 => Acceptance Criteria
207
+ - customfield_11401 => Team
208
+ </append>
209
+ ```
210
+
211
+ **Input:** `Create a bug ticket in #jira-mcp about the memory leak`
212
+
213
+ **Output:**
214
+ ```
215
+ Create a bug ticket in Jira MCP server about the memory leak
216
+
217
+ ## Jira MCP Usage
218
+
219
+ Use these custom field mappings when creating issues:
220
+ - customfield_16570 => Acceptance Criteria
221
+ - customfield_11401 => Team
222
+ ```
223
+
224
+ Write naturallyβ€”reference what you need mid-sentenceβ€”and the context follows at the bottom.
225
+
226
+ Use `<prepend>` for content that should appear at the top of your message. Multiple blocks of the same type are concatenated in order of appearance.
227
+
228
+ **Block behavior:**
229
+ - Content outside `<prepend>`/`<append>` blocks replaces the hashtag inline
230
+ - If a snippet has only blocks (no inline content), the hashtag is simply removed
231
+ - Blocks from nested snippets are collected and assembled in the final message
232
+ - Unclosed tags are handled leniently (rest of content becomes the block)
233
+ - Nested `<prepend>` inside `<append>` (or vice versa) is an errorβ€”the hashtag is left unchanged
234
+
189
235
  ## Example Snippets
190
236
 
191
237
  ### `~/.config/opencode/snippet/context.md`
@@ -217,12 +263,25 @@ Be extremely concise. No explanations unless asked.
217
263
  | Live shell data | Yes πŸ’» | Yes πŸ’» |
218
264
  | Best for | Triggering actions & workflows ⚑ | Context injection πŸ“ |
219
265
 
220
- **Use both together:**
221
- ```
222
- /commit #conventional-commits #project-context
223
- ```
266
+ > [!TIP]
267
+ > #### My recommendation:
268
+ >
269
+ > Use /slash commands for **triggering actions and workflows** imperatively - anything that needs to happen _right now_: `/commit-and-push`, `/add-worktree`, or `/pull-rebase`.
270
+ > Use #snippets for **all other context engineering**.
271
+ >
272
+ > If you can't decide, get the best of both worlds and just have your command proxy through to the snippet:
273
+ >
274
+ > `~/.config/opencode/command/pull.md`:
275
+ > ```markdown
276
+ > ---
277
+ > description: Proxy through to the snippet at snippet/pull.md
278
+ > ---
279
+ > #pull
280
+ > ```
281
+
282
+ ## Configuration
224
283
 
225
- ## Configuration### Debug Logging
284
+ ### Debug Logging
226
285
 
227
286
  Enable debug logs by setting an environment variable:
228
287
 
@@ -243,7 +302,8 @@ Logs are written to `~/.config/opencode/logs/snippets/daily/`.
243
302
 
244
303
  ## Contributing
245
304
 
246
- Contributions welcome! Please open an issue or PR on GitHub.
305
+ Contributions welcome! Please open an issue or PR on GitHub.
306
+ πŸ‘₯ [Discord Forum](https://discord.com/channels/1391832426048651334/1463378026833379409)
247
307
 
248
308
  ## License
249
309
 
package/index.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { Plugin } from "@opencode-ai/plugin";
2
- import { expandHashtags } from "./src/expander.js";
2
+ import { createCommandExecuteHandler } from "./src/commands.js";
3
+ import { assembleMessage, expandHashtags } from "./src/expander.js";
3
4
  import { loadSnippets } from "./src/loader.js";
4
5
  import { logger } from "./src/logger.js";
5
6
  import { executeShellCommands, type ShellContext } from "./src/shell.js";
@@ -8,6 +9,7 @@ import { executeShellCommands, type ShellContext } from "./src/shell.js";
8
9
  * Snippets Plugin for OpenCode
9
10
  *
10
11
  * Expands hashtag-based shortcuts in user messages into predefined text snippets.
12
+ * Also provides /snippet command for managing snippets.
11
13
  *
12
14
  * @see https://github.com/JosXa/opencode-snippets for full documentation
13
15
  */
@@ -22,7 +24,22 @@ export const SnippetsPlugin: Plugin = async (ctx) => {
22
24
  snippetCount: snippets.size,
23
25
  });
24
26
 
27
+ // Create command handler
28
+ const commandHandler = createCommandExecuteHandler(ctx.client, snippets, ctx.directory);
29
+
25
30
  return {
31
+ // Register /snippet command
32
+ config: async (opencodeConfig) => {
33
+ opencodeConfig.command ??= {};
34
+ opencodeConfig.command.snippet = {
35
+ template: "",
36
+ description: "Manage text snippets (create, delete, list, help)",
37
+ };
38
+ },
39
+
40
+ // Handle /snippet command execution
41
+ "command.execute.before": commandHandler,
42
+
26
43
  "chat.message": async (_input, output) => {
27
44
  // Only process user messages, never assistant messages
28
45
  if (output.message.role !== "user") return;
@@ -36,7 +53,8 @@ export const SnippetsPlugin: Plugin = async (ctx) => {
36
53
  if (part.type === "text" && part.text) {
37
54
  // 1. Expand hashtags recursively with loop detection
38
55
  const expandStart = performance.now();
39
- part.text = expandHashtags(part.text, snippets);
56
+ const expansionResult = expandHashtags(part.text, snippets);
57
+ part.text = assembleMessage(expansionResult);
40
58
  const expandTime = performance.now() - expandStart;
41
59
  expandTimeTotal += expandTime;
42
60
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-snippets",
3
- "version": "1.2.0",
3
+ "version": "1.4.0",
4
4
  "description": "Hashtag-based snippet expansion plugin for OpenCode - instant inline text shortcuts",
5
5
  "main": "index.ts",
6
6
  "type": "module",
@@ -38,13 +38,15 @@
38
38
  "@types/bun": "latest",
39
39
  "@types/node": "^22",
40
40
  "@typescript/native-preview": "^7.0.0-dev.20260120.1",
41
+ "husky": "^9.1.7",
41
42
  "typescript": "^5"
42
43
  },
43
44
  "scripts": {
44
45
  "build": "tsgo --noEmit",
45
46
  "format:check": "biome check .",
46
47
  "format:fix": "biome check --write .",
47
- "ai:check": "bun run format:fix"
48
+ "ai:check": "bun run format:fix",
49
+ "prepare": "husky"
48
50
  },
49
51
  "files": ["index.ts", "src/**/*.ts"]
50
52
  }
@@ -0,0 +1,335 @@
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
+ default:
59
+ await handleHelpCommand(ctx);
60
+ break;
61
+ }
62
+ } catch (error) {
63
+ if (error instanceof Error && error.message === COMMAND_HANDLED_MARKER) {
64
+ throw error;
65
+ }
66
+ logger.error("Command execution failed", {
67
+ subcommand,
68
+ error: error instanceof Error ? error.message : String(error),
69
+ });
70
+ await sendIgnoredMessage(
71
+ ctx.client,
72
+ ctx.sessionId,
73
+ `Error: ${error instanceof Error ? error.message : String(error)}`,
74
+ );
75
+ }
76
+
77
+ // Signal that command was handled
78
+ throw new Error(COMMAND_HANDLED_MARKER);
79
+ };
80
+ }
81
+
82
+ /**
83
+ * Handle /snippet add <name> ["content"] [--project] [--alias=<alias>] [--desc=<description>]
84
+ */
85
+ async function handleAddCommand(ctx: CommandContext): Promise<void> {
86
+ const { client, sessionId, args, rawArguments, snippets, projectDir } = ctx;
87
+
88
+ if (args.length === 0) {
89
+ await sendIgnoredMessage(
90
+ client,
91
+ sessionId,
92
+ 'Usage: /snippet add <name> ["content"] [options]\n\n' +
93
+ "Adds a new snippet. Defaults to global directory.\n\n" +
94
+ "Examples:\n" +
95
+ " /snippet add greeting\n" +
96
+ ' /snippet add bye "see you later"\n' +
97
+ ' /snippet add hi "hello there" --aliases hello,hey\n' +
98
+ ' /snippet add fix "fix imports" --project\n\n' +
99
+ "Options:\n" +
100
+ " --project Add to project directory (.opencode/snippet/)\n" +
101
+ " --aliases X,Y,Z Add aliases (comma-separated)\n" +
102
+ ' --desc "..." Add a description',
103
+ );
104
+ return;
105
+ }
106
+
107
+ const name = args[0];
108
+
109
+ // Extract quoted content from raw arguments
110
+ // Match content between quotes after the subcommand and name
111
+ const quotedMatch = rawArguments.match(/(?:add|create|new)\s+\S+\s+"([^"]+)"/i);
112
+ const content = quotedMatch ? quotedMatch[1] : "";
113
+
114
+ const isProject = args.includes("--project");
115
+ const aliases: string[] = [];
116
+ let description: string | undefined;
117
+
118
+ // Parse arguments with --param value syntax
119
+ for (let i = 1; i < args.length; i++) {
120
+ const arg = args[i];
121
+
122
+ // Handle --aliases
123
+ if (arg === "--aliases") {
124
+ const nextArg = args[i + 1];
125
+ if (nextArg && !nextArg.startsWith("--")) {
126
+ const values = nextArg
127
+ .split(",")
128
+ .map((s) => s.trim())
129
+ .filter(Boolean);
130
+ aliases.push(...values);
131
+ i++; // Skip the value arg
132
+ }
133
+ }
134
+ // Handle --desc or --description
135
+ else if (arg === "--desc" || arg === "--description") {
136
+ const nextArg = args[i + 1];
137
+ if (nextArg && !nextArg.startsWith("--")) {
138
+ description = nextArg;
139
+ i++; // Skip the value arg
140
+ }
141
+ }
142
+ }
143
+
144
+ // Default to global, --project puts it in project directory
145
+ const targetDir = isProject ? projectDir : undefined;
146
+ const location = isProject && projectDir ? "project" : "global";
147
+
148
+ try {
149
+ const filePath = await createSnippet(name, content, { aliases, description }, targetDir);
150
+
151
+ // Reload snippets
152
+ await reloadSnippets(snippets, projectDir);
153
+
154
+ let message = `Added ${location} snippet: ${name}\nFile: ${filePath}`;
155
+ if (content) {
156
+ message += `\nContent: "${truncate(content, 50)}"`;
157
+ } else {
158
+ message += "\n\nEdit the file to add your snippet content.";
159
+ }
160
+ if (aliases.length > 0) {
161
+ message += `\nAliases: ${aliases.join(", ")}`;
162
+ }
163
+
164
+ await sendIgnoredMessage(client, sessionId, message);
165
+ } catch (error) {
166
+ await sendIgnoredMessage(
167
+ client,
168
+ sessionId,
169
+ `Failed to add snippet: ${error instanceof Error ? error.message : String(error)}`,
170
+ );
171
+ }
172
+ }
173
+
174
+ /**
175
+ * Handle /snippet delete <name>
176
+ */
177
+ async function handleDeleteCommand(ctx: CommandContext): Promise<void> {
178
+ const { client, sessionId, args, snippets, projectDir } = ctx;
179
+
180
+ if (args.length === 0) {
181
+ await sendIgnoredMessage(
182
+ client,
183
+ sessionId,
184
+ "Usage: /snippet delete <name>\n\nDeletes a snippet by name. " +
185
+ "Project snippets are checked first, then global.",
186
+ );
187
+ return;
188
+ }
189
+
190
+ const name = args[0];
191
+
192
+ const deletedPath = await deleteSnippet(name, projectDir);
193
+
194
+ if (deletedPath) {
195
+ // Reload snippets
196
+ await reloadSnippets(snippets, projectDir);
197
+
198
+ await sendIgnoredMessage(
199
+ client,
200
+ sessionId,
201
+ `Deleted snippet: #${name}\nRemoved: ${deletedPath}`,
202
+ );
203
+ } else {
204
+ await sendIgnoredMessage(
205
+ client,
206
+ sessionId,
207
+ `Snippet not found: #${name}\n\nUse /snippet list to see available snippets.`,
208
+ );
209
+ }
210
+ }
211
+
212
+ /** Maximum characters for snippet content preview */
213
+ const MAX_CONTENT_PREVIEW_LENGTH = 200;
214
+ /** Maximum characters for aliases display */
215
+ const MAX_ALIASES_LENGTH = 50;
216
+ /** Divider line */
217
+ const DIVIDER = "────────────────────────────────────────────────";
218
+
219
+ /**
220
+ * Truncate text with ellipsis if it exceeds maxLength
221
+ */
222
+ function truncate(text: string, maxLength: number): string {
223
+ if (text.length <= maxLength) return text;
224
+ return text.slice(0, maxLength - 3) + "...";
225
+ }
226
+
227
+ /**
228
+ * Format aliases for display, truncating if needed
229
+ */
230
+ function formatAliases(aliases: string[]): string {
231
+ if (aliases.length === 0) return "";
232
+
233
+ const joined = aliases.join(", ");
234
+ if (joined.length <= MAX_ALIASES_LENGTH) {
235
+ return ` (aliases: ${joined})`;
236
+ }
237
+
238
+ // Truncate and show count
239
+ const truncated = truncate(joined, MAX_ALIASES_LENGTH - 10);
240
+ return ` (aliases: ${truncated} +${aliases.length})`;
241
+ }
242
+
243
+ /**
244
+ * Format a single snippet for display
245
+ */
246
+ function formatSnippetEntry(s: { name: string; content: string; aliases: string[] }): string {
247
+ const header = `${s.name}${formatAliases(s.aliases)}`;
248
+ const content = truncate(s.content.trim(), MAX_CONTENT_PREVIEW_LENGTH);
249
+
250
+ return `${header}\n${DIVIDER}\n${content || "(empty)"}`;
251
+ }
252
+
253
+ /**
254
+ * Handle /snippet list
255
+ */
256
+ async function handleListCommand(ctx: CommandContext): Promise<void> {
257
+ const { client, sessionId, snippets, projectDir } = ctx;
258
+
259
+ const snippetList = listSnippets(snippets);
260
+
261
+ if (snippetList.length === 0) {
262
+ await sendIgnoredMessage(
263
+ client,
264
+ sessionId,
265
+ "No snippets found.\n\n" +
266
+ `Global snippets: ${PATHS.SNIPPETS_DIR}\n` +
267
+ (projectDir
268
+ ? `Project snippets: ${projectDir}/.opencode/snippet/`
269
+ : "No project directory detected.") +
270
+ "\n\nUse /snippet add <name> to add a new snippet.",
271
+ );
272
+ return;
273
+ }
274
+
275
+ const lines: string[] = [];
276
+
277
+ // Group by source
278
+ const globalSnippets = snippetList.filter((s) => s.source === "global");
279
+ const projectSnippets = snippetList.filter((s) => s.source === "project");
280
+
281
+ if (globalSnippets.length > 0) {
282
+ lines.push(`── Global (${PATHS.SNIPPETS_DIR}) ──`, "");
283
+ for (const s of globalSnippets) {
284
+ lines.push(formatSnippetEntry(s), "");
285
+ }
286
+ }
287
+
288
+ if (projectSnippets.length > 0) {
289
+ lines.push(`── Project (${projectDir}/.opencode/snippet/) ──`, "");
290
+ for (const s of projectSnippets) {
291
+ lines.push(formatSnippetEntry(s), "");
292
+ }
293
+ }
294
+
295
+ await sendIgnoredMessage(client, sessionId, lines.join("\n").trimEnd());
296
+ }
297
+
298
+ /**
299
+ * Handle /snippet help
300
+ */
301
+ async function handleHelpCommand(ctx: CommandContext): Promise<void> {
302
+ const { client, sessionId } = ctx;
303
+
304
+ const helpText = `Snippets Command - Manage text snippets
305
+
306
+ Usage: /snippet <command> [options]
307
+
308
+ Commands:
309
+ add <name> ["content"] [options]
310
+ --project Add to project directory (default: global)
311
+ --aliases X,Y,Z Add aliases (comma-separated)
312
+ --desc "..." Add a description
313
+
314
+ delete <name> Delete a snippet
315
+ list List all available snippets
316
+ help Show this help message
317
+
318
+ Snippet Locations:
319
+ Global: ~/.config/opencode/snippet/
320
+ Project: <project>/.opencode/snippet/
321
+
322
+ Usage in messages:
323
+ Type #snippet-name to expand a snippet inline.
324
+ Snippets can reference other snippets recursively.
325
+
326
+ Examples:
327
+ /snippet add greeting
328
+ /snippet add bye "see you later"
329
+ /snippet add hi "hello there" --aliases hello,hey
330
+ /snippet add fix "fix imports" --project
331
+ /snippet delete old-snippet
332
+ /snippet list`;
333
+
334
+ await sendIgnoredMessage(client, sessionId, helpText);
335
+ }
package/src/constants.ts CHANGED
@@ -12,20 +12,15 @@ export const PATTERNS = {
12
12
  SHELL_COMMAND: /!`([^`]+)`/g,
13
13
  } as const;
14
14
 
15
- /**
16
- * OpenCode configuration directory
17
- */
18
- export const OPENCODE_CONFIG_DIR = join(homedir(), ".config", "opencode");
19
-
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,
20
+ CONFIG_DIR: join(homedir(), ".config", "opencode"),
26
21
 
27
22
  /** Snippets directory */
28
- SNIPPETS_DIR: join(OPENCODE_CONFIG_DIR, "snippet"),
23
+ SNIPPETS_DIR: join(join(homedir(), ".config", "opencode"), "snippet"),
29
24
  } as const;
30
25
 
31
26
  /**