Skip to content

Commit 9bd0dda

Browse files
committed
Introduce AGENTS.md file support
1 parent a659a26 commit 9bd0dda

File tree

4 files changed

+186
-17
lines changed

4 files changed

+186
-17
lines changed

packages/cli-kit/src/public/node/fs.test.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ import {
2020
glob,
2121
detectEOL,
2222
copyDirectoryContents,
23+
symlink,
24+
fileRealPath,
2325
} from './fs.js'
2426
import {joinPath} from './path.js'
2527
import {takeRandomFromArray} from '../common/array.js'
@@ -488,3 +490,59 @@ describe('unixFileIsOwnedByCurrentUser', () => {
488490
})
489491
})
490492
})
493+
494+
describe('symlink', () => {
495+
test('creates a symbolic link to a file', async () => {
496+
await inTemporaryDirectory(async (tmpDir) => {
497+
// Given
498+
const targetFile = joinPath(tmpDir, 'target.txt')
499+
const linkPath = joinPath(tmpDir, 'link.txt')
500+
const content = 'test content'
501+
await writeFile(targetFile, content)
502+
503+
// When
504+
await symlink(targetFile, linkPath)
505+
506+
// Then
507+
await expect(fileExists(linkPath)).resolves.toBe(true)
508+
await expect(readFile(linkPath)).resolves.toEqual(content)
509+
const realPath = await fileRealPath(linkPath)
510+
expect(realPath).toBe(targetFile)
511+
})
512+
})
513+
514+
test('creates a symbolic link to a directory', async () => {
515+
await inTemporaryDirectory(async (tmpDir) => {
516+
// Given
517+
const targetDir = joinPath(tmpDir, 'target-dir')
518+
const linkPath = joinPath(tmpDir, 'link-dir')
519+
const testFile = joinPath(targetDir, 'test.txt')
520+
const content = 'directory content'
521+
await mkdir(targetDir)
522+
await writeFile(testFile, content)
523+
524+
// When
525+
await symlink(targetDir, linkPath)
526+
527+
// Then
528+
await expect(fileExists(linkPath)).resolves.toBe(true)
529+
await expect(readFile(joinPath(linkPath, 'test.txt'))).resolves.toEqual(content)
530+
})
531+
})
532+
533+
test('symbolic link points to the correct target', async () => {
534+
await inTemporaryDirectory(async (tmpDir) => {
535+
// Given
536+
const targetFile = joinPath(tmpDir, 'original.txt')
537+
const linkPath = joinPath(tmpDir, 'symlink.txt')
538+
await writeFile(targetFile, 'original content')
539+
540+
// When
541+
await symlink(targetFile, linkPath)
542+
await writeFile(targetFile, 'modified content')
543+
544+
// Then
545+
await expect(readFile(linkPath)).resolves.toEqual('modified content')
546+
})
547+
})
548+
})

packages/cli-kit/src/public/node/fs.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ import {
4848
rename as fsRename,
4949
unlink as fsUnlink,
5050
readdir as fsReaddir,
51+
symlink as fsSymlink,
5152
} from 'fs/promises'
5253
import {pathToFileURL as pathToFile} from 'url'
5354
import * as os from 'os'
@@ -264,6 +265,17 @@ export async function renameFile(from: string, to: string): Promise<void> {
264265
await fsRename(from, to)
265266
}
266267

