opencode-snippets 1.3.0 → 1.4.1

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 CHANGED
@@ -190,6 +190,48 @@ I reference I reference I reference ... (15 times) ... I reference #self
190
190
 
191
191
  This generous limit supports complex snippet hierarchies while preventing infinite loops.
192
192
 
193
+ ### Prepend and Append Blocks
194
+
195
+ For long reference material that would break your writing flow, use `<append>` blocks to place content at the end of your message:
196
+
197
+ ```markdown
198
+ ---
199
+ aliases: jira-mcp
200
+ ---
201
+ Jira MCP server
202
+ <append>
203
+ ## Jira MCP Usage
204
+
205
+ Use these custom field mappings when creating issues:
206
+ - customfield_16570 => Acceptance Criteria
207
+ - customfield_11401 => Team
208
+ </append>
209
+ ```
210
+
211
+ **Input:** `Create a bug ticket in #jira-mcp about the memory leak`
212
+
213
+ **Output:**
214
+ ```
215
+ Create a bug ticket in Jira MCP server about the memory leak
216
+
217
+ ## Jira MCP Usage
218
+
219
+ Use these custom field mappings when creating issues:
220
+ - customfield_16570 => Acceptance Criteria
221
+ - customfield_11401 => Team
222
+ ```
223
+
224
+ Write naturally—reference what you need mid-sentence—and the context follows at the bottom.
225
+
226
+ Use `<prepend>` for content that should appear at the top of your message. Multiple blocks of the same type are concatenated in order of appearance.
227
+
228
+ **Block behavior:**
229
+ - Content outside `<prepend>`/`<append>` blocks replaces the hashtag inline
230
+ - If a snippet has only blocks (no inline content), the hashtag is simply removed
231
+ - Blocks from nested snippets are collected and assembled in the final message
232
+ - Unclosed tags are handled leniently (rest of content becomes the block)
233
+ - Nested blocks are not allowed—the hashtag is left unchanged
234
+
193
235
  ## Example Snippets
194
236
 
195
237
  ### `~/.config/opencode/snippet/context.md`
package/index.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import type { Plugin } from "@opencode-ai/plugin";
2
2
  import { createCommandExecuteHandler } from "./src/commands.js";
3
- import { expandHashtags } from "./src/expander.js";
3
+ import { assembleMessage, expandHashtags } from "./src/expander.js";
4
4
  import { loadSnippets } from "./src/loader.js";
5
5
  import { logger } from "./src/logger.js";
6
6
  import { executeShellCommands, type ShellContext } from "./src/shell.js";
