Compare commits

...

5 Commits

Author SHA1 Message Date
shivammittal274
e2c4486f13 refactor(skills): extract syncOneSkill, patch content before writing
- syncBuiltinSkills is now 15 lines: build map, iterate, clean up
- syncOneSkill: flat, patches enabled state before writing (single write)
- setEnabled: pure function for content patching
- removeObsoleteSkills: extracted from inline block
2026-03-19 02:04:12 +05:30
shivammittal274
d91ae391bf refactor(skills): simplify syncBuiltinSkills to single clean pass
Build content map (bundled + remote), iterate once, preserve enabled,
reconcile deletions. Removes 7 helper functions, 70 lines of code.
2026-03-19 02:02:19 +05:30
shivammittal274
111b977ea8 fix(skills): reconciliation preserves bundled defaults, delete returns 403
- reconcileRemovedSkills now keeps DEFAULT_SKILLS IDs in the safe set,
  preventing delete-then-reinstall cycle that lost enabled:false state
- DELETE /skills/:id returns 403 for built-in skills instead of 500
2026-03-19 01:54:23 +05:30
shivammittal274
42d4d14d24 fix(skills): address review — dedup by id, guard applyEnabled regex
- loader.ts: deduplication now keys on skill.id (directory slug) not
  skill.name (display name), preventing silent drops on name collision
- remote-sync.ts: applyEnabled checks if regex matched before writing,
  logs warning if remote content lacks an enabled field
2026-03-19 01:42:46 +05:30
shivammittal274
74b12d56d7 fix(skills): separate built-in and user skills into distinct directories
- Move built-in skills to ~/.browseros/skills/builtin/, user skills stay in root
- Unify seed + sync into single syncBuiltinSkills() function, delete seed.ts
- Preserve user's enabled/disabled state during remote sync version updates
- Add catalog reconciliation — remove built-in skills dropped from remote catalog
- Fallback to bundled defaults per-skill when remote sync fails
- One-time migration moves existing default skills from root to builtin/
- Add builtIn field to SkillMeta, determined by directory (not metadata)
- UI shows "Built-in" badge, hides delete button for built-in skills
- Reject deletion of built-in skills in service layer
- Check both dirs for ID collision on skill creation
2026-03-19 01:10:05 +05:30
18 changed files with 616 additions and 362 deletions

View File

@@ -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>

View File

@@ -7,6 +7,7 @@ export type SkillMeta = {
description: string
location: string
enabled: boolean
builtIn: boolean
}
export type SkillDetail = SkillMeta & {

View File

@@ -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

View File

@@ -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)
}
})

View File

@@ -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 })
}

View File

@@ -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,

View File

@@ -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)
}

View 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`)
}
}

View File

@@ -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()
}

View File

@@ -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)`)
}
}

View File

@@ -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 })
}

View File

@@ -23,6 +23,7 @@ export type SkillMeta = {
location: string
enabled: boolean
version?: string
builtIn: boolean
}
export type SkillDetail = SkillMeta & {

View File

@@ -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)

View 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')
})
})

View File

@@ -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)
})
})

View File

@@ -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()
})
})

View File

@@ -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/)
})
})

View File

@@ -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,