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
19 changes: 17 additions & 2 deletions src/core/skills.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import { existsSync } from 'node:fs';
import { readFile, readdir } from 'node:fs/promises';
import { join, resolve } from 'node:path';
import { join, basename, resolve } from 'node:path';
import { load } from 'js-yaml';
import { CONFIG_DIR, WORKSPACE_CONFIG_FILE } from '../constants.js';
import { getPluginSource, type WorkspaceConfig, type PluginSkillsConfig } from '../models/workspace-config.js';
import { fetchPlugin, getPluginName } from './plugin.js';
import { isGitHubUrl, parseGitHubUrl } from '../utils/plugin-path.js';
import { isPluginSpec, resolvePluginSpecWithAutoRegister } from './marketplace.js';
import { parseSkillMetadata } from '../validators/skill.js';

/**
* Information about a skill from an installed plugin
Expand Down Expand Up @@ -134,7 +135,21 @@ export async function getAllSkillsFromPlugins(
flatSkills.push({ name: entry.name, skillPath: join(pluginPath, entry.name) });
}
}
skillEntries = flatSkills;

if (flatSkills.length > 0) {
skillEntries = flatSkills;
} else {
// Root-level single-skill layout: plugin/SKILL.md
const rootSkillMd = join(pluginPath, 'SKILL.md');
if (existsSync(rootSkillMd)) {
const skillContent = await readFile(rootSkillMd, 'utf-8');
const metadata = parseSkillMetadata(skillContent);
const skillName = metadata?.name ?? basename(pluginPath);
skillEntries = [{ name: skillName, skillPath: pluginPath }];
} else {
skillEntries = [];
}
}
}

const pluginSkillsMode: SkillInfo['pluginSkillsMode'] =
Expand Down
98 changes: 74 additions & 24 deletions src/core/transform.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { readFile, writeFile, mkdir, cp, readdir } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { join, dirname, relative } from 'node:path';
import { join, basename, dirname, relative } from 'node:path';
import micromatch from 'micromatch';
import { resolveGlobPatterns, isGlobPattern } from '../utils/glob-patterns.js';
import { CLIENT_MAPPINGS, isUniversalClient } from '../models/client-mapping.js';
Expand All @@ -10,6 +10,7 @@ import { generateWorkspaceRules, type WorkspaceRepository } from '../constants.j
import { parseFileSource } from '../utils/plugin-path.js';
import { createSymlink } from '../utils/symlink.js';
import { adjustLinksInContent } from '../utils/link-adjuster.js';
import { parseSkillMetadata } from '../validators/skill.js';

/**
* Agent instruction files that receive WORKSPACE-RULES injection
Expand Down Expand Up @@ -256,8 +257,51 @@ export async function copySkills(
return results;
}

const sourceDir = join(pluginPath, 'skills');
if (!existsSync(sourceDir)) {
// Discover skill sources across all layouts
const skillsDir = join(pluginPath, 'skills');
let skillSources: { name: string; sourcePath: string; isRootLevel: boolean }[];

if (existsSync(skillsDir)) {
// Standard layout: plugin/skills/<skill-name>/
const entries = await readdir(skillsDir, { withFileTypes: true });
skillSources = entries
.filter((e) => e.isDirectory())
.filter((e) => !isExcluded(pluginPath, join(skillsDir, e.name), options.exclude))
.map((e) => ({ name: e.name, sourcePath: join(skillsDir, e.name), isRootLevel: false }));
} else {
// Flat layout: plugin/<skill-name>/SKILL.md
const entries = await readdir(pluginPath, { withFileTypes: true });
const flatSkills: { name: string; sourcePath: string; isRootLevel: boolean }[] = [];
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const skillMdPath = join(pluginPath, entry.name, 'SKILL.md');
if (existsSync(skillMdPath)) {
flatSkills.push({ name: entry.name, sourcePath: join(pluginPath, entry.name), isRootLevel: false });
}
}

if (flatSkills.length > 0) {
skillSources = flatSkills;
} else {
// Root-level single-skill layout: plugin/SKILL.md
const rootSkillMd = join(pluginPath, 'SKILL.md');
if (existsSync(rootSkillMd)) {
const content = await readFile(rootSkillMd, 'utf-8');
const metadata = parseSkillMetadata(content);
const skillName = metadata?.name ?? basename(pluginPath);
skillSources = [{ name: skillName, sourcePath: pluginPath, isRootLevel: true }];
} else {
return results;
}
}
}

// When skillNameMap is provided, only copy skills that are in the map
if (skillNameMap) {
skillSources = skillSources.filter((s) => skillNameMap.has(s.name));
}

if (skillSources.length === 0) {
return results;
}

Expand All @@ -266,29 +310,18 @@ export async function copySkills(
await mkdir(destDir, { recursive: true });
}

const entries = await readdir(sourceDir, { withFileTypes: true });
let skillDirs = entries.filter((e) => e.isDirectory())
.filter((e) => !isExcluded(pluginPath, join(sourceDir, e.name), options.exclude));

// When skillNameMap is provided, only copy skills that are in the map
// (disabled skills are excluded from the map during collection)
if (skillNameMap) {
skillDirs = skillDirs.filter((e) => skillNameMap.has(e.name));
}

// Determine if we should use symlinks for this client
const useSymlinks = syncMode === 'symlink' && !isUniversalClient(client) && canonicalSkillsPath;

// Process skill directories in parallel for better performance
const copyPromises = skillDirs.map(async (entry): Promise<CopyResult> => {
const skillSourcePath = join(sourceDir, entry.name);
const copyPromises = skillSources.map(async (skill): Promise<CopyResult> => {
// Use resolved name from skillNameMap if available, otherwise use folder name
const resolvedName = skillNameMap?.get(entry.name) ?? entry.name;
const resolvedName = skillNameMap?.get(skill.name) ?? skill.name;
const skillDestPath = join(destDir, resolvedName);

if (dryRun) {
return {
source: skillSourcePath,
source: skill.sourcePath,
destination: skillDestPath,
action: 'copied',
};
Expand All @@ -307,23 +340,26 @@ export async function copySkills(
};
}
// Symlink failed, fall back to copy
// Log warning? For now, just fall through to copy
}

try {
if (options.exclude && options.exclude.length > 0) {
await copyDirectoryWithExclusions(skillSourcePath, skillDestPath, pluginPath, options.exclude);
if (skill.isRootLevel) {
// Root-level: copy only the SKILL.md into a new skill directory
await mkdir(skillDestPath, { recursive: true });
await cp(join(skill.sourcePath, 'SKILL.md'), join(skillDestPath, 'SKILL.md'));
} else if (options.exclude && options.exclude.length > 0) {
await copyDirectoryWithExclusions(skill.sourcePath, skillDestPath, pluginPath, options.exclude);
} else {
await cp(skillSourcePath, skillDestPath, { recursive: true });
await cp(skill.sourcePath, skillDestPath, { recursive: true });
}
return {
source: skillSourcePath,
source: skill.sourcePath,
destination: skillDestPath,
action: 'copied',
};
} catch (error) {
return {
source: skillSourcePath,
source: skill.sourcePath,
destination: skillDestPath,
action: 'failed',
error: error instanceof Error ? error.message : 'Unknown error',
Expand Down Expand Up @@ -392,7 +428,21 @@ export async function collectPluginSkills(
flatDirs.push({ name: entry.name, path: join(pluginPath, entry.name) });
}
}
candidateDirs = flatDirs;

if (flatDirs.length > 0) {
candidateDirs = flatDirs;
} else {
// Root-level single-skill layout: plugin/SKILL.md
const rootSkillMd = join(pluginPath, 'SKILL.md');
if (existsSync(rootSkillMd)) {
const content = await readFile(rootSkillMd, 'utf-8');
const metadata = parseSkillMetadata(content);
const skillName = metadata?.name ?? basename(pluginPath);
candidateDirs = [{ name: skillName, path: pluginPath }];
} else {
candidateDirs = [];
}
}
}

let filteredDirs: typeof candidateDirs;
Expand Down
57 changes: 57 additions & 0 deletions tests/unit/core/skills.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,4 +78,61 @@ describe('getAllSkillsFromPlugins', () => {
// skill-b is NOT in the allowlist -> disabled (disabled: true)
expect(skillB?.disabled).toBe(true);
});

it('discovers root-level SKILL.md with frontmatter name', async () => {
const rootSkillPlugin = join(tmpDir, 'root-skill-plugin');
await mkdir(rootSkillPlugin, { recursive: true });
await writeFile(
join(rootSkillPlugin, 'SKILL.md'),
'---\nname: my-skill\ndescription: A test skill\n---\n# My Skill',
);

const config = {
repositories: [],
plugins: [rootSkillPlugin],
clients: ['claude'],
};
await writeFile(join(tmpDir, '.allagents/workspace.yaml'), dump(config));

const skills = await getAllSkillsFromPlugins(tmpDir);
expect(skills).toHaveLength(1);
expect(skills[0]!.name).toBe('my-skill');
expect(skills[0]!.pluginName).toBe('root-skill-plugin');
expect(skills[0]!.path).toBe(rootSkillPlugin);
});

it('falls back to directory name when root SKILL.md has no frontmatter name', async () => {
const rootSkillPlugin = join(tmpDir, 'fallback-name-plugin');
await mkdir(rootSkillPlugin, { recursive: true });
await writeFile(join(rootSkillPlugin, 'SKILL.md'), '# Just content, no frontmatter');

const config = {
repositories: [],
plugins: [rootSkillPlugin],
clients: ['claude'],
};
await writeFile(join(tmpDir, '.allagents/workspace.yaml'), dump(config));

const skills = await getAllSkillsFromPlugins(tmpDir);
expect(skills).toHaveLength(1);
expect(skills[0]!.name).toBe('fallback-name-plugin');
});

it('prefers flat layout over root-level SKILL.md', async () => {
const mixedPlugin = join(tmpDir, 'mixed-plugin');
await mkdir(join(mixedPlugin, 'sub-skill'), { recursive: true });
await writeFile(join(mixedPlugin, 'SKILL.md'), '---\nname: root\ndescription: root\n---\n');
await writeFile(join(mixedPlugin, 'sub-skill/SKILL.md'), '# Sub skill');

const config = {
repositories: [],
plugins: [mixedPlugin],
clients: ['claude'],
};
await writeFile(join(tmpDir, '.allagents/workspace.yaml'), dump(config));

const skills = await getAllSkillsFromPlugins(tmpDir);
expect(skills).toHaveLength(1);
expect(skills[0]!.name).toBe('sub-skill');
});
});