/** * Install resolved assets into a local project. * * 1. Streams asset files into the project. * 2. Merges npm dependencies into package.json. * 3. Runs the package manager to install npm deps. * 4. Installs skill dependencies via the `skills` CLI (`skills add `). * * NOTE: This module uses Node.js APIs (fs, path, nypm, child_process) and is * available from the package's Node-only `./install` entry point. */ import { execFile } from 'child_process' import * as fs from 'fs/promises' import * as path from 'path' import { detectPackageManager, installDependencies } from 'nypm' import pMap from 'p-map' import type { MarketClient } from './client.js' import type { AssetFilesResult, AssetInstallMetadata } from './contract.js' import { withProjectInstallLock } from './install-lock.js' import { lookupAssets } from './refs.js' import { md5, resolveDependencyLayout } from './pack.js' import { findInstallRoot, walkWithGitignore } from './project-walk.js' import { packageJsonAssetDependencies, parsePackageJson, type PackageJson } from './package-json.js' import type { ResolveResult } from './resolve.js' export { findInstallRoot } import { assetDependencyAlias, assetDependencyRange, assetDependencyValue, type AssetDependencyAlias, } from './schemas.js' export interface InstallOptions { /** Directory to start project root discovery from (default: cwd) */ cwd?: string /** Overwrite existing files that differ from the asset zip. */ force?: boolean /** Top-level assets requested by the user, used for package.json assetDependencies. */ rootRequests?: InstallRootRequest[] /** Asset-type install policy from the Market API. */ installMetadata?: Record /** Capability key of a shared private asset (`market install --key `). */ key?: string /** Log progress */ onProgress?: (message: string) => void /** * Override how a single resolved skill source is installed. The default * shells out to `npx skills add -y`. Exposed for tests and for * callers that want to drive a different skills runner. */ runSkillAdd?: (source: string, cwd: string) => Promise /** Override package installation for tests or an embedding runner. */ runPackageManagerInstall?: (cwd: string, log: (message: string) => void) => Promise /** * Static-root folders (relative to the install root, e.g. `public`). Asset files destined for * one are not downloaded; `/_redirects` gains an exact-match 302 rule to the file's * canonical data-plane URL instead — the standard static-host redirects format, so any host * (or dev middleware) that understands `_redirects` serves the asset straight from the CDN. */ redirectRoots?: string[] } export interface InstallRootRequest { name: string range: string saveRange: string save: boolean } export interface InstalledAsset { name: string type: string version: string description: string | null files: string[] /** Install paths served via `_redirects` instead of being written to disk. */ redirectedFiles: string[] } export interface InstallResult { assets: InstalledAsset[] npmDependencies: Record /** Installed skills, keyed by label, with the source passed to `skills add`. */ skillDependencies: Record warnings: string[] } interface DownloadResult { assets: InstalledAsset[] wrotePackageJson: boolean warnings: string[] /** Renames adopted during this install (per asset: canonical path → adopted project path). */ adoptedAliases: Record> redirects: RedirectRule[] } /** One `_redirects` line: `/{path}` under `{root}` forwards to the canonical file URL. */ interface RedirectRule { root: string path: string url: string } interface DownloadOptions { force: boolean log: (message: string) => void key?: string /** File aliases per asset name: canonical install path → the project's current path for it, * or `false` for a tombstone (the file is intentionally not installed). */ aliases: Record /** md5 of every project file, computed at most once per install (rename adoption). */ projectHashes: () => Promise> /** * Etags across an asset's published versions — the "recognized bytes" set for content-aware * updates. `null` when the server cannot answer: fail-open in the conservative direction * (never overwrite on uncertainty). */ knownHashes: (name: string) => Promise | null> /** Normalized static roots from `InstallOptions.redirectRoots`. */ redirectRoots: string[] } export async function install( client: MarketClient, resolution: ResolveResult, opts: InstallOptions = {}, ): Promise { const log = opts.onProgress ?? (() => {}) const installRoot = await findInstallRoot(opts.cwd ?? process.cwd()) const metadata = opts.installMetadata ?? {} const aliases = await projectAssetAliases(installRoot, resolution) const download = await downloadAssets(client, resolution, installRoot, { force: opts.force ?? false, log, key: opts.key, aliases, projectHashes: memoizedProjectHashes(installRoot), knownHashes: memoizedKnownHashes(client), redirectRoots: normalizeRedirectRoots(opts.redirectRoots ?? []), }) const integration = await withProjectInstallLock( installRoot, async () => { await writeRedirectsFiles(installRoot, download.redirects) const packageJsonUpdate = await updatePackageJson(resolution, installRoot, { rootRequests: opts.rootRequests ?? [], installMetadata: metadata, packageManagerNeeded: download.wrotePackageJson, adoptedAliases: download.adoptedAliases, }) if (packageJsonUpdate.packageManagerNeeded) { await (opts.runPackageManagerInstall ?? runPackageManagerInstall)(installRoot, log) } // Skill and package CLIs both update shared project state, so they stay in the same short local // integration lock. Network downloads and remote generation happen before this and remain parallel. const skillDependencies = await installSkills( resolution, installRoot, log, opts.runSkillAdd ?? defaultRunSkillAdd, ) return { skillDependencies } }, { timeoutMs: 5 * 60_000 }, ) return { assets: download.assets.map((asset) => ({ name: asset.name, type: asset.type, version: asset.version, description: asset.description, files: asset.files, redirectedFiles: asset.redirectedFiles, })), npmDependencies: resolution.npmDependencies, skillDependencies: integration.skillDependencies, warnings: download.warnings, } } /** Max assets processed in flight at once. Assets write disjoint file trees, so handling them * concurrently is safe. Since planning moved to the index (one light API call per asset, file * bytes fetched separately at FILE_DOWNLOAD_CONCURRENCY per asset), the per-asset step is * latency- not memory-bound. 24 keeps a ~60-asset install to a few round-trip waves; 64-wide * tripped API rate limiting into retry backoff (measured: 9s installs). */ const DOWNLOAD_CONCURRENCY = 24 /** Attempts per asset download, including the first. The worker can shed load transiently (a 5xx * under memory pressure, a dropped connection); a bounded retry rides that out instead of aborting * the whole install and leaving a partial asset tree. */ const DOWNLOAD_ATTEMPTS = 5 async function downloadAssets( client: MarketClient, resolution: ResolveResult, projectRoot: string, opts: DownloadOptions, ): Promise { const perAsset = await pMap( resolution.assets, (asset) => downloadOneAsset(client, asset, projectRoot, opts), { concurrency: DOWNLOAD_CONCURRENCY }, ) return { assets: perAsset.map((r) => r.asset), // A template ships its own package.json; if any downloaded asset wrote one, the caller must run // the package manager to bring its npm deps. wrotePackageJson: perAsset.some((r) => r.wrotePackageJson), warnings: perAsset.flatMap((r) => r.warnings), adoptedAliases: Object.fromEntries( perAsset .filter((r) => Object.keys(r.adopted).length > 0) .map((r) => [r.asset.name, r.adopted]), ), redirects: perAsset.flatMap((r) => r.redirects), } } /** Roots are project-relative folders; reject traversal/absolute forms and normalize slashes. */ function normalizeRedirectRoots(roots: string[]): string[] { return roots.map((root) => { const trimmed = root.replace(/\\/gu, '/').replace(/^\/+|\/+$/gu, '') return safeRelativePath(trimmed, () => `Invalid --redirect folder: ${root}`) }) } /** The redirect root containing `targetPath`, or null when the file installs normally. */ function redirectRootFor(targetPath: string, roots: string[]): string | null { return roots.find((root) => targetPath.startsWith(`${root}/`)) ?? null } /** * Merge this install's rules into each root's `_redirects`. Ownership is by path: a line whose * URL path we are (re)writing is replaced, every other line — hand-authored rules, comments — * survives untouched. Targets pin the resolved version, so the file also records what was * installed. */ async function writeRedirectsFiles(installRoot: string, rules: RedirectRule[]): Promise { const byRoot = new Map() for (const rule of rules) { const group = byRoot.get(rule.root) if (group) group.push(rule) else byRoot.set(rule.root, [rule]) } for (const [root, rootRules] of byRoot) { const managed = new Map(rootRules.map((rule) => [`/${rule.path}`, rule.url])) const file = path.join(installRoot, root, '_redirects') const existing = await maybeReadFile(file) const kept = (existing ? new TextDecoder().decode(existing).split('\n') : []).filter( (line) => !managed.has(line.trim().split(/\s+/u)[0] ?? ''), ) while (kept.at(-1)?.trim() === '') kept.pop() const ours = [...managed.entries()] .sort(([a], [b]) => a.localeCompare(b)) .map(([urlPath, url]) => `${urlPath} ${url} 302`) await fs.mkdir(path.dirname(file), { recursive: true }) await fs.writeFile(file, [...kept, ...ours, ''].join('\n')) } } async function downloadOneAsset( client: MarketClient, asset: ResolveResult['assets'][number], projectRoot: string, opts: DownloadOptions, ): Promise<{ asset: InstalledAsset wrotePackageJson: boolean warnings: string[] adopted: Record redirects: RedirectRule[] }> { opts.log(`Downloading ${asset.name}@${asset.version}...`) // The file index (paths + etags + fetch URLs) arrived with the resolution — // enough to plan targets, adopt renames, and emit redirect rules; file bytes // are fetched afterwards, and only for files actually written. const planned = asset.files.flatMap((entry) => { const normalizedPath = safeRelativePath( entry.path, () => `Zip contains an unsafe path: ${entry.path}`, ) if ( normalizedPath === '.' || (normalizedPath === 'README.md' && asset.type !== 'template') || normalizedPath.endsWith('/') ) { return [] } return [{ ...entry, normalizedPath }] }) // Alias targets come from metadata; reject traversal before any path is stat'd or resolved — // adoption must never quietly route around unsafe metadata. const declaredAlias = opts.aliases[asset.name] ?? {} for (const [canonical, target] of Object.entries(declaredAlias)) { if (target === false) continue safeRelativePath( target, () => `Alias for ${canonical} in ${asset.name} is an unsafe path: ${target}`, ) } const adopted = await adoptRenamedFiles(planned, asset.name, declaredAlias, projectRoot, opts) const alias = { ...declaredAlias, ...adopted } const redirects: RedirectRule[] = [] const installable: Array<{ entry: PlannedEntry; targetPath: string }> = [] for (const entry of planned) { // An alias redirects the file to where this project keeps it (a rename recorded by pack, a // dependent asset, or adoption above); `false` is a tombstone — a deliberate deletion that // must not resurrect on reinstall. Alias targets pass the same traversal guard. const aliased = alias[entry.normalizedPath] if (aliased === false) continue const targetPath = aliased === undefined ? entry.normalizedPath : safeRelativePath( aliased, () => `Alias for ${entry.normalizedPath} in ${asset.name} is an unsafe path: ${aliased}`, ) // `_redirects` lines are whitespace-delimited, so rule paths are stored // URL-encoded (the convention hosts use); consumers decode before matching. const root = redirectRootFor(targetPath, opts.redirectRoots) if (root) { const path = targetPath .slice(root.length + 1) .split('/') .map(encodeURIComponent) .join('/') redirects.push({ root, path, url: entry.url }) continue } installable.push({ entry, targetPath }) } // Only the network fetches are retried — downloads are the sole transient step; file writes are // deterministic local work and a failure there is a real error, not worth re-attempting. const label = `${asset.name}@${asset.version}` const fetched = await pMap( installable, async ({ entry, targetPath }) => ({ targetPath, bytes: await withRetry(() => fetchVerifiedFile(entry), { attempts: DOWNLOAD_ATTEMPTS, onRetry: (msg) => opts.log(`Downloading ${label} ${entry.path}: ${msg}`), }), }), { concurrency: FILE_DOWNLOAD_CONCURRENCY }, ) const installedFiles: string[] = [] const warnings: string[] = [] let wrotePackageJson = false for (const { targetPath, bytes } of fetched) { const filePath = path.join(projectRoot, targetPath) const existing = await maybeReadFile(filePath) if (existing && !bytesEqual(existing, bytes)) { // Recognized bytes — a published version of this same dependency file — are replaceable // without --force; unrecognized bytes are the user's and keep the skip-and-warn behavior. const known = opts.force ? null : await opts.knownHashes(asset.name) if (!opts.force && !known?.has(md5(existing))) { warnings.push( `Skipped ${targetPath} from ${asset.name}@${asset.version}; file already exists. Re-run with --force to overwrite.`, ) continue } } if (!existing || !bytesEqual(existing, bytes)) { await fs.mkdir(path.dirname(filePath), { recursive: true }) await fs.writeFile(filePath, bytes) if (targetPath === 'package.json') { wrotePackageJson = true } } installedFiles.push(targetPath) } opts.log( `Downloaded ${installedFiles.length} files from ${label}` + (redirects.length > 0 ? `, redirected ${redirects.length}.` : '.'), ) return { asset: { name: asset.name, type: asset.type, version: asset.version, description: asset.description, files: installedFiles, redirectedFiles: redirects.map((rule) => `${rule.root}/${rule.path}`), }, wrotePackageJson, warnings, adopted, redirects, } } type PlannedEntry = AssetFilesResult['files'][number] & { normalizedPath: string } /** * Install-time adoption of unsynced renames: when a file's write target is absent but identical * bytes already live elsewhere in the project, that location is used instead of creating a * duplicate — an implicit `sync` before writing. Uses the same layout resolution as pack/sync * (recorded alias → canonical → first match), so the selection rule stays identical everywhere. * The project is only hashed when some target is actually absent; a reinstall with every file in * place never scans. */ async function adoptRenamedFiles( planned: PlannedEntry[], name: string, declaredAlias: AssetDependencyAlias, projectRoot: string, opts: DownloadOptions, ): Promise> { const installable = planned.filter((entry) => declaredAlias[entry.normalizedPath] !== false) const provisionalTargets = installable.map((entry) => { const target = declaredAlias[entry.normalizedPath] return typeof target === 'string' ? target : entry.normalizedPath }) if (!(await anyPathMissing(projectRoot, provisionalTargets))) return {} const layout = resolveDependencyLayout( { [name]: assetDependencyValue('*', declaredAlias) }, { files: installable.map((entry) => ({ name, canonicalPath: entry.normalizedPath, hash: entry.etag, })), fetched: new Set([name]), }, await opts.projectHashes(), ) const resolved = assetDependencyAlias(layout.assetDependencies[name]) return Object.fromEntries( Object.entries(resolved).filter( (pair): pair is [string, string] => typeof pair[1] === 'string' && declaredAlias[pair[0]] !== pair[1], ), ) } async function anyPathMissing(projectRoot: string, targets: string[]): Promise { for (const target of targets) { if (!(await isFile(path.join(projectRoot, target)))) return true } return false } /** md5 of every project file (gitignored trees excluded), computed at most once per install. */ function memoizedProjectHashes(projectRoot: string): () => Promise> { let cached: Promise> | undefined return () => (cached ??= (async () => { const walk = await walkWithGitignore(projectRoot) const hashes = new Map() for (const relative of walk.kept) { hashes.set( relative, md5(new Uint8Array(await fs.readFile(path.join(projectRoot, relative)))), ) } return hashes })()) } /** Newest published versions contributing to the recognized-bytes set (same bound as pack). */ const KNOWN_HASH_VERSION_CAP = 20 function memoizedKnownHashes(client: MarketClient): (name: string) => Promise | null> { const cache = new Map | null>>() return (name) => { const hit = cache.get(name) if (hit) return hit const result = (async () => { try { const [latest] = await lookupAssets(client.asset, [{ name }]) if (!latest) return null const recent = latest.versions.slice(-KNOWN_HASH_VERSION_CAP) const entries = await lookupAssets( client.asset, recent.map((version) => ({ name, version })), ) return new Set(entries.flatMap((entry) => entry?.files.map((file) => file.etag) ?? [])) } catch { // Offline or not visible — the conservative fail-open: the caller keeps skip-and-warn. return null } })() cache.set(name, result) return result } } /** Validate + normalize a posix-relative install path; throws `message()` on traversal/absolute. */ function safeRelativePath(candidate: string, message: () => string): string { const posixPath = candidate.replace(/\\/g, '/') if ( posixPath.split('/').includes('..') || path.posix.isAbsolute(posixPath) || path.win32.isAbsolute(posixPath) ) { throw new Error(message()) } return path.posix.normalize(posixPath) } /** * File aliases per asset, merged from the project's own package.json `assetDependencies` and the * aliases dependents declared in their metadata. The project's entries win: the user's local * layout beats what a template recorded at publish time. */ async function projectAssetAliases( projectRoot: string, resolution: ResolveResult, ): Promise> { const merged: Record = {} for (const [name, alias] of Object.entries(resolution.assetAliases ?? {})) { merged[name] = { ...alias } } const pkgPath = path.join(projectRoot, 'package.json') const pkgJson = await maybeReadFile(pkgPath) if (!pkgJson) return merged const declared = packageJsonAssetDependencies(new TextDecoder().decode(pkgJson), pkgPath) for (const [name, value] of Object.entries(declared)) { const alias = assetDependencyAlias(value) if (Object.keys(alias).length === 0) continue merged[name] = { ...(merged[name] ?? {}), ...alias } } return merged } /** Files fetched in flight per asset. Assets themselves download 8-wide, so this bounds total * connections at 8×4 against edge-cached objects — cheap for the CDN, fast for the install. */ const FILE_DOWNLOAD_CONCURRENCY = 4 /** Each written file is fetched from the data plane and md5-verified against its R2 etag — * the same edge cache browsers warm. */ async function fetchVerifiedFile(file: AssetFilesResult['files'][number]): Promise { const res = await fetch(file.url) if (!res.ok) { throw Object.assign(new Error(`file download responded ${res.status}`), { status: res.status }) } const bytes = new Uint8Array(await res.arrayBuffer()) // A digest mismatch is a corrupt transfer; thrown without a status so withRetry treats it as // transient and re-fetches. const digest = md5(bytes) if (digest !== file.etag) { throw new Error(`md5 mismatch for ${file.path}: expected ${file.etag}, got ${digest}`) } return bytes } /** Retry a transient operation with exponential backoff + jitter. Permanent failures (a 4xx from * the worker) are not retried — only server/overload (5xx, 429) and connection-level errors are. */ async function withRetry( op: () => Promise, opts: { attempts: number; onRetry?: (msg: string) => void }, ): Promise { let lastError: unknown for (let attempt = 1; attempt <= opts.attempts; attempt++) { try { return await op() } catch (error) { lastError = error if (attempt === opts.attempts || !isRetriable(error)) break const backoffMs = Math.min(8000, 400 * 2 ** (attempt - 1)) + Math.floor(Math.random() * 250) opts.onRetry?.( `attempt ${attempt}/${opts.attempts} failed (${errorMessage(error)}), retrying in ${backoffMs}ms`, ) await delay(backoffMs) } } throw lastError } /** oRPC surfaces an HTTP failure as an error carrying the numeric `status`; a server/overload * (5xx) or rate-limit (429) is transient and worth retrying, a client error (4xx) is not. An error * without a status (connection reset, DNS/fetch failure, timeout) is treated as transient. */ function isRetriable(error: unknown): boolean { const status = error && typeof error === 'object' && 'status' in error ? error.status : undefined if (typeof status === 'number') return status === 429 || status >= 500 return true } function errorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error) } function delay(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)) } async function isFile(file: string): Promise { try { return (await fs.stat(file)).isFile() } catch (error) { if (isMissingFile(error)) return false throw error } } async function maybeReadFile(file: string): Promise { try { return await fs.readFile(file) } catch (error) { if (isMissingFile(error)) return null throw error } } function isMissingFile(error: unknown): boolean { return error instanceof Error && 'code' in error && error.code === 'ENOENT' } async function updatePackageJson( resolution: ResolveResult, projectRoot: string, opts: { rootRequests: InstallRootRequest[] installMetadata: Record packageManagerNeeded: boolean adoptedAliases: Record> }, ): Promise<{ packageManagerNeeded: boolean }> { const pkgPath = path.join(projectRoot, 'package.json') const pkg = (await isFile(pkgPath)) ? parsePackageJson(await fs.readFile(pkgPath, 'utf-8'), pkgPath) : defaultPackageJson() let changed = false let packageManagerNeeded = opts.packageManagerNeeded const npmChanged = mergeRecord(pkg, 'dependencies', resolution.npmDependencies) if (npmChanged) { changed = true packageManagerNeeded = true } const assetDependencies = assetDependenciesToSave( resolution, opts.rootRequests, opts.installMetadata, ) if (mergeAssetDependencyRanges(pkg, assetDependencies)) { changed = true } if (mergeAdoptedAliases(pkg, opts.adoptedAliases)) { changed = true } if (changed) { await fs.writeFile(pkgPath, JSON.stringify(pkg, null, 2) + '\n') } return { packageManagerNeeded } } /** Record renames adopted during this install on the entries package.json already declares — * adoption is thereby persisted, not just applied. Undeclared (transitive) deps stay out. */ function mergeAdoptedAliases( pkg: PackageJson, adopted: Record>, ): boolean { const current = pkg.assetDependencies if (!current) return false let changed = false for (const [name, alias] of Object.entries(adopted)) { const existing = current[name] if (existing === undefined) continue const next = assetDependencyValue(assetDependencyRange(existing), { ...assetDependencyAlias(existing), ...alias, }) if (JSON.stringify(existing) === JSON.stringify(next)) continue current[name] = next changed = true } return changed } async function runPackageManagerInstall(projectRoot: string, log: (msg: string) => void) { log('Installing npm dependencies...') const pm = await detectPackageManager(projectRoot) const pmName = pm?.name ?? 'npm' log(`Using ${pmName}...`) await installDependencies({ cwd: projectRoot, packageManager: { name: pmName, command: pmName } }) log('npm dependencies installed.') } function defaultPackageJson(): PackageJson { return { name: 'my-project', private: true, dependencies: {} } } function mergeRecord( pkg: PackageJson, field: 'dependencies', values: Record, ): boolean { const entries = Object.entries(values) if (entries.length === 0) return false const current = pkg[field] ?? {} let changed = false for (const [name, range] of entries) { if (name.startsWith('@drawcall/') && current[name] === 'latest') { continue } if (current[name] === range) continue current[name] = range changed = true } pkg[field] = current return changed } /** Merge saved ranges into `assetDependencies`, preserving any aliases an entry already carries. */ function mergeAssetDependencyRanges(pkg: PackageJson, values: Record): boolean { const entries = Object.entries(values) if (entries.length === 0) return false const current = pkg.assetDependencies ?? {} let changed = false for (const [name, range] of entries) { const existing = current[name] if (existing !== undefined && assetDependencyRange(existing) === range) continue current[name] = existing === undefined ? range : assetDependencyValue(range, assetDependencyAlias(existing)) changed = true } pkg.assetDependencies = current return changed } function assetDependenciesToSave( resolution: ResolveResult, rootRequests: InstallRootRequest[], metadata: Record, ): Record { const resolvedByName = new Map(resolution.assets.map((asset) => [asset.name, asset])) const out: Record = {} for (const request of rootRequests) { if (!request.save) continue const asset = resolvedByName.get(request.name) if (!asset) continue if ((metadata[asset.type]?.saveOnInstall ?? true) === false) continue out[asset.name] = request.saveRange } return out } function bytesEqual(a: Uint8Array, b: Uint8Array): boolean { if (a.length !== b.length) return false for (let i = 0; i < a.length; i += 1) { if (a[i] !== b[i]) return false } return true } async function installSkills( resolution: ResolveResult, projectRoot: string, log: (msg: string) => void, runSkillAdd: (source: string, cwd: string) => Promise, ): Promise> { const skills = resolution.skillDependencies ?? {} const entries = Object.entries(skills) if (entries.length === 0) return {} for (const [label, source] of entries) { const resolvedSource = resolveSkillSource(source, projectRoot) log(`Installing skill ${label} (${source})...`) await runSkillAdd(resolvedSource, projectRoot) } log(`Skills installed: ${entries.map(([label]) => label).join(', ')}`) return skills } /** * Resolve a skill source to the argument passed to `skills add`. Local paths * (`./`, `../`, `.`, `..`, or absolute) are resolved against the install root * so they point at a skill directory shipped inside an installed asset; every * other form (GitHub shorthand, git/HTTP(S) URL, `tree//`) is * a remote ref and is passed through untouched. */ function resolveSkillSource(source: string, projectRoot: string): string { return isLocalSkillPath(source) ? path.resolve(projectRoot, source) : source } function isLocalSkillPath(source: string): boolean { return ( path.isAbsolute(source) || source === '.' || source === '..' || source.startsWith('./') || source.startsWith('../') || /^[a-zA-Z]:[/\\]/.test(source) ) } /** * Install one skill via `npx skills add -y`. When run inside a coding * agent the `skills` CLI auto-detects the agent and installs non-interactively; * `-y` keeps it non-interactive everywhere else. npx resolves (and caches) the * `skills` package on first use. */ function defaultRunSkillAdd(source: string, cwd: string): Promise { return new Promise((resolve, reject) => { execFile('npx', ['--yes', 'skills', 'add', source, '-y'], { cwd }, (error, _stdout, stderr) => { if (error) { const detail = stderr.trim() || error.message reject(new Error(`Failed to install skill "${source}": ${detail}`)) return } resolve() }) }) }