From 193e3f05a9f6b9651d0bae3f46955f27db26e755 Mon Sep 17 00:00:00 2001 From: Thomas Meckel Date: Wed, 18 Mar 2026 23:08:16 +0000 Subject: [PATCH 1/4] fix(scripts): support exporting from repo root URLs Allow GitHub owner/repo URLs to be parsed without requiring a directory path and treat missing paths as repository root during export. Skip copying the .git directory when exporting from root and keep path normalization compatible with optional source paths. --- scripts/git-export.py | 52 +++++++++++++++++++++++++++---------------- 1 file changed, 33 insertions(+), 19 deletions(-) diff --git a/scripts/git-export.py b/scripts/git-export.py index c957208..90f0ef3 100644 --- a/scripts/git-export.py +++ b/scripts/git-export.py @@ -63,9 +63,9 @@ def normalize_source_path(path: str) -> str: return "/".join(parts) -def parse_github_directory_url(url: str) -> tuple[str, str, str | None]: +def parse_github_directory_url(url: str) -> tuple[str, str | None, str | None]: """ - Parse a GitHub directory URL into (repo_url, source_path, ref). + Parse a GitHub URL into (repo_url, source_path, ref). Supported examples: - https://github.com/org/repo/lang/ruby @@ -80,10 +80,8 @@ def parse_github_directory_url(url: str) -> tuple[str, str, str | None]: raise GitExportError(f"Not a supported GitHub URL: {url}") parts = [p for p in parsed.path.split("/") if p] - if len(parts) < 3: - raise GitExportError( - f"GitHub URL must include a directory path after owner/repo (got: {url})" - ) + if len(parts) < 2: + raise GitExportError(f"GitHub URL must include owner/repo (got: {url})") owner = parts[0] repo = parts[1] @@ -92,9 +90,11 @@ def parse_github_directory_url(url: str) -> tuple[str, str, str | None]: rest = parts[2:] ref: str | None = None - source: str + source: str | None = None - if rest[0] in ("tree", "blob"): + if not rest: + source = None + elif rest[0] in ("tree", "blob"): if len(rest) < 3: raise GitExportError( f"tree/blob URLs must include ref and directory path, got: {url}" @@ -105,7 +105,8 @@ def parse_github_directory_url(url: str) -> tuple[str, str, str | None]: source = "/".join(rest) repo_url = f"https://github.com/{owner}/{repo}.git" - return repo_url, normalize_source_path(source), ref + normalized_source = normalize_source_path(source) if source is not None else None + return repo_url, normalized_source, ref def prepare_output_dir(output_dir: Path, force: bool) -> None: @@ -148,11 +149,11 @@ def export_directory( verbose: bool, ) -> None: start_total = time.perf_counter() - source_path = normalize_source_path(source_path) + source_path = source_path.strip("/") output_dir = output_dir.resolve() info(f"Repository: {repo_url}") - info(f"Source path: {source_path}") + info(f"Source path: {source_path or '(repo root)'}") info(f"Ref: {ref or 'default branch'}") info(f"Output: {output_dir}") @@ -186,12 +187,20 @@ def export_directory( cwd=clone_dir, verbose=verbose, ) - run_git( - git_bin, - ["sparse-checkout", "set", "--", source_path], - cwd=clone_dir, - verbose=verbose, - ) + if source_path: + run_git( + git_bin, + ["sparse-checkout", "set", "--", source_path], + cwd=clone_dir, + verbose=verbose, + ) + else: + run_git( + git_bin, + ["sparse-checkout", "disable"], + cwd=clone_dir, + verbose=verbose, + ) info(f"Step 2/6 complete in {time.perf_counter() - step_start:.1f}s") info("Step 3/6: checking out requested ref/path") @@ -214,10 +223,10 @@ def export_directory( info(f"Step 3/6 complete in {time.perf_counter() - step_start:.1f}s") info("Step 4/6: validating source directory") - source_dir = clone_dir / source_path + source_dir = clone_dir if not source_path else clone_dir / source_path if not source_dir.exists() or not source_dir.is_dir(): raise GitExportError( - f"Source directory not found after checkout: {source_path}\n" + f"Source directory not found after checkout: {source_path or '.'}\n" f"Repository: {repo_url}\n" f"Ref: {ref or 'default branch'}" ) @@ -235,6 +244,9 @@ def export_directory( if total_children == 0: info("Source directory is empty") for idx, child in enumerate(children, start=1): + if child.name == ".git": + info(f" - [{idx}/{total_children}] {child.name} (skipped)") + continue info(f" - [{idx}/{total_children}] {child.name}") copy_entry(child, output_dir / child.name) info(f"Step 6/6 complete in {time.perf_counter() - step_start:.1f}s") @@ -276,6 +288,8 @@ def main(argv: list[str]) -> int: repo_url, source_path, inferred_ref = parse_github_directory_url( args.source ) + if source_path is None: + source_path = normalize_source_path(args.path) if args.path else "" ref = args.ref if args.ref is not None else inferred_ref else: if not args.path: From 0fc3f293d32b143adf7b96df6aae1e542537f570 Mon Sep 17 00:00:00 2001 From: Thomas Meckel Date: Wed, 18 Mar 2026 23:08:28 +0000 Subject: [PATCH 2/4] feat(config): replace humanizer with stop-slop skill --- skills.yaml | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/skills.yaml b/skills.yaml index 9b87c5e..a641b5b 100644 --- a/skills.yaml +++ b/skills.yaml @@ -1,9 +1,8 @@ skills: - - name: humanizer + - name: stop-slop steps: - - type: download - url: https://raw.githubusercontent.com/blader/humanizer/refs/heads/main/SKILL.md - dest: SKILL.md + - type: git-export + url: https://github.com/hardikpandya/stop-slop - name: aleph steps: From fb7c07dc9c3196592397f1abdb81d5acb2d9dc41 Mon Sep 17 00:00:00 2001 From: Thomas Meckel Date: Wed, 18 Mar 2026 23:08:40 +0000 Subject: [PATCH 3/4] docs(readme): update tooling list and skill name --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 62b8118..b07325c 100644 --- a/README.md +++ b/README.md @@ -331,7 +331,7 @@ opencodec ## Included Tooling and Skills -The image currently installs or bundles the following pieces during build: +The build bundles these tools and skills: - `opencode-ai` - `mise` @@ -347,7 +347,7 @@ The image currently installs or bundles the following pieces during build: - `git` - `sudo`, `curl`, `gpg`, `make` - Azure Foundry provider build output -- OpenCode skills for `humanizer`, `aleph`, and changelog automation +- OpenCode skills for `stop-slop`, `aleph`, and changelog automation The repository also includes `git-export.py`, a helper script that exports a single directory from a GitHub repository using a treeless, sparse clone workflow. From ca31bcd01b30abcf5754707add0695b611f98a01 Mon Sep 17 00:00:00 2001 From: Thomas Meckel Date: Wed, 18 Mar 2026 23:19:22 +0000 Subject: [PATCH 4/4] refactor(scripts): migrate git-export helper from Python to bun replace the python git-export script with a bun-based TypeScript implementation and update skill installation to invoke it via bun. Update docker ignore rules and README references to point to git-export.ts for consistency across tooling and docs --- .dockerignore | 2 +- README.md | 4 +- scripts/git-export.py | 320 --------------------------- scripts/git-export.ts | 443 ++++++++++++++++++++++++++++++++++++++ scripts/install-skills.ts | 4 +- 5 files changed, 448 insertions(+), 325 deletions(-) delete mode 100644 scripts/git-export.py create mode 100644 scripts/git-export.ts diff --git a/.dockerignore b/.dockerignore index f313f51..42326d8 100644 --- a/.dockerignore +++ b/.dockerignore @@ -3,6 +3,6 @@ !skills.yaml !scripts/ !scripts/entrypoint.sh -!scripts/git-export.py +!scripts/git-export.ts !scripts/convert-gemini.auth.ts !scripts/install-skills.ts diff --git a/README.md b/README.md index b07325c..2019ee5 100644 --- a/README.md +++ b/README.md @@ -349,7 +349,7 @@ The build bundles these tools and skills: - Azure Foundry provider build output - OpenCode skills for `stop-slop`, `aleph`, and changelog automation -The repository also includes `git-export.py`, a helper script that exports a single directory from +The repository also includes `git-export.ts`, a Bun helper script that exports a single directory from a GitHub repository using a treeless, sparse clone workflow. ## Release Model @@ -364,7 +364,7 @@ This repo publishes container images to GitHub Container Registry from version t - `Dockerfile`: Builds the OpenCode container image and installs providers, tools, and skills - `entrypoint.sh`: Loads shell environment and starts `opencode` -- `git-export.py`: Sparse GitHub directory export helper +- `git-export.ts`: Sparse GitHub directory export helper - `Makefile`: Convenience targets for local image build and cleanup - `.github/workflows/`: PR validation, release sync, and registry publishing workflows - `.mise.toml`: Local tool definitions for linting and validation utilities diff --git a/scripts/git-export.py b/scripts/git-export.py deleted file mode 100644 index 90f0ef3..0000000 --- a/scripts/git-export.py +++ /dev/null @@ -1,320 +0,0 @@ -#!/usr/bin/env python3 -""" -Export a directory from a Git repository using treeless + sparse clone. - -Workflow: -1. Clone repository with --filter=tree:0 --sparse --no-checkout. -2. Configure sparse-checkout for the requested directory. -3. Checkout the requested ref (or default branch). -4. Copy only the requested directory contents to output. -5. Always remove .git from the output. -""" - -from __future__ import annotations - -import argparse -import os -import shutil -import subprocess -import sys -import tempfile -import time -import urllib.parse -from pathlib import Path - - -class GitExportError(RuntimeError): - """Domain error for export failures.""" - - -def info(message: str) -> None: - print(f"[git-export] {message}", flush=True) - - -def run_git( - git_bin: str, args: list[str], cwd: Path | None = None, verbose: bool = False -) -> None: - cmd = [git_bin, *args] - if verbose: - location = str(cwd) if cwd else os.getcwd() - print(f"+ (cwd={location}) {' '.join(cmd)}") - try: - subprocess.run( - cmd, - cwd=str(cwd) if cwd else None, - check=True, - text=True, - capture_output=True, - ) - except subprocess.CalledProcessError as e: - stderr = (e.stderr or "").strip() - stdout = (e.stdout or "").strip() - detail = stderr or stdout or str(e) - raise GitExportError(f"git command failed: {' '.join(cmd)}\n{detail}") from e - - -def normalize_source_path(path: str) -> str: - source = path.strip().strip("/") - if not source: - raise GitExportError("Source path must not be empty") - parts = [p for p in source.split("/") if p not in ("", ".")] - if any(part == ".." for part in parts): - raise GitExportError("Source path must not contain '..'") - return "/".join(parts) - - -def parse_github_directory_url(url: str) -> tuple[str, str | None, str | None]: - """ - Parse a GitHub URL into (repo_url, source_path, ref). - - Supported examples: - - https://github.com/org/repo/lang/ruby - - https://github.com/org/repo/tree/main/lang/ruby - - https://github.com/org/repo/blob/main/lang/ruby - """ - parsed = urllib.parse.urlparse(url) - if parsed.scheme not in ("http", "https") or parsed.netloc not in ( - "github.com", - "www.github.com", - ): - raise GitExportError(f"Not a supported GitHub URL: {url}") - - parts = [p for p in parsed.path.split("/") if p] - if len(parts) < 2: - raise GitExportError(f"GitHub URL must include owner/repo (got: {url})") - - owner = parts[0] - repo = parts[1] - if repo.endswith(".git"): - repo = repo[:-4] - - rest = parts[2:] - ref: str | None = None - source: str | None = None - - if not rest: - source = None - elif rest[0] in ("tree", "blob"): - if len(rest) < 3: - raise GitExportError( - f"tree/blob URLs must include ref and directory path, got: {url}" - ) - ref = rest[1] - source = "/".join(rest[2:]) - else: - source = "/".join(rest) - - repo_url = f"https://github.com/{owner}/{repo}.git" - normalized_source = normalize_source_path(source) if source is not None else None - return repo_url, normalized_source, ref - - -def prepare_output_dir(output_dir: Path, force: bool) -> None: - if output_dir.exists(): - if not force: - raise GitExportError( - f"Output path already exists: {output_dir} (use --force to overwrite)" - ) - if output_dir.is_file() or output_dir.is_symlink(): - output_dir.unlink() - else: - shutil.rmtree(output_dir) - output_dir.mkdir(parents=True, exist_ok=True) - - -def copy_entry(src: Path, dst: Path) -> None: - if src.is_symlink(): - target = os.readlink(src) - if dst.exists() or dst.is_symlink(): - if dst.is_dir() and not dst.is_symlink(): - shutil.rmtree(dst) - else: - dst.unlink() - os.symlink(target, dst) - return - if src.is_dir(): - shutil.copytree(src, dst, symlinks=True, dirs_exist_ok=True) - return - shutil.copy2(src, dst, follow_symlinks=False) - - -def export_directory( - repo_url: str, - source_path: str, - output_dir: Path, - ref: str | None, - depth: int, - force: bool, - git_bin: str, - verbose: bool, -) -> None: - start_total = time.perf_counter() - source_path = source_path.strip("/") - output_dir = output_dir.resolve() - - info(f"Repository: {repo_url}") - info(f"Source path: {source_path or '(repo root)'}") - info(f"Ref: {ref or 'default branch'}") - info(f"Output: {output_dir}") - - with tempfile.TemporaryDirectory(prefix="git-export-") as temp_dir: - work_dir = Path(temp_dir) - clone_dir = work_dir / "repo" - - info("Step 1/6: cloning repository (treeless + sparse, no checkout)") - step_start = time.perf_counter() - run_git( - git_bin, - [ - "clone", - "--depth", - str(depth), - "--filter=tree:0", - "--sparse", - "--no-checkout", - repo_url, - str(clone_dir), - ], - verbose=verbose, - ) - info(f"Step 1/6 complete in {time.perf_counter() - step_start:.1f}s") - - info("Step 2/6: configuring sparse checkout") - step_start = time.perf_counter() - run_git( - git_bin, - ["sparse-checkout", "init", "--cone"], - cwd=clone_dir, - verbose=verbose, - ) - if source_path: - run_git( - git_bin, - ["sparse-checkout", "set", "--", source_path], - cwd=clone_dir, - verbose=verbose, - ) - else: - run_git( - git_bin, - ["sparse-checkout", "disable"], - cwd=clone_dir, - verbose=verbose, - ) - info(f"Step 2/6 complete in {time.perf_counter() - step_start:.1f}s") - - info("Step 3/6: checking out requested ref/path") - step_start = time.perf_counter() - if ref: - run_git( - git_bin, - ["fetch", "--depth", str(depth), "origin", ref], - cwd=clone_dir, - verbose=verbose, - ) - run_git( - git_bin, - ["checkout", "--detach", "FETCH_HEAD"], - cwd=clone_dir, - verbose=verbose, - ) - else: - run_git(git_bin, ["checkout"], cwd=clone_dir, verbose=verbose) - info(f"Step 3/6 complete in {time.perf_counter() - step_start:.1f}s") - - info("Step 4/6: validating source directory") - source_dir = clone_dir if not source_path else clone_dir / source_path - if not source_dir.exists() or not source_dir.is_dir(): - raise GitExportError( - f"Source directory not found after checkout: {source_path or '.'}\n" - f"Repository: {repo_url}\n" - f"Ref: {ref or 'default branch'}" - ) - info("Step 4/6 complete") - - info("Step 5/6: preparing output directory") - step_start = time.perf_counter() - prepare_output_dir(output_dir, force=force) - info(f"Step 5/6 complete in {time.perf_counter() - step_start:.1f}s") - - info("Step 6/6: copying exported files") - step_start = time.perf_counter() - children = list(source_dir.iterdir()) - total_children = len(children) - if total_children == 0: - info("Source directory is empty") - for idx, child in enumerate(children, start=1): - if child.name == ".git": - info(f" - [{idx}/{total_children}] {child.name} (skipped)") - continue - info(f" - [{idx}/{total_children}] {child.name}") - copy_entry(child, output_dir / child.name) - info(f"Step 6/6 complete in {time.perf_counter() - step_start:.1f}s") - - info("Finalizing export (removing .git if present)") - shutil.rmtree(output_dir / ".git", ignore_errors=True) - info(f"Export complete in {time.perf_counter() - start_total:.1f}s") - - -def build_parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser( - description="Export a directory from a GitHub repository" - ) - parser.add_argument("source", help="GitHub directory URL or repo URL") - parser.add_argument("output", help="Output directory path") - parser.add_argument("--ref", default=None, help="Git ref to checkout") - parser.add_argument( - "--path", - default=None, - help="Directory path inside the repo (required for raw repo URLs)", - ) - parser.add_argument("--depth", type=int, default=1, help="Clone depth (default: 1)") - parser.add_argument("--git", default="git", help="Git binary to use (default: git)") - parser.add_argument( - "--force", action="store_true", help="Overwrite output if it exists" - ) - parser.add_argument("--verbose", action="store_true", help="Print git commands") - return parser - - -def main(argv: list[str]) -> int: - parser = build_parser() - args = parser.parse_args(argv) - - output_dir = Path(args.output) - - try: - if args.source.startswith("https://github.com/"): - repo_url, source_path, inferred_ref = parse_github_directory_url( - args.source - ) - if source_path is None: - source_path = normalize_source_path(args.path) if args.path else "" - ref = args.ref if args.ref is not None else inferred_ref - else: - if not args.path: - raise GitExportError( - "--path is required when source is not a GitHub directory URL" - ) - repo_url = args.source - source_path = normalize_source_path(args.path) - ref = args.ref - - export_directory( - repo_url=repo_url, - source_path=source_path, - output_dir=output_dir, - ref=ref, - depth=args.depth, - force=args.force, - git_bin=args.git, - verbose=args.verbose, - ) - return 0 - except GitExportError as e: - print(f"Error: {e}", file=sys.stderr) - return 2 - - -if __name__ == "__main__": - raise SystemExit(main(sys.argv[1:])) diff --git a/scripts/git-export.ts b/scripts/git-export.ts new file mode 100644 index 0000000..827c892 --- /dev/null +++ b/scripts/git-export.ts @@ -0,0 +1,443 @@ +#!/usr/bin/env bun +import { constants as fsConstants } from "node:fs" +import { + copyFile, + cp, + lstat, + mkdir, + mkdtemp, + readlink, + readdir, + realpath, + rm, + symlink, + unlink, +} from "node:fs/promises" +import os from "node:os" +import path from "node:path" + +class GitExportError extends Error { + constructor(message: string) { + super(message) + this.name = "GitExportError" + } +} + +type Options = { + source: string + output: string + ref: string | null + sourcePath: string | null + depth: number + gitBin: string + force: boolean + verbose: boolean +} + +type ParsedGithubUrl = { + repoUrl: string + sourcePath: string | null + ref: string | null +} + +function info(message: string) { + console.log(`[git-export] ${message}`) +} + +function fail(message: string): never { + throw new GitExportError(message) +} + +async function pathExists(target: string) { + try { + await lstat(target) + return true + } catch { + return false + } +} + +async function isDirectory(target: string) { + try { + const stat = await lstat(target) + return stat.isDirectory() && !stat.isSymbolicLink() + } catch { + return false + } +} + +function formatCommand(args: string[]) { + return args.map((arg) => (/[\s"']/u.test(arg) ? JSON.stringify(arg) : arg)).join(" ") +} + +async function runGit(gitBin: string, args: string[], cwd?: string, verbose = false) { + const cmd = [gitBin, ...args] + if (verbose) { + info(`+ (cwd=${cwd || process.cwd()}) ${formatCommand(cmd)}`) + } + + const proc = Bun.spawn(cmd, { + cwd, + stdout: "pipe", + stderr: "pipe", + }) + + const [stdoutText, stderrText, code] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited, + ]) + + if (code !== 0) { + const stderr = stderrText.trim() + const stdout = stdoutText.trim() + const detail = stderr || stdout || `exit code ${code}` + fail(`git command failed: ${formatCommand(cmd)}\n${detail}`) + } +} + +function normalizeSourcePath(value: string) { + const source = value.trim().replace(/^\/+|\/+$/g, "") + if (!source) fail("Source path must not be empty") + + const parts = source.split("/").filter((part) => part && part !== ".") + if (parts.some((part) => part === "..")) { + fail("Source path must not contain '..'") + } + + return parts.join("/") +} + +function parseGithubDirectoryUrl(rawUrl: string): ParsedGithubUrl { + let parsed: URL + try { + parsed = new URL(rawUrl) + } catch { + fail(`Not a supported GitHub URL: ${rawUrl}`) + } + + if (!["http:", "https:"].includes(parsed.protocol) || !["github.com", "www.github.com"].includes(parsed.hostname)) { + fail(`Not a supported GitHub URL: ${rawUrl}`) + } + + const parts = parsed.pathname.split("/").filter(Boolean) + if (parts.length < 2) { + fail(`GitHub URL must include owner/repo (got: ${rawUrl})`) + } + + const owner = parts[0] + const repo = parts[1].endsWith(".git") ? parts[1].slice(0, -4) : parts[1] + const rest = parts.slice(2) + + let ref: string | null = null + let sourcePath: string | null = null + + if (rest.length === 0) { + sourcePath = null + } else if (rest[0] === "tree" || rest[0] === "blob") { + if (rest.length < 3) { + fail(`tree/blob URLs must include ref and directory path, got: ${rawUrl}`) + } + ref = rest[1] + sourcePath = rest.slice(2).join("/") + } else { + sourcePath = rest.join("/") + } + + return { + repoUrl: `https://github.com/${owner}/${repo}.git`, + sourcePath: sourcePath === null ? null : normalizeSourcePath(sourcePath), + ref, + } +} + +async function prepareOutputDir(outputDir: string, force: boolean) { + if (await pathExists(outputDir)) { + if (!force) { + fail(`Output path already exists: ${outputDir} (use --force to overwrite)`) + } + + const stat = await lstat(outputDir) + if (stat.isFile() || stat.isSymbolicLink()) { + await unlink(outputDir) + } else { + await rm(outputDir, { recursive: true, force: true }) + } + } + + await mkdir(outputDir, { recursive: true }) +} + +async function removeExisting(dst: string) { + if (!(await pathExists(dst))) return + + const stat = await lstat(dst) + if (stat.isDirectory() && !stat.isSymbolicLink()) { + await rm(dst, { recursive: true, force: true }) + return + } + + await unlink(dst) +} + +async function copyEntry(src: string, dst: string) { + const stat = await lstat(src) + + if (stat.isSymbolicLink()) { + const target = await readlink(src) + await removeExisting(dst) + await symlink(target, dst) + return + } + + if (stat.isDirectory()) { + await cp(src, dst, { + recursive: true, + dereference: false, + force: true, + verbatimSymlinks: true, + }) + return + } + + await copyFile(src, dst, fsConstants.COPYFILE_FICLONE) +} + +async function exportDirectory(options: { + repoUrl: string + sourcePath: string + outputDir: string + ref: string | null + depth: number + force: boolean + gitBin: string + verbose: boolean +}) { + const start = performance.now() + const sourcePath = options.sourcePath.replace(/^\/+|\/+$/g, "") + const outputDir = await realpath(path.dirname(options.outputDir)).then( + (parent) => path.join(parent, path.basename(options.outputDir)), + () => path.resolve(options.outputDir), + ) + + info(`Repository: ${options.repoUrl}`) + info(`Source path: ${sourcePath || "(repo root)"}`) + info(`Ref: ${options.ref || "default branch"}`) + info(`Output: ${outputDir}`) + + const workDir = await mkdtemp(path.join(os.tmpdir(), "git-export-")) + const cloneDir = path.join(workDir, "repo") + + try { + info("Step 1/6: cloning repository (treeless + sparse, no checkout)") + let stepStart = performance.now() + await runGit(options.gitBin, [ + "clone", + "--depth", + String(options.depth), + "--filter=tree:0", + "--sparse", + "--no-checkout", + options.repoUrl, + cloneDir, + ], undefined, options.verbose) + info(`Step 1/6 complete in ${((performance.now() - stepStart) / 1000).toFixed(1)}s`) + + info("Step 2/6: configuring sparse checkout") + stepStart = performance.now() + await runGit(options.gitBin, ["sparse-checkout", "init", "--cone"], cloneDir, options.verbose) + if (sourcePath) { + await runGit(options.gitBin, ["sparse-checkout", "set", "--", sourcePath], cloneDir, options.verbose) + } else { + await runGit(options.gitBin, ["sparse-checkout", "disable"], cloneDir, options.verbose) + } + info(`Step 2/6 complete in ${((performance.now() - stepStart) / 1000).toFixed(1)}s`) + + info("Step 3/6: checking out requested ref/path") + stepStart = performance.now() + if (options.ref) { + await runGit(options.gitBin, ["fetch", "--depth", String(options.depth), "origin", options.ref], cloneDir, options.verbose) + await runGit(options.gitBin, ["checkout", "--detach", "FETCH_HEAD"], cloneDir, options.verbose) + } else { + await runGit(options.gitBin, ["checkout"], cloneDir, options.verbose) + } + info(`Step 3/6 complete in ${((performance.now() - stepStart) / 1000).toFixed(1)}s`) + + info("Step 4/6: validating source directory") + const sourceDir = sourcePath ? path.join(cloneDir, sourcePath) : cloneDir + if (!(await isDirectory(sourceDir))) { + fail( + `Source directory not found after checkout: ${sourcePath || "."}\nRepository: ${options.repoUrl}\nRef: ${options.ref || "default branch"}`, + ) + } + info("Step 4/6 complete") + + info("Step 5/6: preparing output directory") + stepStart = performance.now() + await prepareOutputDir(outputDir, options.force) + info(`Step 5/6 complete in ${((performance.now() - stepStart) / 1000).toFixed(1)}s`) + + info("Step 6/6: copying exported files") + stepStart = performance.now() + const children = await readdir(sourceDir) + const totalChildren = children.length + if (totalChildren === 0) { + info("Source directory is empty") + } + + for (const [index, name] of children.entries()) { + if (name === ".git") { + info(` - [${index + 1}/${totalChildren}] ${name} (skipped)`) + continue + } + + info(` - [${index + 1}/${totalChildren}] ${name}`) + await copyEntry(path.join(sourceDir, name), path.join(outputDir, name)) + } + info(`Step 6/6 complete in ${((performance.now() - stepStart) / 1000).toFixed(1)}s`) + } finally { + await rm(workDir, { recursive: true, force: true }) + } + + info("Finalizing export (removing .git if present)") + await rm(path.join(outputDir, ".git"), { recursive: true, force: true }) + info(`Export complete in ${((performance.now() - start) / 1000).toFixed(1)}s`) +} + +function parseArgs(argv: string[]): Options { + const positionals: string[] = [] + let ref: string | null = null + let sourcePath: string | null = null + let depth = 1 + let gitBin = "git" + let force = false + let verbose = false + + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i] + + if (arg === "--ref") { + ref = argv[++i] ?? fail("--ref requires a value") + continue + } + + if (arg === "--path") { + sourcePath = argv[++i] ?? fail("--path requires a value") + continue + } + + if (arg === "--depth") { + const value = argv[++i] ?? fail("--depth requires a value") + depth = Number.parseInt(value, 10) + if (!Number.isInteger(depth) || depth <= 0) { + fail(`Invalid --depth value: ${value}`) + } + continue + } + + if (arg === "--git") { + gitBin = argv[++i] ?? fail("--git requires a value") + continue + } + + if (arg === "--force") { + force = true + continue + } + + if (arg === "--verbose") { + verbose = true + continue + } + + if (arg === "-h" || arg === "--help") { + printHelp() + process.exit(0) + } + + if (arg.startsWith("-")) { + fail(`Unknown option: ${arg}`) + } + + positionals.push(arg) + } + + if (positionals.length !== 2) { + printHelp() + fail("Expected SOURCE and OUTPUT arguments") + } + + return { + source: positionals[0], + output: positionals[1], + ref, + sourcePath, + depth, + gitBin, + force, + verbose, + } +} + +function printHelp() { + console.log(`Usage: git-export.ts SOURCE OUTPUT [options] + +Export a directory from a GitHub repository. + +Options: + --ref Git ref to checkout + --path Directory path inside the repo (required for raw repo URLs) + --depth Clone depth (default: 1) + --git Git binary to use (default: git) + --force Overwrite output if it exists + --verbose Print git commands + -h, --help Show this help message`) +} + +async function main() { + const args = parseArgs(process.argv.slice(2)) + + if (args.source.startsWith("https://github.com/")) { + const parsed = parseGithubDirectoryUrl(args.source) + const sourcePath = parsed.sourcePath === null ? (args.sourcePath ? normalizeSourcePath(args.sourcePath) : "") : parsed.sourcePath + const ref = args.ref ?? parsed.ref + + await exportDirectory({ + repoUrl: parsed.repoUrl, + sourcePath, + outputDir: args.output, + ref, + depth: args.depth, + force: args.force, + gitBin: args.gitBin, + verbose: args.verbose, + }) + return + } + + if (!args.sourcePath) { + fail("--path is required when source is not a GitHub directory URL") + } + + await exportDirectory({ + repoUrl: args.source, + sourcePath: normalizeSourcePath(args.sourcePath), + outputDir: args.output, + ref: args.ref, + depth: args.depth, + force: args.force, + gitBin: args.gitBin, + verbose: args.verbose, + }) +} + +await main().catch((err) => { + const message = err instanceof Error ? err.message : String(err) + if (err instanceof GitExportError) { + console.error(`Error: ${message}`) + process.exit(2) + } + + console.error(`Error: ${message}`) + process.exit(1) +}) diff --git a/scripts/install-skills.ts b/scripts/install-skills.ts index 1eca8c9..82a08d5 100644 --- a/scripts/install-skills.ts +++ b/scripts/install-skills.ts @@ -21,7 +21,7 @@ const cfg = process.env.OPENCODE_CONFIG_DIR || "/etc/opencode" const root = path.join(cfg, "skills") const base = process.env.OPENCODE_BUILD_DIR || "/tmp" const manifestPath = process.env.SKILLS_MANIFEST || path.join(base, "skills.yaml") -const gitExport = process.env.GIT_EXPORT_SCRIPT || path.join(base, "scripts", "git-export.py") +const gitExport = process.env.GIT_EXPORT_SCRIPT || path.join(base, "scripts", "git-export.ts") function fail(msg: string): never { throw new Error(`[install-skills] ${msg}`) @@ -59,7 +59,7 @@ async function run(name: string, step: Step, dir: string) { if (step.type === "git-export") { await ensure(dir) - await shell(["python", gitExport, step.url, dir, "--force"]) + await shell(["bun", gitExport, step.url, dir, "--force"]) return }