Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 43 additions & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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 \
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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}"

Expand Down Expand Up @@ -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
Expand All @@ -210,6 +250,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": {
Expand Down
144 changes: 134 additions & 10 deletions scripts/install-skills.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,27 @@
#!/usr/bin/env bun
import { mkdir } 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"

type Step =
| { type: "download"; url: string; dest: string }
| { type: "git-export"; url: string }
| { type: "git-export"; repo: string; path: string; version?: string }
| { type: "uv-pip"; packages: string[] }

type GitExportSpec = Extract<Step, { type: "git-export" }>
type ResolvedGitExport = {
repo: string
ref: string | null
path: string
}
type Workspace = {
dir: string
cloneDir: string
ref: string | null
}

type Skill = {
name: string
steps: Step[]
Expand All @@ -22,11 +36,16 @@ 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<string, Workspace>()

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)
Expand All @@ -38,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",
Expand All @@ -48,6 +72,98 @@ 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)
}

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<ResolvedGitExport> {
if (!step.repo) fail(`git-export: missing repo`)
if (!step.path) fail(`git-export: missing path for ${step.repo}`)
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 | null) {
return `${repo}@${ref ?? "HEAD"}`
}

async function ensureWorkspace(repo: string, ref: string | null) {
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])
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)
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 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)
Expand All @@ -59,7 +175,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 = await resolveRef(step)
const workspace = await ensureWorkspace(resolved.repo, resolved.ref)
await copySubtreeFromWorkspace(workspace, resolved.path, dir)
return
}

Expand All @@ -74,22 +197,23 @@ 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<Manifest> | 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`)
await runSkill(skill.name, skill.steps)
}
} finally {
await cleanupWorkspaces()
}
}

Expand Down
42 changes: 42 additions & 0 deletions skills.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,45 @@ 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

- name: caveman-commit
steps:
- type: git-export
repo: JuliusBrussee/caveman
path: skills/caveman-commit

- name: caveman-review
steps:
- type: git-export
repo: JuliusBrussee/caveman
path: skills/caveman-review

- name: caveman-help
steps:
- type: git-export
repo: JuliusBrussee/caveman
path: skills/caveman-help

- name: caveman-stats
steps:
- type: git-export
repo: JuliusBrussee/caveman
path: skills/caveman-stats

- name: caveman-compress
steps:
- type: git-export
repo: JuliusBrussee/caveman
path: skills/caveman-compress

- name: cavecrew
steps:
- type: git-export
repo: JuliusBrussee/caveman
path: skills/cavecrew