mirror of
https://github.com/browseros-ai/BrowserOS.git
synced 2026-05-13 23:53:25 +00:00
Compare commits
5 Commits
fix/dev-se
...
fix/skills
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e2c4486f13 | ||
|
|
d91ae391bf | ||
|
|
111b977ea8 | ||
|
|
42d4d14d24 | ||
|
|
74b12d56d7 |
@@ -260,7 +260,14 @@ const SkillCard: FC<{
|
||||
<Card className="h-full py-0 shadow-sm">
|
||||
<CardContent className="flex h-full flex-col p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<h2 className="font-semibold text-sm leading-5">{skill.name}</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="font-semibold text-sm leading-5">{skill.name}</h2>
|
||||
{skill.builtIn ? (
|
||||
<Badge variant="secondary" className="px-1.5 py-0 text-[10px]">
|
||||
Built-in
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
<Switch
|
||||
checked={skill.enabled}
|
||||
onCheckedChange={onToggle}
|
||||
@@ -284,15 +291,17 @@ const SkillCard: FC<{
|
||||
<Pencil className="size-3.5" />
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={onDelete}
|
||||
className="size-7 text-muted-foreground hover:bg-transparent hover:text-destructive"
|
||||
aria-label={`Delete ${skill.name}`}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
{!skill.builtIn ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={onDelete}
|
||||
className="size-7 text-muted-foreground hover:bg-transparent hover:text-destructive"
|
||||
aria-label={`Delete ${skill.name}`}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -7,6 +7,7 @@ export type SkillMeta = {
|
||||
description: string
|
||||
location: string
|
||||
enabled: boolean
|
||||
builtIn: boolean
|
||||
}
|
||||
|
||||
export type SkillDetail = SkillMeta & {
|
||||
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
wrapLanguageModel,
|
||||
} from 'ai'
|
||||
import type { Browser } from '../browser/browser'
|
||||
import { getSkillsDir } from '../lib/browseros-dir'
|
||||
import type { KlavisClient } from '../lib/clients/klavis/klavis-client'
|
||||
import { logger } from '../lib/logger'
|
||||
import { isSoulBootstrap, readSoul } from '../lib/soul'
|
||||
@@ -151,7 +150,7 @@ export class AiSdkAgent {
|
||||
const isBootstrap = await isSoulBootstrap()
|
||||
|
||||
// Load skills catalog for prompt injection
|
||||
const skills = await loadSkills(getSkillsDir())
|
||||
const skills = await loadSkills()
|
||||
const skillsCatalog =
|
||||
skills.length > 0 ? buildSkillsCatalog(skills) : undefined
|
||||
|
||||
|
||||
@@ -58,7 +58,11 @@ export function createSkillsRoutes() {
|
||||
return c.json({ ok: true })
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Failed to delete'
|
||||
const status = msg.includes('not found') ? 404 : 500
|
||||
const status = msg.includes('not found')
|
||||
? 404
|
||||
: msg.includes('built-in')
|
||||
? 403
|
||||
: 500
|
||||
return c.json({ error: msg }, status)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -28,9 +28,14 @@ export function getSkillsDir(): string {
|
||||
return join(getBrowserosDir(), PATHS.SKILLS_DIR_NAME)
|
||||
}
|
||||
|
||||
export function getBuiltinSkillsDir(): string {
|
||||
return join(getSkillsDir(), PATHS.BUILTIN_DIR_NAME)
|
||||
}
|
||||
|
||||
export async function ensureBrowserosDir(): Promise<void> {
|
||||
await mkdir(getMemoryDir(), { recursive: true })
|
||||
await mkdir(getSkillsDir(), { recursive: true })
|
||||
await mkdir(getBuiltinSkillsDir(), { recursive: true })
|
||||
await mkdir(getSessionsDir(), { recursive: true })
|
||||
}
|
||||
|
||||
|
||||
@@ -28,8 +28,8 @@ import { fetchDailyRateLimit } from './lib/rate-limiter/fetch-config'
|
||||
import { RateLimiter } from './lib/rate-limiter/rate-limiter'
|
||||
import { Sentry } from './lib/sentry'
|
||||
import { seedSoulTemplate } from './lib/soul'
|
||||
import { startSkillSync, stopSkillSync } from './skills/remote-sync'
|
||||
import { seedDefaultSkills } from './skills/seed'
|
||||
import { migrateBuiltinSkills } from './skills/migrate'
|
||||
import { startSkillSync, stopSkillSync, syncBuiltinSkills } from './skills/remote-sync'
|
||||
import { registry } from './tools/registry'
|
||||
import { VERSION } from './version'
|
||||
|
||||
@@ -138,7 +138,8 @@ export class Application {
|
||||
await ensureBrowserosDir()
|
||||
await cleanOldSessions()
|
||||
await seedSoulTemplate()
|
||||
await seedDefaultSkills()
|
||||
await migrateBuiltinSkills()
|
||||
await syncBuiltinSkills()
|
||||
|
||||
const dbPath = path.join(
|
||||
this.config.executionDir || this.config.resourcesDir,
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { readdir, readFile, stat } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import matter from 'gray-matter'
|
||||
import { PATHS } from '@browseros/shared/constants/paths'
|
||||
import { getBuiltinSkillsDir, getSkillsDir } from '../lib/browseros-dir'
|
||||
import { logger } from '../lib/logger'
|
||||
import type { SkillFrontmatter, SkillMeta } from './types'
|
||||
|
||||
@@ -27,6 +29,7 @@ export function isValidFrontmatter(data: unknown): data is SkillFrontmatter {
|
||||
async function parseSkillFile(
|
||||
skillMdPath: string,
|
||||
dirName: string,
|
||||
builtIn: boolean,
|
||||
): Promise<SkillMeta | null> {
|
||||
try {
|
||||
const content = await readFile(skillMdPath, 'utf-8')
|
||||
@@ -48,6 +51,7 @@ async function parseSkillFile(
|
||||
location: skillMdPath,
|
||||
enabled: meta?.enabled !== 'false',
|
||||
version: meta?.version,
|
||||
builtIn,
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn('Failed to parse skill', {
|
||||
@@ -58,10 +62,14 @@ async function parseSkillFile(
|
||||
}
|
||||
}
|
||||
|
||||
async function scanSkills(skillsDir: string): Promise<SkillMeta[]> {
|
||||
async function scanDir(
|
||||
dir: string,
|
||||
builtIn: boolean,
|
||||
skipDirs?: Set<string>,
|
||||
): Promise<SkillMeta[]> {
|
||||
let entries: string[]
|
||||
try {
|
||||
entries = await readdir(skillsDir)
|
||||
entries = await readdir(dir)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
@@ -70,7 +78,8 @@ async function scanSkills(skillsDir: string): Promise<SkillMeta[]> {
|
||||
const seen = new Set<string>()
|
||||
|
||||
for (const entry of entries) {
|
||||
const entryPath = join(skillsDir, entry)
|
||||
if (skipDirs?.has(entry)) continue
|
||||
const entryPath = join(dir, entry)
|
||||
if (!(await isDirectory(entryPath))) continue
|
||||
|
||||
const skillMdPath = join(entryPath, 'SKILL.md')
|
||||
@@ -80,21 +89,27 @@ async function scanSkills(skillsDir: string): Promise<SkillMeta[]> {
|
||||
continue
|
||||
}
|
||||
|
||||
const skill = await parseSkillFile(skillMdPath, entry)
|
||||
if (!skill || seen.has(skill.name)) continue
|
||||
const skill = await parseSkillFile(skillMdPath, entry, builtIn)
|
||||
if (!skill || seen.has(skill.id)) continue
|
||||
|
||||
seen.add(skill.name)
|
||||
seen.add(skill.id)
|
||||
skills.push(skill)
|
||||
}
|
||||
|
||||
return skills
|
||||
}
|
||||
|
||||
export async function loadSkills(skillsDir: string): Promise<SkillMeta[]> {
|
||||
const all = await scanSkills(skillsDir)
|
||||
return all.filter((s) => s.enabled)
|
||||
export async function loadAllSkills(): Promise<SkillMeta[]> {
|
||||
const builtinSkills = await scanDir(getBuiltinSkillsDir(), true)
|
||||
const userSkills = await scanDir(
|
||||
getSkillsDir(),
|
||||
false,
|
||||
new Set([PATHS.BUILTIN_DIR_NAME]),
|
||||
)
|
||||
return [...builtinSkills, ...userSkills]
|
||||
}
|
||||
|
||||
export async function loadAllSkills(skillsDir: string): Promise<SkillMeta[]> {
|
||||
return scanSkills(skillsDir)
|
||||
export async function loadSkills(): Promise<SkillMeta[]> {
|
||||
const all = await loadAllSkills()
|
||||
return all.filter((s) => s.enabled)
|
||||
}
|
||||
|
||||
45
packages/browseros-agent/apps/server/src/skills/migrate.ts
Normal file
45
packages/browseros-agent/apps/server/src/skills/migrate.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { readdir, rename, stat } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { getBuiltinSkillsDir, getSkillsDir } from '../lib/browseros-dir'
|
||||
import { logger } from '../lib/logger'
|
||||
import { DEFAULT_SKILLS } from './defaults'
|
||||
|
||||
const DEFAULT_SKILL_IDS = new Set(DEFAULT_SKILLS.map((s) => s.id))
|
||||
|
||||
export async function migrateBuiltinSkills(): Promise<void> {
|
||||
const builtinDir = getBuiltinSkillsDir()
|
||||
|
||||
try {
|
||||
const entries = await readdir(builtinDir)
|
||||
if (entries.some((e) => !e.startsWith('.'))) return
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
const skillsDir = getSkillsDir()
|
||||
let migrated = 0
|
||||
|
||||
for (const id of DEFAULT_SKILL_IDS) {
|
||||
const sourcePath = join(skillsDir, id)
|
||||
try {
|
||||
const s = await stat(join(sourcePath, 'SKILL.md'))
|
||||
if (!s.isFile()) continue
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
await rename(sourcePath, join(builtinDir, id))
|
||||
migrated++
|
||||
} catch (err) {
|
||||
logger.warn('Failed to migrate builtin skill', {
|
||||
id,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (migrated > 0) {
|
||||
logger.info(`Migrated ${migrated} built-in skills to builtin/ directory`)
|
||||
}
|
||||
}
|
||||
@@ -1,20 +1,33 @@
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
||||
import { mkdir, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { TIMEOUTS } from '@browseros/shared/constants/timeouts'
|
||||
import { EXTERNAL_URLS } from '@browseros/shared/constants/urls'
|
||||
import { INLINED_ENV } from '../env'
|
||||
import { getSkillsDir } from '../lib/browseros-dir'
|
||||
import { getBuiltinSkillsDir } from '../lib/browseros-dir'
|
||||
import { logger } from '../lib/logger'
|
||||
import { safeSkillDir } from './service'
|
||||
import { DEFAULT_SKILLS } from './defaults'
|
||||
import { safeBuiltinSkillDir } from './service'
|
||||
import type { RemoteSkillCatalog, RemoteSkillEntry } from './types'
|
||||
|
||||
let syncTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
export function extractVersion(content: string): string {
|
||||
function extractVersion(content: string): string {
|
||||
const match = content.match(/^\s*version:\s*["']?([^"'\n]+)["']?/m)
|
||||
return match?.[1]?.trim() || '1.0'
|
||||
}
|
||||
|
||||
function extractEnabled(content: string): string | null {
|
||||
const match = content.match(/^\s*enabled:\s*["']?(true|false)["']?/m)
|
||||
return match?.[1] ?? null
|
||||
}
|
||||
|
||||
function setEnabled(content: string, enabled: string): string {
|
||||
return content.replace(
|
||||
/^(\s*enabled:\s*)["']?(?:true|false)["']?/m,
|
||||
`$1"${enabled}"`,
|
||||
)
|
||||
}
|
||||
|
||||
function isValidSkillEntry(entry: unknown): entry is RemoteSkillEntry {
|
||||
if (typeof entry !== 'object' || entry === null) return false
|
||||
const e = entry as Record<string, unknown>
|
||||
@@ -35,19 +48,14 @@ function isValidCatalog(data: unknown): data is RemoteSkillCatalog {
|
||||
)
|
||||
}
|
||||
|
||||
function getCatalogUrl(): string {
|
||||
return INLINED_ENV.SKILLS_CATALOG_URL || EXTERNAL_URLS.SKILLS_CATALOG
|
||||
}
|
||||
|
||||
export async function fetchRemoteCatalog(): Promise<RemoteSkillCatalog | null> {
|
||||
const url = INLINED_ENV.SKILLS_CATALOG_URL || EXTERNAL_URLS.SKILLS_CATALOG
|
||||
try {
|
||||
const response = await fetch(getCatalogUrl(), {
|
||||
const response = await fetch(url, {
|
||||
signal: AbortSignal.timeout(TIMEOUTS.SKILLS_FETCH),
|
||||
})
|
||||
if (!response.ok) {
|
||||
logger.warn('Failed to fetch remote skill catalog', {
|
||||
status: response.status,
|
||||
})
|
||||
logger.warn('Failed to fetch remote skill catalog', { status: response.status })
|
||||
return null
|
||||
}
|
||||
const data: unknown = await response.json()
|
||||
@@ -64,104 +72,86 @@ export async function fetchRemoteCatalog(): Promise<RemoteSkillCatalog | null> {
|
||||
}
|
||||
}
|
||||
|
||||
async function getLocalVersion(skillId: string): Promise<string | null> {
|
||||
try {
|
||||
const safeDir = safeSkillDir(skillId)
|
||||
const content = await readFile(join(safeDir, 'SKILL.md'), 'utf-8')
|
||||
return extractVersion(content)
|
||||
} catch {
|
||||
return null
|
||||
export async function syncBuiltinSkills(): Promise<void> {
|
||||
const catalog = await fetchRemoteCatalog()
|
||||
|
||||
const contentMap = new Map<string, { version: string; content: string }>()
|
||||
for (const skill of DEFAULT_SKILLS) {
|
||||
contentMap.set(skill.id, { version: extractVersion(skill.content), content: skill.content })
|
||||
}
|
||||
if (catalog) {
|
||||
for (const skill of catalog.skills) {
|
||||
contentMap.set(skill.id, { version: skill.version, content: skill.content })
|
||||
}
|
||||
}
|
||||
|
||||
for (const [id, source] of contentMap) {
|
||||
try {
|
||||
await syncOneSkill(id, source)
|
||||
} catch (err) {
|
||||
logger.warn('Failed to sync builtin skill', {
|
||||
id,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (catalog) await removeObsoleteSkills(contentMap)
|
||||
}
|
||||
|
||||
export async function writeSkillFile(
|
||||
skillId: string,
|
||||
content: string,
|
||||
async function syncOneSkill(
|
||||
id: string,
|
||||
source: { version: string; content: string },
|
||||
): Promise<void> {
|
||||
const safeDir = safeSkillDir(skillId)
|
||||
await mkdir(safeDir, { recursive: true })
|
||||
await writeFile(join(safeDir, 'SKILL.md'), content)
|
||||
}
|
||||
const dir = safeBuiltinSkillDir(id)
|
||||
const filePath = join(dir, 'SKILL.md')
|
||||
|
||||
export async function syncRemoteSkills(): Promise<{
|
||||
installed: number
|
||||
updated: number
|
||||
}> {
|
||||
const result = { installed: 0, updated: 0 }
|
||||
const catalog = await fetchRemoteCatalog()
|
||||
if (!catalog) return result
|
||||
|
||||
for (const remoteSkill of catalog.skills) {
|
||||
try {
|
||||
const localVersion = await getLocalVersion(remoteSkill.id)
|
||||
|
||||
if (!localVersion) {
|
||||
await writeSkillFile(remoteSkill.id, remoteSkill.content)
|
||||
result.installed++
|
||||
continue
|
||||
}
|
||||
|
||||
if (localVersion === remoteSkill.version) {
|
||||
continue
|
||||
}
|
||||
|
||||
await writeSkillFile(remoteSkill.id, remoteSkill.content)
|
||||
result.updated++
|
||||
} catch (err) {
|
||||
logger.warn('Failed to sync skill', {
|
||||
id: remoteSkill.id,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
export async function seedFromRemote(): Promise<boolean> {
|
||||
const catalog = await fetchRemoteCatalog()
|
||||
if (!catalog || catalog.skills.length === 0) return false
|
||||
|
||||
let seeded = 0
|
||||
|
||||
for (const skill of catalog.skills) {
|
||||
try {
|
||||
await writeSkillFile(skill.id, skill.content)
|
||||
seeded++
|
||||
} catch (err) {
|
||||
logger.warn('Failed to seed remote skill', {
|
||||
id: skill.id,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (seeded > 0) {
|
||||
logger.info(`Seeded ${seeded}/${catalog.skills.length} skills from remote catalog`)
|
||||
}
|
||||
|
||||
return seeded === catalog.skills.length
|
||||
}
|
||||
|
||||
async function runSync(): Promise<void> {
|
||||
let localContent: string | null = null
|
||||
try {
|
||||
const { installed, updated } = await syncRemoteSkills()
|
||||
if (installed > 0 || updated > 0) {
|
||||
logger.info('Remote skill sync completed', { installed, updated })
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn('Skill sync failed', {
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
localContent = await readFile(filePath, 'utf-8')
|
||||
} catch {}
|
||||
|
||||
if (localContent && extractVersion(localContent) === source.version) return
|
||||
|
||||
let content = source.content
|
||||
if (localContent && extractEnabled(localContent) === 'false') {
|
||||
content = setEnabled(content, 'false')
|
||||
}
|
||||
|
||||
await mkdir(dir, { recursive: true })
|
||||
await writeFile(filePath, content)
|
||||
}
|
||||
|
||||
async function removeObsoleteSkills(
|
||||
keepIds: Map<string, unknown>,
|
||||
): Promise<void> {
|
||||
const builtinDir = getBuiltinSkillsDir()
|
||||
let entries: string[]
|
||||
try {
|
||||
entries = await readdir(builtinDir)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.startsWith('.') || keepIds.has(entry)) continue
|
||||
try {
|
||||
const entryPath = join(builtinDir, entry)
|
||||
const s = await stat(entryPath)
|
||||
if (s.isDirectory()) await rm(entryPath, { recursive: true })
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
export function startSkillSync(): void {
|
||||
if (syncTimer) return
|
||||
|
||||
runSync()
|
||||
|
||||
syncTimer = setInterval(runSync, TIMEOUTS.SKILLS_SYNC_INTERVAL)
|
||||
syncTimer = setInterval(() => {
|
||||
syncBuiltinSkills().catch((err) => {
|
||||
logger.warn('Skill sync failed', {
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
})
|
||||
}, TIMEOUTS.SKILLS_SYNC_INTERVAL)
|
||||
syncTimer.unref()
|
||||
}
|
||||
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
import { readdir, stat } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { getSkillsDir } from '../lib/browseros-dir'
|
||||
import { logger } from '../lib/logger'
|
||||
import { DEFAULT_SKILLS } from './defaults'
|
||||
import { seedFromRemote, writeSkillFile } from './remote-sync'
|
||||
|
||||
async function hasExistingSkills(skillsDir: string): Promise<boolean> {
|
||||
try {
|
||||
const entries = await readdir(skillsDir)
|
||||
return entries.some((e) => !e.startsWith('.'))
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function skillExists(skillsDir: string, id: string): Promise<boolean> {
|
||||
try {
|
||||
await stat(join(skillsDir, id, 'SKILL.md'))
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export async function seedDefaultSkills(): Promise<void> {
|
||||
const skillsDir = getSkillsDir()
|
||||
if (await hasExistingSkills(skillsDir)) return
|
||||
|
||||
const remoteSucceeded = await seedFromRemote()
|
||||
if (remoteSucceeded) return
|
||||
|
||||
let seeded = 0
|
||||
for (const skill of DEFAULT_SKILLS) {
|
||||
if (await skillExists(skillsDir, skill.id)) continue
|
||||
try {
|
||||
await writeSkillFile(skill.id, skill.content)
|
||||
seeded++
|
||||
} catch (err) {
|
||||
logger.warn('Failed to seed skill', {
|
||||
id: skill.id,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (seeded > 0) {
|
||||
logger.info(`Seeded ${seeded} default skills (bundled)`)
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises'
|
||||
import { join, resolve, sep } from 'node:path'
|
||||
import matter from 'gray-matter'
|
||||
import { getSkillsDir } from '../lib/browseros-dir'
|
||||
import { getBuiltinSkillsDir, getSkillsDir } from '../lib/browseros-dir'
|
||||
import { logger } from '../lib/logger'
|
||||
import { isValidFrontmatter, loadAllSkills } from './loader'
|
||||
import type {
|
||||
@@ -28,6 +28,15 @@ export function safeSkillDir(id: string): string {
|
||||
return resolved
|
||||
}
|
||||
|
||||
export function safeBuiltinSkillDir(id: string): string {
|
||||
const builtinDir = getBuiltinSkillsDir()
|
||||
const resolved = resolve(builtinDir, id)
|
||||
if (!resolved.startsWith(`${builtinDir}${sep}`)) {
|
||||
throw new Error('Invalid skill id')
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
|
||||
function buildSkillMd(frontmatter: SkillFrontmatter, content: string): string {
|
||||
return matter.stringify(content, frontmatter)
|
||||
}
|
||||
@@ -41,14 +50,29 @@ async function fileExists(filePath: string): Promise<boolean> {
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveSkillDir(
|
||||
id: string,
|
||||
): Promise<{ dir: string; builtIn: boolean } | null> {
|
||||
const userDir = safeSkillDir(id)
|
||||
if (await fileExists(join(userDir, 'SKILL.md'))) {
|
||||
return { dir: userDir, builtIn: false }
|
||||
}
|
||||
const builtinDir = safeBuiltinSkillDir(id)
|
||||
if (await fileExists(join(builtinDir, 'SKILL.md'))) {
|
||||
return { dir: builtinDir, builtIn: true }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export async function listSkills(): Promise<SkillMeta[]> {
|
||||
return loadAllSkills(getSkillsDir())
|
||||
return loadAllSkills()
|
||||
}
|
||||
|
||||
export async function getSkill(id: string): Promise<SkillDetail | null> {
|
||||
const skillMdPath = join(safeSkillDir(id), 'SKILL.md')
|
||||
if (!(await fileExists(skillMdPath))) return null
|
||||
const resolved = await resolveSkillDir(id)
|
||||
if (!resolved) return null
|
||||
|
||||
const skillMdPath = join(resolved.dir, 'SKILL.md')
|
||||
try {
|
||||
const raw = await readFile(skillMdPath, 'utf-8')
|
||||
const parsed = matter(raw)
|
||||
@@ -66,6 +90,7 @@ export async function getSkill(id: string): Promise<SkillDetail | null> {
|
||||
location: skillMdPath,
|
||||
enabled: meta?.enabled !== 'false',
|
||||
version: meta?.version,
|
||||
builtIn: resolved.builtIn,
|
||||
content: parsed.content.trim(),
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -81,11 +106,14 @@ export async function createSkill(input: CreateSkillInput): Promise<SkillMeta> {
|
||||
const id = slugify(input.name)
|
||||
if (!id) throw new Error('Invalid skill name')
|
||||
|
||||
const dirPath = safeSkillDir(id)
|
||||
if (await fileExists(join(dirPath, 'SKILL.md'))) {
|
||||
if (await fileExists(join(safeSkillDir(id), 'SKILL.md'))) {
|
||||
throw new Error(`Skill "${id}" already exists`)
|
||||
}
|
||||
if (await fileExists(join(safeBuiltinSkillDir(id), 'SKILL.md'))) {
|
||||
throw new Error(`Skill "${id}" already exists`)
|
||||
}
|
||||
|
||||
const dirPath = safeSkillDir(id)
|
||||
await mkdir(dirPath, { recursive: true })
|
||||
const frontmatter: SkillFrontmatter = {
|
||||
name: id,
|
||||
@@ -106,6 +134,7 @@ export async function createSkill(input: CreateSkillInput): Promise<SkillMeta> {
|
||||
description: input.description,
|
||||
location: join(dirPath, 'SKILL.md'),
|
||||
enabled: true,
|
||||
builtIn: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,11 +142,10 @@ export async function updateSkill(
|
||||
id: string,
|
||||
input: UpdateSkillInput,
|
||||
): Promise<SkillMeta> {
|
||||
const skillMdPath = join(safeSkillDir(id), 'SKILL.md')
|
||||
if (!(await fileExists(skillMdPath))) {
|
||||
throw new Error(`Skill "${id}" not found`)
|
||||
}
|
||||
const resolved = await resolveSkillDir(id)
|
||||
if (!resolved) throw new Error(`Skill "${id}" not found`)
|
||||
|
||||
const skillMdPath = join(resolved.dir, 'SKILL.md')
|
||||
const raw = await readFile(skillMdPath, 'utf-8')
|
||||
const parsed = matter(raw)
|
||||
if (!isValidFrontmatter(parsed.data)) {
|
||||
@@ -152,13 +180,13 @@ export async function updateSkill(
|
||||
location: skillMdPath,
|
||||
enabled,
|
||||
version: existingMeta.version,
|
||||
builtIn: resolved.builtIn,
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteSkill(id: string): Promise<void> {
|
||||
const dirPath = safeSkillDir(id)
|
||||
if (!(await fileExists(join(dirPath, 'SKILL.md')))) {
|
||||
throw new Error(`Skill "${id}" not found`)
|
||||
}
|
||||
await rm(dirPath, { recursive: true })
|
||||
const resolved = await resolveSkillDir(id)
|
||||
if (!resolved) throw new Error(`Skill "${id}" not found`)
|
||||
if (resolved.builtIn) throw new Error('Cannot delete built-in skill')
|
||||
await rm(resolved.dir, { recursive: true })
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ export type SkillMeta = {
|
||||
location: string
|
||||
enabled: boolean
|
||||
version?: string
|
||||
builtIn: boolean
|
||||
}
|
||||
|
||||
export type SkillDetail = SkillMeta & {
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
/**
|
||||
* E2E flow tests against live CDN.
|
||||
*/
|
||||
|
||||
import { afterAll, beforeAll, describe, it, mock } from 'bun:test'
|
||||
import assert from 'node:assert'
|
||||
import { mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
@@ -9,9 +5,11 @@ import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
let testDir: string
|
||||
let builtinDir: string
|
||||
|
||||
mock.module('../../src/lib/browseros-dir', () => ({
|
||||
getSkillsDir: () => testDir,
|
||||
getBuiltinSkillsDir: () => builtinDir,
|
||||
}))
|
||||
|
||||
mock.module('../../src/env', () => ({
|
||||
@@ -20,17 +18,12 @@ mock.module('../../src/env', () => ({
|
||||
},
|
||||
}))
|
||||
|
||||
const { seedFromRemote, syncRemoteSkills } =
|
||||
await import('../../src/skills/remote-sync')
|
||||
|
||||
async function listSkills(): Promise<string[]> {
|
||||
const entries = await readdir(testDir)
|
||||
return entries.filter((e) => !e.startsWith('.')).sort()
|
||||
}
|
||||
const { syncBuiltinSkills } = await import('../../src/skills/remote-sync')
|
||||
|
||||
beforeAll(async () => {
|
||||
testDir = join(tmpdir(), `flow-test-${Date.now()}`)
|
||||
await mkdir(testDir, { recursive: true })
|
||||
builtinDir = join(testDir, 'builtin')
|
||||
await mkdir(builtinDir, { recursive: true })
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
@@ -38,51 +31,44 @@ afterAll(async () => {
|
||||
})
|
||||
|
||||
describe('Flow tests against live CDN', () => {
|
||||
it('seeds all skills from CDN on fresh install', async () => {
|
||||
const result = await seedFromRemote()
|
||||
assert.strictEqual(result, true)
|
||||
const skills = await listSkills()
|
||||
it('syncs all skills from CDN on fresh install', async () => {
|
||||
await syncBuiltinSkills()
|
||||
const entries = await readdir(builtinDir)
|
||||
const skills = entries.filter((e) => !e.startsWith('.'))
|
||||
assert.strictEqual(skills.length, 12)
|
||||
})
|
||||
|
||||
it('sync does nothing when already up to date', async () => {
|
||||
const result = await syncRemoteSkills()
|
||||
assert.strictEqual(result.installed, 0)
|
||||
assert.strictEqual(result.updated, 0)
|
||||
})
|
||||
it('preserves disabled state during sync', async () => {
|
||||
const skillPath = join(builtinDir, 'summarize-page', 'SKILL.md')
|
||||
let content = await readFile(skillPath, 'utf-8')
|
||||
|
||||
it('remote overwrites local edits when version differs', async () => {
|
||||
const skillPath = join(testDir, 'summarize-page', 'SKILL.md')
|
||||
const original = await readFile(skillPath, 'utf-8')
|
||||
content = content.replace(/enabled: "true"/, 'enabled: "false"')
|
||||
content = content.replace(/version: "1.0"/, 'version: "0.9"')
|
||||
await writeFile(skillPath, content)
|
||||
|
||||
// User edits the file AND we fake a version mismatch
|
||||
const edited = original.replace(/version: "1.0"/, 'version: "0.9"') + '\n## My Notes\n'
|
||||
await writeFile(skillPath, edited)
|
||||
|
||||
const result = await syncRemoteSkills()
|
||||
assert.strictEqual(result.updated >= 1, true)
|
||||
await syncBuiltinSkills()
|
||||
|
||||
const afterSync = await readFile(skillPath, 'utf-8')
|
||||
assert.ok(!afterSync.includes('My Notes'))
|
||||
assert.ok(
|
||||
afterSync.includes('enabled: "false"') || afterSync.includes("enabled: 'false'"),
|
||||
'disabled state should be preserved',
|
||||
)
|
||||
})
|
||||
|
||||
it('installs skill deleted locally', async () => {
|
||||
await rm(join(testDir, 'save-page'), { recursive: true })
|
||||
|
||||
const result = await syncRemoteSkills()
|
||||
assert.strictEqual(result.installed, 1)
|
||||
|
||||
const content = await readFile(join(testDir, 'save-page', 'SKILL.md'), 'utf-8')
|
||||
it('reinstalls deleted builtin skill', async () => {
|
||||
await rm(join(builtinDir, 'save-page'), { recursive: true })
|
||||
await syncBuiltinSkills()
|
||||
const content = await readFile(join(builtinDir, 'save-page', 'SKILL.md'), 'utf-8')
|
||||
assert.ok(content.includes('name: save-page'))
|
||||
})
|
||||
|
||||
it('user-created skill is never touched', async () => {
|
||||
it('never touches user-created skill in root', async () => {
|
||||
const customDir = join(testDir, 'my-workflow')
|
||||
await mkdir(customDir, { recursive: true })
|
||||
const custom = '---\nname: my-workflow\ndescription: custom\n---\n# Mine\n'
|
||||
await writeFile(join(customDir, 'SKILL.md'), custom)
|
||||
|
||||
await syncRemoteSkills()
|
||||
await syncBuiltinSkills()
|
||||
|
||||
const afterSync = await readFile(join(customDir, 'SKILL.md'), 'utf-8')
|
||||
assert.strictEqual(afterSync, custom)
|
||||
|
||||
113
packages/browseros-agent/apps/server/tests/skills/loader.test.ts
Normal file
113
packages/browseros-agent/apps/server/tests/skills/loader.test.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import { afterEach, beforeEach, describe, it, mock } from 'bun:test'
|
||||
import assert from 'node:assert'
|
||||
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
let testDir: string
|
||||
let builtinDir: string
|
||||
|
||||
beforeEach(async () => {
|
||||
testDir = await mkdtemp(join(tmpdir(), 'loader-test-'))
|
||||
builtinDir = join(testDir, 'builtin')
|
||||
await mkdir(builtinDir, { recursive: true })
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(testDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
mock.module('../../src/lib/browseros-dir', () => ({
|
||||
getSkillsDir: () => testDir,
|
||||
getBuiltinSkillsDir: () => builtinDir,
|
||||
}))
|
||||
|
||||
const { loadAllSkills, loadSkills } = await import('../../src/skills/loader')
|
||||
|
||||
const BUILTIN_SKILL = `---
|
||||
name: summarize-page
|
||||
description: Summarize a page
|
||||
metadata:
|
||||
display-name: Summarize Page
|
||||
enabled: "true"
|
||||
version: "1.0"
|
||||
---
|
||||
|
||||
# Summarize Page
|
||||
`
|
||||
|
||||
const BUILTIN_DISABLED = `---
|
||||
name: deep-research
|
||||
description: Research a topic
|
||||
metadata:
|
||||
display-name: Deep Research
|
||||
enabled: "false"
|
||||
version: "1.0"
|
||||
---
|
||||
|
||||
# Deep Research
|
||||
`
|
||||
|
||||
const USER_SKILL = `---
|
||||
name: my-workflow
|
||||
description: My custom workflow
|
||||
metadata:
|
||||
display-name: My Workflow
|
||||
enabled: "true"
|
||||
---
|
||||
|
||||
# My Workflow
|
||||
`
|
||||
|
||||
describe('loader two-directory scanning', () => {
|
||||
it('marks builtin/ skills as builtIn: true', async () => {
|
||||
await mkdir(join(builtinDir, 'summarize-page'), { recursive: true })
|
||||
await writeFile(join(builtinDir, 'summarize-page', 'SKILL.md'), BUILTIN_SKILL)
|
||||
|
||||
const skills = await loadAllSkills()
|
||||
const skill = skills.find((s) => s.id === 'summarize-page')
|
||||
assert.ok(skill)
|
||||
assert.strictEqual(skill.builtIn, true)
|
||||
})
|
||||
|
||||
it('marks root skills as builtIn: false', async () => {
|
||||
await mkdir(join(testDir, 'my-workflow'), { recursive: true })
|
||||
await writeFile(join(testDir, 'my-workflow', 'SKILL.md'), USER_SKILL)
|
||||
|
||||
const skills = await loadAllSkills()
|
||||
const skill = skills.find((s) => s.id === 'my-workflow')
|
||||
assert.ok(skill)
|
||||
assert.strictEqual(skill.builtIn, false)
|
||||
})
|
||||
|
||||
it('merges skills from both directories', async () => {
|
||||
await mkdir(join(builtinDir, 'summarize-page'), { recursive: true })
|
||||
await writeFile(join(builtinDir, 'summarize-page', 'SKILL.md'), BUILTIN_SKILL)
|
||||
await mkdir(join(testDir, 'my-workflow'), { recursive: true })
|
||||
await writeFile(join(testDir, 'my-workflow', 'SKILL.md'), USER_SKILL)
|
||||
|
||||
const skills = await loadAllSkills()
|
||||
assert.strictEqual(skills.length, 2)
|
||||
})
|
||||
|
||||
it('skips builtin/ subdirectory when scanning root', async () => {
|
||||
await mkdir(join(builtinDir, 'summarize-page'), { recursive: true })
|
||||
await writeFile(join(builtinDir, 'summarize-page', 'SKILL.md'), BUILTIN_SKILL)
|
||||
|
||||
const skills = await loadAllSkills()
|
||||
const dupes = skills.filter((s) => s.id === 'summarize-page')
|
||||
assert.strictEqual(dupes.length, 1)
|
||||
assert.strictEqual(dupes[0].builtIn, true)
|
||||
})
|
||||
|
||||
it('loadSkills filters out disabled skills', async () => {
|
||||
await mkdir(join(builtinDir, 'summarize-page'), { recursive: true })
|
||||
await writeFile(join(builtinDir, 'summarize-page', 'SKILL.md'), BUILTIN_SKILL)
|
||||
await mkdir(join(builtinDir, 'deep-research'), { recursive: true })
|
||||
await writeFile(join(builtinDir, 'deep-research', 'SKILL.md'), BUILTIN_DISABLED)
|
||||
|
||||
const skills = await loadSkills()
|
||||
assert.strictEqual(skills.length, 1)
|
||||
assert.strictEqual(skills[0].id, 'summarize-page')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,81 @@
|
||||
import { afterEach, beforeEach, describe, it, mock } from 'bun:test'
|
||||
import assert from 'node:assert'
|
||||
import { mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
let testDir: string
|
||||
let builtinDir: string
|
||||
|
||||
beforeEach(async () => {
|
||||
testDir = await mkdtemp(join(tmpdir(), 'migrate-test-'))
|
||||
builtinDir = join(testDir, 'builtin')
|
||||
await mkdir(builtinDir, { recursive: true })
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(testDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
mock.module('../../src/lib/browseros-dir', () => ({
|
||||
getSkillsDir: () => testDir,
|
||||
getBuiltinSkillsDir: () => builtinDir,
|
||||
}))
|
||||
|
||||
const { migrateBuiltinSkills } = await import('../../src/skills/migrate')
|
||||
|
||||
const SKILL_CONTENT = `---
|
||||
name: summarize-page
|
||||
description: Summarize a page
|
||||
metadata:
|
||||
display-name: Summarize Page
|
||||
enabled: "false"
|
||||
version: "1.0"
|
||||
---
|
||||
|
||||
# Summarize Page
|
||||
`
|
||||
|
||||
describe('migrateBuiltinSkills', () => {
|
||||
it('moves default skills from root to builtin/', async () => {
|
||||
await mkdir(join(testDir, 'summarize-page'), { recursive: true })
|
||||
await writeFile(join(testDir, 'summarize-page', 'SKILL.md'), SKILL_CONTENT)
|
||||
|
||||
await migrateBuiltinSkills()
|
||||
|
||||
const content = await readFile(join(builtinDir, 'summarize-page', 'SKILL.md'), 'utf-8')
|
||||
assert.strictEqual(content, SKILL_CONTENT)
|
||||
|
||||
const oldExists = await stat(join(testDir, 'summarize-page')).then(() => true).catch(() => false)
|
||||
assert.strictEqual(oldExists, false)
|
||||
})
|
||||
|
||||
it('does not move user-created skills', async () => {
|
||||
const userContent = '---\nname: my-workflow\ndescription: mine\n---\n# Mine\n'
|
||||
await mkdir(join(testDir, 'my-workflow'), { recursive: true })
|
||||
await writeFile(join(testDir, 'my-workflow', 'SKILL.md'), userContent)
|
||||
|
||||
await migrateBuiltinSkills()
|
||||
|
||||
const content = await readFile(join(testDir, 'my-workflow', 'SKILL.md'), 'utf-8')
|
||||
assert.strictEqual(content, userContent)
|
||||
})
|
||||
|
||||
it('skips if builtin/ already has skills', async () => {
|
||||
await mkdir(join(builtinDir, 'summarize-page'), { recursive: true })
|
||||
await writeFile(join(builtinDir, 'summarize-page', 'SKILL.md'), SKILL_CONTENT)
|
||||
await mkdir(join(testDir, 'deep-research'), { recursive: true })
|
||||
await writeFile(join(testDir, 'deep-research', 'SKILL.md'), SKILL_CONTENT)
|
||||
|
||||
await migrateBuiltinSkills()
|
||||
|
||||
const stillInRoot = await stat(join(testDir, 'deep-research')).then(() => true).catch(() => false)
|
||||
assert.strictEqual(stillInRoot, true)
|
||||
})
|
||||
|
||||
it('is a no-op for fresh installs', async () => {
|
||||
await migrateBuiltinSkills()
|
||||
const entries = await readdir(builtinDir)
|
||||
assert.strictEqual(entries.filter((e: string) => !e.startsWith('.')).length, 0)
|
||||
})
|
||||
})
|
||||
@@ -1,19 +1,19 @@
|
||||
import { afterEach, beforeEach, describe, it, mock, spyOn } from 'bun:test'
|
||||
import assert from 'node:assert'
|
||||
import { mkdtemp, readFile, rm, writeFile, mkdir } from 'node:fs/promises'
|
||||
import { mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import type { RemoteSkillCatalog } from '../../src/skills/types'
|
||||
|
||||
let testDir: string
|
||||
|
||||
const mockGetSkillsDir = mock(() => testDir)
|
||||
let builtinDir: string
|
||||
|
||||
mock.module('../../src/lib/browseros-dir', () => ({
|
||||
getSkillsDir: mockGetSkillsDir,
|
||||
getSkillsDir: () => testDir,
|
||||
getBuiltinSkillsDir: () => builtinDir,
|
||||
}))
|
||||
|
||||
const { fetchRemoteCatalog, syncRemoteSkills, seedFromRemote } =
|
||||
const { fetchRemoteCatalog, syncBuiltinSkills } =
|
||||
await import('../../src/skills/remote-sync')
|
||||
|
||||
function makeCatalog(
|
||||
@@ -52,6 +52,8 @@ Do the thing better.
|
||||
|
||||
beforeEach(async () => {
|
||||
testDir = await mkdtemp(join(tmpdir(), 'skill-sync-'))
|
||||
builtinDir = join(testDir, 'builtin')
|
||||
await mkdir(builtinDir, { recursive: true })
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
@@ -82,112 +84,106 @@ describe('fetchRemoteCatalog', () => {
|
||||
assert.deepStrictEqual(await fetchRemoteCatalog(), catalog)
|
||||
spy.mockRestore()
|
||||
})
|
||||
|
||||
it('returns null for invalid catalog shape', async () => {
|
||||
const spy = spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
new Response(JSON.stringify({ skills: 'not-an-array' }), { status: 200 }),
|
||||
)
|
||||
assert.strictEqual(await fetchRemoteCatalog(), null)
|
||||
spy.mockRestore()
|
||||
})
|
||||
|
||||
it('returns null when skill entries have invalid shape', async () => {
|
||||
const spy = spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({ version: 1, skills: [{ id: 123, version: '1.0', content: null }] }),
|
||||
{ status: 200 },
|
||||
),
|
||||
)
|
||||
assert.strictEqual(await fetchRemoteCatalog(), null)
|
||||
spy.mockRestore()
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
describe('syncRemoteSkills', () => {
|
||||
it('returns zeros when remote is unavailable', async () => {
|
||||
const spy = spyOn(globalThis, 'fetch').mockRejectedValue(new Error('offline'))
|
||||
const result = await syncRemoteSkills()
|
||||
assert.deepStrictEqual(result, { installed: 0, updated: 0 })
|
||||
spy.mockRestore()
|
||||
})
|
||||
|
||||
it('installs new skills that do not exist locally', async () => {
|
||||
describe('syncBuiltinSkills', () => {
|
||||
it('installs from remote into builtin/', async () => {
|
||||
const spy = spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
new Response(JSON.stringify(makeCatalog([
|
||||
{ id: 'new-skill', version: '1.0', content: SKILL_V1 },
|
||||
])), { status: 200 }),
|
||||
)
|
||||
const result = await syncRemoteSkills()
|
||||
assert.strictEqual(result.installed, 1)
|
||||
|
||||
const content = await readFile(join(testDir, 'new-skill', 'SKILL.md'), 'utf-8')
|
||||
await syncBuiltinSkills()
|
||||
const content = await readFile(join(builtinDir, 'new-skill', 'SKILL.md'), 'utf-8')
|
||||
assert.strictEqual(content, SKILL_V1)
|
||||
spy.mockRestore()
|
||||
})
|
||||
|
||||
it('updates skill when remote has newer version', async () => {
|
||||
await mkdir(join(testDir, 'test-skill'), { recursive: true })
|
||||
await writeFile(join(testDir, 'test-skill', 'SKILL.md'), SKILL_V1)
|
||||
await mkdir(join(builtinDir, 'test-skill'), { recursive: true })
|
||||
await writeFile(join(builtinDir, 'test-skill', 'SKILL.md'), SKILL_V1)
|
||||
|
||||
const spy = spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
new Response(JSON.stringify(makeCatalog([
|
||||
{ id: 'test-skill', version: '2.0', content: SKILL_V2 },
|
||||
])), { status: 200 }),
|
||||
)
|
||||
const result = await syncRemoteSkills()
|
||||
assert.strictEqual(result.updated, 1)
|
||||
|
||||
const content = await readFile(join(testDir, 'test-skill', 'SKILL.md'), 'utf-8')
|
||||
await syncBuiltinSkills()
|
||||
const content = await readFile(join(builtinDir, 'test-skill', 'SKILL.md'), 'utf-8')
|
||||
assert.strictEqual(content, SKILL_V2)
|
||||
spy.mockRestore()
|
||||
})
|
||||
|
||||
it('overwrites user-edited skill when remote has newer version', async () => {
|
||||
await mkdir(join(testDir, 'test-skill'), { recursive: true })
|
||||
await writeFile(join(testDir, 'test-skill', 'SKILL.md'), SKILL_V1 + '\n## My Notes\n')
|
||||
|
||||
const spy = spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
new Response(JSON.stringify(makeCatalog([
|
||||
{ id: 'test-skill', version: '2.0', content: SKILL_V2 },
|
||||
])), { status: 200 }),
|
||||
)
|
||||
const result = await syncRemoteSkills()
|
||||
assert.strictEqual(result.updated, 1)
|
||||
|
||||
const content = await readFile(join(testDir, 'test-skill', 'SKILL.md'), 'utf-8')
|
||||
assert.strictEqual(content, SKILL_V2)
|
||||
assert.ok(!content.includes('My Notes'))
|
||||
spy.mockRestore()
|
||||
})
|
||||
|
||||
it('skips when version matches', async () => {
|
||||
await mkdir(join(testDir, 'test-skill'), { recursive: true })
|
||||
await writeFile(join(testDir, 'test-skill', 'SKILL.md'), SKILL_V1)
|
||||
await mkdir(join(builtinDir, 'test-skill'), { recursive: true })
|
||||
await writeFile(join(builtinDir, 'test-skill', 'SKILL.md'), SKILL_V1)
|
||||
|
||||
const spy = spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
new Response(JSON.stringify(makeCatalog([
|
||||
{ id: 'test-skill', version: '1.0', content: SKILL_V1 },
|
||||
])), { status: 200 }),
|
||||
)
|
||||
const result = await syncRemoteSkills()
|
||||
assert.strictEqual(result.installed, 0)
|
||||
assert.strictEqual(result.updated, 0)
|
||||
await syncBuiltinSkills()
|
||||
const content = await readFile(join(builtinDir, 'test-skill', 'SKILL.md'), 'utf-8')
|
||||
assert.strictEqual(content, SKILL_V1)
|
||||
spy.mockRestore()
|
||||
})
|
||||
|
||||
it('does not touch user-created skills not in catalog', async () => {
|
||||
it('preserves enabled:false when updating', async () => {
|
||||
const disabledV1 = SKILL_V1.replace('enabled: "true"', 'enabled: "false"')
|
||||
await mkdir(join(builtinDir, 'test-skill'), { recursive: true })
|
||||
await writeFile(join(builtinDir, 'test-skill', 'SKILL.md'), disabledV1)
|
||||
|
||||
const spy = spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
new Response(JSON.stringify(makeCatalog([
|
||||
{ id: 'test-skill', version: '2.0', content: SKILL_V2 },
|
||||
])), { status: 200 }),
|
||||
)
|
||||
await syncBuiltinSkills()
|
||||
const content = await readFile(join(builtinDir, 'test-skill', 'SKILL.md'), 'utf-8')
|
||||
assert.ok(content.includes('v2'), 'should have v2 content')
|
||||
assert.ok(
|
||||
content.includes('enabled: "false"') || content.includes("enabled: 'false'"),
|
||||
'should preserve disabled state',
|
||||
)
|
||||
spy.mockRestore()
|
||||
})
|
||||
|
||||
it('falls back to bundled defaults when offline', async () => {
|
||||
const spy = spyOn(globalThis, 'fetch').mockRejectedValue(new Error('offline'))
|
||||
await syncBuiltinSkills()
|
||||
const entries = await readdir(builtinDir)
|
||||
const skills = entries.filter((e: string) => !e.startsWith('.'))
|
||||
assert.ok(skills.length > 0, 'should have bundled defaults')
|
||||
spy.mockRestore()
|
||||
})
|
||||
|
||||
it('removes builtin skill not in catalog', async () => {
|
||||
await mkdir(join(builtinDir, 'old-skill'), { recursive: true })
|
||||
await writeFile(join(builtinDir, 'old-skill', 'SKILL.md'), SKILL_V1)
|
||||
|
||||
const spy = spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
new Response(JSON.stringify(makeCatalog([
|
||||
{ id: 'other-skill', version: '1.0', content: SKILL_V2 },
|
||||
])), { status: 200 }),
|
||||
)
|
||||
await syncBuiltinSkills()
|
||||
const exists = await stat(join(builtinDir, 'old-skill')).then(() => true).catch(() => false)
|
||||
assert.strictEqual(exists, false)
|
||||
spy.mockRestore()
|
||||
})
|
||||
|
||||
it('does not touch user skills in root', async () => {
|
||||
const custom = '---\nname: my-custom\ndescription: mine\n---\n# Mine\n'
|
||||
await mkdir(join(testDir, 'my-custom'), { recursive: true })
|
||||
const custom = '---\nname: my-custom\ndescription: mine\nmetadata:\n version: "1.0"\n---\n# Mine\n'
|
||||
await writeFile(join(testDir, 'my-custom', 'SKILL.md'), custom)
|
||||
|
||||
const spy = spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
new Response(JSON.stringify(makeCatalog([
|
||||
{ id: 'other-skill', version: '1.0', content: SKILL_V1 },
|
||||
{ id: 'test-skill', version: '1.0', content: SKILL_V1 },
|
||||
])), { status: 200 }),
|
||||
)
|
||||
await syncRemoteSkills()
|
||||
|
||||
await syncBuiltinSkills()
|
||||
const content = await readFile(join(testDir, 'my-custom', 'SKILL.md'), 'utf-8')
|
||||
assert.strictEqual(content, custom)
|
||||
spy.mockRestore()
|
||||
@@ -199,49 +195,9 @@ describe('syncRemoteSkills', () => {
|
||||
{ id: '../../etc/evil', version: '1.0', content: SKILL_V1 },
|
||||
])), { status: 200 }),
|
||||
)
|
||||
const result = await syncRemoteSkills()
|
||||
assert.strictEqual(result.installed, 0)
|
||||
spy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe('seedFromRemote', () => {
|
||||
it('returns false when remote is unavailable', async () => {
|
||||
const spy = spyOn(globalThis, 'fetch').mockRejectedValue(new Error('offline'))
|
||||
assert.strictEqual(await seedFromRemote(), false)
|
||||
spy.mockRestore()
|
||||
})
|
||||
|
||||
it('seeds all skills from remote', async () => {
|
||||
const spy = spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
new Response(JSON.stringify(makeCatalog([
|
||||
{ id: 'skill-a', version: '1.0', content: SKILL_V1 },
|
||||
{ id: 'skill-b', version: '1.0', content: SKILL_V2 },
|
||||
])), { status: 200 }),
|
||||
)
|
||||
assert.strictEqual(await seedFromRemote(), true)
|
||||
|
||||
const content = await readFile(join(testDir, 'skill-a', 'SKILL.md'), 'utf-8')
|
||||
assert.strictEqual(content, SKILL_V1)
|
||||
spy.mockRestore()
|
||||
})
|
||||
|
||||
it('returns false for empty catalog', async () => {
|
||||
const spy = spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
new Response(JSON.stringify(makeCatalog([])), { status: 200 }),
|
||||
)
|
||||
assert.strictEqual(await seedFromRemote(), false)
|
||||
spy.mockRestore()
|
||||
})
|
||||
|
||||
it('returns false on partial failure', async () => {
|
||||
const spy = spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
new Response(JSON.stringify(makeCatalog([
|
||||
{ id: 'good-skill', version: '1.0', content: SKILL_V1 },
|
||||
{ id: '../../traversal', version: '1.0', content: 'evil' },
|
||||
])), { status: 200 }),
|
||||
)
|
||||
assert.strictEqual(await seedFromRemote(), false)
|
||||
await syncBuiltinSkills()
|
||||
const exists = await stat(join(builtinDir, '..', '..', 'etc', 'evil')).then(() => true).catch(() => false)
|
||||
assert.strictEqual(exists, false)
|
||||
spy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,27 +1,96 @@
|
||||
import { describe, it } from 'bun:test'
|
||||
import { afterEach, beforeEach, describe, it, mock } from 'bun:test'
|
||||
import assert from 'node:assert'
|
||||
import { sep } from 'node:path'
|
||||
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
import { createSkillsRoutes } from '../../src/api/routes/skills'
|
||||
let testDir: string
|
||||
let builtinDir: string
|
||||
|
||||
describe('skills routes', () => {
|
||||
const app = createSkillsRoutes()
|
||||
beforeEach(async () => {
|
||||
testDir = await mkdtemp(join(tmpdir(), 'service-test-'))
|
||||
builtinDir = join(testDir, 'builtin')
|
||||
await mkdir(builtinDir, { recursive: true })
|
||||
})
|
||||
|
||||
it('GET /:id returns 404 for non-existent skill (not 500 from path check)', async () => {
|
||||
const res = await app.request('/valid-skill-id')
|
||||
assert.strictEqual(res.status, 404)
|
||||
const body = await res.json()
|
||||
assert.strictEqual(body.error, 'Skill not found')
|
||||
afterEach(async () => {
|
||||
await rm(testDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
mock.module('../../src/lib/browseros-dir', () => ({
|
||||
getSkillsDir: () => testDir,
|
||||
getBuiltinSkillsDir: () => builtinDir,
|
||||
}))
|
||||
|
||||
const { createSkill, deleteSkill, getSkill, listSkills, updateSkill } =
|
||||
await import('../../src/skills/service')
|
||||
|
||||
const BUILTIN_SKILL = `---
|
||||
name: summarize-page
|
||||
description: Summarize a page
|
||||
metadata:
|
||||
display-name: Summarize Page
|
||||
enabled: "true"
|
||||
version: "1.0"
|
||||
---
|
||||
|
||||
# Summarize Page
|
||||
`
|
||||
|
||||
describe('getSkill', () => {
|
||||
it('finds builtin skill with builtIn: true', async () => {
|
||||
await mkdir(join(builtinDir, 'summarize-page'), { recursive: true })
|
||||
await writeFile(join(builtinDir, 'summarize-page', 'SKILL.md'), BUILTIN_SKILL)
|
||||
const skill = await getSkill('summarize-page')
|
||||
assert.ok(skill)
|
||||
assert.strictEqual(skill.builtIn, true)
|
||||
})
|
||||
|
||||
it('GET /:id rejects path traversal attempts', async () => {
|
||||
const res = await app.request('/../../../etc/passwd')
|
||||
assert.notStrictEqual(res.status, 200)
|
||||
it('finds user skill with builtIn: false', async () => {
|
||||
await createSkill({ name: 'My Skill', description: 'Custom', content: '# Custom' })
|
||||
const skill = await getSkill('my-skill')
|
||||
assert.ok(skill)
|
||||
assert.strictEqual(skill.builtIn, false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('safeSkillDir uses platform separator', () => {
|
||||
it(`path.sep is "${sep}" on this platform`, () => {
|
||||
assert.ok(sep === '/' || sep === '\\')
|
||||
describe('createSkill', () => {
|
||||
it('creates in user directory with builtIn: false', async () => {
|
||||
const skill = await createSkill({ name: 'My Skill', description: 'Custom', content: '# Custom' })
|
||||
assert.strictEqual(skill.builtIn, false)
|
||||
assert.ok(!skill.location.includes('builtin'))
|
||||
})
|
||||
|
||||
it('rejects if id collides with builtin skill', async () => {
|
||||
await mkdir(join(builtinDir, 'my-skill'), { recursive: true })
|
||||
await writeFile(join(builtinDir, 'my-skill', 'SKILL.md'), BUILTIN_SKILL)
|
||||
await assert.rejects(
|
||||
() => createSkill({ name: 'My Skill', description: 'Custom', content: '# Custom' }),
|
||||
/already exists/,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('updateSkill', () => {
|
||||
it('updates builtin skill in place', async () => {
|
||||
await mkdir(join(builtinDir, 'summarize-page'), { recursive: true })
|
||||
await writeFile(join(builtinDir, 'summarize-page', 'SKILL.md'), BUILTIN_SKILL)
|
||||
const updated = await updateSkill('summarize-page', { enabled: false })
|
||||
assert.strictEqual(updated.enabled, false)
|
||||
assert.strictEqual(updated.builtIn, true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('deleteSkill', () => {
|
||||
it('deletes user skill', async () => {
|
||||
await createSkill({ name: 'My Skill', description: 'Custom', content: '# Custom' })
|
||||
await deleteSkill('my-skill')
|
||||
assert.strictEqual(await getSkill('my-skill'), null)
|
||||
})
|
||||
|
||||
it('rejects deleting builtin skill', async () => {
|
||||
await mkdir(join(builtinDir, 'summarize-page'), { recursive: true })
|
||||
await writeFile(join(builtinDir, 'summarize-page', 'SKILL.md'), BUILTIN_SKILL)
|
||||
await assert.rejects(() => deleteSkill('summarize-page'), /Cannot delete built-in skill/)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -15,6 +15,7 @@ export const PATHS = {
|
||||
SOUL_FILE_NAME: 'SOUL.md',
|
||||
CORE_MEMORY_FILE_NAME: 'CORE.md',
|
||||
SKILLS_DIR_NAME: 'skills',
|
||||
BUILTIN_DIR_NAME: 'builtin',
|
||||
SOUL_MAX_LINES: 150,
|
||||
MEMORY_RETENTION_DAYS: 30,
|
||||
SESSION_RETENTION_DAYS: 30,
|
||||
|
||||
Reference in New Issue
Block a user