diff --git a/.dockerignore b/.dockerignore index 35d8188..f313f51 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,5 +1,8 @@ * !Dockerfile -!entrypoint.sh -!git-export.py -!convert-gemini.auth.ts +!skills.yaml +!scripts/ +!scripts/entrypoint.sh +!scripts/git-export.py +!scripts/convert-gemini.auth.ts +!scripts/install-skills.ts diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index 81513ae..038f153 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -32,6 +32,8 @@ jobs: with: files: | Dockerfile + skills.yaml + scripts/** - name: Setup toolchain with mise uses: jdx/mise-action@v2 @@ -39,7 +41,7 @@ jobs: - name: Run prek hooks run: prek run --all-files --show-diff-on-failure --color=always - - name: Build Docker image if Dockerfile changed + - name: Build Docker image if Docker assets changed if: steps.changed-files.outputs.any_changed == 'true' run: | docker build -t opencode-cli-pr:latest . diff --git a/Dockerfile b/Dockerfile index 574b557..b718e2d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -81,6 +81,7 @@ FOE ARG OPENCODE_VERSION=latest ARG AZURE_FOUNDRY_PROVIDER_VERSION=0.2.0 ARG ENGRAM_VERSION=latest +ARG OPENCODE_BUILD_DIR=/usr/local/share/opencode-build ENV OPENCODE_CONFIG_DIR=/etc/opencode ENV OPENCODE_EXPERIMENTAL=1 @@ -88,9 +89,6 @@ ENV ENGRAM_DATA_DIR=/home/bun/.local/share/opencode/engram ENV AGENT_BROWSER_ENGINE=lightpanda -# hadolint ignore=DL3045 -COPY git-export.py git-export.py - # hadolint ignore=DL3003,SC2164 RUN <<'FOE' @@ -178,30 +176,20 @@ chown -Rh bun:bun "$(echo ~bun)" FOE -RUN <<'FOE' - source /etc/bash.bashrc - - skills_dir="${OPENCODE_CONFIG_DIR}/skills" - mkdir -p "${skills_dir}" - - skill_name="humanizer" - mkdir -p "${skills_dir}/${skill_name}" - curl -L 'https://raw.githubusercontent.com/blader/humanizer/refs/heads/main/SKILL.md' -o "${skills_dir}/${skill_name}/SKILL.md" +# hadolint ignore=DL3045 +COPY scripts "${OPENCODE_BUILD_DIR}/scripts" +COPY skills.yaml "${OPENCODE_BUILD_DIR}/skills.yaml" - uv pip install --system "aleph-rlm[mcp]" - skill_name="aleph" - mkdir -p "${skills_dir}/${skill_name}" - curl -L 'https://raw.githubusercontent.com/Hmbown/aleph/refs/heads/main/docs/prompts/aleph.md' -o "${skills_dir}/${skill_name}/SKILL.md" +RUN <<'FOE' +source /etc/bash.bashrc - skill_name="changelog" - python git-export.py "https://github.com/sickn33/antigravity-awesome-skills/skills/changelog-automation" "${skills_dir}/${skill_name}" --force +BUN_INSTALL=/tmp/bun bun install --cwd "${OPENCODE_BUILD_DIR}/scripts" yaml || exit 1 +bun "${OPENCODE_BUILD_DIR}/scripts/install-skills.ts" || exit 1 - skill_name="agent-browser" - python git-export.py "https://github.com/vercel-labs/agent-browser/tree/main/skills/${skill_name}" "${skills_dir}/${skill_name}" --force +rm -rf "${OPENCODE_BUILD_DIR}" - rm -f git-export.py - cat >"${OPENCODE_CONFIG_DIR}/opencode.json" <<-'EOF' +cat >"${OPENCODE_CONFIG_DIR}/opencode.json" <<-'EOF' { "$schema": "https://opencode.ai/config.json", "plugin": [ @@ -260,8 +248,8 @@ EOF FOE -COPY --chmod=0555 entrypoint.sh /entrypoint.sh -COPY --chmod=0555 convert-gemini.auth.ts /usr/local/bin/convert-gemini.auth.ts +COPY --chmod=0555 scripts/entrypoint.sh /entrypoint.sh +COPY --chmod=0555 scripts/convert-gemini.auth.ts /usr/local/bin/convert-gemini.auth.ts USER bun:bun diff --git a/convert-gemini.auth.ts b/scripts/convert-gemini.auth.ts similarity index 100% rename from convert-gemini.auth.ts rename to scripts/convert-gemini.auth.ts diff --git a/entrypoint.sh b/scripts/entrypoint.sh similarity index 100% rename from entrypoint.sh rename to scripts/entrypoint.sh diff --git a/git-export.py b/scripts/git-export.py similarity index 67% rename from git-export.py rename to scripts/git-export.py index 96c1c45..c957208 100644 --- a/git-export.py +++ b/scripts/git-export.py @@ -31,13 +31,21 @@ 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: +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) + 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() @@ -65,14 +73,16 @@ def parse_github_directory_url(url: str) -> tuple[str, str, str | None]: - 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"): + 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) < 3: raise GitExportError( - "GitHub URL must include a directory path after owner/repo " - f"(got: {url})" + f"GitHub URL must include a directory path after owner/repo (got: {url})" ) owner = parts[0] @@ -87,8 +97,7 @@ def parse_github_directory_url(url: str) -> tuple[str, str, str | None]: if rest[0] in ("tree", "blob"): if len(rest) < 3: raise GitExportError( - "tree/blob URLs must include ref and directory path, " - f"got: {url}" + f"tree/blob URLs must include ref and directory path, got: {url}" ) ref = rest[1] source = "/".join(rest[2:]) @@ -171,8 +180,18 @@ def export_directory( 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) - run_git(git_bin, ["sparse-checkout", "set", "--", source_path], cwd=clone_dir, verbose=verbose) + run_git( + git_bin, + ["sparse-checkout", "init", "--cone"], + cwd=clone_dir, + verbose=verbose, + ) + run_git( + git_bin, + ["sparse-checkout", "set", "--", source_path], + 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") @@ -184,7 +203,12 @@ def export_directory( cwd=clone_dir, verbose=verbose, ) - run_git(git_bin, ["checkout", "--detach", "FETCH_HEAD"], 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") @@ -215,114 +239,68 @@ def export_directory( copy_entry(child, output_dir / child.name) info(f"Step 6/6 complete in {time.perf_counter() - step_start:.1f}s") - # Explicitly ensure .git is never left in output. info("Finalizing export (removing .git if present)") shutil.rmtree(output_dir / ".git", ignore_errors=True) - info(f"All done in {time.perf_counter() - start_total:.1f}s") + info(f"Export complete in {time.perf_counter() - start_total:.1f}s") -def main() -> int: +def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( - description=( - "Export one directory from a huge Git repository using treeless + sparse clone." - ) - ) - parser.add_argument( - "input", - help=( - "Either a repository URL (legacy mode) or a full GitHub directory URL, " - "e.g. https://github.com/apache/avro/lang/ruby" - ), + 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( - "arg2", - help=( - "In URL mode: destination output directory. " - "In legacy mode: source directory path." - ), + "--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( - "arg3", - nargs="?", - help="Legacy mode only: destination output directory.", - ) - parser.add_argument( - "--source", - help=( - "Source directory path when using 2-arg mode with a repository URL input." - ), - ) - parser.add_argument( - "--ref", - "-r", - help="Branch/tag/ref to export (default: repository default branch)", - ) - parser.add_argument( - "--depth", - type=int, - default=1, - help="Fetch depth for clone/fetch (default: 1)", - ) - parser.add_argument( - "--force", - "-f", - action="store_true", - help="Overwrite output directory if it already exists", - ) - parser.add_argument( - "--git-bin", - default="git", - help="Git binary path/name (default: git)", - ) - parser.add_argument( - "--verbose", - "-v", - action="store_true", - help="Print git commands while running", + "--force", action="store_true", help="Overwrite output if it exists" ) + parser.add_argument("--verbose", action="store_true", help="Print git commands") + return parser - args = parser.parse_args() - if args.depth < 1: - print("Error: --depth must be >= 1", file=sys.stderr) - return 2 +def main(argv: list[str]) -> int: + parser = build_parser() + args = parser.parse_args(argv) + + output_dir = Path(args.output) try: - parsed_ref: str | None = None - if args.arg3 is not None: - # Legacy mode: repo source output - repo_url = args.input - source_path = args.arg2 - output_path = args.arg3 + if args.source.startswith("https://github.com/"): + repo_url, source_path, inferred_ref = parse_github_directory_url( + args.source + ) + ref = args.ref if args.ref is not None else inferred_ref else: - # URL mode: input output - output_path = args.arg2 - if args.source: - repo_url = args.input - source_path = args.source - else: - repo_url, source_path, parsed_ref = parse_github_directory_url(args.input) + 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=Path(output_path), - ref=args.ref or parsed_ref, + output_dir=output_dir, + ref=ref, depth=args.depth, force=args.force, - git_bin=args.git_bin, + git_bin=args.git, verbose=args.verbose, ) + return 0 except GitExportError as e: print(f"Error: {e}", file=sys.stderr) - return 1 - except FileNotFoundError as e: - print(f"Error: unable to execute git binary '{args.git_bin}': {e}", file=sys.stderr) - return 1 - - print(f"Export complete: {Path(output_path).resolve()}") - return 0 + return 2 if __name__ == "__main__": - raise SystemExit(main()) + raise SystemExit(main(sys.argv[1:])) diff --git a/scripts/install-skills.ts b/scripts/install-skills.ts new file mode 100644 index 0000000..1eca8c9 --- /dev/null +++ b/scripts/install-skills.ts @@ -0,0 +1,100 @@ +#!/usr/bin/env bun +import { mkdir } from "node:fs/promises" +import path from "node:path" +import YAML from "yaml" + +type Step = + | { type: "download"; url: string; dest: string } + | { type: "git-export"; url: string } + | { type: "uv-pip"; packages: string[] } + +type Skill = { + name: string + steps: Step[] +} + +type Manifest = { + skills: Skill[] +} + +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") + +function fail(msg: string): never { + throw new Error(`[install-skills] ${msg}`) +} + +function safe(base: string, dest: string) { + const file = path.resolve(base, dest) + const rel = path.relative(base, file) + if (rel.startsWith("..") || path.isAbsolute(rel)) fail(`invalid destination path: ${dest}`) + return file +} + +async function ensure(dir: string) { + await mkdir(dir, { recursive: true }) +} + +async function shell(args: string[]) { + const proc = Bun.spawn(args, { + stdout: "inherit", + stderr: "inherit", + stdin: "inherit", + }) + const code = await proc.exited + if (code !== 0) fail(`command failed (${code}): ${args.join(" ")}`) +} + +async function run(name: string, step: Step, dir: string) { + if (step.type === "download") { + await ensure(dir) + const res = await fetch(step.url) + if (!res.ok) fail(`${name}: failed download ${step.url} (${res.status})`) + await Bun.write(safe(dir, step.dest), await res.text()) + return + } + + if (step.type === "git-export") { + await ensure(dir) + await shell(["python", gitExport, step.url, dir, "--force"]) + return + } + + if (step.type === "uv-pip") { + for (const pkg of step.packages) { + await shell(["uv", "pip", "install", "--system", pkg]) + } + return + } + + fail(`${name}: unsupported step type`) +} + +function parse(input: string): Manifest { + const parsed = YAML.parse(input) as Manifest + if (!parsed || !Array.isArray(parsed.skills)) fail(`invalid manifest at ${manifestPath}`) + return parsed +} + +async function main() { + const manifest = parse(await Bun.file(manifestPath).text()) + await ensure(root) + + for (const skill of manifest.skills) { + if (!skill.name) fail(`skill missing name`) + if (!Array.isArray(skill.steps)) fail(`${skill.name}: missing steps`) + const dir = path.join(root, skill.name) + for (const step of skill.steps) { + await run(skill.name, step, dir) + } + } +} + +await main().catch((err) => { + const msg = err instanceof Error ? err.message : String(err) + console.error(msg) + process.exit(1) +}) diff --git a/skills.yaml b/skills.yaml new file mode 100644 index 0000000..9b87c5e --- /dev/null +++ b/skills.yaml @@ -0,0 +1,25 @@ +skills: + - name: humanizer + steps: + - type: download + url: https://raw.githubusercontent.com/blader/humanizer/refs/heads/main/SKILL.md + dest: SKILL.md + + - name: aleph + steps: + - type: uv-pip + packages: + - aleph-rlm[mcp] + - type: download + url: https://raw.githubusercontent.com/Hmbown/aleph/refs/heads/main/docs/prompts/aleph.md + dest: SKILL.md + + - name: changelog + steps: + - type: git-export + url: https://github.com/sickn33/antigravity-awesome-skills/skills/changelog-automation + + - name: agent-browser + steps: + - type: git-export + url: https://github.com/vercel-labs/agent-browser/tree/main/skills/agent-browser