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/src/expander.ts CHANGED
@@ -1,25 +1,125 @@
1
1
  import { PATTERNS } from "./constants.js";
2
2
  import { logger } from "./logger.js";
3
- import type { SnippetRegistry } from "./types.js";
3
+ import type { ExpansionResult, ParsedSnippetContent, SnippetRegistry } from "./types.js";
4
4
 
5
5
  /**
6
6
  * Maximum number of times a snippet can be expanded to prevent infinite loops
7
7
  */
8
8
  const MAX_EXPANSION_COUNT = 15;
9
9
 
10
+ /**
11
+ * Tag types for parsing
12
+ */
13
+ type BlockType = "prepend" | "append";
14
+
15
+ /**
16
+ * Parses snippet content to extract inline text and prepend/append blocks
17
+ *
18
+ * Uses a lenient stack-based parser:
19
+ * - Unclosed tags → treat rest of content as block
20
+ * - Nesting → log error, return null (skip expansion)
21
+ * - Multiple blocks → collected in document order
22
+ *
23
+ * @param content - The raw snippet content to parse
24
+ * @returns Parsed content with inline, prepend, and append parts, or null on error
25
+ */
26
+ export function parseSnippetBlocks(content: string): ParsedSnippetContent | null {
27
+ const prepend: string[] = [];
28
+ const append: string[] = [];
29
+ let inline = "";
30
+
31
+ // Regex to find opening and closing tags
32
+ const tagPattern = /<(\/?)(?<tagName>prepend|append)>/gi;
33
+ let lastIndex = 0;
34
+ let currentBlock: { type: BlockType; startIndex: number; contentStart: number } | null = null;
35
+
36
+ let match = tagPattern.exec(content);
37
+ while (match !== null) {
38
+ const isClosing = match[1] === "/";
39
+ const tagName = match.groups?.tagName?.toLowerCase() as BlockType;
40
+ const tagStart = match.index;
41
+ const tagEnd = tagStart + match[0].length;
42
+
43
+ if (isClosing) {
44
+ // Closing tag
45
+ if (currentBlock === null) {
46
+ // Closing tag without opening - ignore it, treat as inline content
47
+ continue;
48
+ }
49
+ if (currentBlock.type !== tagName) {
50
+ // Mismatched closing tag - this is a nesting error
51
+ logger.warn(
52
+ `Mismatched closing tag: expected </${currentBlock.type}>, found </${tagName}>`,
53
+ );
54
+ return null;
55
+ }
56
+ // Extract block content
57
+ const blockContent = content.slice(currentBlock.contentStart, tagStart).trim();
58
+ if (blockContent) {
59
+ if (currentBlock.type === "prepend") {
60
+ prepend.push(blockContent);
61
+ } else {
62
+ append.push(blockContent);
63
+ }
64
+ }
65
+ lastIndex = tagEnd;
66
+ currentBlock = null;
67
+ } else {
68
+ // Opening tag
69
+ if (currentBlock !== null) {
70
+ // Nested opening tag - error
71
+ logger.warn(`Nested tags not allowed: found <${tagName}> inside <${currentBlock.type}>`);
72
+ return null;
73
+ }
74
+ // Add any inline content before this tag
75
+ const inlinePart = content.slice(lastIndex, tagStart);
76
+ inline += inlinePart;
77
+ currentBlock = { type: tagName, startIndex: tagStart, contentStart: tagEnd };
78
+ }
79
+ match = tagPattern.exec(content);
80
+ }
81
+
82
+ // Handle unclosed tag (lenient: treat rest as block content)
83
+ if (currentBlock !== null) {
84
+ const blockContent = content.slice(currentBlock.contentStart).trim();
85
+ if (blockContent) {
86
+ if (currentBlock.type === "prepend") {
87
+ prepend.push(blockContent);
88
+ } else {
89
+ append.push(blockContent);
90
+ }
91
+ }
92
+ } else {
93
+ // Add any remaining inline content
94
+ inline += content.slice(lastIndex);
95
+ }
96
+
97
+ return {
98
+ inline: inline.trim(),
99
+ prepend,
100
+ append,
101
+ };
102
+ }
103
+
10
104
  /**
11
105
  * Expands hashtags in text recursively with loop detection
12
106
  *
107
+ * Returns an ExpansionResult containing the inline-expanded text plus
108
+ * collected prepend/append blocks from all expanded snippets.
109
+ *
13
110
  * @param text - The text containing hashtags to expand
14
111
  * @param registry - The snippet registry to look up hashtags
15
112
  * @param expansionCounts - Map tracking how many times each snippet has been expanded
16
- * @returns The text with all hashtags expanded
113
+ * @returns ExpansionResult with text and collected blocks
17
114
  */
