Skip to content

Commit 40b0b8d

Browse files
phernandezclaude
andcommitted
refactor: fetch skills from GitHub at build time
Replace vendored skills/ directory with a build-time fetch from basicmachines-co/basic-memory-skills. This picks up all 9 skills (adding lifecycle, ingest, research) and eliminates drift. - Add scripts/fetch-skills.ts: auto-discovers memory-* dirs via GitHub API, downloads SKILL.md files, generates manifest.json - Rewrite commands/skills.ts to load from manifest instead of hardcoded array - Add prepack script so npm publish always fetches fresh skills - Add fetch-skills step to CI and release workflows - Gitignore skills/ (now a build artifact) - Update tests for 9 skills with order-independent assertions Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent ab6cf74 commit 40b0b8d

File tree

15 files changed

+209
-1085
lines changed

15 files changed

+209
-1085
lines changed

.github/workflows/ci.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,11 @@ jobs:
2222
- name: Install dependencies
2323
run: bun install --frozen-lockfile
2424

25+
- name: Fetch skills
26+
run: bun scripts/fetch-skills.ts
27+
env:
28+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
29+
2530
- name: Lint
2631
run: bun run lint
2732

.github/workflows/release.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,11 @@ jobs:
3939
- name: Install dependencies
4040
run: bun install --frozen-lockfile
4141

42+
- name: Fetch skills
43+
run: bun scripts/fetch-skills.ts
44+
env:
45+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
46+
4247
- name: Release checks
4348
run: bun run release:check
4449

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,3 +144,6 @@ bun.lock
144144
.idea/
145145
benchmark/datasets/
146146
benchmark/corpus-locomo/
147+
148+
# Fetched at build time from basicmachines-co/basic-memory-skills
149+
skills/

commands/skills.test.ts

Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -10,19 +10,24 @@ describe("skill slash commands", () => {
1010

1111
registerSkillCommands(mockApi)
1212

13-
expect(mockApi.registerCommand).toHaveBeenCalledTimes(6)
13+
expect(mockApi.registerCommand).toHaveBeenCalledTimes(9)
1414

1515
const names = (
1616
mockApi.registerCommand as jest.MockedFunction<any>
1717
).mock.calls.map((call: any[]) => call[0].name)
18-
expect(names).toEqual([
19-
"tasks",
20-
"reflect",
21-
"defrag",
22-
"schema",
23-
"notes",
24-
"metadata-search",
25-
])
18+
expect(names).toEqual(
19+
expect.arrayContaining([
20+
"tasks",
21+
"reflect",
22+
"defrag",
23+
"schema",
24+
"notes",
25+
"metadata-search",
26+
"lifecycle",
27+
"ingest",
28+
"research",
29+
]),
30+
)
2631
})
2732

2833
it("should set correct metadata on each command", () => {
@@ -104,6 +109,15 @@ describe("skill slash commands", () => {
104109

105110
const metadataResult = await commands["metadata-search"].handler({})
106111
expect(metadataResult.text).toContain("## Filter Syntax")
112+
113+
const lifecycleResult = await commands.lifecycle.handler({})
114+
expect(lifecycleResult.text).toContain("# Memory Lifecycle")
115+
116+
const ingestResult = await commands.ingest.handler({})
117+
expect(ingestResult.text).toContain("# Memory Ingest")
118+
119+
const researchResult = await commands.research.handler({})
120+
expect(researchResult.text).toContain("# Memory Research")
107121
})
108122
})
109123
})

commands/skills.ts

Lines changed: 25 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -5,39 +5,39 @@ import type { OpenClawPluginApi } from "openclaw/plugin-sdk"
55

66
const __dirname = dirname(fileURLToPath(import.meta.url))
77
const SKILLS_DIR = resolve(__dirname, "..", "skills")
8+
const MANIFEST_PATH = resolve(SKILLS_DIR, "manifest.json")
9+
10+
interface ManifestEntry {
11+
dir: string
12+
name: string
13+
description: string
14+
}
15+
16+
function loadManifest(): ManifestEntry[] {
17+
try {
18+
const raw = readFileSync(MANIFEST_PATH, "utf-8")
19+
return JSON.parse(raw) as ManifestEntry[]
20+
} catch {
21+
throw new Error(
22+
"skills/manifest.json not found. Run `bun scripts/fetch-skills.ts` first.",
23+
)
24+
}
25+
}
826

927
function loadSkill(dir: string): string {
1028
return readFileSync(resolve(SKILLS_DIR, dir, "SKILL.md"), "utf-8")
1129
}
1230

