Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit 4b8954b

Browse files
authored
fix(skills): handle standalone GitHub skill URLs (#409)
1 parent 34e1dda commit 4b8954b

3 files changed

Lines changed: 94 additions & 11 deletions

File tree

src/cli/commands/plugin-skills.ts

Lines changed: 20 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,7 @@ async function recordSourceProvenance(opts: {
113113
});
114114
}
115115

116-
function resolveFetchedSourcePath(source: string, cachePath: string): string {
116+
export function resolveFetchedSourcePath(source: string, cachePath: string): string {
117117
if (!isGitHubUrl(source)) return cachePath;
118118
const parsed = parseGitHubUrl(source);
119119
return parsed?.subpath ? join(cachePath, parsed.subpath) : cachePath;
@@ -458,21 +458,32 @@ type InstallSkillResult =
458458
*
459459
* In both cases, set the plugin to allowlist mode with only the requested skill.
460460
*/
461-
async function installSkillFromSource(opts: {
461+
type InstallSkillFromSourceDeps = {
462+
fetchPlugin?: typeof fetchPlugin;
463+
parseMarketplaceManifest?: typeof parseMarketplaceManifest;
464+
installSkillViaMarketplace?: typeof installSkillViaMarketplace;
465+
installSkillDirect?: typeof installSkillDirect;
466+
};
467+
468+
export async function installSkillFromSource(opts: {
462469
skill: string;
463470
from: string;
464471
isUser: boolean;
465472
workspacePath: string;
466-
}): Promise<InstallSkillResult> {
473+
}, deps: InstallSkillFromSourceDeps = {}): Promise<InstallSkillResult> {
467474
const { skill, from, isUser, workspacePath } = opts;
475+
const fetchPluginFn = deps.fetchPlugin ?? fetchPlugin;
476+
const parseMarketplaceManifestFn = deps.parseMarketplaceManifest ?? parseMarketplaceManifest;
477+
const installSkillViaMarketplaceFn = deps.installSkillViaMarketplace ?? installSkillViaMarketplace;
478+
const installSkillDirectFn = deps.installSkillDirect ?? installSkillDirect;
468479

469480
if (!isJsonMode()) {
470481
console.log(`Installing skill '${skill}' from ${from}...`);
471482
}
472483

473484
// Fetch the source to a local cache so we can inspect it
474485
const parsed = isGitHubUrl(from) ? parseGitHubUrl(from) : null;
475-
const fetchResult = await fetchPlugin(from, {
486+
const fetchResult = await fetchPluginFn(from, {
476487
...(parsed?.branch && { branch: parsed.branch }),
477488
});
478489
if (!fetchResult.success) {
@@ -482,14 +493,14 @@ async function installSkillFromSource(opts: {
482493
const sourcePath = resolveFetchedSourcePath(from, fetchResult.cachePath);
483494

484495
// Check if the source is a marketplace
485-
const manifestResult = await parseMarketplaceManifest(sourcePath);
496+
const manifestResult = await parseMarketplaceManifestFn(sourcePath);
486497

487498
if (manifestResult.success) {
488-
return installSkillViaMarketplace({ skill, from, isUser, workspacePath });
499+
return installSkillViaMarketplaceFn({ skill, from, isUser, workspacePath });
489500
}
490501

491502
// Not a marketplace — install as a direct plugin
492-
return installSkillDirect({ skill, from, isUser, workspacePath, cachePath: sourcePath });
503+
return installSkillDirectFn({ skill, from, isUser, workspacePath, sourcePath });
493504
}
494505

495506
/**
@@ -593,10 +604,9 @@ async function installSkillDirect(opts: {
593604
from: string;
594605
isUser: boolean;
595606
workspacePath: string;
596-
cachePath: string;
607+
sourcePath: string;
597608
}): Promise<InstallSkillResult> {
598-
const { skill, from, isUser, workspacePath, cachePath } = opts;
599-
const sourcePath = resolveFetchedSourcePath(from, cachePath);
609+
const { skill, from, isUser, workspacePath, sourcePath } = opts;
600610

601611
// Verify the skill exists in the cached plugin before installing
602612
const availableSkills = await discoverSkillNames(sourcePath);
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { describe, expect, it, mock } from 'bun:test';
2+
import { installSkillFromSource } from '../../../src/cli/commands/plugin-skills.js';
3+
4+
describe('installSkillFromSource', () => {
5+
it('passes the resolved GitHub skill subpath to direct install without resolving it twice', async () => {
6+
const source = 'https://github.com/NousResearch/hermes-agent/tree/main/skills/research/llm-wiki';
7+
const installSkillDirectMock = mock(async (_opts: {
8+
skill: string;
9+
from: string;
10+
isUser: boolean;
11+
workspacePath: string;
12+
sourcePath: string;
13+
}) => ({
14+
success: true as const,
15+
pluginName: 'llm-wiki',
16+
syncResult: { copied: 1, failed: 0 },
17+
}));
18+
19+
const result = await installSkillFromSource(
20+
{
21+
skill: 'llm-wiki',
22+
from: source,
23+
isUser: false,
24+
workspacePath: '/tmp/workspace',
25+
},
26+
{
27+
fetchPlugin: async () => ({
28+
success: true as const,
29+
action: 'fetched' as const,
30+
cachePath: '/tmp/fetched-hermes-agent',
31+
}),
32+
parseMarketplaceManifest: async () => ({
33+
success: false as const,
34+
error: 'not a marketplace',
35+
}),
36+
installSkillViaMarketplace: async () => ({
37+
success: false as const,
38+
error: 'unexpected marketplace path',
39+
}),
40+
installSkillDirect: installSkillDirectMock,
41+
},
42+
);
43+
44+
expect(result.success).toBe(true);
45+
expect(installSkillDirectMock).toHaveBeenCalledTimes(1);
46+
expect(installSkillDirectMock).toHaveBeenCalledWith({
47+
skill: 'llm-wiki',
48+
from: source,
49+
isUser: false,
50+
workspacePath: '/tmp/workspace',
51+
sourcePath: '/tmp/fetched-hermes-agent/skills/research/llm-wiki',
52+
});
53+
});
54+
});

tests/unit/cli/skills-add-url-detection.test.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,11 @@ import { describe, it, expect } from 'bun:test';
22
import { mkdtemp, writeFile, rm } from 'node:fs/promises';
33
import { join } from 'node:path';
44
import { tmpdir } from 'node:os';
5-
import { resolveSkillFromUrl, resolveSkillNameFromRepo } from '../../../src/cli/commands/plugin-skills.js';
5+
import {
6+
resolveFetchedSourcePath,
7+
resolveSkillFromUrl,
8+
resolveSkillNameFromRepo,
9+
} from '../../../src/cli/commands/plugin-skills.js';
610
import type { FetchResult } from '../../../src/core/plugin.js';
711

812
describe('resolveSkillFromUrl', () => {
@@ -99,3 +103,18 @@ describe('resolveSkillNameFromRepo', () => {
99103
expect(result).toBe('fallback-name');
100104
});
101105
});
106+
107+
describe('resolveFetchedSourcePath', () => {
108+
it('appends the GitHub subpath to the fetched repo root once', () => {
109+
expect(
110+
resolveFetchedSourcePath(
111+
'https://github.com/NousResearch/hermes-agent/tree/main/skills/research/llm-wiki',
112+
'/tmp/hermes-agent-cache',
113+
),
114+
).toBe('/tmp/hermes-agent-cache/skills/research/llm-wiki');
115+
});
116+
117+
it('leaves non-GitHub sources unchanged', () => {
118+
expect(resolveFetchedSourcePath('/tmp/local-skill', '/tmp/local-skill')).toBe('/tmp/local-skill');
119+
});
120+
});

0 commit comments

Comments
 (0)