Skills Manager — CLI for Syncing Agent Skills Across Machines
The Problem
Agent Skills (per the open standard at agentskills.io) are filesystem-based capability packages that live in:
~/.claude/skills/— global skills, available to all projects.claude/skills/— project-scoped skills
This is powerful, but it creates a real pain point: skills are trapped on the machine where you created them. If you work across multiple machines, want to share skills with a team, or just want a backup, you have no native sync mechanism.
Skills Manager solves this by giving you a CLI to store skills in remote storage (Google Drive today, more backends later) and selectively install them into any agent’s skills directory.
What is a Skill?
A skill is a directory containing at minimum a SKILL.md file:
write-linkedin-post/
├── SKILL.md ← required: YAML frontmatter (name, description) + instructions
├── REFERENCE.md ← optional: reference docs the agent loads on demand
└── scripts/ ← optional: scripts the agent can invoke
SKILL.md frontmatter is the discovery contract:
---
name: write-linkedin-post
description: Writes clear, concise LinkedIn posts with proper formatting
---
Core Concepts
Registry
A registry (SKILLS_REGISTRY.yaml) is the root index. It lists all collections the user has access to, each as a backend-typed ref. A registry can reference collections on any backend — e.g. a gdrive registry can point to both gdrive and github collections.
name: my-registry
owner: you@example.com
source: gdrive
collections:
- name: personal
backend: gdrive
ref: SKILLS_PERSONAL # Drive folder name
- name: work-tools
backend: github
ref: owner/repo:.skillsmanager/work-tools
A local registry (~/.skillsmanager/registry.yaml) can additionally reference local collections. A remote registry cannot — local paths don’t resolve on other machines.
Registry is always present. collection create ensures a registry exists before creating a collection, auto-creating a local one if needed. A collection can never exist in an unregistered state.
Collections
A collection is a folder containing a SKILLS_COLLECTION.yaml index and skill subdirectories. It’s the unit of organization — one person might have one collection (personal), or multiple (personal, work).
name: personal
owner: you@example.com
skills:
- name: write-linkedin-post
path: write-linkedin-post/
description: Writes clear, concise LinkedIn posts with proper formatting
- name: code-review
path: code-review/
description: Opinionated code review workflow
The legacy filename SKILLS_SYNC.yaml is still recognized for backwards compatibility.
Drive Folder Naming
All Google Drive folders created by Skills Manager are prefixed with SKILLS_ to avoid collisions with regular Drive folders:
| Drive folder | Logical name (in YAML + CLI) |
|---|---|
SKILLS_MY_SKILLS | MY_SKILLS |
SKILLS_work | work |
SKILLS_personal | personal |
The prefix is stripped everywhere in the CLI — users and agents always work with the clean logical name.
CLI Commands
# Google Drive setup (human-facing, interactive, one-time)
sm setup google
# GitHub: no setup needed — requires gh CLI authenticated via: gh auth login
# Discover / refresh collections
sm refresh
# Browse skills
sm list
sm search <query>
# Install a skill into an agent's skills directory
sm install <name> --agent <agent>
# Add a local skill to a collection
sm add <path>
sm add <path> --collection <name>
# Push local changes to an existing skill back to remote
sm update <path>
# Manage collections (auto-registers in the existing registry)
sm collection create [name] # gdrive
sm collection create [name] --backend github --repo <owner/repo> # github
# Registry management
sm registry list
sm registry create [--backend gdrive|github] [--repo <owner/repo>]
sm registry push --backend gdrive|github [--repo <owner/repo>] # idempotent: skips already-synced collections
sm registry discover --backend gdrive|github
sm registry add-collection <name>
sm registry remove-collection <name> [--delete]
Agent-first design
All commands except setup google are non-interactive — they never block waiting for stdin. If something is missing (no collection, no credentials), they fail fast with a clear error message. This makes them safe to call from any AI agent.
Authentication
No explicit login step required. Any command that needs Drive access calls ensureAuth() which:
- Checks
~/.skillsmanager/credentials.jsonexists — if not, throws withRun: sm setup google - Checks
~/.skillsmanager/token.jsonexists — if not, launches the OAuth flow automatically - Returns the authenticated client with auto-refresh on token expiry
sm setup google is the one-time human-facing wizard that walks through:
- Installing
gcloudCLI (via Homebrew on macOS) gcloud auth login- Creating or selecting a Google Cloud project
- Enabling the Google Drive API
- Opening the browser to create OAuth 2.0 Desktop credentials
- Adding the authenticated user as a test user on the OAuth consent screen
- Running the OAuth flow to save
token.json
Auto-Discovery
On first use of any command, ensureReady() runs discoverCollections() if no config exists yet:
Drive API query: name='SKILLS_SYNC.yaml' and 'me' in owners and trashed=false
For each match, fetches the parent folder name, strips the SKILLS_ prefix, and stores the collection in ~/.skillsmanager/config.json.
Local Cache and Agent Symlinks
Skills are cached locally at:
~/.skillsmanager/cache/<collection-uuid>/<skill-name>/
The UUID is a stable identifier assigned per collection in config.json. It is backend-agnostic — it does not encode the backend type or folder ID. This keeps cache paths stable even if a collection is renamed or migrated to a different backend.
When sm install write-linkedin-post --agent claude is run:
- Looks up which collection owns the skill
- Downloads to
~/.skillsmanager/cache/<uuid>/write-linkedin-post/ - Creates symlink:
~/.claude/skills/write-linkedin-post → ~/.skillsmanager/cache/<uuid>/write-linkedin-post/
Multiple agents can be linked to the same cache entry:
~/.claude/skills/write-linkedin-post → ~/.skillsmanager/cache/<uuid>/write-linkedin-post/
~/.codex/skills/write-linkedin-post → ~/.skillsmanager/cache/<uuid>/write-linkedin-post/
One copy, many agents. Update once, all agents get the change.
Supported agents
| Agent | Skills directory |
|---|---|
claude | ~/.claude/skills/ |
codex | ~/.codex/skills/ |
cursor | ~/.cursor/skills/ |
windsurf | ~/.codeium/windsurf/skills/ |
copilot | ~/.copilot/skills/ |
gemini | ~/.gemini/skills/ |
roo | ~/.roo/skills/ |
agents | ~/.agents/skills/ |
Config File
~/.skillsmanager/config.json:
{
"registries": [
{
"id": "a1b2c3d4-...",
"name": "my-registry",
"backend": "gdrive",
"folderId": "1bZW0-Nic5D53dBwMH_h7JN_aB0W-Rqyq",
"fileId": "1yMuqe7JmelSYqm9TptKBWPk5ThTV5OJo"
}
],
"collections": [
{
"id": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
"name": "personal",
"backend": "gdrive",
"folderId": "1bZW0-Nic5D53dBwMH_h7JN_aB0W-Rqyq",
"registryFileId": "1yMuqe7JmelSYqm9TptKBWPk5ThTV5OJo",
"sourceRegistryId": "a1b2c3d4-..."
}
],
"skills": {
"code-review": [
{
"collectionId": "f47ac10b-...",
"installedAt": ["/Users/you/.claude/skills/code-review"]
}
]
},
"discoveredAt": "2026-03-16T00:06:33.570Z"
}
registries[].id— stable UUID, matched across refreshes byfolderIdcollections[].id— stable UUID for the cache path, never changes even if the collection is renamedfolderId— backend-specific location identifier, used to match entities across refreshes to preserve UUIDssourceRegistryId— links a collection back to the registry that owns it
Cross-Backend Skill Routing
The problem
A collection YAML can live anywhere (Google Drive, GitHub, local), but skill files may need to live in a different location — for example, a curated collection hosted in Drive that points to a public GitHub skills library. Initially this routing logic was duplicated inside GDriveBackend, which would have required repeating it in every future backend.
The type field
SKILLS_COLLECTION.yaml gains two optional fields:
type: github # declares who handles skill-file operations
metadata:
repo: owner/skills-repo # type-specific config
When type is absent, skill files come from the same backend as the collection YAML. When type: github is set, sm install downloads skill files from metadata.repo regardless of where the collection YAML is stored.
RoutingBackend — the decorator pattern
All backends are wrapped with a RoutingBackend decorator inside resolveBackend():
resolveBackend("gdrive") → new RoutingBackend(new GDriveBackend(auth))
resolveBackend("github") → new RoutingBackend(new GithubBackend())
resolveBackend("local") → new RoutingBackend(new LocalBackend())
RoutingBackend intercepts the three skill-file operations and dispatches based on col.type:
downloadSkill → col.type == "github" && backend != "github" → GithubBackend.downloadSkillFromRepo()
→ otherwise → inner.downloadSkill()
uploadSkill → col.type != collection.backend → throw (--remote-path hint)
→ github collection + metadata.repo != hostRepo → throw (foreign repo guard)
→ otherwise → inner.uploadSkill()
deleteSkill → col.type == "github" && backend != "github" → GithubBackend.deleteSkillFromRepo()
→ otherwise → inner.deleteSkill()
All other methods (registry ops, readCollection, writeCollection) pass straight through to the inner backend — the YAML always lives where the collection was declared.
Key invariant: cross-dispatch only happens when skillType !== collection.backend. Same-backend collections always fall through to the inner backend, which handles any internal routing (e.g. GithubBackend.downloadSkill uses skillsRepo() internally for metadata.repo GitHub collections).
Individual backends stay pure
GDriveBackendnever importsGithubBackend— it only knows about Google DriveGithubBackendnever needs to checkcol.typefor foreign collections- Adding a new skill source type (e.g.
type: s3) only requires updatingRoutingBackend, not each backend
--remote-path for cross-backend add
When a collection has type: github, uploading local skill files makes no sense — the canonical files live in the GitHub repo. sm add --remote-path registers a skill entry (path + name + description) into the collection YAML without touching any skill files:
sm add --remote-path skills/write-tests/ --name write-tests \
--description "Generate unit tests" --collection curated-col
Storage Backend Architecture
The StorageBackend interface is the only contract backends must implement:
interface BackendStatus {
loggedIn: boolean;
identity: string; // email/username, or "" when not logged in
hint?: string; // shown when loggedIn=false
}
interface StorageBackend {
// Identity
getOwner(): Promise<string>;
getStatus(): Promise<BackendStatus>;
// Collections
discoverCollections(): Promise<Omit<CollectionInfo, "id">[]>;
readCollection(collection: CollectionInfo): Promise<CollectionFile>;
writeCollection(collection: CollectionInfo, data: CollectionFile): Promise<void>;
deleteCollection(collection: CollectionInfo): Promise<void>;
downloadSkill(collection: CollectionInfo, skillName: string, destDir: string): Promise<void>;
uploadSkill(collection: CollectionInfo, localPath: string, skillName: string): Promise<string>;
deleteSkill(collection: CollectionInfo, skillName: string): Promise<void>;
// Registries
discoverRegistries(): Promise<Omit<RegistryInfo, "id">[]>;
readRegistry(registry: RegistryInfo): Promise<RegistryFile>;
writeRegistry(registry: RegistryInfo, data: RegistryFile): Promise<void>;
resolveCollectionRef(ref: RegistryCollectionRef): Promise<Omit<CollectionInfo, "id"> | null>;
createRegistry(options?: CreateRegistryOptions): Promise<RegistryInfo>;
createCollection(options: CreateCollectionOptions): Promise<CollectionInfo>;
}
Note: discoverRegistries returns without id — UUID assignment is handled by the config layer (mergeRegistries()), not the backend. This keeps backends storage-agnostic.
getStatus() returns login state and identity without triggering auth flows. The companion tryResolveBackend() in resolve.ts constructs a backend without calling ensureAuth() — returns null for unconfigured backends (e.g. gdrive with no token) rather than launching a browser OAuth flow. Used by sm status.
Implemented backends
Local (~/.skillsmanager/)
- Default backend, no setup needed
- Registry at
~/.skillsmanager/registry.yaml, collections under~/.skillsmanager/collections/ - Can reference remote collections in its registry (useful as the local index for cross-backend setups)
Google Drive
- Discovery: searches for
SKILLS_REGISTRY.yamlowned by the user across all of Drive - Download/upload: recursive folder operations via Drive API v3
- Auth: OAuth2 Desktop app flow — user creates their own Google Cloud project via
sm setup google - Folder naming:
SKILLS_prefix to distinguish from regular Drive folders
GitHub
- Uses the
ghCLI — requiresgh auth login, no additional Skills Manager setup - Clones repo to
~/.skillsmanager/github-workdir/<owner_repo>/on first access,git pullon subsequent - Writes commit directly; falls back to creating a PR if branch protection blocks direct push
- Skills stored under
.skillsmanager/<collection-name>/in the repo
Planned backends
- S3 / R2 — private cloud storage
- Dropbox — users already on Dropbox
Tech Stack
| Layer | Choice |
|---|---|
| Language | TypeScript / Node.js (ESM, "type": "module") |
| CLI framework | commander |
| Google Drive | googleapis npm package |
| Terminal output | chalk@4 + ora@5 |
| YAML | yaml |
| Config | Plain JSON at ~/.skillsmanager/ |
| Distribution | npm install -g skillsmanager |
Design Decisions
| Decision | Choice | Reason |
|---|---|---|
| CLI name | sm | Avoids conflicts with sk, skills |
| Skill structure | Flat, globally unique names | No category nesting — simpler for agents to reference |
| Two-tier layout | Registry → Collections → Skills | Registry is the discovery root; collections are independently portable |
| Collection file | SKILLS_COLLECTION.yaml | Human-readable, lives alongside skills in any storage |
| Terminology | Collection not Registry | More natural for personal/shared skill sets |
| Drive folder prefix | SKILLS_ | Distinguishes skillsmanager folders from regular Drive folders |
| Logical name | Strip prefix in YAML + CLI | Users and agents work with clean names, not Drive conventions |
| Cache path | ~/.skillsmanager/cache/<uuid>/ | UUID is backend-agnostic and stable across renames/migrations |
| UUID assignment | Config layer (mergeRegistries/mergeCollections) | Backends don’t need to know about UUIDs; preserved across refreshes by matching folderId |
| Auth | Auto-launch OAuth if no token | No explicit init required; any command triggers login when needed |
| Interactive prompts | Only in setup google | All other commands are non-interactive — safe for agent use |
| Drive scope | Full drive | Required to discover pre-existing files not created by the app |
| Google credentials | User creates own Cloud project | Avoids sharing a single OAuth app; each user controls their own credentials |
| Collection create → auto-register | Always registers immediately in existing registry | Prevents orphaned collections; registry is auto-created if none exists |
registry push idempotency | Skip collections already in remote registry | Safe to re-run for incremental updates; no duplicate refs |
| Direct registry writes | All mutation commands write to registry’s own backend immediately | No explicit sync step needed after add/remove operations |
| Cross-backend routing | RoutingBackend decorator wraps all backends | Centralizes col.type dispatch; individual backends stay pure and don’t need to know about other backends |
type field in SKILLS_COLLECTION.yaml | Declares skill-file handler; absent = same as collection backend | Portable — survives moving collection between backends; self-contained in the YAML |
--remote-path for cross-backend add | Registers path pointer without uploading files | You can’t upload to a foreign repo; pointer registration is the correct operation |