opencode-snippets 1.0.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 JosXa
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,195 @@
1
+ # opencode-snippets
2
+
3
+ **Instant inline text expansion for OpenCode** - Type `#snippet` anywhere in your message and watch it transform.
4
+
5
+ ## Why Snippets?
6
+
7
+ OpenCode has powerful `/slash` commands, but they must come first in your message. What if you want to inject context *mid-thought*?
8
+
9
+ ```
10
+ # With slash commands (must be first):
11
+ /git-status Please review my changes
12
+
13
+ # With snippets (anywhere!):
14
+ Please review my changes #git-status and suggest improvements #code-style
15
+ ```
16
+
17
+ **Snippets work like `@file` mentions** - natural, inline, composable. Build complex prompts from reusable pieces without breaking your flow.
18
+
19
+ ## Installation
20
+
21
+ ```bash
22
+ # Add to your opencode.json plugins array:
23
+ "plugins": ["opencode-snippets"]
24
+
25
+ # Then install:
26
+ bun install
27
+ ```
28
+
29
+ ## Quick Start
30
+
31
+ **1. Create a snippet file:**
32
+
33
+ ```bash
34
+ mkdir -p ~/.config/opencode/snippet
35
+ ```
36
+
37
+ **2. Add your first snippet:**
38
+
39
+ `~/.config/opencode/snippet/careful.md`:
40
+ ```markdown
41
+ ---
42
+ aliases: ["safe", "cautious"]
43
+ ---
44
+ Think step by step. Double-check your work before making changes.
45
+ Ask clarifying questions if anything is ambiguous.
46
+ ```
47
+
48
+ **3. Use it anywhere:**
49
+
50
+ ```
51
+ Refactor this function #careful
52
+ ```
53
+
54
+ The LLM receives:
55
+ ```
56
+ Refactor this function Think step by step. Double-check your work before making changes.
57
+ Ask clarifying questions if anything is ambiguous.
58
+ ```
59
+
60
+ ## Features
61
+
62
+ ### Hashtag Expansion
63
+
64
+ Any `#snippet-name` is replaced with the contents of `~/.config/opencode/snippet/snippet-name.md`:
65
+
66
+ ```
67
+ #review-checklist Please check my PR
68
+ ```
69
+
70
+ ### Aliases
71
+
72
+ Define multiple triggers for the same snippet:
73
+
74
+ ```markdown
75
+ ---
76
+ aliases: ["cp", "pick"]
77
+ description: "Git cherry-pick helper"
78
+ ---
79
+ Always pick parent 1 for merge commits.
80
+ ```
81
+
82
+ Now `#cherry-pick`, `#cp`, and `#pick` all expand to the same content.
83
+
84
+ ### Shell Command Substitution
85
+
86
+ Inject live system data with `!`backtick\`` syntax:
87
+
88
+ ```markdown
89
+ Current branch: !`git branch --show-current`
90
+ Last commit: !`git log -1 --oneline`
91
+ Working directory: !`pwd`
92
+ ```
93
+
94
+ Output:
95
+ ```
96
+ Current branch: $ git branch --show-current
97
+ --> main
98
+ Last commit: $ git log -1 --oneline
99
+ --> abc123f feat: add new feature
100
+ Working directory: $ pwd
101
+ --> /home/user/project
102
+ ```
103
+
104
+ ### Recursive Includes
105
+
106
+ Snippets can include other snippets:
107
+
108
+ ```markdown
109
+ # In base-context.md:
110
+ #project-info
111
+ #coding-standards
112
+ #git-conventions
113
+ ```
114
+
115
+ Loop detection prevents infinite recursion.
116
+
117
+ ## Example Snippets
118
+
119
+ ### `~/.config/opencode/snippet/context.md`
120
+ ```markdown
121
+ ---
122
+ aliases: ["ctx"]
123
+ ---
124
+ Project: !`basename $(pwd)`
125
+ Branch: !`git branch --show-current`
126
+ Recent changes: !`git diff --stat HEAD~3 | tail -5`
127
+ ```
128
+
129
+ ### `~/.config/opencode/snippet/review.md`
130
+ ```markdown
131
+ ---
132
+ aliases: ["pr", "check"]
133
+ ---
134
+ Review this code for:
135
+ - Security vulnerabilities
136
+ - Performance issues
137
+ - Code style consistency
138
+ - Missing error handling
139
+ - Test coverage gaps
140
+ ```
141
+
142
+ ### `~/.config/opencode/snippet/minimal.md`
143
+ ```markdown
144
+ ---
145
+ aliases: ["min", "terse"]
146
+ ---
147
+ Be extremely concise. No explanations unless asked.
148
+ ```
149
+
150
+ ## Snippets vs Slash Commands
151
+
152
+ | Feature | `/commands` | `#snippets` |
153
+ |---------|-------------|-------------|
154
+ | Position | Must be first | Anywhere |
155
+ | Multiple per message | No | Yes |
156
+ | Live shell data | Via implementation | Built-in `!\`cmd\`` |
157
+ | Best for | Actions & workflows | Context injection |
158
+
159
+ **Use both together:**
160
+ ```
161
+ /commit #conventional-commits #project-context
162
+ ```
163
+
164
+ ## Configuration
165
+
166
+ ### Snippet Directory
167
+
168
+ All snippets live in `~/.config/opencode/snippet/` as `.md` files.
169
+
170
+ ### Debug Logging
171
+
172
+ Enable debug logs by setting an environment variable:
173
+
174
+ ```bash
175
+ DEBUG_SNIPPETS=true opencode
176
+ ```
177
+
178
+ Logs are written to `~/.config/opencode/logs/snippets/daily/`.
179
+
180
+ ## Behavior Notes
181
+
182
+ - Snippets are loaded once at plugin startup
183
+ - Hashtag matching is **case-insensitive** (`#Hello` = `#hello`)
184
+ - Unknown hashtags are left unchanged
185
+ - Failed shell commands preserve the `!\`cmd\`` syntax
186
+ - Frontmatter is stripped from expanded content
187
+ - Only user messages are processed (not assistant responses)
188
+
189
+ ## Contributing
190
+
191
+ Contributions welcome! Please open an issue or PR on GitHub.
192
+
193
+ ## License
194
+
195
+ MIT
package/index.ts ADDED
@@ -0,0 +1,33 @@
1
+ import type { Plugin } from "@opencode-ai/plugin"
2
+ import { loadSnippets } from "./src/loader.js"
3
+ import { expandHashtags } from "./src/expander.js"
4
+ import { executeShellCommands } from "./src/shell.js"
5
+
6
+ /**
7
+ * Snippets Plugin for OpenCode
8
+ *
9
+ * Expands hashtag-based shortcuts in user messages into predefined text snippets.
10
+ *
11
+ * @see https://github.com/JosXa/opencode-snippets for full documentation
12
+ */
13
+ export const SnippetsPlugin: Plugin = async (ctx) => {
14
+ // Load all snippets at startup
15
+ const snippets = await loadSnippets()
16
+
17
+ return {
18
+ "chat.message": async (input, output) => {
19
+ // Only process user messages, never assistant messages
20
+ if (output.message.role !== "user") return
21
+
22
+ for (const part of output.parts) {
23
+ if (part.type === "text" && part.text) {
24
+ // 1. Expand hashtags recursively with loop detection
25
+ part.text = expandHashtags(part.text, snippets)
26
+
27
+ // 2. Execute shell commands: !`command`
28
+ part.text = await executeShellCommands(part.text, ctx)
29
+ }
30
+ }
31
+ }
32
+ }
33
+ }
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "opencode-snippets",
3
+ "version": "1.0.0",
4
+ "description": "Hashtag-based snippet expansion plugin for OpenCode - instant inline text shortcuts",
5
+ "main": "index.ts",
6
+ "type": "module",
7
+ "engines": {
8
+ "bun": ">=1.0.0"
9
+ },
10
+ "keywords": [
11
+ "opencode",
12
+ "opencode-plugin",
13
+ "plugin",
14
+ "snippets",
15
+ "templates",
16
+ "shortcuts",
17
+ "ai",
18
+ "coding-assistant"
19
+ ],
20
+ "author": "JosXa <info@josxa.dev>",
21
+ "license": "MIT",
22
+ "repository": {
23
+ "type": "git",
24
+ "url": "git+https://github.com/JosXa/opencode-snippets.git"
25
+ },
26
+ "bugs": {
27
+ "url": "https://github.com/JosXa/opencode-snippets/issues"
28
+ },
29
+ "homepage": "https://github.com/JosXa/opencode-snippets#readme",
30
+ "dependencies": {
31
+ "gray-matter": "^4.0.3"
32
+ },
33
+ "peerDependencies": {
34
+ "@opencode-ai/plugin": ">=1.0.0"
35
+ },
36
+ "devDependencies": {
37
+ "@types/bun": "latest",
38
+ "@types/node": "^22",
39
+ "typescript": "^5"
40
+ },
41
+ "files": [
42
+ "index.ts",
43
+ "src/**/*.ts"
44
+ ]
45
+ }
@@ -0,0 +1,37 @@
1
+ import { join } from "node:path"
2
+ import { homedir } from "node:os"
3
+
4
+ /**
5
+ * Regular expression patterns used throughout the plugin
6
+ */
7
+ export const PATTERNS = {
8
+ /** Matches hashtags like #snippet-name */
9
+ HASHTAG: /#([a-z0-9\-_]+)/gi,
10
+
11
+ /** Matches shell commands like !`command` */
12
+ SHELL_COMMAND: /!`([^`]+)`/g,
13
+ } as const
14
+
15
+ /**
16
+ * OpenCode configuration directory
17
+ */
18
+ export const OPENCODE_CONFIG_DIR = join(homedir(), ".config", "opencode")
19
+
20
+ /**
21
+ * File system paths
22
+ */
23
+ export const PATHS = {
24
+ /** OpenCode configuration directory */
25
+ CONFIG_DIR: OPENCODE_CONFIG_DIR,
26
+
27
+ /** Snippets directory */
28
+ SNIPPETS_DIR: join(OPENCODE_CONFIG_DIR, "snippet"),
29
+ } as const
30
+
31
+ /**
32
+ * Plugin configuration
33
+ */
34
+ export const CONFIG = {
35
+ /** File extension for snippet files */
36
+ SNIPPET_EXTENSION: ".md",
37
+ } as const
@@ -0,0 +1,57 @@
1
+ import type { SnippetRegistry } from "./types.js"
2
+ import { PATTERNS } from "./constants.js"
3
+
4
+ /**
5
+ * Expands hashtags in text recursively with loop detection
6
+ *
7
+ * @param text - The text containing hashtags to expand
8
+ * @param registry - The snippet registry to look up hashtags
9
+ * @param visited - Set of already-visited snippet keys (for loop detection)
10
+ * @returns The text with all hashtags expanded
11
+ */
12
+ export function expandHashtags(
13
+ text: string,
14
+ registry: SnippetRegistry,
15
+ visited = new Set<string>()
16
+ ): string {
17
+ let expanded = text
18
+ let hasChanges = true
19
+
20
+ // Keep expanding until no more hashtags are found
21
+ while (hasChanges) {
22
+ hasChanges = false
23
+
24
+ // Reset regex state (global flag requires this)
25
+ PATTERNS.HASHTAG.lastIndex = 0
26
+
27
+ expanded = expanded.replace(PATTERNS.HASHTAG, (match, name) => {
28
+ const key = name.toLowerCase()
29
+
30
+ // Check if we've already expanded this snippet in the current chain
31
+ if (visited.has(key)) {
32
+ // Loop detected! Leave the hashtag unchanged to prevent infinite recursion
33
+ return match
34
+ }
35
+
36
+ const content = registry.get(key)
37
+ if (!content) {
38
+ // Unknown snippet - leave as-is
39
+ return match
40
+ }
41
+
42
+ // Mark this snippet as visited and expand it
43
+ visited.add(key)
44
+ hasChanges = true
45
+
46
+ // Recursively expand any hashtags in the snippet content
47
+ const result = expandHashtags(content, registry, new Set(visited))
48
+
49
+ // Remove from visited set after expansion (allows reuse in different branches)
50
+ visited.delete(key)
51
+
52
+ return result
53
+ })
54
+ }
55
+
56
+ return expanded
57
+ }
package/src/loader.ts ADDED
@@ -0,0 +1,88 @@
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"
7
+
8
+ /**
9
+ * Loads all snippets from the snippets directory
10
+ *
11
+ * @returns A map of snippet keys (lowercase) to their content
12
+ */
13
+ export async function loadSnippets(): Promise<SnippetRegistry> {
14
+ const snippets: SnippetRegistry = new Map()
15
+
16
+ try {
17
+ const files = await readdir(PATHS.SNIPPETS_DIR)
18
+
19
+ for (const file of files) {
20
+ if (!file.endsWith(CONFIG.SNIPPET_EXTENSION)) continue
21
+
22
+ const snippet = await loadSnippetFile(file)
23
+ if (snippet) {
24
+ registerSnippet(snippets, snippet.name, snippet.content, snippet.aliases)
25
+ }
26
+ }
27
+ } catch (error) {
28
+ // 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
34
+ }
35
+
36
+ return snippets
37
+ }
38
+
39
+ /**
40
+ * Loads and parses a single snippet file
41
+ *
42
+ * @param filename - The filename to load (e.g., "my-snippet.md")
43
+ * @returns The parsed snippet data, or null if parsing failed
44
+ */
45
+ async function loadSnippetFile(filename: string) {
46
+ 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 }
57
+ } catch (error) {
58
+ // Failed to read or parse this snippet - skip it
59
+ logger.warn("Failed to load snippet file", {
60
+ filename,
61
+ error: error instanceof Error ? error.message : String(error)
62
+ })
63
+ return null
64
+ }
65
+ }
66
+
67
+ /**
68
+ * 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
72
+ * @param content - The snippet content
73
+ * @param aliases - Alternative names for the snippet
74
+ */
75
+ function registerSnippet(
76
+ registry: SnippetRegistry,
77
+ name: string,
78
+ 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)
87
+ }
88
+ }
package/src/logger.ts ADDED
@@ -0,0 +1,121 @@
1
+ import { writeFileSync, mkdirSync, existsSync } from "fs"
2
+ import { join } from "path"
3
+ import { OPENCODE_CONFIG_DIR } from "./constants.js"
4
+
5
+ /**
6
+ * Check if debug logging is enabled via environment variable
7
+ */
8
+ function isDebugEnabled(): boolean {
9
+ const value = process.env.DEBUG_SNIPPETS
10
+ return value === "1" || value === "true"
11
+ }
12
+
13
+ export class Logger {
14
+ private logDir: string
15
+
16
+ constructor() {
17
+ this.logDir = join(OPENCODE_CONFIG_DIR, "logs", "snippets")
18
+ }
19
+
20
+ private get enabled(): boolean {
21
+ return isDebugEnabled()
22
+ }
23
+
24
+ private ensureLogDir() {
25
+ if (!existsSync(this.logDir)) {
26
+ mkdirSync(this.logDir, { recursive: true })
27
+ }
28
+ }
29
+
30
+ private formatData(data?: any): string {
31
+ if (!data) return ""
32
+
33
+ const parts: string[] = []
34
+ for (const [key, value] of Object.entries(data)) {
35
+ if (value === undefined || value === null) continue
36
+
37
+ // Format arrays compactly
38
+ if (Array.isArray(value)) {
39
+ if (value.length === 0) continue
40
+ parts.push(`${key}=[${value.slice(0, 3).join(",")}${value.length > 3 ? `...+${value.length - 3}` : ""}]`)
41
+ }
42
+ else if (typeof value === 'object') {
43
+ const str = JSON.stringify(value)
44
+ if (str.length < 50) {
45
+ parts.push(`${key}=${str}`)
46
+ }
47
+ }
48
+ else {
49
+ parts.push(`${key}=${value}`)
50
+ }
51
+ }
52
+ return parts.join(" ")
53
+ }
54
+
55
+ private getCallerFile(): string {
56
+ const originalPrepareStackTrace = Error.prepareStackTrace
57
+ try {
58
+ const err = new Error()
59
+ Error.prepareStackTrace = (_, stack) => stack
60
+ const stack = err.stack as unknown as NodeJS.CallSite[]
61
+ Error.prepareStackTrace = originalPrepareStackTrace
62
+
63
+ for (let i = 3; i < stack.length; i++) {
64
+ const filename = stack[i]?.getFileName()
65
+ if (filename && !filename.includes('logger.')) {
66
+ const match = filename.match(/([^/\\]+)\.[tj]s$/)
67
+ return match ? match[1] : 'unknown'
68
+ }
69
+ }
70
+ return 'unknown'
71
+ } catch {
72
+ return 'unknown'
73
+ }
74
+ }
75
+
76
+ private write(level: string, component: string, message: string, data?: any) {
77
+ if (!this.enabled) return
78
+
79
+ try {
80
+ this.ensureLogDir()
81
+
82
+ const timestamp = new Date().toISOString()
83
+ const dataStr = this.formatData(data)
84
+
85
+ const dailyLogDir = join(this.logDir, "daily")
86
+ if (!existsSync(dailyLogDir)) {
87
+ mkdirSync(dailyLogDir, { recursive: true })
88
+ }
89
+
90
+ const logLine = `${timestamp} ${level.padEnd(5)} ${component}: ${message}${dataStr ? " | " + dataStr : ""}\n`
91
+
92
+ const logFile = join(dailyLogDir, `${new Date().toISOString().split('T')[0]}.log`)
93
+ writeFileSync(logFile, logLine, { flag: "a" })
94
+ } catch (error) {
95
+ // Silent fail
96
+ }
97
+ }
98
+
99
+ info(message: string, data?: any) {
100
+ const component = this.getCallerFile()
101
+ this.write("INFO", component, message, data)
102
+ }
103
+
104
+ debug(message: string, data?: any) {
105
+ const component = this.getCallerFile()
106
+ this.write("DEBUG", component, message, data)
107
+ }
108
+
109
+ warn(message: string, data?: any) {
110
+ const component = this.getCallerFile()
111
+ this.write("WARN", component, message, data)
112
+ }
113
+
114
+ error(message: string, data?: any) {
115
+ const component = this.getCallerFile()
116
+ this.write("ERROR", component, message, data)
117
+ }
118
+ }
119
+
120
+ // Export singleton logger instance
121
+ export const logger = new Logger()
package/src/shell.ts ADDED
@@ -0,0 +1,44 @@
1
+ import { PATTERNS } from "./constants.js"
2
+ import { logger } from "./logger.js"
3
+
4
+ /**
5
+ * Executes shell commands in text using !`command` syntax
6
+ *
7
+ * @param text - The text containing shell commands to execute
8
+ * @param ctx - The plugin context (with Bun shell)
9
+ * @returns The text with shell commands replaced by their output
10
+ */
11
+ export async function executeShellCommands(
12
+ text: string,
13
+ ctx: any
14
+ ): Promise<string> {
15
+ let result = text
16
+
17
+ // Reset regex state (global flag requires this)
18
+ PATTERNS.SHELL_COMMAND.lastIndex = 0
19
+
20
+ // Find all shell command matches
21
+ const matches = [...text.matchAll(PATTERNS.SHELL_COMMAND)]
22
+
23
+ // Execute each command and replace in text
24
+ for (const match of matches) {
25
+ const cmd = match[1]
26
+ const placeholder = match[0]
27
+
28
+ try {
29
+ const output = await ctx.$`${{ raw: cmd }}`.quiet().nothrow().text()
30
+ // Format like slash commands: print command first, then output
31
+ const replacement = `$ ${cmd}\n--> ${output.trim()}`
32
+ result = result.replace(placeholder, replacement)
33
+ } catch (error) {
34
+ // If shell command fails, leave it as-is
35
+ // This preserves the original syntax for debugging
36
+ logger.warn("Shell command execution failed", {
37
+ command: cmd,
38
+ error: error instanceof Error ? error.message : String(error)
39
+ })
40
+ }
41
+ }
42
+
43
+ return result
44
+ }
package/src/types.ts ADDED
@@ -0,0 +1,26 @@
1
+ /**
2
+ * A snippet with its content and metadata
3
+ */
4
+ export interface Snippet {
5
+ /** The primary name/key of the snippet */
6
+ name: string
7
+ /** The content of the snippet (without frontmatter) */
8
+ content: string
9
+ /** Alternative names that also trigger this snippet */
10
+ aliases: string[]
11
+ }
12
+
13
+ /**
14
+ * Snippet registry that maps keys to content
15
+ */
16
+ export type SnippetRegistry = Map<string, string>
17
+
18
+ /**
19
+ * Frontmatter data from snippet files
20
+ */
21
+ export interface SnippetFrontmatter {
22
+ /** Alternative hashtags for this snippet */
23
+ aliases?: string[]
24
+ /** Optional description of what this snippet does */
25
+ description?: string
26
+ }