opencode-snippets 1.2.0 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +73 -13
- package/index.ts +20 -2
- package/package.json +4 -2
- package/src/commands.ts +335 -0
- package/src/constants.ts +2 -7
- package/src/expander.test.ts +468 -86
- package/src/expander.ts +167 -9
- package/src/loader.test.ts +25 -25
- package/src/loader.ts +154 -30
- package/src/logger.ts +2 -2
- package/src/notification.ts +29 -0
- package/src/types.ts +38 -2
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
|
|
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
|
-
):
|
|
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
|
|
38
|
-
if (
|
|
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
|
-
//
|
|
57
|
-
const
|
|
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
|
-
|
|
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
|
|
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
|
}
|
package/src/loader.test.ts
CHANGED
|
@@ -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
|
|
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:
|
|
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
|
|
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
|
-
* @
|
|
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(
|
|
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 {
|
|
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
|
|
113
|
-
* @param
|
|
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
|
|
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
|
|
118
|
-
registry: SnippetRegistry,
|
|
185
|
+
export async function createSnippet(
|
|
119
186
|
name: string,
|
|
120
187
|
content: string,
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
if
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
//
|
|
136
|
-
|
|
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
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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 {
|
|
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(
|
|
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
|
+
}
|