18
115
  export function expandHashtags(
19
116
  text: string,
20
117
  registry: SnippetRegistry,
21
118
  expansionCounts = new Map<string, number>(),
22
- ): string {
119
+ ): ExpansionResult {
120
+ const collectedPrepend: string[] = [];
121
+ const collectedAppend: string[] = [];
122
+
23
123
  let expanded = text;
24
124
  let hasChanges = true;
25
125
 
@@ -31,11 +131,15 @@ export function expandHashtags(
31
131
  // Reset regex state (global flag requires this)
32
132
  PATTERNS.HASHTAG.lastIndex = 0;
33
133
 
134
+ // We need to collect blocks during replacement, so we track them here
135
+ const roundPrepend: string[] = [];
136
+ const roundAppend: string[] = [];
137
+
34
138
  expanded = expanded.replace(PATTERNS.HASHTAG, (match, name) => {
35
139
  const key = name.toLowerCase();
36
140
 
37
- const content = registry.get(key);
38
- if (content === undefined) {
141
+ const snippet = registry.get(key);
142
+ if (snippet === undefined) {
39
143
  // Unknown snippet - leave as-is
40
144
  return match;
41
145
  }
@@ -53,15 +157,69 @@ export function expandHashtags(
53
157
 
54
158
  expansionCounts.set(key, count);
55
159
 
56
- // Recursively expand any hashtags in the snippet content
57
- const result = expandHashtags(content, registry, expansionCounts);
160
+ // Parse the snippet content for blocks
161
+ const parsed = parseSnippetBlocks(snippet.content);
162
+ if (parsed === null) {
163
+ // Parse error - leave hashtag unchanged
164
+ logger.warn(`Failed to parse snippet '${key}', leaving hashtag unchanged`);
165
+ return match;
166
+ }
167
+
168
+ // Collect prepend/append blocks
169
+ roundPrepend.push(...parsed.prepend);
170
+ roundAppend.push(...parsed.append);
58
171
 
59
- return result;
172
+ // Recursively expand any hashtags in the inline content
173
+ const nestedResult = expandHashtags(parsed.inline, registry, expansionCounts);
174
+
175
+ // Collect blocks from nested expansion
176
+ roundPrepend.push(...nestedResult.prepend);
177
+ roundAppend.push(...nestedResult.append);
178
+
179
+ return nestedResult.text;
60
180
  });
61
181
 
182
+ // Add this round's blocks to collected blocks
183
+ collectedPrepend.push(...roundPrepend);
184
+ collectedAppend.push(...roundAppend);
185
+
62
186
  // Only continue if the text actually changed AND no loop was detected
63
187
  hasChanges = expanded !== previous && !loopDetected;
64
188
  }
65
189
 
66
- return expanded;
190
+ return {
191
+ text: expanded,
192
+ prepend: collectedPrepend,
193
+ append: collectedAppend,
194
+ };
195
+ }
196
+
197
+ /**
198
+ * Assembles the final message from an expansion result
199
+ *
200
+ * Joins: prepend blocks + inline text + append blocks
201
+ * with double newlines between non-empty sections.
202
+ *
203
+ * @param result - The expansion result to assemble
204
+ * @returns The final assembled message
205
+ */
206
+ export function assembleMessage(result: ExpansionResult): string {
207
+ const parts: string[] = [];
208
+
209
+ // Add prepend blocks
210
+ if (result.prepend.length > 0) {
211
+ parts.push(result.prepend.join("\n\n"));
212
+ }
213
+
214
+ // Add main text
215
+ if (result.text.trim()) {
216
+ parts.push(result.text);
217
+ }
218
+
219
+ // Add append blocks
220
+ if (result.append.length > 0) {
221
+ parts.push(result.append.join("\n\n"));
222
+ }
223
+
224
+ return parts.join("\n\n");
67
225
  }
@@ -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
+ }