13-
const SKILLS = [
14-
{ name: "tasks", dir: "memory-tasks", desc: "Task management workflow" },
15-
{
16-
name: "reflect",
17-
dir: "memory-reflect",
18-
desc: "Memory reflection workflow",
19-
},
20-
{ name: "defrag", dir: "memory-defrag", desc: "Memory defrag workflow" },
21-
{ name: "schema", dir: "memory-schema", desc: "Schema management workflow" },
22-
{
23-
name: "notes",
24-
dir: "memory-notes",
25-
desc: "How to write well-structured notes",
26-
},
27-
{
28-
name: "metadata-search",
29-
dir: "memory-metadata-search",
30-
desc: "Structured metadata search workflow",
31-
},
32-
] as const
33-
3431
export function registerSkillCommands(api: OpenClawPluginApi): void {
35-
for (const skill of SKILLS) {
36-
const content = loadSkill(skill.dir)
32+
const manifest = loadManifest()
33+
34+
for (const entry of manifest) {
35+
const commandName = entry.dir.replace(/^memory-/, "")
36+
const content = loadSkill(entry.dir)
3737

3838
api.registerCommand({
39-
name: skill.name,
40-
description: skill.desc,
39+
name: commandName,
40+
description: entry.description,
4141
acceptsArgs: true,
4242
requireAuth: true,
4343
handler: async (ctx: { args?: string }) => {

justfile

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,14 @@
11
# OpenClaw Basic Memory Plugin
22

3+
# Fetch skills from GitHub
4+
fetch-skills:
5+
bun scripts/fetch-skills.ts
6+
37
# Install dependencies (bun + basic-memory CLI)
48
install:
59
bun install
610
bash scripts/setup-bm.sh
11+
bun scripts/fetch-skills.ts
712

813
# Setup Basic Memory project
914
setup:

openclaw.plugin.json

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,15 @@
22
"id": "openclaw-basic-memory",
33
"kind": "memory",
44
"skills": [
5-
"skills/memory-tasks",
6-
"skills/memory-reflect",
75
"skills/memory-defrag",
8-
"skills/memory-schema",
6+
"skills/memory-ingest",
7+
"skills/memory-lifecycle",
98
"skills/memory-metadata-search",
10-
"skills/memory-notes"
9+
"skills/memory-notes",
10+
"skills/memory-reflect",
11+
"skills/memory-research",
12+
"skills/memory-schema",
13+
"skills/memory-tasks"
1114
],
1215
"uiHints": {
1316
"project": {

package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
"schema/task-schema.ts",
3636
"schema/conversation-schema.ts",
3737
"skills/",
38+
"scripts/fetch-skills.ts",
3839
"scripts/setup-bm.sh",
3940
"openclaw.plugin.json",
4041
"README.md",
@@ -55,6 +56,8 @@
5556
"check-types": "tsc --noEmit",
5657
"lint": "bunx @biomejs/biome ci .",
5758
"lint:fix": "bunx @biomejs/biome check --write .",
59+
"fetch-skills": "bun scripts/fetch-skills.ts",
60+
"prepack": "bun scripts/fetch-skills.ts",
5861
"test": "bun test",
5962
"test:int": "BM_INTEGRATION=1 BM_BIN=${BM_BIN:-./scripts/bm-local.sh} bun test integration --timeout 120000",
6063
"test:coverage": "bun test --coverage",

scripts/fetch-skills.ts

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
/**
2+
* Fetch all memory-* skills from basicmachines-co/basic-memory-skills.
3+
*
4+
* Auto-discovers skill directories via GitHub Contents API, downloads each
5+
* SKILL.md, writes to skills/<dir>/SKILL.md, and generates skills/manifest.json.
6+
*
7+
* Env vars:
8+
* GITHUB_TOKEN — optional, avoids 60 req/hr unauthenticated rate limit
9+
* SKILLS_BRANCH — branch to fetch from (default: "main")
10+
*/
11+
12+
import { mkdirSync, writeFileSync } from "node:fs"
13+
import { dirname, resolve } from "node:path"
14+
import { fileURLToPath } from "node:url"
15+
16+
const __dirname = dirname(fileURLToPath(import.meta.url))
17+
const SKILLS_DIR = resolve(__dirname, "..", "skills")
18+
19+
const REPO = "basicmachines-co/basic-memory-skills"
20+
const BRANCH = process.env.SKILLS_BRANCH ?? "main"
21+
const TOKEN = process.env.GITHUB_TOKEN
22+
23+
interface GitHubEntry {
24+
name: string
25+
type: "file" | "dir"
26+
}
27+
28+
interface SkillManifestEntry {
29+
dir: string
30+
name: string
31+
description: string
32+
}
33+
34+
function headers(): Record<string, string> {
35+
const h: Record<string, string> = {
36+
Accept: "application/vnd.github.v3+json",
37+
"User-Agent": "openclaw-basic-memory/fetch-skills",
38+
}
39+
if (TOKEN) h.Authorization = `Bearer ${TOKEN}`
40+
return h
41+
}
42+
43+
async function fetchJSON<T>(url: string): Promise<T> {
44+
const res = await fetch(url, { headers: headers() })
45+
if (!res.ok) {
46+
throw new Error(`GET ${url}${res.status} ${res.statusText}`)
47+
}
48+
return res.json() as Promise<T>
49+
}
50+
51+
async function fetchText(url: string): Promise<string> {
52+
const res = await fetch(url, { headers: headers() })
53+
if (!res.ok) {
54+
throw new Error(`GET ${url}${res.status} ${res.statusText}`)
55+
}
56+
return res.text()
57+
}
58+
59+
function parseFrontmatter(md: string): { name: string; description: string } {
60+
const match = md.match(/^---\r?\n([\s\S]*?)\r?\n---/)
61+
if (!match) throw new Error("SKILL.md missing YAML frontmatter")
62+
63+
const yaml = match[1]
64+
const name = yaml
65+
.match(/^name:\s*(.+)$/m)?.[1]
66+
?.trim()
67+
.replace(/^["']|["']$/g, "")
68+
const description = yaml
69+
.match(/^description:\s*(.+)$/m)?.[1]
70+
?.trim()
71+
.replace(/^["']|["']$/g, "")
72+
73+
if (!name) throw new Error("Frontmatter missing 'name'")
74+
if (!description) throw new Error("Frontmatter missing 'description'")
75+
76+
return { name, description }
77+
}
78+
79+
async function main() {
80+
console.log(`Fetching skills from ${REPO}@${BRANCH}`)
81+
82+
// 1. Discover all memory-* directories
83+
const contentsUrl = `https://api.github.com/repos/${REPO}/contents?ref=${BRANCH}`
84+
const entries = await fetchJSON<GitHubEntry[]>(contentsUrl)
85+
const skillDirs = entries
86+
.filter((e) => e.type === "dir" && e.name.startsWith("memory-"))
87+
.map((e) => e.name)
88+
.sort()
89+
90+
if (skillDirs.length === 0) {
91+
throw new Error("No memory-* directories found in repo")
92+
}
93+
94+
console.log(`Found ${skillDirs.length} skills: ${skillDirs.join(", ")}`)
95+
96+
// 2. Download each SKILL.md and parse frontmatter
97+
const manifest: SkillManifestEntry[] = []
98+
99+
const results = await Promise.all(
100+
skillDirs.map(async (dir) => {
101+
const rawUrl = `https://raw.githubusercontent.com/${REPO}/${BRANCH}/${dir}/SKILL.md`
102+
const content = await fetchText(rawUrl)
103+
const meta = parseFrontmatter(content)
104+
return { dir, content, meta }
105+
}),
106+
)
107+
108+
// 3. Write files and build manifest
109+
for (const { dir, content, meta } of results) {
110+
const outDir = resolve(SKILLS_DIR, dir)
111+
mkdirSync(outDir, { recursive: true })
112+
writeFileSync(resolve(outDir, "SKILL.md"), content)
113+
manifest.push({ dir, name: meta.name, description: meta.description })
114+
console.log(` ✓ ${dir}`)
115+
}
116+
117+
// Sort manifest by dir name for deterministic output
118+
manifest.sort((a, b) => a.dir.localeCompare(b.dir))
119+
120+
// 4. Write manifest
121+
mkdirSync(SKILLS_DIR, { recursive: true })
122+
writeFileSync(
123+
resolve(SKILLS_DIR, "manifest.json"),
124+
`${JSON.stringify(manifest, null, 2)}\n`,
125+
)
126+
127+
console.log(`\nWrote ${manifest.length} skills + manifest.json to skills/`)
128+
}
129+
130+
main().catch((err) => {
131+
console.error("Fatal:", err.message)
132+
process.exit(1)
133+
})

0 commit comments

Comments
 (0)