@ariane-emory/must-have-plugin 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 +213 -0
- package/dist/index.d.ts +27 -0
- package/dist/index.js +270 -0
- package/package.json +53 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ariane Emory
|
|
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,213 @@
|
|
|
1
|
+
# A MUST-have plugin
|
|
2
|
+
|
|
3
|
+
Automatically replaces text patterns in your prompts before they're sent to the LLM.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
### From npm
|
|
8
|
+
|
|
9
|
+
Add the published package to your OpenCode config:
|
|
10
|
+
|
|
11
|
+
```json
|
|
12
|
+
{
|
|
13
|
+
"$schema": "https://opencode.ai/config.json",
|
|
14
|
+
"plugin": ["@ariane-emory/must-have-plugin"]
|
|
15
|
+
}
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
OpenCode installs npm plugins automatically at startup.
|
|
19
|
+
|
|
20
|
+
### From Source
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
npm install
|
|
24
|
+
npm run build
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Then copy the built file into your OpenCode plugins directory:
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
mkdir -p ~/.config/opencode/plugins
|
|
31
|
+
cp dist/index.js ~/.config/opencode/plugins/MUST-have-plugin.js
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## What It Does
|
|
35
|
+
|
|
36
|
+
Performs case-insensitive string replacements on user-typed prompts. The primary use case is auto-capitalizing RFC2119 keywords (MUST, SHOULD, MAY, etc.) in technical specifications.
|
|
37
|
+
|
|
38
|
+
**Example**: Typing `"the system must validate input"` becomes `"the system MUST validate input"`.
|
|
39
|
+
|
|
40
|
+
### Features
|
|
41
|
+
|
|
42
|
+
- **Case-insensitive matching**: `must`, `Must`, and `MUST` all match
|
|
43
|
+
- **Word boundary aware**: Won't replace `may` inside `maybe`
|
|
44
|
+
- **Multi-word phrases**: `must not` is matched as a unit (before `must` alone)
|
|
45
|
+
- **Hot reload**: Config changes take effect immediately (no restart needed)
|
|
46
|
+
- **JSONC support**: Comments and trailing commas allowed in config file
|
|
47
|
+
|
|
48
|
+
### Scope
|
|
49
|
+
|
|
50
|
+
- Only replaces text in user-typed prompts
|
|
51
|
+
- Does NOT modify file content attached via `@` mentions
|
|
52
|
+
- Does NOT modify slash command output
|
|
53
|
+
|
|
54
|
+
## Configuration
|
|
55
|
+
|
|
56
|
+
**Config file**: `~/.config/opencode/MUST-have-plugin.jsonc`
|
|
57
|
+
|
|
58
|
+
If the config file doesn't exist, it's automatically created with RFC2119 defaults.
|
|
59
|
+
|
|
60
|
+
### Default Configuration
|
|
61
|
+
|
|
62
|
+
```jsonc
|
|
63
|
+
{
|
|
64
|
+
// Uncomment to enable debug logging (logs appear in OpenCode's log file)
|
|
65
|
+
// "debug": true,
|
|
66
|
+
|
|
67
|
+
"replacements": {
|
|
68
|
+
"must": "MUST",
|
|
69
|
+
"must not": "MUST NOT",
|
|
70
|
+
"required": "REQUIRED",
|
|
71
|
+
"shall": "SHALL",
|
|
72
|
+
"shall not": "SHALL NOT",
|
|
73
|
+
"should": "SHOULD",
|
|
74
|
+
"should not": "SHOULD NOT",
|
|
75
|
+
"recommended": "RECOMMENDED",
|
|
76
|
+
"not recommended": "NOT RECOMMENDED",
|
|
77
|
+
"may": "MAY",
|
|
78
|
+
"optional": "OPTIONAL"
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
### Custom Replacements
|
|
84
|
+
|
|
85
|
+
Add your own replacement pairs to the `replacements` object:
|
|
86
|
+
|
|
87
|
+
```jsonc
|
|
88
|
+
{
|
|
89
|
+
// Uncomment to enable debug logging (view with: tail -f /tmp/opencode-replacer-debug.log)
|
|
90
|
+
// "debug": true,
|
|
91
|
+
|
|
92
|
+
"replacements": {
|
|
93
|
+
"bl.md": "~/.config/opencode/supplemental/md/branch-list.md",
|
|
94
|
+
"dfp": "Diagnose and fix this problem: ",
|
|
95
|
+
"mnm": "Make no mistakes!",
|
|
96
|
+
"rfc!": "The key words \"**MUST**\", \"**MUST NOT**\", \"**REQUIRED**\", \"**SHALL**\", \"**SHALL NOT**\", \"**SHOULD**\", \"**SHOULD NOT**\", \"**RECOMMENDED**\", \"**MAY**\", and \"**OPTIONAL**\" in this message are to be interpreted as described in RFC2119.\n\n",
|
|
97
|
+
|
|
98
|
+
"pto": "push that to origin,",
|
|
99
|
+
"mb": "return to the initial branch and merge the new branch back in.",
|
|
100
|
+
|
|
101
|
+
"always": "**ALWAYS**",
|
|
102
|
+
"ever": "**EVER**",
|
|
103
|
+
"head": "HEAD",
|
|
104
|
+
"may not": "**MAY NOT**",
|
|
105
|
+
"may": "**MAY**",
|
|
106
|
+
"must always": "**MUST ALWAYS**",
|
|
107
|
+
"must never" : "**MUST NEVER**",
|
|
108
|
+
"must not" : "**MUST NOT**",
|
|
109
|
+
"must": "**MUST**",
|
|
110
|
+
"mustn't" : "**MUST NOT**",
|
|
111
|
+
"never": "**NEVER**",
|
|
112
|
+
"not recommended": "**NOT RECOMMENDED**",
|
|
113
|
+
"nothing": "**NOTHING**",
|
|
114
|
+
"not": "**NOT**",
|
|
115
|
+
"optional": "**OPTIONAL**",
|
|
116
|
+
"ought": "**SHOULD**",
|
|
117
|
+
"oughtn't": "**SHOULD NOT**",
|
|
118
|
+
"recommended": "**RECOMMENDED**",
|
|
119
|
+
"required": "**REQUIRED**",
|
|
120
|
+
"shall not": "**SHALL NOT**",
|
|
121
|
+
"shan't": "**SHALL NOT**",
|
|
122
|
+
"shall": "**SHALL**",
|
|
123
|
+
"should not": "**SHOULD NOT**",
|
|
124
|
+
"shouldn't": "**SHOULD NOT**",
|
|
125
|
+
"should": "**SHOULD**",
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
### Configuration Options
|
|
131
|
+
|
|
132
|
+
| Option | Type | Default | Description |
|
|
133
|
+
|--------|------|---------|-------------|
|
|
134
|
+
| `debug` | boolean | `false` | Enable debug logging to OpenCode's log file |
|
|
135
|
+
| `replacements` | object | RFC2119 keywords | Key-value pairs for text replacement |
|
|
136
|
+
|
|
137
|
+
## Debug Logging
|
|
138
|
+
|
|
139
|
+
Logs are written to OpenCode's unified log file using the SDK logging system.
|
|
140
|
+
|
|
141
|
+
**Log location**: `~/.local/share/opencode/log/dev.log`
|
|
142
|
+
|
|
143
|
+
Enable debug mode to see what replacements are being made:
|
|
144
|
+
|
|
145
|
+
1. Edit `~/.config/opencode/MUST-have-plugin.jsonc`
|
|
146
|
+
2. Uncomment or add `"debug": true`
|
|
147
|
+
3. View logs in real-time (filtering by this plugin):
|
|
148
|
+
|
|
149
|
+
```bash
|
|
150
|
+
tail -f ~/.local/share/opencode/log/dev.log | grep "MUST-have-plugin"
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
Or view all recent plugin logs:
|
|
154
|
+
|
|
155
|
+
```bash
|
|
156
|
+
grep "MUST-have-plugin" ~/.local/share/opencode/log/dev.log | tail -20
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
### Log Format
|
|
160
|
+
|
|
161
|
+
Logs use OpenCode's standard format with structured metadata:
|
|
162
|
+
|
|
163
|
+
```
|
|
164
|
+
INFO 2026-01-20T15:30:42 +2ms service=MUST-have-plugin Plugin loaded
|
|
165
|
+
INFO 2026-01-20T15:31:05 +5ms service=MUST-have-plugin Applied 3 replacement(s) replacements={"must":{"value":"MUST","count":2},"should":{"value":"SHOULD","count":1}}
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
## RFC2119 Keywords
|
|
169
|
+
|
|
170
|
+
The default configuration includes all keywords from [RFC 2119](https://datatracker.ietf.org/doc/html/rfc2119), which defines requirement levels for use in technical specifications:
|
|
171
|
+
|
|
172
|
+
| Keyword | Meaning |
|
|
173
|
+
|---------|---------|
|
|
174
|
+
| MUST / REQUIRED / SHALL | Absolute requirement |
|
|
175
|
+
| MUST NOT / SHALL NOT | Absolute prohibition |
|
|
176
|
+
| SHOULD / RECOMMENDED | Recommended, but valid reasons may exist to ignore |
|
|
177
|
+
| SHOULD NOT / NOT RECOMMENDED | Not recommended, but may be acceptable in some cases |
|
|
178
|
+
| MAY / OPTIONAL | Truly optional |
|
|
179
|
+
|
|
180
|
+
## Troubleshooting
|
|
181
|
+
|
|
182
|
+
### Replacements not working
|
|
183
|
+
|
|
184
|
+
1. Check that the config file exists: `cat ~/.config/opencode/MUST-have-plugin.jsonc`
|
|
185
|
+
2. Verify JSONC syntax is valid (comments and trailing commas are allowed)
|
|
186
|
+
3. Enable debug mode and check the log file
|
|
187
|
+
|
|
188
|
+
### Unexpected replacements
|
|
189
|
+
|
|
190
|
+
- Replacements use word boundaries, so `must` won't match inside `customer`
|
|
191
|
+
- Multi-word phrases are matched first, so `must not` won't become `MUST not`
|
|
192
|
+
- Check for typos in your replacement keys
|
|
193
|
+
|
|
194
|
+
### Config changes not taking effect
|
|
195
|
+
|
|
196
|
+
The plugin re-reads the config on every message, so changes should be immediate. If not:
|
|
197
|
+
|
|
198
|
+
1. Verify you saved the config file
|
|
199
|
+
2. Check for JSONC syntax errors
|
|
200
|
+
3. Restart OpenCode as a last resort
|
|
201
|
+
|
|
202
|
+
## Publishing
|
|
203
|
+
|
|
204
|
+
```bash
|
|
205
|
+
npm install
|
|
206
|
+
npm login
|
|
207
|
+
npm run build
|
|
208
|
+
npm publish --access public
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
`npm publish` does not install dev dependencies for you. Run `npm install` first in a fresh checkout so `typescript` and `@types/node` are available for the `prepublishOnly` build.
|
|
212
|
+
|
|
213
|
+
For a preflight check before publishing, run `npm publish --dry-run`.
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MUST-have-plugin (Replacer Plugin)
|
|
3
|
+
*
|
|
4
|
+
* Performs case-insensitive string replacements on user-typed prompts
|
|
5
|
+
* before they're sent to the LLM.
|
|
6
|
+
*
|
|
7
|
+
* Config: ~/.config/opencode/MUST-have-plugin.jsonc (JSONC format)
|
|
8
|
+
* Logs: ~/.local/share/opencode/log/dev.log (filter by service=MUST-have-plugin)
|
|
9
|
+
*
|
|
10
|
+
* Features:
|
|
11
|
+
* - Case-insensitive matching with word boundaries
|
|
12
|
+
* - Multi-word phrase support (longest-first matching)
|
|
13
|
+
* - Hot reload: config re-read on every message
|
|
14
|
+
* - Auto-generates RFC2119 defaults if no config exists
|
|
15
|
+
* - JSONC support (comments and trailing commas allowed in config)
|
|
16
|
+
*
|
|
17
|
+
* Scope:
|
|
18
|
+
* - Only replaces in user-typed prompts
|
|
19
|
+
* - Does NOT modify file content attached via @ mentions
|
|
20
|
+
* - Does NOT modify slash command output
|
|
21
|
+
*/
|
|
22
|
+
import type { Plugin } from "@opencode-ai/plugin";
|
|
23
|
+
/**
|
|
24
|
+
* MUST-have-plugin (Replacer Plugin)
|
|
25
|
+
*/
|
|
26
|
+
export declare const MustHavePlugin: Plugin;
|
|
27
|
+
export default MustHavePlugin;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, existsSync } from "fs";
|
|
2
|
+
import { resolve } from "path";
|
|
3
|
+
const CONFIG_PATH = resolve(process.env.HOME || "", ".config", "opencode", "MUST-have-plugin.jsonc");
|
|
4
|
+
const SERVICE_NAME = "MUST-have-plugin";
|
|
5
|
+
// Track debug state (loaded from config)
|
|
6
|
+
let debugEnabled = false;
|
|
7
|
+
// SDK client reference for logging (set during plugin init)
|
|
8
|
+
let sdkClient = null;
|
|
9
|
+
// Track sessions that just executed a slash command (to skip replacement)
|
|
10
|
+
const sessionsWithCommand = new Set();
|
|
11
|
+
/**
|
|
12
|
+
* RFC2119 default replacements
|
|
13
|
+
* These keywords are used in technical specifications to indicate requirement levels.
|
|
14
|
+
* See: https://datatracker.ietf.org/doc/html/rfc2119
|
|
15
|
+
*/
|
|
16
|
+
const RFC2119_DEFAULTS = {
|
|
17
|
+
"must": "MUST",
|
|
18
|
+
"must not": "MUST NOT",
|
|
19
|
+
"required": "REQUIRED",
|
|
20
|
+
"shall": "SHALL",
|
|
21
|
+
"shall not": "SHALL NOT",
|
|
22
|
+
"should": "SHOULD",
|
|
23
|
+
"should not": "SHOULD NOT",
|
|
24
|
+
"recommended": "RECOMMENDED",
|
|
25
|
+
"not recommended": "NOT RECOMMENDED",
|
|
26
|
+
"may": "MAY",
|
|
27
|
+
"optional": "OPTIONAL",
|
|
28
|
+
};
|
|
29
|
+
/**
|
|
30
|
+
* Default config file content (JSONC format)
|
|
31
|
+
*/
|
|
32
|
+
const DEFAULT_CONFIG = `{
|
|
33
|
+
// Uncomment to enable debug logging (view in: ~/.local/share/opencode/log/dev.log)
|
|
34
|
+
// Filter with: grep "service=${SERVICE_NAME}" ~/.local/share/opencode/log/dev.log
|
|
35
|
+
// "debug": true,
|
|
36
|
+
|
|
37
|
+
"replacements": {
|
|
38
|
+
"must": "MUST",
|
|
39
|
+
"must not": "MUST NOT",
|
|
40
|
+
"required": "REQUIRED",
|
|
41
|
+
"shall": "SHALL",
|
|
42
|
+
"shall not": "SHALL NOT",
|
|
43
|
+
"should": "SHOULD",
|
|
44
|
+
"should not": "SHOULD NOT",
|
|
45
|
+
"recommended": "RECOMMENDED",
|
|
46
|
+
"not recommended": "NOT RECOMMENDED",
|
|
47
|
+
"may": "MAY",
|
|
48
|
+
"optional": "OPTIONAL"
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
`;
|
|
52
|
+
/**
|
|
53
|
+
* Log a message using the OpenCode SDK logging system
|
|
54
|
+
* Logs appear in ~/.local/share/opencode/log/dev.log
|
|
55
|
+
*/
|
|
56
|
+
async function log(level, message, extra) {
|
|
57
|
+
if (!debugEnabled && level === "debug")
|
|
58
|
+
return;
|
|
59
|
+
if (!sdkClient)
|
|
60
|
+
return;
|
|
61
|
+
try {
|
|
62
|
+
await sdkClient.app.log({
|
|
63
|
+
body: {
|
|
64
|
+
service: SERVICE_NAME,
|
|
65
|
+
level,
|
|
66
|
+
message,
|
|
67
|
+
extra,
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
// Silently fail - logging should never break the plugin
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Parse JSONC (JSON with Comments) by stripping comments before parsing
|
|
77
|
+
*/
|
|
78
|
+
function parseJSONC(content) {
|
|
79
|
+
// Strip single-line comments (// ...) - but not inside strings
|
|
80
|
+
// This is a simplified approach that works for typical config files
|
|
81
|
+
const lines = content.split("\n");
|
|
82
|
+
const strippedLines = lines.map((line) => {
|
|
83
|
+
// Find // that's not inside a string
|
|
84
|
+
let inString = false;
|
|
85
|
+
let escapeNext = false;
|
|
86
|
+
for (let i = 0; i < line.length; i++) {
|
|
87
|
+
const char = line[i];
|
|
88
|
+
if (escapeNext) {
|
|
89
|
+
escapeNext = false;
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
if (char === "\\") {
|
|
93
|
+
escapeNext = true;
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
if (char === '"') {
|
|
97
|
+
inString = !inString;
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
if (!inString && char === "/" && line[i + 1] === "/") {
|
|
101
|
+
return line.substring(0, i);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return line;
|
|
105
|
+
});
|
|
106
|
+
const stripped = strippedLines.join("\n");
|
|
107
|
+
// Also strip multi-line comments /* ... */
|
|
108
|
+
const noMultiLine = stripped.replace(/\/\*[\s\S]*?\*\//g, "");
|
|
109
|
+
// Strip trailing commas before ] or } (for JSONC compatibility)
|
|
110
|
+
const noTrailingCommas = noMultiLine.replace(/,(\s*[}\]])/g, "$1");
|
|
111
|
+
return JSON.parse(noTrailingCommas);
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Create default config file with RFC2119 keywords if it doesn't exist
|
|
115
|
+
*/
|
|
116
|
+
function ensureConfigExists() {
|
|
117
|
+
if (!existsSync(CONFIG_PATH)) {
|
|
118
|
+
try {
|
|
119
|
+
writeFileSync(CONFIG_PATH, DEFAULT_CONFIG, "utf-8");
|
|
120
|
+
// Fire-and-forget log - don't block init
|
|
121
|
+
log("info", "Created default config", { path: CONFIG_PATH });
|
|
122
|
+
}
|
|
123
|
+
catch (error) {
|
|
124
|
+
// Fire-and-forget log - don't block init
|
|
125
|
+
log("error", "Failed to create default config", { error: String(error) });
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Load and parse the config file
|
|
131
|
+
* Returns debug flag and replacements map
|
|
132
|
+
*/
|
|
133
|
+
function loadConfig() {
|
|
134
|
+
const defaultConfig = {
|
|
135
|
+
debug: false,
|
|
136
|
+
replacements: RFC2119_DEFAULTS,
|
|
137
|
+
};
|
|
138
|
+
try {
|
|
139
|
+
if (!existsSync(CONFIG_PATH)) {
|
|
140
|
+
return defaultConfig;
|
|
141
|
+
}
|
|
142
|
+
const content = readFileSync(CONFIG_PATH, "utf-8");
|
|
143
|
+
const parsed = parseJSONC(content);
|
|
144
|
+
return {
|
|
145
|
+
debug: parsed.debug === true,
|
|
146
|
+
replacements: parsed.replacements || {},
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
catch (error) {
|
|
150
|
+
// Log to stderr since SDK client might not be ready yet
|
|
151
|
+
process.stderr.write(`MUST-have-plugin: [WARN] Failed to parse config: ${error}\n`);
|
|
152
|
+
return defaultConfig;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Escape special regex characters in a string
|
|
157
|
+
*/
|
|
158
|
+
function escapeRegex(str) {
|
|
159
|
+
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Apply all replacements to the given text
|
|
163
|
+
* - Case-insensitive matching
|
|
164
|
+
* - Word boundary aware (won't replace "must" inside "customer")
|
|
165
|
+
* - Won't match inside markdown formatting (won't replace MUST inside **MUST**)
|
|
166
|
+
* - Single-pass: all patterns matched at once to prevent re-replacement
|
|
167
|
+
*/
|
|
168
|
+
function applyReplacements(text, replacements) {
|
|
169
|
+
if (Object.keys(replacements).length === 0) {
|
|
170
|
+
return { result: text, counts: new Map() };
|
|
171
|
+
}
|
|
172
|
+
// Sort keys by length descending (longest first) for proper matching priority
|
|
173
|
+
const sortedKeys = Object.keys(replacements).sort((a, b) => b.length - a.length);
|
|
174
|
+
// Build a single regex that matches any of the patterns
|
|
175
|
+
// Uses negative lookbehind/lookahead to exclude matches inside markdown formatting
|
|
176
|
+
// (?<![a-zA-Z*_'']) = not preceded by letter, asterisk, underscore, or apostrophe
|
|
177
|
+
// (?![a-zA-Z*_'']) = not followed by letter, asterisk, underscore, or apostrophe
|
|
178
|
+
const patternStrings = sortedKeys.map((key) => `(?<![a-zA-Z*_''])${escapeRegex(key)}(?![a-zA-Z*_''])`);
|
|
179
|
+
const combinedPattern = new RegExp(`(${patternStrings.join("|")})`, "gi");
|
|
180
|
+
// Build a case-insensitive lookup map
|
|
181
|
+
const lookup = new Map();
|
|
182
|
+
for (const key of sortedKeys) {
|
|
183
|
+
lookup.set(key.toLowerCase(), replacements[key]);
|
|
184
|
+
}
|
|
185
|
+
const counts = new Map();
|
|
186
|
+
// Single-pass replacement: each match is replaced exactly once
|
|
187
|
+
let lastIndex = 0;
|
|
188
|
+
let result = "";
|
|
189
|
+
let match;
|
|
190
|
+
while ((match = combinedPattern.exec(text)) !== null) {
|
|
191
|
+
const replacement = lookup.get(match[0].toLowerCase());
|
|
192
|
+
if (replacement) {
|
|
193
|
+
counts.set(match[0].toLowerCase(), (counts.get(match[0].toLowerCase()) || 0) + 1);
|
|
194
|
+
result += text.slice(lastIndex, match.index) + replacement;
|
|
195
|
+
lastIndex = match.index + match[0].length;
|
|
196
|
+
// If replacement ends with whitespace, consume trailing space from original text
|
|
197
|
+
if (/\s$/.test(replacement) && text[lastIndex] === " ") {
|
|
198
|
+
lastIndex++;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
else {
|
|
202
|
+
result += text.slice(lastIndex, match.index) + match[0];
|
|
203
|
+
lastIndex = match.index + match[0].length;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
result += text.slice(lastIndex);
|
|
207
|
+
return { result, counts };
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* MUST-have-plugin (Replacer Plugin)
|
|
211
|
+
*/
|
|
212
|
+
export const MustHavePlugin = async ({ client }) => {
|
|
213
|
+
// Store SDK client for logging
|
|
214
|
+
sdkClient = client;
|
|
215
|
+
// Ensure default config exists (synchronous)
|
|
216
|
+
ensureConfigExists();
|
|
217
|
+
// Initial config load to get debug state
|
|
218
|
+
const initialConfig = loadConfig();
|
|
219
|
+
debugEnabled = initialConfig.debug;
|
|
220
|
+
// Fire-and-forget log during init - don't await to avoid blocking startup
|
|
221
|
+
log("info", "Plugin loaded", {
|
|
222
|
+
configPath: CONFIG_PATH,
|
|
223
|
+
replacementCount: Object.keys(initialConfig.replacements).length,
|
|
224
|
+
});
|
|
225
|
+
return {
|
|
226
|
+
"command.execute.before": async (input) => {
|
|
227
|
+
sessionsWithCommand.add(input.sessionID);
|
|
228
|
+
await log("debug", "Tracking command execution", { sessionID: input.sessionID, command: input.command });
|
|
229
|
+
},
|
|
230
|
+
"chat.message": async (input, output) => {
|
|
231
|
+
// Skip processing if this message came from a slash command execution
|
|
232
|
+
if (sessionsWithCommand.has(input.sessionID)) {
|
|
233
|
+
sessionsWithCommand.delete(input.sessionID);
|
|
234
|
+
await log("debug", "Skipping replacements - message from slash command", { sessionID: input.sessionID });
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
// Hot reload: re-read config on every message
|
|
238
|
+
const config = loadConfig();
|
|
239
|
+
debugEnabled = config.debug;
|
|
240
|
+
if (Object.keys(config.replacements).length === 0) {
|
|
241
|
+
await log("debug", "No replacements configured, skipping");
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
let totalReplacements = 0;
|
|
245
|
+
const allCounts = new Map();
|
|
246
|
+
// Apply to user-typed text only (not file content, not slash command output, not synthetic)
|
|
247
|
+
// User-typed content: type === "text" && synthetic !== true
|
|
248
|
+
for (const part of output.parts) {
|
|
249
|
+
if (part.type === "text" && "text" in part && typeof part.text === "string" && !part.synthetic) {
|
|
250
|
+
const { result, counts } = applyReplacements(part.text, config.replacements);
|
|
251
|
+
part.text = result;
|
|
252
|
+
// Merge counts
|
|
253
|
+
for (const [key, count] of counts) {
|
|
254
|
+
allCounts.set(key, (allCounts.get(key) || 0) + count);
|
|
255
|
+
totalReplacements += count;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
// Log replacements made
|
|
260
|
+
if (totalReplacements > 0) {
|
|
261
|
+
const replacements = {};
|
|
262
|
+
for (const [key, count] of allCounts) {
|
|
263
|
+
replacements[key] = { value: config.replacements[key], count };
|
|
264
|
+
}
|
|
265
|
+
await log("info", `Applied ${totalReplacements} replacement(s)`, { replacements });
|
|
266
|
+
}
|
|
267
|
+
},
|
|
268
|
+
};
|
|
269
|
+
};
|
|
270
|
+
export default MustHavePlugin;
|
package/package.json
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json.schemastore.org/package.json",
|
|
3
|
+
"name": "@ariane-emory/must-have-plugin",
|
|
4
|
+
"version": "1.0.0",
|
|
5
|
+
"description": "OpenCode plugin that applies configurable prompt text replacements, including RFC2119 keyword capitalization.",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "./dist/index.js",
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"import": "./dist/index.js",
|
|
12
|
+
"types": "./dist/index.d.ts"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"dist",
|
|
17
|
+
"README.md",
|
|
18
|
+
"LICENSE"
|
|
19
|
+
],
|
|
20
|
+
"scripts": {
|
|
21
|
+
"build": "tsc",
|
|
22
|
+
"typecheck": "tsc --noEmit",
|
|
23
|
+
"prepublishOnly": "npm run build"
|
|
24
|
+
},
|
|
25
|
+
"keywords": [
|
|
26
|
+
"opencode",
|
|
27
|
+
"opencode-plugin",
|
|
28
|
+
"plugin",
|
|
29
|
+
"rfc2119",
|
|
30
|
+
"text-replacement"
|
|
31
|
+
],
|
|
32
|
+
"author": "Ariane Emory",
|
|
33
|
+
"license": "MIT",
|
|
34
|
+
"homepage": "https://github.com/ariane-emory/MUST-have-plugin#readme",
|
|
35
|
+
"repository": {
|
|
36
|
+
"type": "git",
|
|
37
|
+
"url": "git+https://github.com/ariane-emory/MUST-have-plugin.git"
|
|
38
|
+
},
|
|
39
|
+
"bugs": {
|
|
40
|
+
"url": "https://github.com/ariane-emory/MUST-have-plugin/issues"
|
|
41
|
+
},
|
|
42
|
+
"publishConfig": {
|
|
43
|
+
"access": "public"
|
|
44
|
+
},
|
|
45
|
+
"peerDependencies": {
|
|
46
|
+
"@opencode-ai/plugin": ">=1.0.0"
|
|
47
|
+
},
|
|
48
|
+
"devDependencies": {
|
|
49
|
+
"@opencode-ai/plugin": "^1.4.3",
|
|
50
|
+
"@types/node": "^22.13.9",
|
|
51
|
+
"typescript": "^5.8.2"
|
|
52
|
+
}
|
|
53
|
+
}
|