/** * skillRefs → ~/.agenticros/skills-cache resolver (marketplace auto-fetch). * * Declarative refs like `owner/skill`, `owner/skill@main`, or * `@agenticros/foo@^1.0.0` are resolved via the skills marketplace * install API (or directly from npm), cached under ~/.agenticros/skills-cache, * and returned as absolute skillPaths for capability reading / OpenClaw loading. * * Prefer npm when the install descriptor includes `npmPackage` (or the ref * is already a scoped npm name). Fall back to git clone + build. * * Never auto-upgrades: if the cache dir for a pin already exists, reuse it * (optional pull when AGENTICROS_SKILLS_CACHE_PULL=1 for git caches). */ import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync, } from "node:fs"; import { tmpdir, homedir } from "node:os"; import { basename, join } from "node:path"; import { execFileSync } from "node:child_process"; import type { AgenticROSConfig } from "./config.js"; export const DEFAULT_SKILLS_API = "https://skills.agenticros.com/api"; export const DEFAULT_SKILLS_CACHE_DIR = join(homedir(), ".agenticros", "skills-cache"); export interface ParsedSkillRef { kind: "marketplace" | "npm"; /** owner/skill (lowercase) — marketplace refs only */ marketplaceRef?: string; owner?: string; skill?: string; /** git ref / branch pin; default main — marketplace refs */ gitRef?: string; /** Scoped npm package name — npm refs */ npmPackage?: string; /** Semver range or exact version pin — npm refs (default: latest resolved) */ npmVersion?: string; } export interface InstallDescriptor { slug: string; marketplaceRef?: string; skillId: string; packageName: string; githubUrl: string; ref: string; buildCmd: string; /** When set, prefer npm pack over git clone. */ npmPackage?: string; /** Pinned npm version when known. */ npmVersion?: string; } export interface ResolveSkillRefsOptions { apiBase?: string; cacheDir?: string; /** When true, git pull --ff-only if cache already exists. */ pullIfPresent?: boolean; /** Skip network / clone (return only paths that already exist in cache). */ offline?: boolean; onLog?: (msg: string) => void; } export interface ResolveSkillRefsResult { /** Absolute directories ready to merge into skillPaths. */ paths: string[]; errors: Array<{ ref: string; error: string }>; } /** * Parse `owner/skill`, `owner/skill@branch`, or `@scope/name[@semver]`. */ export function parseSkillRef(raw: string): ParsedSkillRef | null { const trimmed = raw.trim(); if (!trimmed) return null; if (trimmed.startsWith("@")) { // @scope/name or @scope/name@1.2.3 / @scope/name@^1.0.0 const withoutAt = trimmed.slice(1); const slash = withoutAt.indexOf("/"); if (slash <= 0) return null; const scope = withoutAt.slice(0, slash); const rest = withoutAt.slice(slash + 1); if (!scope || !rest) return null; // Version pin is the last @ after the package name segment. const at = rest.lastIndexOf("@"); let pkgName = rest; let npmVersion: string | undefined; if (at > 0) { pkgName = rest.slice(0, at); npmVersion = rest.slice(at + 1).trim() || undefined; } if (!pkgName || pkgName.includes("/")) return null; return { kind: "npm", npmPackage: `@${scope}/${pkgName}`, npmVersion, }; } const at = trimmed.lastIndexOf("@"); let body = trimmed; let gitRef = "main"; if (at > 0) { body = trimmed.slice(0, at); gitRef = trimmed.slice(at + 1).trim() || "main"; } const parts = body.split("/").filter(Boolean); if (parts.length < 2) return null; const owner = parts[0]!.toLowerCase(); const skill = parts.slice(1).join("/").toLowerCase(); if (!owner || !skill) return null; return { kind: "marketplace", marketplaceRef: `${owner}/${skill}`, owner, skill, gitRef, }; } export function skillsApiBase(override?: string): string { return (override || process.env.AGENTICROS_SKILLS_API || DEFAULT_SKILLS_API).replace( /\/+$/, "", ); } export function skillsCacheDir(override?: string): string { return override || process.env.AGENTICROS_SKILLS_CACHE || DEFAULT_SKILLS_CACHE_DIR; } function skillApiPath(apiBase: string, ref: string): string { const [owner, ...rest] = ref.split("/"); const skill = rest.join("/"); return `${apiBase}/skills/${encodeURIComponent(owner!)}/${encodeURIComponent(skill)}`; } export async function fetchInstallDescriptor( marketplaceRef: string, apiBase?: string, ): Promise { const base = skillsApiBase(apiBase); const url = `${skillApiPath(base, marketplaceRef)}/install`; const res = await fetch(url, { headers: { Accept: "application/json" } }); if (!res.ok) { const body = await res.text().catch(() => ""); throw new Error(`Marketplace install ${res.status}: ${body.slice(0, 200)}`); } const desc = (await res.json()) as InstallDescriptor; if (!desc.githubUrl && !desc.npmPackage && !desc.packageName) { throw new Error( `Install descriptor for ${marketplaceRef} has neither githubUrl nor npmPackage`, ); } return desc; } function cachePathForGit( cacheDir: string, owner: string, skill: string, gitRef: string, ): string { const safeRef = gitRef.replace(/[^a-zA-Z0-9._-]+/g, "_"); return join(cacheDir, owner, skill, safeRef); } function cachePathForNpm(cacheDir: string, npmPackage: string, version: string): string { const safeName = npmPackage.replace(/^@/, "").replace(/\//g, "__"); const safeVer = version.replace(/[^a-zA-Z0-9._+-]+/g, "_"); return join(cacheDir, "npm", safeName, safeVer); } function hasBuiltEntry(dir: string): boolean { try { const pkg = JSON.parse(readFileSync(join(dir, "package.json"), "utf8")) as { main?: string; }; const main = pkg.main ?? "dist/index.js"; return existsSync(join(dir, main)); } catch { return false; } } /** * Whether `dir`'s runtime dependencies are actually installed. Distinct from * `hasBuiltEntry`: a package published with a prebuilt `dist/` (the normal * case for npm-installed skills) satisfies `hasBuiltEntry` immediately, which * used to make `buildInPlace` skip `npm/pnpm install` altogether — silently * leaving every declared dependency (e.g. `@sinclair/typebox`) unresolved at * runtime. "Built" and "has its deps installed" are independent questions; * check both. */ function hasInstalledDeps(dir: string): boolean { return existsSync(join(dir, "node_modules")); } /** Whether `dir` still needs `buildInPlace` (missing build output or deps). */ function needsSetup(dir: string): boolean { return !hasBuiltEntry(dir) || !hasInstalledDeps(dir); } function run(cmd: string, args: string[], cwd: string, log: (m: string) => void): void { log(`$ ${cmd} ${args.join(" ")} (in ${cwd})`); execFileSync(cmd, args, { cwd, stdio: "inherit" }); } function runCapture(cmd: string, args: string[], cwd: string): string { return execFileSync(cmd, args, { cwd, encoding: "utf8" }).trim(); } function hasBin(name: string): boolean { try { execFileSync(name, ["--version"], { stdio: "ignore" }); return true; } catch { return false; } } function safeNpmVersionPin(raw: string | undefined): string { if (!raw || !raw.trim()) return "latest"; return raw.trim(); } /** * Resolve the exact version npm would install for a package@range. */ function resolveNpmVersion(npmPackage: string, versionRange: string, log: (m: string) => void): string { const spec = versionRange === "latest" ? npmPackage : `${npmPackage}@${versionRange}`; log(`Resolving npm version for ${spec}`); const out = runCapture("npm", ["view", spec, "version"], process.cwd()); // npm view can return a single version or a JSON array for ranges — take last line. const lines = out.split("\n").map((l) => l.trim()).filter(Boolean); const last = lines[lines.length - 1] ?? ""; // Strip quotes if JSON-ish const cleaned = last.replace(/^"|"$/g, ""); if (!cleaned) throw new Error(`Could not resolve npm version for ${spec}`); return cleaned; } /** * Pack an npm package into the skills cache and return the absolute dir. */ export async function ensureNpmPackageCached( npmPackage: string, versionRange: string | undefined, opts: ResolveSkillRefsOptions = {}, ): Promise { const log = opts.onLog ?? (() => undefined); const cacheRoot = skillsCacheDir(opts.cacheDir); const pin = safeNpmVersionPin(versionRange); // Fast path: exact version already cached if (pin !== "latest" && !pin.startsWith("^") && !pin.startsWith("~") && !pin.includes("*")) { const exactDir = cachePathForNpm(cacheRoot, npmPackage, pin); if (existsSync(join(exactDir, "package.json"))) { if (needsSetup(exactDir) && !opts.offline) { await buildInPlace(exactDir, log); } return exactDir; } } if (opts.offline) { // Best-effort: look for any cached version of this package const safeName = npmPackage.replace(/^@/, "").replace(/\//g, "__"); const pkgRoot = join(cacheRoot, "npm", safeName); if (existsSync(pkgRoot)) { const versions = readdirSync(pkgRoot).filter((v) => existsSync(join(pkgRoot, v, "package.json")), ); if (versions.length > 0) { versions.sort(); return join(pkgRoot, versions[versions.length - 1]!); } } throw new Error(`npm package ${npmPackage}@${pin} not in cache (offline)`); } const resolvedVersion = resolveNpmVersion(npmPackage, pin, log); const dir = cachePathForNpm(cacheRoot, npmPackage, resolvedVersion); if (existsSync(join(dir, "package.json"))) { if (needsSetup(dir)) { await buildInPlace(dir, log); } return dir; } mkdirSync(join(cacheRoot, "npm", npmPackage.replace(/^@/, "").replace(/\//g, "__")), { recursive: true, }); const tmp = mkdtempSync(join(tmpdir(), "agenticros-skill-npm-")); try { const spec = `${npmPackage}@${resolvedVersion}`; log(`npm pack ${spec} → ${tmp}`); const tgzName = runCapture("npm", ["pack", spec, "--pack-destination", tmp], tmp); const tgzPath = join(tmp, tgzName); const extractDir = join(tmp, "extract"); mkdirSync(extractDir, { recursive: true }); run("tar", ["-xzf", tgzPath, "-C", extractDir], tmp, log); const packedRoot = join(extractDir, "package"); if (!existsSync(join(packedRoot, "package.json"))) { throw new Error(`npm pack for ${spec} did not produce package/package.json`); } mkdirSync(join(dir, ".."), { recursive: true }); if (existsSync(dir)) { rmSync(dir, { recursive: true, force: true }); } renameSync(packedRoot, dir); writeFileSync( join(dir, ".agenticros-skill-ref.json"), JSON.stringify( { kind: "npm", npmPackage, npmVersion: resolvedVersion, cachedAt: new Date().toISOString(), }, null, 2, ), ); } finally { try { rmSync(tmp, { recursive: true, force: true }); } catch { /* ignore */ } } if (needsSetup(dir)) { await buildInPlace(dir, log); } return dir; } /** * Ensure one skillRef is present under the cache. Returns absolute path or throws. */ export async function ensureSkillRefCached( rawRef: string, opts: ResolveSkillRefsOptions = {}, ): Promise { const parsed = parseSkillRef(rawRef); if (!parsed) { throw new Error( `Invalid skillRef "${rawRef}" (expected owner/skill, owner/skill@ref, or @scope/name[@semver])`, ); } const log = opts.onLog ?? (() => undefined); if (parsed.kind === "npm") { return ensureNpmPackageCached(parsed.npmPackage!, parsed.npmVersion, opts); } const cacheRoot = skillsCacheDir(opts.cacheDir); const dir = cachePathForGit(cacheRoot, parsed.owner!, parsed.skill!, parsed.gitRef!); if (existsSync(join(dir, "package.json"))) { if (opts.pullIfPresent || process.env.AGENTICROS_SKILLS_CACHE_PULL === "1") { try { run("git", ["pull", "--ff-only"], dir, log); } catch (e) { log(`git pull failed (keeping cache): ${e instanceof Error ? e.message : String(e)}`); } } if (needsSetup(dir) && !opts.offline) { await buildInPlace(dir, log); } return dir; } if (opts.offline) { throw new Error(`skillRef ${parsed.marketplaceRef} not in cache (offline)`); } const descriptor = await fetchInstallDescriptor(parsed.marketplaceRef!, opts.apiBase); // Prefer npm when the marketplace advertises an npm package. const npmPkg = descriptor.npmPackage || (descriptor.packageName?.startsWith("@") ? descriptor.packageName : undefined); if (npmPkg) { const version = parsed.gitRef && parsed.gitRef !== "main" ? parsed.gitRef : descriptor.npmVersion; log(`Install descriptor prefers npm: ${npmPkg}${version ? `@${version}` : ""}`); return ensureNpmPackageCached(npmPkg, version, opts); } if (!descriptor.githubUrl) { throw new Error( `Install descriptor for ${parsed.marketplaceRef} has no githubUrl or npmPackage`, ); } const gitRef = parsed.gitRef !== "main" ? parsed.gitRef! : descriptor.ref || "main"; const repoUrl = descriptor.githubUrl.endsWith(".git") ? descriptor.githubUrl : `${descriptor.githubUrl}.git`; mkdirSync(join(cacheRoot, parsed.owner!, parsed.skill!), { recursive: true }); if (!existsSync(join(dir, ".git"))) { log(`Cloning ${repoUrl} (${gitRef}) → ${dir}`); try { run( "git", ["clone", "--branch", gitRef, "--single-branch", "--depth", "1", repoUrl, dir], join(cacheRoot, parsed.owner!, parsed.skill!), log, ); } catch { run("git", ["clone", "--depth", "1", repoUrl, dir], join(cacheRoot, parsed.owner!, parsed.skill!), log); try { run("git", ["checkout", gitRef], dir, log); } catch { /* stay on default branch */ } } } try { writeFileSync( join(dir, ".agenticros-skill-ref.json"), JSON.stringify( { kind: "git", marketplaceRef: parsed.marketplaceRef, gitRef, githubUrl: descriptor.githubUrl, cachedAt: new Date().toISOString(), }, null, 2, ), ); } catch { /* ignore */ } await buildInPlace(dir, log); return dir; } async function buildInPlace(dir: string, log: (m: string) => void): Promise { const needsInstall = !hasInstalledDeps(dir); const needsBuild = !hasBuiltEntry(dir); if (!needsInstall && !needsBuild) return; const pm = hasBin("pnpm") ? "pnpm" : "npm"; if (needsInstall) run(pm, ["install"], dir, log); if (needsBuild) run(pm, ["run", "build"], dir, log); } /** * Resolve all config.skillRefs into absolute directories. * Does not mutate config — caller merges into skillPaths. */ export async function resolveSkillRefs( refs: string[], opts: ResolveSkillRefsOptions = {}, ): Promise { const paths: string[] = []; const errors: ResolveSkillRefsResult["errors"] = []; const seen = new Set(); for (const raw of refs) { try { const dir = await ensureSkillRefCached(raw, opts); if (!seen.has(dir)) { seen.add(dir); paths.push(dir); } } catch (e) { errors.push({ ref: raw, error: e instanceof Error ? e.message : String(e), }); } } return { paths, errors }; } /** * Return a config copy with skillRefs resolved into skillPaths (deduped). */ export async function withResolvedSkillRefs( config: AgenticROSConfig, opts: ResolveSkillRefsOptions = {}, ): Promise<{ config: AgenticROSConfig; errors: ResolveSkillRefsResult["errors"] }> { const refs = config.skillRefs ?? []; if (refs.length === 0) { return { config, errors: [] }; } const { paths, errors } = await resolveSkillRefs(refs, opts); const merged = [...(config.skillPaths ?? [])]; const have = new Set(merged.map((p) => p)); for (const p of paths) { if (!have.has(p)) { have.add(p); merged.push(p); } } return { config: { ...config, skillPaths: merged }, errors, }; } /** * Sync: merge skillRefs that are already present under the skills-cache * into skillPaths. No network — use CLI install / ensureSkillRefCached to * populate the cache first. Safe for OpenClaw's synchronous register(). */ export function applyCachedSkillRefs(config: AgenticROSConfig): AgenticROSConfig { const refs = config.skillRefs ?? []; if (refs.length === 0) return config; const cacheRoot = skillsCacheDir(); const merged = [...(config.skillPaths ?? [])]; const have = new Set(merged); for (const raw of refs) { const parsed = parseSkillRef(raw); if (!parsed) continue; if (parsed.kind === "npm") { const safeName = parsed.npmPackage!.replace(/^@/, "").replace(/\//g, "__"); const pkgRoot = join(cacheRoot, "npm", safeName); if (!existsSync(pkgRoot)) continue; const pin = parsed.npmVersion; if (pin && !pin.startsWith("^") && !pin.startsWith("~") && !pin.includes("*") && pin !== "latest") { const dir = cachePathForNpm(cacheRoot, parsed.npmPackage!, pin); if (existsSync(join(dir, "package.json")) && !have.has(dir)) { have.add(dir); merged.push(dir); } continue; } // Any cached version — prefer highest lexical version dir const versions = readdirSync(pkgRoot).filter((v) => existsSync(join(pkgRoot, v, "package.json")), ); if (versions.length === 0) continue; versions.sort(); const dir = join(pkgRoot, versions[versions.length - 1]!); if (!have.has(dir)) { have.add(dir); merged.push(dir); } continue; } const dir = cachePathForGit(cacheRoot, parsed.owner!, parsed.skill!, parsed.gitRef!); if (existsSync(join(dir, "package.json")) && !have.has(dir)) { have.add(dir); merged.push(dir); } } if (merged.length === (config.skillPaths ?? []).length) return config; return { ...config, skillPaths: merged }; } /** Repo name hint from github URL (for logging). */ export function githubRepoBasename(githubUrl: string): string { return basename(githubUrl.replace(/\.git$/, "").replace(/\/+$/, "")); }