feat: add unified switch.py for cross-platform skill porting

Single script to copy skills between AI platforms (Cursor, Codex,
Copilot, Gemini CLI, OpenCode) with path rewriting and optional
runtime switching. Includes interactive mode for newcomers.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Nick Shirokov
2026-02-28 19:05:12 +03:00
parent da0cedf256
commit 7b69228d23
5 changed files with 427 additions and 4 deletions
+392
View File
@@ -0,0 +1,392 @@
#!/usr/bin/env python3
# switch.py v1.0 — Переключение навыков 1С между AI-платформами и рантаймами
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
"""
Копирует навыки из .claude/skills/ на другие AI-платформы (Cursor, Codex, Copilot,
Gemini CLI, OpenCode) с перезаписью путей, и/или переключает рантайм (PowerShell ↔ Python).
Использование:
python scripts/switch.py # интерактивный режим
python scripts/switch.py cursor # скопировать на Cursor
python scripts/switch.py cursor --runtime python # скопировать + Python
python scripts/switch.py --undo cursor # удалить копию
python scripts/switch.py --runtime python # сменить runtime in-place
"""
import argparse
import glob
import os
import re
import shutil
import sys
# ---------------------------------------------------------------------------
# Platform registry
# ---------------------------------------------------------------------------
PLATFORMS = {
'claude-code': '.claude/skills',
'codex': '.codex/skills',
'cursor': '.cursor/skills',
'copilot': '.github/skills',
'gemini': '.gemini/skills',
'opencode': '.opencode/skills',
}
SOURCE_PREFIX = '.claude/skills'
# ---------------------------------------------------------------------------
# Runtime regex patterns (from switch-to-python.py / switch-to-powershell.py)
# ---------------------------------------------------------------------------
RX_PS = re.compile(r'powershell\.exe\s+(?:-NoProfile\s+)?-File\s+(.+?)\.ps1')
RX_PY = re.compile(r"python\s+('?[\w./_-]+?)\.py")
def repo_root():
"""Return the repository root (parent of scripts/)."""
return os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
def source_skills_dir():
return os.path.join(repo_root(), '.claude', 'skills')
def scan_skills(skills_dir):
"""Return sorted list of skill directory names that contain SKILL.md."""
result = []
for entry in sorted(os.listdir(skills_dir)):
skill_path = os.path.join(skills_dir, entry)
if os.path.isdir(skill_path) and os.path.isfile(os.path.join(skill_path, 'SKILL.md')):
result.append(entry)
return result
def collect_md_files(skill_dir):
"""Return list of .md files in a skill directory."""
return sorted(glob.glob(os.path.join(skill_dir, '*.md')))
# ---------------------------------------------------------------------------
# Transformations
# ---------------------------------------------------------------------------
def rewrite_paths(content, source_prefix, target_prefix):
"""Replace .claude/skills/ path prefix with target platform prefix."""
return content.replace(source_prefix + '/', target_prefix + '/')
def switch_runtime_content(content, target_runtime):
"""Switch runtime invocations in .md content. Returns (new_content, switched)."""
if target_runtime == 'python':
new = RX_PS.sub(r'python \1.py', content)
elif target_runtime == 'powershell':
new = RX_PY.sub(r'powershell.exe -NoProfile -File \1.ps1', content)
else:
return content, False
return new, new != content
def check_runtime_files(skills_dir, target_runtime, root):
"""Check that target runtime script files exist. Returns list of warnings."""
warnings = []
for skill_name in scan_skills(skills_dir):
for md_path in collect_md_files(os.path.join(skills_dir, skill_name)):
with open(md_path, 'r', encoding='utf-8') as f:
content = f.read()
if target_runtime == 'python':
matches = RX_PS.findall(content)
for m in matches:
py_path = m.lstrip("'") + '.py'
if not os.path.isfile(os.path.join(root, py_path)):
warnings.append(f" {py_path} не найден")
elif target_runtime == 'powershell':
matches = RX_PY.findall(content)
for m in matches:
ps1_path = m.lstrip("'") + '.ps1'
if not os.path.isfile(os.path.join(root, ps1_path)):
warnings.append(f" {ps1_path} не найден")
return warnings
# ---------------------------------------------------------------------------
# Commands
# ---------------------------------------------------------------------------
def cmd_install(platform, runtime, project_dir):
"""Copy skills to target platform directory with path rewriting."""
src_dir = source_skills_dir()
target_prefix = PLATFORMS[platform]
target_dir = os.path.join(project_dir, target_prefix.replace('/', os.sep))
skills = scan_skills(src_dir)
if not skills:
print(f"Ошибка: навыки не найдены в {src_dir}", file=sys.stderr)
return 1
if os.path.isdir(target_dir):
existing = scan_skills(target_dir)
if existing:
print(f"В {target_prefix}/ уже есть {len(existing)} навыков. Обновляю...")
shutil.rmtree(target_dir)
os.makedirs(target_dir, exist_ok=True)
installed = 0
warnings = []
print(f"\nКопирование {len(skills)} навыков в {target_prefix}/ ...")
for skill_name in skills:
src_skill = os.path.join(src_dir, skill_name)
dst_skill = os.path.join(target_dir, skill_name)
# Copy entire skill directory
shutil.copytree(src_skill, dst_skill)
# Rewrite paths in all .md files
for md_path in collect_md_files(dst_skill):
with open(md_path, 'r', encoding='utf-8') as f:
content = f.read()
new_content = rewrite_paths(content, SOURCE_PREFIX, target_prefix)
# Apply runtime switch if requested
if runtime == 'python':
new_content, _ = switch_runtime_content(new_content, 'python')
# Check .py files exist in source repo
for m in RX_PS.findall(content):
clean = m.lstrip("'").replace(SOURCE_PREFIX, target_prefix)
original_py = m.lstrip("'") + '.py'
if not os.path.isfile(os.path.join(repo_root(), original_py)):
warnings.append(f" {original_py} не найден ({skill_name})")
if new_content != content:
with open(md_path, 'w', encoding='utf-8') as f:
f.write(new_content)
print(f" [OK] {skill_name}")
installed += 1
print(f"\nГотово! {installed} навыков установлено в {target_prefix}/")
if warnings:
print("\nПредупреждения (отсутствующие .py файлы):")
for w in warnings:
print(w)
print(f"\nДля удаления: python scripts/switch.py --undo {platform}")
return 0
def cmd_undo(platform, project_dir):
"""Remove installed skills for a platform."""
target_prefix = PLATFORMS[platform]
target_dir = os.path.join(project_dir, target_prefix.replace('/', os.sep))
if not os.path.isdir(target_dir):
print(f"Директория {target_prefix}/ не найдена — нечего удалять.")
return 0
skills = scan_skills(target_dir)
shutil.rmtree(target_dir)
# Clean up empty parent directories
parent = os.path.dirname(target_dir)
if os.path.isdir(parent) and not os.listdir(parent):
os.rmdir(parent)
print(f"Удалено: {target_prefix}/ ({len(skills)} навыков)")
return 0
def cmd_switch_runtime(runtime, project_dir):
"""Switch runtime in-place for skills in the current project."""
# Find skills directory: try all known platform dirs
skills_dir = None
platform_name = None
for name, prefix in PLATFORMS.items():
candidate = os.path.join(project_dir, prefix.replace('/', os.sep))
if os.path.isdir(candidate) and scan_skills(candidate):
skills_dir = candidate
platform_name = name
break
if not skills_dir:
print("Ошибка: не найдена директория навыков в текущем каталоге.", file=sys.stderr)
return 1
skills = scan_skills(skills_dir)
switched = 0
warnings = []
print(f"\nПереключение на {runtime} в {PLATFORMS[platform_name]}/ ...")
for skill_name in skills:
skill_path = os.path.join(skills_dir, skill_name)
for md_path in collect_md_files(skill_path):
with open(md_path, 'r', encoding='utf-8') as f:
content = f.read()
new_content, changed = switch_runtime_content(content, runtime)
# Check target files exist
if runtime == 'python':
for m in RX_PS.findall(content):
py_path = m.lstrip("'") + '.py'
full = os.path.join(repo_root(), py_path)
if not os.path.isfile(full):
md_name = os.path.basename(md_path)
warnings.append(f" {py_path} не найден ({skill_name}/{md_name})")
elif runtime == 'powershell':
for m in RX_PY.findall(content):
ps1_path = m.lstrip("'") + '.ps1'
full = os.path.join(repo_root(), ps1_path)
if not os.path.isfile(full):
md_name = os.path.basename(md_path)
warnings.append(f" {ps1_path} не найден ({skill_name}/{md_name})")
if changed:
with open(md_path, 'w', encoding='utf-8') as f:
f.write(new_content)
md_name = os.path.basename(md_path)
print(f" [OK] {skill_name}/{md_name}")
switched += 1
print(f"\nПереключено {switched} файлов на {runtime}.")
if warnings:
print(f"\nПредупреждения (отсутствующие файлы):")
for w in warnings:
print(w)
return 0
# ---------------------------------------------------------------------------
# Interactive mode
# ---------------------------------------------------------------------------
def ask_choice(prompt, options, default=1):
"""Ask user to choose from numbered options. Returns 1-based index."""
print(f"\n{prompt}")
for i, (label, hint) in enumerate(options, 1):
marker = "*" if i == default else " "
print(f" {marker}{i}. {label:<16} ({hint})")
while True:
try:
raw = input(f"\nВыбор [{default}]: ").strip()
if not raw:
return default
val = int(raw)
if 1 <= val <= len(options):
return val
print(f" Введите число от 1 до {len(options)}")
except ValueError:
print(f" Введите число от 1 до {len(options)}")
except (EOFError, KeyboardInterrupt):
print("\nОтмена.")
sys.exit(0)
def interactive_mode():
"""Run interactive setup wizard."""
print("Навыки 1С — настройка платформы")
print("=" * 31)
platform_options = [
("Claude Code", ".claude/skills/"),
("Cursor", ".cursor/skills/"),
("GitHub Copilot", ".github/skills/"),
("OpenAI Codex", ".codex/skills/"),
("Gemini CLI", ".gemini/skills/"),
("OpenCode", ".opencode/skills/"),
]
platform_keys = ['claude-code', 'cursor', 'copilot', 'codex', 'gemini', 'opencode']
choice = ask_choice("Для какой платформы настроить навыки?", platform_options)
platform = platform_keys[choice - 1]
# Check if already installed — offer update or remove
project_dir = os.getcwd()
target_prefix = PLATFORMS[platform]
target_dir = os.path.join(project_dir, target_prefix.replace('/', os.sep))
if platform != 'claude-code' and os.path.isdir(target_dir):
existing = scan_skills(target_dir)
if existing:
action_options = [
("Обновить", f"перезаписать {len(existing)} навыков"),
("Удалить", f"удалить {target_prefix}/"),
("Отмена", "ничего не делать"),
]
action = ask_choice(
f"В {target_prefix}/ уже есть {len(existing)} навыков.",
action_options
)
if action == 2:
return cmd_undo(platform, project_dir)
if action == 3:
print("Отмена.")
return 0
runtime_options = [
("PowerShell", "рекомендуется для Windows"),
("Python", "рекомендуется для Linux/Mac"),
]
rt_choice = ask_choice("Какой рантайм скриптов?", runtime_options)
runtime = 'powershell' if rt_choice == 1 else 'python'
if platform == 'claude-code':
return cmd_switch_runtime(runtime, project_dir)
else:
return cmd_install(platform, runtime, project_dir)
# ---------------------------------------------------------------------------
# CLI argument parsing
# ---------------------------------------------------------------------------
def main():
if len(sys.argv) == 1:
return interactive_mode()
parser = argparse.ArgumentParser(
description='Переключение навыков 1С между AI-платформами и рантаймами',
epilog='Примеры:\n'
' python scripts/switch.py cursor\n'
' python scripts/switch.py cursor --runtime python\n'
' python scripts/switch.py --undo cursor\n'
' python scripts/switch.py --runtime python\n',
formatter_class=argparse.RawDescriptionHelpFormatter
)
parser.add_argument('platform', nargs='?', choices=list(PLATFORMS.keys()),
help='целевая платформа')
parser.add_argument('--runtime', choices=['python', 'powershell'],
help='рантайм скриптов (python или powershell)')
parser.add_argument('--undo', action='store_true',
help='удалить навыки для указанной платформы')
parser.add_argument('--project-dir', default=os.getcwd(),
help='путь к целевому проекту (по умолчанию: текущий каталог)')
args = parser.parse_args()
# --undo requires platform
if args.undo:
if not args.platform:
parser.error("--undo требует указания платформы")
if args.platform == 'claude-code':
parser.error("--undo не применим к claude-code (это исходная платформа)")
return cmd_undo(args.platform, args.project_dir)
# --runtime without platform = in-place switch
if args.runtime and not args.platform:
return cmd_switch_runtime(args.runtime, args.project_dir)
# platform specified
if args.platform:
if args.platform == 'claude-code':
if args.runtime:
return cmd_switch_runtime(args.runtime, args.project_dir)
else:
parser.error("для claude-code укажите --runtime python или --runtime powershell")
runtime = args.runtime or 'powershell'
return cmd_install(args.platform, runtime, args.project_dir)
# No args at all — shouldn't reach here due to len(sys.argv)==1 check
return interactive_mode()
if __name__ == '__main__':
sys.exit(main() or 0)