Skip to content

Commit 0e9d44a

Browse files
authored
feat: ✨ add caveman skill and plugin (#23)
2 parents 3a68da7 + ac28821 commit 0e9d44a

3 files changed

Lines changed: 219 additions & 12 deletions

File tree

Dockerfile

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ ENV PATH="${PATH}:${PYTHON_DIR}/bin"
1414
RUN <<'FOE'
1515

1616
ln -s /usr/local/bin/bun /usr/local/bin/node
17-
ls -s /usr/local/bin/bun /usr/local/bin/npx
17+
ln -s /usr/local/bin/bun /usr/local/bin/npx
1818

1919
apt-get update
2020
apt-get install \
@@ -79,7 +79,8 @@ EOF
7979
FOE
8080

8181
ARG OPENCODE_VERSION=latest
82-
ARG AZURE_FOUNDRY_PROVIDER_REF=v0.3.1
82+
ARG CAVEMAN_VERSION=latest
83+
ARG AZURE_FOUNDRY_PROVIDER_REF=v0.4.0
8384
ARG ENGRAM_VERSION=latest
8485
ARG OPENCODE_BUILD_DIR=/usr/local/share/opencode-build
8586

@@ -116,6 +117,10 @@ resolve_github_latest_version() {
116117
echo "${version}"
117118
}
118119

120+
resolve_caveman_version() {
121+
resolve_github_latest_version "JuliusBrussee/caveman" "${CAVEMAN_VERSION}"
122+
}
123+
119124
mkdir -p "${BUN_INSTALL}" "${OPENCODE_CONFIG_DIR}" "${OPENCODE_PLUGINS_DIR}" "${PROVIDER_DIR}"
120125
chmod 0777 "${OPENCODE_CONFIG_DIR}"
121126

@@ -185,6 +190,41 @@ curl -fsSL "https://raw.githubusercontent.com/rtk-ai/rtk/refs/heads/master/hooks
185190
# vercel/skills
186191
bun install -g --trust skills@latest
187192

193+
###
194+
# caveman
195+
#
196+
caveman_resolved_version=$(resolve_caveman_version) || exit 1
197+
echo "CAVEMAN_RESOLVED_REF=${caveman_resolved_version}"
198+
echo "${caveman_resolved_version}" > /tmp/caveman_version
199+
200+
mkdir -p "${OPENCODE_CONFIG_DIR}/plugins/caveman" "${OPENCODE_CONFIG_DIR}/commands"
201+
202+
CAVEMAN_RAW_BASE="https://raw.githubusercontent.com/JuliusBrussee/caveman/refs/tags/${caveman_resolved_version}"
203+
204+
{
205+
curl -fsSL "${CAVEMAN_RAW_BASE}/src/plugins/opencode/package.json" -o "${OPENCODE_CONFIG_DIR}/plugins/caveman/package.json"
206+
curl -fsSL "${CAVEMAN_RAW_BASE}/src/plugins/opencode/plugin.js" -o "${OPENCODE_CONFIG_DIR}/plugins/caveman/plugin.js"
207+
curl -fsSL "${CAVEMAN_RAW_BASE}/src/hooks/caveman-config.js" -o "${OPENCODE_CONFIG_DIR}/plugins/caveman/caveman-config.cjs"
208+
curl -fsSL "${CAVEMAN_RAW_BASE}/src/plugins/opencode/commands/caveman.md" -o "${OPENCODE_CONFIG_DIR}/commands/caveman.md"
209+
curl -fsSL "${CAVEMAN_RAW_BASE}/src/plugins/opencode/commands/caveman-commit.md" -o "${OPENCODE_CONFIG_DIR}/commands/caveman-commit.md"
210+
curl -fsSL "${CAVEMAN_RAW_BASE}/src/plugins/opencode/commands/caveman-review.md" -o "${OPENCODE_CONFIG_DIR}/commands/caveman-review.md"
211+
curl -fsSL "${CAVEMAN_RAW_BASE}/src/plugins/opencode/commands/caveman-stats.md" -o "${OPENCODE_CONFIG_DIR}/commands/caveman-stats.md"
212+
curl -fsSL "${CAVEMAN_RAW_BASE}/src/plugins/opencode/commands/caveman-help.md" -o "${OPENCODE_CONFIG_DIR}/commands/caveman-help.md"
213+
} & wait || exit 1
214+
215+
cat > "${OPENCODE_CONFIG_DIR}/commands/caveman-compress.md" <<'EOF'
216+
---
217+
description: Compress a Markdown memory file using the caveman-compress skill
218+
---
219+
Compress the target file with `caveman-compress`.
220+
221+
Input: `$ARGUMENTS`
222+
223+
Run the installed `caveman-compress` skill workflow against the given file path.
224+
Preserve code, URLs, paths, commands, and structure exactly as the skill requires.
225+
Overwrite the original file only if the compression succeeds, and keep the `.original.md` backup.
226+
EOF
227+
188228
###
189229
# cleanup
190230
rm -rf /root/.bun
@@ -210,6 +250,7 @@ cat >"${OPENCODE_CONFIG_DIR}/opencode.json" <<-'EOF'
210250
"$schema": "https://opencode.ai/config.json",
211251
"plugin": [
212252
"file:///usr/local/bun/install/global/node_modules/opencode-gemini-auth",
253+
"file:///etc/opencode/plugins/caveman"
213254
],
214255
"mcp": {
215256
"engram": {

scripts/install-skills.ts

Lines changed: 134 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,27 @@
11
#!/usr/bin/env bun
2-
import { mkdir } from "node:fs/promises"
2+
import { mkdir, mkdtemp, readdir, cp, rm, rename } from "node:fs/promises"
33
import path from "node:path"
4+
import os from "node:os"
45
import YAML from "yaml"
56

67
type Step =
78
| { type: "download"; url: string; dest: string }
89
| { type: "git-export"; url: string }
10+
| { type: "git-export"; repo: string; path: string; version?: string }
911
| { type: "uv-pip"; packages: string[] }
1012

13+
type GitExportSpec = Extract<Step, { type: "git-export" }>
14+
type ResolvedGitExport = {
15+
repo: string
16+
ref: string | null
17+
path: string
18+
}
19+
type Workspace = {
20+
dir: string
21+
cloneDir: string
22+
ref: string | null
23+
}
24+
1125
type Skill = {
1226
name: string
1327
steps: Step[]
@@ -22,11 +36,16 @@ const root = path.join(cfg, "skills")
2236
const base = process.env.OPENCODE_BUILD_DIR || "/tmp"
2337
const manifestPath = process.env.SKILLS_MANIFEST || path.join(base, "skills.yaml")
2438
const gitExport = process.env.GIT_EXPORT_SCRIPT || path.join(base, "scripts", "git-export.ts")
39+
const workspaces = new Map<string, Workspace>()
2540

2641
function fail(msg: string): never {
2742
throw new Error(`[install-skills] ${msg}`)
2843
}
2944

45+
function info(msg: string) {
46+
console.log(`[install-skills] ${msg}`)
47+
}
48+
3049
function safe(base: string, dest: string) {
3150
const file = path.resolve(base, dest)
3251
const rel = path.relative(base, file)
@@ -38,6 +57,11 @@ async function ensure(dir: string) {
3857
await mkdir(dir, { recursive: true })
3958
}
4059

60+
async function resetDir(dir: string) {
61+
await rm(dir, { recursive: true, force: true })
62+
await mkdir(dir, { recursive: true })
63+
}
64+
4165
async function shell(args: string[]) {
4266
const proc = Bun.spawn(args, {
4367
stdout: "inherit",
@@ -48,6 +72,98 @@ async function shell(args: string[]) {
4872
if (code !== 0) fail(`command failed (${code}): ${args.join(" ")}`)
4973
}
5074

75+
function isGitHubRepo(repo: string) {
76+
return /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repo)
77+
}
78+
79+
async function resolveLatestReleaseTag(repo: string) {
80+
const res = await fetch(`https://api.github.com/repos/${repo}/releases/latest`, {
81+
headers: {
82+
Accept: "application/vnd.github+json",
83+
"User-Agent": "install-skills",
84+
},
85+
})
86+
87+
if (res.status === 404) return null
88+
if (!res.ok) fail(`git-export: failed latest release lookup for ${repo} (${res.status})`)
89+
90+
const data = (await res.json()) as { tag_name?: unknown }
91+
const tag = typeof data.tag_name === "string" && data.tag_name ? data.tag_name : null
92+
if (tag) info(`git-export: resolved latest release for ${repo} -> ${tag}`)
93+
return tag
94+
}
95+
96+
async function resolveRef(step: GitExportSpec): Promise<ResolvedGitExport> {
97+
if (!step.repo) fail(`git-export: missing repo`)
98+
if (!step.path) fail(`git-export: missing path for ${step.repo}`)
99+
if (!isGitHubRepo(step.repo)) fail(`git-export: unsupported repo format: ${step.repo}`)
100+
101+
const version = step.version ?? "latest"
102+
const ref = version === "latest" ? await resolveLatestReleaseTag(step.repo) : version
103+
return { repo: step.repo, path: step.path, ref }
104+
}
105+
106+
function workspaceKey(repo: string, ref: string | null) {
107+
return `${repo}@${ref ?? "HEAD"}`
108+
}
109+
110+
async function ensureWorkspace(repo: string, ref: string | null) {
111+
const key = workspaceKey(repo, ref)
112+
const existing = workspaces.get(key)
113+
if (existing) return existing
114+
115+
const dir = await mkdtemp(path.join(os.tmpdir(), "skills-export-"))
116+
const cloneDir = path.join(dir, "repo")
117+
const repoUrl = `https://github.com/${repo}.git`
118+
await shell(["git", "clone", "--depth", "1", "--filter=tree:0", "--no-checkout", repoUrl, cloneDir])
119+
if (ref) {
120+
await shell(["git", "-C", cloneDir, "fetch", "--depth", "1", "origin", ref])
121+
await shell(["git", "-C", cloneDir, "checkout", "--detach", "FETCH_HEAD"])
122+
} else {
123+
await shell(["git", "-C", cloneDir, "checkout"])
124+
}
125+
126+
const workspace: Workspace = { dir, cloneDir, ref }
127+
workspaces.set(key, workspace)
128+
return workspace
129+
}
130+
131+
async function copySubtreeFromWorkspace(workspace: Workspace, sourcePath: string, destDir: string) {
132+
const sourceDir = sourcePath ? path.join(workspace.cloneDir, sourcePath) : workspace.cloneDir
133+
const entries = await readdir(sourceDir)
134+
await ensure(destDir)
135+
for (const entry of entries) {
136+
if (entry === ".git") continue
137+
await cp(path.join(sourceDir, entry), path.join(destDir, entry), { recursive: true, force: true, verbatimSymlinks: true })
138+
}
139+
}
140+
141+
async function cleanupWorkspaces() {
142+
for (const workspace of workspaces.values()) {
143+
await rm(workspace.dir, { recursive: true, force: true })
144+
}
145+
workspaces.clear()
146+
}
147+
148+
async function runSkill(name: string, steps: Step[]) {
149+
const destDir = path.join(root, name)
150+
const stageParent = await mkdtemp(path.join(os.tmpdir(), "skills-install-"))
151+
const stageDir = path.join(stageParent, name)
152+
153+
try {
154+
await resetDir(stageDir)
155+
for (const step of steps) {
156+
await run(name, step, stageDir)
157+
}
158+
159+
await rm(destDir, { recursive: true, force: true })
160+
await ensure(path.dirname(destDir))
161+
await rename(stageDir, destDir)
162+
} finally {
163+
await rm(stageParent, { recursive: true, force: true })
164+
}
165+
}
166+
51167
async function run(name: string, step: Step, dir: string) {
52168
if (step.type === "download") {
53169
await ensure(dir)
@@ -59,7 +175,14 @@ async function run(name: string, step: Step, dir: string) {
59175

60176
if (step.type === "git-export") {
61177
await ensure(dir)
62-
await shell(["bun", gitExport, step.url, dir, "--force"])
178+
if ("url" in step) {
179+
await shell(["bun", gitExport, step.url, dir, "--force"])
180+
return
181+
}
182+
183+
const resolved = await resolveRef(step)
184+
const workspace = await ensureWorkspace(resolved.repo, resolved.ref)
185+
await copySubtreeFromWorkspace(workspace, resolved.path, dir)
63186
return
64187
}
65188

@@ -74,22 +197,23 @@ async function run(name: string, step: Step, dir: string) {
74197
}
75198

76199
function parse(input: string): Manifest {
77-
const parsed = YAML.parse(input) as Manifest
200+
const parsed = YAML.parse(input) as Partial<Manifest> | null
78201
if (!parsed || !Array.isArray(parsed.skills)) fail(`invalid manifest at ${manifestPath}`)
79-
return parsed
202+
return { skills: parsed.skills as Skill[] }
80203
}
81204

82205
async function main() {
83206
const manifest = parse(await Bun.file(manifestPath).text())
84207
await ensure(root)
85208

86-
for (const skill of manifest.skills) {
87-
if (!skill.name) fail(`skill missing name`)
88-
if (!Array.isArray(skill.steps)) fail(`${skill.name}: missing steps`)
89-
const dir = path.join(root, skill.name)
90-
for (const step of skill.steps) {
91-
await run(skill.name, step, dir)
209+
try {
210+
for (const skill of manifest.skills) {
211+
if (!skill.name) fail(`skill missing name`)
212+
if (!Array.isArray(skill.steps)) fail(`${skill.name}: missing steps`)
213+
await runSkill(skill.name, skill.steps)
92214
}
215+
} finally {
216+
await cleanupWorkspaces()
93217
}
94218
}
95219

skills.yaml

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,3 +22,45 @@ skills:
2222
steps:
2323
- type: git-export
2424
url: https://github.com/vercel-labs/agent-browser/tree/main/skills/agent-browser
25+
26+
- name: caveman
27+
steps:
28+
- type: git-export
29+
repo: JuliusBrussee/caveman
30+
path: skills/caveman
31+
32+
- name: caveman-commit
33+
steps:
34+
- type: git-export
35+
repo: JuliusBrussee/caveman
36+
path: skills/caveman-commit
37+
38+
- name: caveman-review
39+
steps:
40+
- type: git-export
41+
repo: JuliusBrussee/caveman
42+
path: skills/caveman-review
43+
44+
- name: caveman-help
45+
steps:
46+
- type: git-export
47+
repo: JuliusBrussee/caveman
48+
path: skills/caveman-help
49+
50+
- name: caveman-stats
51+
steps:
52+
- type: git-export
53+
repo: JuliusBrussee/caveman
54+
path: skills/caveman-stats
55+
56+
- name: caveman-compress
57+
steps:
58+
- type: git-export
59+
repo: JuliusBrussee/caveman
60+
path: skills/caveman-compress
61+
62+
- name: cavecrew
63+
steps:
64+
- type: git-export
65+
repo: JuliusBrussee/caveman
66+
path: skills/cavecrew

0 commit comments

Comments
 (0)