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.

  • namecommand-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

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.

~/.claude/skills/command-to-skill-migration/SKILL.md

--- name: command-to-skill-migration description: Converts legacy Claude Code custom slash commands in .claude/commands/*.md and ~/.claude/commands into skills under… license: MIT ---

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:

NeedMigrate?
Supporting files (templates, checklists, scripts) next to the instructionsYes
Claude should pick it up automatically from a natural requestYes (description + default invocation)
Body is longer than ~150 lines and only parts are needed per runYes, split into references/
Share via a plugin or claude.ai skill uploadYes (plugins and uploads use skill folders)
Small, works, user-only, no filesOptional; 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 behaviorFrontmatterEffect
Side effects: deploy, push, release, send messages, DB writesdisable-model-invocation: trueOnly 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 actionuser-invocable: falseHidden from the / menu; Claude loads it when relevant
Heavy or noisy work that should not fill the main contextadd 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:

PlaceholderMeaning
$ARGUMENTSWhole argument string
$ARGUMENTS[0], $0First argument (0-based); $1 is the second
$nameNamed 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 $1 meant 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 --renumber if 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 || true to commands that may exit non-zero, and pre-approve them in allowed-tools or 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 with allowed-tools: Bash(${CLAUDE_SKILL_DIR}/scripts/x.sh *).
  • Move templates and long checklists into references/<topic>.md and 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

  1. Restart Claude Code or open /skills and confirm the skill appears with the expected source; the old command should no longer be listed separately.
  2. Invoke with real arguments; compare the rendered behavior with the old command.
  3. 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.
  4. 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 only name, description, license, compatibility, metadata, allowed-tools.
  • Skill names: lowercase, digits, hyphens; must match the folder; never contain claude or anthropic; do not name a folder synced or anthropic-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-tools grants 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 180 lines · 7.3 KB
#!/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.

esc