diff --git a/.github/logos/logo-mark.svg b/.github/logos/logo-mark.svg new file mode 100644 index 00000000..12e38cc8 --- /dev/null +++ b/.github/logos/logo-mark.svg @@ -0,0 +1,3 @@ + + + diff --git a/CLAUDE.md b/CLAUDE.md index 5483cdc2..40c5bff7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -20,34 +20,43 @@ pnpm test -- --project e2e # E2E tests only ### CLI Commands ```bash -skilld # Interactive menu -skilld add vue nuxt # Add skills for packages -skilld update # Update all outdated skills -skilld update vue # Update specific package -skilld remove # Remove installed skills -skilld list # List installed skills (one per line) -skilld list --json # List as JSON -skilld info # Show config, agents, features, per-package detail -skilld config # Change settings -skilld install # Restore references from lockfile -skilld prepare # Hook for package.json "prepare" (restore refs, sync shipped, report outdated) -skilld uninstall # Remove skilld data -skilld search "query" # Search indexed docs -skilld search "query" -p nuxt # Search filtered by package -skilld cache # Clean expired LLM cache entries -skilld add owner/repo # Add pre-authored skills from git repo -skilld eject vue # Eject skill (portable, no symlinks) -skilld eject vue --name vue # Eject with custom skill dir name -skilld eject vue --out ./dir/ # Eject to custom path -skilld eject vue --from 2025-07-01 # Only releases/issues since date -skilld author # Generate skill for npm publishing (monorepo-aware) -skilld author -m haiku # Author with specific LLM model -skilld author -o ./custom/ # Author to custom output directory +skilld # Interactive menu +skilld add npm:vue npm:nuxt # Install package skills from registry +skilld add gh:owner/repo # Install git skills from GitHub +skilld add @curator # Install all skills from a curator (coming soon) +skilld add @curator/collection # Install a specific collection (coming soon) +skilld add vue # Bare names deprecated, resolves as npm:vue with warning +skilld update # Update all outdated skills +skilld update vue # Update specific package +skilld remove # Remove installed skills +skilld list # List installed skills (one per line) +skilld list --json # List as JSON +skilld info # Show config, agents, features, per-package detail +skilld config # Change settings +skilld install # Restore references from lockfile +skilld prepare # Hook for package.json "prepare" (restore refs, sync shipped, report outdated) +skilld uninstall # Remove skilld data +skilld search "query" # Search indexed docs +skilld search "query" -p nuxt # Search filtered by package +skilld cache # Clean expired LLM cache entries +``` + +### Author Commands (skill creation and publishing) + +```bash +skilld author package # Generate a package skill from docs (monorepo-aware) +skilld author package -m haiku # Author with specific LLM model +skilld author package -o ./custom/ # Author to custom output directory +skilld author publish # Publish skill list to skilld.dev +skilld author eject vue # Eject skill (portable, no symlinks) +skilld author eject vue --name vue # Eject with custom skill dir name +skilld author validate # Validate a skill section +skilld author assemble [dir] # Merge enhancement output into SKILL.md ``` ## Architecture -CLI tool that generates AI agent skills from NPM package documentation. Requires Node >= 22.6.0. Flow: `package name → resolve docs → cache references → generate SKILL.md → install to agent dirs`. +CLI tool and curated registry for AI agent skills. Requires Node >= 22.6.0. Primary flow: `skilld add npm:` → fetch curated skill from skilld.dev → install to agent dirs. Fallback: `skilld author package ` → resolve docs → cache references → generate SKILL.md. **Key directories:** - `~/.skilld/` - Global cache: `references/@/`, `llm-cache/`, `config.yaml` @@ -60,7 +69,8 @@ CLI tool that generates AI agent skills from NPM package documentation. Requires - `src/sources/` - Doc fetching (npm registry, llms.txt, GitHub via ungh.cc) - `src/cache/` - Reference caching with symlinks to `~/.skilld/references/` - `src/retriv/` - Vector search with sqlite-vec + @huggingface/transformers embeddings -- `src/core/` - Config (custom YAML parser), skills iteration, formatting, lockfile +- `src/core/` - Config (custom YAML parser), skills iteration, formatting, lockfile, prefix parser +- `src/registry/` - Registry client for skilld.dev API (curated skill fetching) **Doc resolution cascade (src/commands/sync.ts):** 1. Package ships `skills/` directory → symlink directly (skills-npm convention) diff --git a/README.md b/README.md index 1c5a6b2b..e4f87a44 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -

skilld

+

skilld

