opencode-snippets 1.4.2 → 1.5.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.
Files changed (62) hide show
  1. package/README.md +21 -5
  2. package/dist/index.d.ts +11 -0
  3. package/dist/index.d.ts.map +1 -0
  4. package/dist/index.js +157 -0
  5. package/dist/index.js.map +1 -0
  6. package/dist/src/arg-parser.d.ts +16 -0
  7. package/dist/src/arg-parser.d.ts.map +1 -0
  8. package/dist/src/arg-parser.js +94 -0
  9. package/dist/src/arg-parser.js.map +1 -0
  10. package/dist/src/commands.d.ts +30 -0
  11. package/dist/src/commands.d.ts.map +1 -0
  12. package/dist/src/commands.js +315 -0
  13. package/dist/src/commands.js.map +1 -0
  14. package/dist/src/config.d.ts +41 -0
  15. package/dist/src/config.d.ts.map +1 -0
  16. package/dist/src/config.js +150 -0
  17. package/dist/src/config.js.map +1 -0
  18. package/dist/src/constants.d.ts +35 -0
  19. package/dist/src/constants.d.ts.map +1 -0
  20. package/dist/src/constants.js +40 -0
  21. package/dist/src/constants.js.map +1 -0
  22. package/dist/src/expander.d.ts +36 -0
  23. package/dist/src/expander.d.ts.map +1 -0
  24. package/dist/src/expander.js +187 -0
  25. package/dist/src/expander.js.map +1 -0
  26. package/dist/src/loader.d.ts +46 -0
  27. package/dist/src/loader.d.ts.map +1 -0
  28. package/dist/src/loader.js +224 -0
  29. package/dist/src/loader.js.map +1 -0
  30. package/dist/src/logger.d.ts +15 -0
  31. package/dist/src/logger.d.ts.map +1 -0
  32. package/dist/src/logger.js +100 -0
  33. package/dist/src/logger.js.map +1 -0
  34. package/dist/src/notification.d.ts +11 -0
  35. package/dist/src/notification.d.ts.map +1 -0
  36. package/dist/src/notification.js +26 -0
  37. package/dist/src/notification.js.map +1 -0
  38. package/dist/src/shell.d.ts +18 -0
  39. package/dist/src/shell.d.ts.map +1 -0
  40. package/dist/src/shell.js +30 -0
  41. package/dist/src/shell.js.map +1 -0
  42. package/dist/src/types.d.ts +65 -0
  43. package/dist/src/types.d.ts.map +1 -0
  44. package/dist/src/types.js +2 -0
  45. package/dist/src/types.js.map +1 -0
  46. package/package.json +10 -6
  47. package/skill/snippets/SKILL.md +148 -0
  48. package/index.ts +0 -81
  49. package/src/arg-parser.test.ts +0 -177
  50. package/src/arg-parser.ts +0 -87
  51. package/src/commands.test.ts +0 -188
  52. package/src/commands.ts +0 -414
  53. package/src/constants.ts +0 -32
  54. package/src/expander.test.ts +0 -846
  55. package/src/expander.ts +0 -225
  56. package/src/loader.test.ts +0 -352
  57. package/src/loader.ts +0 -268
  58. package/src/logger.test.ts +0 -136
  59. package/src/logger.ts +0 -121
  60. package/src/notification.ts +0 -30
  61. package/src/shell.ts +0 -50
  62. package/src/types.ts +0 -71
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Executes shell commands in text using !`command` syntax
3
+ *
4
+ * @param text - The text containing shell commands to execute
5
+ * @param ctx - The plugin context (with Bun shell)
6
+ * @returns The text with shell commands replaced by their output
7
+ */
8
+ export type ShellContext = {
9
+ $: (template: TemplateStringsArray, ...args: unknown[]) => {
10
+ quiet: () => {
11
+ nothrow: () => {
12
+ text: () => Promise<string>;
13
+ };
14
+ };
15
+ };
16
+ };
17
+ export declare function executeShellCommands(text: string, ctx: ShellContext): Promise<string>;
18
+ //# sourceMappingURL=shell.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"shell.d.ts","sourceRoot":"","sources":["../../src/shell.ts"],"names":[],"mappings":"AAGA;;;;;;GAMG;AACH,MAAM,MAAM,YAAY,GAAG;IACzB,CAAC,EAAE,CACD,QAAQ,EAAE,oBAAoB,EAC9B,GAAG,IAAI,EAAE,OAAO,EAAE,KACf;QACH,KAAK,EAAE,MAAM;YAAE,OAAO,EAAE,MAAM;gBAAE,IAAI,EAAE,MAAM,OAAO,CAAC,MAAM,CAAC,CAAA;aAAE,CAAA;SAAE,CAAC;KACjE,CAAC;CACH,CAAC;AAEF,wBAAsB,oBAAoB,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,EAAE,YAAY,GAAG,OAAO,CAAC,MAAM,CAAC,CA8B3F"}
@@ -0,0 +1,30 @@
1
+ import { PATTERNS } from "./constants.js";
2
+ import { logger } from "./logger.js";
3
+ export async function executeShellCommands(text, ctx) {
4
+ let result = text;
5
+ // Reset regex state (global flag requires this)
6
+ PATTERNS.SHELL_COMMAND.lastIndex = 0;
7
+ // Find all shell command matches
8
+ const matches = [...text.matchAll(PATTERNS.SHELL_COMMAND)];
9
+ // Execute each command and replace in text
10
+ for (const match of matches) {
11
+ const cmd = match[1];
12
+ const _placeholder = match[0];
13
+ try {
14
+ const output = await ctx.$ `${{ raw: cmd }}`.quiet().nothrow().text();
15
+ // Deviate from slash commands' substitution mechanism: print command first, then output
16
+ const replacement = `$ ${cmd}\n--> ${output.trim()}`;
17
+ result = result.replace(_placeholder, replacement);
18
+ }
19
+ catch (error) {
20
+ // If shell command fails, leave it as-is
21
+ // This preserves the original syntax for debugging
22
+ logger.warn("Shell command execution failed", {
23
+ command: cmd,
24
+ error: error instanceof Error ? error.message : String(error),
25
+ });
26
+ }
27
+ }
28
+ return result;
29
+ }
30
+ //# sourceMappingURL=shell.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"shell.js","sourceRoot":"","sources":["../../src/shell.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AAC1C,OAAO,EAAE,MAAM,EAAE,MAAM,aAAa,CAAC;AAkBrC,MAAM,CAAC,KAAK,UAAU,oBAAoB,CAAC,IAAY,EAAE,GAAiB;IACxE,IAAI,MAAM,GAAG,IAAI,CAAC;IAElB,gDAAgD;IAChD,QAAQ,CAAC,aAAa,CAAC,SAAS,GAAG,CAAC,CAAC;IAErC,iCAAiC;IACjC,MAAM,OAAO,GAAG,CAAC,GAAG,IAAI,CAAC,QAAQ,CAAC,QAAQ,CAAC,aAAa,CAAC,CAAC,CAAC;IAE3D,2CAA2C;IAC3C,KAAK,MAAM,KAAK,IAAI,OAAO,EAAE,CAAC;QAC5B,MAAM,GAAG,GAAG,KAAK,CAAC,CAAC,CAAC,CAAC;QACrB,MAAM,YAAY,GAAG,KAAK,CAAC,CAAC,CAAC,CAAC;QAE9B,IAAI,CAAC;YACH,MAAM,MAAM,GAAG,MAAM,GAAG,CAAC,CAAC,CAAA,GAAG,EAAE,GAAG,EAAE,GAAG,EAAE,EAAE,CAAC,KAAK,EAAE,CAAC,OAAO,EAAE,CAAC,IAAI,EAAE,CAAC;YACrE,wFAAwF;YACxF,MAAM,WAAW,GAAG,KAAK,GAAG,SAAS,MAAM,CAAC,IAAI,EAAE,EAAE,CAAC;YACrD,MAAM,GAAG,MAAM,CAAC,OAAO,CAAC,YAAY,EAAE,WAAW,CAAC,CAAC;QACrD,CAAC;QAAC,OAAO,KAAK,EAAE,CAAC;YACf,yCAAyC;YACzC,mDAAmD;YACnD,MAAM,CAAC,IAAI,CAAC,gCAAgC,EAAE;gBAC5C,OAAO,EAAE,GAAG;gBACZ,KAAK,EAAE,KAAK,YAAY,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC;aAC9D,CAAC,CAAC;QACL,CAAC;IACH,CAAC;IAED,OAAO,MAAM,CAAC;AAChB,CAAC"}
@@ -0,0 +1,65 @@
1
+ import type { createOpencodeClient } from "@opencode-ai/sdk";
2
+ /**
3
+ * OpenCode client type from the SDK
4
+ */
5
+ export type OpencodeClient = ReturnType<typeof createOpencodeClient>;
6
+ /**
7
+ * A snippet with its content and metadata
8
+ */
9
+ export interface Snippet {
10
+ /** The primary name/key of the snippet */
11
+ name: string;
12
+ /** The content of the snippet (without frontmatter) */
13
+ content: string;
14
+ /** Alternative names that also trigger this snippet */
15
+ aliases: string[];
16
+ }
17
+ /**
18
+ * Extended snippet info with file metadata
19
+ */
20
+ export interface SnippetInfo {
21
+ name: string;
22
+ content: string;
23
+ aliases: string[];
24
+ description?: string;
25
+ filePath: string;
26
+ source: "global" | "project";
27
+ }
28
+ /**
29
+ * Snippet registry that maps keys to snippet info
30
+ */
31
+ export type SnippetRegistry = Map<string, SnippetInfo>;
32
+ /**
33
+ * Frontmatter data from snippet files
34
+ */
35
+ export interface SnippetFrontmatter {
36
+ /** Alternative hashtags for this snippet (plural form, preferred) */
37
+ aliases?: string | string[];
38
+ /** Alternative hashtags for this snippet (singular form, also accepted) */
39
+ alias?: string | string[];
40
+ /** Optional description of what this snippet does */
41
+ description?: string;
42
+ }
43
+ /**
44
+ * Parsed snippet content with inline text and prepend/append blocks
45
+ */
46
+ export interface ParsedSnippetContent {
47
+ /** Content outside blocks (replaces hashtag inline) */
48
+ inline: string;
49
+ /** <prepend> block contents in document order */
50
+ prepend: string[];
51
+ /** <append> block contents in document order */
52
+ append: string[];
53
+ }
54
+ /**
55
+ * Result of expanding hashtags, including collected prepend/append blocks
56
+ */
57
+ export interface ExpansionResult {
58
+ /** The inline-expanded text */
59
+ text: string;
60
+ /** Collected prepend blocks from all expanded snippets */
61
+ prepend: string[];
62
+ /** Collected append blocks from all expanded snippets */
63
+ append: string[];
64
+ }
65
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,kBAAkB,CAAC;AAE7D;;GAEG;AACH,MAAM,MAAM,cAAc,GAAG,UAAU,CAAC,OAAO,oBAAoB,CAAC,CAAC;AAErE;;GAEG;AACH,MAAM,WAAW,OAAO;IACtB,0CAA0C;IAC1C,IAAI,EAAE,MAAM,CAAC;IACb,uDAAuD;IACvD,OAAO,EAAE,MAAM,CAAC;IAChB,uDAAuD;IACvD,OAAO,EAAE,MAAM,EAAE,CAAC;CACnB;AAED;;GAEG;AACH,MAAM,WAAW,WAAW;IAC1B,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,MAAM,EAAE,CAAC;IAClB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,QAAQ,GAAG,SAAS,CAAC;CAC9B;AAED;;GAEG;AACH,MAAM,MAAM,eAAe,GAAG,GAAG,CAAC,MAAM,EAAE,WAAW,CAAC,CAAC;AAEvD;;GAEG;AACH,MAAM,WAAW,kBAAkB;IACjC,qEAAqE;IACrE,OAAO,CAAC,EAAE,MAAM,GAAG,MAAM,EAAE,CAAC;IAC5B,2EAA2E;IAC3E,KAAK,CAAC,EAAE,MAAM,GAAG,MAAM,EAAE,CAAC;IAC1B,qDAAqD;IACrD,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAED;;GAEG;AACH,MAAM,WAAW,oBAAoB;IACnC,uDAAuD;IACvD,MAAM,EAAE,MAAM,CAAC;IACf,iDAAiD;IACjD,OAAO,EAAE,MAAM,EAAE,CAAC;IAClB,gDAAgD;IAChD,MAAM,EAAE,MAAM,EAAE,CAAC;CAClB;AAED;;GAEG;AACH,MAAM,WAAW,eAAe;IAC9B,+BAA+B;IAC/B,IAAI,EAAE,MAAM,CAAC;IACb,0DAA0D;IAC1D,OAAO,EAAE,MAAM,EAAE,CAAC;IAClB,yDAAyD;IACzD,MAAM,EAAE,MAAM,EAAE,CAAC;CAClB"}
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=types.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.js","sourceRoot":"","sources":["../../src/types.ts"],"names":[],"mappings":""}
package/package.json CHANGED
@@ -1,8 +1,9 @@
1
1
  {
2
2
  "name": "opencode-snippets",
3
- "version": "1.4.2",
3
+ "version": "1.5.0",
4
4
  "description": "Hashtag-based snippet expansion plugin for OpenCode - instant inline text shortcuts",
5
- "main": "index.ts",
5
+ "main": "dist/index.js",
6
+ "types": "dist/index.d.ts",
6
7
  "type": "module",
7
8
  "engines": {
8
9
  "bun": ">=1.0.0"
@@ -28,7 +29,8 @@
28
29
  },
29
30
  "homepage": "https://github.com/JosXa/opencode-snippets#readme",
30
31
  "dependencies": {
31
- "gray-matter": "^4.0.3"
32
+ "gray-matter": "^4.0.3",
33
+ "jsonc-parser": "^3.3.1"
32
34
  },
33
35
  "peerDependencies": {
34
36
  "@opencode-ai/plugin": ">=1.0.0"
@@ -42,11 +44,13 @@
42
44
  "typescript": "^5"
43
45
  },
44
46
  "scripts": {
45
- "build": "tsgo --noEmit",
47
+ "build": "tsc -p tsconfig.build.json",
48
+ "typecheck": "tsgo --noEmit",
46
49
  "format:check": "biome check .",
47
50
  "format:fix": "biome check --write .",
48
51
  "ai:check": "bun run format:fix",
49
- "prepare": "husky"
52
+ "prepare": "husky",
53
+ "prepublishOnly": "bun run build"
50
54
  },
51
- "files": ["index.ts", "src/**/*.ts"]
55
+ "files": ["dist", "skill"]
52
56
  }
