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
27 changes: 19 additions & 8 deletions src/add.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1302,14 +1302,19 @@ function getSkillRepoPaths(resolved: ResolvedSkills, skills: Skill[]): Record<st
return skillFiles;
}

/** The `source` a project lock entry records for `parsed`. */
export function getProjectLockSource(parsed: ParsedSource): string {
return getLockSource(parsed.url, getOwnerRepo(parsed)) || parsed.url;
}

function projectLockEntry(
parsed: ParsedSource,
skillPath: string | undefined,
computedHash: string
): LocalSkillLockEntry {
const sourceUrl = getProjectLockSourceUrl(parsed.type, parsed.url);
return {
source: getLockSource(parsed.url, getOwnerRepo(parsed)) || parsed.url,
source: getProjectLockSource(parsed),
...(sourceUrl && { sourceUrl }),
ref: parsed.ref,
sourceType: parsed.type,
Expand All @@ -1325,25 +1330,31 @@ interface SourceInstallResult {
}

/**
* Install skills from `source` at project scope without prompting or exiting:
* keep `skills` (all when empty), install into `agents`, record the project lock.
* Install skills from `parsed` at project scope without prompting or exiting:
* keep `skills` (all when empty), let `select` drop any, install into
* `agents`, and record the project lock (with `via` when given).
*/
export async function installFromSource(
source: string,
options: { skills: string[]; agents: AgentType[] }
parsed: ParsedSource,
options: {
skills: string[];
agents: AgentType[];
via?: string;
select?: (skills: Skill[]) => Promise<Skill[]>;
}
): Promise<SourceInstallResult> {
const parsed = parseSource(source);
const spinner = p.spinner();
let resolved: ResolvedSkills | null = null;
try {
resolved = await resolveSkills(parsed, { includeInternal: options.skills.length > 0 }, spinner);
const selected =
let selected =
options.skills.length > 0 ? filterSkills(resolved.skills, options.skills) : resolved.skills;
if (selected.length === 0) {
spinner.stop(pc.red('No matching skills found'));
return { installed: [], failed: [], error: 'No matching skills found' };
}
spinner.stop(`Found ${pc.green(selected.length)} skill${selected.length > 1 ? 's' : ''}`);
if (options.select) selected = await options.select(selected);

const results = await installToTargets(
resolved,
Expand All @@ -1356,7 +1367,7 @@ export async function installFromSource(
for (const skill of selected) {
if (!installed.has(getSkillDisplayName(skill))) continue;
const entry = projectLockEntry(parsed, repoPaths[skill.name], await sourceSkillHash(skill));
await addSkillToLocalLock(skill.name, entry);
await addSkillToLocalLock(skill.name, { ...entry, ...(options.via && { via: options.via }) });
}
return {
installed: [...installed],
Expand Down
1 change: 1 addition & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,7 @@ ${BOLD}Experimental Sync Options:${RESET}
--copy Copy skills instead of linking them to node_modules
--dry-run Show what would change without changing anything
--no-cleanup Keep skills whose package no longer ships them
--no-remote Skip git sources listed in package.json skills fields
--include <patterns> Only sync matching packages (<pkg>) or skills (<pkg>#<skill>)
--exclude <patterns> Skip matching packages (<pkg>) or skills (<pkg>#<skill>)

Expand Down
6 changes: 5 additions & 1 deletion src/install.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { installFromSource, runAdd } from './add.ts';
import { runSync, parseSyncOptions } from './sync.ts';
import { getUniversalAgents } from './agents.ts';
import { buildLocalUpdateSource } from './update-source.ts';
import { parseSource } from './source-parser.ts';

/**
* Install all skills from the local skills-lock.json.
Expand Down Expand Up @@ -74,7 +75,10 @@ export async function runInstallFromLock(args: string[]): Promise<void> {
await runAdd([source], { skill: skills, agent: universalAgentNames, yes: true });
continue;
}
const result = await installFromSource(source, { skills, agents: universalAgentNames });
const result = await installFromSource(parseSource(source), {
skills,
agents: universalAgentNames,
});
if (result.error) {
p.log.error(`Failed to install from ${pc.cyan(source)}: ${result.error}`);
process.exitCode = 1;
Expand Down
32 changes: 27 additions & 5 deletions src/skills-field.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import { parseSource } from './source-parser.ts';
import type { ParsedSource } from './types.ts';

/**
* The `skills` field of package.json: skills a package wants installed
* without shipping their files. Grammar: https://github.com/antfu/skills-npm/blob/main/SPEC.md
Expand All @@ -11,12 +14,22 @@ interface NpmSkillsRequest {
skills: string[];
}

/** A git source to install skills from. */
export interface RemoteSkillsRequest {
parsed: ParsedSource;
/** Skill names to keep; empty means all. */
skills: string[];
}

interface ParsedSkillsField {
npm: NpmSkillsRequest[];
remote: RemoteSkillsRequest[];
errors: string[];
}

const NPM_PREFIX = 'npm:';
// the SPEC allows git-hosted sources only, not local paths or plain URLs
const REMOTE_SOURCE_TYPES = new Set<ParsedSource['type']>(['github', 'gitlab', 'git']);

function isSkillsFieldEntry(value: unknown): value is SkillsFieldEntry {
if (typeof value === 'string') return true;
Expand All @@ -33,18 +46,27 @@ function isSkillsFieldEntry(value: unknown): value is SkillsFieldEntry {

/** Parse the entries of `declarer`'s `skills` field. Problems are returned, not thrown. */
export function parseSkillsField(entries: unknown[], declarer: string): ParsedSkillsField {
const parsed: ParsedSkillsField = { npm: [], errors: [] };
const parsed: ParsedSkillsField = { npm: [], remote: [], errors: [] };

for (const raw of entries) {
if (!isSkillsFieldEntry(raw)) {
parsed.errors.push(`${declarer}: invalid "skills" entry ${JSON.stringify(raw)}`);
continue;
}
const { source, skills = [], ref } = typeof raw === 'string' ? { source: raw } : raw;
// remote (git) entries are not synced yet
if (!source.startsWith(NPM_PREFIX)) continue;

if (ref !== undefined) {
if (!source.startsWith(NPM_PREFIX)) {
const remote = parseSource(source);
if (!REMOTE_SOURCE_TYPES.has(remote.type)) {
parsed.errors.push(`${declarer}: "${source}" is not a git source`);
} else if (ref !== undefined && remote.ref !== undefined) {
parsed.errors.push(`${declarer}: "${source}" already has a ref; remove "ref"`);
} else {
parsed.remote.push({
parsed: { ...remote, ref: ref ?? remote.ref },
skills: remote.skillFilter ? [...skills, remote.skillFilter] : skills,
});
}
} else if (ref !== undefined) {
parsed.errors.push(`${declarer}: "ref" cannot be used with "${source}"`);
} else {
parsed.npm.push({ package: source.slice(NPM_PREFIX.length), skills });
Expand Down
Loading
Loading