[![npm version](https://img.shields.io/npm/v/skilld?color=yellow)](https://npmjs.com/package/skilld) [![npm downloads](https://img.shields.io/npm/dm/skilld?color=yellow)](https://npm.chart.dev/skilld) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5aae414d..bd5662cb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,8 +7,8 @@ settings: catalogs: default: '@mariozechner/pi-ai': - specifier: ^0.63.1 - version: 0.63.1 + specifier: ^0.64.0 + version: 0.64.0 '@mdream/crawl': specifier: ^1.0.3 version: 1.0.3 @@ -89,8 +89,8 @@ catalogs: specifier: ^7.3.0 version: 7.3.0 sqlite-vec: - specifier: ^0.1.7 - version: 0.1.7 + specifier: ^0.1.8 + version: 0.1.8 tinyglobby: specifier: ^0.2.15 version: 0.2.15 @@ -134,7 +134,7 @@ importers: version: 3.8.1 '@mariozechner/pi-ai': specifier: 'catalog:' - version: 0.63.1(ws@8.19.0)(zod@4.3.6) + version: 0.64.0(ws@8.19.0)(zod@4.3.6) '@mdream/crawl': specifier: 'catalog:' version: 1.0.3(crawlee@3.16.0(@types/node@25.5.0))(magicast@0.5.2) @@ -185,13 +185,13 @@ importers: version: 2.0.3 retriv: specifier: 'catalog:' - version: 0.13.0(@huggingface/transformers@3.8.1)(ai@6.0.68(zod@4.3.6))(sqlite-vec@0.1.7)(typescript@6.0.2) + version: 0.13.0(@huggingface/transformers@3.8.1)(ai@6.0.68(zod@4.3.6))(sqlite-vec@0.1.8)(typescript@6.0.2) semver: specifier: 'catalog:' version: 7.7.4 sqlite-vec: specifier: catalog:deps - version: 0.1.7 + version: 0.1.8 std-env: specifier: 'catalog:' version: 4.0.0 @@ -203,7 +203,7 @@ importers: version: 6.0.2 unagent: specifier: 'catalog:' - version: 0.0.8(@huggingface/transformers@3.8.1)(ai@6.0.68(zod@4.3.6))(sqlite-vec@0.1.7) + version: 0.0.8(@huggingface/transformers@3.8.1)(ai@6.0.68(zod@4.3.6))(sqlite-vec@0.1.8) unist-util-visit: specifier: catalog:deps version: 5.1.0 @@ -1334,8 +1334,8 @@ packages: resolution: {integrity: sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==} engines: {node: '>=8'} - '@mariozechner/pi-ai@0.63.1': - resolution: {integrity: sha512-wjgwY+yfrFO6a9QdAfjWpH7iSrDean6GsKDDMohNcLCy6PreMxHOZvNM0NwJARL1tZoZovr7ikAQfLGFZbnjsw==} + '@mariozechner/pi-ai@0.64.0': + resolution: {integrity: sha512-Z/Jnf+JSVDPLRcxJsa8XhYTJKIqKekNueaCpBLGQHgizL1F9RQ1Rur3rIfZpfXkt2cLu/AIPtOs223ueuoWaWg==} engines: {node: '>=20.0.0'} hasBin: true @@ -4738,33 +4738,33 @@ packages: split@0.3.3: resolution: {integrity: sha512-wD2AeVmxXRBoX44wAycgjVpMhvbwdI2aZjCkvfNcH1YqHQvJVa1duWc73OyVGJUc05fhFaTZeQ/PYsrmyH0JVA==} - sqlite-vec-darwin-arm64@0.1.7: - resolution: {integrity: sha512-dQ7u4GKPdOPi3IfZ44K7HHdYup2JssM6fuKR9zgqRzW137uFOQmRhbYChNu+ZfW+yhJutsPgfNRFsuWKmy627w==} + sqlite-vec-darwin-arm64@0.1.8: + resolution: {integrity: sha512-EoHyjPgX8uhWWcYSWbLwJHU6j8AQvwYbwDXFaT1ocdNtJy5qAGtX6LLsBR3vuWOyosMnULF6+DExjldku23h7A==} cpu: [arm64] os: [darwin] - sqlite-vec-darwin-x64@0.1.7: - resolution: {integrity: sha512-MDoczft1BriQcGMEz+CqeSCkB0OsAf12ytZOapS6MaB7zgNzLLSLH6Sxe3yzcPWUyDuCWgK7WzyRIo8u1vAIVA==} + sqlite-vec-darwin-x64@0.1.8: + resolution: {integrity: sha512-IafNV502w90y1BvWQXaRZ2AyyqNINmZ+7LEvkfL45Gf7kC182EFKxn9vbCy6UrY6QYXN/XQo7aICpTO7MIZBqw==} cpu: [x64] os: [darwin] - sqlite-vec-linux-arm64@0.1.7: - resolution: {integrity: sha512-V429sYT/gwr9PgtT8rbjQd6ls7CFchFpiS45TKSf7rU7wxt9MBmCVorUcheD4kEZb4VeZ6PnFXXCqPMeaHkaUw==} + sqlite-vec-linux-arm64@0.1.8: + resolution: {integrity: sha512-R/zogzxQ4LEAgju3knhZuWs8MKdIqN6Bq+ORhLYz5S4eQ98F+rYqilHQ8vsE3Ouy347yxNB2HyldDMcwzgfTyQ==} cpu: [arm64] os: [linux] - sqlite-vec-linux-x64@0.1.7: - resolution: {integrity: sha512-wZL+lXeW7y63DLv6FYU6Q4nv2lP5F94cWt7bJCWNiHmZ6NdKIgz/p0QlyuJA/51b8TyoDvsTdusLVlZz9cIh5A==} + sqlite-vec-linux-x64@0.1.8: + resolution: {integrity: sha512-DBIf2d2mvDb1M//snAKljpIsNQNqXX2AXWNBua0YCj40F/ygFJJ8j3k1pbcJfaEoaP21B+VquH0Gc6RIwZvXig==} cpu: [x64] os: [linux] - sqlite-vec-windows-x64@0.1.7: - resolution: {integrity: sha512-FEZMjMT03irJxwqMQg+A+4hHCiFslxISOAkQ0eYn2lP7GdpppkgYveaT5Xnw/2V+GLq2MXOJb0nDGFNethHSkg==} + sqlite-vec-windows-x64@0.1.8: + resolution: {integrity: sha512-+fBb7kzTpSAvoO/06YMDefnM5PpRhnPVqAYiUvLfBnUCRILDq2F9lcjQQIRjhobrb3eoJWzsGHwnqdVfaIUNxQ==} cpu: [x64] os: [win32] - sqlite-vec@0.1.7: - resolution: {integrity: sha512-1Sge9uRc3B6wDKR4J6sGFi/E2ai9SAU5FenDki3OmhdP/a49PO2Juy1U5yQnx2bZP5t+C3BYJTkG+KkDi3q9Xg==} + sqlite-vec@0.1.8: + resolution: {integrity: sha512-L3xKhQUYQ7kcb3v31KPyPaEigE2upETMSx/5K3vTwm8HRsbci9PKGklXU+mxEYVogojpkenM0TZK5Sz/2FXTQw==} stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} @@ -6659,7 +6659,7 @@ snapshots: '@lukeed/ms@2.0.2': {} - '@mariozechner/pi-ai@0.63.1(ws@8.19.0)(zod@4.3.6)': + '@mariozechner/pi-ai@0.64.0(ws@8.19.0)(zod@4.3.6)': dependencies: '@anthropic-ai/sdk': 0.73.0(zod@4.3.6) '@aws-sdk/client-bedrock-runtime': 3.1014.0 @@ -10380,11 +10380,11 @@ snapshots: ret@0.5.0: {} - retriv@0.13.0(@huggingface/transformers@3.8.1)(ai@6.0.68(zod@4.3.6))(sqlite-vec@0.1.7)(typescript@6.0.2): + retriv@0.13.0(@huggingface/transformers@3.8.1)(ai@6.0.68(zod@4.3.6))(sqlite-vec@0.1.8)(typescript@6.0.2): optionalDependencies: '@huggingface/transformers': 3.8.1 ai: 6.0.68(zod@4.3.6) - sqlite-vec: 0.1.7 + sqlite-vec: 0.1.8 typescript: 6.0.2 retry@0.12.0: @@ -10661,28 +10661,28 @@ snapshots: through: 2.3.8 optional: true - sqlite-vec-darwin-arm64@0.1.7: + sqlite-vec-darwin-arm64@0.1.8: optional: true - sqlite-vec-darwin-x64@0.1.7: + sqlite-vec-darwin-x64@0.1.8: optional: true - sqlite-vec-linux-arm64@0.1.7: + sqlite-vec-linux-arm64@0.1.8: optional: true - sqlite-vec-linux-x64@0.1.7: + sqlite-vec-linux-x64@0.1.8: optional: true - sqlite-vec-windows-x64@0.1.7: + sqlite-vec-windows-x64@0.1.8: optional: true - sqlite-vec@0.1.7: + sqlite-vec@0.1.8: optionalDependencies: - sqlite-vec-darwin-arm64: 0.1.7 - sqlite-vec-darwin-x64: 0.1.7 - sqlite-vec-linux-arm64: 0.1.7 - sqlite-vec-linux-x64: 0.1.7 - sqlite-vec-windows-x64: 0.1.7 + sqlite-vec-darwin-arm64: 0.1.8 + sqlite-vec-darwin-x64: 0.1.8 + sqlite-vec-linux-arm64: 0.1.8 + sqlite-vec-linux-x64: 0.1.8 + sqlite-vec-windows-x64: 0.1.8 stackback@0.0.2: {} @@ -10928,7 +10928,7 @@ snapshots: uint8array-extras@1.5.0: {} - unagent@0.0.8(@huggingface/transformers@3.8.1)(ai@6.0.68(zod@4.3.6))(sqlite-vec@0.1.7): + unagent@0.0.8(@huggingface/transformers@3.8.1)(ai@6.0.68(zod@4.3.6))(sqlite-vec@0.1.8): dependencies: croner: 9.1.0 hookable: 6.0.1 @@ -10938,7 +10938,7 @@ snapshots: optionalDependencies: '@huggingface/transformers': 3.8.1 ai: 6.0.68(zod@4.3.6) - sqlite-vec: 0.1.7 + sqlite-vec: 0.1.8 unconfig-core@7.5.0: dependencies: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 777f96e8..87c6223c 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -7,7 +7,7 @@ packages: overrides: global-agent: ^4.1.3 catalog: - '@mariozechner/pi-ai': ^0.63.1 + '@mariozechner/pi-ai': ^0.64.0 '@mdream/crawl': ^1.0.3 '@types/semver': ^7.7.1 bumpp: ^11.0.1 @@ -36,7 +36,7 @@ catalogs: mlly: ^1.8.2 oxc-parser: ^0.121.0 p-limit: ^7.3.0 - sqlite-vec: ^0.1.7 + sqlite-vec: ^0.1.8 tinyglobby: ^0.2.15 unist-util-visit: ^5.1.0 dev-build: diff --git a/src/cli.ts b/src/cli.ts index 0ee64813..4de0606b 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -149,9 +149,29 @@ async function brandLoader(work: () => Promise, minMs = 1500): Promise return result } +// ── Deprecation forwarder ── + +function deprecatedForwarder( + oldName: string, + newName: string, + loader: () => Promise, +): () => Promise { + return () => loader().then((cmd: any) => { + const original = cmd.run + return defineCommand({ + ...cmd, + meta: { ...cmd.meta, name: oldName }, + async run(ctx: any) { + console.warn(`\x1B[33m⚠ \`skilld ${oldName}\` is deprecated. Use \`skilld ${newName}\` instead.\x1B[0m`) + return original(ctx) + }, + }) + }) +} + // ── Subcommands (lazy-loaded) ── -const SUBCOMMAND_NAMES = ['add', 'eject', 'update', 'info', 'list', 'config', 'remove', 'install', 'uninstall', 'search', 'cache', 'validate', 'assemble', 'setup', 'prepare', 'author', 'publish'] +const SUBCOMMAND_NAMES = ['add', 'eject', 'update', 'info', 'list', 'config', 'remove', 'install', 'uninstall', 'search', 'cache', 'validate', 'assemble', 'setup', 'prepare', 'author', 'publish', 'upload'] // ── Main command ── @@ -159,14 +179,13 @@ const main = defineCommand({ meta: { name: 'skilld', version, - description: 'Sync package documentation for agentic use', + description: 'Curated agent skills for your projects', }, args: { agent: sharedArgs.agent, }, subCommands: { add: () => import('./commands/sync.ts').then(m => m.addCommandDef), - eject: () => import('./commands/sync.ts').then(m => m.ejectCommandDef), update: () => import('./commands/sync.ts').then(m => m.updateCommandDef), info: () => infoCommandDef, list: () => import('./commands/list.ts').then(m => m.listCommandDef), @@ -177,11 +196,15 @@ const main = defineCommand({ uninstall: () => import('./commands/uninstall.ts').then(m => m.uninstallCommandDef), search: () => import('./commands/search.ts').then(m => m.searchCommandDef), cache: () => import('./commands/cache.ts').then(m => m.cacheCommandDef), - validate: () => import('./commands/validate.ts').then(m => m.validateCommandDef), - assemble: () => import('./commands/assemble.ts').then(m => m.assembleCommandDef), setup: () => import('./commands/setup.ts').then(m => m.setupCommandDef), - author: () => import('./commands/author.ts').then(m => m.authorCommandDef), - publish: () => import('./commands/author.ts').then(m => m.authorCommandDef), + // Author group (nested subcommands) + author: () => import('./commands/author-group.ts').then(m => m.authorGroupDef), + // Deprecated forwarders (old top-level commands → skilld author ) + eject: deprecatedForwarder('eject', 'author eject', () => import('./commands/sync.ts').then(m => m.ejectCommandDef)), + validate: deprecatedForwarder('validate', 'author validate', () => import('./commands/validate.ts').then(m => m.validateCommandDef)), + assemble: deprecatedForwarder('assemble', 'author assemble', () => import('./commands/assemble.ts').then(m => m.assembleCommandDef)), + publish: deprecatedForwarder('publish', 'author publish', () => import('./commands/author.ts').then(m => m.authorCommandDef)), + upload: deprecatedForwarder('upload', 'author publish', () => import('./commands/upload.ts').then(m => m.uploadCommandDef)), }, async run({ args }) { // Guard: citty always calls parent run() after subcommand dispatch. diff --git a/src/commands/author-group.ts b/src/commands/author-group.ts new file mode 100644 index 00000000..06db64a5 --- /dev/null +++ b/src/commands/author-group.ts @@ -0,0 +1,12 @@ +import { defineCommand } from 'citty' + +export const authorGroupDef = defineCommand({ + meta: { name: 'author', description: 'Create, generate, and publish skills' }, + subCommands: { + package: () => import('./author.ts').then(m => m.authorCommandDef), + publish: () => import('./upload.ts').then(m => m.uploadCommandDef), + eject: () => import('./sync.ts').then(m => m.ejectCommandDef), + validate: () => import('./validate.ts').then(m => m.validateCommandDef), + assemble: () => import('./assemble.ts').then(m => m.assembleCommandDef), + }, +}) diff --git a/src/commands/author.ts b/src/commands/author.ts index 7c40c943..e395ff40 100644 --- a/src/commands/author.ts +++ b/src/commands/author.ts @@ -637,7 +637,7 @@ function printConsumerGuidance(packageNames: string[]): void { } export const authorCommandDef = defineCommand({ - meta: { name: 'author', description: 'Generate portable skill for npm publishing' }, + meta: { name: 'package', description: 'Generate a package skill from documentation' }, args: { out: { type: 'string', diff --git a/src/commands/sync-registry.ts b/src/commands/sync-registry.ts new file mode 100644 index 00000000..90417f36 --- /dev/null +++ b/src/commands/sync-registry.ts @@ -0,0 +1,59 @@ +/** + * Registry-based skill installation + * + * Simplified install flow for curated skills from skilld.dev: + * fetch SKILL.md → write to disk → update lockfile → link to agents. + * + * No doc resolution, no LLM, no caching. Fast path. + */ + +import type { AgentType } from '../agent/index.ts' +import type { RegistrySkill } from '../registry/client.ts' +import { mkdirSync, writeFileSync } from 'node:fs' +import { join } from 'pathe' +import { linkSkillToAgents } from '../agent/install.ts' +import { writeLock } from '../core/lockfile.ts' +import { SHARED_SKILLS_DIR } from '../core/shared.ts' +import { fetchRegistrySkill } from '../registry/client.ts' + +export interface SyncRegistryOptions { + packageName: string + agent: AgentType + global?: boolean + cwd?: string +} + +/** + * Install a package skill from the skilld.dev registry. + * Returns the installed skill, or null if no curated skill exists. + */ +export async function syncRegistrySkill(opts: SyncRegistryOptions): Promise { + const { packageName, agent, cwd = process.cwd() } = opts + + const skill = await fetchRegistrySkill(packageName) + if (!skill) + return null + + // Write SKILL.md to shared skills dir + const sharedDir = join(cwd, SHARED_SKILLS_DIR) + const skillDir = join(sharedDir, skill.name) + mkdirSync(skillDir, { recursive: true }) + writeFileSync(join(skillDir, 'SKILL.md'), skill.content) + + // Update lockfile + const baseDir = join(cwd, '.claude', 'skills') + mkdirSync(baseDir, { recursive: true }) + writeLock(baseDir, skill.name, { + packageName: skill.packageName, + version: skill.version, + repo: skill.repo, + source: 'registry', + syncedAt: new Date().toISOString().slice(0, 10), + generator: 'curator', + }) + + // Link to agent skill directories + linkSkillToAgents(skill.name, skillDir, cwd, agent) + + return skill +} diff --git a/src/commands/sync.ts b/src/commands/sync.ts index ad1f1387..367a7d14 100644 --- a/src/commands/sync.ts +++ b/src/commands/sync.ts @@ -38,10 +38,10 @@ import { defaultFeatures, hasCompletedWizard, readConfig, registerProject } from import { timedSpinner } from '../core/formatting.ts' import { parsePackages, readLock, removeLockEntry, writeLock } from '../core/lockfile.ts' import { parseFrontmatter } from '../core/markdown.ts' +import { parseSkillInput } from '../core/prefix.ts' import { getSharedSkillsDir, SHARED_SKILLS_DIR } from '../core/shared.ts' import { getProjectState } from '../core/skills.ts' import { shutdownWorker } from '../retriv/pool.ts' -import { parseGitSkillInput } from '../sources/git-skills.ts' import { fetchPkgDist, isPrerelease, @@ -741,7 +741,7 @@ async function syncSinglePackage(packageSpec: string, config: SyncConfig): Promi // ── Citty command definitions (lazy-loaded by cli.ts) ── export const addCommandDef = defineCommand({ - meta: { name: 'add', description: 'Add skills for package(s)' }, + meta: { name: 'add', description: 'Install skills (npm:, gh:, @)' }, args: { package: { type: 'positional', @@ -784,16 +784,30 @@ export const addCommandDef = defineCommand({ if (!hasCompletedWizard()) await runWizard({ agent }) - // Partition: git sources vs npm packages + // Classify inputs via prefix parser + const parsedSources = rawInputs.map(parseSkillInput) const gitSources: GitSkillSource[] = [] - const npmTokens: string[] = [] - - for (const input of rawInputs) { - const git = parseGitSkillInput(input) - if (git) - gitSources.push(git) - else - npmTokens.push(input) + const npmEntries: Array<{ name: string, spec: string }> = [] + + for (const source of parsedSources) { + switch (source.type) { + case 'git': + gitSources.push(source.source) + break + case 'npm': + npmEntries.push({ name: source.package, spec: source.tag ? `${source.package}@${source.tag}` : source.package }) + break + case 'bare': + p.log.warn(`Bare names are deprecated. Use \x1B[36mnpm:${source.package}\x1B[0m instead.`) + npmEntries.push({ name: source.package, spec: source.tag ? `${source.package}@${source.tag}` : source.package }) + break + case 'curator': + p.log.warn(`Curator installs (\x1B[36m@${source.handle}\x1B[0m) are not yet available.`) + break + case 'collection': + p.log.warn(`Collection installs (\x1B[36m@${source.handle}/${source.name}\x1B[0m) are not yet available.`) + break + } } // Handle git sources @@ -804,20 +818,43 @@ export const addCommandDef = defineCommand({ } } - // Handle npm packages via existing flow - if (npmTokens.length > 0) { - const packages = [...new Set(npmTokens.flatMap(s => s.split(/[,\s]+/)).map(s => s.trim()).filter(Boolean))] - const state = await getProjectState(cwd) - p.intro(introLine({ state, agentId: agent || undefined })) - return syncCommand(state, { - packages, - global: args.global, - agent, - model: args.model as OptimizeModel | undefined, - yes: args.yes, - force: args.force, - debug: args.debug, + // Handle npm packages: registry first, then fallback to doc generation + if (npmEntries.length > 0) { + const { syncRegistrySkill } = await import('./sync-registry.ts') + const seen = new Set() + const dedupedEntries = npmEntries.filter((e) => { + if (seen.has(e.name)) + return false + seen.add(e.name) + return true }) + + // Try registry for each package, collect misses for fallback + const fallbackPackages: string[] = [] + for (const entry of dedupedEntries) { + const result = await syncRegistrySkill({ packageName: entry.name, agent, cwd }) + if (result) { + p.log.success(`Installed \x1B[36m${result.name}\x1B[0m (${result.version}) from registry`) + } + else { + fallbackPackages.push(entry.spec) + } + } + + // Fallback: generate from docs for packages not in registry + if (fallbackPackages.length > 0) { + const state = await getProjectState(cwd) + p.intro(introLine({ state, agentId: agent || undefined })) + return syncCommand(state, { + packages: fallbackPackages, + global: args.global, + agent, + model: args.model as OptimizeModel | undefined, + yes: args.yes, + force: args.force, + debug: args.debug, + }) + } } }, }) diff --git a/src/commands/upload.ts b/src/commands/upload.ts new file mode 100644 index 00000000..ef42be57 --- /dev/null +++ b/src/commands/upload.ts @@ -0,0 +1,211 @@ +import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs' +import { homedir } from 'node:os' +import { multiselect } from '@clack/prompts' +import { defineCommand } from 'citty' +import { colorize } from 'consola/utils' +import { ofetch } from 'ofetch' +import { join } from 'pathe' +import { readLock } from '../core/lockfile.ts' +import { parseFrontmatter } from '../core/markdown.ts' + +const UPLOAD_URL = 'https://skilld.dev/api/collections/import' + +interface DiscoveredSkill { + name: string + description?: string + version?: string + repo?: string + generator?: string + source: 'local' | 'global' | 'plugin' +} + +function readSkillsFromDir(dir: string, source: 'local' | 'global', extraLockDirs?: string[]): DiscoveredSkill[] { + if (!existsSync(dir)) + return [] + + // Merge lockfiles: primary dir + any extra dirs (e.g. ~/.skilld/skills/ for global) + let lock = readLock(dir) + if (!lock && extraLockDirs) { + for (const d of extraLockDirs) { + lock = readLock(d) + if (lock) + break + } + } + const entries = readdirSync(dir).filter((f) => { + if (f.startsWith('.') || f.endsWith('.yaml') || f.endsWith('.yml')) + return false + const full = join(dir, f) + return statSync(full).isDirectory() + }) + + const skills: DiscoveredSkill[] = [] + for (const dirName of entries) { + const skillMd = join(dir, dirName, 'SKILL.md') + if (!existsSync(skillMd)) + continue + const content = readFileSync(skillMd, 'utf-8') + const fm = parseFrontmatter(content) + const lockInfo = lock?.skills[dirName] + skills.push({ + name: fm.name || dirName, + description: fm.description, + version: fm.version || lockInfo?.version, + repo: lockInfo?.repo, + generator: lockInfo?.generator, + source, + }) + } + + return skills +} + +interface MarketplaceInfo { + source: { source: string, repo: string } +} + +function readPlugins(configDir: string): DiscoveredSkill[] { + const settingsPath = join(configDir, 'settings.json') + if (!existsSync(settingsPath)) + return [] + + const settings = JSON.parse(readFileSync(settingsPath, 'utf-8')) + const enabledPlugins = settings.enabledPlugins as Record | undefined + if (!enabledPlugins) + return [] + + // Load marketplace repos for GitHub links + const marketplacesPath = join(configDir, 'plugins', 'known_marketplaces.json') + const marketplaces: Record = existsSync(marketplacesPath) + ? JSON.parse(readFileSync(marketplacesPath, 'utf-8')) + : {} + + // Load installed plugins for version info + const installedPath = join(configDir, 'plugins', 'installed_plugins.json') + const installed: { plugins: Record> } = existsSync(installedPath) + ? JSON.parse(readFileSync(installedPath, 'utf-8')) + : { plugins: {} } + + return Object.entries(enabledPlugins) + .filter(([, enabled]) => enabled) + .map(([id]) => { + const marketplace = id.split('@')[1] + const pluginName = id.split('@')[0] + const marketplaceInfo = marketplace ? marketplaces[marketplace] : undefined + const repo = marketplaceInfo?.source?.repo + const versions = installed.plugins[id] + const version = versions?.[0]?.version !== 'unknown' ? versions?.[0]?.version : undefined + + return { + name: pluginName!, + version, + repo: repo ? `${repo}` : undefined, + source: 'plugin' as const, + } + }) +} + +function discoverAllSkills(cwd: string): DiscoveredSkill[] { + const claudeHome = process.env.CLAUDE_CONFIG_DIR || join(homedir(), '.claude') + const localDir = join(cwd, '.claude', 'skills') + const globalDir = join(claudeHome, 'skills') + + const skilldGlobalDir = join(homedir(), '.skilld', 'skills') + const local = readSkillsFromDir(localDir, 'local') + const global = readSkillsFromDir(globalDir, 'global', [skilldGlobalDir]) + const plugins = readPlugins(claudeHome) + + // Filter out skilld-generated skills, deduplicate by repo + const seenRepos = new Set() + const all: DiscoveredSkill[] = [] + for (const skill of [...local, ...global, ...plugins]) { + if (!skill.repo || seenRepos.has(skill.repo)) + continue + if (skill.generator === 'skilld') + continue + seenRepos.add(skill.repo) + all.push(skill) + } + return all +} + +const SOURCE_COLORS: Record = { + local: 'green', + global: 'blue', + plugin: 'magenta', +} + +export async function uploadCommand(options?: { dryRun?: boolean }): Promise { + const skills = discoverAllSkills(process.cwd()) + + if (skills.length === 0) { + process.stdout.write('No skills found.\n') + return + } + + if (options?.dryRun) { + process.stdout.write(`Found ${colorize('bold', String(skills.length))} skill${skills.length === 1 ? '' : 's'}:\n\n`) + for (const skill of skills) { + const version = skill.version ? colorize('dim', ` v${skill.version}`) : '' + const tag = colorize((SOURCE_COLORS[skill.source] || 'dim') as 'dim', skill.source) + const repo = skill.repo ? colorize('dim', ` github.com/${skill.repo}`) : '' + process.stdout.write(` ${colorize('cyan', skill.name)}${version} ${tag}${repo}\n`) + } + process.stdout.write(`\n${colorize('dim', 'Dry run complete. No requests were made.')}\n`) + return + } + + const selected = await multiselect({ + message: `Select skills to upload (${skills.length} found)`, + options: skills.map((s) => { + const version = s.version ? ` v${s.version}` : '' + const repo = s.repo ? ` github.com/${s.repo}` : '' + return { + value: s.name, + label: `${s.name}${version}`, + hint: `${s.source}${repo}`, + } + }), + initialValues: [], + }) + + if (typeof selected === 'symbol' || selected.length === 0) + return + + const selectedSet = new Set(selected) + const payload = skills + .filter(s => selectedSet.has(s.name)) + .map(s => ({ + name: s.name, + version: s.version, + repo: s.repo, + source: s.source, + })) + + const { token, expires } = await ofetch<{ token: string, expires: string }>(UPLOAD_URL, { + method: 'POST', + body: { skills: payload }, + }) + + const expiresDate = new Date(expires) + const minutesLeft = Math.round((expiresDate.getTime() - Date.now()) / 60000) + + process.stdout.write(`\nToken: ${colorize('green', token)}\n\n`) + process.stdout.write(`Paste this token at: ${colorize('cyan', 'https://skilld.dev/people/YOUR_HANDLE/edit-skills')}\n`) + process.stdout.write(colorize('dim', `Token expires in ${minutesLeft} minutes.\n`)) +} + +export const uploadCommandDef = defineCommand({ + meta: { name: 'publish', description: 'Publish your skill list to skilld.dev' }, + args: { + dryRun: { + type: 'boolean', + alias: 'd', + description: 'Show what would be uploaded without making any requests', + default: false, + }, + }, + run({ args }) { + return uploadCommand({ dryRun: args.dryRun }) + }, +}) diff --git a/src/core/lockfile.ts b/src/core/lockfile.ts index b5afe06f..6e2634d5 100644 --- a/src/core/lockfile.ts +++ b/src/core/lockfile.ts @@ -96,6 +96,11 @@ export function readLock(skillsDir: string): SkilldLock | null { skills[currentSkill]![kv[0]] = kv[1] } } + // Normalize legacy source values + for (const info of Object.values(skills)) { + if (info.source === 'npm') + info.source = 'registry' + } const lock = { skills } lockCache.set(skillsDir, lock) return { skills: { ...lock.skills } } diff --git a/src/core/prefix.ts b/src/core/prefix.ts new file mode 100644 index 00000000..0f371b3b --- /dev/null +++ b/src/core/prefix.ts @@ -0,0 +1,106 @@ +/** + * Prefix-based input parser for `skilld add` + * + * All sources require an explicit prefix: + * npm:vue → package skill from registry + * gh:owner/repo → git skill + * github:o/r → git skill (alias) + * @handle → curator's skills + * @handle/coll → specific collection + * + * Bare names (no prefix) are deprecated but still resolve as npm: with a warning. + */ + +import type { GitSkillSource } from '../sources/git-skills.ts' +import { parseGitSkillInput } from '../sources/git-skills.ts' + +export type SkillSource + = | { type: 'npm', package: string, tag?: string } + | { type: 'git', source: GitSkillSource } + | { type: 'curator', handle: string } + | { type: 'collection', handle: string, name: string } + | { type: 'bare', package: string, tag?: string } + +/** + * Parse a single CLI input token into a typed SkillSource. + * + * Does NOT emit deprecation warnings; callers handle that for `bare` type. + */ +export function parseSkillInput(input: string): SkillSource { + const trimmed = input.trim() + + // npm: prefix → package skill + if (trimmed.startsWith('npm:')) { + const rest = trimmed.slice(4) + const { name, tag } = splitPackageTag(rest) + return { type: 'npm', package: name, tag } + } + + // gh: or github: prefix → git skill + if (trimmed.startsWith('gh:') || trimmed.startsWith('github:')) { + const rest = trimmed.startsWith('gh:') ? trimmed.slice(3) : trimmed.slice(7) + const gitSource = parseGitSkillInput(rest) + if (gitSource) + return { type: 'git', source: gitSource } + // If gh: prefix used but can't parse as git, treat as github shorthand + if (/^[\w.-]+\/[\w.-]+/.test(rest)) { + const [owner, repo] = rest.split('/') + return { type: 'git', source: { type: 'github', owner, repo } } + } + // Invalid gh: input, fall through to bare + return { type: 'bare', package: rest } + } + + // @handle or @handle/collection + if (trimmed.startsWith('@')) { + const rest = trimmed.slice(1) + const slashIdx = rest.indexOf('/') + if (slashIdx === -1) { + return { type: 'curator', handle: rest } + } + const handle = rest.slice(0, slashIdx) + const name = rest.slice(slashIdx + 1) + // Disambiguate: @scope/pkg (npm scoped) vs @handle/collection + // Scoped npm packages need npm: prefix in the new world. + // @handle with / is always a collection. + return { type: 'collection', handle, name } + } + + // Try existing git detection (SSH, URLs, local paths, owner/repo shorthand) + const gitSource = parseGitSkillInput(trimmed) + if (gitSource) + return { type: 'git', source: gitSource } + + // Bare name (deprecated) → resolves as npm + const { name, tag } = splitPackageTag(trimmed) + return { type: 'bare', package: name, tag } +} + +/** + * Parse multiple CLI input tokens, classifying each. + */ +export function parseSkillInputs(inputs: string[]): SkillSource[] { + return inputs.map(parseSkillInput) +} + +/** + * Split "package@tag" into name and optional tag. + * Handles scoped packages: "@scope/pkg@tag" + */ +function splitPackageTag(spec: string): { name: string, tag?: string } { + // Scoped: @scope/pkg@tag → find the @ after the scope + if (spec.startsWith('@')) { + const slashIdx = spec.indexOf('/') + if (slashIdx !== -1) { + const afterSlash = spec.indexOf('@', slashIdx) + if (afterSlash !== -1) + return { name: spec.slice(0, afterSlash), tag: spec.slice(afterSlash + 1) || undefined } + } + return { name: spec } + } + // Unscoped: pkg@tag + const atIdx = spec.indexOf('@') + if (atIdx !== -1) + return { name: spec.slice(0, atIdx), tag: spec.slice(atIdx + 1) || undefined } + return { name: spec } +} diff --git a/src/registry/client.ts b/src/registry/client.ts new file mode 100644 index 00000000..566ed163 --- /dev/null +++ b/src/registry/client.ts @@ -0,0 +1,73 @@ +/** + * Registry client for skilld.dev + * + * Fetches curated package skills from the skilld.dev registry API. + * Currently stubbed — returns null for all lookups until the API is live. + */ + +import { ofetch } from 'ofetch' + +const REGISTRY_BASE = 'https://skilld.dev/api' + +export interface RegistrySkill { + /** Skill directory name (e.g. "vue-skilld") */ + name: string + /** npm package name */ + packageName: string + /** Package version this skill was generated for */ + version: string + /** Full SKILL.md content */ + content: string + /** GitHub repo (owner/repo) */ + repo?: string + /** ISO timestamp of last update */ + updatedAt?: string +} + +export interface RegistrySearchResult { + skills: Array<{ + name: string + packageName: string + version: string + description?: string + updatedAt?: string + }> +} + +/** + * Fetch a curated package skill from the registry. + * Returns null if no curated skill exists for this package. + */ +export async function fetchRegistrySkill(packageName: string): Promise { + try { + return await ofetch(`${REGISTRY_BASE}/skills/${encodeURIComponent(packageName)}`) + } + catch { + // Registry unavailable or skill not found + return null + } +} + +/** + * Search the registry for skills matching a query. + */ +export async function searchRegistry(query: string): Promise { + try { + return await ofetch(`${REGISTRY_BASE}/search`, { + query: { q: query }, + }) + } + catch { + return { skills: [] } + } +} + +/** + * Check if a newer version of a registry skill is available. + */ +export async function checkRegistryUpdate(packageName: string, currentVersion: string): Promise { + const skill = await fetchRegistrySkill(packageName) + if (!skill || skill.version === currentVersion) + return null + return skill.version +} diff --git a/test/unit/prefix.test.ts b/test/unit/prefix.test.ts new file mode 100644 index 00000000..bd51cdf3 --- /dev/null +++ b/test/unit/prefix.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, it } from 'vitest' +import { parseSkillInput, parseSkillInputs } from '../../src/core/prefix' + +describe('prefix parser', () => { + describe('npm: prefix', () => { + it('parses simple package name', () => { + expect(parseSkillInput('npm:vue')).toEqual({ + type: 'npm', + package: 'vue', + tag: undefined, + }) + }) + + it('parses package with tag', () => { + expect(parseSkillInput('npm:vue@3.5')).toEqual({ + type: 'npm', + package: 'vue', + tag: '3.5', + }) + }) + + it('parses scoped package', () => { + expect(parseSkillInput('npm:@nuxt/ui')).toEqual({ + type: 'npm', + package: '@nuxt/ui', + tag: undefined, + }) + }) + + it('parses scoped package with tag', () => { + expect(parseSkillInput('npm:@nuxt/ui@3.0.0')).toEqual({ + type: 'npm', + package: '@nuxt/ui', + tag: '3.0.0', + }) + }) + }) + + describe('gh: and github: prefix', () => { + it('parses gh:owner/repo', () => { + const result = parseSkillInput('gh:vercel-labs/skills') + expect(result.type).toBe('git') + if (result.type === 'git') { + expect(result.source.owner).toBe('vercel-labs') + expect(result.source.repo).toBe('skills') + } + }) + + it('parses github:owner/repo', () => { + const result = parseSkillInput('github:vercel-labs/skills') + expect(result.type).toBe('git') + if (result.type === 'git') { + expect(result.source.owner).toBe('vercel-labs') + expect(result.source.repo).toBe('skills') + } + }) + }) + + describe('@ prefix (curator and collection)', () => { + it('parses @handle as curator', () => { + expect(parseSkillInput('@antfu')).toEqual({ + type: 'curator', + handle: 'antfu', + }) + }) + + it('parses @handle/collection as collection', () => { + expect(parseSkillInput('@antfu/vue-stack')).toEqual({ + type: 'collection', + handle: 'antfu', + name: 'vue-stack', + }) + }) + }) + + describe('bare names (deprecated)', () => { + it('treats bare name as deprecated npm', () => { + expect(parseSkillInput('vue')).toEqual({ + type: 'bare', + package: 'vue', + tag: undefined, + }) + }) + + it('treats bare name with tag as deprecated npm', () => { + expect(parseSkillInput('vue@3.5')).toEqual({ + type: 'bare', + package: 'vue', + tag: '3.5', + }) + }) + }) + + describe('legacy git detection (no prefix)', () => { + it('detects owner/repo shorthand as git', () => { + const result = parseSkillInput('vercel-labs/skills') + expect(result.type).toBe('git') + }) + + it('detects https URLs as git', () => { + const result = parseSkillInput('https://github.com/vercel-labs/skills') + expect(result.type).toBe('git') + }) + + it('detects SSH URLs as git', () => { + const result = parseSkillInput('git@github.com:vercel-labs/skills') + expect(result.type).toBe('git') + }) + + it('detects local paths as git', () => { + const result = parseSkillInput('./my-skills') + expect(result.type).toBe('git') + }) + }) + + describe('parseSkillInputs (batch)', () => { + it('classifies mixed inputs', () => { + const results = parseSkillInputs(['npm:vue', 'gh:owner/repo', '@antfu', 'nuxt']) + expect(results.map(r => r.type)).toEqual(['npm', 'git', 'curator', 'bare']) + }) + }) + + describe('whitespace handling', () => { + it('trims input', () => { + expect(parseSkillInput(' npm:vue ')).toEqual({ + type: 'npm', + package: 'vue', + tag: undefined, + }) + }) + }) +})