Command to Skill Migration skill for Claude Code
Move slash commands into skills without breaking a name. Claude inventories your legacy command files, dry-runs a converter that flags name collisions, unknown keys, side effects and positional arguments, then gives each new skill a deliberate invocation setting and real description. Long bodies move into references and scripts, every skill is invoked once with real arguments, and only then are the old files removed in the same commit.
- name
command-to-skill-migration - size176 lines · ≈2,100 tokens
- files2
- licenseMIT
- updated
What Claude sees before loading it
This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.
description: Converts legacy Claude Code custom slash commands in .claude/commands/*.md and ~/.claude/commands into skills under .claude/skills/NAME/SKILL.md, preserving the slash name, arguments, allowed-tools, and model, choosing invocation control with disable-model-invocation or user-invocable, splitting long bodies into references/ and scripts/, and fixing positional argument indexing. Includes a dry-run converter script. Use when migrating commands to skills, when a command should also trigger automatically, or when a command file outgrew one page. Try asking
- Convert everything in .claude/commands into proper skills
- Turn my /deploy command into a skill with its script bundled, and make sure Claude can't run it by itself
- After moving my command to a skill, $1 picks up the wrong argument — fix it
Use it when
- You have legacy .claude/commands files and want skill folders
- A command needs templates or scripts beside it
- A command should also trigger automatically from natural requests
- Arguments broke after converting a command
Skip it when
- You are writing a brand-new skill from scratch — use skill-authoring instead
- The skill exists but triggers at the wrong times — use skill-description-tuning instead
How to check the result
Each migrated skill appears in /skills, runs with two distinct real arguments landing in the right placeholders, and side-effecting skills do not fire from a plain-language request.
The common mistake it prevents
Keeping 1-based placeholders from an old command file, so $1 now reads the second argument and the deploy targets the wrong environment.
A migration is done when every old /command still works with the same name and the
same arguments, each skill has a deliberate invocation setting, side-effecting skills
cannot fire on their own, and the old command files are deleted only after each skill
was invoked once with real arguments.
Why and when to migrate
Commands and skills are merged: .claude/commands/deploy.md and
.claude/skills/deploy/SKILL.md both create /deploy, and old command files keep
working. Migrate a command when it needs something only a folder gives:
| Need | Migrate? |
|---|---|
| Supporting files (templates, checklists, scripts) next to the instructions | Yes |
| Claude should pick it up automatically from a natural request | Yes (description + default invocation) |
| Body is longer than ~150 lines and only parts are needed per run | Yes, split into references/ |
| Share via a plugin or claude.ai skill upload | Yes (plugins and uploads use skill folders) |
| Small, works, user-only, no files | Optional; leaving it is fine |
Precedence when both exist with one name: the skill wins over the command file. That is what makes an incremental migration safe — and what makes a stale skill folder silently shadow an edited command.
Workflow
- [ ] 1. Inventory: list project and personal commands, note args and side effects
- [ ] 2. Dry-run the converter; resolve every WARNING line
- [ ] 3. Decide invocation control per skill (table below)
- [ ] 4. Write real descriptions with trigger phrases
- [ ] 5. Fix arguments; move long sections to references/, shell logic to scripts/
- [ ] 6. Generate skills (--write); invoke each with real args; compare to old output
- [ ] 7. Delete old command files in the same commit; update docs/CLAUDE.md mentions
1. Inventory
find .claude/commands ~/.claude/commands -name '*.md' 2>/dev/null
grep -rlE '\$[0-9]|\$ARGUMENTS' .claude/commands # which ones take arguments
grep -rlE '^allowed-tools:|^model:' .claude/commands # frontmatter to carry over
Subdirectories in commands/ only labelled the command; the slash name was the file
stem. Two files with the same stem in different subdirectories need distinct skill names.
2. Dry-run the converter
python3 scripts/convert_commands.py --src .claude/commands --dest .claude/skills
python3 scripts/convert_commands.py --src ~/.claude/commands --dest ~/.claude/skills
It prints source -> target, decisions, and warnings, and writes nothing. It:
renames on stem collisions, drops unknown frontmatter keys, drafts a missing
description from the first line, sets disable-model-invocation: true when the body
mentions push/deploy/publish/apply/destroy/rm -rf, flags $1-style placeholders,
and refuses to touch an existing skill folder. Exit code 1 means warnings need a human.
Add --write to create the folders, --renumber to shift $1..$9 down by one, and
--json for a machine-readable plan.
3. Invocation control
| Command behavior | Frontmatter | Effect |
|---|---|---|
| Side effects: deploy, push, release, send messages, DB writes | disable-model-invocation: true | Only the user can run it; description is not loaded into context |
| Safe, useful when relevant (review checklist, explain module) | default (no key) | User and Claude can invoke |
| Background knowledge Claude should apply, not a user action | user-invocable: false | Hidden from the / menu; Claude loads it when relevant |
| Heavy or noisy work that should not fill the main context | add context: fork (optionally agent: Explore) | Runs in a subagent and returns a summary |
Default to disable-model-invocation: true when unsure: it reproduces exactly how the
old command behaved (user-typed only) and costs no context.
4. Descriptions
A command description only labelled a menu entry. A skill description decides automatic loading, so for model-invocable skills write: what it does with concrete nouns, then “Use when …” with phrases the user types. Keep the key use case first; listing text is truncated around 1,536 characters. For user-only skills, one clear line is enough.
5. Arguments, files, and dynamic context
Substitutions available in skills:
| Placeholder | Meaning |
|---|---|
$ARGUMENTS | Whole argument string |
$ARGUMENTS[0], $0 | First argument (0-based); $1 is the second |
$name | Named argument declared in arguments: [issue, branch], mapped by position |
${CLAUDE_SKILL_DIR} | The skill’s folder, for bundled scripts and templates |
\$ | Literal dollar sign |
- Indexing is 0-based. A command file written when
$1meant the first argument now reads the second one. Test with two distinct values (/deploy alpha beta) and check which one lands; rerun the converter with--renumberif needed. - Prefer named
arguments:for anything with two or more parameters; they survive reordering and read clearly. - If no placeholder is present, arguments are appended as
ARGUMENTS: <value>. !`cmd`lines and```!blocks run before the content reaches Claude. A failing command aborts the whole invocation: append|| trueto commands that may exit non-zero, and pre-approve them inallowed-toolsor the invocation can abort on a permission prompt.- Move shell pipelines from the body into
scripts/and call them as${CLAUDE_SKILL_DIR}/scripts/x.sh; pre-approve withallowed-tools: Bash(${CLAUDE_SKILL_DIR}/scripts/x.sh *). - Move templates and long checklists into
references/<topic>.mdand link them from SKILL.md with when to read them.
Example result:
---
name: fix-issue
description: Fixes a GitHub issue end to end from its number, reproducing it first and opening a PR. Use when invoked as /fix-issue.
argument-hint: [issue-number] [base-branch]
arguments: [issue, base]
disable-model-invocation: true
allowed-tools: Bash(gh issue view *) Bash(gh pr create *) Read Edit
---
Issue context: !`gh issue view $issue --json title,body,labels || true`
1. Reproduce the bug described above with a failing test.
2. Fix it on a branch from `$base`; follow [references/pr-template.md](references/pr-template.md).
6. Test each skill
- Restart Claude Code or open
/skillsand confirm the skill appears with the expected source; the old command should no longer be listed separately. - Invoke with real arguments; compare the rendered behavior with the old command.
- For model-invocable skills, send two natural requests that should trigger it and one
that should not. If triggering is off, tune with
skill-description-tuning. - For skills with
disable-model-invocation: true, ask for the task in plain words and confirm Claude does not run it on its own.
7. Clean up
Delete the old files only after step 6, in the same commit as the new skills, so history shows a rename-like change. Search docs, READMEs, and CLAUDE.md for the old names.
Gotchas
- Keys only Claude Code understands (
disable-model-invocation,context,arguments,hooks) are rejected by claude.ai uploads and the Skills API. For skills you also ship there, keep onlyname,description,license,compatibility,metadata,allowed-tools. - Skill names: lowercase, digits, hyphens; must match the folder; never contain
claudeoranthropic; do not name a foldersyncedoranthropic-skills. - A personal skill overrides a project skill of the same name. If a teammate reports
different behavior, check
~/.claude/skills/for a stale copy. allowed-toolsgrants approval only during the invoking turn; it is not a sandbox. Do not broaden it during migration (Bash(*)is never a fair translation).- Making a side-effecting command model-invocable is a behavior change. Call it out to the user and get explicit approval.
Output
**Migrated (5):** /deploy, /release-notes, /fix-issue, /review-checklist, /explain
| Skill | Invocation | Args | Supporting files |
|---|---|---|---|
| deploy | user only | $env | scripts/deploy.sh |
| review-checklist | user + Claude | — | references/checklist.md |
**Renamed:** frontend/component.md -> /frontend-component (stem collision)
**Arg fix:** /deploy used $1 for the first arg; renumbered to $0, verified with `/deploy alpha beta`
**Removed:** .claude/commands/*.md (same commit) · **Docs updated:** CLAUDE.md, README Bundled files
Loaded only when a step in the skill needs them. Review them here before installing.
scripts/convert_commands.py
#!/usr/bin/env python3
"""Plan or perform migration of .claude/commands/**/*.md into .claude/skills/<name>/SKILL.md.
Default is a dry run that prints a migration table and warnings. Nothing is written
unless --write is given. Existing skill folders are never overwritten.
Usage:
python3 convert_commands.py [--src .claude/commands] [--dest .claude/skills]
[--write] [--renumber] [--json]
--renumber shift 1-based positional placeholders ($1..$9) down by one ($0..$8),
for command files written when $1 meant the first argument.
Exit codes: 0 ok, 1 problems found that need a human decision (collisions, existing
skills, missing descriptions), 2 usage error.
"""
import argparse
import json
import re
import sys
from pathlib import Path
KNOWN_KEYS = {
"description", "argument-hint", "allowed-tools", "model", "disable-model-invocation",
"user-invocable", "arguments", "when_to_use", "effort", "context", "agent",
"disallowed-tools", "hooks", "shell",
}
SIDE_EFFECTS = re.compile(
r"\b(git\s+push|git\s+commit|deploy|publish|release|npm\s+publish|kubectl\s+(apply|delete)|"
r"terraform\s+(apply|destroy)|rm\s+-rf|gh\s+pr\s+(create|merge)|gh\s+release|drop\s+table)\b",
re.I,
)
POSITIONAL = re.compile(r"(?<![\\\w])\$([1-9])(?!\d)")
NAME_OK = re.compile(r"^[a-z0-9]+(-[a-z0-9]+)*$")
def split_frontmatter(text: str):
if text.startswith("---\n"):
end = text.find("\n---", 4)
if end != -1:
return text[4:end], text[end + 4:].lstrip("\n")
return "", text
def parse_simple_yaml(block: str) -> dict:
"""Parse flat 'key: value' frontmatter; keeps unknown structure as raw lines."""
data, current = {}, None
for line in block.splitlines():
if not line.strip():
continue
m = re.match(r"^([A-Za-z_][\w-]*):\s*(.*)$", line)
if m and not line.startswith((" ", "\t")):
current = m.group(1)
data[current] = m.group(2)
elif current:
data[current] += "\n" + line
return data
def slugify(s: str) -> str:
return re.sub(r"-+", "-", re.sub(r"[^a-z0-9]+", "-", s.lower())).strip("-")
def first_sentence(body: str) -> str:
for line in body.splitlines():
t = line.strip().lstrip("#").strip()
if t and not t.startswith(("!", "```", "@")):
return t[:200]
return ""
def plan(src: Path, dest: Path, renumber: bool):
files = sorted(p for p in src.rglob("*.md") if p.is_file())
stems = {}
for p in files:
stems.setdefault(slugify(p.stem), []).append(p)
items = []
for p in files:
rel = p.relative_to(src)
base = slugify(p.stem)
collision = len(stems[base]) > 1
name = slugify("-".join(rel.with_suffix("").parts)) if collision else base
fm_raw, body = split_frontmatter(p.read_text(encoding="utf-8"))
fm = parse_simple_yaml(fm_raw)
warnings, decisions = [], []
if collision:
warnings.append(f"name collision on '{base}': renamed to /{name} (slash command changes)")
if not NAME_OK.match(name) or "claude" in name or "anthropic" in name:
warnings.append(f"name '{name}' is not portable; choose another")
unknown = sorted(set(fm) - KNOWN_KEYS)
if unknown:
warnings.append(f"unknown frontmatter keys dropped: {', '.join(unknown)}")
for k in unknown:
fm.pop(k)
if "name" in fm:
fm.pop("name")
if not fm.get("description", "").strip():
guess = first_sentence(body)
fm["description"] = guess or "TODO: describe what this does and when to use it"
warnings.append("no description: drafted from first line, rewrite it with trigger words")
if "disable-model-invocation" not in fm:
if SIDE_EFFECTS.search(body):
fm["disable-model-invocation"] = "true"
decisions.append("side effects detected: set disable-model-invocation: true")
else:
decisions.append("no side effects detected: Claude may auto-invoke; confirm this is wanted")
positions = sorted({int(m.group(1)) for m in POSITIONAL.finditer(body)})
if positions:
if renumber:
body = POSITIONAL.sub(lambda m: f"${int(m.group(1)) - 1}", body)
decisions.append(f"renumbered positional args {positions} -> {[n - 1 for n in positions]}")
else:
warnings.append(
f"uses positional ${'/$'.join(map(str, positions))}: current indexing is 0-based "
"($0 = first). Verify, or rerun with --renumber"
)
if "$ARGUMENTS" not in body and not positions and fm.get("argument-hint"):
decisions.append("argument-hint set but body has no placeholder: args will be appended as 'ARGUMENTS: ...'")
target = dest / name / "SKILL.md"
if target.parent.exists():
warnings.append(f"{target.parent} already exists: skipped (a skill already shadows this command)")
items.append({
"source": str(p), "skill": name, "target": str(target),
"frontmatter": fm, "body": body, "warnings": warnings, "decisions": decisions,
})
return items
def render(item) -> str:
fm = item["frontmatter"]
lines = ["---", f"name: {item['skill']}"]
for k in ["description", "when_to_use", "argument-hint", "arguments", "disable-model-invocation",
"user-invocable", "allowed-tools", "disallowed-tools", "model", "effort", "context",
"agent", "shell", "hooks"]:
if k in fm:
lines.append(f"{k}: {fm[k]}")
lines.append("---")
return "\n".join(lines) + "\n\n" + item["body"].rstrip() + "\n"
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--src", default=".claude/commands")
ap.add_argument("--dest", default=".claude/skills")
ap.add_argument("--write", action="store_true")
ap.add_argument("--renumber", action="store_true")
ap.add_argument("--json", action="store_true")
a = ap.parse_args()
src, dest = Path(a.src), Path(a.dest)
if not src.is_dir():
print(f"error: {src} is not a directory", file=sys.stderr)
return 2
items = plan(src, dest, a.renumber)
if a.json:
print(json.dumps([{k: v for k, v in i.items() if k != "body"} for i in items], indent=2))
else:
print(f"{len(items)} command file(s) in {src}\n")
for i in items:
print(f"- {i['source']} -> {i['target']}")
for d in i["decisions"]:
print(f" decision: {d}")
for w in i["warnings"]:
print(f" WARNING: {w}")
written = 0
if a.write:
for i in items:
t = Path(i["target"])
if t.parent.exists():
continue
t.parent.mkdir(parents=True)
t.write_text(render(i), encoding="utf-8")
written += 1
print(f"\nwrote {written} skill(s). Old command files were NOT deleted; remove them after testing.")
else:
print("\ndry run: nothing written (pass --write to create skills)")
needs_human = any(i["warnings"] for i in items)
return 1 if needs_human else 0
if __name__ == "__main__":
sys.exit(main())
About this skill
Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.