Skip to content

Commit ffc6646

Browse files
authored
Merge pull request #6625 from Shopify/themes-agents.md
Introduce `AGENTS.md` file support in `shopify theme init`
2 parents a70af49 + 313b9bf commit ffc6646

File tree

5 files changed

+243
-19
lines changed

5 files changed

+243
-19
lines changed

.changeset/forty-steaks-decide.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@shopify/cli-kit': minor
3+
'@shopify/theme': minor
4+
---
5+
6+
Introduce `AGENTS.md` file support in `shopify theme init`

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

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,10 @@ import {
2020
glob,
2121
detectEOL,
2222
copyDirectoryContents,
23+
symlink,
24+
fileRealPath,
2325
} from './fs.js'
24-
import {joinPath} from './path.js'
26+
import {joinPath, normalizePath} from './path.js'
2527
import {takeRandomFromArray} from '../common/array.js'
2628
import {beforeEach, describe, expect, test, vi} from 'vitest'
2729
import FastGlob from 'fast-glob'
@@ -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(normalizePath(realPath)).toBe(normalizePath(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: 26 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,31 @@ 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+
277+
// On Windows, we need to specify the type of symlink (file or dir)
278+
let type: 'file' | 'dir' | 'junction' = 'file'
279+
280+
try {
281+
const stats = await fsLstat(target)
282+
if (stats.isDirectory()) {
283+
type = 'junction'
284+
}
285+
// eslint-disable-next-line no-catch-all/no-catch-all
286+
} catch {
287+
// If we can't stat the target, assume it's a file
288+
}
289+
290+
await fsSymlink(target, path, type)
291+
}
292+
267293
/**
268294
* Synchronously removes a file at the given path.
269295
*

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

Lines changed: 81 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,97 @@ 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).not.toHaveBeenCalled()
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+
expect(readFile).toHaveBeenCalledTimes(1)
185+
expect(writeFile).toHaveBeenCalledTimes(1)
186+
expect(symlink).toHaveBeenCalledTimes(2)
187+
expect(symlink).toHaveBeenCalledWith('/path/to/theme/AGENTS.md', '/path/to/theme/copilot-instructions.md')
188+
expect(symlink).toHaveBeenCalledWith('/path/to/theme/AGENTS.md', '/path/to/theme/CLAUDE.md')
189+
})
190+
191+
test('throws an error when file operations fail', async () => {
192+
// Given
193+
vi.mocked(downloadGitRepository).mockResolvedValue()
194+
vi.mocked(readFile).mockRejectedValue(new Error('File not found'))
173195

174196
await expect(createAIInstructions(destination, 'cursor')).rejects.toThrow('Failed to create AI instructions')
175197
})
176198
})
199+
200+
describe('createAIInstructionFiles()', () => {
201+
const themeRoot = '/path/to/theme'
202+
const agentsPath = '/path/to/theme/AGENTS.md'
203+
204+
beforeEach(() => {
205+
vi.mocked(joinPath).mockImplementation((...paths) => paths.join('/'))
206+
vi.mocked(readFile).mockResolvedValue('AI instruction content' as any)
207+
vi.mocked(writeFile).mockResolvedValue()
208+
vi.mocked(symlink).mockResolvedValue()
209+
})
210+
211+
test('creates symlink for github instruction', async () => {
212+
// Givin/When
213+
await createAIInstructionFiles(themeRoot, agentsPath, 'github')
214+
215+
// Then
216+
expect(symlink).toHaveBeenCalledWith('/path/to/theme/AGENTS.md', '/path/to/theme/copilot-instructions.md')
217+
})
218+
219+
test('does not create symlink for cursor instruction (uses AGENTS.md natively)', async () => {
220+
// When
221+
await createAIInstructionFiles(themeRoot, agentsPath, 'cursor')
222+
223+
// Then
224+
expect(symlink).not.toHaveBeenCalled()
225+
})
226+
227+
test('creates symlink for claude instruction', async () => {
228+
// When
229+
await createAIInstructionFiles(themeRoot, agentsPath, 'claude')
230+
231+
// Then
232+
expect(symlink).toHaveBeenCalledWith('/path/to/theme/AGENTS.md', '/path/to/theme/CLAUDE.md')
233+
})
234+
235+
test('falls back to copying file when symlink fails with EPERM', async () => {
236+
// Given
237+
vi.mocked(symlink).mockRejectedValue(new Error('EPERM: operation not permitted'))
238+
vi.mocked(readFile).mockResolvedValue('AGENTS.md content' as any)
239+
240+
// When
241+
const result = await createAIInstructionFiles(themeRoot, agentsPath, 'github')
242+
243+
// Then
244+
expect(symlink).toHaveBeenCalled()
245+
expect(readFile).toHaveBeenCalledWith(agentsPath)
246+
expect(writeFile).toHaveBeenCalledWith('/path/to/theme/copilot-instructions.md', 'AGENTS.md content')
247+
expect(result.copiedFile).toBe('copilot-instructions.md')
248+
})
249+
})

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

Lines changed: 71 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
1-
import {renderSelectPrompt, renderTasks} from '@shopify/cli-kit/node/ui'
1+
import {renderSelectPrompt, renderWarning, 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,
@@ -71,6 +72,8 @@ export async function promptAIInstruction() {
7172
}
7273

7374
export async function createAIInstructions(themeRoot: string, aiInstruction: AIInstruction) {
75+
const createdFiles: string[] = []
76+
7477
await renderTasks([
7578
{
7679
title: `Adding AI instructions into ${themeRoot}`,
@@ -81,21 +84,79 @@ export async function createAIInstructions(themeRoot: string, aiInstruction: AII
8184
destination: tempDir,
8285
shallow: true,
8386
})
84-
const aiSrcDir = joinPath(tempDir, 'ai', aiInstruction)
85-
86-
let aiDestDir = themeRoot
8787

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

9294
try {
93-
await copyDirectoryContents(aiSrcDir, aiDestDir)
95+
const sourcePath = joinPath(tempDir, 'ai', 'github', 'copilot-instructions.md')
96+
const sourceContent = await readFile(sourcePath)
97+
98+
const agentsPath = joinPath(themeRoot, 'AGENTS.md')
99+
const agentsContent = `# AGENTS.md\n\n${sourceContent}`
100+
await writeFile(agentsPath, agentsContent)
101+
102+
const results = await Promise.all(
103+
instructions.map((instruction) => createAIInstructionFiles(themeRoot, agentsPath, instruction)),
104+
)
105+
106+
// Collect files that were copied instead of symlinked
107+
results.forEach((result) => {
108+
if (result.copiedFile) {
109+
createdFiles.push(result.copiedFile)
110+
}
111+
})
94112
} catch (error) {
95113
throw new AbortError('Failed to create AI instructions')
96114
}
97115
})
98116
},
99117
},
100118
])
119+
120+
if (createdFiles.length > 0) {
121+
renderWarning({
122+
headline: 'Files created instead of symlinks.',
123+
body: `Shopify CLI attempted to create symbolic links between AGENTS.md and ${createdFiles.join(
124+
', ',
125+
)}, but your system doesn't have Developer Mode enabled or symlinks are disabled. Separate files were created instead.`,
126+
})
127+
}
128+
}
129+
130+
export async function createAIInstructionFiles(
131+
themeRoot: string,
132+
agentsPath: string,
133+
instruction: Exclude<AIInstruction, 'all'>,
134+
): Promise<{copiedFile?: string}> {
135+
if (instruction === 'cursor') {
136+
// Cursor natively supports AGENTS.md, so no symlink needed
137+
return {}
138+
}
139+
140+
const symlinkMap = {
141+
github: 'copilot-instructions.md',
142+
claude: 'CLAUDE.md',
143+
}
144+
145+
const symlinkName = symlinkMap[instruction]
146+
const symlinkPath = joinPath(themeRoot, symlinkName)
147+
148+
try {
149+
await symlink(agentsPath, symlinkPath)
150+
return {}
151+
} catch (error) {
152+
// On Windows, symlinks may require admin privileges or Developer Mode
153+
// Fall back to copying the file if symlink creation fails
154+
if (error instanceof Error) {
155+
const agentsContent = await readFile(agentsPath)
156+
await writeFile(symlinkPath, agentsContent)
157+
return {copiedFile: symlinkName}
158+
} else {
159+
throw error
160+
}
161+
}
101162
}

0 commit comments

Comments
 (0)