diff --git a/packages/cli/src/utils/__tests__/agent.spec.ts b/packages/cli/src/utils/__tests__/agent.spec.ts index 9e460df311..f2bb1cabfa 100644 --- a/packages/cli/src/utils/__tests__/agent.spec.ts +++ b/packages/cli/src/utils/__tests__/agent.spec.ts @@ -349,6 +349,36 @@ describe('writeAgentInstructions symlink behavior', () => { ); }); + it('falls back to copy when symlink throws EPERM (Windows without admin)', async () => { + const dir = await createProjectDir(); + const symlinkSpy = vi.spyOn(fsPromises, 'symlink'); + const copyFileSpy = vi.spyOn(fsPromises, 'copyFile').mockResolvedValue(undefined); + + // Make symlink throw EPERM (Windows behavior without admin privileges) + symlinkSpy.mockRejectedValue( + Object.assign(new Error('EPERM: operation not permitted, symlink'), { code: 'EPERM' }), + ); + + await writeAgentInstructions({ + projectRoot: dir, + targetPaths: ['AGENTS.md', 'CLAUDE.md', '.github/copilot-instructions.md'], + interactive: false, + }); + + // AGENTS.md should be written as a regular file (not symlinked) + expect(mockFs.existsSync(path.join(dir, 'AGENTS.md'))).toBe(true); + + // Non-standard paths should fall back to copyFile since symlink failed + expect(copyFileSpy).toHaveBeenCalledWith( + path.join(dir, 'AGENTS.md'), + path.join(dir, 'CLAUDE.md'), + ); + expect(copyFileSpy).toHaveBeenCalledWith( + path.join(dir, 'AGENTS.md'), + path.join(dir, '.github', 'copilot-instructions.md'), + ); + }); + it('does not replace existing non-symlink files with symlinks', async () => { const dir = await createProjectDir(); const existingClaude = path.join(dir, 'CLAUDE.md'); diff --git a/packages/cli/src/utils/agent.ts b/packages/cli/src/utils/agent.ts index 02935e5e80..a4a38c1b36 100644 --- a/packages/cli/src/utils/agent.ts +++ b/packages/cli/src/utils/agent.ts @@ -609,7 +609,20 @@ async function tryLinkTargetToAgents(projectRoot: string, targetPath: string, si await fsPromises.unlink(destinationPath); } - await fsPromises.symlink(symlinkTarget, destinationPath); + try { + await fsPromises.symlink(symlinkTarget, destinationPath); + } catch (err: unknown) { + if ((err as NodeJS.ErrnoException).code === 'EPERM') { + // On Windows, symlinks require admin privileges. + // Fall back to copying the file instead. + await fsPromises.copyFile(agentsPath, destinationPath); + if (!silent) { + prompts.log.success(`Copied ${AGENT_STANDARD_PATH} to ${targetPath}`); + } + return true; + } + throw err; + } if (!silent) { prompts.log.success(`Linked ${targetPath} to ${AGENT_STANDARD_PATH}`); } diff --git a/packages/cli/src/utils/skills.ts b/packages/cli/src/utils/skills.ts index ea23e7e127..14077403b1 100644 --- a/packages/cli/src/utils/skills.ts +++ b/packages/cli/src/utils/skills.ts @@ -74,16 +74,20 @@ function linkSkills( mkdirSync(targetDir, { recursive: true }); } + const isWindows = process.platform === 'win32'; + const symlinkType = isWindows ? 'junction' : 'dir'; + let linked = 0; for (const skill of skills) { const linkPath = join(targetDir, skill.dirName); const sourcePath = join(skillsDir, skill.dirName); const relativeTarget = relative(targetDir, sourcePath); + const symlinkTarget = isWindows ? sourcePath : relativeTarget; if (pathExists(linkPath)) { try { const existing = readlinkSync(linkPath); - if (existing === relativeTarget) { + if (existing === symlinkTarget) { prompts.log.info(` ${skill.name} — already linked`); continue; } @@ -100,7 +104,7 @@ function linkSkills( } try { - symlinkSync(relativeTarget, linkPath, 'dir'); + symlinkSync(symlinkTarget, linkPath, symlinkType); } catch (err: unknown) { prompts.log.warn(` ${skill.name} — failed to create symlink: ${(err as Error).message}`); continue; diff --git a/packages/tools/src/install-global-cli.ts b/packages/tools/src/install-global-cli.ts index fc5b9796b2..68dd3e4b79 100644 --- a/packages/tools/src/install-global-cli.ts +++ b/packages/tools/src/install-global-cli.ts @@ -248,7 +248,8 @@ function setupLocalDevDeps(versionDir: string) { // Symlink node_modules/vite-plus → packages/cli (source) const cliDir = path.join(repoRoot, 'packages', 'cli'); - symlinkSync(cliDir, path.join(nodeModulesDir, 'vite-plus'), 'dir'); + const symlinkType = isWindows ? 'junction' : 'dir'; + symlinkSync(cliDir, path.join(nodeModulesDir, 'vite-plus'), symlinkType); // Symlink transitive deps from packages/cli/node_modules const cliNodeModules = path.join(cliDir, 'node_modules'); @@ -267,10 +268,10 @@ function setupLocalDevDeps(versionDir: string) { if (entry.startsWith('@')) { mkdirSync(dest, { recursive: true }); for (const sub of readdirSync(src)) { - symlinkSync(path.join(src, sub), path.join(dest, sub), 'dir'); + symlinkSync(path.join(src, sub), path.join(dest, sub), symlinkType); } } else { - symlinkSync(src, dest, 'dir'); + symlinkSync(src, dest, symlinkType); } } }