opencode-snippets 1.4.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 +1 -1
- package/package.json +1 -1
- package/src/arg-parser.test.ts +177 -0
- package/src/arg-parser.ts +87 -0
- package/src/commands.test.ts +188 -0
- package/src/commands.ts +121 -42
- package/src/expander.test.ts +9 -1
- package/src/loader.test.ts +91 -0
- package/src/loader.ts +7 -5
- package/src/notification.ts +2 -1
- package/src/types.ts +11 -2
package/README.md
CHANGED
|
@@ -230,7 +230,7 @@ Use `<prepend>` for content that should appear at the top of your message. Multi
|
|
|
230
230
|
- If a snippet has only blocks (no inline content), the hashtag is simply removed
|
|
231
231
|
- Blocks from nested snippets are collected and assembled in the final message
|
|
232
232
|
- Unclosed tags are handled leniently (rest of content becomes the block)
|
|
233
|
-
- Nested
|
|
233
|
+
- Nested blocks are not allowed—the hashtag is left unchanged
|
|
234
234
|
|
|
235
235
|
## Example Snippets
|
|
236
236
|
|
package/package.json
CHANGED
|
@@ -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
|
+
});
|
package/src/commands.ts
CHANGED
|
@@ -1,14 +1,15 @@
|
|
|
1
|
+
import { parseCommandArgs } from "./arg-parser.js";
|
|
1
2
|
import { PATHS } from "./constants.js";
|
|
2
3
|
import { createSnippet, deleteSnippet, listSnippets, reloadSnippets } from "./loader.js";
|
|
3
4
|
import { logger } from "./logger.js";
|
|
4
5
|
import { sendIgnoredMessage } from "./notification.js";
|
|
5
|
-
import type { SnippetRegistry } from "./types.js";
|
|
6
|
+
import type { OpencodeClient, SnippetRegistry } from "./types.js";
|
|
6
7
|
|
|
7
8
|
/** Marker error to indicate command was handled */
|
|
8
9
|
const COMMAND_HANDLED_MARKER = "__SNIPPETS_COMMAND_HANDLED__";
|
|
9
10
|
|
|
10
11
|
interface CommandContext {
|
|
11
|
-
client:
|
|
12
|
+
client: OpencodeClient;
|
|
12
13
|
sessionId: string;
|
|
13
14
|
args: string[];
|
|
14
15
|
rawArguments: string;
|
|
@@ -16,18 +17,113 @@ interface CommandContext {
|
|
|
16
17
|
projectDir?: string;
|
|
17
18
|
}
|
|
18
19
|
|
|
20
|
+
/**
|
|
21
|
+
* Parsed options from the add command arguments
|
|
22
|
+
*/
|
|
23
|
+
export interface AddOptions {
|
|
24
|
+
aliases: string[];
|
|
25
|
+
description: string | undefined;
|
|
26
|
+
isProject: boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Parses option arguments for the add command.
|
|
31
|
+
*
|
|
32
|
+
* Supports all variations per PR #13 requirements:
|
|
33
|
+
* - --alias=a,b, --alias a,b, --aliases=a,b, --aliases a,b
|
|
34
|
+
* - --desc=x, --desc x, --description=x, --description x
|
|
35
|
+
* - --project flag
|
|
36
|
+
*
|
|
37
|
+
* @param args - Array of parsed arguments (after name and content extraction)
|
|
38
|
+
* @returns Parsed options object
|
|
39
|
+
*/
|
|
40
|
+
export function parseAddOptions(args: string[]): AddOptions {
|
|
41
|
+
const result: AddOptions = {
|
|
42
|
+
aliases: [],
|
|
43
|
+
description: undefined,
|
|
44
|
+
isProject: false,
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
for (let i = 0; i < args.length; i++) {
|
|
48
|
+
const arg = args[i];
|
|
49
|
+
|
|
50
|
+
// Skip non-option arguments
|
|
51
|
+
if (!arg.startsWith("--")) {
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Handle --project flag
|
|
56
|
+
if (arg === "--project") {
|
|
57
|
+
result.isProject = true;
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Check for --alias or --aliases
|
|
62
|
+
if (arg === "--alias" || arg === "--aliases") {
|
|
63
|
+
// Space-separated: --alias a,b
|
|
64
|
+
const nextArg = args[i + 1];
|
|
65
|
+
if (nextArg && !nextArg.startsWith("--")) {
|
|
66
|
+
result.aliases = parseAliasValue(nextArg);
|
|
67
|
+
i++; // Skip the value arg
|
|
68
|
+
}
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (arg.startsWith("--alias=") || arg.startsWith("--aliases=")) {
|
|
73
|
+
// Equals syntax: --alias=a,b
|
|
74
|
+
const value = arg.includes("--aliases=")
|
|
75
|
+
? arg.slice("--aliases=".length)
|
|
76
|
+
: arg.slice("--alias=".length);
|
|
77
|
+
result.aliases = parseAliasValue(value);
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Check for --desc or --description
|
|
82
|
+
if (arg === "--desc" || arg === "--description") {
|
|
83
|
+
// Space-separated: --desc value
|
|
84
|
+
const nextArg = args[i + 1];
|
|
85
|
+
if (nextArg && !nextArg.startsWith("--")) {
|
|
86
|
+
result.description = nextArg;
|
|
87
|
+
i++; // Skip the value arg
|
|
88
|
+
}
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (arg.startsWith("--desc=") || arg.startsWith("--description=")) {
|
|
93
|
+
// Equals syntax: --desc=value
|
|
94
|
+
const value = arg.startsWith("--description=")
|
|
95
|
+
? arg.slice("--description=".length)
|
|
96
|
+
: arg.slice("--desc=".length);
|
|
97
|
+
result.description = value;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return result;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Parse comma-separated alias values, trimming whitespace
|
|
106
|
+
*/
|
|
107
|
+
function parseAliasValue(value: string): string[] {
|
|
108
|
+
return value
|
|
109
|
+
.split(",")
|
|
110
|
+
.map((s) => s.trim())
|
|
111
|
+
.filter(Boolean);
|
|
112
|
+
}
|
|
113
|
+
|
|
19
114
|
/**
|
|
20
115
|
* Creates the command execute handler for the snippets command
|
|
21
116
|
*/
|
|
22
117
|
export function createCommandExecuteHandler(
|
|
23
|
-
client:
|
|
118
|
+
client: OpencodeClient,
|
|
24
119
|
snippets: SnippetRegistry,
|
|
25
120
|
projectDir?: string,
|
|
26
121
|
) {
|
|
27
122
|
return async (input: { command: string; sessionID: string; arguments: string }) => {
|
|
28
123
|
if (input.command !== "snippet") return;
|
|
29
124
|
|
|
30
|
-
|
|
125
|
+
// Use shell-like argument parsing to handle quoted strings correctly
|
|
126
|
+
const args = parseCommandArgs(input.arguments);
|
|
31
127
|
const subcommand = args[0]?.toLowerCase() || "help";
|
|
32
128
|
|
|
33
129
|
const ctx: CommandContext = {
|
|
@@ -83,7 +179,7 @@ export function createCommandExecuteHandler(
|
|
|
83
179
|
* Handle /snippet add <name> ["content"] [--project] [--alias=<alias>] [--desc=<description>]
|
|
84
180
|
*/
|
|
85
181
|
async function handleAddCommand(ctx: CommandContext): Promise<void> {
|
|
86
|
-
const { client, sessionId, args,
|
|
182
|
+
const { client, sessionId, args, snippets, projectDir } = ctx;
|
|
87
183
|
|
|
88
184
|
if (args.length === 0) {
|
|
89
185
|
await sendIgnoredMessage(
|
|
@@ -106,47 +202,30 @@ async function handleAddCommand(ctx: CommandContext): Promise<void> {
|
|
|
106
202
|
|
|
107
203
|
const name = args[0];
|
|
108
204
|
|
|
109
|
-
// Extract
|
|
110
|
-
//
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
const isProject = args.includes("--project");
|
|
115
|
-
const aliases: string[] = [];
|
|
116
|
-
let description: string | undefined;
|
|
117
|
-
|
|
118
|
-
// Parse arguments with --param value syntax
|
|
119
|
-
for (let i = 1; i < args.length; i++) {
|
|
120
|
-
const arg = args[i];
|
|
205
|
+
// Extract content: second argument if it doesn't start with --
|
|
206
|
+
// The arg-parser already handles quoted strings, so content is clean
|
|
207
|
+
let content = "";
|
|
208
|
+
let optionArgs = args.slice(1);
|
|
121
209
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
if (nextArg && !nextArg.startsWith("--")) {
|
|
126
|
-
const values = nextArg
|
|
127
|
-
.split(",")
|
|
128
|
-
.map((s) => s.trim())
|
|
129
|
-
.filter(Boolean);
|
|
130
|
-
aliases.push(...values);
|
|
131
|
-
i++; // Skip the value arg
|
|
132
|
-
}
|
|
133
|
-
}
|
|
134
|
-
// Handle --desc or --description
|
|
135
|
-
else if (arg === "--desc" || arg === "--description") {
|
|
136
|
-
const nextArg = args[i + 1];
|
|
137
|
-
if (nextArg && !nextArg.startsWith("--")) {
|
|
138
|
-
description = nextArg;
|
|
139
|
-
i++; // Skip the value arg
|
|
140
|
-
}
|
|
141
|
-
}
|
|
210
|
+
if (args[1] && !args[1].startsWith("--")) {
|
|
211
|
+
content = args[1];
|
|
212
|
+
optionArgs = args.slice(2);
|
|
142
213
|
}
|
|
143
214
|
|
|
215
|
+
// Parse all options using the new parser
|
|
216
|
+
const options = parseAddOptions(optionArgs);
|
|
217
|
+
|
|
144
218
|
// Default to global, --project puts it in project directory
|
|
145
|
-
const targetDir = isProject ? projectDir : undefined;
|
|
146
|
-
const location = isProject && projectDir ? "project" : "global";
|
|
219
|
+
const targetDir = options.isProject ? projectDir : undefined;
|
|
220
|
+
const location = options.isProject && projectDir ? "project" : "global";
|
|
147
221
|
|
|
148
222
|
try {
|
|
149
|
-
const filePath = await createSnippet(
|
|
223
|
+
const filePath = await createSnippet(
|
|
224
|
+
name,
|
|
225
|
+
content,
|
|
226
|
+
{ aliases: options.aliases, description: options.description },
|
|
227
|
+
targetDir,
|
|
228
|
+
);
|
|
150
229
|
|
|
151
230
|
// Reload snippets
|
|
152
231
|
await reloadSnippets(snippets, projectDir);
|
|
@@ -157,8 +236,8 @@ async function handleAddCommand(ctx: CommandContext): Promise<void> {
|
|
|
157
236
|
} else {
|
|
158
237
|
message += "\n\nEdit the file to add your snippet content.";
|
|
159
238
|
}
|
|
160
|
-
if (aliases.length > 0) {
|
|
161
|
-
message += `\nAliases: ${aliases.join(", ")}`;
|
|
239
|
+
if (options.aliases.length > 0) {
|
|
240
|
+
message += `\nAliases: ${options.aliases.join(", ")}`;
|
|
162
241
|
}
|
|
163
242
|
|
|
164
243
|
await sendIgnoredMessage(client, sessionId, message);
|
package/src/expander.test.ts
CHANGED
|
@@ -563,7 +563,7 @@ Only append content
|
|
|
563
563
|
});
|
|
564
564
|
});
|
|
565
565
|
|
|
566
|
-
it("should return null for nested tags", () => {
|
|
566
|
+
it("should return null for nested tags (different types)", () => {
|
|
567
567
|
const content = "<append>\n<prepend>\nnested\n</prepend>\n</append>";
|
|
568
568
|
|
|
569
569
|
const result = parseSnippetBlocks(content);
|
|
@@ -571,6 +571,14 @@ Only append content
|
|
|
571
571
|
expect(result).toBeNull();
|
|
572
572
|
});
|
|
573
573
|
|
|
574
|
+
it("should return null for nested tags (same type)", () => {
|
|
575
|
+
const content = "<prepend>\n<prepend>\nnested\n</prepend>\n</prepend>";
|
|
576
|
+
|
|
577
|
+
const result = parseSnippetBlocks(content);
|
|
578
|
+
|
|
579
|
+
expect(result).toBeNull();
|
|
580
|
+
});
|
|
581
|
+
|
|
574
582
|
it("should trim content inside blocks", () => {
|
|
575
583
|
const content = "<append>\n \n Content with whitespace \n \n</append>";
|
|
576
584
|
|
package/src/loader.test.ts
CHANGED
|
@@ -258,4 +258,95 @@ Content`,
|
|
|
258
258
|
expect(Array.isArray(Array.from(snippets.keys()))).toBe(true);
|
|
259
259
|
});
|
|
260
260
|
});
|
|
261
|
+
|
|
262
|
+
// PR #13 requirement: accept both 'alias' and 'aliases' in frontmatter
|
|
263
|
+
describe("Frontmatter alias/aliases normalization", () => {
|
|
264
|
+
it("accepts 'alias' as string (singular form)", async () => {
|
|
265
|
+
await writeFile(
|
|
266
|
+
join(globalSnippetDir, "greeting.md"),
|
|
267
|
+
`---
|
|
268
|
+
alias: hi
|
|
269
|
+
---
|
|
270
|
+
Hello there!`,
|
|
271
|
+
);
|
|
272
|
+
|
|
273
|
+
const snippets = await loadSnippets(undefined, globalSnippetDir);
|
|
274
|
+
|
|
275
|
+
expect(snippets.size).toBe(2);
|
|
276
|
+
expect(snippets.get("greeting")?.content).toBe("Hello there!");
|
|
277
|
+
expect(snippets.get("hi")?.content).toBe("Hello there!");
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
it("accepts 'alias' as array (singular form)", async () => {
|
|
281
|
+
await writeFile(
|
|
282
|
+
join(globalSnippetDir, "greeting.md"),
|
|
283
|
+
`---
|
|
284
|
+
alias:
|
|
285
|
+
- hi
|
|
286
|
+
- hello
|
|
287
|
+
---
|
|
288
|
+
Hello there!`,
|
|
289
|
+
);
|
|
290
|
+
|
|
291
|
+
const snippets = await loadSnippets(undefined, globalSnippetDir);
|
|
292
|
+
|
|
293
|
+
expect(snippets.size).toBe(3);
|
|
294
|
+
expect(snippets.get("greeting")?.content).toBe("Hello there!");
|
|
295
|
+
expect(snippets.get("hi")?.content).toBe("Hello there!");
|
|
296
|
+
expect(snippets.get("hello")?.content).toBe("Hello there!");
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
it("accepts 'aliases' as string (existing behavior)", async () => {
|
|
300
|
+
await writeFile(
|
|
301
|
+
join(globalSnippetDir, "greeting.md"),
|
|
302
|
+
`---
|
|
303
|
+
aliases: hi
|
|
304
|
+
---
|
|
305
|
+
Hello there!`,
|
|
306
|
+
);
|
|
307
|
+
|
|
308
|
+
const snippets = await loadSnippets(undefined, globalSnippetDir);
|
|
309
|
+
|
|
310
|
+
expect(snippets.size).toBe(2);
|
|
311
|
+
expect(snippets.get("greeting")?.content).toBe("Hello there!");
|
|
312
|
+
expect(snippets.get("hi")?.content).toBe("Hello there!");
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
it("accepts 'aliases' as array (existing behavior)", async () => {
|
|
316
|
+
await writeFile(
|
|
317
|
+
join(globalSnippetDir, "greeting.md"),
|
|
318
|
+
`---
|
|
319
|
+
aliases:
|
|
320
|
+
- hi
|
|
321
|
+
- hello
|
|
322
|
+
---
|
|
323
|
+
Hello there!`,
|
|
324
|
+
);
|
|
325
|
+
|
|
326
|
+
const snippets = await loadSnippets(undefined, globalSnippetDir);
|
|
327
|
+
|
|
328
|
+
expect(snippets.size).toBe(3);
|
|
329
|
+
expect(snippets.get("greeting")?.content).toBe("Hello there!");
|
|
330
|
+
expect(snippets.get("hi")?.content).toBe("Hello there!");
|
|
331
|
+
expect(snippets.get("hello")?.content).toBe("Hello there!");
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
it("prefers 'aliases' over 'alias' if both present", async () => {
|
|
335
|
+
await writeFile(
|
|
336
|
+
join(globalSnippetDir, "greeting.md"),
|
|
337
|
+
`---
|
|
338
|
+
alias: ignored
|
|
339
|
+
aliases: used
|
|
340
|
+
---
|
|
341
|
+
Hello there!`,
|
|
342
|
+
);
|
|
343
|
+
|
|
344
|
+
const snippets = await loadSnippets(undefined, globalSnippetDir);
|
|
345
|
+
|
|
346
|
+
expect(snippets.size).toBe(2);
|
|
347
|
+
expect(snippets.get("greeting")?.content).toBe("Hello there!");
|
|
348
|
+
expect(snippets.get("used")?.content).toBe("Hello there!");
|
|
349
|
+
expect(snippets.has("ignored")).toBe(false);
|
|
350
|
+
});
|
|
351
|
+
});
|
|
261
352
|
});
|
package/src/loader.ts
CHANGED
|
@@ -90,13 +90,15 @@ async function loadSnippetFile(
|
|
|
90
90
|
const content = parsed.content.trim();
|
|
91
91
|
const frontmatter = parsed.data as SnippetFrontmatter;
|
|
92
92
|
|
|
93
|
-
// Handle aliases
|
|
93
|
+
// Handle aliases: accept both 'aliases' (plural) and 'alias' (singular)
|
|
94
|
+
// Prefer 'aliases' if both are present
|
|
94
95
|
let aliases: string[] = [];
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
96
|
+
const aliasSource = frontmatter.aliases ?? frontmatter.alias;
|
|
97
|
+
if (aliasSource) {
|
|
98
|
+
if (Array.isArray(aliasSource)) {
|
|
99
|
+
aliases = aliasSource;
|
|
98
100
|
} else {
|
|
99
|
-
aliases = [
|
|
101
|
+
aliases = [aliasSource];
|
|
100
102
|
}
|
|
101
103
|
}
|
|
102
104
|
|
package/src/notification.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { logger } from "./logger.js";
|
|
2
|
+
import type { OpencodeClient } from "./types.js";
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Sends a message that will be displayed but ignored by the AI
|
|
@@ -9,7 +10,7 @@ import { logger } from "./logger.js";
|
|
|
9
10
|
* @param text - The text to display
|
|
10
11
|
*/
|
|
11
12
|
export async function sendIgnoredMessage(
|
|
12
|
-
client:
|
|
13
|
+
client: OpencodeClient,
|
|
13
14
|
sessionId: string,
|
|
14
15
|
text: string,
|
|
15
16
|
): Promise<void> {
|
package/src/types.ts
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
import type { createOpencodeClient } from "@opencode-ai/sdk";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* OpenCode client type from the SDK
|
|
5
|
+
*/
|
|
6
|
+
export type OpencodeClient = ReturnType<typeof createOpencodeClient>;
|
|
7
|
+
|
|
1
8
|
/**
|
|
2
9
|
* A snippet with its content and metadata
|
|
3
10
|
*/
|
|
@@ -31,8 +38,10 @@ export type SnippetRegistry = Map<string, SnippetInfo>;
|
|
|
31
38
|
* Frontmatter data from snippet files
|
|
32
39
|
*/
|
|
33
40
|
export interface SnippetFrontmatter {
|
|
34
|
-
/** Alternative hashtags for this snippet */
|
|
35
|
-
aliases?: string[];
|
|
41
|
+
/** Alternative hashtags for this snippet (plural form, preferred) */
|
|
42
|
+
aliases?: string | string[];
|
|
43
|
+
/** Alternative hashtags for this snippet (singular form, also accepted) */
|
|
44
|
+
alias?: string | string[];
|
|
36
45
|
/** Optional description of what this snippet does */
|
|
37
46
|
description?: string;
|
|
38
47
|
}
|