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 +21 -0
- package/README.md +195 -0
- package/index.ts +33 -0
- package/package.json +45 -0
- package/src/constants.ts +37 -0
- package/src/expander.ts +57 -0
- package/src/loader.ts +88 -0
- package/src/logger.ts +121 -0
- package/src/shell.ts +44 -0
- package/src/types.ts +26 -0
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
|
+
}
|
package/src/constants.ts
ADDED
|
@@ -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
|
package/src/expander.ts
ADDED
|
@@ -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
|
+
}
|