opencode-snippets 1.2.0 → 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 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
 
@@ -217,12 +221,25 @@ Be extremely concise. No explanations unless asked.
217
221
  | Live shell data | Yes 💻 | Yes 💻 |
218
222
  | Best for | Triggering actions & workflows ⚡ | Context injection 📝 |
219
223
 
220
- **Use both together:**
221
- ```
222
- /commit #conventional-commits #project-context
223
- ```
224
+ > [!TIP]
225
+ > #### My recommendation:
226
+ >
227
+ > Use /slash commands for **triggering actions and workflows** imperatively - anything that needs to happen _right now_: `/commit-and-push`, `/add-worktree`, or `/pull-rebase`.
228
+ > Use #snippets for **all other context engineering**.
229
+ >
230
+ > If you can't decide, get the best of both worlds and just have your command proxy through to the snippet:
231
+ >
232
+ > `~/.config/opencode/command/pull.md`:
233
+ > ```markdown
234
+ > ---
235
+ > description: Proxy through to the snippet at snippet/pull.md
236
+ > ---
237
+ > #pull
238
+ > ```
239
+
240
+ ## Configuration
224
241
 
225
- ## Configuration### Debug Logging
242
+ ### Debug Logging
226
243
 
227
244
  Enable debug logs by setting an environment variable:
228
245
 
@@ -243,7 +260,8 @@ Logs are written to `~/.config/opencode/logs/snippets/daily/`.
243
260
 
244
261
  ## Contributing
245
262
 
