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
[](https://npmjs.com/package/skilld)
[](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,
+ })
+ })
+ })
+})