specrails-hub 0.1.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.
@@ -0,0 +1,480 @@
1
+ /**
2
+ * Pure-logic tests for CommandGrid Discovery & Delivery sections.
3
+ *
4
+ * CommandGrid.tsx lives in the client and cannot be imported in a Node
5
+ * environment (it uses JSX, React hooks, and DOM-only imports). However,
6
+ * the critical business logic — section ordering, display-name overrides,
7
+ * hidden-slug filtering — is expressed entirely through module-level
8
+ * constants and pure array/map operations. These tests replicate those
9
+ * constants verbatim and verify every behavioural guarantee listed in the
10
+ * feature spec.
11
+ *
12
+ * If the constants in client/src/components/CommandGrid.tsx ever change,
13
+ * update the corresponding declarations below.
14
+ */
15
+
16
+ import { describe, it, expect } from 'vitest'
17
+
18
+ // ---------------------------------------------------------------------------
19
+ // Constants mirrored from client/src/components/CommandGrid.tsx
20
+ // ---------------------------------------------------------------------------
21
+
22
+ const DISCOVERY_ORDER = ['propose-spec', 'update-product-driven-backlog', 'product-backlog'] as const
23
+ const DELIVERY_ORDER = ['implement', 'batch-implement'] as const
24
+ const DISCOVERY_SET = new Set<string>(DISCOVERY_ORDER)
25
+ const DELIVERY_SET = new Set<string>(DELIVERY_ORDER)
26
+ const DISPLAY_NAMES: Record<string, string> = {
27
+ 'update-product-driven-backlog': 'Auto-propose Specs',
28
+ 'product-backlog': 'Auto-Select Specs',
29
+ }
30
+ const HIDDEN_SLUGS = new Set(['propose-feature'])
31
+ const WIZARD_COMMANDS = new Set(['implement', 'batch-implement'])
32
+
33
+ // ---------------------------------------------------------------------------
34
+ // Helper: mimics the filtering + ordering logic inside CommandGrid render
35
+ // ---------------------------------------------------------------------------
36
+
37
+ interface CommandInfo {
38
+ id: string
39
+ name: string
40
+ description: string
41
+ slug: string
42
+ }
43
+
44
+ function buildSections(commands: CommandInfo[]) {
45
+ const visibleCommands = commands.filter((c) => !HIDDEN_SLUGS.has(c.slug))
46
+ const bySlug = new Map(visibleCommands.map((c) => [c.slug, c]))
47
+
48
+ const discovery = DISCOVERY_ORDER
49
+ .map((s) => bySlug.get(s))
50
+ .filter((c): c is CommandInfo => c !== undefined)
51
+
52
+ const delivery = DELIVERY_ORDER
53
+ .map((s) => bySlug.get(s))
54
+ .filter((c): c is CommandInfo => c !== undefined)
55
+
56
+ const others = visibleCommands
57
+ .filter((c) => !DISCOVERY_SET.has(c.slug) && !DELIVERY_SET.has(c.slug))
58
+ .sort((a, b) => a.name.localeCompare(b.name))
59
+
60
+ return { discovery, delivery, others, visibleCommands }
61
+ }
62
+
63
+ function displayName(cmd: CommandInfo): string {
64
+ return DISPLAY_NAMES[cmd.slug] ?? cmd.name
65
+ }
66
+
67
+ function tooltipSlug(cmd: CommandInfo): string {
68
+ return `/sr:${cmd.slug}`
69
+ }
70
+
71
+ // ---------------------------------------------------------------------------
72
+ // Test fixtures
73
+ // ---------------------------------------------------------------------------
74
+
75
+ function makeCommand(slug: string, overrides: Partial<CommandInfo> = {}): CommandInfo {
76
+ return {
77
+ id: `id-${slug}`,
78
+ name: slug, // default: use slug as name
79
+ description: `Description of ${slug}`,
80
+ slug,
81
+ ...overrides,
82
+ }
83
+ }
84
+
85
+ /** Full set of all known specrails commands including the hidden one. */
86
+ const ALL_COMMANDS: CommandInfo[] = [
87
+ makeCommand('propose-spec', { name: 'Propose Spec' }),
88
+ makeCommand('update-product-driven-backlog', { name: 'Update Backlog' }),
89
+ makeCommand('product-backlog', { name: 'Product Backlog' }),
90
+ makeCommand('implement', { name: 'Implement' }),
91
+ makeCommand('batch-implement', { name: 'Batch Implement' }),
92
+ makeCommand('propose-feature', { name: 'Propose Feature' }), // hidden
93
+ makeCommand('refactor-recommender', { name: 'Refactor Recommender' }),
94
+ makeCommand('health-check', { name: 'Health Check' }),
95
+ makeCommand('compat-check', { name: 'Compat Check' }),
96
+ makeCommand('why', { name: 'Why' }),
97
+ ]
98
+
99
+ // ---------------------------------------------------------------------------
100
+ // Discovery section — ordering
101
+ // ---------------------------------------------------------------------------
102
+
103
+ describe('Discovery section', () => {
104
+ it('contains exactly the three expected commands', () => {
105
+ const { discovery } = buildSections(ALL_COMMANDS)
106
+ expect(discovery.map((c) => c.slug)).toEqual([
107
+ 'propose-spec',
108
+ 'update-product-driven-backlog',
109
+ 'product-backlog',
110
+ ])
111
+ })
112
+
113
+ it('puts propose-spec first', () => {
114
+ const { discovery } = buildSections(ALL_COMMANDS)
115
+ expect(discovery[0].slug).toBe('propose-spec')
116
+ })
117
+
118
+ it('puts update-product-driven-backlog second', () => {
119
+ const { discovery } = buildSections(ALL_COMMANDS)
120
+ expect(discovery[1].slug).toBe('update-product-driven-backlog')
121
+ })
122
+
123
+ it('puts product-backlog third', () => {
124
+ const { discovery } = buildSections(ALL_COMMANDS)
125
+ expect(discovery[2].slug).toBe('product-backlog')
126
+ })
127
+
128
+ it('preserves DISCOVERY_ORDER even when input array is shuffled', () => {
129
+ const shuffled = [...ALL_COMMANDS].reverse()
130
+ const { discovery } = buildSections(shuffled)
131
+ expect(discovery.map((c) => c.slug)).toEqual([
132
+ 'propose-spec',
133
+ 'update-product-driven-backlog',
134
+ 'product-backlog',
135
+ ])
136
+ })
137
+
138
+ it('omits a discovery command that is absent from the input', () => {
139
+ const withoutProductBacklog = ALL_COMMANDS.filter((c) => c.slug !== 'product-backlog')
140
+ const { discovery } = buildSections(withoutProductBacklog)
141
+ expect(discovery.map((c) => c.slug)).toEqual([
142
+ 'propose-spec',
143
+ 'update-product-driven-backlog',
144
+ ])
145
+ expect(discovery).toHaveLength(2)
146
+ })
147
+
148
+ it('returns empty discovery when none of its commands are present', () => {
149
+ const noDiscovery = ALL_COMMANDS.filter((c) => !DISCOVERY_SET.has(c.slug))
150
+ const { discovery } = buildSections(noDiscovery)
151
+ expect(discovery).toHaveLength(0)
152
+ })
153
+ })
154
+
155
+ // ---------------------------------------------------------------------------
156
+ // Delivery section — ordering
157
+ // ---------------------------------------------------------------------------
158
+
159
+ describe('Delivery section', () => {
160
+ it('contains exactly the two expected commands', () => {
161
+ const { delivery } = buildSections(ALL_COMMANDS)
162
+ expect(delivery.map((c) => c.slug)).toEqual(['implement', 'batch-implement'])
163
+ })
164
+
165
+ it('puts implement before batch-implement', () => {
166
+ const { delivery } = buildSections(ALL_COMMANDS)
167
+ expect(delivery[0].slug).toBe('implement')
168
+ expect(delivery[1].slug).toBe('batch-implement')
169
+ })
170
+
171
+ it('preserves DELIVERY_ORDER even when input array is shuffled', () => {
172
+ const shuffled = [...ALL_COMMANDS].reverse()
173
+ const { delivery } = buildSections(shuffled)
174
+ expect(delivery.map((c) => c.slug)).toEqual(['implement', 'batch-implement'])
175
+ })
176
+
177
+ it('omits a delivery command that is absent from the input', () => {
178
+ const withoutBatchImplement = ALL_COMMANDS.filter((c) => c.slug !== 'batch-implement')
179
+ const { delivery } = buildSections(withoutBatchImplement)
180
+ expect(delivery.map((c) => c.slug)).toEqual(['implement'])
181
+ expect(delivery).toHaveLength(1)
182
+ })
183
+
184
+ it('returns empty delivery when none of its commands are present', () => {
185
+ const noDelivery = ALL_COMMANDS.filter((c) => !DELIVERY_SET.has(c.slug))
186
+ const { delivery } = buildSections(noDelivery)
187
+ expect(delivery).toHaveLength(0)
188
+ })
189
+ })
190
+
191
+ // ---------------------------------------------------------------------------
192
+ // HIDDEN_SLUGS — propose-feature must never appear
193
+ // ---------------------------------------------------------------------------
194
+
195
+ describe('Hidden slugs (propose-feature)', () => {
196
+ it('propose-feature does not appear in discovery', () => {
197
+ const { discovery } = buildSections(ALL_COMMANDS)
198
+ expect(discovery.map((c) => c.slug)).not.toContain('propose-feature')
199
+ })
200
+
201
+ it('propose-feature does not appear in delivery', () => {
202
+ const { delivery } = buildSections(ALL_COMMANDS)
203
+ expect(delivery.map((c) => c.slug)).not.toContain('propose-feature')
204
+ })
205
+
206
+ it('propose-feature does not appear in others', () => {
207
+ const { others } = buildSections(ALL_COMMANDS)
208
+ expect(others.map((c) => c.slug)).not.toContain('propose-feature')
209
+ })
210
+
211
+ it('propose-feature does not appear in visibleCommands', () => {
212
+ const { visibleCommands } = buildSections(ALL_COMMANDS)
213
+ expect(visibleCommands.map((c) => c.slug)).not.toContain('propose-feature')
214
+ })
215
+
216
+ it('propose-feature is excluded even when it is the only command', () => {
217
+ const only = [makeCommand('propose-feature')]
218
+ const { discovery, delivery, others, visibleCommands } = buildSections(only)
219
+ expect(discovery).toHaveLength(0)
220
+ expect(delivery).toHaveLength(0)
221
+ expect(others).toHaveLength(0)
222
+ expect(visibleCommands).toHaveLength(0)
223
+ })
224
+ })
225
+
226
+ // ---------------------------------------------------------------------------
227
+ // Display name overrides
228
+ // ---------------------------------------------------------------------------
229
+
230
+ describe('DISPLAY_NAMES overrides', () => {
231
+ it('update-product-driven-backlog renders as "Auto-propose Specs"', () => {
232
+ const cmd = makeCommand('update-product-driven-backlog')
233
+ expect(displayName(cmd)).toBe('Auto-propose Specs')
234
+ })
235
+
236
+ it('product-backlog renders as "Auto-Select Specs"', () => {
237
+ const cmd = makeCommand('product-backlog')
238
+ expect(displayName(cmd)).toBe('Auto-Select Specs')
239
+ })
240
+
241
+ it('propose-spec has no display-name override — falls back to cmd.name', () => {
242
+ const cmd = makeCommand('propose-spec', { name: 'Propose Spec' })
243
+ expect(displayName(cmd)).toBe('Propose Spec')
244
+ })
245
+
246
+ it('implement has no display-name override — falls back to cmd.name', () => {
247
+ const cmd = makeCommand('implement', { name: 'Implement' })
248
+ expect(displayName(cmd)).toBe('Implement')
249
+ })
250
+
251
+ it('batch-implement has no display-name override — falls back to cmd.name', () => {
252
+ const cmd = makeCommand('batch-implement', { name: 'Batch Implement' })
253
+ expect(displayName(cmd)).toBe('Batch Implement')
254
+ })
255
+
256
+ it('an unknown slug falls back to cmd.name', () => {
257
+ const cmd = makeCommand('some-unknown-slug', { name: 'Unknown Command' })
258
+ expect(displayName(cmd)).toBe('Unknown Command')
259
+ })
260
+
261
+ it('DISPLAY_NAMES map has exactly two entries', () => {
262
+ expect(Object.keys(DISPLAY_NAMES)).toHaveLength(2)
263
+ })
264
+ })
265
+
266
+ // ---------------------------------------------------------------------------
267
+ // Tooltip slug — always /sr:<slug>, never the overridden display name
268
+ // ---------------------------------------------------------------------------
269
+
270
+ describe('Tooltip /sr:<slug> format', () => {
271
+ it('update-product-driven-backlog tooltip is /sr:update-product-driven-backlog (not display name)', () => {
272
+ const cmd = makeCommand('update-product-driven-backlog')
273
+ expect(tooltipSlug(cmd)).toBe('/sr:update-product-driven-backlog')
274
+ expect(tooltipSlug(cmd)).not.toBe(`/sr:${displayName(cmd)}`)
275
+ })
276
+
277
+ it('product-backlog tooltip is /sr:product-backlog (not display name)', () => {
278
+ const cmd = makeCommand('product-backlog')
279
+ expect(tooltipSlug(cmd)).toBe('/sr:product-backlog')
280
+ expect(tooltipSlug(cmd)).not.toBe(`/sr:${displayName(cmd)}`)
281
+ })
282
+
283
+ it('propose-spec tooltip is /sr:propose-spec', () => {
284
+ const cmd = makeCommand('propose-spec')
285
+ expect(tooltipSlug(cmd)).toBe('/sr:propose-spec')
286
+ })
287
+
288
+ it('implement tooltip is /sr:implement', () => {
289
+ const cmd = makeCommand('implement')
290
+ expect(tooltipSlug(cmd)).toBe('/sr:implement')
291
+ })
292
+
293
+ it('tooltip always uses real slug regardless of overridden display name', () => {
294
+ // For every command that has a display-name override, the tooltip must
295
+ // use the real slug, not the human-readable override.
296
+ for (const [slug, override] of Object.entries(DISPLAY_NAMES)) {
297
+ const cmd = makeCommand(slug)
298
+ const tip = tooltipSlug(cmd)
299
+ expect(tip).toBe(`/sr:${slug}`)
300
+ expect(tip).not.toContain(override)
301
+ }
302
+ })
303
+ })
304
+
305
+ // ---------------------------------------------------------------------------
306
+ // Others section
307
+ // ---------------------------------------------------------------------------
308
+
309
+ describe('Others section', () => {
310
+ it('excludes all Discovery slugs', () => {
311
+ const { others } = buildSections(ALL_COMMANDS)
312
+ const otherSlugs = others.map((c) => c.slug)
313
+ for (const s of DISCOVERY_ORDER) {
314
+ expect(otherSlugs).not.toContain(s)
315
+ }
316
+ })
317
+
318
+ it('excludes all Delivery slugs', () => {
319
+ const { others } = buildSections(ALL_COMMANDS)
320
+ const otherSlugs = others.map((c) => c.slug)
321
+ for (const s of DELIVERY_ORDER) {
322
+ expect(otherSlugs).not.toContain(s)
323
+ }
324
+ })
325
+
326
+ it('excludes hidden slugs', () => {
327
+ const { others } = buildSections(ALL_COMMANDS)
328
+ expect(others.map((c) => c.slug)).not.toContain('propose-feature')
329
+ })
330
+
331
+ it('contains the expected non-categorised commands', () => {
332
+ const { others } = buildSections(ALL_COMMANDS)
333
+ // ALL_COMMANDS has: refactor-recommender, health-check, compat-check, why
334
+ // (after excluding discovery, delivery, and hidden)
335
+ const otherSlugs = others.map((c) => c.slug)
336
+ expect(otherSlugs).toContain('refactor-recommender')
337
+ expect(otherSlugs).toContain('health-check')
338
+ expect(otherSlugs).toContain('compat-check')
339
+ expect(otherSlugs).toContain('why')
340
+ })
341
+
342
+ it('is sorted alphabetically by name', () => {
343
+ const { others } = buildSections(ALL_COMMANDS)
344
+ const names = others.map((c) => c.name)
345
+ const sorted = [...names].sort((a, b) => a.localeCompare(b))
346
+ expect(names).toEqual(sorted)
347
+ })
348
+
349
+ it('is empty when commands contain only discovery and delivery entries', () => {
350
+ const discoveryAndDelivery = ALL_COMMANDS.filter(
351
+ (c) => DISCOVERY_SET.has(c.slug) || DELIVERY_SET.has(c.slug)
352
+ )
353
+ const { others } = buildSections(discoveryAndDelivery)
354
+ expect(others).toHaveLength(0)
355
+ })
356
+
357
+ it('is empty when commands array is empty', () => {
358
+ const { others } = buildSections([])
359
+ expect(others).toHaveLength(0)
360
+ })
361
+ })
362
+
363
+ // ---------------------------------------------------------------------------
364
+ // WIZARD_COMMANDS
365
+ // ---------------------------------------------------------------------------
366
+
367
+ describe('WIZARD_COMMANDS', () => {
368
+ it('implement is a wizard command', () => {
369
+ expect(WIZARD_COMMANDS.has('implement')).toBe(true)
370
+ })
371
+
372
+ it('batch-implement is a wizard command', () => {
373
+ expect(WIZARD_COMMANDS.has('batch-implement')).toBe(true)
374
+ })
375
+
376
+ it('propose-spec is NOT a wizard command', () => {
377
+ expect(WIZARD_COMMANDS.has('propose-spec')).toBe(false)
378
+ })
379
+
380
+ it('update-product-driven-backlog is NOT a wizard command', () => {
381
+ expect(WIZARD_COMMANDS.has('update-product-driven-backlog')).toBe(false)
382
+ })
383
+
384
+ it('product-backlog is NOT a wizard command', () => {
385
+ expect(WIZARD_COMMANDS.has('product-backlog')).toBe(false)
386
+ })
387
+ })
388
+
389
+ // ---------------------------------------------------------------------------
390
+ // Edge cases — empty and minimal inputs
391
+ // ---------------------------------------------------------------------------
392
+
393
+ describe('Edge cases', () => {
394
+ it('all sections are empty when commands array is empty', () => {
395
+ const { discovery, delivery, others } = buildSections([])
396
+ expect(discovery).toHaveLength(0)
397
+ expect(delivery).toHaveLength(0)
398
+ expect(others).toHaveLength(0)
399
+ })
400
+
401
+ it('only-hidden input produces empty sections', () => {
402
+ const hiddenOnly = [makeCommand('propose-feature')]
403
+ const { discovery, delivery, others } = buildSections(hiddenOnly)
404
+ expect(discovery).toHaveLength(0)
405
+ expect(delivery).toHaveLength(0)
406
+ expect(others).toHaveLength(0)
407
+ })
408
+
409
+ it('a single discovery command produces correct single-item section', () => {
410
+ const single = [makeCommand('propose-spec', { name: 'Propose Spec' })]
411
+ const { discovery } = buildSections(single)
412
+ expect(discovery).toHaveLength(1)
413
+ expect(discovery[0].slug).toBe('propose-spec')
414
+ })
415
+
416
+ it('duplicate slugs in input — map de-duplicates, last write wins', () => {
417
+ // Map construction: later entry for same slug overwrites earlier.
418
+ const dupe: CommandInfo[] = [
419
+ makeCommand('implement', { name: 'First' }),
420
+ makeCommand('implement', { name: 'Second' }),
421
+ ]
422
+ const { delivery } = buildSections(dupe)
423
+ // The map will have one entry for 'implement', whichever was last.
424
+ expect(delivery).toHaveLength(1)
425
+ expect(delivery[0].slug).toBe('implement')
426
+ })
427
+
428
+ it('commands that share no slugs with any section all go to others', () => {
429
+ const unknown = [
430
+ makeCommand('alpha', { name: 'Alpha' }),
431
+ makeCommand('beta', { name: 'Beta' }),
432
+ ]
433
+ const { discovery, delivery, others } = buildSections(unknown)
434
+ expect(discovery).toHaveLength(0)
435
+ expect(delivery).toHaveLength(0)
436
+ expect(others).toHaveLength(2)
437
+ expect(others[0].slug).toBe('alpha')
438
+ expect(others[1].slug).toBe('beta')
439
+ })
440
+ })
441
+
442
+ // ---------------------------------------------------------------------------
443
+ // Constant shape assertions (guard against accidental upstream changes)
444
+ // ---------------------------------------------------------------------------
445
+
446
+ describe('Constant shape', () => {
447
+ it('DISCOVERY_ORDER has three entries', () => {
448
+ expect(DISCOVERY_ORDER).toHaveLength(3)
449
+ })
450
+
451
+ it('DELIVERY_ORDER has two entries', () => {
452
+ expect(DELIVERY_ORDER).toHaveLength(2)
453
+ })
454
+
455
+ it('HIDDEN_SLUGS contains propose-feature', () => {
456
+ expect(HIDDEN_SLUGS.has('propose-feature')).toBe(true)
457
+ })
458
+
459
+ it('HIDDEN_SLUGS has exactly one entry', () => {
460
+ expect(HIDDEN_SLUGS.size).toBe(1)
461
+ })
462
+
463
+ it('DISCOVERY_SET and DELIVERY_SET are disjoint', () => {
464
+ for (const s of DISCOVERY_SET) {
465
+ expect(DELIVERY_SET.has(s)).toBe(false)
466
+ }
467
+ })
468
+
469
+ it('HIDDEN_SLUGS does not overlap with DISCOVERY_SET', () => {
470
+ for (const s of HIDDEN_SLUGS) {
471
+ expect(DISCOVERY_SET.has(s)).toBe(false)
472
+ }
473
+ })
474
+
475
+ it('HIDDEN_SLUGS does not overlap with DELIVERY_SET', () => {
476
+ for (const s of HIDDEN_SLUGS) {
477
+ expect(DELIVERY_SET.has(s)).toBe(false)
478
+ }
479
+ })
480
+ })
@@ -0,0 +1,136 @@
1
+ import { describe, it, expect, afterEach } from 'vitest'
2
+ import fs from 'fs'
3
+ import path from 'path'
4
+ import os from 'os'
5
+ import { resolveCommand } from './command-resolver'
6
+
7
+ let tmpDir: string | null = null
8
+
9
+ function createTempDir(): string {
10
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cmd-resolver-test-'))
11
+ return tmpDir
12
+ }
13
+
14
+ function writeCommandFile(dir: string, relativePath: string, content: string): void {
15
+ const fullPath = path.join(dir, relativePath)
16
+ fs.mkdirSync(path.dirname(fullPath), { recursive: true })
17
+ fs.writeFileSync(fullPath, content, 'utf-8')
18
+ }
19
+
20
+ afterEach(() => {
21
+ if (tmpDir) {
22
+ fs.rmSync(tmpDir, { recursive: true, force: true })
23
+ tmpDir = null
24
+ }
25
+ })
26
+
27
+ describe('resolveCommand', () => {
28
+ it('returns command as-is for non-slash commands', () => {
29
+ const result = resolveCommand('just some text', '/any/cwd')
30
+ expect(result).toBe('just some text')
31
+ })
32
+
33
+ it('returns command as-is when command file does not exist', () => {
34
+ const dir = createTempDir()
35
+ const result = resolveCommand('/sr:missing-command hello', dir)
36
+ expect(result).toBe('/sr:missing-command hello')
37
+ })
38
+
39
+ it('reads command file, strips frontmatter, substitutes $ARGUMENTS', () => {
40
+ const dir = createTempDir()
41
+ writeCommandFile(
42
+ dir,
43
+ '.claude/commands/sr/test.md',
44
+ `---
45
+ description: Test command
46
+ ---
47
+
48
+ You are helping with: $ARGUMENTS
49
+
50
+ Do something.`
51
+ )
52
+
53
+ const result = resolveCommand('/sr:test hello world', dir)
54
+ expect(result).not.toContain('---')
55
+ expect(result).not.toContain('description:')
56
+ expect(result).toContain('hello world')
57
+ expect(result).not.toContain('$ARGUMENTS')
58
+ expect(result).toContain('You are helping with: hello world')
59
+ })
60
+
61
+ it('falls back to skills directory if commands file not found', () => {
62
+ const dir = createTempDir()
63
+ writeCommandFile(
64
+ dir,
65
+ '.claude/skills/sr/skill-cmd.md',
66
+ `---
67
+ description: A skill
68
+ ---
69
+
70
+ Skill prompt with args: $ARGUMENTS`
71
+ )
72
+
73
+ const result = resolveCommand('/sr:skill-cmd the-arg', dir)
74
+ expect(result).toContain('the-arg')
75
+ expect(result).not.toContain('$ARGUMENTS')
76
+ expect(result).not.toContain('description:')
77
+ })
78
+
79
+ it('substitutes all occurrences of $ARGUMENTS', () => {
80
+ const dir = createTempDir()
81
+ writeCommandFile(
82
+ dir,
83
+ '.claude/commands/sr/multi.md',
84
+ `---
85
+ description: Multi sub
86
+ ---
87
+
88
+ First: $ARGUMENTS
89
+ Second: $ARGUMENTS`
90
+ )
91
+
92
+ const result = resolveCommand('/sr:multi myarg', dir)
93
+ expect(result).toBe('First: myarg\nSecond: myarg')
94
+ })
95
+
96
+ it('handles command with no arguments (empty $ARGUMENTS substitution)', () => {
97
+ const dir = createTempDir()
98
+ writeCommandFile(
99
+ dir,
100
+ '.claude/commands/sr/noargs.md',
101
+ `---
102
+ description: No args
103
+ ---
104
+
105
+ Do this: $ARGUMENTS`
106
+ )
107
+
108
+ const result = resolveCommand('/sr:noargs', dir)
109
+ expect(result).toBe('Do this:')
110
+ })
111
+
112
+ it('commands directory takes priority over skills directory', () => {
113
+ const dir = createTempDir()
114
+ writeCommandFile(
115
+ dir,
116
+ '.claude/commands/sr/both.md',
117
+ `---
118
+ description: Commands version
119
+ ---
120
+
121
+ From commands: $ARGUMENTS`
122
+ )
123
+ writeCommandFile(
124
+ dir,
125
+ '.claude/skills/sr/both.md',
126
+ `---
127
+ description: Skills version
128
+ ---
129
+
130
+ From skills: $ARGUMENTS`
131
+ )
132
+
133
+ const result = resolveCommand('/sr:both test', dir)
134
+ expect(result).toBe('From commands: test')
135
+ })
136
+ })
@@ -0,0 +1,29 @@
1
+ import { readFileSync, existsSync } from 'fs'
2
+ import { join } from 'path'
3
+
4
+ /**
5
+ * Resolves a slash command string to its full prompt content.
6
+ * Reads the command file from .claude/commands/ or .claude/skills/,
7
+ * strips YAML frontmatter, and substitutes $ARGUMENTS.
8
+ *
9
+ * Falls back to returning the command string as-is if the file is not found.
10
+ */
11
+ export function resolveCommand(command: string, cwd: string): string {
12
+ const match = command.match(/^\/([^\s]+)\s*(.*)$/s)
13
+ if (!match) return command
14
+
15
+ const commandPath = match[1]
16
+ const commandArgs = match[2].trim()
17
+
18
+ const filePath = join(cwd, '.claude', 'commands', ...commandPath.split(':')) + '.md'
19
+ const skillPath = join(cwd, '.claude', 'skills', ...commandPath.split(':')) + '.md'
20
+
21
+ const resolvedPath = existsSync(filePath) ? filePath : existsSync(skillPath) ? skillPath : null
22
+
23
+ if (!resolvedPath) return command
24
+
25
+ let content = readFileSync(resolvedPath, 'utf-8')
26
+ content = content.replace(/^---[\s\S]*?---\s*/, '')
27
+ content = content.replace(/\$ARGUMENTS/g, commandArgs)
28
+ return content.trim()
29
+ }