From 7b69228d23e85065cfa8a4d46c4404fcc49ef3d1 Mon Sep 17 00:00:00 2001 From: Nick Shirokov Date: Sat, 28 Feb 2026 19:05:12 +0300 Subject: [PATCH] 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 --- .gitignore | 7 + README.md | 30 ++- scripts/switch-to-powershell.py | 1 + scripts/switch-to-python.py | 1 + scripts/switch.py | 392 ++++++++++++++++++++++++++++++++ 5 files changed, 427 insertions(+), 4 deletions(-) create mode 100644 scripts/switch.py diff --git a/.gitignore b/.gitignore index 7076fc4e..568b801c 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,10 @@ __pycache__/ # Скриншоты (артефакты тестирования web-test) *.png + +# Навыки, скопированные для других AI-платформ (генерируются scripts/switch.py) +.codex/ +.cursor/skills/ +.github/skills/ +.gemini/ +.opencode/ diff --git a/README.md b/README.md index 7673fe1b..9be046a6 100644 --- a/README.md +++ b/README.md @@ -44,13 +44,35 @@ - **1С:Предприятие 8.3** — для сборки/разборки EPF/ERF (навыки генерации XML работают без платформы) - **Node.js 18+** — для `/web-test` (тестирование через браузер) -### Кроссплатформенный режим (Python) +### Другие AI-платформы -На Windows рекомендуется использовать PS1-рантайм (по умолчанию) как более стабильный и протестированный. Python-порты — для **Linux/Mac** или если PowerShell недоступен. PS1-скрипты — мастер-версия; Python-порты производные (см. [Python Porting Guide](docs/python-porting-guide.md)). +Навыки совместимы с Cursor, GitHub Copilot, OpenAI Codex, Gemini CLI и OpenCode. Скрипт `switch.py` копирует навыки в нужный формат с перезаписью путей: ```bash -python scripts/switch-to-python.py # переключить на Python -python scripts/switch-to-powershell.py # вернуть на PowerShell +python scripts/switch.py # интерактивный режим (пошаговый диалог) +python scripts/switch.py cursor # скопировать навыки для Cursor +python scripts/switch.py cursor --runtime python # Cursor + Python-рантайм +python scripts/switch.py --undo cursor # удалить копию +``` + +Поддерживаемые платформы: + +| Платформа | Целевой каталог | Слеш-команды | +|-----------|----------------|--------------| +| Claude Code | `.claude/skills/` | `/epf-init`, `/mxl-compile`, ... | +| Cursor | `.cursor/skills/` | через меню команд | +| GitHub Copilot | `.github/skills/` | `/skills` в чате | +| OpenAI Codex | `.codex/skills/` | `$skill-name` | +| Gemini CLI | `.gemini/skills/` | автоактивация | +| OpenCode | `.opencode/skills/` | через skill tool | + +### Переключение рантайма (PowerShell ↔ Python) + +На Windows рекомендуется PS1-рантайм (по умолчанию). Python-порты — для **Linux/Mac** или если PowerShell недоступен. PS1-скрипты — мастер-версия; Python-порты производные (см. [Python Porting Guide](docs/python-porting-guide.md)). + +```bash +python scripts/switch.py --runtime python # переключить на Python +python scripts/switch.py --runtime powershell # вернуть на PowerShell ``` Дополнительные зависимости Python-рантайма: diff --git a/scripts/switch-to-powershell.py b/scripts/switch-to-powershell.py index 1a20fbb9..9c9a2984 100644 --- a/scripts/switch-to-powershell.py +++ b/scripts/switch-to-powershell.py @@ -5,6 +5,7 @@ import os, re, glob, sys def main(): + print("Совет: используйте 'python scripts/switch.py --runtime powershell' (новый интерфейс)\n") repo_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) skills_dir = os.path.join(repo_root, '.claude', 'skills') diff --git a/scripts/switch-to-python.py b/scripts/switch-to-python.py index 9f547291..40310ea1 100644 --- a/scripts/switch-to-python.py +++ b/scripts/switch-to-python.py @@ -5,6 +5,7 @@ import os, re, glob, sys def main(): + print("Совет: используйте 'python scripts/switch.py --runtime python' (новый интерфейс)\n") repo_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) skills_dir = os.path.join(repo_root, '.claude', 'skills') diff --git a/scripts/switch.py b/scripts/switch.py new file mode 100644 index 00000000..14e8ba26 --- /dev/null +++ b/scripts/switch.py @@ -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)