From e372a7d1be8e6287cc6549c579891fcb70604099 Mon Sep 17 00:00:00 2001 From: Thomas Meckel Date: Wed, 13 May 2026 13:26:22 +0000 Subject: [PATCH 1/6] feat(skills): add structured git-export step type Add support for a new git-export step variant that specifies repo, path, and ref_env instead of a raw url. Resolve the ref from the given environment variable and export a subtree from a temporary clone. Cache workspaces by repo and ref to avoid redundant clones when multiple skills reference the same repository. Perform shallow clones with tree filtering, checkout the resolved ref, and copy the requested path contents into the skill directory. Wrap the main installation loop in try/finally to ensure all temporary workspace directories are cleaned up after processing. Maintain backward compatibility with the existing url-based git-export format. --- scripts/install-skills.ts | 99 +++++++++++++++++++++++++++++++++++---- 1 file changed, 89 insertions(+), 10 deletions(-) diff --git a/scripts/install-skills.ts b/scripts/install-skills.ts index 82a08d5..fa540d0 100644 --- a/scripts/install-skills.ts +++ b/scripts/install-skills.ts @@ -1,13 +1,27 @@ #!/usr/bin/env bun -import { mkdir } from "node:fs/promises" +import { mkdir, mkdtemp, readdir, cp, rm } from "node:fs/promises" import path from "node:path" +import os from "node:os" import YAML from "yaml" type Step = | { type: "download"; url: string; dest: string } | { type: "git-export"; url: string } + | { type: "git-export"; repo: string; path: string; ref_env?: string } | { type: "uv-pip"; packages: string[] } +type GitExportSpec = Extract +type ResolvedGitExport = { + repo: string + ref: string + path: string +} +type Workspace = { + dir: string + cloneDir: string + ref: string +} + type Skill = { name: string steps: Step[] @@ -22,6 +36,7 @@ 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.ts") +const workspaces = new Map() function fail(msg: string): never { throw new Error(`[install-skills] ${msg}`) @@ -48,6 +63,59 @@ async function shell(args: string[]) { if (code !== 0) fail(`command failed (${code}): ${args.join(" ")}`) } +function isGitHubRepo(repo: string) { + return /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repo) +} + +function resolveRef(step: GitExportSpec): ResolvedGitExport { + if (!step.repo) fail(`git-export: missing repo`) + if (!step.path) fail(`git-export: missing path for ${step.repo}`) + if (!step.ref_env) fail(`git-export: missing ref_env for ${step.repo}/${step.path}`) + + const ref = process.env[step.ref_env] + if (!ref) fail(`git-export: missing environment variable ${step.ref_env}`) + if (!isGitHubRepo(step.repo)) fail(`git-export: unsupported repo format: ${step.repo}`) + return { repo: step.repo, path: step.path, ref } +} + +function workspaceKey(repo: string, ref: string) { + return `${repo}@${ref}` +} + +async function ensureWorkspace(repo: string, ref: string) { + const key = workspaceKey(repo, ref) + const existing = workspaces.get(key) + if (existing) return existing + + const dir = await mkdtemp(path.join(os.tmpdir(), "skills-export-")) + const cloneDir = path.join(dir, "repo") + const repoUrl = `https://github.com/${repo}.git` + await shell(["git", "clone", "--depth", "1", "--filter=tree:0", "--no-checkout", repoUrl, cloneDir]) + await shell(["git", "-C", cloneDir, "fetch", "--depth", "1", "origin", ref]) + await shell(["git", "-C", cloneDir, "checkout", "--detach", "FETCH_HEAD"]) + + const workspace: Workspace = { dir, cloneDir, ref } + workspaces.set(key, workspace) + return workspace +} + +async function copySubtreeFromWorkspace(workspace: Workspace, sourcePath: string, destDir: string) { + const sourceDir = sourcePath ? path.join(workspace.cloneDir, sourcePath) : workspace.cloneDir + const entries = await readdir(sourceDir) + await ensure(destDir) + for (const entry of entries) { + if (entry === ".git") continue + await cp(path.join(sourceDir, entry), path.join(destDir, entry), { recursive: true, force: true, verbatimSymlinks: true }) + } +} + +async function cleanupWorkspaces() { + for (const workspace of workspaces.values()) { + await rm(workspace.dir, { recursive: true, force: true }) + } + workspaces.clear() +} + async function run(name: string, step: Step, dir: string) { if (step.type === "download") { await ensure(dir) @@ -59,7 +127,14 @@ async function run(name: string, step: Step, dir: string) { if (step.type === "git-export") { await ensure(dir) - await shell(["bun", gitExport, step.url, dir, "--force"]) + if ("url" in step) { + await shell(["bun", gitExport, step.url, dir, "--force"]) + return + } + + const resolved = resolveRef(step) + const workspace = await ensureWorkspace(resolved.repo, resolved.ref) + await copySubtreeFromWorkspace(workspace, resolved.path, dir) return } @@ -74,22 +149,26 @@ async function run(name: string, step: Step, dir: string) { } function parse(input: string): Manifest { - const parsed = YAML.parse(input) as Manifest + const parsed = YAML.parse(input) as Partial | null if (!parsed || !Array.isArray(parsed.skills)) fail(`invalid manifest at ${manifestPath}`) - return parsed + return { skills: parsed.skills as Skill[] } } 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) + try { + 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) + } } + } finally { + await cleanupWorkspaces() } } From bd2630a7e32940e2bd5d911962902219a37934b8 Mon Sep 17 00:00:00 2001 From: Thomas Meckel Date: Wed, 13 May 2026 13:29:28 +0000 Subject: [PATCH 2/6] feat(skills): add caveman skill suite --- skills.yaml | 49 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/skills.yaml b/skills.yaml index a641b5b..c3695c8 100644 --- a/skills.yaml +++ b/skills.yaml @@ -22,3 +22,52 @@ skills: steps: - type: git-export url: https://github.com/vercel-labs/agent-browser/tree/main/skills/agent-browser + + - name: caveman + steps: + - type: git-export + repo: JuliusBrussee/caveman + path: skills/caveman + ref_env: CAVEMAN_RESOLVED_REF + + - name: caveman-commit + steps: + - type: git-export + repo: JuliusBrussee/caveman + path: skills/caveman-commit + ref_env: CAVEMAN_RESOLVED_REF + + - name: caveman-review + steps: + - type: git-export + repo: JuliusBrussee/caveman + path: skills/caveman-review + ref_env: CAVEMAN_RESOLVED_REF + + - name: caveman-help + steps: + - type: git-export + repo: JuliusBrussee/caveman + path: skills/caveman-help + ref_env: CAVEMAN_RESOLVED_REF + + - name: caveman-stats + steps: + - type: git-export + repo: JuliusBrussee/caveman + path: skills/caveman-stats + ref_env: CAVEMAN_RESOLVED_REF + + - name: caveman-compress + steps: + - type: git-export + repo: JuliusBrussee/caveman + path: skills/caveman-compress + ref_env: CAVEMAN_RESOLVED_REF + + - name: cavecrew + steps: + - type: git-export + repo: JuliusBrussee/caveman + path: skills/cavecrew + ref_env: CAVEMAN_RESOLVED_REF From caba3515d4a2b84689a92b5cff2134439f639aba Mon Sep 17 00:00:00 2001 From: Thomas Meckel Date: Wed, 13 May 2026 13:36:19 +0000 Subject: [PATCH 3/6] feat(docker): add caveman plugin integration Add caveman plugin to the Docker image with version resolution, file downloads, and opencode.json registration. Also fixes the npx symlink command and updates the Azure Foundry provider to v0.4.0. --- Dockerfile | 49 +++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 47 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index 36da768..4e3361a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -14,7 +14,7 @@ ENV PATH="${PATH}:${PYTHON_DIR}/bin" RUN <<'FOE' ln -s /usr/local/bin/bun /usr/local/bin/node -ls -s /usr/local/bin/bun /usr/local/bin/npx +ln -s /usr/local/bin/bun /usr/local/bin/npx apt-get update apt-get install \ @@ -79,7 +79,8 @@ EOF FOE ARG OPENCODE_VERSION=latest -ARG AZURE_FOUNDRY_PROVIDER_REF=v0.3.1 +ARG CAVEMAN_VERSION=latest +ARG AZURE_FOUNDRY_PROVIDER_REF=v0.4.0 ARG ENGRAM_VERSION=latest ARG OPENCODE_BUILD_DIR=/usr/local/share/opencode-build @@ -116,6 +117,10 @@ resolve_github_latest_version() { echo "${version}" } +resolve_caveman_version() { + resolve_github_latest_version "JuliusBrussee/caveman" "${CAVEMAN_VERSION}" +} + mkdir -p "${BUN_INSTALL}" "${OPENCODE_CONFIG_DIR}" "${OPENCODE_PLUGINS_DIR}" "${PROVIDER_DIR}" chmod 0777 "${OPENCODE_CONFIG_DIR}" @@ -185,6 +190,41 @@ curl -fsSL "https://raw.githubusercontent.com/rtk-ai/rtk/refs/heads/master/hooks # vercel/skills bun install -g --trust skills@latest +### +# caveman +# +caveman_resolved_version=$(resolve_caveman_version) || exit 1 +echo "CAVEMAN_RESOLVED_REF=${caveman_resolved_version}" +echo "${caveman_resolved_version}" > /tmp/caveman_version + +mkdir -p "${OPENCODE_CONFIG_DIR}/plugins/caveman" "${OPENCODE_CONFIG_DIR}/commands" + +CAVEMAN_RAW_BASE="https://raw.githubusercontent.com/JuliusBrussee/caveman/refs/tags/${caveman_resolved_version}" + +{ + curl -fsSL "${CAVEMAN_RAW_BASE}/src/plugins/opencode/package.json" -o "${OPENCODE_CONFIG_DIR}/plugins/caveman/package.json" + curl -fsSL "${CAVEMAN_RAW_BASE}/src/plugins/opencode/plugin.js" -o "${OPENCODE_CONFIG_DIR}/plugins/caveman/plugin.js" + curl -fsSL "${CAVEMAN_RAW_BASE}/src/hooks/caveman-config.js" -o "${OPENCODE_CONFIG_DIR}/plugins/caveman/caveman-config.cjs" + curl -fsSL "${CAVEMAN_RAW_BASE}/src/plugins/opencode/commands/caveman.md" -o "${OPENCODE_CONFIG_DIR}/commands/caveman.md" + curl -fsSL "${CAVEMAN_RAW_BASE}/src/plugins/opencode/commands/caveman-commit.md" -o "${OPENCODE_CONFIG_DIR}/commands/caveman-commit.md" + curl -fsSL "${CAVEMAN_RAW_BASE}/src/plugins/opencode/commands/caveman-review.md" -o "${OPENCODE_CONFIG_DIR}/commands/caveman-review.md" + curl -fsSL "${CAVEMAN_RAW_BASE}/src/plugins/opencode/commands/caveman-stats.md" -o "${OPENCODE_CONFIG_DIR}/commands/caveman-stats.md" + curl -fsSL "${CAVEMAN_RAW_BASE}/src/plugins/opencode/commands/caveman-help.md" -o "${OPENCODE_CONFIG_DIR}/commands/caveman-help.md" +} & wait || exit 1 + +cat > "${OPENCODE_CONFIG_DIR}/commands/caveman-compress.md" <<'EOF' +--- +description: Compress a Markdown memory file using the caveman-compress skill +--- +Compress the target file with `caveman-compress`. + +Input: `$ARGUMENTS` + +Run the installed `caveman-compress` skill workflow against the given file path. +Preserve code, URLs, paths, commands, and structure exactly as the skill requires. +Overwrite the original file only if the compression succeeds, and keep the `.original.md` backup. +EOF + ### # cleanup rm -rf /root/.bun @@ -199,6 +239,10 @@ COPY skills.yaml "${OPENCODE_BUILD_DIR}/skills.yaml" RUN <<'FOE' source /etc/bash.bashrc +CAVEMAN_RESOLVED_REF=$(cat /tmp/caveman_version) + +export CAVEMAN_RESOLVED_REF + BUN_INSTALL=/tmp/bun bun install --cwd "${OPENCODE_BUILD_DIR}/scripts" yaml || exit 1 bun "${OPENCODE_BUILD_DIR}/scripts/install-skills.ts" || exit 1 @@ -210,6 +254,7 @@ cat >"${OPENCODE_CONFIG_DIR}/opencode.json" <<-'EOF' "$schema": "https://opencode.ai/config.json", "plugin": [ "file:///usr/local/bun/install/global/node_modules/opencode-gemini-auth", + "file:///etc/opencode/plugins/caveman" ], "mcp": { "engram": { From 19e5ad96d876ab6bec03876232172806e108053c Mon Sep 17 00:00:00 2001 From: Thomas Meckel Date: Wed, 13 May 2026 15:36:25 +0000 Subject: [PATCH 4/6] feat(skills): add versioned git-export and atomic install Replace ref_env with optional version field defaulting to "latest". Resolve latest release tags via GitHub API. Perform atomic skill installs by staging to a temp directory before renaming into place. --- scripts/install-skills.ts | 83 ++++++++++++++++++++++++++++++--------- 1 file changed, 64 insertions(+), 19 deletions(-) diff --git a/scripts/install-skills.ts b/scripts/install-skills.ts index fa540d0..e8584a4 100644 --- a/scripts/install-skills.ts +++ b/scripts/install-skills.ts @@ -1,5 +1,5 @@ #!/usr/bin/env bun -import { mkdir, mkdtemp, readdir, cp, rm } from "node:fs/promises" +import { mkdir, mkdtemp, readdir, cp, rm, rename } from "node:fs/promises" import path from "node:path" import os from "node:os" import YAML from "yaml" @@ -7,19 +7,19 @@ import YAML from "yaml" type Step = | { type: "download"; url: string; dest: string } | { type: "git-export"; url: string } - | { type: "git-export"; repo: string; path: string; ref_env?: string } + | { type: "git-export"; repo: string; path: string; version?: string } | { type: "uv-pip"; packages: string[] } type GitExportSpec = Extract type ResolvedGitExport = { repo: string - ref: string + ref: string | null path: string } type Workspace = { dir: string cloneDir: string - ref: string + ref: string | null } type Skill = { @@ -42,6 +42,10 @@ function fail(msg: string): never { throw new Error(`[install-skills] ${msg}`) } +function info(msg: string) { + console.log(`[install-skills] ${msg}`) +} + function safe(base: string, dest: string) { const file = path.resolve(base, dest) const rel = path.relative(base, file) @@ -53,6 +57,11 @@ async function ensure(dir: string) { await mkdir(dir, { recursive: true }) } +async function resetDir(dir: string) { + await rm(dir, { recursive: true, force: true }) + await mkdir(dir, { recursive: true }) +} + async function shell(args: string[]) { const proc = Bun.spawn(args, { stdout: "inherit", @@ -67,22 +76,38 @@ function isGitHubRepo(repo: string) { return /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repo) } -function resolveRef(step: GitExportSpec): ResolvedGitExport { +async function resolveLatestReleaseTag(repo: string) { + const res = await fetch(`https://api.github.com/repos/${repo}/releases/latest`, { + headers: { + Accept: "application/vnd.github+json", + "User-Agent": "install-skills", + }, + }) + + if (res.status === 404) return null + if (!res.ok) fail(`git-export: failed latest release lookup for ${repo} (${res.status})`) + + const data = (await res.json()) as { tag_name?: unknown } + const tag = typeof data.tag_name === "string" && data.tag_name ? data.tag_name : null + if (tag) info(`git-export: resolved latest release for ${repo} -> ${tag}`) + return tag +} + +async function resolveRef(step: GitExportSpec): Promise { if (!step.repo) fail(`git-export: missing repo`) if (!step.path) fail(`git-export: missing path for ${step.repo}`) - if (!step.ref_env) fail(`git-export: missing ref_env for ${step.repo}/${step.path}`) - - const ref = process.env[step.ref_env] - if (!ref) fail(`git-export: missing environment variable ${step.ref_env}`) if (!isGitHubRepo(step.repo)) fail(`git-export: unsupported repo format: ${step.repo}`) + + const version = step.version ?? "latest" + const ref = version === "latest" ? await resolveLatestReleaseTag(step.repo) : version return { repo: step.repo, path: step.path, ref } } -function workspaceKey(repo: string, ref: string) { - return `${repo}@${ref}` +function workspaceKey(repo: string, ref: string | null) { + return `${repo}@${ref ?? "HEAD"}` } -async function ensureWorkspace(repo: string, ref: string) { +async function ensureWorkspace(repo: string, ref: string | null) { const key = workspaceKey(repo, ref) const existing = workspaces.get(key) if (existing) return existing @@ -91,8 +116,12 @@ async function ensureWorkspace(repo: string, ref: string) { const cloneDir = path.join(dir, "repo") const repoUrl = `https://github.com/${repo}.git` await shell(["git", "clone", "--depth", "1", "--filter=tree:0", "--no-checkout", repoUrl, cloneDir]) - await shell(["git", "-C", cloneDir, "fetch", "--depth", "1", "origin", ref]) - await shell(["git", "-C", cloneDir, "checkout", "--detach", "FETCH_HEAD"]) + if (ref) { + await shell(["git", "-C", cloneDir, "fetch", "--depth", "1", "origin", ref]) + await shell(["git", "-C", cloneDir, "checkout", "--detach", "FETCH_HEAD"]) + } else { + await shell(["git", "-C", cloneDir, "checkout"]) + } const workspace: Workspace = { dir, cloneDir, ref } workspaces.set(key, workspace) @@ -116,6 +145,25 @@ async function cleanupWorkspaces() { workspaces.clear() } +async function runSkill(name: string, steps: Step[]) { + const destDir = path.join(root, name) + const stageParent = await mkdtemp(path.join(os.tmpdir(), "skills-install-")) + const stageDir = path.join(stageParent, name) + + try { + await resetDir(stageDir) + for (const step of steps) { + await run(name, step, stageDir) + } + + await rm(destDir, { recursive: true, force: true }) + await ensure(path.dirname(destDir)) + await rename(stageDir, destDir) + } finally { + await rm(stageParent, { recursive: true, force: true }) + } +} + async function run(name: string, step: Step, dir: string) { if (step.type === "download") { await ensure(dir) @@ -132,7 +180,7 @@ async function run(name: string, step: Step, dir: string) { return } - const resolved = resolveRef(step) + const resolved = await resolveRef(step) const workspace = await ensureWorkspace(resolved.repo, resolved.ref) await copySubtreeFromWorkspace(workspace, resolved.path, dir) return @@ -162,10 +210,7 @@ async function main() { 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 runSkill(skill.name, skill.steps) } } finally { await cleanupWorkspaces() From 2d38fae73f4d350b44fcf58337d14ef01c717cb7 Mon Sep 17 00:00:00 2001 From: Thomas Meckel Date: Wed, 13 May 2026 15:36:55 +0000 Subject: [PATCH 5/6] refactor(docker): remove caveman version export --- Dockerfile | 4 ---- 1 file changed, 4 deletions(-) diff --git a/Dockerfile b/Dockerfile index 4e3361a..8446b93 100644 --- a/Dockerfile +++ b/Dockerfile @@ -239,10 +239,6 @@ COPY skills.yaml "${OPENCODE_BUILD_DIR}/skills.yaml" RUN <<'FOE' source /etc/bash.bashrc -CAVEMAN_RESOLVED_REF=$(cat /tmp/caveman_version) - -export CAVEMAN_RESOLVED_REF - BUN_INSTALL=/tmp/bun bun install --cwd "${OPENCODE_BUILD_DIR}/scripts" yaml || exit 1 bun "${OPENCODE_BUILD_DIR}/scripts/install-skills.ts" || exit 1 From ac288211fda7de482196fc5992e13515fa7cbfc0 Mon Sep 17 00:00:00 2001 From: Thomas Meckel Date: Wed, 13 May 2026 15:37:45 +0000 Subject: [PATCH 6/6] refactor(skills): remove caveman ref_env --- skills.yaml | 7 ------- 1 file changed, 7 deletions(-) diff --git a/skills.yaml b/skills.yaml index c3695c8..5963e46 100644 --- a/skills.yaml +++ b/skills.yaml @@ -28,46 +28,39 @@ skills: - type: git-export repo: JuliusBrussee/caveman path: skills/caveman - ref_env: CAVEMAN_RESOLVED_REF - name: caveman-commit steps: - type: git-export repo: JuliusBrussee/caveman path: skills/caveman-commit - ref_env: CAVEMAN_RESOLVED_REF - name: caveman-review steps: - type: git-export repo: JuliusBrussee/caveman path: skills/caveman-review - ref_env: CAVEMAN_RESOLVED_REF - name: caveman-help steps: - type: git-export repo: JuliusBrussee/caveman path: skills/caveman-help - ref_env: CAVEMAN_RESOLVED_REF - name: caveman-stats steps: - type: git-export repo: JuliusBrussee/caveman path: skills/caveman-stats - ref_env: CAVEMAN_RESOLVED_REF - name: caveman-compress steps: - type: git-export repo: JuliusBrussee/caveman path: skills/caveman-compress - ref_env: CAVEMAN_RESOLVED_REF - name: cavecrew steps: - type: git-export repo: JuliusBrussee/caveman path: skills/cavecrew - ref_env: CAVEMAN_RESOLVED_REF