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 +31 -13
- package/index.ts +17 -0
- package/package.json +4 -2
- package/src/commands.ts +336 -0
- package/src/constants.ts +2 -7
- package/src/expander.test.ts +48 -38
- package/src/expander.ts +3 -3
- 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 +14 -2
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
|
|
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
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
}
|
package/src/commands.ts
ADDED
|
@@ -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:
|
|
20
|
+
CONFIG_DIR: join(homedir(), ".config", "opencode"),
|
|
26
21
|
|
|
27
22
|
/** Snippets directory */
|
|
28
|
-
SNIPPETS_DIR: join(
|
|
23
|
+
SNIPPETS_DIR: join(join(homedir(), ".config", "opencode"), "snippet"),
|
|
29
24
|
} as const;
|
|
30
25
|
|
|
31
26
|
/**
|
package/src/expander.test.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
38
|
-
if (
|
|
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
|
});
|
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
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -11,9 +11,21 @@ export interface Snippet {
|
|
|
11
11
|
}
|
|
12
12
|
|
|
13
13
|
/**
|
|
14
|
-
*
|
|
14
|
+
* Extended snippet info with file metadata
|
|
15
15
|
*/
|
|
16
|
-
export
|
|
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
|