#!/usr/bin/env python3 """NEXT installer: Python 3.9+, standard library only. Default: preview, no writes.""" import argparse import hashlib import io import json import re import stat import sys import urllib.parse import urllib.request import zipfile from pathlib import Path, PurePosixPath MAX_SIZE = 8 * 1024 * 1024 TARGETS = {'codex': '.agents/skills', 'claude': '.claude/skills', 'cursor': '.cursor/skills'} def safe_name(name): p = PurePosixPath(name) if not name or '\\' in name or p.is_absolute() or '..' in p.parts or ':' in name: raise ValueError('Niebezpieczna ścieżka w pakiecie: ' + name) return p def load_archive(data): if len(data) > MAX_SIZE: raise ValueError('Pakiet przekracza limit 8 MB.') files = {} total = 0 with zipfile.ZipFile(io.BytesIO(data)) as archive: entries = archive.infolist() if len(entries) > 300: raise ValueError('Zbyt wiele plików w pakiecie.') for info in entries: safe_name(info.filename) if stat.S_ISLNK(info.external_attr >> 16): raise ValueError('Pakiet nie może zawierać dowiązań.') if info.is_dir(): continue total += info.file_size if total > MAX_SIZE or info.filename in files: raise ValueError('Zbyt duży pakiet lub powtórzona ścieżka.') files[info.filename] = archive.read(info) return files def load_source(directory): base = Path(directory).resolve(strict=True) files = {} for path in base.rglob('*'): if path.is_symlink(): raise ValueError('Źródło nie może zawierać dowiązań.') if path.is_file() and (path.name == 'SKILL.md' or 'references' in path.parts or 'agents' in path.parts): name = 'skills/' + path.relative_to(base).as_posix() safe_name(name) files[name] = path.read_bytes() if sum(map(len, files.values())) > MAX_SIZE or len(files) > 300: raise ValueError('Źródło przekracza dopuszczalny rozmiar.') return files def validate(files, require_manifest): if require_manifest: raw = files.get('manifest.json') if raw is None: raise ValueError('Brak manifestu pakietu NEXT.') manifest = json.loads(raw) if manifest.get('format') != 'next-starter-skills' or manifest.get('version') != '1.0.0': raise ValueError('Nieobsługiwany format lub wersja pakietu.') hashes = manifest.get('files', {}) if not isinstance(hashes, dict) or set(hashes) != set(files) - {'manifest.json'}: raise ValueError('Lista plików nie zgadza się z manifestem.') for name, digest in hashes.items(): if hashlib.sha256(files[name]).hexdigest() != digest: raise ValueError('Błędna suma kontrolna: ' + name) selected = {} skills = set() for name, data in files.items(): parts = safe_name(name).parts if parts[0] != 'skills': continue if len(parts) < 3 or not re.fullmatch(r'next-[a-z0-9-]+', parts[1]): raise ValueError('Nieprawidłowa nazwa skilla: ' + name) rest = '/'.join(parts[2:]) if rest != 'SKILL.md' and not (rest.startswith('references/') and rest.endswith('.md')) and rest != 'agents/openai.yaml': raise ValueError('Niedozwolony plik w skillu: ' + name) text = data.decode('utf-8') if rest == 'SKILL.md': if not text.startswith('---\n') or '\n---\n' not in text[4:]: raise ValueError('Brak nagłówka skilla: ' + name) front = text.split('---', 2)[1] if not re.search(r'^name: ' + re.escape(parts[1]) + r'\s*$', front, re.M) or not re.search(r'^description: .+', front, re.M): raise ValueError('Błędna definicja skilla: ' + name) skills.add(parts[1]) selected['/'.join(parts[1:])] = data if not skills or any(name.split('/')[0] not in skills for name in selected): raise ValueError('Niekompletny zestaw skilli.') return selected, skills def reject_symlinks(path): for parent in [path, *path.parents]: if parent.is_symlink(): raise ValueError('Nie zapisujemy przez dowiązanie: ' + str(parent)) def install(files, agent, project, apply=False): project = Path(project).absolute() reject_symlinks(project) if not project.is_dir(): raise ValueError('Folder projektu nie istnieje. Najpierw wybierz lub utwórz folder.') target = project / TARGETS[agent] destinations = [] for name, data in sorted(files.items()): dest = target / name reject_symlinks(dest) if dest.exists(): if not dest.is_file() or dest.read_bytes() != data: raise ValueError('Konflikt. Zachowano istniejący plik: ' + str(dest)) continue destinations.append((dest, data)) print(('Instalacja' if apply else 'Podgląd bez zapisu') + ': ' + str(target)) print('Nowe pliki: ' + str(len(destinations)) + '. Identyczne pliki są pomijane.') if apply: written = [] try: for dest, data in destinations: dest.parent.mkdir(parents=True, exist_ok=True) reject_symlinks(dest) # Exclusive create prevents overwriting a file created since preflight. with dest.open('xb') as stream: written.append(dest) stream.write(data) except Exception: for dest in reversed(written): dest.unlink(missing_ok=True) raise print('Gotowe. Rozpocznij nową rozmowę i sprawdź listę skilli w swoim agencie.') else: print('Przejrzyj pakiet; dodaj --apply, aby zapisać go w wybranym projekcie.') def main(): parser = argparse.ArgumentParser(description=__doc__) source = parser.add_mutually_exclusive_group(required=True) source.add_argument('--url', help='HTTPS URL oficjalnego pakietu ZIP NEXT') source.add_argument('--archive', help='Pobrany lokalnie ZIP') source.add_argument('--source-dir', help='Katalog skills w sklonowanym repo') parser.add_argument('--agent', choices=TARGETS, required=True) parser.add_argument('--project', required=True, help='Istniejący folder docelowego projektu') parser.add_argument('--apply', action='store_true', help='Zapisz pliki; bez tego tylko podgląd') args = parser.parse_args() try: if args.source_dir: files = load_source(args.source_dir) elif args.archive: files = load_archive(Path(args.archive).read_bytes()) else: parsed = urllib.parse.urlparse(args.url) if parsed.scheme != 'https' or parsed.username or parsed.password: raise ValueError('Adres pakietu musi używać HTTPS, bez danych logowania.') req = urllib.request.Request(args.url, headers={'User-Agent': 'NEXT-Skills-Installer/1.0'}) with urllib.request.urlopen(req, timeout=30) as response: if urllib.parse.urlparse(response.url).scheme != 'https': raise ValueError('Przekierowanie na adres bez HTTPS.') files = load_archive(response.read(MAX_SIZE + 1)) selected, skills = validate(files, require_manifest=not args.source_dir) print('Znaleziono skille: ' + ', '.join(sorted(skills))) install(selected, args.agent, args.project, args.apply) except (ValueError, OSError, zipfile.BadZipFile, UnicodeError, KeyError) as error: print('Nie zainstalowano pakietu: ' + str(error), file=sys.stderr) return 1 return 0 if __name__ == '__main__': sys.exit(main())