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
121 changes: 114 additions & 7 deletions src/cli/commands/plugin-skills.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@ import {
addEnabledSkill,
addPlugin,
hasPlugin,
resolveGitHubIdentity,
setPluginSkillsMode,
upsertGitHubPluginSourceAllowlist,
} from '../../core/workspace-modify.js';
import {
addUserDisabledSkill,
Expand All @@ -22,6 +24,7 @@ import {
addUserPlugin,
hasUserPlugin,
setUserPluginSkillsMode,
upsertUserGitHubPluginSourceAllowlist,
} from '../../core/user-workspace.js';
import { getAllSkillsFromPlugins, findSkillByName, discoverSkillNames } from '../../core/skills.js';
import { isJsonMode, jsonOutput } from '../json-output.js';
Expand Down Expand Up @@ -476,15 +479,17 @@ async function installSkillFromSource(opts: {
return { success: false, error: `Failed to fetch '${from}': ${fetchResult.error ?? 'Unknown error'}` };
}

const sourcePath = resolveFetchedSourcePath(from, fetchResult.cachePath);

// Check if the source is a marketplace
const manifestResult = await parseMarketplaceManifest(fetchResult.cachePath);
const manifestResult = await parseMarketplaceManifest(sourcePath);

if (manifestResult.success) {
return installSkillViaMarketplace({ skill, from, isUser, workspacePath });
}

// Not a marketplace — install as a direct plugin
return installSkillDirect({ skill, from, isUser, workspacePath, cachePath: fetchResult.cachePath });
return installSkillDirect({ skill, from, isUser, workspacePath, cachePath: sourcePath });
}

/**
Expand Down Expand Up @@ -602,6 +607,30 @@ async function installSkillDirect(opts: {
};
}

if (isGitHubUrl(from)) {
const existingEnabledSkills = await getEnabledSkillsForGitHubSource(from, workspacePath);
const desiredSkills = [...existingEnabledSkills];
if (!desiredSkills.includes(skill)) desiredSkills.push(skill);

const updateResult = isUser
? await upsertUserGitHubPluginSourceAllowlist(from, desiredSkills)
: await upsertGitHubPluginSourceAllowlist(from, desiredSkills, workspacePath);

if (!updateResult.success) {
return {
success: false,
error: `Failed to update plugin '${from}': ${updateResult.error ?? 'Unknown error'}`,
};
}

return finishSkillEnable({
skill,
pluginName: extractPrimaryPluginName(updateResult.normalizedPlugin ?? from),
isUser,
workspacePath,
});
}

const installResult = isUser
? await addUserPlugin(from)
: await addPlugin(from, workspacePath);
Expand All @@ -619,6 +648,37 @@ async function installSkillDirect(opts: {
return applySkillAllowlist({ skill, pluginName, isUser, workspacePath });
}

function extractPrimaryPluginName(source: string): string {
const parsed = isGitHubUrl(source) ? parseGitHubUrl(source) : null;
if (parsed?.subpath) {
const segments = parsed.subpath.split('/').filter(Boolean);
const leaf = segments[segments.length - 1];
if (leaf) return leaf;
}

return getPluginName(source);
}

async function getEnabledSkillsForGitHubSource(
source: string,
workspacePath: string,
): Promise<string[]> {
const identity = await resolveGitHubIdentity(source);
if (!identity) return [];

const enabledSkills: string[] = [];
const allSkills = await getAllSkillsFromPlugins(workspacePath);

for (const skill of allSkills) {
if (skill.disabled) continue;
const skillIdentity = await resolveGitHubIdentity(skill.pluginSource);
if (skillIdentity !== identity) continue;
if (!enabledSkills.includes(skill.name)) enabledSkills.push(skill.name);
}

return enabledSkills;
}

/**
* Set or extend the plugin's skill allowlist with the requested skill, then sync.
*/
Expand Down Expand Up @@ -659,6 +719,17 @@ async function applySkillAllowlist(opts: {
}
}

return finishSkillEnable({ skill, pluginName, isUser, workspacePath });
}

async function finishSkillEnable(opts: {
skill: string;
pluginName: string;
isUser: boolean;
workspacePath: string;
}): Promise<InstallSkillResult> {
const { skill, pluginName, isUser, workspacePath } = opts;

if (!isJsonMode()) {
console.log(`\u2713 Enabled skill: ${skill} (${pluginName})`);
}
Expand Down Expand Up @@ -744,21 +815,21 @@ async function discoverSkillsFromSource(from: string): Promise<
return { success: false, error: `Failed to fetch '${from}': ${fetchResult.error ?? 'Unknown error'}` };
}

