import { extractFirstParagraph, parseSlashCommandContent, serializeCommand, serializeSlashCommandMarkdown, validateCommandName, yamlString } from '@/utils/slashCommand'; describe('parseSlashCommandContent', () => { describe('basic parsing', () => { it('should parse command with full frontmatter', () => { const content = `--- description: Review code for issues argument-hint: "[file] [focus]" allowed-tools: - Read - Grep model: claude-sonnet-4-5 --- Review this code: $ARGUMENTS`; const parsed = parseSlashCommandContent(content); expect(parsed.description).toBe('Review code for issues'); expect(parsed.argumentHint).toBe('[file] [focus]'); expect(parsed.allowedTools).toEqual(['Read', 'Grep']); expect(parsed.model).toBe('claude-sonnet-4-5'); expect(parsed.promptContent).toBe('Review this code: $ARGUMENTS'); }); it('should parse command with minimal frontmatter', () => { const content = `--- description: Simple command --- Do something`; const parsed = parseSlashCommandContent(content); expect(parsed.description).toBe('Simple command'); expect(parsed.argumentHint).toBeUndefined(); expect(parsed.allowedTools).toBeUndefined(); expect(parsed.model).toBeUndefined(); expect(parsed.promptContent).toBe('Do something'); }); it('should handle content without frontmatter', () => { const content = 'Just a prompt without frontmatter'; const parsed = parseSlashCommandContent(content); expect(parsed.description).toBeUndefined(); expect(parsed.promptContent).toBe('Just a prompt without frontmatter'); }); it('should handle inline array syntax for allowed-tools', () => { const content = `--- allowed-tools: [Read, Write, Bash] --- Prompt`; const parsed = parseSlashCommandContent(content); expect(parsed.allowedTools).toEqual(['Read', 'Write', 'Bash']); }); it('should handle quoted values', () => { const content = `--- description: "Value with: colon" argument-hint: 'Single quoted' --- Prompt`; const parsed = parseSlashCommandContent(content); expect(parsed.description).toBe('Value with: colon'); expect(parsed.argumentHint).toBe('Single quoted'); }); }); describe('block scalar support', () => { it('should parse literal block scalar (|) for description', () => { const content = `--- description: | Records a checkpoint of progress in the daily note. Includes timestamp and current task status. --- Prompt`; const parsed = parseSlashCommandContent(content); expect(parsed.description).toBe('Records a checkpoint of progress in the daily note.\nIncludes timestamp and current task status.'); expect(parsed.promptContent).toBe('Prompt'); }); it('should parse folded block scalar (>) for description', () => { const content = `--- description: > Records a checkpoint of progress in the daily note. --- Prompt`; const parsed = parseSlashCommandContent(content); expect(parsed.description).toBe('Records a checkpoint of progress in the daily note.'); expect(parsed.promptContent).toBe('Prompt'); }); it('should produce different output for | vs > block scalars', () => { const literalContent = `--- description: | Line one Line two --- Prompt`; const foldedContent = `--- description: > Line one Line two --- Prompt`; const literal = parseSlashCommandContent(literalContent); const folded = parseSlashCommandContent(foldedContent); expect(literal.description).toBe('Line one\nLine two'); expect(folded.description).toBe('Line one Line two'); expect(literal.description).not.toBe(folded.description); }); it('should preserve paragraph breaks in folded block scalar (>)', () => { const content = `--- description: > First paragraph here. Second paragraph after empty line. --- Prompt`; const parsed = parseSlashCommandContent(content); expect(parsed.description).toBe('First paragraph here.\n\nSecond paragraph after empty line.'); expect(parsed.promptContent).toBe('Prompt'); }); it('should parse literal block scalar for argument-hint', () => { const content = `--- argument-hint: | [task-name] [optional-notes] --- Prompt`; const parsed = parseSlashCommandContent(content); expect(parsed.argumentHint).toBe('[task-name]\n[optional-notes]'); }); it('should handle empty lines in literal block scalar', () => { const content = `--- description: | First paragraph here. Second paragraph after empty line. --- Prompt`; const parsed = parseSlashCommandContent(content); expect(parsed.description).toBe('First paragraph here.\n\nSecond paragraph after empty line.'); }); it('should parse block scalar with other fields', () => { const content = `--- description: | Multi-line description with multiple lines model: claude-sonnet-4-5 allowed-tools: - Read - Write --- Prompt content`; const parsed = parseSlashCommandContent(content); expect(parsed.description).toBe('Multi-line description\nwith multiple lines'); expect(parsed.model).toBe('claude-sonnet-4-5'); expect(parsed.allowedTools).toEqual(['Read', 'Write']); expect(parsed.promptContent).toBe('Prompt content'); }); it('should handle block scalar at end of frontmatter', () => { const content = `--- model: claude-haiku-4-5 description: | Last field in frontmatter with multiple lines --- Prompt`; const parsed = parseSlashCommandContent(content); expect(parsed.description).toBe('Last field in frontmatter\nwith multiple lines'); expect(parsed.model).toBe('claude-haiku-4-5'); }); it('should preserve indentation within block scalar content', () => { const content = `--- description: | Code example: - Step 1 - Step 2 --- Prompt`; const parsed = parseSlashCommandContent(content); expect(parsed.description).toBe('Code example:\n - Step 1\n - Step 2'); }); it('should handle single-line block scalar', () => { const content = `--- description: | Just one line --- Prompt`; const parsed = parseSlashCommandContent(content); expect(parsed.description).toBe('Just one line'); }); it('should not confuse pipe in quoted string with block scalar', () => { const content = `--- description: "Contains | pipe character" --- Prompt`; const parsed = parseSlashCommandContent(content); expect(parsed.description).toBe('Contains | pipe character'); }); it('should handle multiple block scalars in same frontmatter', () => { const content = `--- description: | First block scalar with multiple lines argument-hint: | Second block scalar also multi-line --- Prompt`; const parsed = parseSlashCommandContent(content); expect(parsed.description).toBe('First block scalar\nwith multiple lines'); expect(parsed.argumentHint).toBe('Second block scalar\nalso multi-line'); }); it('should handle CRLF line endings in block scalar', () => { const content = '---\r\ndescription: |\r\n Line one\r\n Line two\r\n---\r\nPrompt'; const parsed = parseSlashCommandContent(content); expect(parsed.description).toBe('Line one\nLine two'); expect(parsed.promptContent).toBe('Prompt'); }); }); describe('block scalar edge cases', () => { it('should handle empty block scalar followed by another field', () => { const content = `--- description: | model: claude-sonnet-4-5 --- Prompt`; const parsed = parseSlashCommandContent(content); // Empty block scalar yields no description (semantically same as absent) expect(parsed.description).toBeUndefined(); expect(parsed.model).toBe('claude-sonnet-4-5'); }); it('should handle block scalar with only empty lines before next field', () => { const content = `--- description: | model: claude-sonnet-4-5 --- Prompt`; const parsed = parseSlashCommandContent(content); // Empty lines followed by unindented field should end the block scalar expect(parsed.model).toBe('claude-sonnet-4-5'); }); it('should handle strip chomping indicator (|-)', () => { const content = `--- description: |- No trailing newline here --- Prompt`; const parsed = parseSlashCommandContent(content); // Chomping indicator is recognized and parsed as block scalar expect(parsed.description).toBe('No trailing newline here'); }); it('should handle keep chomping indicator (|+)', () => { const content = `--- description: |+ Keep indicator recognized --- Prompt`; const parsed = parseSlashCommandContent(content); // Keep chomping indicator is recognized expect(parsed.description).toBe('Keep indicator recognized'); }); it('should handle folded with strip chomping (>-)', () => { const content = `--- description: >- Folded with strip chomping indicator --- Prompt`; const parsed = parseSlashCommandContent(content); expect(parsed.description).toBe('Folded with strip chomping indicator'); }); it('should not enable block scalar for unsupported keys', () => { const content = `--- notes: | This should not be parsed as block scalar description: Regular description --- Prompt`; const parsed = parseSlashCommandContent(content); // notes is not a supported key, so | is treated as the value // description should be parsed normally expect(parsed.description).toBe('Regular description'); }); it('should handle allowed-tools with block scalar indicator gracefully', () => { const content = `--- allowed-tools: | Read Write description: Test --- Prompt`; const parsed = parseSlashCommandContent(content); // allowed-tools doesn't support block scalar, so | becomes the value // The Read/Write lines are ignored as they're not valid YAML keys expect(parsed.description).toBe('Test'); }); it('should preserve unicode content in block scalar', () => { const content = `--- description: | Hello 世界 Émoji: 🎉 --- Prompt`; const parsed = parseSlashCommandContent(content); expect(parsed.description).toBe('Hello 世界\nÉmoji: 🎉'); }); it('should preserve relative indentation in deeply nested content', () => { const content = `--- description: | Level 1 Level 2 Level 3 Level 4 --- Prompt`; const parsed = parseSlashCommandContent(content); expect(parsed.description).toBe('Level 1\n Level 2\n Level 3\n Level 4'); }); it('should preserve colons in block scalar content', () => { const content = `--- description: | key: value another: pair --- Prompt`; const parsed = parseSlashCommandContent(content); expect(parsed.description).toBe('key: value\nanother: pair'); }); it('should preserve comment-like content (# lines)', () => { const content = `--- description: | # This looks like a YAML comment But it is preserved as content --- Prompt`; const parsed = parseSlashCommandContent(content); expect(parsed.description).toBe('# This looks like a YAML comment\nBut it is preserved as content'); }); it('should preserve trailing whitespace in block scalar lines', () => { // Use explicit string to ensure trailing spaces are preserved const content = '---\ndescription: |\n Line with trailing spaces \n Normal line\n---\nPrompt'; const parsed = parseSlashCommandContent(content); expect(parsed.description).toBe('Line with trailing spaces \nNormal line'); }); it('should preserve leading empty lines in block scalar', () => { const content = `--- description: | Content after empty line --- Prompt`; const parsed = parseSlashCommandContent(content); // Leading empty lines are preserved per YAML spec expect(parsed.description).toBe('\nContent after empty line'); }); }); describe('skill fields', () => { it('should parse kebab-case disable-model-invocation', () => { const content = `--- description: A skill disable-model-invocation: true --- Prompt`; const parsed = parseSlashCommandContent(content); expect(parsed.disableModelInvocation).toBe(true); }); it('should parse kebab-case user-invocable', () => { const content = `--- description: A skill user-invocable: false --- Prompt`; const parsed = parseSlashCommandContent(content); expect(parsed.userInvocable).toBe(false); }); it('should parse camelCase disableModelInvocation (backwards compat)', () => { const content = `--- description: A skill disableModelInvocation: true --- Prompt`; const parsed = parseSlashCommandContent(content); expect(parsed.disableModelInvocation).toBe(true); }); it('should parse camelCase userInvocable (backwards compat)', () => { const content = `--- description: A skill userInvocable: false --- Prompt`; const parsed = parseSlashCommandContent(content); expect(parsed.userInvocable).toBe(false); }); it('should prefer kebab-case over camelCase when both present', () => { const content = `--- disable-model-invocation: true disableModelInvocation: false user-invocable: false userInvocable: true --- Prompt`; const parsed = parseSlashCommandContent(content); expect(parsed.disableModelInvocation).toBe(true); expect(parsed.userInvocable).toBe(false); }); it('should parse context string', () => { const content = `--- description: A skill context: fork --- Prompt`; const parsed = parseSlashCommandContent(content); expect(parsed.context).toBe('fork'); }); it('should parse agent string', () => { const content = `--- description: A skill agent: code-reviewer --- Prompt`; const parsed = parseSlashCommandContent(content); expect(parsed.agent).toBe('code-reviewer'); }); it('should parse all skill fields together', () => { const content = `--- description: Full skill disableModelInvocation: true userInvocable: true context: fork agent: code-reviewer model: sonnet --- Do the thing`; const parsed = parseSlashCommandContent(content); expect(parsed.description).toBe('Full skill'); expect(parsed.disableModelInvocation).toBe(true); expect(parsed.userInvocable).toBe(true); expect(parsed.context).toBe('fork'); expect(parsed.agent).toBe('code-reviewer'); expect(parsed.model).toBe('sonnet'); expect(parsed.promptContent).toBe('Do the thing'); }); it('should return undefined for missing skill fields', () => { const content = `--- description: Simple command --- Prompt`; const parsed = parseSlashCommandContent(content); expect(parsed.disableModelInvocation).toBeUndefined(); expect(parsed.userInvocable).toBeUndefined(); expect(parsed.context).toBeUndefined(); expect(parsed.agent).toBeUndefined(); expect(parsed.hooks).toBeUndefined(); }); }); }); describe('yamlString', () => { it('returns plain value for simple strings', () => { expect(yamlString('hello world')).toBe('hello world'); }); it('quotes strings with colons', () => { expect(yamlString('key: value')).toBe('"key: value"'); }); it('quotes strings with hash', () => { expect(yamlString('has # comment')).toBe('"has # comment"'); }); it('quotes strings with newlines', () => { expect(yamlString('line1\nline2')).toBe('"line1\nline2"'); }); it('quotes strings starting with space', () => { expect(yamlString(' leading')).toBe('" leading"'); }); it('quotes strings ending with space', () => { expect(yamlString('trailing ')).toBe('"trailing "'); }); it('escapes double quotes inside quoted strings', () => { expect(yamlString('has "quotes" inside: yes')).toBe('"has \\"quotes\\" inside: yes"'); }); }); describe('serializeCommand', () => { it('strips frontmatter from content before serializing', () => { const result = serializeCommand({ id: 'cmd-test', name: 'test', description: 'Test', content: '---\ndescription: old\n---\nBody text', }); // Should use the SlashCommand's description, not the one in content frontmatter expect(result).toContain('description: Test'); expect(result).toContain('Body text'); expect(result).not.toContain('description: old'); }); it('handles content without frontmatter', () => { const result = serializeCommand({ id: 'cmd-test', name: 'test', description: 'Simple', content: 'Just a prompt', }); expect(result).toContain('description: Simple'); expect(result).toContain('Just a prompt'); }); }); describe('serializeSlashCommandMarkdown', () => { it('serializes all fields in kebab-case', () => { const result = serializeSlashCommandMarkdown({ name: 'my-skill', description: 'Test command', argumentHint: '[file]', allowedTools: ['Read', 'Grep'], model: 'claude-sonnet-4-5', disableModelInvocation: true, userInvocable: false, context: 'fork', agent: 'code-reviewer', }, 'Do the thing'); expect(result).toContain('name: my-skill'); expect(result).toContain('description: Test command'); expect(result).toContain('argument-hint: "[file]"'); expect(result).toContain('allowed-tools:'); expect(result).toContain(' - Read'); expect(result).toContain(' - Grep'); expect(result).toContain('model: claude-sonnet-4-5'); expect(result).toContain('disable-model-invocation: true'); expect(result).toContain('user-invocable: false'); expect(result).toContain('context: fork'); expect(result).toContain('agent: code-reviewer'); expect(result).toContain('Do the thing'); }); it('omits undefined fields', () => { const result = serializeSlashCommandMarkdown({ description: 'Minimal', }, 'Prompt'); expect(result).toContain('description: Minimal'); expect(result).not.toContain('name'); expect(result).not.toContain('argument-hint'); expect(result).not.toContain('allowed-tools'); expect(result).not.toContain('model'); expect(result).not.toContain('disable-model-invocation'); expect(result).not.toContain('user-invocable'); expect(result).not.toContain('context'); expect(result).not.toContain('agent'); expect(result).not.toContain('hooks'); }); it('serializes hooks as JSON', () => { const hooks = { PreToolUse: [{ matcher: 'Bash' }] }; const result = serializeSlashCommandMarkdown({ hooks }, 'Prompt'); expect(result).toContain(`hooks: ${JSON.stringify(hooks)}`); }); it('produces valid frontmatter when no metadata exists', () => { const result = serializeSlashCommandMarkdown({}, 'Just a prompt'); expect(result).toBe('---\n\n---\nJust a prompt'); }); it('round-trips through parse', () => { const serialized = serializeSlashCommandMarkdown({ description: 'Round trip', disableModelInvocation: true, userInvocable: false, context: 'fork', agent: 'reviewer', }, 'Body text'); const parsed = parseSlashCommandContent(serialized); expect(parsed.description).toBe('Round trip'); expect(parsed.disableModelInvocation).toBe(true); expect(parsed.userInvocable).toBe(false); expect(parsed.context).toBe('fork'); expect(parsed.agent).toBe('reviewer'); expect(parsed.promptContent).toBe('Body text'); }); }); describe('validateCommandName', () => { it('accepts valid lowercase names', () => { expect(validateCommandName('my-command')).toBeNull(); expect(validateCommandName('test')).toBeNull(); expect(validateCommandName('a')).toBeNull(); expect(validateCommandName('abc123')).toBeNull(); expect(validateCommandName('my-cmd-2')).toBeNull(); expect(validateCommandName('a1b2c3')).toBeNull(); }); it('accepts name at exactly 64 characters', () => { const name = 'a'.repeat(64); expect(validateCommandName(name)).toBeNull(); }); it('rejects empty name', () => { expect(validateCommandName('')).not.toBeNull(); }); it('rejects uppercase letters', () => { expect(validateCommandName('MyCommand')).not.toBeNull(); expect(validateCommandName('TEST')).not.toBeNull(); }); it('rejects underscores', () => { expect(validateCommandName('my_command')).not.toBeNull(); }); it('rejects slashes', () => { expect(validateCommandName('my/command')).not.toBeNull(); }); it('rejects spaces', () => { expect(validateCommandName('my command')).not.toBeNull(); }); it('rejects colons', () => { expect(validateCommandName('my:command')).not.toBeNull(); }); it('rejects names exceeding 64 characters', () => { const name = 'a'.repeat(65); expect(validateCommandName(name)).not.toBeNull(); }); it('rejects special characters', () => { expect(validateCommandName('cmd!@#')).not.toBeNull(); expect(validateCommandName('cmd.test')).not.toBeNull(); }); it.each(['true', 'false', 'null', 'yes', 'no', 'on', 'off'])( 'rejects YAML reserved word "%s"', (word) => { expect(validateCommandName(word)).not.toBeNull(); } ); }); describe('extractFirstParagraph', () => { it('returns the first paragraph from multi-paragraph content', () => { expect(extractFirstParagraph('First paragraph.\n\nSecond paragraph.')) .toBe('First paragraph.'); }); it('returns single-line content as-is', () => { expect(extractFirstParagraph('Only one line')).toBe('Only one line'); }); it('collapses multi-line first paragraph into single line', () => { expect(extractFirstParagraph('Line one\nline two\n\nSecond paragraph')) .toBe('Line one line two'); }); it('returns undefined for empty content', () => { expect(extractFirstParagraph('')).toBeUndefined(); }); it('returns undefined for whitespace-only content', () => { expect(extractFirstParagraph(' \n \n ')).toBeUndefined(); }); it('skips leading blank lines', () => { expect(extractFirstParagraph('\n\nActual first paragraph.\n\nSecond.')) .toBe('Actual first paragraph.'); }); });