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 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 `<prepend>` inside `<append>` (or vice versa) is an error—the hashtag is left unchanged
233
+ - Nested blocks are not allowed—the hashtag is left unchanged
234
234
 
235
235
  ## Example Snippets
236
236
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-snippets",
3
- "version": "1.4.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
+ });
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: any;
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: any,
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
- const args = input.arguments.split(/\s+/).filter(Boolean);
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, rawArguments, snippets, projectDir } = ctx;
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 quoted content from raw arguments
110
- // Match content between quotes after the subcommand and name
111
- const quotedMatch = rawArguments.match(/(?:add|create|new)\s+\S+\s+"([^"]+)"/i);
112
- const content = quotedMatch ? quotedMatch[1] : "";
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
- // Handle --aliases
123
- if (arg === "--aliases") {
124
- const nextArg = args[i + 1];
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(name, content, { aliases, description }, targetDir);
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);
@@ -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
 
@@ -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 as string or array
93
+ // Handle aliases: accept both 'aliases' (plural) and 'alias' (singular)
94
+ // Prefer 'aliases' if both are present
94
95
  let aliases: string[] = [];
95
- if (frontmatter.aliases) {
96
- if (Array.isArray(frontmatter.aliases)) {
97
- aliases = frontmatter.aliases;
96
+ const aliasSource = frontmatter.aliases ?? frontmatter.alias;
97
+ if (aliasSource) {
98
+ if (Array.isArray(aliasSource)) {
99
+ aliases = aliasSource;
98
100
  } else {
99
- aliases = [frontmatter.aliases];
101
+ aliases = [aliasSource];
100
102
  }
101
103
  }
102
104
 
@@ -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: any,
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
  }