246
- Contributions welcome! Please open an issue or PR on GitHub.
263
+ Contributions welcome! Please open an issue or PR on GitHub.
264
+ 👥 [Discord Forum](https://discord.com/channels/1391832426048651334/1463378026833379409)
247
265
 
248
266
  ## License
249
267
 
package/index.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { Plugin } from "@opencode-ai/plugin";
2
+ import { createCommandExecuteHandler } from "./src/commands.js";
2
3
  import { expandHashtags } from "./src/expander.js";
3
4
  import { loadSnippets } from "./src/loader.js";
4
5
  import { logger } from "./src/logger.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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-snippets",
3
- "version": "1.2.0",
3
+ "version": "1.3.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,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
@@ -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
  /**
@@ -1,10 +1,20 @@
1
1
  import { expandHashtags } from "../src/expander.js";
2
- import type { SnippetRegistry } from "../src/types.js";
2
+ import type { SnippetInfo, SnippetRegistry } from "../src/types.js";
3
+
4
+ /** Helper to create a SnippetInfo from just content */
5
+ function snippet(content: string, name = "test"): SnippetInfo {
6
+ return { name, content, aliases: [], filePath: "", source: "global" };
7
+ }
8
+
9
+ /** Helper to create a registry from [key, content] pairs */
10
+ function createRegistry(entries: [string, string][]): SnippetRegistry {
11
+ return new Map(entries.map(([key, content]) => [key, snippet(content, key)]));
12
+ }
3
13
 
4
14
  describe("expandHashtags - Recursive Includes and Loop Detection", () => {
5
15
  describe("Basic expansion", () => {
6
16
  it("should expand a single hashtag", () => {
7
- const registry: SnippetRegistry = new Map([["greeting", "Hello, World!"]]);
17
+ const registry = createRegistry([["greeting", "Hello, World!"]]);
8
18
 
9
19
  const result = expandHashtags("Say #greeting", registry);
10
20
 
@@ -12,7 +22,7 @@ describe("expandHashtags - Recursive Includes and Loop Detection", () => {
12
22
  });
13
23
 
14
24
  it("should expand multiple hashtags in one text", () => {
15
- const registry: SnippetRegistry = new Map([
25
+ const registry = createRegistry([
16
26
  ["greeting", "Hello"],
17
27
  ["name", "Alice"],
18
28
  ]);
@@ -23,7 +33,7 @@ describe("expandHashtags - Recursive Includes and Loop Detection", () => {
23
33
  });
24
34
 
25
35
  it("should leave unknown hashtags unchanged", () => {
26
- const registry: SnippetRegistry = new Map([["known", "content"]]);
36
+ const registry = createRegistry([["known", "content"]]);
27
37
 
28
38
  const result = expandHashtags("This is #known and #unknown", registry);
29
39
 
@@ -31,7 +41,7 @@ describe("expandHashtags - Recursive Includes and Loop Detection", () => {
31
41
  });
32
42
 
33
43
  it("should handle empty text", () => {
34
- const registry: SnippetRegistry = new Map([["test", "content"]]);
44
+ const registry = createRegistry([["test", "content"]]);
35
45
 
36
46
  const result = expandHashtags("", registry);
37
47
 
@@ -39,7 +49,7 @@ describe("expandHashtags - Recursive Includes and Loop Detection", () => {
39
49
  });
40
50
 
41
51
  it("should handle text with no hashtags", () => {
42
- const registry: SnippetRegistry = new Map([["test", "content"]]);
52
+ const registry = createRegistry([["test", "content"]]);
43
53
 
44
54
  const result = expandHashtags("No hashtags here", registry);
45
55
 
@@ -47,7 +57,7 @@ describe("expandHashtags - Recursive Includes and Loop Detection", () => {
47
57
  });
48
58
 
49
59
  it("should handle case-insensitive hashtags", () => {
50
- const registry: SnippetRegistry = new Map([["greeting", "Hello"]]);
60
+ const registry = createRegistry([["greeting", "Hello"]]);
51
61
 
52
62
  const result = expandHashtags("#Greeting #GREETING #greeting", registry);
53
63
 
@@ -57,7 +67,7 @@ describe("expandHashtags - Recursive Includes and Loop Detection", () => {
57
67
 
58
68
  describe("Recursive expansion", () => {
59
69
  it("should expand nested hashtags one level deep", () => {
60
- const registry: SnippetRegistry = new Map([
70
+ const registry = createRegistry([
61
71
  ["outer", "Start #inner End"],
62
72
  ["inner", "Middle"],
63
73
  ]);
@@ -68,7 +78,7 @@ describe("expandHashtags - Recursive Includes and Loop Detection", () => {
68
78
  });
69
79
 
70
80
  it("should expand nested hashtags multiple levels deep", () => {
71
- const registry: SnippetRegistry = new Map([
81
+ const registry = createRegistry([
72
82
  ["level1", "L1 #level2"],
73
83
  ["level2", "L2 #level3"],
74
84
  ["level3", "L3 #level4"],
@@ -81,7 +91,7 @@ describe("expandHashtags - Recursive Includes and Loop Detection", () => {
81
91
  });
82
92
 
83
93
  it("should expand multiple nested hashtags in one snippet", () => {
84
- const registry: SnippetRegistry = new Map([
94
+ const registry = createRegistry([
85
95
  ["main", "Start #a and #b End"],
86
96
  ["a", "Content A"],
87
97
  ["b", "Content B"],
@@ -93,7 +103,7 @@ describe("expandHashtags - Recursive Includes and Loop Detection", () => {
93
103
  });
94
104
 
95
105
  it("should expand complex nested structure", () => {
96
- const registry: SnippetRegistry = new Map([
106
+ const registry = createRegistry([
97
107
  ["greeting", "#hello #name"],
98
108
  ["hello", "Hello"],
99
109
  ["name", "#firstname #lastname"],
@@ -109,7 +119,7 @@ describe("expandHashtags - Recursive Includes and Loop Detection", () => {
109
119
 
110
120
  describe("Loop detection - Direct cycles", () => {
111
121
  it("should detect and prevent simple self-reference", { timeout: 100 }, () => {
112
- const registry: SnippetRegistry = new Map([["self", "I reference #self"]]);
122
+ const registry = createRegistry([["self", "I reference #self"]]);
113
123
 
114
124
  const result = expandHashtags("#self", registry);
115
125
 
@@ -119,7 +129,7 @@ describe("expandHashtags - Recursive Includes and Loop Detection", () => {
119
129
  });
120
130
 
121
131
  it("should detect and prevent two-way circular reference", () => {
122
- const registry: SnippetRegistry = new Map([
132
+ const registry = createRegistry([
123
133
  ["a", "A references #b"],
124
134
  ["b", "B references #a"],
125
135
  ]);
@@ -132,7 +142,7 @@ describe("expandHashtags - Recursive Includes and Loop Detection", () => {
132
142
  });
133
143
 
134
144
  it("should detect and prevent three-way circular reference", () => {
135
- const registry: SnippetRegistry = new Map([
145
+ const registry = createRegistry([
136
146
  ["a", "A -> #b"],
137
147
  ["b", "B -> #c"],
138
148
  ["c", "C -> #a"],
@@ -146,7 +156,7 @@ describe("expandHashtags - Recursive Includes and Loop Detection", () => {
146
156
  });
147
157
 
148
158
  it("should detect loops in longer chains", () => {
149
- const registry: SnippetRegistry = new Map([
159
+ const registry = createRegistry([
150
160
  ["a", "#b"],
151
161
  ["b", "#c"],
152
162
  ["c", "#d"],
@@ -163,7 +173,7 @@ describe("expandHashtags - Recursive Includes and Loop Detection", () => {
163
173
 
164
174
  describe("Loop detection - Complex scenarios", () => {
165
175
  it("should allow same snippet in different branches", () => {
166
- const registry: SnippetRegistry = new Map([
176
+ const registry = createRegistry([
167
177
  ["main", "#branch1 and #branch2"],
168
178
  ["branch1", "B1 uses #shared"],
169
179
  ["branch2", "B2 uses #shared"],
@@ -177,7 +187,7 @@ describe("expandHashtags - Recursive Includes and Loop Detection", () => {
177
187
  });
178
188
 
179
189
  it("should handle partial loops with valid branches", () => {
180
- const registry: SnippetRegistry = new Map([
190
+ const registry = createRegistry([
181
191
  ["main", "#valid and #loop"],
182
192
  ["valid", "Valid content"],
183
193
  ["loop", "Loop #loop"],
@@ -191,7 +201,7 @@ describe("expandHashtags - Recursive Includes and Loop Detection", () => {
191
201
  });
192
202
 
193
203
  it("should handle multiple independent loops", () => {
194
- const registry: SnippetRegistry = new Map([
204
+ const registry = createRegistry([
195
205
  ["main", "#loop1 and #loop2"],
196
206
  ["loop1", "L1 #loop1"],
197
207
  ["loop2", "L2 #loop2"],
@@ -205,7 +215,7 @@ describe("expandHashtags - Recursive Includes and Loop Detection", () => {
205
215
  });
206
216
 
207
217
  it("should handle nested loops", () => {
208
- const registry: SnippetRegistry = new Map([
218
+ const registry = createRegistry([
209
219
  ["outer", "Outer #inner"],
210
220
  ["inner", "Inner #outer and #self"],
211
221
  ["self", "Self #self"],
@@ -222,7 +232,7 @@ describe("expandHashtags - Recursive Includes and Loop Detection", () => {
222
232
  });
223
233
 
224
234
  it("should handle diamond pattern (same snippet reached via multiple paths)", () => {
225
- const registry: SnippetRegistry = new Map([
235
+ const registry = createRegistry([
226
236
  ["top", "#left #right"],
227
237
  ["left", "Left #bottom"],
228
238
  ["right", "Right #bottom"],
@@ -236,7 +246,7 @@ describe("expandHashtags - Recursive Includes and Loop Detection", () => {
236
246
  });
237
247
 
238
248
  it("should handle loop after valid expansion", () => {
239
- const registry: SnippetRegistry = new Map([
249
+ const registry = createRegistry([
240
250
  ["a", "#b #c"],
241
251
  ["b", "Valid B"],
242
252
  ["c", "#d"],
@@ -259,7 +269,7 @@ describe("expandHashtags - Recursive Includes and Loop Detection", () => {
259
269
  });
260
270
 
261
271
  it("should handle snippet with empty content", () => {
262
- const registry: SnippetRegistry = new Map([["empty", ""]]);
272
+ const registry = createRegistry([["empty", ""]]);
263
273
 
264
274
  const result = expandHashtags("Before #empty After", registry);
265
275
 
@@ -267,7 +277,7 @@ describe("expandHashtags - Recursive Includes and Loop Detection", () => {
267
277
  });
268
278
 
269
279
  it("should handle snippet containing only hashtags", () => {
270
- const registry: SnippetRegistry = new Map([
280
+ const registry = createRegistry([
271
281
  ["only-refs", "#a #b"],
272
282
  ["a", "A"],
273
283
  ["b", "B"],
@@ -279,7 +289,7 @@ describe("expandHashtags - Recursive Includes and Loop Detection", () => {
279
289
  });
280
290
 
281
291
  it("should handle hashtags at start, middle, and end", () => {
282
- const registry: SnippetRegistry = new Map([
292
+ const registry = createRegistry([
283
293
  ["start", "Start"],
284
294
  ["middle", "Middle"],
285
295
  ["end", "End"],
@@ -291,7 +301,7 @@ describe("expandHashtags - Recursive Includes and Loop Detection", () => {
291
301
  });
292
302
 
293
303
  it("should handle consecutive hashtags", () => {
294
- const registry: SnippetRegistry = new Map([
304
+ const registry = createRegistry([
295
305
  ["a", "A"],
296
306
  ["b", "B"],
297
307
  ["c", "C"],
@@ -303,7 +313,7 @@ describe("expandHashtags - Recursive Includes and Loop Detection", () => {
303
313
  });
304
314
 
305
315
  it("should handle hashtags with hyphens and underscores", () => {
306
- const registry: SnippetRegistry = new Map([
316
+ const registry = createRegistry([
307
317
  ["my-snippet", "Hyphenated"],
308
318
  ["my_snippet", "Underscored"],
309
319
  ["my-complex_name", "Mixed"],
@@ -315,7 +325,7 @@ describe("expandHashtags - Recursive Includes and Loop Detection", () => {
315
325
  });
316
326
 
317
327
  it("should handle hashtags with numbers", () => {
318
- const registry: SnippetRegistry = new Map([
328
+ const registry = createRegistry([
319
329
  ["test123", "Test with numbers"],
320
330
  ["123test", "Numbers first"],
321
331
  ]);
@@ -326,7 +336,7 @@ describe("expandHashtags - Recursive Includes and Loop Detection", () => {
326
336
  });
327
337
 
328
338
  it("should not expand hashtags in URLs", () => {
329
- const registry: SnippetRegistry = new Map([["issue", "ISSUE"]]);
339
+ const registry = createRegistry([["issue", "ISSUE"]]);
330
340
 
331
341
  // Note: The current implementation WILL expand #issue in URLs
332
342
  // This test documents current behavior
@@ -336,7 +346,7 @@ describe("expandHashtags - Recursive Includes and Loop Detection", () => {
336
346
  });
337
347
 
338
348
  it("should handle multiline content", () => {
339
- const registry: SnippetRegistry = new Map([["multiline", "Line 1\nLine 2\nLine 3"]]);
349
+ const registry = createRegistry([["multiline", "Line 1\nLine 2\nLine 3"]]);
340
350
 
341
351
  const result = expandHashtags("Start\n#multiline\nEnd", registry);
342
352
 
@@ -344,7 +354,7 @@ describe("expandHashtags - Recursive Includes and Loop Detection", () => {
344
354
  });
345
355
 
346
356
  it("should handle nested multiline content", () => {
347
- const registry: SnippetRegistry = new Map([
357
+ const registry = createRegistry([
348
358
  ["outer", "Outer start\n#inner\nOuter end"],
349
359
  ["inner", "Inner line 1\nInner line 2"],
350
360
  ]);
@@ -357,7 +367,7 @@ describe("expandHashtags - Recursive Includes and Loop Detection", () => {
357
367
 
358
368
  describe("Real-world scenarios", () => {
359
369
  it("should expand code review template with nested snippets", () => {
360
- const registry: SnippetRegistry = new Map([
370
+ const registry = createRegistry([
361
371
  ["review", "Code Review Checklist:\n#security\n#performance\n#tests"],
362
372
  ["security", "- Check for SQL injection\n- Validate input"],
363
373
  ["performance", "- Check for N+1 queries\n- Review algorithm complexity"],
@@ -373,7 +383,7 @@ describe("expandHashtags - Recursive Includes and Loop Detection", () => {
373
383
  });
374
384
 
375
385
  it("should expand documentation template with shared components", () => {
376
- const registry: SnippetRegistry = new Map([
386
+ const registry = createRegistry([
377
387
  ["doc", "# Documentation\n#header\n#body\n#footer"],
378
388
  ["header", "Author: #author\nDate: 2024-01-01"],
379
389
  ["author", "John Doe"],
@@ -389,7 +399,7 @@ describe("expandHashtags - Recursive Includes and Loop Detection", () => {
389
399
  });
390
400
 
391
401
  it("should handle instruction composition", () => {
392
- const registry: SnippetRegistry = new Map([
402
+ const registry = createRegistry([
393
403
  ["careful", "Think step by step. #verify"],
394
404
  ["verify", "Double-check your work."],
395
405
  ["complete", "Be thorough. #careful"],
@@ -408,9 +418,9 @@ describe("expandHashtags - Recursive Includes and Loop Detection", () => {
408
418
 
409
419
  // Create a chain: level0 -> level1 -> level2 -> ... -> level49 -> "End"
410
420
  for (let i = 0; i < depth - 1; i++) {
411
- registry.set(`level${i}`, `L${i} #level${i + 1}`);
421
+ registry.set(`level${i}`, snippet(`L${i} #level${i + 1}`, `level${i}`));
412
422
  }
413
- registry.set(`level${depth - 1}`, "End");
423
+ registry.set(`level${depth - 1}`, snippet("End", `level${depth - 1}`));
414
424
 
415
425
  const result = expandHashtags("#level0", registry);
416
426
 
@@ -424,7 +434,7 @@ describe("expandHashtags - Recursive Includes and Loop Detection", () => {
424
434
  const count = 100;
425
435
 
426
436
  for (let i = 0; i < count; i++) {
427
- registry.set(`snippet${i}`, `Content${i}`);
437
+ registry.set(`snippet${i}`, snippet(`Content${i}`, `snippet${i}`));
428
438
  }
429
439
 
430
440
  const hashtags = Array.from({ length: count }, (_, i) => `#snippet${i}`).join(" ");
@@ -440,10 +450,10 @@ describe("expandHashtags - Recursive Includes and Loop Detection", () => {
440
450
  const branches = 20;
441
451
 
442
452
  const children = Array.from({ length: branches }, (_, i) => `#child${i}`).join(" ");
443
- registry.set("parent", children);
453
+ registry.set("parent", snippet(children, "parent"));
444
454
 
445
455
  for (let i = 0; i < branches; i++) {
446
- registry.set(`child${i}`, `Child${i}`);
456
+ registry.set(`child${i}`, snippet(`Child${i}`, `child${i}`));
447
457
  }
448
458
 
449
459
  const result = expandHashtags("#parent", registry);
package/src/expander.ts CHANGED
@@ -34,8 +34,8 @@ export function expandHashtags(
34
34
  expanded = expanded.replace(PATTERNS.HASHTAG, (match, name) => {
35
35
  const key = name.toLowerCase();
36
36
 
37
- const content = registry.get(key);
38
- if (content === undefined) {
37
+ const snippet = registry.get(key);
38
+ if (snippet === undefined) {
39
39
  // Unknown snippet - leave as-is
40
40
  return match;
41
41
  }
@@ -54,7 +54,7 @@ export function expandHashtags(
54
54
  expansionCounts.set(key, count);
55
55
 
56
56
  // Recursively expand any hashtags in the snippet content
57
- const result = expandHashtags(content, registry, expansionCounts);
57
+ const result = expandHashtags(snippet.content, registry, expansionCounts);
58
58
 
59
59
  return result;
60
60
  });
@@ -35,8 +35,8 @@ Think step by step. Double-check your work.`,
35
35
  const snippets = await loadSnippets(undefined, globalSnippetDir);
36
36
 
37
37
  expect(snippets.size).toBe(2);
38
- expect(snippets.get("careful")).toBe("Think step by step. Double-check your work.");
39
- expect(snippets.get("safe")).toBe("Think step by step. Double-check your work.");
38
+ expect(snippets.get("careful")?.content).toBe("Think step by step. Double-check your work.");
39
+ expect(snippets.get("safe")?.content).toBe("Think step by step. Double-check your work.");
40
40
  });
41
41
 
42
42
  it("should load multiple snippets from global directory", async () => {
@@ -46,8 +46,8 @@ Think step by step. Double-check your work.`,
46
46
  const snippets = await loadSnippets(undefined, globalSnippetDir);
47
47
 
48
48
  expect(snippets.size).toBe(2);
49
- expect(snippets.get("snippet1")).toBe("Content of snippet 1");
50
- expect(snippets.get("snippet2")).toBe("Content of snippet 2");
49
+ expect(snippets.get("snippet1")?.content).toBe("Content of snippet 1");
50
+ expect(snippets.get("snippet2")?.content).toBe("Content of snippet 2");
51
51
  });
52
52
  });
53
53
 
@@ -63,7 +63,7 @@ Think step by step. Double-check your work.`,
63
63
  const snippets = await loadSnippets(projectDir, globalSnippetDir);
64
64
 
65
65
  expect(snippets.size).toBe(1);
66
- expect(snippets.get("project-specific")).toBe("This is a project-specific snippet");
66
+ expect(snippets.get("project-specific")?.content).toBe("This is a project-specific snippet");
67
67
  });
68
68
 
69
69
  it("should handle missing global directory when project exists", async () => {
@@ -74,8 +74,8 @@ Think step by step. Double-check your work.`,
74
74
  const snippets = await loadSnippets(projectDir, globalSnippetDir);
75
75
 
76
76
  expect(snippets.size).toBe(2);
77
- expect(snippets.get("team-rule")).toBe("Team rule 1");
78
- expect(snippets.get("domain-knowledge")).toBe("Domain knowledge");
77
+ expect(snippets.get("team-rule")?.content).toBe("Team rule 1");
78
+ expect(snippets.get("domain-knowledge")?.content).toBe("Domain knowledge");
79
79
  });
80
80
  });
81
81
 
@@ -91,8 +91,8 @@ Think step by step. Double-check your work.`,
91
91
  const snippets = await loadSnippets(projectDir, globalSnippetDir);
92
92
 
93
93
  expect(snippets.size).toBe(2);
94
- expect(snippets.get("global")).toBe("Global snippet content");
95
- expect(snippets.get("project")).toBe("Project snippet content");
94
+ expect(snippets.get("global")?.content).toBe("Global snippet content");
95
+ expect(snippets.get("project")?.content).toBe("Project snippet content");
96
96
  });
97
97
 
98
98
  it("should allow project snippets to override global snippets", async () => {
@@ -106,7 +106,7 @@ Think step by step. Double-check your work.`,
106
106
  const snippets = await loadSnippets(projectDir, globalSnippetDir);
107
107
 
108
108
  // Project snippet should override global
109
- expect(snippets.get("careful")).toBe("Project-specific careful content");
109
+ expect(snippets.get("careful")?.content).toBe("Project-specific careful content");
110
110
  expect(snippets.size).toBe(1);
111
111
  });
112
112
  });
@@ -139,12 +139,12 @@ Project test guidelines`,
139
139
  const snippets = await loadSnippets(projectDir, globalSnippetDir);
140
140
 
141
141
  expect(snippets.size).toBe(6); // review, pr, check, test, tdd, testing
142
- expect(snippets.get("review")).toBe("Global review guidelines");
143
- expect(snippets.get("pr")).toBe("Global review guidelines");
144
- expect(snippets.get("check")).toBe("Global review guidelines");
145
- expect(snippets.get("test")).toBe("Project test guidelines");
146
- expect(snippets.get("tdd")).toBe("Project test guidelines");
147
- expect(snippets.get("testing")).toBe("Project test guidelines");
142
+ expect(snippets.get("review")?.content).toBe("Global review guidelines");
143
+ expect(snippets.get("pr")?.content).toBe("Global review guidelines");
144
+ expect(snippets.get("check")?.content).toBe("Global review guidelines");
145
+ expect(snippets.get("test")?.content).toBe("Project test guidelines");
146
+ expect(snippets.get("tdd")?.content).toBe("Project test guidelines");
147
+ expect(snippets.get("testing")?.content).toBe("Project test guidelines");
148
148
  });
149
149
 
150
150
  it("should allow project to override global aliases", async () => {
@@ -172,9 +172,9 @@ Project careful`,
172
172
  const snippets = await loadSnippets(projectDir, globalSnippetDir);
173
173
 
174
174
  // Project should override with its aliases
175
- expect(snippets.get("careful")).toBe("Project careful");
176
- expect(snippets.get("safe")).toBe("Project careful");
177
- expect(snippets.get("cautious")).toBeUndefined();
175
+ expect(snippets.get("careful")?.content).toBe("Project careful");
176
+ expect(snippets.get("safe")?.content).toBe("Project careful");
177
+ expect(snippets.get("cautious")?.content).toBeUndefined();
178
178
  expect(snippets.size).toBe(2);
179
179
  });
180
180
  });
@@ -185,7 +185,7 @@ Project careful`,
185
185
 
186
186
  const snippets = await loadSnippets(undefined, globalSnippetDir);
187
187
  expect(snippets.size).toBe(1);
188
- expect(snippets.get("empty")).toBe("");
188
+ expect(snippets.get("empty")?.content).toBe("");
189
189
  });
190
190
 
191
191
  it("should handle snippet with only metadata", async () => {
@@ -199,8 +199,8 @@ aliases: meta
199
199
 
200
200
  const snippets = await loadSnippets(undefined, globalSnippetDir);
201
201
  expect(snippets.size).toBe(2);
202
- expect(snippets.get("metadata-only")).toBe("");
203
- expect(snippets.get("meta")).toBe("");
202
+ expect(snippets.get("metadata-only")?.content).toBe("");
203
+ expect(snippets.get("meta")?.content).toBe("");
204
204
  });
205
205
 
206
206
  it("should handle multiline content", async () => {
@@ -212,7 +212,7 @@ Line 3`,
212
212
  );
213
213
 
214
214
  const snippets = await loadSnippets(undefined, globalSnippetDir);
215
- expect(snippets.get("multiline")).toBe("Line 1\nLine 2\nLine 3");
215
+ expect(snippets.get("multiline")?.content).toBe("Line 1\nLine 2\nLine 3");
216
216
  });
217
217
 
218
218
  it("should ignore non-.md files", async () => {
@@ -221,7 +221,7 @@ Line 3`,
221
221
 
222
222
  const snippets = await loadSnippets(undefined, globalSnippetDir);
223
223
  expect(snippets.size).toBe(1);
224
- expect(snippets.get("valid")).toBe("This should be loaded");
224
+ expect(snippets.get("valid")?.content).toBe("This should be loaded");
225
225
  expect(snippets.has("not-a-snippet")).toBe(false);
226
226
  });
227
227
 
@@ -238,7 +238,7 @@ Content`,
238
238
 
239
239
  const snippets = await loadSnippets(undefined, globalSnippetDir);
240
240
  // Should load valid snippet, skip invalid one
241
- expect(snippets.get("special-chars")).toBe("Special content");
241
+ expect(snippets.get("special-chars")?.content).toBe("Special content");
242
242
  });
243
243
 
244
244
  it("should handle non-existent directories gracefully", async () => {
package/src/loader.ts CHANGED
@@ -1,16 +1,16 @@
1
- import { readdir, readFile } from "node:fs/promises";
1
+ import { mkdir, readdir, readFile, unlink, writeFile } from "node:fs/promises";
2
2
  import { basename, join } from "node:path";
3
3
  import matter from "gray-matter";
4
4
  import { CONFIG, PATHS } from "./constants.js";
5
5
  import { logger } from "./logger.js";
6
- import type { SnippetFrontmatter, SnippetRegistry } from "./types.js";
6
+ import type { SnippetFrontmatter, SnippetInfo, SnippetRegistry } from "./types.js";
7
7
 
8
8
  /**
9
9
  * Loads all snippets from global and project directories
10
10
  *
11
11
  * @param projectDir - Optional project directory path (from ctx.directory)
12
12
  * @param globalDir - Optional global snippets directory (for testing)
13
- * @returns A map of snippet keys (lowercase) to their content
13
+ * @returns A map of snippet keys (lowercase) to their SnippetInfo
14
14
  */
15
15
  export async function loadSnippets(
16
16
  projectDir?: string,
@@ -41,7 +41,7 @@ export async function loadSnippets(
41
41
  async function loadFromDirectory(
42
42
  dir: string,
43
43
  registry: SnippetRegistry,
44
- source: string,
44
+ source: "global" | "project",
45
45
  ): Promise<void> {
46
46
  try {
47
47
  const files = await readdir(dir);
@@ -49,9 +49,9 @@ async function loadFromDirectory(
49
49
  for (const file of files) {
50
50
  if (!file.endsWith(CONFIG.SNIPPET_EXTENSION)) continue;
51
51
 
52
- const snippet = await loadSnippetFile(dir, file);
52
+ const snippet = await loadSnippetFile(dir, file, source);
53
53
  if (snippet) {
54
- registerSnippet(registry, snippet.name, snippet.content, snippet.aliases);
54
+ registerSnippet(registry, snippet);
55
55
  }
56
56
  }
57
57
 
@@ -73,9 +73,14 @@ async function loadFromDirectory(
73
73
  *
74
74
  * @param dir - Directory containing the snippet file
75
75
  * @param filename - The filename to load (e.g., "my-snippet.md")
76
- * @returns The parsed snippet data, or null if parsing failed
76
+ * @param source - Whether this is a global or project snippet
77
+ * @returns The parsed snippet info, or null if parsing failed
77
78
  */
78
- async function loadSnippetFile(dir: string, filename: string) {
79
+ async function loadSnippetFile(
80
+ dir: string,
81
+ filename: string,
82
+ source: "global" | "project",
83
+ ): Promise<SnippetInfo | null> {
79
84
  try {
80
85
  const name = basename(filename, CONFIG.SNIPPET_EXTENSION);
81
86
  const filePath = join(dir, filename);
@@ -95,7 +100,14 @@ async function loadSnippetFile(dir: string, filename: string) {
95
100
  }
96
101
  }
97
102
 
98
- return { name, content, aliases };
103
+ return {
104
+ name,
105
+ content,
106
+ aliases,
107
+ description: frontmatter.description,
108
+ filePath,
109
+ source,
110
+ };
99
111
  } catch (error) {
100
112
  // Failed to read or parse this snippet - skip it
101
113
  logger.warn("Failed to load snippet file", {
@@ -109,34 +121,146 @@ async function loadSnippetFile(dir: string, filename: string) {
109
121
  /**
110
122
  * Registers a snippet and its aliases in the registry
111
123
  *
112
- * @param registry - The snippet registry to update
113
- * @param name - The primary name of the snippet
124
+ * @param registry - The registry to add the snippet to
125
+ * @param snippet - The snippet info to register
126
+ */
127
+ function registerSnippet(registry: SnippetRegistry, snippet: SnippetInfo): void {
128
+ const key = snippet.name.toLowerCase();
129
+
130
+ // If snippet with same name exists, remove its old aliases first
131
+ const existing = registry.get(key);
132
+ if (existing) {
133
+ for (const alias of existing.aliases) {
134
+ registry.delete(alias.toLowerCase());
135
+ }
136
+ }
137
+
138
+ // Register the snippet under its name
139
+ registry.set(key, snippet);
140
+
141
+ // Register under all aliases (pointing to the same snippet info)
142
+ for (const alias of snippet.aliases) {
143
+ registry.set(alias.toLowerCase(), snippet);
144
+ }
145
+ }
146
+
147
+ /**
148
+ * Lists all unique snippets (by name) from the registry
149
+ *
150
+ * @param registry - The snippet registry
151
+ * @returns Array of unique snippet info objects
152
+ */
153
+ export function listSnippets(registry: SnippetRegistry): SnippetInfo[] {
154
+ const seen = new Set<string>();
155
+ const snippets: SnippetInfo[] = [];
156
+
157
+ for (const snippet of registry.values()) {
158
+ if (!seen.has(snippet.name)) {
159
+ seen.add(snippet.name);
160
+ snippets.push(snippet);
161
+ }
162
+ }
163
+
164
+ return snippets;
165
+ }
166
+
167
+ /**
168
+ * Ensures the snippets directory exists
169
+ */
170
+ export async function ensureSnippetsDir(projectDir?: string): Promise<string> {
171
+ const dir = projectDir ? join(projectDir, ".opencode", "snippet") : PATHS.SNIPPETS_DIR;
172
+ await mkdir(dir, { recursive: true });
173
+ return dir;
174
+ }
175
+
176
+ /**
177
+ * Creates a new snippet file
178
+ *
179
+ * @param name - The snippet name (without extension)
114
180
  * @param content - The snippet content
115
- * @param aliases - Alternative names for the snippet
181
+ * @param options - Optional metadata (aliases, description)
182
+ * @param projectDir - If provided, creates in project directory; otherwise global
183
+ * @returns The path to the created snippet file
116
184
  */
117
- function registerSnippet(
118
- registry: SnippetRegistry,
185
+ export async function createSnippet(
119
186
  name: string,
120
187
  content: string,
121
- aliases: string[],
122
- ) {
123
- const key = name.toLowerCase();
124
-
125
- // If snippet already exists, remove all entries with the old content
126
- const oldContent = registry.get(key);
127
- if (oldContent !== undefined) {
128
- for (const [k, v] of registry.entries()) {
129
- if (v === oldContent) {
130
- registry.delete(k);
131
- }
188
+ options: { aliases?: string[]; description?: string } = {},
189
+ projectDir?: string,
190
+ ): Promise<string> {
191
+ const dir = await ensureSnippetsDir(projectDir);
192
+ const filePath = join(dir, `${name}${CONFIG.SNIPPET_EXTENSION}`);
193
+
194
+ // Build frontmatter if we have metadata
195
+ const frontmatter: SnippetFrontmatter = {};
196
+ if (options.aliases?.length) {
197
+ frontmatter.aliases = options.aliases;
198
+ }
199
+ if (options.description) {
200
+ frontmatter.description = options.description;
201
+ }
202
+
203
+ // Create file content with frontmatter if needed
204
+ let fileContent: string;
205
+ if (Object.keys(frontmatter).length > 0) {
206
+ fileContent = matter.stringify(content, frontmatter);
207
+ } else {
208
+ fileContent = content;
209
+ }
210
+
211
+ await writeFile(filePath, fileContent, "utf-8");
212
+ logger.info("Created snippet", { name, path: filePath });
213
+
214
+ return filePath;
215
+ }
216
+
217
+ /**
218
+ * Deletes a snippet file
219
+ *
220
+ * @param name - The snippet name (without extension)
221
+ * @param projectDir - If provided, looks in project directory first; otherwise global
222
+ * @returns The path of the deleted file, or null if not found
223
+ */
224
+ export async function deleteSnippet(name: string, projectDir?: string): Promise<string | null> {
225
+ // Try project directory first if provided
226
+ if (projectDir) {
227
+ const projectPath = join(
228
+ projectDir,
229
+ ".opencode",
230
+ "snippet",
231
+ `${name}${CONFIG.SNIPPET_EXTENSION}`,
232
+ );
233
+ try {
234
+ await unlink(projectPath);
235
+ logger.info("Deleted project snippet", { name, path: projectPath });
236
+ return projectPath;
237
+ } catch {
238
+ // Not found in project, try global
132
239
  }
133
240
  }
134
241
 
135
- // Register with primary name (lowercase)
136
- registry.set(key, content);
242
+ // Try global directory
243
+ const globalPath = join(PATHS.SNIPPETS_DIR, `${name}${CONFIG.SNIPPET_EXTENSION}`);
244
+ try {
245
+ await unlink(globalPath);
246
+ logger.info("Deleted global snippet", { name, path: globalPath });
247
+ return globalPath;
248
+ } catch {
249
+ logger.warn("Snippet not found for deletion", { name });
250
+ return null;
251
+ }
252
+ }
137
253
 
138
- // Register all aliases (lowercase)
139
- for (const alias of aliases) {
140
- registry.set(alias.toLowerCase(), content);
254
+ /**
255
+ * Reloads snippets into the registry from disk
256
+ */
257
+ export async function reloadSnippets(
258
+ registry: SnippetRegistry,
259
+ projectDir?: string,
260
+ ): Promise<void> {
261
+ registry.clear();
262
+ const fresh = await loadSnippets(projectDir);
263
+ for (const [key, value] of fresh) {
264
+ registry.set(key, value);
141
265
  }
142
266
  }
package/src/logger.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { existsSync, mkdirSync, writeFileSync } from "fs";
2
2
  import { join } from "path";
3
- import { OPENCODE_CONFIG_DIR } from "./constants.js";
3
+ import { PATHS } from "./constants.js";
4
4
 
5
5
  /**
6
6
  * Check if debug logging is enabled via environment variable
@@ -14,7 +14,7 @@ export class Logger {
14
14
  private logDir: string;
15
15
 
16
16
  constructor(logDirOverride?: string) {
17
- this.logDir = logDirOverride ?? join(OPENCODE_CONFIG_DIR, "logs", "snippets");
17
+ this.logDir = logDirOverride ?? join(PATHS.CONFIG_DIR, "logs", "snippets");
18
18
  }
19
19
 
20
20
  get enabled(): boolean {
@@ -0,0 +1,29 @@
1
+ import { logger } from "./logger.js";
2
+
3
+ /**
4
+ * Sends a message that will be displayed but ignored by the AI
5
+ * Used for command output that shouldn't trigger AI responses
6
+ *
7
+ * @param client - The OpenCode client instance
8
+ * @param sessionId - The current session ID
9
+ * @param text - The text to display
10
+ */
11
+ export async function sendIgnoredMessage(
12
+ client: any,
13
+ sessionId: string,
14
+ text: string,
15
+ ): Promise<void> {
16
+ try {
17
+ await client.session.prompt({
18
+ path: { id: sessionId },
19
+ body: {
20
+ noReply: true,
21
+ parts: [{ type: "text", text, ignored: true }],
22
+ },
23
+ });
24
+ } catch (error) {
25
+ logger.error("Failed to send ignored message", {
26
+ error: error instanceof Error ? error.message : String(error),
27
+ });
28
+ }
29
+ }
package/src/types.ts CHANGED
@@ -11,9 +11,21 @@ export interface Snippet {
11
11
  }
12
12
 
13
13
  /**
14
- * Snippet registry that maps keys to content
14
+ * Extended snippet info with file metadata
15
15
  */
16
- export type SnippetRegistry = Map<string, string>;
16
+ export interface SnippetInfo {
17
+ name: string;
18
+ content: string;
19
+ aliases: string[];
20
+ description?: string;
21
+ filePath: string;
22
+ source: "global" | "project";
23
+ }
24
+
25
+ /**
26
+ * Snippet registry that maps keys to snippet info
27
+ */
28
+ export type SnippetRegistry = Map<string, SnippetInfo>;
17
29
 
18
30
  /**
19
31
  * Frontmatter data from snippet files