Files
shockbot/utils/skills.ts
T
Colin McDonnell 57f54e37c5 add bundled git-archaeology skill, auto-installed for opencode and claude (#565)
* add bundled git-archaeology skill, auto-installed for opencode and claude

ships a SKILL.md teaching agents the underused git history primitives
(pickaxe -S/-G, -L for function/line ranges, --reverse blame, deleted-file
recovery) so they stop scrolling git log -p when blame comes up empty.

introduces a lightweight bundled-skill path alongside the existing
addSkill (npx skills add) flow used for external skills like agent-browser.
SKILL.md is inlined into dist/cli.mjs via esbuild's text loader and written
to <home>/.agents/skills/<name>/SKILL.md at runtime — no network, no version
drift, no per-run install cost.

* fix: register vitest plugin to load .md as text for bundled-skill tests

* fix: drop vite type import from vitest plugin (vite isn't a direct dep)

* fix: load bundled skills via readFileSync so source mode works

esbuild's text loader only applies to the npm-bundled dist/cli.mjs path. the
preview / oss path runs cli.ts directly with node (PULLFROG_FORCE_LOCAL_CLI=1
in runCli.ts#runLocalCli), where node has no idea how to import .md files —
ERR_UNKNOWN_FILE_EXTENSION crashes the action before any agent starts.

switch to runtime readFileSync that checks both candidate locations:
- source mode: <actionRoot>/skills/<name>/SKILL.md (relative to utils/skills.ts)
- bundled mode: <distDir>/skills/<name>/SKILL.md (esbuild copies the tree)

drops the no-longer-needed esbuild text loader, vitest .md plugin, and
ambient *.md type declaration. wiki/skills.md updated with the why.

* fix: write bundled skills to per-agent dirs so claude actually registers them
2026-05-04 18:49:50 +00:00

118 lines
4.2 KiB
TypeScript

import { spawnSync } from "node:child_process";
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { log } from "./cli.ts";
import { getDevDependencyVersion } from "./version.ts";
const skillsVersion = getDevDependencyVersion("skills");
/**
* skills bundled with the action runtime. the SKILL.md files live in
* `action/skills/<name>/SKILL.md` and are read at runtime — no esbuild loader,
* no codegen. this matters because the preview / oss path runs `cli.ts` from
* source (see `runCli.ts#runLocalCli`) where esbuild loaders don't apply.
*/
const BUNDLED_SKILL_NAMES = ["git-archaeology"] as const;
/**
* resolve the on-disk path of a bundled SKILL.md by checking the two locations
* the file may live in:
* - source mode (`runLocalCli`): `<actionRoot>/skills/<name>/SKILL.md`,
* reached as `../skills/...` from `utils/skills.ts`.
* - bundled mode (npx published package): `<distDir>/skills/<name>/SKILL.md`,
* reached as `./skills/...` from `dist/cli.mjs`.
*
* the bundled-mode copy is produced by an esbuild post-build step in
* `esbuild.config.js`.
*/
function resolveSkillPath(name: string): string {
const here = dirname(fileURLToPath(import.meta.url));
const candidates = [
join(here, "..", "skills", name, "SKILL.md"),
join(here, "skills", name, "SKILL.md"),
];
for (const candidate of candidates) {
if (existsSync(candidate)) return candidate;
}
throw new Error(`bundled skill not found: ${name} (looked in ${candidates.join(", ")})`);
}
/**
* each agent has its own auto-scan dir under HOME. we write to all of them so
* the same `installBundledSkills` call works regardless of which agent is
* running, without coupling skills.ts to agent identity.
*
* verified empirically (PR #565):
* - OpenCode registers skills from `$HOME/.agents/skills/` and `.opencode/skills/`.
* - Claude Code only registers skills from `$HOME/.claude/skills/` —
* it does NOT scan `.agents/skills/`, so writing only there leaves the
* skill on disk but invisible to Claude's `Skill` tool.
*/
const SKILL_TARGET_DIRS = [".opencode/skills", ".claude/skills", ".agents/skills"] as const;
/**
* write all bundled skills into the fake HOME so OpenCode / Claude Code discover
* them via their auto-scan directories.
*
* called once per agent run from each agent's `run()`. cheap (small file
* writes), no network, idempotent.
*/
export function installBundledSkills(params: { home: string }): void {
for (const name of BUNDLED_SKILL_NAMES) {
const content = readFileSync(resolveSkillPath(name), "utf8");
for (const targetDir of SKILL_TARGET_DIRS) {
const skillDir = join(params.home, targetDir, name);
mkdirSync(skillDir, { recursive: true });
writeFileSync(join(skillDir, "SKILL.md"), content);
}
}
log.info(`installed bundled skills: ${BUNDLED_SKILL_NAMES.join(", ")}`);
}
/**
* install a skill globally via the `skills` CLI.
*
* runs `npx skills add <ref> --skill <name> -g` with `cwd` set to os tmpdir
* so npm doesn't walk up and find a project-level `.npmrc` with pnpm-specific
* settings (e.g. `public-hoist-pattern`) that break npx binary resolution.
* the `-g` flag writes to `$HOME/.agents/skills/` which is controlled by
* `params.env.HOME` (the fake HOME), so cwd has no effect on install location.
*/
export function addSkill(params: {
ref: string;
skill: string;
env: Record<string, string>;
agent: string;
}): void {
const result = spawnSync(
"npx",
[
"-y",
`skills@${skillsVersion}`,
"add",
params.ref,
"--skill",
params.skill,
"-g",
"-a",
params.agent,
"-y",
],
{
cwd: tmpdir(),
env: { ...process.env, ...params.env },
stdio: "pipe",
timeout: 30_000,
}
);
if (result.status === 0) {
log.info(`installed ${params.skill} skill (${params.agent})`);
} else {
const stderr = (result.stderr?.toString() || "").trim();
const errorMsg = result.error ? result.error.message : stderr;
log.info(`${params.skill} skill install failed: ${errorMsg}`);
}
}