Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions packages/agents/src/commands/__tests__/install.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -536,4 +536,57 @@ describe('installCommand', () => {
// The surrounding double quotes should be stripped
expect(doubleQuotedEntry.description).toBe('A double-quoted description');
});

it('should rewrite relative Markdown link paths to absolute in copy mode', async () => {
const claudeHome = path.join(tempDir, '.claude');
await mkdir(path.join(claudeHome, 'skills'), { recursive: true });
await mkdir(path.join(claudeHome, 'agents'), { recursive: true });

await installCommand(makeOptions(), tempDir);

// code-patterns SKILL.md has a Markdown link: [naming-conventions.md](../_data/naming-conventions.md)
const codePatternSkillPath = path.join(claudeHome, 'skills', 'code-patterns', 'SKILL.md');
const content = await readFile(codePatternSkillPath, 'utf8');

// Relative Markdown link paths should have been rewritten to ~/... absolute paths
expect(content).toContain('~/.claude/skills/_data/naming-conventions.md');

// No remaining relative ../_data/ Markdown link references should exist
expect(content).not.toMatch(/\]\(\.\.\/_data\//);
});

it('should preserve anchor fragments in rewritten paths', async () => {
const claudeHome = path.join(tempDir, '.claude');
await mkdir(path.join(claudeHome, 'skills'), { recursive: true });
await mkdir(path.join(claudeHome, 'agents'), { recursive: true });

await installCommand(makeOptions(), tempDir);

// review-criteria has a link with an anchor fragment:
// [artifact conventions](../_data/artifact-conventions.md#finding-scheme-fwtrsl)
const reviewCriteriaPath = path.join(claudeHome, 'skills', 'review-criteria', 'SKILL.md');
const content = await readFile(reviewCriteriaPath, 'utf8');

// Anchor fragment should be preserved in the rewritten path
expect(content).toContain('~/.claude/skills/_data/artifact-conventions.md#finding-scheme-fwtrsl');
});

it('should not rewrite paths in link mode', async () => {
const claudeHome = path.join(tempDir, '.claude');
await mkdir(path.join(claudeHome, 'skills'), { recursive: true });
await mkdir(path.join(claudeHome, 'agents'), { recursive: true });

await installCommand(makeOptions({ link: true }), tempDir);

// In link mode, skills are installed as symlinks pointing to the source directory.
// The rewriter should not run, so the source files remain unchanged.
const codePatternSkillPath = path.join(claudeHome, 'skills', 'code-patterns');
const stats = lstatSync(codePatternSkillPath);
expect(stats.isSymbolicLink()).toBe(true);

// Verify that the original source file still has relative paths (not rewritten)
const contentDir = resolveContentDir();
const sourceContent = await readFile(path.join(contentDir, 'skills', 'code-patterns', 'SKILL.md'), 'utf8');
expect(sourceContent).toContain('../_data/naming-conventions.md');
});
});
14 changes: 14 additions & 0 deletions packages/agents/src/commands/install.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { resolveContentDir } from '../lib/content-resolver.js';
import { mergeFrontmatter, parseFrontmatter } from '../lib/frontmatter-merger.js';
import { checkSymlinkSafety, copyItem, linkItem, unlinkIfSymlink } from '../lib/installer.js';
import { computeContentHash, detectDrift, getManifestPath, readManifest, writeManifest } from '../lib/manifest.js';
import { rewritePathsInDirectory } from '../lib/path-rewriter.js';
import { PLATFORMS, resolvePlatformIds, resolvePlatformPaths } from '../lib/platform.js';
import type { AgentsManifest, InstallOptions, ManifestEntry, PlatformId, PlatformManifest } from '../lib/types.js';

Expand Down Expand Up @@ -39,13 +40,16 @@ export async function installCommand(options: InstallOptions, baseDir?: string):
const entries: Array<ManifestEntry> = [];