@@ -53,7 +53,8 @@ export const SnippetsPlugin: Plugin = async (ctx) => {
53
53
  if (part.type === "text" && part.text) {
54
54
  // 1. Expand hashtags recursively with loop detection
55
55
  const expandStart = performance.now();
56
- part.text = expandHashtags(part.text, snippets);
56
+ const expansionResult = expandHashtags(part.text, snippets);
57
+ part.text = assembleMessage(expansionResult);
57
58
  const expandTime = performance.now() - expandStart;
58
59
  expandTimeTotal += expandTime;
59
60
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-snippets",
3
- "version": "1.3.0",
3
+ "version": "1.4.1",
4
4
  "description": "Hashtag-based snippet expansion plugin for OpenCode - instant inline text shortcuts",
5
5
  "main": "index.ts",
6
6
  "type": "module",
@@ -0,0 +1,177 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { parseCommandArgs } from "./arg-parser.js";
3
+
4
+ describe("parseCommandArgs", () => {
5
+ // Basic splitting
6
+ describe("basic argument splitting", () => {
7
+ it("splits simple space-separated args", () => {
8
+ expect(parseCommandArgs("add test")).toEqual(["add", "test"]);
9
+ });
10
+
11
+ it("handles empty input", () => {
12
+ expect(parseCommandArgs("")).toEqual([]);
13
+ });
14
+
15
+ it("handles whitespace-only input", () => {
16
+ expect(parseCommandArgs(" ")).toEqual([]);
17
+ });
18
+
19
+ it("handles multiple spaces between args", () => {
20
+ expect(parseCommandArgs("add test")).toEqual(["add", "test"]);
21
+ });
22
+
23
+ it("handles leading and trailing spaces", () => {
24
+ expect(parseCommandArgs(" add test ")).toEqual(["add", "test"]);
25
+ });
26
+
27
+ it("handles tabs and mixed whitespace", () => {
28
+ expect(parseCommandArgs("add\t\ttest")).toEqual(["add", "test"]);
29
+ });
30
+ });
31
+
32
+ // Double quote handling
33
+ describe("double quote handling", () => {
34
+ it("preserves double-quoted strings with spaces", () => {
35
+ expect(parseCommandArgs('add "hello world"')).toEqual(["add", "hello world"]);
36
+ });
37
+
38
+ it("handles single quote inside double quotes", () => {
39
+ // THE MAIN BUG - apostrophe in description
40
+ expect(parseCommandArgs('--desc="don\'t do this"')).toEqual(["--desc=don't do this"]);
41
+ });
42
+
43
+ it("handles empty double-quoted string", () => {
44
+ expect(parseCommandArgs('add ""')).toEqual(["add", ""]);
45
+ });
46
+
47
+ it("handles double-quoted string at start", () => {
48
+ expect(parseCommandArgs('"hello world" test')).toEqual(["hello world", "test"]);
49
+ });
50
+
51
+ it("handles multiple double-quoted strings", () => {
52
+ expect(parseCommandArgs('"first" "second"')).toEqual(["first", "second"]);
53
+ });
54
+ });
55
+
56
+ // Single quote handling
57
+ describe("single quote handling", () => {
58
+ it("preserves single-quoted strings with spaces", () => {
59
+ expect(parseCommandArgs("add 'hello world'")).toEqual(["add", "hello world"]);
60
+ });
61
+
62
+ it("handles double quote inside single quotes", () => {
63
+ expect(parseCommandArgs("--desc='say \"hello\"'")).toEqual(['--desc=say "hello"']);
64
+ });
65
+
66
+ it("handles empty single-quoted string", () => {
67
+ expect(parseCommandArgs("add ''")).toEqual(["add", ""]);
68
+ });
69
+ });
70
+
71
+ // --key=value syntax
72
+ describe("--key=value syntax", () => {
73
+ it("handles --key=value without quotes", () => {
74
+ expect(parseCommandArgs("--desc=hello")).toEqual(["--desc=hello"]);
75
+ });
76
+
77
+ it('handles --key="value" with quotes stripped from value', () => {
78
+ expect(parseCommandArgs('--desc="hello world"')).toEqual(["--desc=hello world"]);
79
+ });
80
+
81
+ it("handles --key='value' with quotes stripped from value", () => {
82
+ expect(parseCommandArgs("--key='hello world'")).toEqual(["--key=hello world"]);
83
+ });
84
+
85
+ it("handles --key=value with special characters", () => {
86
+ expect(parseCommandArgs("--desc=hello,world")).toEqual(["--desc=hello,world"]);
87
+ });
88
+
89
+ it("preserves = inside quoted value", () => {
90
+ expect(parseCommandArgs('--desc="a=b"')).toEqual(["--desc=a=b"]);
91
+ });
92
+ });
93
+
94
+ // Multiline content (critical for snippet bodies)
95
+ describe("multiline content", () => {
96
+ it("handles multiline content in double quotes", () => {
97
+ expect(parseCommandArgs('add test "line1\nline2\nline3"')).toEqual([
98
+ "add",
99
+ "test",
100
+ "line1\nline2\nline3",
101
+ ]);
102
+ });
103
+
104
+ it("handles multiline content in single quotes", () => {
105
+ expect(parseCommandArgs("add test 'line1\nline2'")).toEqual(["add", "test", "line1\nline2"]);
106
+ });
107
+
108
+ it("handles multiline content with --key=value syntax", () => {
109
+ expect(parseCommandArgs('--desc="line1\nline2"')).toEqual(["--desc=line1\nline2"]);
110
+ });
111
+
112
+ it("preserves indentation in multiline content", () => {
113
+ const input = 'add test "line1\n indented\n more indented"';
114
+ expect(parseCommandArgs(input)).toEqual([
115
+ "add",
116
+ "test",
117
+ "line1\n indented\n more indented",
118
+ ]);
119
+ });
120
+ });
121
+
122
+ // Mixed scenarios
123
+ describe("mixed scenarios", () => {
124
+ it("handles mixed quoted and unquoted args", () => {
125
+ expect(parseCommandArgs('add test "hello world" --project')).toEqual([
126
+ "add",
127
+ "test",
128
+ "hello world",
129
+ "--project",
130
+ ]);
131
+ });
132
+
133
+ it("handles complex command with all option types", () => {
134
+ const input = 'add mysnippet "content here" --aliases=a,b --desc="don\'t forget" --project';
135
+ expect(parseCommandArgs(input)).toEqual([
136
+ "add",
137
+ "mysnippet",
138
+ "content here",
139
+ "--aliases=a,b",
140
+ "--desc=don't forget",
141
+ "--project",
142
+ ]);
143
+ });
144
+
145
+ it("handles --key value syntax (space-separated)", () => {
146
+ expect(parseCommandArgs("--desc hello")).toEqual(["--desc", "hello"]);
147
+ });
148
+
149
+ it("handles --key followed by quoted value", () => {
150
+ expect(parseCommandArgs('--desc "hello world"')).toEqual(["--desc", "hello world"]);
151
+ });
152
+ });
153
+
154
+ // Edge cases
155
+ describe("edge cases", () => {
156
+ it("handles unclosed double quote by including rest of string", () => {
157
+ // Graceful handling: treat unclosed quote as extending to end
158
+ expect(parseCommandArgs('add "unclosed')).toEqual(["add", "unclosed"]);
159
+ });
160
+
161
+ it("handles unclosed single quote by including rest of string", () => {
162
+ expect(parseCommandArgs("add 'unclosed")).toEqual(["add", "unclosed"]);
163
+ });
164
+
165
+ it("handles backslash-escaped quotes inside double quotes", () => {
166
+ expect(parseCommandArgs('--desc="say \\"hello\\""')).toEqual(['--desc=say "hello"']);
167
+ });
168
+
169
+ it("handles backslash-escaped quotes inside single quotes", () => {
170
+ expect(parseCommandArgs("--desc='it\\'s fine'")).toEqual(["--desc=it's fine"]);
171
+ });
172
+
173
+ it("handles literal backslash", () => {
174
+ expect(parseCommandArgs('--path="C:\\\\Users"')).toEqual(["--path=C:\\Users"]);
175
+ });
176
+ });
177
+ });
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Shell-like argument parser that handles quoted strings correctly.
3
+ *
4
+ * Supports:
5
+ * - Space-separated arguments
6
+ * - Double-quoted strings (preserves spaces, allows single quotes inside)
7
+ * - Single-quoted strings (preserves spaces, allows double quotes inside)
8
+ * - --key=value syntax with quoted values
9
+ * - Multiline content inside quotes
10
+ * - Backslash escapes for quotes inside quoted strings
11
+ *
12
+ * @param input - The raw argument string to parse
13
+ * @returns Array of parsed arguments with quotes stripped
14
+ */
15
+ export function parseCommandArgs(input: string): string[] {
16
+ const args: string[] = [];
17
+ let current = "";
18
+ let state: "normal" | "double" | "single" = "normal";
19
+ let hasQuotedContent = false; // Track if we've entered a quoted section
20
+ let i = 0;
21
+
22
+ while (i < input.length) {
23
+ const char = input[i];
24
+
25
+ if (state === "normal") {
26
+ if (char === " " || char === "\t") {
27
+ // Whitespace in normal mode: finish current token
28
+ if (current.length > 0 || hasQuotedContent) {
29
+ args.push(current);
30
+ current = "";
31
+ hasQuotedContent = false;
32
+ }
33
+ } else if (char === '"') {
34
+ // Enter double-quote mode
35
+ state = "double";
36
+ hasQuotedContent = true;
37
+ } else if (char === "'") {
38
+ // Enter single-quote mode
39
+ state = "single";
40
+ hasQuotedContent = true;
41
+ } else {
42
+ current += char;
43
+ }
44
+ } else if (state === "double") {
45
+ if (char === "\\") {
46
+ // Check for escape sequences
47
+ const next = input[i + 1];
48
+ if (next === '"' || next === "\\") {
49
+ current += next;
50
+ i++; // Skip the escaped character
51
+ } else {
52
+ current += char;
53
+ }
54
+ } else if (char === '"') {
55
+ // Exit double-quote mode
56
+ state = "normal";
57
+ } else {
58
+ current += char;
59
+ }
60
+ } else if (state === "single") {
61
+ if (char === "\\") {
62
+ // Check for escape sequences
63
+ const next = input[i + 1];
64
+ if (next === "'" || next === "\\") {
65
+ current += next;
66
+ i++; // Skip the escaped character
67
+ } else {
68
+ current += char;
69
+ }
70
+ } else if (char === "'") {
71
+ // Exit single-quote mode
72
+ state = "normal";
73
+ } else {
74
+ current += char;
75
+ }
76
+ }
77
+
78
+ i++;
79
+ }
80
+
81
+ // Handle any remaining content (including unclosed quotes or empty quoted strings)
82
+ if (current.length > 0 || hasQuotedContent) {
83
+ args.push(current);
84
+ }
85
+
86
+ return args;
87
+ }
@@ -0,0 +1,188 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { parseAddOptions } from "./commands.js";
3
+
4
+ describe("parseAddOptions", () => {
5
+ // Alias variations - all 4 must work per PR #13 requirements
6
+ describe("alias parameter variations", () => {
7
+ it("parses --alias=a,b", () => {
8
+ expect(parseAddOptions(["--alias=a,b"])).toEqual({
9
+ aliases: ["a", "b"],
10
+ description: undefined,
11
+ isProject: false,
12
+ });
13
+ });
14
+
15
+ it("parses --alias a,b (space-separated)", () => {
16
+ expect(parseAddOptions(["--alias", "a,b"])).toEqual({
17
+ aliases: ["a", "b"],
18
+ description: undefined,
19
+ isProject: false,
20
+ });
21
+ });
22
+
23
+ it("parses --aliases=a,b", () => {
24
+ expect(parseAddOptions(["--aliases=a,b"])).toEqual({
25
+ aliases: ["a", "b"],
26
+ description: undefined,
27
+ isProject: false,
28
+ });
29
+ });
30
+
31
+ it("parses --aliases a,b (space-separated)", () => {
32
+ expect(parseAddOptions(["--aliases", "a,b"])).toEqual({
33
+ aliases: ["a", "b"],
34
+ description: undefined,
35
+ isProject: false,
36
+ });
37
+ });
38
+
39
+ it("parses single alias", () => {
40
+ expect(parseAddOptions(["--alias=foo"])).toEqual({
41
+ aliases: ["foo"],
42
+ description: undefined,
43
+ isProject: false,
44
+ });
45
+ });
46
+
47
+ it("handles multiple alias values with spaces", () => {
48
+ expect(parseAddOptions(["--aliases=hello, world, foo"])).toEqual({
49
+ aliases: ["hello", "world", "foo"],
50
+ description: undefined,
51
+ isProject: false,
52
+ });
53
+ });
54
+ });
55
+
56
+ // Description variations - all must work
57
+ describe("description parameter variations", () => {
58
+ it("parses --desc=value", () => {
59
+ expect(parseAddOptions(["--desc=hello"])).toEqual({
60
+ aliases: [],
61
+ description: "hello",
62
+ isProject: false,
63
+ });
64
+ });
65
+
66
+ it("parses --desc value (space-separated)", () => {
67
+ expect(parseAddOptions(["--desc", "hello"])).toEqual({
68
+ aliases: [],
69
+ description: "hello",
70
+ isProject: false,
71
+ });
72
+ });
73
+
74
+ it("parses --desc with apostrophe (main bug)", () => {
75
+ expect(parseAddOptions(["--desc=don't break"])).toEqual({
76
+ aliases: [],
77
+ description: "don't break",
78
+ isProject: false,
79
+ });
80
+ });
81
+
82
+ it("parses --description=value", () => {
83
+ expect(parseAddOptions(["--description=test"])).toEqual({
84
+ aliases: [],
85
+ description: "test",
86
+ isProject: false,
87
+ });
88
+ });
89
+
90
+ it("parses --description value (space-separated)", () => {
91
+ expect(parseAddOptions(["--description", "test"])).toEqual({
92
+ aliases: [],
93
+ description: "test",
94
+ isProject: false,
95
+ });
96
+ });
97
+
98
+ it("parses --desc with multiline content", () => {
99
+ expect(parseAddOptions(["--desc=line1\nline2"])).toEqual({
100
+ aliases: [],
101
+ description: "line1\nline2",
102
+ isProject: false,
103
+ });
104
+ });
105
+ });
106
+
107
+ // Project flag
108
+ describe("--project flag", () => {
109
+ it("parses --project flag", () => {
110
+ expect(parseAddOptions(["--project"])).toEqual({
111
+ aliases: [],
112
+ description: undefined,
113
+ isProject: true,
114
+ });
115
+ });
116
+
117
+ it("parses --project in any position", () => {
118
+ expect(parseAddOptions(["--desc=test", "--project"])).toEqual({
119
+ aliases: [],
120
+ description: "test",
121
+ isProject: true,
122
+ });
123
+ });
124
+ });
125
+
126
+ // Combined options
127
+ describe("combined options", () => {
128
+ it("parses multiple options together", () => {
129
+ expect(parseAddOptions(["--alias=a,b", "--desc=hello", "--project"])).toEqual({
130
+ aliases: ["a", "b"],
131
+ description: "hello",
132
+ isProject: true,
133
+ });
134
+ });
135
+
136
+ it("parses all space-separated options together", () => {
137
+ expect(parseAddOptions(["--alias", "a,b", "--desc", "hello", "--project"])).toEqual({
138
+ aliases: ["a", "b"],
139
+ description: "hello",
140
+ isProject: true,
141
+ });
142
+ });
143
+
144
+ it("handles mixed = and space syntax", () => {
145
+ expect(parseAddOptions(["--alias=a,b", "--desc", "hello"])).toEqual({
146
+ aliases: ["a", "b"],
147
+ description: "hello",
148
+ isProject: false,
149
+ });
150
+ });
151
+ });
152
+
153
+ // Edge cases
154
+ describe("edge cases", () => {
155
+ it("returns defaults for empty args", () => {
156
+ expect(parseAddOptions([])).toEqual({
157
+ aliases: [],
158
+ description: undefined,
159
+ isProject: false,
160
+ });
161
+ });
162
+
163
+ it("ignores unknown options", () => {
164
+ expect(parseAddOptions(["--unknown=value", "--desc=hello"])).toEqual({
165
+ aliases: [],
166
+ description: "hello",
167
+ isProject: false,
168
+ });
169
+ });
170
+
171
+ it("ignores positional args (non-option)", () => {
172
+ expect(parseAddOptions(["positional", "--desc=hello"])).toEqual({
173
+ aliases: [],
174
+ description: "hello",
175
+ isProject: false,
176
+ });
177
+ });
178
+
179
+ it("does not consume value after --project", () => {
180
+ // --project is a flag, should not consume next arg
181
+ expect(parseAddOptions(["--project", "--desc=hello"])).toEqual({
182
+ aliases: [],
183
+ description: "hello",
184
+ isProject: true,
185
+ });
186
+ });
187
+ });
188
+ });