diff --git a/src/cli/commands/plugin-skills.ts b/src/cli/commands/plugin-skills.ts index 2b410ae..5c3d986 100644 --- a/src/cli/commands/plugin-skills.ts +++ b/src/cli/commands/plugin-skills.ts @@ -11,7 +11,9 @@ import { addEnabledSkill, addPlugin, hasPlugin, + resolveGitHubIdentity, setPluginSkillsMode, + upsertGitHubPluginSourceAllowlist, } from '../../core/workspace-modify.js'; import { addUserDisabledSkill, @@ -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'; @@ -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 }); } /** @@ -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); @@ -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 { + 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. */ @@ -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 { + const { skill, pluginName, isUser, workspacePath } = opts; + if (!isJsonMode()) { console.log(`\u2713 Enabled skill: ${skill} (${pluginName})`); } @@ -744,13 +815,14 @@ 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); @@ -758,7 +830,6 @@ async function discoverSkillsFromSource(from: string): Promise< return { success: true, skills: all, isMarketplace: true }; } - const sourcePath = resolveFetchedSourcePath(from, fetchResult.cachePath); const skills = await discoverSkillsWithMetadata(sourcePath); return { success: true, skills, isMarketplace: false }; } @@ -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')) { diff --git a/src/core/skills.ts b/src/core/skills.ts index 867e8cf..a973ff3 100644 --- a/src/core/skills.ts +++ b/src/core/skills.ts @@ -31,6 +31,11 @@ export interface SkillInfo { pluginSkillsMode: 'allowlist' | 'blocklist' | 'none'; } +export interface DiscoveredSkillEntry { + name: string; + skillPath: string; +} + /** * Result of resolving a plugin source */ @@ -77,6 +82,25 @@ async function resolvePluginPath( return existsSync(resolved) ? { path: resolved } : null; } +export async function discoverNestedSkillEntries(pluginPath: string): Promise { + 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 @@ -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.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'); @@ -223,16 +237,8 @@ export async function discoverSkillNames(pluginPath: string): Promise 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'); diff --git a/src/core/transform.ts b/src/core/transform.ts index 6fcd8e3..909ec5c 100644 --- a/src/core/transform.ts +++ b/src/core/transform.ts @@ -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 @@ -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.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'); @@ -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.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'); diff --git a/src/core/user-workspace.ts b/src/core/user-workspace.ts index c189840..247ffb7 100644 --- a/src/core/user-workspace.ts +++ b/src/core/user-workspace.ts @@ -29,6 +29,7 @@ import { pruneDisabledSkillsForPlugin, pruneEnabledSkillsForPlugin, resolveGitHubIdentity, + upsertGitHubPluginSourceAllowlistInConfig, } from './workspace-modify.js'; /** @@ -755,6 +756,33 @@ export async function setUserPluginSkillsMode( } } +export async function upsertUserGitHubPluginSourceAllowlist( + source: string, + skillNames: string[], +): Promise { + 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 */ diff --git a/src/core/workspace-modify.ts b/src/core/workspace-modify.ts index 85c9d00..91415f5 100644 --- a/src/core/workspace-modify.ts +++ b/src/core/workspace-modify.ts @@ -407,13 +407,15 @@ export function extractPluginNames(pluginSource: string): string[] { if (isGitHubUrl(pluginSource)) { const parsed = parseGitHubUrl(pluginSource); if (parsed) { - const names = [parsed.repo]; + const names: string[] = []; const ownerRepo = `${parsed.owner}-${parsed.repo}`; if (ownerRepo !== parsed.repo) names.push(ownerRepo); if (parsed.subpath) { const subpathName = parsed.subpath.split('/').filter(Boolean).pop(); if (subpathName && !names.includes(subpathName)) names.push(subpathName); } + if (!names.includes(parsed.repo)) names.push(parsed.repo); + if (!names.includes(ownerRepo)) names.push(ownerRepo); return names; } } @@ -456,6 +458,131 @@ export function ensureObjectPluginEntry( return entry; } +function uniqueSkillNames(skillNames: string[]): string[] { + const unique: string[] = []; + const seen = new Set(); + + for (const skillName of skillNames) { + if (seen.has(skillName)) continue; + seen.add(skillName); + unique.push(skillName); + } + + return unique; +} + +function formatGitHubSource( + parsed: { owner: string; repo: string; branch?: string; subpath?: string }, + styleSource: string, +): string { + const basePath = `${parsed.owner}/${parsed.repo}`; + + if ( + styleSource.startsWith('http://') || + styleSource.startsWith('https://') || + styleSource.startsWith('github.com/') + ) { + const baseUrl = `https://github.com/${basePath}`; + if (!parsed.branch) return baseUrl; + return parsed.subpath + ? `${baseUrl}/tree/${parsed.branch}/${parsed.subpath}` + : `${baseUrl}/tree/${parsed.branch}`; + } + + if (!parsed.branch) { + return parsed.subpath ? `${basePath}/${parsed.subpath}` : basePath; + } + + return parsed.subpath + ? `${basePath}@${parsed.branch}/${parsed.subpath}` + : `${basePath}@${parsed.branch}`; +} + +export function canonicalizeGitHubPluginSource( + currentSource: string, + nextSource: string, +): string { + const current = parseGitHubUrl(currentSource); + const next = parseGitHubUrl(nextSource); + + if (!current || !next) return nextSource; + if ( + current.owner.toLowerCase() !== next.owner.toLowerCase() || + current.repo.toLowerCase() !== next.repo.toLowerCase() + ) { + return nextSource; + } + + if (current.branch && next.branch && current.branch !== next.branch) { + return currentSource; + } + + const currentParts = current.subpath?.split('/').filter(Boolean) ?? []; + const nextParts = next.subpath?.split('/').filter(Boolean) ?? []; + const sharedParts: string[] = []; + const sharedLength = Math.min(currentParts.length, nextParts.length); + + for (let i = 0; i < sharedLength; i++) { + if (currentParts[i] !== nextParts[i]) break; + sharedParts.push(currentParts[i] as string); + } + + return formatGitHubSource( + { + owner: current.owner, + repo: current.repo, + ...(current.branch || next.branch ? { branch: current.branch ?? next.branch } : {}), + ...(sharedParts.length > 0 ? { subpath: sharedParts.join('/') } : {}), + }, + currentSource, + ); +} + +async function findPluginEntryByGitHubIdentity( + config: WorkspaceConfig, + source: string, +): Promise { + const identity = await resolveGitHubIdentity(source); + if (!identity) return -1; + + for (let i = 0; i < config.plugins.length; i++) { + const entry = config.plugins[i]; + if (!entry) continue; + const existingIdentity = await resolveGitHubIdentity(getPluginSource(entry)); + if (existingIdentity === identity) return i; + } + + return -1; +} + +export async function upsertGitHubPluginSourceAllowlistInConfig( + config: WorkspaceConfig, + source: string, + skillNames: string[], +): Promise { + const normalizedSkills = uniqueSkillNames(skillNames); + const exactIndex = config.plugins.findIndex((entry) => getPluginSource(entry) === source); + + if (exactIndex !== -1) { + const entry = ensureObjectPluginEntry(config, exactIndex); + entry.source = source; + entry.skills = normalizedSkills; + return { success: true, normalizedPlugin: source }; + } + + const semanticIndex = await findPluginEntryByGitHubIdentity(config, source); + if (semanticIndex === -1) { + config.plugins.push({ source, skills: normalizedSkills }); + return { success: true, normalizedPlugin: source }; + } + + const entry = ensureObjectPluginEntry(config, semanticIndex); + const normalizedSource = canonicalizeGitHubPluginSource(entry.source, source); + entry.source = normalizedSource; + entry.skills = normalizedSkills; + return { success: true, normalizedPlugin: normalizedSource }; +} + /** Parse "pluginName:skillName" into its two parts, or return null on bad format. */ function parseSkillKey( skillKey: string, @@ -884,6 +1011,40 @@ export async function setPluginSkillsMode( } } +export async function upsertGitHubPluginSourceAllowlist( + source: string, + skillNames: string[], + workspacePath: string = process.cwd(), +): Promise { + await ensureWorkspace(workspacePath); + const configPath = join(workspacePath, CONFIG_DIR, WORKSPACE_CONFIG_FILE); + if (!existsSync(configPath)) { + return { + success: false, + error: `${CONFIG_DIR}/${WORKSPACE_CONFIG_FILE} not found in ${workspacePath}`, + }; + } + + 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), + }; + } +} + /** * Remove enabledSkills entries whose plugin name matches the removed plugin entry. * Exported for reuse by user-workspace.ts. diff --git a/tests/e2e/plugin-skills.test.ts b/tests/e2e/plugin-skills.test.ts index 265a07d..cb72493 100644 --- a/tests/e2e/plugin-skills.test.ts +++ b/tests/e2e/plugin-skills.test.ts @@ -13,7 +13,9 @@ import { removeDisabledSkill, setPluginSkillsMode, addEnabledSkill, + upsertGitHubPluginSourceAllowlist, } from '../../src/core/workspace-modify.js'; +import { resetFetchCache } from '../../src/core/plugin.js'; import type { WorkspaceConfig } from '../../src/models/workspace-config.js'; describe('plugin skills e2e', () => { @@ -186,4 +188,65 @@ description: Test skill expect(pluginEntry.skills).toEqual(['skill-a', 'skill-b']); } }); + + it('promoted GitHub skill sources stay coherent for listing and sync', async () => { + const originalHome = process.env.HOME; + const fakeHome = join(tmpDir, 'home'); + const cacheDir = join( + fakeHome, + '.allagents/plugins/marketplaces/NousResearch-hermes-agent@main/skills/research', + ); + + process.env.HOME = fakeHome; + resetFetchCache(); + + try { + await mkdir(join(cacheDir, 'llm-wiki'), { recursive: true }); + await mkdir(join(cacheDir, 'blogwatcher'), { recursive: true }); + await writeFile(join(cacheDir, 'llm-wiki/SKILL.md'), `--- +name: llm-wiki +description: Wiki skill +--- +# llm-wiki +`); + await writeFile(join(cacheDir, 'blogwatcher/SKILL.md'), `--- +name: blogwatcher +description: Blog watcher +--- +# blogwatcher +`); + + const config: WorkspaceConfig = { + repositories: [], + plugins: [{ + source: 'https://github.com/NousResearch/hermes-agent/tree/main/skills/research/llm-wiki', + skills: ['llm-wiki'], + }], + clients: ['claude'], + version: 2, + }; + await writeFile(join(tmpDir, '.allagents/workspace.yaml'), dump(config), 'utf-8'); + + const updateResult = await upsertGitHubPluginSourceAllowlist( + 'https://github.com/NousResearch/hermes-agent/tree/main/skills/research/blogwatcher', + ['llm-wiki', 'blogwatcher'], + tmpDir, + ); + expect(updateResult.success).toBe(true); + + const skills = await getAllSkillsFromPlugins(tmpDir); + expect(skills.filter((skill) => !skill.disabled).map((skill) => skill.name).sort()).toEqual([ + 'blogwatcher', + 'llm-wiki', + ]); + expect(skills.every((skill) => skill.pluginSource === 'https://github.com/NousResearch/hermes-agent/tree/main/skills/research')).toBe(true); + + await syncWorkspace(tmpDir); + expect(existsSync(join(tmpDir, '.claude/skills/llm-wiki'))).toBe(true); + expect(existsSync(join(tmpDir, '.claude/skills/blogwatcher'))).toBe(true); + } finally { + resetFetchCache(); + process.env.HOME = originalHome; + } + }); }); diff --git a/tests/unit/core/github-skill-source-promotion.test.ts b/tests/unit/core/github-skill-source-promotion.test.ts new file mode 100644 index 0000000..1449819 --- /dev/null +++ b/tests/unit/core/github-skill-source-promotion.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from 'bun:test'; +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { dump, load } from 'js-yaml'; +import { + canonicalizeGitHubPluginSource, + upsertGitHubPluginSourceAllowlist, +} from '../../../src/core/workspace-modify.js'; +import type { WorkspaceConfig } from '../../../src/models/workspace-config.js'; + +describe('canonicalizeGitHubPluginSource', () => { + it('promotes sibling standalone skills to their shared subtree', () => { + const current = + 'https://github.com/NousResearch/hermes-agent/tree/main/skills/research/llm-wiki'; + const next = + 'https://github.com/NousResearch/hermes-agent/tree/main/skills/research/blogwatcher'; + + expect(canonicalizeGitHubPluginSource(current, next)).toBe( + 'https://github.com/NousResearch/hermes-agent/tree/main/skills/research', + ); + }); + + it('promotes different subtrees to the next shared container', () => { + const current = + 'https://github.com/NousResearch/hermes-agent/tree/main/skills/research'; + const next = + 'https://github.com/NousResearch/hermes-agent/tree/main/skills/productivity/task-planner'; + + expect(canonicalizeGitHubPluginSource(current, next)).toBe( + 'https://github.com/NousResearch/hermes-agent/tree/main/skills', + ); + }); +}); + +describe('upsertGitHubPluginSourceAllowlist', () => { + it('rewrites the stored source instead of creating a duplicate entry', async () => { + const tmpDir = await mkdtemp(join(tmpdir(), 'allagents-skill-source-')); + + try { + await mkdir(join(tmpDir, '.allagents'), { recursive: true }); + const config: WorkspaceConfig = { + repositories: [], + plugins: [{ + source: 'https://github.com/NousResearch/hermes-agent/tree/main/skills/research/llm-wiki', + skills: ['llm-wiki'], + }], + clients: ['claude'], + version: 2, + }; + await writeFile(join(tmpDir, '.allagents/workspace.yaml'), dump(config), 'utf-8'); + + const result = await upsertGitHubPluginSourceAllowlist( + 'https://github.com/NousResearch/hermes-agent/tree/main/skills/research/blogwatcher', + ['llm-wiki', 'blogwatcher'], + tmpDir, + ); + + expect(result.success).toBe(true); + expect(result.normalizedPlugin).toBe( + 'https://github.com/NousResearch/hermes-agent/tree/main/skills/research', + ); + + const content = await readFile(join(tmpDir, '.allagents/workspace.yaml'), 'utf-8'); + const updated = load(content) as WorkspaceConfig; + expect(updated.plugins).toHaveLength(1); + + const plugin = updated.plugins[0]; + expect(typeof plugin).toBe('object'); + if (typeof plugin !== 'string') { + expect(plugin.source).toBe( + 'https://github.com/NousResearch/hermes-agent/tree/main/skills/research', + ); + expect(plugin.skills).toEqual(['llm-wiki', 'blogwatcher']); + } + } finally { + await rm(tmpDir, { recursive: true, force: true }); + } + }); +}); diff --git a/tests/unit/core/skill-resolution.test.ts b/tests/unit/core/skill-resolution.test.ts index 0e40c8a..f627d83 100644 --- a/tests/unit/core/skill-resolution.test.ts +++ b/tests/unit/core/skill-resolution.test.ts @@ -95,6 +95,30 @@ description: Real Skill expect(skills).toHaveLength(1); expect(skills[0]?.folderName).toBe('real-skill'); }); + + it('should collect nested skills from a promoted container path', async () => { + const pluginDir = join(testDir, 'container-plugin'); + await mkdir(join(pluginDir, 'research', 'llm-wiki'), { recursive: true }); + await mkdir(join(pluginDir, 'productivity', 'nano-pdf'), { recursive: true }); + await writeFile( + join(pluginDir, 'research', 'llm-wiki', 'SKILL.md'), + `--- +name: llm-wiki +description: wiki +---`, + ); + await writeFile( + join(pluginDir, 'productivity', 'nano-pdf', 'SKILL.md'), + `--- +name: nano-pdf +description: pdf +---`, + ); + + const skills = await collectPluginSkills(pluginDir, 'test-source'); + + expect(skills.map((s) => s.folderName).sort()).toEqual(['llm-wiki', 'nano-pdf']); + }); }); describe('skill name resolution in sync', () => { diff --git a/tests/unit/core/skills.test.ts b/tests/unit/core/skills.test.ts index 9c08375..87e06d9 100644 --- a/tests/unit/core/skills.test.ts +++ b/tests/unit/core/skills.test.ts @@ -137,6 +137,26 @@ describe('getAllSkillsFromPlugins', () => { expect(skills[0]!.name).toBe('sub-skill'); }); + it('discovers nested skills from a promoted container path', async () => { + const containerPlugin = join(tmpDir, 'container-plugin'); + await mkdir(join(containerPlugin, 'research', 'llm-wiki'), { recursive: true }); + await mkdir(join(containerPlugin, 'productivity', 'nano-pdf'), { recursive: true }); + await writeFile(join(containerPlugin, 'research', 'llm-wiki', 'SKILL.md'), '# llm-wiki'); + await writeFile(join(containerPlugin, 'productivity', 'nano-pdf', 'SKILL.md'), '# nano-pdf'); + + const config = { + repositories: [], + plugins: [{ source: containerPlugin, skills: ['llm-wiki', 'nano-pdf'] }], + clients: ['claude'], + version: 2, + }; + await writeFile(join(tmpDir, '.allagents/workspace.yaml'), dump(config)); + + const skills = await getAllSkillsFromPlugins(tmpDir); + expect(skills.map((s) => s.name).sort()).toEqual(['llm-wiki', 'nano-pdf']); + expect(skills.every((s) => s.disabled === false)).toBe(true); + }); + it('skips GitHub URL entries whose subpath no longer exists in cache', async () => { const originalHome = process.env.HOME; process.env.HOME = tmpDir;