diff --git a/Dockerfile b/Dockerfile index 36da768..8446b93 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 @@ -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": { diff --git a/scripts/install-skills.ts b/scripts/install-skills.ts index 82a08d5..e8584a4 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, 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 +type ResolvedGitExport = { + repo: string + ref: string | null + path: string +} +type Workspace = { + dir: string + cloneDir: string + ref: string | null +} + type Skill = { name: string steps: Step[] @@ -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() 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) @@ -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", @@ -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 { + 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) @@ -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 } @@ -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 | 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() } } diff --git a/skills.yaml b/skills.yaml index a641b5b..5963e46 100644 --- a/skills.yaml +++ b/skills.yaml @@ -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