// Install skills (shared + platform-specific)
const platformConfig = PLATFORMS[platformId];
const skillsPrefix = `${platformConfig.homeDir}/${platformConfig.skillsDir}`;
const skillEntries = await installSkills(
contentDir,
paths.skillsDir,
paths.platformHome,
existingByPath,
options,
platformId,
skillsPrefix,
);
entries.push(...skillEntries);

Expand Down Expand Up @@ -103,6 +107,7 @@ async function installSkills(
existingByPath: ReadonlyMap<string, ManifestEntry>,
options: InstallOptions,
platformId: PlatformId,
skillsPrefix: string,
): Promise<ReadonlyArray<ManifestEntry>> {
const skillsSrcDir = path.join(contentDir, 'skills');
const dirEntries = await readdir(skillsSrcDir);
Expand All @@ -120,6 +125,7 @@ async function installSkills(
platformHome,
existingByPath,
options,
skillsPrefix,
);
entries.push(result);
}
Expand Down Expand Up @@ -148,6 +154,7 @@ async function installSkills(
platformHome,
existingByPath,
options,
skillsPrefix,
'(platform-specific)',
);
entries.push(result);
Expand All @@ -169,6 +176,7 @@ async function installSkillEntry(
platformHome: string,
existingByPath: ReadonlyMap<string, ManifestEntry>,
options: InstallOptions,
skillsPrefix: string,
label = '',
): Promise<ManifestEntry> {
if (options.dryRun) {
Expand All @@ -189,7 +197,13 @@ async function installSkillEntry(

await (options.link ? linkItem(srcPath, destPath) : copyItem(srcPath, destPath));

// Rewrite relative Markdown paths to absolute in copy mode for directories
const stats = await stat(srcPath);
if (!options.link && stats.isDirectory()) {
const skillsDestDir = path.dirname(destPath);
await rewritePathsInDirectory(destPath, skillsDestDir, skillsPrefix);
}

return {
relativePath,
contentHash: stats.isDirectory() ? `sha256:dir:${relativePath}` : await computeContentHash(destPath),
Expand Down
171 changes: 171 additions & 0 deletions packages/agents/src/lib/__tests__/path-rewriter.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';

import { afterEach, beforeEach, describe, expect, it } from 'vitest';

import { rewriteMarkdownPaths, rewritePathsInDirectory } from '../path-rewriter.js';

describe(rewriteMarkdownPaths, () => {
const skillsPrefix = '.claude/skills';

it.each([
{
name: 'rewrites a relative ../_data/ link',
fileRelPath: 'commit/SKILL.md',
content: 'See [commit-format.md](../_data/commit-format.md) for details.',
expected: 'See [commit-format.md](~/.claude/skills/_data/commit-format.md) for details.',
},
{
name: 'preserves anchor fragments',
fileRelPath: 'review-criteria/SKILL.md',
content: 'See [finding scheme](../_data/artifact-conventions.md#finding-scheme-fwtrsl) for criteria.',
expected:
'See [finding scheme](~/.claude/skills/_data/artifact-conventions.md#finding-scheme-fwtrsl) for criteria.',
},
{
name: 'leaves URL links untouched',
fileRelPath: 'commit/SKILL.md',
content: 'Visit [docs](https://example.com/docs) for info.',
expected: 'Visit [docs](https://example.com/docs) for info.',
},
{
name: 'leaves http URL links untouched',
fileRelPath: 'commit/SKILL.md',
content: 'Visit [docs](http://example.com/docs) for info.',
expected: 'Visit [docs](http://example.com/docs) for info.',
},
{
name: 'leaves absolute paths untouched',
fileRelPath: 'commit/SKILL.md',
content: 'See [file](/absolute/path.md) for info.',
expected: 'See [file](/absolute/path.md) for info.',
},
{
name: 'leaves tilde-prefixed paths untouched',
fileRelPath: 'commit/SKILL.md',
content: 'See [file](~/.claude/skills/_data/commit-format.md) for info.',
expected: 'See [file](~/.claude/skills/_data/commit-format.md) for info.',
},
{
name: 'handles nested file paths correctly',
fileRelPath: 'orchestrate/modules/review-cycle.md',
content: 'See [conventions](../../_data/artifact-conventions.md) for details.',
expected: 'See [conventions](~/.claude/skills/_data/artifact-conventions.md) for details.',
},
{
name: 'handles sibling-directory relative links',
fileRelPath: 'orchestrate-dev/SKILL.md',
content: 'See [review-criteria](../review-criteria/SKILL.md) for the scheme.',
expected: 'See [review-criteria](~/.claude/skills/review-criteria/SKILL.md) for the scheme.',
},
{
name: 'handles same-directory relative links',
fileRelPath: 'orchestrate/SKILL.md',
content: 'See [review cycle](./modules/review-cycle.md) for details.',
expected: 'See [review cycle](~/.claude/skills/orchestrate/modules/review-cycle.md) for details.',
},
{
name: 'leaves anchor-only links untouched',
fileRelPath: 'prepare-pr/SKILL.md',
content: 'See [Saving](#saving) section.',
expected: 'See [Saving](#saving) section.',
},
{
name: 'rewrites multiple links in the same content',
fileRelPath: 'commit/SKILL.md',
content: 'See [format](../_data/commit-format.md) and [types](../_data/work-types.md).',
expected:
'See [format](~/.claude/skills/_data/commit-format.md) and [types](~/.claude/skills/_data/work-types.md).',
},
])('$name', ({ fileRelPath, content, expected }) => {
expect(rewriteMarkdownPaths(content, fileRelPath, skillsPrefix)).toBe(expected);
});

it('returns empty content unchanged', () => {
expect(rewriteMarkdownPaths('', 'commit/SKILL.md', skillsPrefix)).toBe('');
});

it('returns content with no links unchanged', () => {
const content = '# Heading\n\nSome text without links.\n';
expect(rewriteMarkdownPaths(content, 'commit/SKILL.md', skillsPrefix)).toBe(content);
});

it('handles mixed link types in the same line', () => {
const content = 'See [local](../_data/file.md) and [remote](https://example.com) and [abs](/path.md).';
const expected =
'See [local](~/.claude/skills/_data/file.md) and [remote](https://example.com) and [abs](/path.md).';
expect(rewriteMarkdownPaths(content, 'commit/SKILL.md', skillsPrefix)).toBe(expected);
});
});

describe(rewritePathsInDirectory, () => {
let tempDir: string;
let skillsDestDir: string;

beforeEach(async () => {
tempDir = path.join(tmpdir(), `path-rewriter-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
skillsDestDir = path.join(tempDir, 'skills');
await mkdir(skillsDestDir, { recursive: true });
});

afterEach(async () => {
await rm(tempDir, { recursive: true, force: true });
});

it('rewrites relative links in .md files within a directory', async () => {
const skillDir = path.join(skillsDestDir, 'commit');
await mkdir(skillDir, { recursive: true });
await writeFile(path.join(skillDir, 'SKILL.md'), 'See [format](../_data/commit-format.md) for spec.', 'utf8');

await rewritePathsInDirectory(skillDir, skillsDestDir, '.claude/skills');

const result = await readFile(path.join(skillDir, 'SKILL.md'), 'utf8');
expect(result).toBe('See [format](~/.claude/skills/_data/commit-format.md) for spec.');
});

it('recursively processes nested directories', async () => {
const nestedDir = path.join(skillsDestDir, 'orchestrate', 'modules');
await mkdir(nestedDir, { recursive: true });
await writeFile(
path.join(nestedDir, 'review-cycle.md'),
'See [conventions](../../_data/artifact-conventions.md) for details.',
'utf8',
);

await rewritePathsInDirectory(path.join(skillsDestDir, 'orchestrate'), skillsDestDir, '.claude/skills');

const result = await readFile(path.join(nestedDir, 'review-cycle.md'), 'utf8');
expect(result).toBe('See [conventions](~/.claude/skills/_data/artifact-conventions.md) for details.');
});

it('skips non-.md files', async () => {
const skillDir = path.join(skillsDestDir, 'test-skill');
await mkdir(skillDir, { recursive: true });
const originalContent = 'See [format](../_data/commit-format.md) for spec.';
await writeFile(path.join(skillDir, 'notes.txt'), originalContent, 'utf8');

await rewritePathsInDirectory(skillDir, skillsDestDir, '.claude/skills');

const result = await readFile(path.join(skillDir, 'notes.txt'), 'utf8');
expect(result).toBe(originalContent);
});

it('handles an empty directory without error', async () => {
const skillDir = path.join(skillsDestDir, 'empty-skill');
await mkdir(skillDir, { recursive: true });

await expect(rewritePathsInDirectory(skillDir, skillsDestDir, '.claude/skills')).resolves.toBeUndefined();
});

it('does not write files when no changes are needed', async () => {
const skillDir = path.join(skillsDestDir, 'test-skill');
await mkdir(skillDir, { recursive: true });
await writeFile(path.join(skillDir, 'SKILL.md'), '# No links here\n', 'utf8');

await rewritePathsInDirectory(skillDir, skillsDestDir, '.claude/skills');

const result = await readFile(path.join(skillDir, 'SKILL.md'), 'utf8');
expect(result).toBe('# No links here\n');
});
});
75 changes: 75 additions & 0 deletions packages/agents/src/lib/path-rewriter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { lstat, readdir, readFile, writeFile } from 'node:fs/promises';
import path from 'node:path';

/**
* Rewrites relative Markdown link targets in `content` to absolute `~`-prefixed paths.
* Resolves each relative target against the directory of `fileRelPath` within the skills tree,
* then maps to `~/{skillsPrefix}/{resolved}`.
*/
export function rewriteMarkdownPaths(content: string, fileRelPath: string, skillsPrefix: string): string {
const fileDir = path.posix.dirname(fileRelPath);

// Match Markdown links [text](target) where target is a relative path
return content.replace(/\[([^\]]*)\]\(([^)]+)\)/g, (_match, text: string, target: string) => {
// Skip non-relative targets: URLs, absolute paths, tilde paths, anchor-only links
if (/^https?:\/\//.test(target) || target.startsWith('/') || target.startsWith('~') || target.startsWith('#')) {
return `[${text}](${target})`;
}

// Split off anchor fragment before resolution
const hashIndex = target.indexOf('#');
let pathPart: string;
let fragment: string;
if (hashIndex === -1) {
pathPart = target;
fragment = '';
} else {
pathPart = target.slice(0, hashIndex);
fragment = target.slice(hashIndex);
}

// Resolve the relative path against the file's directory, then normalize to collapse ../
const joined = path.posix.join(fileDir, pathPart);
const normalized = path.posix.normalize(joined);

return `[${text}](~/${skillsPrefix}/${normalized}${fragment})`;
});
}

/**
* Walks `.md` files in `dirPath`, applies `rewriteMarkdownPaths` to each, and writes back.
* `skillsDestDir` is the root skills install directory, used to compute each file's relative path.
* `skillsPrefix` is the platform-relative prefix (e.g., `.claude/skills`).
*/
export async function rewritePathsInDirectory(
dirPath: string,
skillsDestDir: string,
skillsPrefix: string,
): Promise<void> {
const entries = await readdir(dirPath);

for (const entry of entries) {
const fullPath = path.join(dirPath, entry);
const stats = await lstat(fullPath);

if (stats.isSymbolicLink()) {
continue;
}

if (stats.isDirectory()) {
await rewritePathsInDirectory(fullPath, skillsDestDir, skillsPrefix);
} else if (entry.endsWith('.md')) {
try {
const fileRelPath = path.relative(skillsDestDir, fullPath).split(path.sep).join('/');
const content = await readFile(fullPath, 'utf8');
const rewritten = rewriteMarkdownPaths(content, fileRelPath, skillsPrefix);
if (rewritten !== content) {
await writeFile(fullPath, rewritten, 'utf8');
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw new Error(`Failed to rewrite paths in ${fullPath}: ${message}`);
}
}
}
}
Loading