const manifestResult = await parseMarketplaceManifest(fetchResult.cachePath);
const sourcePath = resolveFetchedSourcePath(from, fetchResult.cachePath);
const manifestResult = await parseMarketplaceManifest(sourcePath);
if (manifestResult.success) {
const all: DiscoveredSkill[] = [];
for (const plugin of manifestResult.data.plugins) {
// Skip remote URL sources — listing would need extra fetches
if (typeof plugin.source === 'object') continue;
const resolved = resolvePluginSourcePath(plugin.source, fetchResult.cachePath);
const resolved = resolvePluginSourcePath(plugin.source, sourcePath);
if (!existsSync(resolved)) continue;
const skills = await discoverSkillsWithMetadata(resolved, plugin.name);
all.push(...skills);
}
return { success: true, skills: all, isMarketplace: true };
}

const sourcePath = resolveFetchedSourcePath(from, fetchResult.cachePath);
const skills = await discoverSkillsWithMetadata(sourcePath);
return { success: true, skills, isMarketplace: false };
}
Expand Down Expand Up @@ -789,19 +860,55 @@ async function installAllSkillsFromSource(opts: {
return { success: false, error: `Failed to fetch '${from}': ${fetchResult.error ?? 'Unknown error'}` };
}

const manifestResult = await parseMarketplaceManifest(fetchResult.cachePath);
const sourcePath = resolveFetchedSourcePath(from, fetchResult.cachePath);
const manifestResult = await parseMarketplaceManifest(sourcePath);

if (manifestResult.success) {
return installAllViaMarketplace({ from, isUser, workspacePath, cachedPath: fetchResult.cachePath });
}

// Direct plugin install — enable every discovered skill
const sourcePath = resolveFetchedSourcePath(from, fetchResult.cachePath);
const skillNames = await discoverSkillNames(sourcePath);
if (skillNames.length === 0) {
return { success: false, error: `No skills found in '${from}'.` };
}

if (isGitHubUrl(from)) {
const existingEnabledSkills = await getEnabledSkillsForGitHubSource(from, workspacePath);
const desiredSkills = [...existingEnabledSkills];
for (const skillName of skillNames) {
if (!desiredSkills.includes(skillName)) desiredSkills.push(skillName);
}

const updateResult = isUser
? await upsertUserGitHubPluginSourceAllowlist(from, desiredSkills)
: await upsertGitHubPluginSourceAllowlist(from, desiredSkills, workspacePath);

if (!updateResult.success) {
return {
success: false,
error: `Failed to configure skill allowlist: ${updateResult.error ?? 'Unknown error'}`,
};
}

const pluginName = extractPrimaryPluginName(updateResult.normalizedPlugin ?? from);

if (!isJsonMode()) {
console.log(`✓ Enabled ${skillNames.length} skill(s) from ${pluginName}: ${skillNames.join(', ')}`);
}

const syncResult = isUser ? await syncUserWorkspace() : await syncWorkspace(workspacePath);
if (!syncResult.success) {
return { success: false, error: 'Sync failed' };
}

return {
success: true,
installed: [{ pluginName, skills: desiredSkills }],
syncResult,
};
}

const installResult = isUser ? await addUserPlugin(from) : await addPlugin(from, workspacePath);
if (!installResult.success) {
if (!installResult.error?.includes('already exists') && !installResult.error?.includes('duplicates existing')) {
Expand Down
52 changes: 29 additions & 23 deletions src/core/skills.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,11 @@ export interface SkillInfo {
pluginSkillsMode: 'allowlist' | 'blocklist' | 'none';
}

export interface DiscoveredSkillEntry {
name: string;
skillPath: string;
}

/**
* Result of resolving a plugin source
*/
Expand Down Expand Up @@ -77,6 +82,25 @@ async function resolvePluginPath(
return existsSync(resolved) ? { path: resolved } : null;
}

export async function discoverNestedSkillEntries(pluginPath: string): Promise<DiscoveredSkillEntry[]> {
const entries = await readdir(pluginPath, { withFileTypes: true });
const discovered: DiscoveredSkillEntry[] = [];

for (const entry of entries) {
if (!entry.isDirectory()) continue;

const skillPath = join(pluginPath, entry.name);
if (existsSync(join(skillPath, 'SKILL.md'))) {
discovered.push({ name: entry.name, skillPath });
continue;
}

discovered.push(...await discoverNestedSkillEntries(skillPath));
}

return discovered;
}

/**
* Get all skills from all installed plugins
* @param workspacePath - Path to workspace directory
Expand Down Expand Up @@ -126,19 +150,9 @@ export async function getAllSkillsFromPlugins(
.filter((e) => e.isDirectory())
.map((e) => ({ name: e.name, skillPath: join(skillsDir, e.name) }));
} else {
// Flat layout: plugin/<skill-name>/SKILL.md
const entries = await readdir(pluginPath, { withFileTypes: true });
const flatSkills: { name: string; skillPath: string }[] = [];
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, skillPath: join(pluginPath, entry.name) });
}
}

if (flatSkills.length > 0) {
skillEntries = flatSkills;
const nestedSkills = await discoverNestedSkillEntries(pluginPath);
if (nestedSkills.length > 0) {
skillEntries = nestedSkills;
} else {
// Root-level single-skill layout: plugin/SKILL.md
const rootSkillMd = join(pluginPath, 'SKILL.md');
Expand Down Expand Up @@ -223,16 +237,8 @@ export async function discoverSkillNames(pluginPath: string): Promise<string[]>
return entries.filter((e) => e.isDirectory()).map((e) => e.name);
}

// Flat layout: subdirs with SKILL.md
const entries = await readdir(pluginPath, { withFileTypes: true });
const flatSkills: string[] = [];
for (const entry of entries) {
if (!entry.isDirectory()) continue;
if (existsSync(join(pluginPath, entry.name, 'SKILL.md'))) {
flatSkills.push(entry.name);
}
}
if (flatSkills.length > 0) return flatSkills;
const nestedSkills = await discoverNestedSkillEntries(pluginPath);
if (nestedSkills.length > 0) return nestedSkills.map((entry) => entry.name);

// Root-level SKILL.md
const rootSkillMd = join(pluginPath, 'SKILL.md');
Expand Down
35 changes: 11 additions & 24 deletions src/core/transform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ 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';
import { discoverNestedSkillEntries } from './skills.js';

/**
* Agent instruction files that receive WORKSPACE-RULES injection
Expand Down Expand Up @@ -278,19 +279,12 @@ export async function copySkills(
.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 });
}
}
const nestedSkills = (await discoverNestedSkillEntries(pluginPath))
.filter((entry) => !isExcluded(pluginPath, entry.skillPath, options.exclude))
.map((entry) => ({ name: entry.name, sourcePath: entry.skillPath, isRootLevel: false }));

if (flatSkills.length > 0) {
skillSources = flatSkills;
if (nestedSkills.length > 0) {
skillSources = nestedSkills;
} else {
// Root-level single-skill layout: plugin/SKILL.md
const rootSkillMd = join(pluginPath, 'SKILL.md');
Expand Down Expand Up @@ -427,19 +421,12 @@ export async function collectPluginSkills(
.filter((e) => e.isDirectory())
.map((e) => ({ name: e.name, path: join(skillsDir, e.name) }));
} else {
// Flat layout: plugin/<skill-name>/SKILL.md
const entries = await readdir(pluginPath, { withFileTypes: true });
const flatDirs: { name: string; path: string }[] = [];
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const skillMdPath = join(pluginPath, entry.name, 'SKILL.md');
if (existsSync(skillMdPath)) {
flatDirs.push({ name: entry.name, path: join(pluginPath, entry.name) });
}
}
const nestedDirs = await discoverNestedSkillEntries(pluginPath).then((entries) =>
entries.map((entry) => ({ name: entry.name, path: entry.skillPath }))
);

if (flatDirs.length > 0) {
candidateDirs = flatDirs;
if (nestedDirs.length > 0) {
candidateDirs = nestedDirs;
} else {
// Root-level single-skill layout: plugin/SKILL.md
const rootSkillMd = join(pluginPath, 'SKILL.md');
Expand Down
28 changes: 28 additions & 0 deletions src/core/user-workspace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
pruneDisabledSkillsForPlugin,
pruneEnabledSkillsForPlugin,
resolveGitHubIdentity,
upsertGitHubPluginSourceAllowlistInConfig,
} from './workspace-modify.js';

/**
Expand Down Expand Up @@ -755,6 +756,33 @@ export async function setUserPluginSkillsMode(
}
}

export async function upsertUserGitHubPluginSourceAllowlist(
source: string,
skillNames: string[],
): Promise<ModifyResult> {
await ensureUserWorkspace();
const configPath = getUserWorkspaceConfigPath();

try {
const content = await readFile(configPath, 'utf-8');
const config = load(content) as WorkspaceConfig;
const result = await upsertGitHubPluginSourceAllowlistInConfig(
config,
source,
skillNames,
);
if (!result.success) return result;

await writeFile(configPath, dump(config, { lineWidth: -1 }), 'utf-8');
return result;
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : String(error),
};
}
}

/**
* Scope where a plugin is installed
*/
Expand Down
Loading