diff --git a/src/cli.ts b/src/cli.ts index 2b94b06..a73f9a2 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -11,6 +11,7 @@ import { getPreferredAgents, savePreferredAgents } from './config.ts'; import { loadCredentials, saveCredentials, clearCredentials, maskToken } from './credentials.ts'; import { extractDependencies, isValidDependencySpec, parseDependency, dependencyToSlug, parseSkillMd } from './parser.ts'; import { parseSource, type ParsedSource } from './source-parser.ts'; +import { getRequestedSkill } from './install-request.ts'; import { discoverSkills, filterSkills, type DiscoveredSkill } from './discover.ts'; import { getCollectionInstallRefs } from './collection.ts'; import { cloneRepo, cleanupTempDir, GitCloneError } from './git.ts'; @@ -704,6 +705,7 @@ async function runInstallJson(skillName: string, options: InstallOptions): Promi const spinner = createSpinner(true); const { skills: discoveredSkills, parsed: sourceParsed, tempDir } = await resolveSkills(skillName, spinner, options); + const requestedSkill = getRequestedSkill(skillName, sourceParsed, options.skill); const cleanup = async () => { if (tempDir) await cleanupTempDir(tempDir).catch(() => {}); @@ -711,15 +713,15 @@ async function runInstallJson(skillName: string, options: InstallOptions): Promi try { if (discoveredSkills.length === 0) { - if (options.skill) { + if (requestedSkill) { printJson({ ok: false, error: { code: 'SKILL_NOT_FOUND', - message: `Skill "${options.skill}" not found in source`, + message: `Skill "${requestedSkill.name}" not found in source`, details: { - source: skillName, - requestedSkill: options.skill, + source: requestedSkill.source, + requestedSkill: requestedSkill.name, }, }, }); @@ -1096,6 +1098,7 @@ async function runInstall(args: string[]): Promise { // Step 1: Resolve skills (clone or API) const { skills: discoveredSkills, parsed: sourceParsed, tempDir } = await resolveSkills(skillName, spinner, options); + const requestedSkill = getRequestedSkill(skillName, sourceParsed, options.skill); // Ensure tempDir is always cleaned up, even on cancel/error/process.exit const cleanup = async () => { @@ -1105,8 +1108,8 @@ async function runInstall(args: string[]): Promise { try { if (discoveredSkills.length === 0) { - if (options.skill) { - p.log.error(`Skill "${options.skill}" not found in ${pc.cyan(skillName)}`); + if (requestedSkill) { + p.log.error(`Skill "${requestedSkill.name}" not found in ${pc.cyan(requestedSkill.source)}`); process.exitCode = 1; return; } diff --git a/src/install-request.test.ts b/src/install-request.test.ts new file mode 100644 index 0000000..70b1d5c --- /dev/null +++ b/src/install-request.test.ts @@ -0,0 +1,29 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { getRequestedSkill } from './install-request.ts'; +import { parseSource } from './source-parser.ts'; + +test('getRequestedSkill resolves source@name as an explicit skill request', () => { + const input = 'gh:openclaw/openclaw@tavily'; + + assert.deepEqual(getRequestedSkill(input, parseSource(input)), { + name: 'tavily', + source: 'gh:openclaw/openclaw', + }); +}); + +test('getRequestedSkill preserves the base source for --skill requests', () => { + const input = 'gh:openclaw/openclaw'; + + assert.deepEqual(getRequestedSkill(input, parseSource(input), 'tavily'), { + name: 'tavily', + source: 'gh:openclaw/openclaw', + }); +}); + +test('getRequestedSkill returns null when no skill was explicitly requested', () => { + const input = 'gh:openclaw/openclaw'; + + assert.equal(getRequestedSkill(input, parseSource(input)), null); +}); diff --git a/src/install-request.ts b/src/install-request.ts new file mode 100644 index 0000000..86a022b --- /dev/null +++ b/src/install-request.ts @@ -0,0 +1,33 @@ +import type { ParsedSource } from './source-parser.ts'; + +export interface RequestedSkill { + name: string; + source: string; +} + +export function getRequestedSkill( + sourceInput: string, + sourceParsed: Pick, + explicitSkill?: string, +): RequestedSkill | null { + if (explicitSkill) { + return { + name: explicitSkill, + source: sourceInput, + }; + } + + if (!sourceParsed.skillFilter) { + return null; + } + + const selectorSuffix = `@${sourceParsed.skillFilter}`; + const source = sourceInput.endsWith(selectorSuffix) + ? sourceInput.slice(0, -selectorSuffix.length) + : sourceInput; + + return { + name: sourceParsed.skillFilter, + source, + }; +}