opencode-snippets 1.1.2 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,261 @@
1
+ import { mkdir, rm, writeFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { loadSnippets } from "../src/loader.js";
4
+
5
+ describe("loadSnippets - Dual Path Support", () => {
6
+ let tempDir: string;
7
+ let globalSnippetDir: string;
8
+ let projectSnippetDir: string;
9
+
10
+ beforeEach(async () => {
11
+ // Create temporary directories for testing
12
+ tempDir = join(process.cwd(), ".test-temp");
13
+ globalSnippetDir = join(tempDir, "global-snippet");
14
+ projectSnippetDir = join(tempDir, "project", ".opencode", "snippet");
15
+
16
+ await mkdir(globalSnippetDir, { recursive: true });
17
+ await mkdir(projectSnippetDir, { recursive: true });
18
+ });
19
+
20
+ afterEach(async () => {
21
+ // Clean up temporary directories
22
+ await rm(tempDir, { recursive: true, force: true });
23
+ });
24
+
25
+ describe("Global snippets only", () => {
26
+ it("should load snippets with aliases", async () => {
27
+ await writeFile(
28
+ join(globalSnippetDir, "careful.md"),
29
+ `---
30
+ aliases: safe
31
+ ---
32
+ Think step by step. Double-check your work.`,
33
+ );
34
+
35
+ const snippets = await loadSnippets(undefined, globalSnippetDir);
36
+
37
+ expect(snippets.size).toBe(2);
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
+ });
41
+
42
+ it("should load multiple snippets from global directory", async () => {
43
+ await writeFile(join(globalSnippetDir, "snippet1.md"), "Content of snippet 1");
44
+ await writeFile(join(globalSnippetDir, "snippet2.md"), "Content of snippet 2");
45
+
46
+ const snippets = await loadSnippets(undefined, globalSnippetDir);
47
+
48
+ expect(snippets.size).toBe(2);
49
+ expect(snippets.get("snippet1")?.content).toBe("Content of snippet 1");
50
+ expect(snippets.get("snippet2")?.content).toBe("Content of snippet 2");
51
+ });
52
+ });
53
+
54
+ describe("Project snippets only", () => {
55
+ it("should load snippets from project directory", async () => {
56
+ await writeFile(
57
+ join(projectSnippetDir, "project-specific.md"),
58
+ "This is a project-specific snippet",
59
+ );
60
+
61
+ // Load with project directory
62
+ const projectDir = join(tempDir, "project");
63
+ const snippets = await loadSnippets(projectDir, globalSnippetDir);
64
+
65
+ expect(snippets.size).toBe(1);
66
+ expect(snippets.get("project-specific")?.content).toBe("This is a project-specific snippet");
67
+ });
68
+
69
+ it("should handle missing global directory when project exists", async () => {
70
+ await writeFile(join(projectSnippetDir, "team-rule.md"), "Team rule 1");
71
+ await writeFile(join(projectSnippetDir, "domain-knowledge.md"), "Domain knowledge");
72
+
73
+ const projectDir = join(tempDir, "project");
74
+ const snippets = await loadSnippets(projectDir, globalSnippetDir);
75
+
76
+ expect(snippets.size).toBe(2);
77
+ expect(snippets.get("team-rule")?.content).toBe("Team rule 1");
78
+ expect(snippets.get("domain-knowledge")?.content).toBe("Domain knowledge");
79
+ });
80
+ });
81
+
82
+ describe("Both global and project snippets", () => {
83
+ it("should merge global and project snippets", async () => {
84
+ // Create global snippet
85
+ await writeFile(join(globalSnippetDir, "global.md"), "Global snippet content");
86
+
87
+ // Create project snippet
88
+ await writeFile(join(projectSnippetDir, "project.md"), "Project snippet content");
89
+
90
+ const projectDir = join(tempDir, "project");
91
+ const snippets = await loadSnippets(projectDir, globalSnippetDir);
92
+
93
+ expect(snippets.size).toBe(2);
94
+ expect(snippets.get("global")?.content).toBe("Global snippet content");
95
+ expect(snippets.get("project")?.content).toBe("Project snippet content");
96
+ });
97
+
98
+ it("should allow project snippets to override global snippets", async () => {
99
+ // Create global snippet
100
+ await writeFile(join(globalSnippetDir, "careful.md"), "Global careful content");
101
+
102
+ // Create project snippet with same name
103
+ await writeFile(join(projectSnippetDir, "careful.md"), "Project-specific careful content");
104
+
105
+ const projectDir = join(tempDir, "project");
106
+ const snippets = await loadSnippets(projectDir, globalSnippetDir);
107
+
108
+ // Project snippet should override global
109
+ expect(snippets.get("careful")?.content).toBe("Project-specific careful content");
110
+ expect(snippets.size).toBe(1);
111
+ });
112
+ });
113
+
114
+ describe("Alias handling", () => {
115
+ it("should handle multiple aliases from different sources", async () => {
116
+ // Global snippet with aliases
117
+ await writeFile(
118
+ join(globalSnippetDir, "review.md"),
119
+ `---
120
+ aliases:
121
+ - pr
122
+ - check
123
+ ---
124
+ Global review guidelines`,
125
+ );
126
+
127
+ // Project snippet with aliases
128
+ await writeFile(
129
+ join(projectSnippetDir, "test.md"),
130
+ `---
131
+ aliases:
132
+ - tdd
133
+ - testing
134
+ ---
135
+ Project test guidelines`,
136
+ );
137
+
138
+ const projectDir = join(tempDir, "project");
139
+ const snippets = await loadSnippets(projectDir, globalSnippetDir);
140
+
141
+ expect(snippets.size).toBe(6); // review, pr, check, test, tdd, testing
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
+ });
149
+
150
+ it("should allow project to override global aliases", async () => {
151
+ // Global snippet with aliases
152
+ await writeFile(
153
+ join(globalSnippetDir, "careful.md"),
154
+ `---
155
+ aliases:
156
+ - safe
157
+ - cautious
158
+ ---
159
+ Global careful`,
160
+ );
161
+
162
+ // Project snippet with same name but different aliases
163
+ await writeFile(
164
+ join(projectSnippetDir, "careful.md"),
165
+ `---
166
+ aliases: safe
167
+ ---
168
+ Project careful`,
169
+ );
170
+
171
+ const projectDir = join(tempDir, "project");
172
+ const snippets = await loadSnippets(projectDir, globalSnippetDir);
173
+
174
+ // Project should override with its aliases
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
+ expect(snippets.size).toBe(2);
179
+ });
180
+ });
181
+
182
+ describe("Edge cases", () => {
183
+ it("should handle empty snippet content", async () => {
184
+ await writeFile(join(globalSnippetDir, "empty.md"), "");
185
+
186
+ const snippets = await loadSnippets(undefined, globalSnippetDir);
187
+ expect(snippets.size).toBe(1);
188
+ expect(snippets.get("empty")?.content).toBe("");
189
+ });
190
+
191
+ it("should handle snippet with only metadata", async () => {
192
+ await writeFile(
193
+ join(globalSnippetDir, "metadata-only.md"),
194
+ `---
195
+ description: "A snippet with only metadata"
196
+ aliases: meta
197
+ ---`,
198
+ );
199
+
200
+ const snippets = await loadSnippets(undefined, globalSnippetDir);
201
+ expect(snippets.size).toBe(2);
202
+ expect(snippets.get("metadata-only")?.content).toBe("");
203
+ expect(snippets.get("meta")?.content).toBe("");
204
+ });
205
+
206
+ it("should handle multiline content", async () => {
207
+ await writeFile(
208
+ join(globalSnippetDir, "multiline.md"),
209
+ `Line 1
210
+ Line 2
211
+ Line 3`,
212
+ );
213
+
214
+ const snippets = await loadSnippets(undefined, globalSnippetDir);
215
+ expect(snippets.get("multiline")?.content).toBe("Line 1\nLine 2\nLine 3");
216
+ });
217
+
218
+ it("should ignore non-.md files", async () => {
219
+ await writeFile(join(globalSnippetDir, "not-a-snippet.txt"), "This should be ignored");
220
+ await writeFile(join(globalSnippetDir, "valid.md"), "This should be loaded");
221
+
222
+ const snippets = await loadSnippets(undefined, globalSnippetDir);
223
+ expect(snippets.size).toBe(1);
224
+ expect(snippets.get("valid")?.content).toBe("This should be loaded");
225
+ expect(snippets.has("not-a-snippet")).toBe(false);
226
+ });
227
+
228
+ it("should handle invalid frontmatter", async () => {
229
+ await writeFile(
230
+ join(globalSnippetDir, "bad-frontmatter.md"),
231
+ `---
232
+ invalid yaml
233
+ ---
234
+ Content`,
235
+ );
236
+
237
+ await writeFile(join(globalSnippetDir, "special-chars.md"), "Special content");
238
+
239
+ const snippets = await loadSnippets(undefined, globalSnippetDir);
240
+ // Should load valid snippet, skip invalid one
241
+ expect(snippets.get("special-chars")?.content).toBe("Special content");
242
+ });
243
+
244
+ it("should handle non-existent directories gracefully", async () => {
245
+ const snippets = await loadSnippets(undefined, "/nonexistent/path");
246
+ expect(snippets.size).toBe(0);
247
+ });
248
+ });
249
+
250
+ describe("Smoke test - real global snippets", () => {
251
+ it("should load real global snippets without crashing", async () => {
252
+ // This is a smoke test - just verify it doesn't crash
253
+ const snippets = await loadSnippets();
254
+
255
+ // We don't assert specific content since we don't control user's global snippets
256
+ // Just verify it returns a Map
257
+ expect(snippets).toBeInstanceOf(Map);
258
+ expect(Array.isArray(Array.from(snippets.keys()))).toBe(true);
259
+ });
260
+ });
261
+ });
package/src/loader.ts CHANGED
@@ -1,88 +1,266 @@
1
- import { readdir, readFile } from "node:fs/promises"
2
- import { join, basename } from "node:path"
3
- import matter from "gray-matter"
4
- import type { SnippetRegistry, SnippetFrontmatter } from "./types.js"
5
- import { PATHS, CONFIG } from "./constants.js"
6
- import { logger } from "./logger.js"
1
+ import { mkdir, readdir, readFile, unlink, writeFile } from "node:fs/promises";
2
+ import { basename, join } from "node:path";
3
+ import matter from "gray-matter";
4
+ import { CONFIG, PATHS } from "./constants.js";
5
+ import { logger } from "./logger.js";
6
+ import type { SnippetFrontmatter, SnippetInfo, SnippetRegistry } from "./types.js";
7
7
 
8
8
  /**
9
- * Loads all snippets from the snippets directory
10
- *
11
- * @returns A map of snippet keys (lowercase) to their content
9
+ * Loads all snippets from global and project directories
10
+ *
11
+ * @param projectDir - Optional project directory path (from ctx.directory)
12
+ * @param globalDir - Optional global snippets directory (for testing)
13
+ * @returns A map of snippet keys (lowercase) to their SnippetInfo
12
14
  */
13
- export async function loadSnippets(): Promise<SnippetRegistry> {
14
- const snippets: SnippetRegistry = new Map()
15
-
15
+ export async function loadSnippets(
16
+ projectDir?: string,
17
+ globalDir?: string,
18
+ ): Promise<SnippetRegistry> {
19
+ const snippets: SnippetRegistry = new Map();
20
+
21
+ // Load from global directory first (use provided or default)
22
+ const globalSnippetsDir = globalDir ?? PATHS.SNIPPETS_DIR;
23
+ await loadFromDirectory(globalSnippetsDir, snippets, "global");
24
+
25
+ // Load from project directory if provided (overrides global)
26
+ if (projectDir) {
27
+ const projectSnippetsDir = join(projectDir, ".opencode", "snippet");
28
+ await loadFromDirectory(projectSnippetsDir, snippets, "project");
29
+ }
30
+
31
+ return snippets;
32
+ }
33
+
34
+ /**
35
+ * Loads snippets from a specific directory
36
+ *
37
+ * @param dir - Directory to load snippets from
38
+ * @param registry - Registry to populate
39
+ * @param source - Source label for logging
40
+ */
41
+ async function loadFromDirectory(
42
+ dir: string,
43
+ registry: SnippetRegistry,
44
+ source: "global" | "project",
45
+ ): Promise<void> {
16
46
  try {
17
- const files = await readdir(PATHS.SNIPPETS_DIR)
18
-
47
+ const files = await readdir(dir);
48
+
19
49
  for (const file of files) {
20
- if (!file.endsWith(CONFIG.SNIPPET_EXTENSION)) continue
21
-
22
- const snippet = await loadSnippetFile(file)
50
+ if (!file.endsWith(CONFIG.SNIPPET_EXTENSION)) continue;
51
+
52
+ const snippet = await loadSnippetFile(dir, file, source);
23
53
  if (snippet) {
24
- registerSnippet(snippets, snippet.name, snippet.content, snippet.aliases)
54
+ registerSnippet(registry, snippet);
25
55
  }
26
56
  }
57
+
58
+ logger.debug(`Loaded snippets from ${source} directory`, {
59
+ path: dir,
60
+ fileCount: files.length,
61
+ });
27
62
  } catch (error) {
28
63
  // Snippets directory doesn't exist or can't be read - that's fine
29
- logger.info("Snippets directory not found or unreadable", {
30
- path: PATHS.SNIPPETS_DIR,
31
- error: error instanceof Error ? error.message : String(error)
32
- })
33
- // Return empty registry
64
+ logger.debug(`${source} snippets directory not found or unreadable`, {
65
+ path: dir,
66
+ error: error instanceof Error ? error.message : String(error),
67
+ });
34
68
  }
35
-
36
- return snippets
37
69
  }
38
70
 
39
71
  /**
40
72
  * Loads and parses a single snippet file
41
- *
73
+ *
74
+ * @param dir - Directory containing the snippet file
42
75
  * @param filename - The filename to load (e.g., "my-snippet.md")
43
- * @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
44
78
  */
45
- async function loadSnippetFile(filename: string) {
79
+ async function loadSnippetFile(
80
+ dir: string,
81
+ filename: string,
82
+ source: "global" | "project",
83
+ ): Promise<SnippetInfo | null> {
46
84
  try {
47
- const name = basename(filename, CONFIG.SNIPPET_EXTENSION)
48
- const filePath = join(PATHS.SNIPPETS_DIR, filename)
49
- const fileContent = await readFile(filePath, "utf-8")
50
- const parsed = matter(fileContent)
51
-
52
- const content = parsed.content.trim()
53
- const frontmatter = parsed.data as SnippetFrontmatter
54
- const aliases = Array.isArray(frontmatter.aliases) ? frontmatter.aliases : []
55
-
56
- return { name, content, aliases }
85
+ const name = basename(filename, CONFIG.SNIPPET_EXTENSION);
86
+ const filePath = join(dir, filename);
87
+ const fileContent = await readFile(filePath, "utf-8");
88
+ const parsed = matter(fileContent);
89
+
90
+ const content = parsed.content.trim();
91
+ const frontmatter = parsed.data as SnippetFrontmatter;
92
+
93
+ // Handle aliases as string or array
94
+ let aliases: string[] = [];
95
+ if (frontmatter.aliases) {
96
+ if (Array.isArray(frontmatter.aliases)) {
97
+ aliases = frontmatter.aliases;
98
+ } else {
99
+ aliases = [frontmatter.aliases];
100
+ }
101
+ }
102
+
103
+ return {
104
+ name,
105
+ content,
106
+ aliases,
107
+ description: frontmatter.description,
108
+ filePath,
109
+ source,
110
+ };
57
111
  } catch (error) {
58
112
  // Failed to read or parse this snippet - skip it
59
- logger.warn("Failed to load snippet file", {
113
+ logger.warn("Failed to load snippet file", {
60
114
  filename,
61
- error: error instanceof Error ? error.message : String(error)
62
- })
63
- return null
115
+ error: error instanceof Error ? error.message : String(error),
116
+ });
117
+ return null;
64
118
  }
65
119
  }
66
120
 
67
121
  /**
68
122
  * Registers a snippet and its aliases in the registry
69
- *
70
- * @param registry - The snippet registry to update
71
- * @param name - The primary name of the snippet
123
+ *
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)
72
180
  * @param content - The snippet content
73
- * @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
74
184
  */
75
- function registerSnippet(
76
- registry: SnippetRegistry,
185
+ export async function createSnippet(
77
186
  name: string,
78
187
  content: string,
79
- aliases: string[]
80
- ) {
81
- // Register with primary name (lowercase)
82
- registry.set(name.toLowerCase(), content)
83
-
84
- // Register all aliases (lowercase)
85
- for (const alias of aliases) {
86
- registry.set(alias.toLowerCase(), content)
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
239
+ }
240
+ }
241
+
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
+ }
253
+
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);
87
265
  }
88
266
  }