268+
/**
269+
* Creates a symbolic link.
270+
*
271+
* @param target - Path that the symlink points to.
272+
* @param path - Path where the symlink will be created.
273+
*/
274+
export async function symlink(target: string, path: string): Promise<void> {
275+
outputDebug(outputContent`Creating symbolic link from ${outputToken.path(path)} to ${outputToken.path(target)}...`)
276+
await fsSymlink(target, path)
277+
}
278+
267279
/**
268280
* Synchronously removes a file at the given path.
269281
*

packages/theme/src/cli/services/init.test.ts

Lines changed: 86 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1-
import {cloneRepoAndCheckoutLatestTag, cloneRepo, createAIInstructions} from './init.js'
1+
import {cloneRepoAndCheckoutLatestTag, cloneRepo, createAIInstructions, createAIInstructionFiles} from './init.js'
22
import {describe, expect, vi, test, beforeEach} from 'vitest'
33
import {downloadGitRepository, removeGitRemote} from '@shopify/cli-kit/node/git'
4-
import {rmdir, fileExists, copyDirectoryContents} from '@shopify/cli-kit/node/fs'
4+
import {rmdir, fileExists, readFile, writeFile, symlink} from '@shopify/cli-kit/node/fs'
55
import {joinPath} from '@shopify/cli-kit/node/path'
66

77
vi.mock('@shopify/cli-kit/node/git')
@@ -11,7 +11,9 @@ vi.mock('@shopify/cli-kit/node/fs', async () => {
1111
...actual,
1212
fileExists: vi.fn(),
1313
rmdir: vi.fn(),
14-
copyDirectoryContents: vi.fn(),
14+
readFile: vi.fn(),
15+
writeFile: vi.fn(),
16+
symlink: vi.fn(),
1517
inTemporaryDirectory: vi.fn(async (callback) => {
1618
// eslint-disable-next-line node/no-callback-literal
1719
return callback('/tmp')
@@ -151,26 +153,102 @@ describe('createAIInstructions()', () => {
151153

152154
beforeEach(() => {
153155
vi.mocked(joinPath).mockImplementation((...paths) => paths.join('/'))
156+
vi.mocked(readFile).mockResolvedValue('Sample AI instructions content' as any)
157+
vi.mocked(writeFile).mockResolvedValue()
158+
vi.mocked(symlink).mockResolvedValue()
154159
})
155160

156-
test('creates AI instructions if it exists', async () => {
161+
test('creates AI instructions for a single instruction type', async () => {
157162
// Given
158163
vi.mocked(downloadGitRepository).mockResolvedValue()
159-
vi.mocked(copyDirectoryContents).mockResolvedValue()
160164

161165
// When
162166
await createAIInstructions(destination, 'cursor')
163167

164168
// Then
165169
expect(downloadGitRepository).toHaveBeenCalled()
166-
expect(copyDirectoryContents).toHaveBeenCalledWith('/tmp/ai/cursor', '/path/to/theme/.cursor')
170+
expect(readFile).toHaveBeenCalledWith('/tmp/ai/github/copilot-instructions.md')
171+
expect(writeFile).toHaveBeenCalledWith('/path/to/theme/AGENTS.md', expect.stringContaining('# AGENTS.md'))
172+
expect(symlink).toHaveBeenCalledWith('/path/to/theme/AGENTS.md', '/path/to/theme/.cursorrules')
167173
})
168174

169-
test('throws an error when the AI instructions directory does not exist', async () => {
175+
test('creates AI instructions for all instruction types when "all" is selected', async () => {
170176
// Given
171177
vi.mocked(downloadGitRepository).mockResolvedValue()
172-
vi.mocked(copyDirectoryContents).mockRejectedValue(new Error('Directory does not exist'))
178+
179+
// When
180+
await createAIInstructions(destination, 'all')
181+
182+
// Then
183+
expect(downloadGitRepository).toHaveBeenCalled()
184+
// github, cursor, claude
185+
expect(readFile).toHaveBeenCalledTimes(3)
186+
expect(writeFile).toHaveBeenCalledTimes(3)
187+
expect(symlink).toHaveBeenCalledTimes(3)
188+
expect(symlink).toHaveBeenCalledWith('/path/to/theme/AGENTS.md', '/path/to/theme/copilot-instructions.md')
189+
expect(symlink).toHaveBeenCalledWith('/path/to/theme/AGENTS.md', '/path/to/theme/.cursorrules')
190+
expect(symlink).toHaveBeenCalledWith('/path/to/theme/AGENTS.md', '/path/to/theme/CLAUDE.md')
191+
})
192+
193+
test('throws an error when file operations fail', async () => {
194+
// Given
195+
vi.mocked(downloadGitRepository).mockResolvedValue()
196+
vi.mocked(readFile).mockRejectedValue(new Error('File not found'))
173197

174198
await expect(createAIInstructions(destination, 'cursor')).rejects.toThrow('Failed to create AI instructions')
175199
})
176200
})
201+
202+
describe('createAIInstructionFiles()', () => {
203+
const tempDir = '/tmp'
204+
const themeRoot = '/path/to/theme'
205+
206+
beforeEach(() => {
207+
vi.mocked(joinPath).mockImplementation((...paths) => paths.join('/'))
208+
vi.mocked(readFile).mockResolvedValue('AI instruction content' as any)
209+
vi.mocked(writeFile).mockResolvedValue()
210+
vi.mocked(symlink).mockResolvedValue()
211+
})
212+
213+
test('creates AGENTS.md with prepended header for github instruction', async () => {
214+
// When
215+
await createAIInstructionFiles(tempDir, themeRoot, 'github')
216+
217+
// Then
218+
expect(readFile).toHaveBeenCalledWith('/tmp/ai/github/copilot-instructions.md')
219+
expect(writeFile).toHaveBeenCalledWith('/path/to/theme/AGENTS.md', '# AGENTS.md\n\nAI instruction content')
220+
expect(symlink).toHaveBeenCalledWith('/path/to/theme/AGENTS.md', '/path/to/theme/copilot-instructions.md')
221+
})
222+
223+
test('creates AGENTS.md and .cursorrules symlink for cursor instruction', async () => {
224+
// When
225+
await createAIInstructionFiles(tempDir, themeRoot, 'cursor')
226+
227+
// Then
228+
expect(readFile).toHaveBeenCalledWith('/tmp/ai/github/copilot-instructions.md')
229+
expect(writeFile).toHaveBeenCalledWith('/path/to/theme/AGENTS.md', '# AGENTS.md\n\nAI instruction content')
230+
expect(symlink).toHaveBeenCalledWith('/path/to/theme/AGENTS.md', '/path/to/theme/.cursorrules')
231+
})
232+
233+
test('creates AGENTS.md and CLAUDE.md symlink for claude instruction', async () => {
234+
// When
235+
await createAIInstructionFiles(tempDir, themeRoot, 'claude')
236+
237+
// Then
238+
expect(readFile).toHaveBeenCalledWith('/tmp/ai/github/copilot-instructions.md')
239+
expect(writeFile).toHaveBeenCalledWith('/path/to/theme/AGENTS.md', '# AGENTS.md\n\nAI instruction content')
240+
expect(symlink).toHaveBeenCalledWith('/path/to/theme/AGENTS.md', '/path/to/theme/CLAUDE.md')
241+
})
242+
243+
test('prepends header to source content', async () => {
244+
// Given
245+
const sourceContent = 'Original content from repo'
246+
vi.mocked(readFile).mockResolvedValue(sourceContent as any)
247+
248+
// When
249+
await createAIInstructionFiles(tempDir, themeRoot, 'github')
250+
251+
// Then
252+
expect(writeFile).toHaveBeenCalledWith('/path/to/theme/AGENTS.md', `# AGENTS.md\n\n${sourceContent}`)
253+
})
254+
})

packages/theme/src/cli/services/init.ts

Lines changed: 30 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
import {renderSelectPrompt, renderTasks} from '@shopify/cli-kit/node/ui'
22
import {downloadGitRepository, removeGitRemote} from '@shopify/cli-kit/node/git'
33
import {joinPath} from '@shopify/cli-kit/node/path'
4-
import {rmdir, fileExists, inTemporaryDirectory, copyDirectoryContents} from '@shopify/cli-kit/node/fs'
4+
import {rmdir, fileExists, inTemporaryDirectory, readFile, writeFile, symlink} from '@shopify/cli-kit/node/fs'
55
import {AbortError} from '@shopify/cli-kit/node/error'
66

77
export const SKELETON_THEME_URL = 'https://github.com/Shopify/skeleton-theme.git'
88
const AI_INSTRUCTIONS_REPO_URL = 'https://github.com/Shopify/theme-liquid-docs.git'
99

1010
const SUPPORTED_AI_INSTRUCTIONS = {
11+
all: 'All',
1112
github: 'VS Code (GitHub Copilot)',
1213
cursor: 'Cursor',
1314
claude: 'Claude',
@@ -57,7 +58,7 @@ async function removeDirectory(path: string) {
5758

5859
export async function promptAIInstruction() {
5960
const aiChoice = (await renderSelectPrompt({
60-
message: 'Include LLM instructions in the theme?',
61+
message: 'Which LLM instruction file would you like to include in your theme?',
6162
choices: [
6263
...Object.entries(SUPPORTED_AI_INSTRUCTIONS).map(([key, value]) => ({
6364
label: value,
@@ -81,16 +82,16 @@ export async function createAIInstructions(themeRoot: string, aiInstruction: AII
8182
destination: tempDir,
8283
shallow: true,
8384
})
84-
const aiSrcDir = joinPath(tempDir, 'ai', aiInstruction)
8585

86-
let aiDestDir = themeRoot
87-
88-
if (aiInstruction !== 'claude') {
89-
aiDestDir = joinPath(themeRoot, `.${aiInstruction}`)
90-
}
86+
const instructions =
87+
aiInstruction === 'all'
88+
? (Object.keys(SUPPORTED_AI_INSTRUCTIONS).filter((key) => key !== 'all') as AIInstruction[])
89+
: [aiInstruction]
9190

9291
try {
93-
await copyDirectoryContents(aiSrcDir, aiDestDir)
92+
await Promise.all(
93+
instructions.map((instruction) => createAIInstructionFiles(tempDir, themeRoot, instruction)),
94+
)
9495
} catch (error) {
9596
throw new AbortError('Failed to create AI instructions')
9697
}
@@ -99,3 +100,23 @@ export async function createAIInstructions(themeRoot: string, aiInstruction: AII
99100
},
100101
])
101102
}
103+
104+
export async function createAIInstructionFiles(tempDir: string, themeRoot: string, instruction: AIInstruction) {
105+
const sourcePath = joinPath(tempDir, 'ai', 'github', 'copilot-instructions.md')
106+
const sourceContent = await readFile(sourcePath)
107+
108+
const agentsPath = joinPath(themeRoot, 'AGENTS.md')
109+
const agentsContent = `# AGENTS.md\n\n${sourceContent}`
110+
await writeFile(agentsPath, agentsContent)
111+
112+
const symlinkMap: {[key in Exclude<AIInstruction, 'all'>]: string} = {
113+
github: 'copilot-instructions.md',
114+
cursor: '.cursorrules',
115+
claude: 'CLAUDE.md',
116+
}
117+
118+
const symlinkName = symlinkMap[instruction as Exclude<AIInstruction, 'all'>]
119+
const symlinkPath = joinPath(themeRoot, symlinkName)
120+
121+
await symlink(agentsPath, symlinkPath)
122+
}

0 commit comments

Comments
 (0)