mirror of
https://github.com/Nikolay-Shirokov/cc-1c-skills.git
synced 2026-06-10 16:14:54 +03:00
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:
@@ -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')
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user