Skip to content

Commit 6f306ee

Browse files
committed
Introduce AGENTS.md file support
1 parent 756d78a commit 6f306ee

File tree

5 files changed

+242
-19
lines changed

5 files changed

+242
-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: 70 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,78 @@ 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') as AIInstruction[])
91+
: [aiInstruction]
9192

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

0 commit comments

Comments
 (0)