@@ -0,0 +1,148 @@
1
+ ---
2
+ name: snippets
3
+ description: MUST use when user asks to create, edit, manage, or share snippets, or asks how snippets work
4
+ ---
5
+
6
+ # Snippets
7
+
8
+ Reusable text blocks expanded via `#hashtag` in messages.
9
+
10
+ ## Locations
11
+
12
+ ### Snippets
13
+ - **Global**: `~/.config/opencode/snippet/*.md`
14
+ - **Project**: `.opencode/snippet/*.md` (overrides global)
15
+
16
+ ### Configuration
17
+ - **Global**: `~/.config/opencode/snippet/config.jsonc`
18
+ - **Project**: `.opencode/snippet/config.jsonc` (merges with global, project takes priority)
19
+
20
+ ### Logs
21
+ - **Debug logs**: `~/.config/opencode/logs/snippets/daily/YYYY-MM-DD.log`
22
+
23
+ ## Configuration
24
+
25
+ All boolean settings accept: `true`, `false`, `"enabled"`, `"disabled"`
26
+
27
+ Full config example with all options:
28
+
29
+ ```jsonc
30
+ {
31
+ // JSON Schema for editor autocompletion
32
+ "$schema": "https://raw.githubusercontent.com/JosXa/opencode-snippets/main/schema/config.schema.json",
33
+
34
+ // Logging settings
35
+ "logging": {
36
+ // Enable debug logging to file
37
+ // Logs are written to ~/.config/opencode/logs/snippets/daily/
38
+ // Default: false
39
+ "debug": false
40
+ },
41
+
42
+ // Automatically install SKILL.md to global skill directory
43
+ // When enabled, the snippets skill is copied to ~/.config/opencode/skill/snippets/
44
+ // This enables the LLM to understand how to use snippets
45
+ // Default: true
46
+ "installSkill": true
47
+ }
48
+ ```
49
+
50
+ ## Snippet Format
51
+
52
+ ```md
53
+ ---
54
+ aliases:
55
+ - short
56
+ - alt
57
+ description: Optional
58
+ ---
59
+ Content here
60
+ ```
61
+
62
+ Frontmatter optional. Filename (minus .md) = primary hashtag.
63
+
64
+ ## Features
65
+
66
+ - `#other` - include another snippet (recursive, max 15 depth)
67
+ - `` !`cmd` `` - shell substitution, output injected
68
+
69
+ ### Prepend/Append Blocks
70
+
71
+ Move content to message start/end instead of inline. Best for long reference material that breaks writing flow.
72
+
73
+ ```md
74
+ ---
75
+ aliases: jira
76
+ ---
77
+ Jira MCP
78
+ <prepend>
79
+ ## Jira Field Mappings
80
+
81
+ - customfield_16570 => Acceptance Criteria
82
+ - customfield_11401 => Team
83
+ </prepend>
84
+ ```
85
+
86
+ Input: `Create bug in #jira about leak`
87
+ Output: Prepended section at top + `Create bug in Jira MCP about leak`.
88
+
89
+ Use `<append>` for reference material at end. Content inside blocks should use `##` headings.
90
+
91
+ ## Commands
92
+
93
+ - `/snippet add <name> [content]` - create global snippet
94
+ - `/snippet add --project <name>` - create project snippet
95
+ - `/snippet list` - show all available
96
+ - `/snippet delete <name>` - remove snippet
97
+
98
+ ## Good Snippets
99
+
100
+ Short, focused, single-purpose. Examples:
101
+
102
+ ```md
103
+ # careful.md
104
+ ---
105
+ aliases: safe
106
+ ---
107
+ Be careful, autonomous, and ONLY do what I asked.
108
+ ```
109
+
110
+ ```md
111
+ # context.md
112
+ ---
113
+ aliases: ctx
114
+ ---
115
+ Project: !`basename $(pwd)`
116
+ Branch: !`git branch --show-current`
117
+ ```
118
+
119
+ Compose via includes: `#base-rules` inside `#project-config`.
120
+
121
+ ## Sharing Snippets
122
+
123
+ Share to GitHub Discussions: https://github.com/JosXa/opencode-snippets/discussions/categories/snippets
124
+
125
+ When user wants to share:
126
+
127
+ 1. Check `gh --version` works
128
+ 2. **If gh available**: MUST use question tool to ask user to confirm posting + ask "When do you use it?". Then:
129
+ ```bash
130
+ gh api graphql -f query='mutation($repoId: ID!, $catId: ID!, $title: String!, $body: String!) { createDiscussion(input: {repositoryId: $repoId, categoryId: $catId, title: $title, body: $body}) { discussion { url } } }' -f repoId="R_kgDOQ968oA" -f catId="DIC_kwDOQ968oM4C1Qcv" -f title="filename.md" -f body="<body>"
131
+ ```
132
+ Body format:
133
+ ```
134
+ ## Snippet Content
135
+
136
+ \`\`\`markdown
137
+ <full snippet file content>
138
+ \`\`\`
139
+
140
+ ## When do you use it?
141
+
142
+ <user's answer>
143
+ ```
144
+ 3. **If gh unavailable**: Open browser:
145
+ ```
146
+ https://github.com/JosXa/opencode-snippets/discussions/new?category=snippets&title=<url-encoded-filename>.md
147
+ ```
148
+ Ask user (without question tool) for "When do you use it?" info. Tell them to paste snippet in markdown fence.
package/index.ts DELETED
@@ -1,81 +0,0 @@
1
- import type { Plugin } from "@opencode-ai/plugin";
2
- import { createCommandExecuteHandler } from "./src/commands.js";
3
- import { assembleMessage, expandHashtags } from "./src/expander.js";
4
- import { loadSnippets } from "./src/loader.js";
5
- import { logger } from "./src/logger.js";
6
- import { executeShellCommands, type ShellContext } from "./src/shell.js";
7
-
8
- /**
9
- * Snippets Plugin for OpenCode
10
- *
11
- * Expands hashtag-based shortcuts in user messages into predefined text snippets.
12
- * Also provides /snippet command for managing snippets.
13
- *
14
- * @see https://github.com/JosXa/opencode-snippets for full documentation
15
- */
16
- export const SnippetsPlugin: Plugin = async (ctx) => {
17
- // Load all snippets at startup (global + project directory)
18
- const startupStart = performance.now();
19
- const snippets = await loadSnippets(ctx.directory);
20
- const startupTime = performance.now() - startupStart;
21
-
22
- logger.debug("Plugin startup complete", {
23
- startupTimeMs: startupTime.toFixed(2),
24
- snippetCount: snippets.size,
25
- });
26
-
27
- // Create command handler
28
- const commandHandler = createCommandExecuteHandler(ctx.client, snippets, ctx.directory);
29
-
30
- return {
31
- // Register /snippet command
32
- config: async (opencodeConfig) => {
33
- opencodeConfig.command ??= {};
34
- opencodeConfig.command.snippet = {
35
- template: "",
36
- description: "Manage text snippets (add, delete, list, help)",
37
- };
38
- },
39
-
40
- // Handle /snippet command execution
41
- "command.execute.before": commandHandler,
42
-
43
- "chat.message": async (_input, output) => {
44
- // Only process user messages, never assistant messages
45
- if (output.message.role !== "user") return;
46
-
47
- const messageStart = performance.now();
48
- let expandTimeTotal = 0;
49
- let shellTimeTotal = 0;
50
- let processedParts = 0;
51
-
52
- for (const part of output.parts) {
53
- if (part.type === "text" && part.text) {
54
- // 1. Expand hashtags recursively with loop detection
55
- const expandStart = performance.now();
56
- const expansionResult = expandHashtags(part.text, snippets);
57
- part.text = assembleMessage(expansionResult);
58
- const expandTime = performance.now() - expandStart;
59
- expandTimeTotal += expandTime;
60
-
61
- // 2. Execute shell commands: !`command`
62
- const shellStart = performance.now();
63
- part.text = await executeShellCommands(part.text, ctx as unknown as ShellContext);
64
- const shellTime = performance.now() - shellStart;
65
- shellTimeTotal += shellTime;
66
- processedParts += 1;
67
- }
68
- }
69
-
70
- const totalTime = performance.now() - messageStart;
71
- if (processedParts > 0) {
72
- logger.debug("Message processing complete", {
73
- totalTimeMs: totalTime.toFixed(2),
74
- snippetExpandTimeMs: expandTimeTotal.toFixed(2),
75
- shellTimeMs: shellTimeTotal.toFixed(2),
76
- processedParts,
77
- });
78
- }
79
- },
80
- };
81
- };
@@ -1,177 +0,0 @@
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
- });
package/src/arg-parser.ts DELETED
@@ -1,87 +0,0 @@
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
- }