Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/server/db/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ export const SETTINGS_KEYS = {
DISPLAY_COLLAPSE_PROVIDERS_BY_DEFAULT: 'display.collapseProvidersByDefault',
DISPLAY_COLLAPSE_FAVORITES_BY_DEFAULT: 'display.collapseFavoritesByDefault',
DISPLAY_MODEL_FAVORITES: 'display.modelFavorites',
DISPLAY_FULLSCREEN_SLASH_COMMAND: 'display.fullscreenSlashCommand',
LLM_DYNAMIC_SYSTEM_PROMPT: 'llm.dynamicSystemPrompt',
CACHE_WARMING: 'cache.warming',
KEYBINDINGS: 'keybindings',
Expand Down Expand Up @@ -76,6 +77,7 @@ export const SETTINGS_DEFAULTS: Record<string, string> = {
[SETTINGS_KEYS.DISPLAY_COLLAPSE_PROVIDERS_BY_DEFAULT]: 'false',
[SETTINGS_KEYS.DISPLAY_COLLAPSE_FAVORITES_BY_DEFAULT]: 'false',
[SETTINGS_KEYS.DISPLAY_MODEL_FAVORITES]: '[]',
[SETTINGS_KEYS.DISPLAY_FULLSCREEN_SLASH_COMMAND]: 'false',
[SETTINGS_KEYS.LLM_DYNAMIC_SYSTEM_PROMPT]: 'false',
[SETTINGS_KEYS.CACHE_WARMING]: 'false',
[SETTINGS_KEYS.RETRY_PATTERNS]: JSON.stringify({ patterns: [], maxRetriesPerTurn: 10 }),
Expand Down
33 changes: 28 additions & 5 deletions src/server/llm/proxy.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { createServer, type Server } from 'http'
import type { AddressInfo } from 'net'
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'

const { mockGetSetting } = vi.hoisted(() => ({
mockGetSetting: vi.fn(),
Expand Down Expand Up @@ -39,6 +41,27 @@ vi.mock('undici', () => {
import { __resetProxyCache } from './proxy.js'

describe('global fetch override', () => {
let localServer: Server
let localUrl: string

beforeAll(async () => {
localServer = createServer((_req, res) => {
res.writeHead(200, { 'Content-Type': 'text/plain' })
res.end('local-native-response')
})
await new Promise<void>((resolve) => {
localServer.listen(0, '127.0.0.1', () => resolve())
})
const addr = localServer.address() as AddressInfo
localUrl = `http://127.0.0.1:${addr.port}`
})

afterAll(async () => {
await new Promise<void>((resolve) => {
localServer.close(() => resolve())
})
})

beforeEach(() => {
vi.clearAllMocks()
mockProxyAgentInstances.length = 0
Expand All @@ -48,7 +71,7 @@ describe('global fetch override', () => {
it('calls native fetch when no proxy is configured', async () => {
mockGetSetting.mockReturnValue(null)

const result = await fetch('http://example.com')
const result = await fetch(localUrl)

expect(result).toBeInstanceOf(Response)
expect(mockUndiciFetch).not.toHaveBeenCalled()
Expand All @@ -57,7 +80,7 @@ describe('global fetch override', () => {
it('calls native fetch when proxy URL is empty string', async () => {
mockGetSetting.mockReturnValue('')

const result = await fetch('http://example.com')
const result = await fetch(localUrl)

expect(result).toBeInstanceOf(Response)
expect(mockUndiciFetch).not.toHaveBeenCalled()
Expand Down Expand Up @@ -138,7 +161,7 @@ describe('global fetch override', () => {
expect(oldAgent).toBeDefined()

mockGetSetting.mockReturnValue(null)
await fetch('http://example.com')
await fetch(localUrl)

expect(oldAgent!.destroy).toHaveBeenCalledTimes(1)
})
Expand All @@ -150,7 +173,7 @@ describe('global fetch override', () => {
expect(mockUndiciFetch).toHaveBeenCalledTimes(1)

mockGetSetting.mockReturnValue(null)
const result = await fetch('http://example.com')
const result = await fetch(localUrl)

expect(result).toBeInstanceOf(Response)
expect(mockUndiciFetch).toHaveBeenCalledTimes(1)
Expand Down
11 changes: 9 additions & 2 deletions src/server/routes/skills.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,13 +60,20 @@ describe('skill library routes', () => {
const response = await fetch(`${baseUrl}/api/skills`)
const body = (await response.json()) as {
selectedDirectory: { configuredPath: string; resolvedPath: string } | null
items: Array<{ id: string; source: string; readOnly: boolean }>
items: Array<{ id: string; source: string; readOnly: boolean; estimatedTokens?: number }>
defaults: unknown[]
userItems: unknown[]
projectItems: unknown[]
}
expect(body.selectedDirectory?.configuredPath).toBe(library)
expect(body.items).toContainEqual(expect.objectContaining({ id: 'portable', source: 'selected', readOnly: false }))
expect(body.items).toContainEqual(
expect.objectContaining({
id: 'portable',
source: 'selected',
readOnly: false,
estimatedTokens: expect.any(Number),
}),
)
expect(body).toMatchObject({ defaults: expect.any(Array), userItems: [], projectItems: [] })
})

Expand Down
5 changes: 5 additions & 0 deletions src/server/routes/skills.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,10 +66,15 @@ async function resolveLibrary(path: string): Promise<{ configuredPath: string; r
return { configuredPath: path, resolvedPath: await realpath(absolute) }
}

export function estimateSkillTokens(prompt: string): number {
return Math.ceil(prompt.length / 4)
}

function mapToResponse(skill: SkillDefinition) {
const source = skill.source ?? 'global-openfox'
return {
...skill.metadata,
estimatedTokens: estimateSkillTokens(skill.prompt),
enabled: isSkillEnabled(skill.metadata.id),
source,
path: skill.entrypoint ?? null,
Expand Down
22 changes: 22 additions & 0 deletions src/server/skills/registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,28 @@ describe('loadUserSkills', () => {
expect(skills[0]).toMatchObject({ prompt: 'Portable prompt.', legacy: false })
})

it('loads portable skills recursively from subdirectories and assigns group', async () => {
const categoryDir = join(tempDir, 'skills', 'tools')
await mkdir(categoryDir, { recursive: true })
await createPortableInRoot(categoryDir, 'nested-tool', 'Tool prompt.')

const subCategoryDir = join(tempDir, 'skills', 'dev', 'frontend')
await mkdir(subCategoryDir, { recursive: true })
await createPortableInRoot(subCategoryDir, 'react-helper', 'React helper prompt.')

const skills = await loadUserSkills(tempDir)

const nested = skills.find((s) => s.metadata.id === 'nested-tool')
expect(nested).toBeDefined()
expect(nested?.metadata.group).toBe('tools')
expect(nested?.prompt).toBe('Tool prompt.')

const deepNested = skills.find((s) => s.metadata.id === 'react-helper')
expect(deepNested).toBeDefined()
expect(deepNested?.metadata.group).toBe('dev/frontend')
expect(deepNested?.prompt).toBe('React helper prompt.')
})

it('should skip files without an id', async () => {
const skillsDir = join(tempDir, 'skills')
await mkdir(skillsDir, { recursive: true })
Expand Down
80 changes: 48 additions & 32 deletions src/server/skills/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,53 +54,69 @@ function portableVersion(data: Record<string, unknown>): string {
return data['version'] === undefined ? '' : String(data['version'])
}

async function loadPortableSkills(dir: string, source: SkillSource): Promise<SkillDefinition[]> {
async function loadPortableSkills(
dir: string,
source: SkillSource,
currentDir: string = dir,
group?: string,
): Promise<SkillDefinition[]> {
let entries
try {
entries = await readdir(dir, { withFileTypes: true })
entries = await readdir(currentDir, { withFileTypes: true })
} catch {
return []
}

const skills: SkillDefinition[] = []
for (const entry of entries) {
if (!entry.isDirectory() && !entry.isSymbolicLink()) continue
const packageDir = join(dir, entry.name)
const packageDir = join(currentDir, entry.name)
const entrypoint = join(packageDir, 'SKILL.md')
try {
const content = await readFile(entrypoint, 'utf-8')
const parsed = matter(content)
const data = parsed.data as Record<string, unknown>
const id = typeof data['name'] === 'string' ? data['name'].trim() : ''
const description = typeof data['description'] === 'string' ? data['description'].trim() : ''
const prompt = parsed.content.trim()
if (!id || !description || !prompt) continue
const resolvedDirectory = await realpath(packageDir)
const warnings: string[] = []
if (id.length > 64 || !PORTABLE_NAME_REGEX.test(id)) {
warnings.push('Skill name must use 1-64 lowercase letters, numbers, and single hyphens')
}
if (id !== entry.name) {
warnings.push(`Skill name "${id}" does not match package directory "${entry.name}"`)
if (await pathExists(entrypoint)) {
const content = await readFile(entrypoint, 'utf-8')
const parsed = matter(content)
const data = parsed.data as Record<string, unknown>
const id = typeof data['name'] === 'string' ? data['name'].trim() : ''
const description = typeof data['description'] === 'string' ? data['description'].trim() : ''
const prompt = parsed.content.trim()
if (id && description && prompt) {
const resolvedDirectory = await realpath(packageDir)
const warnings: string[] = []
if (id.length > 64 || !PORTABLE_NAME_REGEX.test(id)) {
warnings.push('Skill name must use 1-64 lowercase letters, numbers, and single hyphens')
}
if (id !== entry.name) {
warnings.push(`Skill name "${id}" does not match package directory "${entry.name}"`)
}
skills.push({
metadata: {
id,
name: portableDisplayName(data, id),
description,
version: portableVersion(data),
...(group ? { group } : {}),
},
prompt,
rawMetadata: data,
entrypoint,
directory: resolvedDirectory,
source,
legacy: false,
warnings,
})
// If this directory is a skill package, we don't recurse inside its internal subdirectories (e.g. assets, scripts)
continue
}
}
skills.push({
metadata: {
id,
name: portableDisplayName(data, id),
description,
version: portableVersion(data),
},
prompt,
rawMetadata: data,
entrypoint,
directory: resolvedDirectory,
source,
legacy: false,
warnings,
})
} catch {
// Invalid or unreadable packages do not block discovery.
}

// Not a skill package directly — recurse into subdirectory
const subGroup = group ? `${group}/${entry.name}` : entry.name
const nestedSkills = await loadPortableSkills(dir, source, packageDir, subGroup)
skills.push(...nestedSkills)
}
return skills
}
Expand Down
2 changes: 2 additions & 0 deletions src/server/skills/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ export interface SkillMetadata {
name: string
description: string
version: string
group?: string
estimatedTokens?: number
[key: string]: unknown
}

Expand Down
7 changes: 6 additions & 1 deletion web/src/components/plan/ChatInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { useSessionStore, useIsRunning, useQueuedMessages } from '../../stores/s
import { useScopedPaneState } from '../../stores/session/session-scope'
import { useResource } from '../../hooks/useResource'
import { useWorkflows } from '../../hooks/useWorkflows'
import { commandsResource, commandResource } from '../../lib/resources'
import { commandsResource, commandResource, skillsResource, selectActiveSkills } from '../../lib/resources'
import { authFetch } from '../../lib/api'
import { parseSlashCommand, extractTemplateParams } from '../../lib/parse-slash-command'
import { insertSuggestionAtCursor, focusTextareaAt, resolveSlashParamIds } from '../../lib/composer-utils'
Expand Down Expand Up @@ -103,6 +103,7 @@ export function ChatInput({
}: ChatInputProps) {
const t = useT()
const textareaRef = useRef<HTMLTextAreaElement>(null)
const composerWrapRef = useRef<HTMLDivElement>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
const prevLenRef = useRef(0)
const cursorPosRef = useRef(0)
Expand Down Expand Up @@ -147,6 +148,8 @@ export function ChatInput({
? dedupById(dedupById(commandsData.defaults, commandsData.userItems), commandsData.projectItems)
: []
const { workflows } = useWorkflows(workdir)
const { data: skillsData } = useResource(skillsResource, workdir)
const activeSkills = selectActiveSkills(skillsData)

// Clear inline param hints when input is emptied (after send, escape, etc.)
useEffect(() => {
Expand Down Expand Up @@ -617,6 +620,7 @@ export function ChatInput({
/>

<div
ref={composerWrapRef}
className={`flex items-end gap-3 p-3 rounded transition-colors ${
dragOver ? 'bg-accent-primary/10' : 'bg-primary'
}`}
Expand Down Expand Up @@ -652,6 +656,7 @@ export function ChatInput({
cursorPos={cursorPosRef.current}
workflows={workflows}
commands={commands}
skills={activeSkills}
onSelect={handleSelectSlash}
/>
{activeSlashParams.length > 0 &&
Expand Down
3 changes: 3 additions & 0 deletions web/src/components/settings/CRUDListItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ export interface CRUDListItemSimpleProps {
id: string
name: string
description?: string
extraBadge?: ReactNode
isBuiltIn: boolean
isConfirmingDelete: boolean
onView?: () => void
Expand All @@ -81,6 +82,7 @@ export function CRUDListItemSimple({
id,
name,
description,
extraBadge,
isBuiltIn,
isConfirmingDelete,
onView,
Expand All @@ -104,6 +106,7 @@ export function CRUDListItemSimple({
<div className="flex items-center gap-2">
<span className="text-text-primary text-sm font-medium">{name}</span>
<span className="text-text-muted text-xs font-mono">{id}</span>
{extraBadge}
</div>
{description && <p className="text-text-muted text-xs truncate">{description}</p>}
</CRUDListItem>
Expand Down
19 changes: 18 additions & 1 deletion web/src/components/settings/SkillListItem.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import type { SkillInfo } from '../../lib/skills-actions'
import { Toggle } from '../shared/Toggle'
import { CRUDListItemSimple } from './CRUDListItem'
import { formatTokens } from '../../lib/mcp-utils'
import { useT } from '../../hooks/useT'

interface SkillListItemProps {
skill: SkillInfo
Expand All @@ -25,18 +27,33 @@ export function SkillListItem({
onToggle,
readOnly = false,
}: SkillListItemProps) {
const t = useT()

return (
<CRUDListItemSimple
id={skill.id}
name={skill.name}
description={skill.description}
extraBadge={
skill.estimatedTokens !== undefined && skill.estimatedTokens > 0 ? (
<span className="text-xs text-text-muted">
{t({ en: '{{tokens}} tokens', fr: '{{tokens}} tokens' }, { tokens: formatTokens(skill.estimatedTokens) })}
</span>
) : undefined
}
isBuiltIn={isBuiltIn}
isConfirmingDelete={isConfirmingDelete}
onView={onView}
onEdit={readOnly ? undefined : onEdit}
onDuplicate={onDuplicate}
onDelete={readOnly ? undefined : onDelete}
actions={<Toggle enabled={skill.enabled} onClick={onToggle} label={`Activation for ${skill.name}`} />}
actions={
<Toggle
enabled={skill.enabled}
onClick={onToggle}
label={t({ en: 'Activation for {{name}}', fr: 'Activation pour {{name}}' }, { name: skill.name })}
/>
}
/>
)
}
Loading
Loading