Compare commits

...

1 Commits

Author SHA1 Message Date
Felarof
f14e00fcb6 feat: separate system and user skills with enable/disable support
System skills (from remote sync and bundled defaults) are now tagged with
source: "system" in metadata and displayed in a separate "System Skills"
section. Users can enable/disable system skills but cannot delete them.
The sync process preserves user's enabled/disabled preference when updating.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-18 08:47:05 -07:00
8 changed files with 165 additions and 30 deletions

View File

@@ -53,6 +53,8 @@ export const SkillsPage: FC = () => {
const [editingSkill, setEditingSkill] = useState<SkillDetail | null>(null)
const [skillToDelete, setSkillToDelete] = useState<SkillMeta | null>(null)
const userSkills = skills.filter((s) => s.source !== 'system')
const systemSkills = skills.filter((s) => s.source === 'system')
const enabledCount = skills.filter((skill) => skill.enabled).length
const handleCreate = () => {
@@ -108,16 +110,30 @@ export const SkillsPage: FC = () => {
) : null}
{!isLoading && !error && skills.length > 0 ? (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-3">
{skills.map((skill) => (
<SkillCard
key={skill.id}
skill={skill}
onEdit={() => handleEdit(skill)}
onDelete={() => setSkillToDelete(skill)}
onToggle={(enabled) => handleToggle(skill, enabled)}
<div className="space-y-8">
<SkillsSection
title="My Skills"
subtitle="Custom skills you've created"
skills={userSkills}
showDelete
onEdit={handleEdit}
onDelete={setSkillToDelete}
onToggle={handleToggle}
emptyMessage={
'No custom skills yet. Click "New Skill" to create one.'
}
/>
{systemSkills.length > 0 ? (
<SkillsSection
title="System Skills"
subtitle="Built-in skills provided by BrowserOS"
skills={systemSkills}
onEdit={handleEdit}
onDelete={setSkillToDelete}
onToggle={handleToggle}
/>
))}
) : null}
</div>
) : null}
@@ -251,12 +267,58 @@ const EmptyState: FC<{ onCreateClick: () => void }> = ({ onCreateClick }) => (
</Card>
)
const SkillsSection: FC<{
title: string
subtitle: string
skills: SkillMeta[]
showDelete?: boolean
onEdit: (skill: SkillMeta) => void
onDelete: (skill: SkillMeta) => void
onToggle: (skill: SkillMeta, enabled: boolean) => void
emptyMessage?: string
}> = ({
title,
subtitle,
skills,
showDelete,
onEdit,
onDelete,
onToggle,
emptyMessage,
}) => (
<div className="space-y-3">
<div>
<h2 className="font-semibold text-sm">{title}</h2>
<p className="text-muted-foreground text-xs">{subtitle}</p>
</div>
{skills.length === 0 && emptyMessage ? (
<p className="py-4 text-center text-muted-foreground text-sm">
{emptyMessage}
</p>
) : (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-3">
{skills.map((skill) => (
<SkillCard
key={skill.id}
skill={skill}
showDelete={showDelete}
onEdit={() => onEdit(skill)}
onDelete={() => onDelete(skill)}
onToggle={(enabled) => onToggle(skill, enabled)}
/>
))}
</div>
)}
</div>
)
const SkillCard: FC<{
skill: SkillMeta
showDelete?: boolean
onEdit: () => void
onDelete: () => void
onToggle: (enabled: boolean) => void
}> = ({ skill, onEdit, onDelete, onToggle }) => (
}> = ({ skill, showDelete, onEdit, onDelete, onToggle }) => (
<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">
@@ -284,15 +346,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>
{showDelete ? (
<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

@@ -1,12 +1,15 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useAgentServerUrl } from '@/lib/browseros/useBrowserOSProviders'
export type SkillSource = 'system' | 'user'
export type SkillMeta = {
id: string
name: string
description: string
location: string
enabled: boolean
source: SkillSource
}
export type SkillDetail = SkillMeta & {

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('Cannot delete system')
? 403
: 500
return c.json({ error: msg }, status)
}
})

View File

@@ -2,7 +2,7 @@ import { readdir, readFile, stat } from 'node:fs/promises'
import { join } from 'node:path'
import matter from 'gray-matter'
import { logger } from '../lib/logger'
import type { SkillFrontmatter, SkillMeta } from './types'
import type { SkillFrontmatter, SkillMeta, SkillSource } from './types'
async function isDirectory(dirPath: string): Promise<boolean> {
try {
@@ -41,6 +41,7 @@ async function parseSkillFile(
}
const meta = data.metadata
const source: SkillSource = meta?.source === 'system' ? 'system' : 'user'
return {
id: dirName,
name: meta?.['display-name'] || data.name,
@@ -48,6 +49,7 @@ async function parseSkillFile(
location: skillMdPath,
enabled: meta?.enabled !== 'false',
version: meta?.version,
source,
}
} catch (err) {
logger.warn('Failed to parse skill', {

View File

@@ -2,8 +2,8 @@ import { mkdir, readFile, 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 matter from 'gray-matter'
import { INLINED_ENV } from '../env'
import { getSkillsDir } from '../lib/browseros-dir'
import { logger } from '../lib/logger'
import { safeSkillDir } from './service'
import type { RemoteSkillCatalog, RemoteSkillEntry } from './types'
@@ -74,6 +74,36 @@ async function getLocalVersion(skillId: string): Promise<string | null> {
}
}
async function getLocalEnabledState(skillId: string): Promise<string | null> {
try {
const safeDir = safeSkillDir(skillId)
const content = await readFile(join(safeDir, 'SKILL.md'), 'utf-8')
const { data } = matter(content)
const meta = data?.metadata as Record<string, string> | undefined
return meta?.enabled ?? null
} catch {
return null
}
}
export function ensureSystemSource(content: string): string {
const parsed = matter(content)
const data = parsed.data as Record<string, unknown>
const meta = (data.metadata ?? {}) as Record<string, string>
meta.source = 'system'
data.metadata = meta
return matter.stringify(parsed.content, data)
}
function setEnabledState(content: string, enabled: string): string {
const parsed = matter(content)
const data = parsed.data as Record<string, unknown>
const meta = (data.metadata ?? {}) as Record<string, string>
meta.enabled = enabled
data.metadata = meta
return matter.stringify(parsed.content, data)
}
export async function writeSkillFile(
skillId: string,
content: string,
@@ -94,9 +124,10 @@ export async function syncRemoteSkills(): Promise<{
for (const remoteSkill of catalog.skills) {
try {
const localVersion = await getLocalVersion(remoteSkill.id)
let content = ensureSystemSource(remoteSkill.content)
if (!localVersion) {
await writeSkillFile(remoteSkill.id, remoteSkill.content)
await writeSkillFile(remoteSkill.id, content)
result.installed++
continue
}
@@ -105,7 +136,12 @@ export async function syncRemoteSkills(): Promise<{
continue
}
await writeSkillFile(remoteSkill.id, remoteSkill.content)
const localEnabled = await getLocalEnabledState(remoteSkill.id)
if (localEnabled === 'false') {
content = setEnabledState(content, 'false')
}
await writeSkillFile(remoteSkill.id, content)
result.updated++
} catch (err) {
logger.warn('Failed to sync skill', {
@@ -126,7 +162,8 @@ export async function seedFromRemote(): Promise<boolean> {
for (const skill of catalog.skills) {
try {
await writeSkillFile(skill.id, skill.content)
const content = ensureSystemSource(skill.content)
await writeSkillFile(skill.id, content)
seeded++
} catch (err) {
logger.warn('Failed to seed remote skill', {
@@ -137,7 +174,9 @@ export async function seedFromRemote(): Promise<boolean> {
}
if (seeded > 0) {
logger.info(`Seeded ${seeded}/${catalog.skills.length} skills from remote catalog`)
logger.info(
`Seeded ${seeded}/${catalog.skills.length} skills from remote catalog`,
)
}
return seeded === catalog.skills.length

View File

@@ -3,7 +3,11 @@ 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'
import {
ensureSystemSource,
seedFromRemote,
writeSkillFile,
} from './remote-sync'
async function hasExistingSkills(skillsDir: string): Promise<boolean> {
try {
@@ -34,7 +38,8 @@ export async function seedDefaultSkills(): Promise<void> {
for (const skill of DEFAULT_SKILLS) {
if (await skillExists(skillsDir, skill.id)) continue
try {
await writeSkillFile(skill.id, skill.content)
const content = ensureSystemSource(skill.content)
await writeSkillFile(skill.id, content)
seeded++
} catch (err) {
logger.warn('Failed to seed skill', {

View File

@@ -9,6 +9,7 @@ import type {
SkillDetail,
SkillFrontmatter,
SkillMeta,
SkillSource,
UpdateSkillInput,
} from './types'
@@ -59,6 +60,7 @@ export async function getSkill(id: string): Promise<SkillDetail | null> {
}
const meta = parsed.data.metadata
const source: SkillSource = meta?.source === 'system' ? 'system' : 'user'
return {
id,
name: meta?.['display-name'] || parsed.data.name,
@@ -66,6 +68,7 @@ export async function getSkill(id: string): Promise<SkillDetail | null> {
location: skillMdPath,
enabled: meta?.enabled !== 'false',
version: meta?.version,
source,
content: parsed.content.trim(),
}
} catch (err) {
@@ -106,6 +109,7 @@ export async function createSkill(input: CreateSkillInput): Promise<SkillMeta> {
description: input.description,
location: join(dirPath, 'SKILL.md'),
enabled: true,
source: 'user',
}
}
@@ -145,6 +149,8 @@ export async function updateSkill(
await writeFile(skillMdPath, buildSkillMd(frontmatter, content))
const source: SkillSource =
existingMeta.source === 'system' ? 'system' : 'user'
return {
id,
name: displayName,
@@ -152,13 +158,23 @@ export async function updateSkill(
location: skillMdPath,
enabled,
version: existingMeta.version,
source,
}
}
export async function deleteSkill(id: string): Promise<void> {
const dirPath = safeSkillDir(id)
if (!(await fileExists(join(dirPath, 'SKILL.md')))) {
const skillMdPath = join(dirPath, 'SKILL.md')
if (!(await fileExists(skillMdPath))) {
throw new Error(`Skill "${id}" not found`)
}
const raw = await readFile(skillMdPath, 'utf-8')
const parsed = matter(raw)
const meta = parsed.data?.metadata as Record<string, string> | undefined
if (meta?.source === 'system') {
throw new Error(`Cannot delete system skill "${id}"`)
}
await rm(dirPath, { recursive: true })
}

View File

@@ -16,6 +16,8 @@ export type SkillFrontmatter = {
'allowed-tools'?: string
}
export type SkillSource = 'system' | 'user'
export type SkillMeta = {
id: string
name: string
@@ -23,6 +25,7 @@ export type SkillMeta = {
location: string
enabled: boolean
version?: string
source: SkillSource
}
export type SkillDetail = SkillMeta & {
@@ -49,4 +52,3 @@ export type RemoteSkillCatalog = {
version: number
skills: RemoteSkillEntry[]
}