mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
perf(skills): bound WSL installed skill discovery (#12314)
* perf(skills): bound WSL installed skill discovery * fix(skills): preserve bounded discovery correctness * fix(skills): bound WSL metadata prefilter reads * fix(skills): isolate absent discovery cwd cache keys * test(skills): adapt WSL discovery mocks to runner * fix(skills): preserve filtered discovery fallbacks * fix(skills): share filtered scans and preserve WSL inventory * fix(skills): share WSL scans without losing skill aliases * fix: preserve skill metadata and retire filtered peer caches --------- Co-authored-by: m4air <m4air@Mac.localdomain>
This commit is contained in:
@@ -3,7 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
const {
|
||||
handleMock,
|
||||
discoverSkillsMock,
|
||||
discoverSkillsInWslMock,
|
||||
discoverSkillObservationInWslMock,
|
||||
inventorySkillFreshnessMock,
|
||||
getDefaultWslDistroMock,
|
||||
getWslHomeMock,
|
||||
@@ -11,7 +11,7 @@ const {
|
||||
} = vi.hoisted(() => ({
|
||||
handleMock: vi.fn(),
|
||||
discoverSkillsMock: vi.fn(),
|
||||
discoverSkillsInWslMock: vi.fn(),
|
||||
discoverSkillObservationInWslMock: vi.fn(),
|
||||
inventorySkillFreshnessMock: vi.fn(),
|
||||
getDefaultWslDistroMock: vi.fn(),
|
||||
getWslHomeMock: vi.fn(),
|
||||
@@ -37,7 +37,7 @@ vi.mock('../skills/discovery', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('../skills/skill-discovery-wsl', () => ({
|
||||
discoverSkillsInWsl: discoverSkillsInWslMock
|
||||
discoverSkillObservationInWsl: discoverSkillObservationInWslMock
|
||||
}))
|
||||
|
||||
vi.mock('../skills/skill-freshness-inventory', () => ({
|
||||
@@ -60,6 +60,7 @@ vi.mock('../wsl', () => ({
|
||||
}))
|
||||
|
||||
import { registerSkillsHandlers } from './skills'
|
||||
import { clearSkillDiscoveryCaches } from '../skills/skill-discovery-target'
|
||||
|
||||
describe('registerSkillsHandlers', () => {
|
||||
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')
|
||||
@@ -69,15 +70,16 @@ describe('registerSkillsHandlers', () => {
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
clearSkillDiscoveryCaches()
|
||||
handleMock.mockReset()
|
||||
discoverSkillsMock.mockReset()
|
||||
discoverSkillsInWslMock.mockReset()
|
||||
discoverSkillObservationInWslMock.mockReset()
|
||||
getDefaultWslDistroMock.mockReset()
|
||||
getWslHomeMock.mockReset()
|
||||
parseWslPathMock.mockReset()
|
||||
parseWslPathMock.mockReturnValue(null)
|
||||
discoverSkillsMock.mockResolvedValue({ skills: [], sources: [], scannedAt: 1 })
|
||||
discoverSkillsInWslMock.mockResolvedValue({ skills: [], sources: [], scannedAt: 1 })
|
||||
discoverSkillObservationInWslMock.mockResolvedValue({ rows: [], sources: [], scannedAt: 1 })
|
||||
inventorySkillFreshnessMock.mockResolvedValue({
|
||||
schemaVersion: 1,
|
||||
installations: [],
|
||||
@@ -171,10 +173,30 @@ describe('registerSkillsHandlers', () => {
|
||||
|
||||
expect(getDefaultWslDistroMock).not.toHaveBeenCalled()
|
||||
expect(getWslHomeMock).toHaveBeenCalledWith('Ubuntu')
|
||||
expect(discoverSkillsInWslMock).toHaveBeenCalledWith({
|
||||
expect(discoverSkillObservationInWslMock).toHaveBeenCalledWith({
|
||||
distro: 'Ubuntu',
|
||||
homeDir: '/home/alice',
|
||||
cwd: '/home/alice'
|
||||
sourceKinds: undefined
|
||||
})
|
||||
})
|
||||
|
||||
it('shares the home and bundled WSL scan across name-filtered requests', async () => {
|
||||
const handler = getDiscoverHandler()
|
||||
|
||||
for (const name of ['orchestration', 'linear-tickets']) {
|
||||
await handler(null, {
|
||||
runtime: 'wsl',
|
||||
wslDistro: 'Ubuntu',
|
||||
names: [name],
|
||||
sourceKinds: ['home']
|
||||
})
|
||||
}
|
||||
|
||||
expect(discoverSkillObservationInWslMock).toHaveBeenCalledOnce()
|
||||
expect(discoverSkillObservationInWslMock).toHaveBeenCalledWith({
|
||||
distro: 'Ubuntu',
|
||||
homeDir: '/home/alice',
|
||||
sourceKinds: ['bundled', 'home']
|
||||
})
|
||||
})
|
||||
|
||||
@@ -196,10 +218,11 @@ describe('registerSkillsHandlers', () => {
|
||||
}
|
||||
})
|
||||
|
||||
expect(discoverSkillsInWslMock).toHaveBeenCalledWith({
|
||||
expect(discoverSkillObservationInWslMock).toHaveBeenCalledWith({
|
||||
distro: 'Ubuntu',
|
||||
homeDir: '/home/alice',
|
||||
cwd: '/mnt/c/repo/worktree'
|
||||
cwd: '/mnt/c/repo/worktree',
|
||||
sourceKinds: undefined
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -106,6 +106,25 @@ describe('skills.discover RPC', () => {
|
||||
it('accepts a params payload from an older client that cannot send refresh', () => {
|
||||
expect(discoverMethod().params?.parse({ cwd: '/repo' })).toEqual({ cwd: '/repo' })
|
||||
})
|
||||
|
||||
it('preserves portable filters through the server RPC boundary', async () => {
|
||||
await discoverMethod().handler(
|
||||
{ names: ['orchestration'], sourceKinds: ['home'] },
|
||||
makeContext({})
|
||||
)
|
||||
|
||||
expect(vi.mocked(resolveSkillDiscoveryTarget)).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ names: ['orchestration'], sourceKinds: ['home'] })
|
||||
)
|
||||
})
|
||||
|
||||
it('accepts empty portable filters as an unbounded request', async () => {
|
||||
await discoverMethod().handler({ names: [], sourceKinds: [] }, makeContext({}))
|
||||
|
||||
expect(vi.mocked(resolveSkillDiscoveryTarget)).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ names: [], sourceKinds: [] })
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('skills.install RPC', () => {
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
import { mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises'
|
||||
import type * as FsPromises from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
|
||||
const observed = vi.hoisted(() => ({ opens: 0 }))
|
||||
vi.mock('node:fs/promises', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof FsPromises>()
|
||||
return {
|
||||
...actual,
|
||||
open: (...args: Parameters<typeof actual.open>) => {
|
||||
observed.opens += 1
|
||||
return actual.open(...args)
|
||||
}
|
||||
}
|
||||
})
|
||||
import * as repair from './discovery'
|
||||
import { SkillScanCoalescer, SkillScanShedError } from './skill-scan-coalescer'
|
||||
|
||||
afterEach(() => {
|
||||
repair.clearSkillRootScanCache()
|
||||
vi.restoreAllMocks()
|
||||
vi.unstubAllEnvs()
|
||||
})
|
||||
|
||||
async function fixture(task: (root: string) => Promise<void>): Promise<void> {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-skill-name-repair-'))
|
||||
vi.stubEnv('HERMES_HOME', '')
|
||||
vi.stubEnv('LOCALAPPDATA', '')
|
||||
try {
|
||||
for (let index = 0; index < 48; index += 1) {
|
||||
const dir = join(root, '.agents', 'skills', `skill-${index}`)
|
||||
await mkdir(dir, { recursive: true })
|
||||
await writeFile(join(dir, 'SKILL.md'), `---\nname: skill-${index}\n---\n`)
|
||||
}
|
||||
await task(root)
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
it('shares one root read across six concurrent name filters', async () => {
|
||||
await fixture(async (root) => {
|
||||
repair.clearSkillRootScanCache()
|
||||
observed.opens = 0
|
||||
const checks = await Promise.all(
|
||||
Array.from({ length: 6 }, (_, index) =>
|
||||
repair.discoverSkills({
|
||||
homeDir: root,
|
||||
repos: [],
|
||||
includeCwd: false,
|
||||
names: [`skill-${index}`],
|
||||
sourceKinds: ['home']
|
||||
})
|
||||
)
|
||||
)
|
||||
expect(checks.map((result) => result.skills.map((skill) => skill.name))).toEqual(
|
||||
Array.from({ length: 6 }, (_, index) => [`skill-${index}`])
|
||||
)
|
||||
expect(observed.opens).toBe(48)
|
||||
})
|
||||
})
|
||||
|
||||
it('reuses the raw snapshot for a new name and invalidates it on mutation', async () => {
|
||||
await fixture(async (root) => {
|
||||
const args = { homeDir: root, repos: [], includeCwd: false, sourceKinds: ['home' as const] }
|
||||
observed.opens = 0
|
||||
await repair.discoverSkills({ ...args, names: ['skill-0'] })
|
||||
const next = await repair.discoverSkills({ ...args, names: ['skill-47'] })
|
||||
expect(next.skills.map((skill) => skill.name)).toEqual(['skill-47'])
|
||||
expect(observed.opens).toBe(48)
|
||||
await writeFile(
|
||||
join(root, '.agents', 'skills', 'skill-47', 'SKILL.md'),
|
||||
'---\nname: renamed\n---\n'
|
||||
)
|
||||
repair.clearSkillRootScanCache()
|
||||
const updated = await repair.discoverSkills({ ...args, names: ['renamed'] })
|
||||
expect(updated.skills.map((skill) => skill.name)).toEqual(['renamed'])
|
||||
expect(observed.opens).toBe(96)
|
||||
})
|
||||
})
|
||||
|
||||
it('retains a newly requested name when its previously observed root becomes unavailable', async () => {
|
||||
await fixture(async (root) => {
|
||||
const args = { homeDir: root, repos: [], includeCwd: false, sourceKinds: ['home' as const] }
|
||||
await repair.discoverSkills({ ...args, names: ['skill-0'] })
|
||||
const original = SkillScanCoalescer.prototype.run
|
||||
vi.spyOn(SkillScanCoalescer.prototype, 'run').mockImplementation(
|
||||
function (this: SkillScanCoalescer<unknown>, key, options, task) {
|
||||
if (
|
||||
key === `home\0${join(root, '.agents', 'skills')}` ||
|
||||
key.startsWith(`home\0${join(root, '.agents', 'skills')}\0`)
|
||||
) {
|
||||
return Promise.reject(new SkillScanShedError())
|
||||
}
|
||||
return original.call(this, key, options, task)
|
||||
}
|
||||
)
|
||||
const next = await repair.discoverSkills({ ...args, names: ['skill-47'] })
|
||||
expect(next.skills.map((skill) => skill.name)).toEqual(['skill-47'])
|
||||
expect(next.sources.find((source) => source.id === 'home-agents')?.skippedReason).toBe(
|
||||
'unavailable'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps simultaneous forced refreshes independent', async () => {
|
||||
await fixture(async (root) => {
|
||||
const args = { homeDir: root, repos: [], includeCwd: false, sourceKinds: ['home' as const] }
|
||||
await repair.discoverSkills({ ...args, names: ['skill-0'] })
|
||||
observed.opens = 0
|
||||
const results = await Promise.all(
|
||||
[0, 1].map((index) =>
|
||||
repair.discoverSkills({ ...args, names: [`skill-${index}`], refresh: true })
|
||||
)
|
||||
)
|
||||
expect(results.map((result) => result.skills[0]?.name)).toEqual(['skill-0', 'skill-1'])
|
||||
expect(observed.opens).toBe(96)
|
||||
})
|
||||
})
|
||||
|
||||
it('filters aliases before deduplication so excluded bundled roots cannot own home results', async () => {
|
||||
await fixture(async (root) => {
|
||||
const bundled = join(root, '.codex', 'skills', '.system', 'bundle')
|
||||
const alias = join(root, '.agents', 'skills', 'bundle-alias')
|
||||
await mkdir(bundled, { recursive: true })
|
||||
await writeFile(join(bundled, 'SKILL.md'), '---\nname: bundle\n---\n')
|
||||
await symlink(bundled, alias, 'dir')
|
||||
const result = await repair.discoverSkills({
|
||||
homeDir: root,
|
||||
repos: [],
|
||||
includeCwd: false,
|
||||
names: ['bundle'],
|
||||
sourceKinds: ['home']
|
||||
})
|
||||
expect(result.skills).toHaveLength(1)
|
||||
expect(result.skills[0]).toMatchObject({
|
||||
sourceKind: 'home',
|
||||
rootPath: join(root, '.agents', 'skills')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,45 @@
|
||||
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type * as SkillMetadata from '../../shared/skill-metadata'
|
||||
|
||||
const summarizeSkillMarkdown = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('../../shared/skill-metadata', async (importOriginal) => {
|
||||
const original = await importOriginal<typeof SkillMetadata>()
|
||||
return {
|
||||
...original,
|
||||
summarizeSkillMarkdown: (markdown: string) => {
|
||||
summarizeSkillMarkdown(markdown)
|
||||
return original.summarizeSkillMarkdown(markdown)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
import { discoverSkills } from './discovery'
|
||||
|
||||
describe('native skill source filtering', () => {
|
||||
it('serves home and bundled filters from one raw root observation', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-skill-source-filter-'))
|
||||
const homeSkill = join(root, '.codex', 'skills', 'home-skill')
|
||||
const bundledSkill = join(root, '.codex', 'skills', '.system', 'bundled-skill')
|
||||
await mkdir(homeSkill, { recursive: true })
|
||||
await mkdir(bundledSkill, { recursive: true })
|
||||
await writeFile(join(homeSkill, 'SKILL.md'), '# Home Skill\n')
|
||||
await writeFile(join(bundledSkill, 'SKILL.md'), '# Bundled Skill\n')
|
||||
|
||||
try {
|
||||
const result = await discoverSkills({ homeDir: root, repos: [], sourceKinds: ['home'] })
|
||||
|
||||
expect(result.skills.map((skill) => skill.name)).toEqual(['Home Skill'])
|
||||
expect(summarizeSkillMarkdown).toHaveBeenCalledTimes(2)
|
||||
expect(summarizeSkillMarkdown).toHaveBeenCalledWith('# Home Skill\n')
|
||||
const bundled = await discoverSkills({ homeDir: root, repos: [], sourceKinds: ['bundled'] })
|
||||
expect(bundled.skills.map((skill) => skill.name)).toEqual(['Bundled Skill'])
|
||||
expect(summarizeSkillMarkdown).toHaveBeenCalledTimes(2)
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -212,6 +212,46 @@ describe('skill discovery', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('filters discovery by requested directory name and source kind', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-skills-'))
|
||||
const home = join(root, 'home')
|
||||
const repo = join(root, 'repo')
|
||||
const homeSkill = join(home, '.agents', 'skills', 'orchestration')
|
||||
const repoSkill = join(repo, '.agents', 'skills', 'orchestration')
|
||||
const unrelatedSkill = join(home, '.agents', 'skills', 'computer-use')
|
||||
await mkdir(homeSkill, { recursive: true })
|
||||
await mkdir(repoSkill, { recursive: true })
|
||||
await mkdir(unrelatedSkill, { recursive: true })
|
||||
await writeFile(join(homeSkill, 'SKILL.md'), '---\nname: Agent Orchestration\n---\n')
|
||||
await writeFile(join(repoSkill, 'SKILL.md'), '# orchestration')
|
||||
await writeFile(join(unrelatedSkill, 'SKILL.md'), '# computer-use')
|
||||
|
||||
const result = await discoverSkills({
|
||||
homeDir: home,
|
||||
cwd: repo,
|
||||
repos: [],
|
||||
names: ['orchestration'],
|
||||
sourceKinds: ['home']
|
||||
})
|
||||
|
||||
expect(result.skills).toMatchObject([
|
||||
{ name: 'Agent Orchestration', sourceKind: 'home', directoryPath: homeSkill }
|
||||
])
|
||||
expect(result.sources.every((source) => source.sourceKind === 'home')).toBe(true)
|
||||
|
||||
const unfiltered = await discoverSkills({
|
||||
homeDir: home,
|
||||
cwd: repo,
|
||||
repos: [],
|
||||
sourceKinds: []
|
||||
})
|
||||
expect(unfiltered.skills.map((skill) => skill.name).sort()).toEqual([
|
||||
'Agent Orchestration',
|
||||
'computer-use',
|
||||
'orchestration'
|
||||
])
|
||||
})
|
||||
|
||||
it('discovers the enabled Claude plugin version applicable to the project cwd', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-skills-'))
|
||||
const home = join(root, 'home')
|
||||
|
||||
@@ -6,7 +6,8 @@ import type { Repo } from '../../shared/repo-types'
|
||||
import type {
|
||||
DiscoveredSkill,
|
||||
SkillDiscoveryResult,
|
||||
SkillDiscoverySource
|
||||
SkillDiscoverySource,
|
||||
SkillSourceKind
|
||||
} from '../../shared/skills'
|
||||
import {
|
||||
buildSkillDiscoverySources,
|
||||
@@ -17,6 +18,7 @@ import {
|
||||
stablePathId,
|
||||
type SkillScanRoot
|
||||
} from './skill-discovery-sources'
|
||||
import { rootMayContainSourceKind } from './skill-discovery-source-filter'
|
||||
import { discoverClaudePluginSkillSources } from './claude-plugin-skill-sources'
|
||||
import { findSkillFiles } from './skill-root-file-walk'
|
||||
import { runSkillCandidateTasks } from './skill-candidate-concurrency'
|
||||
@@ -264,6 +266,8 @@ export async function discoverSkills(args: {
|
||||
includeCwd?: boolean
|
||||
providerRootOverrides?: SkillProviderRootOverrides
|
||||
refresh?: boolean
|
||||
names?: string[]
|
||||
sourceKinds?: SkillSourceKind[]
|
||||
}): Promise<SkillDiscoveryResult> {
|
||||
const startedAt = Date.now()
|
||||
const homeDir = args.homeDir ?? homedir()
|
||||
@@ -272,10 +276,12 @@ export async function discoverSkills(args: {
|
||||
...buildSkillDiscoverySources({ ...args, homeDir }),
|
||||
// Why: plugin discovery is native-chat data keyed to an explicit workspace.
|
||||
// Untargeted scans (Settings) keep their pre-picker inventory and cost.
|
||||
...(args.cwd && args.includeCwd !== false
|
||||
...(args.cwd &&
|
||||
args.includeCwd !== false &&
|
||||
(!args.sourceKinds?.length || args.sourceKinds.includes('plugin'))
|
||||
? await discoverClaudePluginSkillSources({ homeDir, cwd: args.cwd })
|
||||
: [])
|
||||
]
|
||||
].filter((root) => rootMayContainSourceKind(root, args.sourceKinds))
|
||||
const scans = await Promise.all(roots.map((root) => scanRootShared(root, refresh)))
|
||||
const sources: SkillDiscoverySource[] = roots.map((root, index) => ({
|
||||
...root,
|
||||
@@ -287,9 +293,21 @@ export async function discoverSkills(args: {
|
||||
? undefined
|
||||
: 'missing'
|
||||
}))
|
||||
const normalizedNames = args.names?.map((name) => name.trim().toLowerCase()).filter(Boolean)
|
||||
const expectedNames = normalizedNames?.length ? new Set(normalizedNames) : undefined
|
||||
const seen = new Map<string, DiscoveredSkill>()
|
||||
for (const { value } of scans) {
|
||||
for (const skill of value.skills) {
|
||||
if (args.sourceKinds?.length && !args.sourceKinds.includes(skill.sourceKind)) {
|
||||
continue
|
||||
}
|
||||
if (
|
||||
expectedNames &&
|
||||
!expectedNames.has(skill.name.trim().toLowerCase()) &&
|
||||
!expectedNames.has(basename(skill.directoryPath).trim().toLowerCase())
|
||||
) {
|
||||
continue
|
||||
}
|
||||
mergeScannedSkill(seen, skill)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('../claude-plugin-skill-sources-wsl', () => ({
|
||||
discoverClaudePluginSkillSourcesInWsl: vi.fn().mockResolvedValue([])
|
||||
}))
|
||||
import { discoverClaudePluginSkillSourcesInWsl } from '../claude-plugin-skill-sources-wsl'
|
||||
import { buildSkillDeleteRootSet } from './roots'
|
||||
|
||||
describe('WSL skill deletion root ownership', () => {
|
||||
it('uses the guest home for an omitted cwd, preserving the prior resolved target', async () => {
|
||||
const target = {
|
||||
kind: 'wsl' as const,
|
||||
distro: 'Ubuntu',
|
||||
homeDir: '/home/alice',
|
||||
cwd: undefined
|
||||
}
|
||||
const omitted = await buildSkillDeleteRootSet({ target, repos: [] })
|
||||
const explicit = await buildSkillDeleteRootSet({
|
||||
target: { ...target, cwd: target.homeDir },
|
||||
repos: []
|
||||
})
|
||||
expect(omitted.roots).toEqual(explicit.roots)
|
||||
expect(omitted.roots.every((root) => root.path.startsWith('/home/alice/'))).toBe(true)
|
||||
expect(discoverClaudePluginSkillSourcesInWsl).toHaveBeenCalledWith({
|
||||
distro: 'Ubuntu',
|
||||
homeDir: '/home/alice',
|
||||
cwd: '/home/alice'
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -33,7 +33,8 @@ export async function buildSkillDeleteRootSet(input: {
|
||||
homeDir?: string
|
||||
}): Promise<SkillDeleteRootSet> {
|
||||
if (input.target.kind === 'wsl') {
|
||||
const { distro, homeDir, cwd } = input.target
|
||||
const { distro, homeDir } = input.target
|
||||
const cwd = input.target.cwd ?? homeDir
|
||||
return {
|
||||
roots: [
|
||||
...buildSkillDiscoverySources({
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { SkillScanRoot } from './skill-discovery-sources'
|
||||
import { rootMayContainSourceKind } from './skill-discovery-source-filter'
|
||||
|
||||
const homeRoot: SkillScanRoot = {
|
||||
id: 'home',
|
||||
owner: 'agents',
|
||||
path: '/home/alice/.agents/skills',
|
||||
label: 'Home',
|
||||
sourceKind: 'home',
|
||||
providers: ['agent-skills']
|
||||
}
|
||||
|
||||
describe('rootMayContainSourceKind', () => {
|
||||
it('treats an empty list as no filter', () => {
|
||||
expect(rootMayContainSourceKind(homeRoot, undefined)).toBe(true)
|
||||
expect(rootMayContainSourceKind(homeRoot, [])).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps home roots for bundled classification', () => {
|
||||
expect(rootMayContainSourceKind(homeRoot, ['bundled'])).toBe(true)
|
||||
expect(rootMayContainSourceKind(homeRoot, ['plugin'])).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { SkillSourceKind } from '../../shared/skills'
|
||||
import type { SkillScanRoot } from './skill-discovery-sources'
|
||||
|
||||
export function skillScanSourceKinds(
|
||||
sourceKinds: readonly SkillSourceKind[] | undefined
|
||||
): SkillSourceKind[] | undefined {
|
||||
if (!sourceKinds?.length) {
|
||||
return undefined
|
||||
}
|
||||
const kinds = new Set(sourceKinds)
|
||||
if (kinds.has('home') || kinds.has('bundled')) {
|
||||
kinds.add('home')
|
||||
kinds.add('bundled')
|
||||
}
|
||||
return [...kinds].sort()
|
||||
}
|
||||
|
||||
export function rootMayContainSourceKind(
|
||||
root: SkillScanRoot,
|
||||
sourceKinds: readonly SkillSourceKind[] | undefined
|
||||
): boolean {
|
||||
if (!sourceKinds?.length) {
|
||||
return true
|
||||
}
|
||||
if (root.sourceKind === 'home') {
|
||||
return sourceKinds.includes('home') || sourceKinds.includes('bundled')
|
||||
}
|
||||
return sourceKinds.includes(root.sourceKind)
|
||||
}
|
||||
@@ -18,9 +18,9 @@ vi.mock('./discovery', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('./skill-discovery-wsl', () => ({
|
||||
discoverSkillsInWsl: vi.fn(async (args: unknown) => {
|
||||
discoverSkillObservationInWsl: vi.fn(async (args: unknown) => {
|
||||
wslScans.push(args)
|
||||
return emptyResult()
|
||||
return { rows: [], sources: [], scannedAt: 1 }
|
||||
})
|
||||
}))
|
||||
|
||||
@@ -135,6 +135,22 @@ describe('discoverSkillsOnTarget', () => {
|
||||
expect(wslScans).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('distinguishes an absent WSL cwd from the literal undefined path', async () => {
|
||||
await discoverSkillsOnTarget(
|
||||
{ kind: 'wsl', distro: 'Ubuntu', homeDir: '/home/dev', cwd: undefined },
|
||||
[]
|
||||
)
|
||||
await discoverSkillsOnTarget(
|
||||
{ kind: 'wsl', distro: 'Ubuntu', homeDir: '/home/dev', cwd: 'undefined' },
|
||||
[]
|
||||
)
|
||||
|
||||
expect(wslScans).toEqual([
|
||||
{ distro: 'Ubuntu', homeDir: '/home/dev' },
|
||||
{ distro: 'Ubuntu', homeDir: '/home/dev', cwd: 'undefined' }
|
||||
])
|
||||
})
|
||||
|
||||
it('re-reads a WSL target when the caller refreshes', async () => {
|
||||
const target = {
|
||||
kind: 'wsl',
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import {
|
||||
projectWslSkillDiscovery,
|
||||
type WslSkillDiscoveryObservation
|
||||
} from './skill-discovery-wsl-observation'
|
||||
import type { Repo } from '../../shared/repo-types'
|
||||
import type { SkillDiscoveryResult, SkillDiscoveryTarget } from '../../shared/skills'
|
||||
import { getDefaultWslDistro, getWslHome, parseWslPath, toLinuxPath } from '../wsl'
|
||||
import { clearSkillRootScanCache, discoverSkills } from './discovery'
|
||||
import { discoverSkillsInWsl } from './skill-discovery-wsl'
|
||||
import { discoverSkillObservationInWsl } from './skill-discovery-wsl'
|
||||
import type { SkillProviderRootOverrides } from './skill-provider-destinations'
|
||||
import { stablePathId } from './skill-discovery-sources'
|
||||
import { skillScanSourceKinds } from './skill-discovery-source-filter'
|
||||
import { getRepoExecutionHostId } from '../../shared/execution-host'
|
||||
import { isSkillRootUnavailableError, SkillScanCoalescer } from './skill-scan-coalescer'
|
||||
|
||||
@@ -14,7 +19,10 @@ import { isSkillRootUnavailableError, SkillScanCoalescer } from './skill-scan-co
|
||||
const WSL_RESULT_TTL_MS = 10_000
|
||||
const MAX_CACHED_SKILL_TARGETS = 32
|
||||
|
||||
const targetScans = new SkillScanCoalescer<SkillDiscoveryResult>(MAX_CACHED_SKILL_TARGETS)
|
||||
type TargetScanObservation =
|
||||
| { kind: 'native'; result: SkillDiscoveryResult }
|
||||
| { kind: 'wsl'; observation: WslSkillDiscoveryObservation }
|
||||
const targetScans = new SkillScanCoalescer<TargetScanObservation>(MAX_CACHED_SKILL_TARGETS)
|
||||
|
||||
/** Drop every shared scan; used when a skill update run has rewritten disk. */
|
||||
export function clearSkillDiscoveryCaches(): void {
|
||||
@@ -23,8 +31,20 @@ export function clearSkillDiscoveryCaches(): void {
|
||||
}
|
||||
|
||||
export type ResolvedSkillDiscoveryTarget =
|
||||
| { kind: 'native-host'; cwd: string | undefined }
|
||||
| { kind: 'wsl'; distro: string; homeDir: string; cwd: string }
|
||||
| {
|
||||
kind: 'native-host'
|
||||
cwd: string | undefined
|
||||
names?: string[]
|
||||
sourceKinds?: SkillDiscoveryTarget['sourceKinds']
|
||||
}
|
||||
| {
|
||||
kind: 'wsl'
|
||||
distro: string
|
||||
homeDir: string
|
||||
cwd: string | undefined
|
||||
names?: string[]
|
||||
sourceKinds?: SkillDiscoveryTarget['sourceKinds']
|
||||
}
|
||||
|
||||
export function resolveSkillDiscoveryTarget(
|
||||
target: SkillDiscoveryTarget | undefined
|
||||
@@ -49,7 +69,12 @@ export function resolveSkillDiscoveryTarget(
|
||||
throw new Error('No WSL distribution is available for skill discovery.')
|
||||
}
|
||||
if (!wslDistro) {
|
||||
return { kind: 'native-host', cwd: target?.cwd?.trim() || undefined }
|
||||
return {
|
||||
kind: 'native-host',
|
||||
cwd: target?.cwd?.trim() || undefined,
|
||||
...(target?.names ? { names: target.names } : {}),
|
||||
...(target?.sourceKinds ? { sourceKinds: target.sourceKinds } : {})
|
||||
}
|
||||
}
|
||||
if (process.platform !== 'win32') {
|
||||
throw new Error('WSL skill discovery is only available on Windows.')
|
||||
@@ -67,8 +92,15 @@ export function resolveSkillDiscoveryTarget(
|
||||
)
|
||||
}
|
||||
const linuxHomeDir = toLinuxPath(homeDir)
|
||||
const cwd = parsedCwd?.linuxPath ?? (requestedCwd ? toLinuxPath(requestedCwd) : linuxHomeDir)
|
||||
return { kind: 'wsl', distro: wslDistro, homeDir: linuxHomeDir, cwd }
|
||||
const cwd = parsedCwd?.linuxPath ?? (requestedCwd ? toLinuxPath(requestedCwd) : undefined)
|
||||
return {
|
||||
kind: 'wsl',
|
||||
distro: wslDistro,
|
||||
homeDir: linuxHomeDir,
|
||||
cwd,
|
||||
...(target?.names ? { names: target.names } : {}),
|
||||
...(target?.sourceKinds ? { sourceKinds: target.sourceKinds } : {})
|
||||
}
|
||||
}
|
||||
|
||||
// Why: repos widen the native root set, so two targets that differ only by the
|
||||
@@ -93,17 +125,28 @@ function scanKey(
|
||||
repos: readonly Repo[],
|
||||
providerRootOverrides: SkillProviderRootOverrides | undefined
|
||||
): string {
|
||||
const providerRoots = stablePathId(
|
||||
Object.entries(providerRootOverrides ?? {})
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.map(([provider, root]) => `${provider}\0${root}`)
|
||||
.join('\0')
|
||||
const providerRoots = Object.entries(providerRootOverrides ?? {}).sort(([left], [right]) =>
|
||||
left.localeCompare(right)
|
||||
)
|
||||
const targetKey =
|
||||
target.kind === 'wsl'
|
||||
? `wsl\0${target.distro}\0${target.homeDir}\0${target.cwd}`
|
||||
: `native\0${target.cwd ?? ''}\0${target.cwd ? '' : repoDigest(repos)}`
|
||||
return `${targetKey}\0${providerRoots}`
|
||||
const names = target.names?.slice().sort() ?? null
|
||||
const sourceKinds = target.sourceKinds?.slice().sort() ?? null
|
||||
return target.kind === 'wsl'
|
||||
? JSON.stringify([
|
||||
'wsl',
|
||||
target.distro,
|
||||
target.homeDir,
|
||||
target.cwd ?? null,
|
||||
providerRoots,
|
||||
skillScanSourceKinds(target.sourceKinds) ?? null
|
||||
])
|
||||
: JSON.stringify([
|
||||
'native',
|
||||
target.cwd ?? null,
|
||||
target.cwd ? null : repoDigest(repos),
|
||||
providerRoots,
|
||||
names,
|
||||
sourceKinds
|
||||
])
|
||||
}
|
||||
|
||||
export async function discoverSkillsOnTarget(
|
||||
@@ -116,30 +159,41 @@ export async function discoverSkillsOnTarget(
|
||||
const outcome = await targetScans.run(
|
||||
scanKey(target, repos, options.providerRootOverrides),
|
||||
{ ttlMs: target.kind === 'wsl' ? WSL_RESULT_TTL_MS : 0, refresh },
|
||||
async () => {
|
||||
async (): Promise<TargetScanObservation> => {
|
||||
if (target.kind === 'wsl') {
|
||||
return discoverSkillsInWsl({
|
||||
distro: target.distro,
|
||||
homeDir: target.homeDir,
|
||||
cwd: target.cwd,
|
||||
providerRootOverrides: options.providerRootOverrides
|
||||
})
|
||||
return {
|
||||
kind: 'wsl',
|
||||
observation: await discoverSkillObservationInWsl({
|
||||
distro: target.distro,
|
||||
homeDir: target.homeDir,
|
||||
...(target.cwd ? { cwd: target.cwd } : {}),
|
||||
sourceKinds: skillScanSourceKinds(target.sourceKinds),
|
||||
providerRootOverrides: options.providerRootOverrides
|
||||
})
|
||||
}
|
||||
}
|
||||
return target.cwd
|
||||
const result = await (target.cwd
|
||||
? discoverSkills({
|
||||
repos: [],
|
||||
cwd: target.cwd,
|
||||
refresh,
|
||||
...(target.names ? { names: target.names } : {}),
|
||||
...(target.sourceKinds ? { sourceKinds: target.sourceKinds } : {}),
|
||||
providerRootOverrides: options.providerRootOverrides
|
||||
})
|
||||
: discoverSkills({
|
||||
repos: [...repos],
|
||||
refresh,
|
||||
...(target.names ? { names: target.names } : {}),
|
||||
...(target.sourceKinds ? { sourceKinds: target.sourceKinds } : {}),
|
||||
providerRootOverrides: options.providerRootOverrides
|
||||
})
|
||||
}))
|
||||
return { kind: 'native', result }
|
||||
}
|
||||
)
|
||||
return outcome.value
|
||||
return outcome.value.kind === 'wsl'
|
||||
? projectWslSkillDiscovery(outcome.value.observation, target.sourceKinds, target.names)
|
||||
: outcome.value.result
|
||||
} catch (error) {
|
||||
if (!isSkillRootUnavailableError(error)) {
|
||||
throw error
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import { beforeEach, expect, it, vi } from 'vitest'
|
||||
const io = vi.hoisted(() => ({ run: vi.fn(), plugins: vi.fn(async () => []) }))
|
||||
vi.mock('../wsl/wsl-runner', () => ({ runWslProcess: io.run }))
|
||||
vi.mock('./claude-plugin-skill-sources-wsl', () => ({
|
||||
discoverClaudePluginSkillSourcesInWsl: io.plugins
|
||||
}))
|
||||
vi.mock('./discovery', () => ({ clearSkillRootScanCache: vi.fn(), discoverSkills: vi.fn() }))
|
||||
import { clearSkillDiscoveryCaches, discoverSkillsOnTarget } from './skill-discovery-target'
|
||||
import {
|
||||
readWslSkillDiscoveryObservation,
|
||||
projectWslSkillDiscovery
|
||||
} from './skill-discovery-wsl-observation'
|
||||
import type { SkillScanRoot } from './skill-discovery-sources'
|
||||
const target = { kind: 'wsl' as const, distro: 'Ubuntu', homeDir: '/home/test', cwd: '/repo' }
|
||||
const record = (...fields: string[]) => `${fields.join('\0')}\0`
|
||||
const encoded = Buffer.from('---\nname: shared-frontmatter\ndescription: Fixture\n---\n').toString(
|
||||
'base64'
|
||||
)
|
||||
const common = '/opt/physical/SKILL.md'
|
||||
const rows = [
|
||||
record('S', '0', '/home/test/.codex/skills/.system/bundle/SKILL.md', common, '1', encoded),
|
||||
record('S', '0', '/home/test/.codex/skills/alias-a/SKILL.md', common, '1', encoded),
|
||||
record('S', '1', '/home/test/.agents/skills/alias-b/SKILL.md', common, '1', encoded),
|
||||
...Array.from({ length: 6 }, (_, i) =>
|
||||
record(
|
||||
'S',
|
||||
'0',
|
||||
`/home/test/.codex/skills/skill-${i}/SKILL.md`,
|
||||
`/physical/skill-${i}/SKILL.md`,
|
||||
'1',
|
||||
encoded
|
||||
)
|
||||
)
|
||||
]
|
||||
const output = record('R', '0', '1') + record('R', '1', '1') + rows.join('')
|
||||
beforeEach(() => {
|
||||
clearSkillDiscoveryCaches()
|
||||
io.run.mockReset()
|
||||
io.plugins.mockClear()
|
||||
io.run.mockResolvedValue({ code: 0, timedOut: false, stdout: output, stderr: '' })
|
||||
})
|
||||
it('keeps both home aliases when a bundled canonical duplicate appears first', async () => {
|
||||
const [a, b, bundle] = await Promise.all([
|
||||
discoverSkillsOnTarget({ ...target, names: ['alias-a'], sourceKinds: ['home'] }, []),
|
||||
discoverSkillsOnTarget({ ...target, names: ['alias-b'], sourceKinds: ['home'] }, []),
|
||||
discoverSkillsOnTarget({ ...target, names: ['bundle'], sourceKinds: ['bundled'] }, [])
|
||||
])
|
||||
expect(io.run).toHaveBeenCalledTimes(1)
|
||||
expect(a.skills.map((s) => s.directoryPath)).toEqual(['/home/test/.codex/skills/alias-a'])
|
||||
expect(b.skills.map((s) => s.directoryPath)).toEqual(['/home/test/.agents/skills/alias-b'])
|
||||
expect(bundle.skills.map((s) => s.directoryPath)).toEqual([
|
||||
'/home/test/.codex/skills/.system/bundle'
|
||||
])
|
||||
expect(a.skills[0].providers).toEqual(['codex'])
|
||||
expect(b.skills[0].providers).toEqual(['agent-skills'])
|
||||
expect(a.skills[0].id).toBe(b.skills[0].id)
|
||||
expect(a.skills[0].sourceKind).toBe('home')
|
||||
})
|
||||
it('six distinct installed-name checks share one scan and retain all six answers', async () => {
|
||||
const results = await Promise.all(
|
||||
Array.from({ length: 6 }, (_, i) =>
|
||||
discoverSkillsOnTarget({ ...target, names: [`skill-${i}`], sourceKinds: ['home'] }, [])
|
||||
)
|
||||
)
|
||||
expect(io.run).toHaveBeenCalledTimes(1)
|
||||
expect(results.map((r) => r.skills[0]?.directoryPath)).toEqual(
|
||||
Array.from({ length: 6 }, (_, i) => `/home/test/.codex/skills/skill-${i}`)
|
||||
)
|
||||
expect(io.run.mock.calls[0][0].timeoutMs).toBe(10000)
|
||||
expect(io.run.mock.calls[0][0].script).not.toContain('matches_requested_name')
|
||||
expect(io.run.mock.calls[0][0].script).not.toContain("'/repo/")
|
||||
expect(io.plugins).not.toHaveBeenCalled()
|
||||
})
|
||||
it('cache projections do not contaminate later aliases or source metadata', async () => {
|
||||
const first = await discoverSkillsOnTarget(
|
||||
{ ...target, names: ['alias-a'], sourceKinds: ['home'] },
|
||||
[]
|
||||
)
|
||||
first.skills[0].providers.push('claude')
|
||||
first.skills[0].rootPaths!.push('/poison')
|
||||
first.sources[0].providers.push('claude')
|
||||
const later = await discoverSkillsOnTarget(
|
||||
{ ...target, names: ['alias-a'], sourceKinds: ['home'] },
|
||||
[]
|
||||
)
|
||||
expect(later.skills[0].providers).toEqual(['codex'])
|
||||
expect(later.skills[0].rootPaths).toEqual(['/home/test/.codex/skills'])
|
||||
expect(later.sources[0].providers).not.toContain('claude')
|
||||
expect(io.run).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
it('refresh, cache clear, distro and broader root requirements are isolated', async () => {
|
||||
const req = { ...target, names: ['alias-a'], sourceKinds: ['home' as const] }
|
||||
await discoverSkillsOnTarget(req, [])
|
||||
await discoverSkillsOnTarget({ ...req, names: ['alias-b'] }, [])
|
||||
expect(io.run).toHaveBeenCalledTimes(1)
|
||||
await discoverSkillsOnTarget(req, [], { refresh: true })
|
||||
clearSkillDiscoveryCaches()
|
||||
await discoverSkillsOnTarget(req, [])
|
||||
await discoverSkillsOnTarget({ ...req, distro: 'Other' }, [])
|
||||
await discoverSkillsOnTarget({ ...target, names: ['alias-a'] }, [])
|
||||
expect(io.run).toHaveBeenCalledTimes(5)
|
||||
expect(io.plugins).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
it('deduplicates and merges only eligible alias roots, independently of row order', () => {
|
||||
const roots: SkillScanRoot[] = [
|
||||
{
|
||||
id: 'home',
|
||||
label: 'Home',
|
||||
path: '/home/test/.codex/skills',
|
||||
sourceKind: 'home',
|
||||
providers: ['codex'],
|
||||
owner: 'codex'
|
||||
},
|
||||
{
|
||||
id: 'home2',
|
||||
label: 'Home2',
|
||||
path: '/home/test/.agents/skills',
|
||||
sourceKind: 'home',
|
||||
providers: ['agent-skills'],
|
||||
owner: null
|
||||
}
|
||||
]
|
||||
for (const records of [rows, rows.toReversed()]) {
|
||||
const obs = readWslSkillDiscoveryObservation(records.join(''), roots, 42)
|
||||
const a = projectWslSkillDiscovery(obs, ['home'], ['alias-a'])
|
||||
const b = projectWslSkillDiscovery(obs, ['home'], ['alias-b'])
|
||||
expect(a.skills).toHaveLength(1)
|
||||
expect(b.skills).toHaveLength(1)
|
||||
expect(a.skills[0].providers).toEqual(['codex'])
|
||||
expect(b.skills[0].providers).toEqual(['agent-skills'])
|
||||
const both = projectWslSkillDiscovery(obs, ['home'], ['alias-a', 'alias-b'])
|
||||
expect(both.skills).toHaveLength(1)
|
||||
expect(new Set(both.skills[0].providers)).toEqual(new Set(['codex', 'agent-skills']))
|
||||
const all = projectWslSkillDiscovery(obs)
|
||||
expect(all.skills).toHaveLength(7)
|
||||
expect(all.scannedAt).toBe(42)
|
||||
}
|
||||
})
|
||||
it('does not cache failed scans as an empty successful observation', async () => {
|
||||
io.run.mockResolvedValueOnce({ code: 1, timedOut: false, stdout: '', stderr: 'failure' })
|
||||
const req = { ...target, names: ['alias-a'], sourceKinds: ['home' as const] }
|
||||
await expect(discoverSkillsOnTarget(req, [])).rejects.toThrow('skill-discovery-wsl-scan-failed')
|
||||
expect((await discoverSkillsOnTarget(req, [])).skills).toHaveLength(1)
|
||||
expect(io.run).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
@@ -0,0 +1,89 @@
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { buildWslSkillDiscoveryCommand, parseWslSkillDiscoveryOutput } from './skill-discovery-wsl'
|
||||
import type { SkillScanRoot } from './skill-discovery-sources'
|
||||
|
||||
async function writeSkill(root: string, directory: string, markdown: string): Promise<void> {
|
||||
const skillDirectory = join(root, directory)
|
||||
await mkdir(skillDirectory, { recursive: true })
|
||||
await writeFile(join(skillDirectory, 'SKILL.md'), markdown)
|
||||
}
|
||||
|
||||
describe('generated WSL skill name filter', () => {
|
||||
it.skipIf(process.platform !== 'linux')(
|
||||
'rejects only known scalar mismatches and passes uncertain names to TypeScript',
|
||||
async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-wsl-name-filter-'))
|
||||
const scanRoot: SkillScanRoot = {
|
||||
id: 'home',
|
||||
owner: 'agents',
|
||||
path: root,
|
||||
label: 'Home',
|
||||
sourceKind: 'home',
|
||||
providers: ['agent-skills']
|
||||
}
|
||||
for (const directory of [' orchestration', 'orchestration ', ' orchestration ']) {
|
||||
await writeSkill(root, directory, '---\nname: unrelated\n---\n')
|
||||
}
|
||||
await writeSkill(root, 'scalar-match', '---\nname: orchestration\n---\n')
|
||||
await writeSkill(root, 'scalar-mismatch', '---\nname: unrelated\n---\n')
|
||||
await writeSkill(root, 'empty-quoted', '---\nname: ""\n---\n# orchestration\n')
|
||||
await writeSkill(root, 'one-quote', '---\nname: "\n---\n# orchestration\n')
|
||||
await writeSkill(root, 'block-name', '---\nname: >-\n orchestration\n---\n')
|
||||
await writeSkill(root, 'bom-crlf', "\uFEFF---\r\nname: 'orchestration'\r\n---\r\n")
|
||||
await writeSkill(root, 'unicode-space', '---\nname:\u3000orchestration\n---\n')
|
||||
await writeSkill(root, 'missing-close', '---\nname: unrelated\n# orchestration\n')
|
||||
await writeSkill(root, 'duplicate-match', '---\nname: unrelated\nname: orchestration\n---\n')
|
||||
await writeSkill(
|
||||
root,
|
||||
'duplicate-mismatch',
|
||||
'---\nname: orchestration\nname: unrelated\n---\n'
|
||||
)
|
||||
await writeSkill(
|
||||
root,
|
||||
'beyond-limit',
|
||||
`---\ndescription: |\n${' x\n'.repeat(70_000)}name: unrelated\n---\n# orchestration\n`
|
||||
)
|
||||
await writeSkill(
|
||||
root,
|
||||
'multibyte-beyond-limit',
|
||||
`---\ndescription: ${'한'.repeat(90_000)}\nname: unrelated\n---\n# orchestration\n`
|
||||
)
|
||||
|
||||
try {
|
||||
const command = buildWslSkillDiscoveryCommand([scanRoot], ['orchestration'])
|
||||
const output = execFileSync('/bin/bash', ['-c', command], {
|
||||
encoding: 'utf8',
|
||||
maxBuffer: 4 * 1024 * 1024
|
||||
})
|
||||
expect(output).not.toContain('scalar-mismatch')
|
||||
expect(output).not.toContain('duplicate-mismatch')
|
||||
expect(output).toContain('beyond-limit')
|
||||
expect(output).toContain('multibyte-beyond-limit')
|
||||
expect(
|
||||
parseWslSkillDiscoveryOutput(output, [scanRoot], 42, ['home'], ['orchestration'])
|
||||
.skills.map((skill) => skill.directoryPath.split('/').at(-1))
|
||||
.sort()
|
||||
).toEqual([
|
||||
' orchestration',
|
||||
' orchestration ',
|
||||
'block-name',
|
||||
'bom-crlf',
|
||||
'duplicate-match',
|
||||
'empty-quoted',
|
||||
'missing-close',
|
||||
'one-quote',
|
||||
'orchestration ',
|
||||
'scalar-match',
|
||||
'unicode-space'
|
||||
])
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
},
|
||||
30_000
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,165 @@
|
||||
import { posix as pathPosix } from 'node:path'
|
||||
import { summarizeSkillMarkdown } from '../../shared/skill-metadata'
|
||||
import type {
|
||||
DiscoveredSkill,
|
||||
SkillDiscoveryResult,
|
||||
SkillDiscoverySource,
|
||||
SkillSourceKind
|
||||
} from '../../shared/skills'
|
||||
import {
|
||||
sortDiscoveredSkills,
|
||||
sortSkillDiscoverySources,
|
||||
sourceKindForSkill,
|
||||
sourceLabelForSkill,
|
||||
stablePathId,
|
||||
type SkillScanRoot
|
||||
} from './skill-discovery-sources'
|
||||
import { rootMayContainSourceKind } from './skill-discovery-source-filter'
|
||||
|
||||
export type WslSkillDiscoveryObservation = {
|
||||
rows: { canonicalSkillFilePath: string; skill: DiscoveredSkill }[]
|
||||
sources: SkillDiscoverySource[]
|
||||
scannedAt: number
|
||||
}
|
||||
|
||||
function readProtocolField(fields: string[], index: number): string {
|
||||
const value = fields[index]
|
||||
if (value === undefined) {
|
||||
throw new Error('WSL skill discovery returned an incomplete response.')
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
export function readWslSkillDiscoveryObservation(
|
||||
output: string,
|
||||
roots: readonly SkillScanRoot[],
|
||||
scannedAt = Date.now()
|
||||
): WslSkillDiscoveryObservation {
|
||||
const fields = output.split('\0')
|
||||
const rootExists = new Map<number, boolean>()
|
||||
const rows: WslSkillDiscoveryObservation['rows'] = []
|
||||
let index = 0
|
||||
while (index < fields.length && fields[index]) {
|
||||
const recordKind = fields[index++]
|
||||
const rootIndex = Number.parseInt(readProtocolField(fields, index++), 10)
|
||||
const root = roots[rootIndex]
|
||||
if (!root) {
|
||||
throw new Error('WSL skill discovery returned an unknown source.')
|
||||
}
|
||||
if (recordKind === 'R') {
|
||||
rootExists.set(rootIndex, readProtocolField(fields, index++) === '1')
|
||||
continue
|
||||
}
|
||||
if (recordKind !== 'S') {
|
||||
throw new Error('WSL skill discovery returned an invalid response.')
|
||||
}
|
||||
|
||||
const skillFilePath = readProtocolField(fields, index++)
|
||||
const canonicalSkillFilePath = readProtocolField(fields, index++)
|
||||
const updatedAtSeconds = Number.parseInt(readProtocolField(fields, index++), 10)
|
||||
const markdown = Buffer.from(readProtocolField(fields, index++), 'base64').toString('utf8')
|
||||
const directoryPath = pathPosix.dirname(skillFilePath)
|
||||
const summary = summarizeSkillMarkdown(markdown)
|
||||
const sourceKind = sourceKindForSkill(root, skillFilePath, pathPosix)
|
||||
const directoryName = pathPosix.basename(directoryPath)
|
||||
rows.push({
|
||||
canonicalSkillFilePath,
|
||||
skill: {
|
||||
id: stablePathId(canonicalSkillFilePath),
|
||||
name: summary.name ?? directoryName,
|
||||
description: summary.description,
|
||||
// Copy: `root.providers` is shared across every skill/source from this
|
||||
// root, so a later in-place merge must not mutate the aliased array.
|
||||
providers: [...root.providers],
|
||||
sourceKind,
|
||||
sourceLabel: sourceLabelForSkill(root, sourceKind),
|
||||
rootPath: root.path,
|
||||
rootPaths: [root.path],
|
||||
directoryPath,
|
||||
skillFilePath,
|
||||
installed: true,
|
||||
updatedAt: Number.isFinite(updatedAtSeconds) ? updatedAtSeconds * 1000 : null
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const sources: SkillDiscoverySource[] = roots.map((root, rootIndex) => {
|
||||
const exists = rootExists.get(rootIndex) ?? false
|
||||
return {
|
||||
...root,
|
||||
providers: [...root.providers],
|
||||
exists,
|
||||
skippedReason: exists ? undefined : 'missing'
|
||||
}
|
||||
})
|
||||
return {
|
||||
rows,
|
||||
sources: sortSkillDiscoverySources(sources),
|
||||
scannedAt
|
||||
}
|
||||
}
|
||||
|
||||
export function projectWslSkillDiscovery(
|
||||
observation: WslSkillDiscoveryObservation,
|
||||
sourceKinds?: readonly SkillSourceKind[],
|
||||
names?: readonly string[]
|
||||
): SkillDiscoveryResult {
|
||||
const normalizedNames = names?.map((name) => name.trim().toLowerCase()).filter(Boolean)
|
||||
const expectedNames = normalizedNames?.length ? new Set(normalizedNames) : undefined
|
||||
const skillsByCanonicalPath = new Map<string, DiscoveredSkill>()
|
||||
for (const { canonicalSkillFilePath, skill } of observation.rows) {
|
||||
if (sourceKinds?.length && !sourceKinds.includes(skill.sourceKind)) {
|
||||
continue
|
||||
}
|
||||
const directoryName = pathPosix.basename(skill.directoryPath)
|
||||
if (
|
||||
expectedNames &&
|
||||
!expectedNames.has(skill.name.trim().toLowerCase()) &&
|
||||
!expectedNames.has(directoryName.trim().toLowerCase())
|
||||
) {
|
||||
continue
|
||||
}
|
||||
// Filter aliases before deduplication; each name/source may select a different row.
|
||||
const existing = skillsByCanonicalPath.get(canonicalSkillFilePath)
|
||||
if (existing) {
|
||||
const existingRoots = (existing.rootPaths ??= [existing.rootPath])
|
||||
for (const rootPath of skill.rootPaths ?? [skill.rootPath]) {
|
||||
if (!existingRoots.includes(rootPath)) {
|
||||
existingRoots.push(rootPath)
|
||||
}
|
||||
}
|
||||
for (const provider of skill.providers) {
|
||||
if (!existing.providers.includes(provider)) {
|
||||
existing.providers.push(provider)
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
skillsByCanonicalPath.set(canonicalSkillFilePath, {
|
||||
...skill,
|
||||
providers: [...skill.providers],
|
||||
rootPaths: [...(skill.rootPaths ?? [skill.rootPath])]
|
||||
})
|
||||
}
|
||||
return {
|
||||
skills: sortDiscoveredSkills([...skillsByCanonicalPath.values()]),
|
||||
sources: observation.sources
|
||||
.filter((source) => rootMayContainSourceKind(source, sourceKinds))
|
||||
.map((source) => ({ ...source, providers: [...source.providers] })),
|
||||
scannedAt: observation.scannedAt
|
||||
}
|
||||
}
|
||||
|
||||
export function parseWslSkillDiscoveryOutput(
|
||||
output: string,
|
||||
roots: readonly SkillScanRoot[],
|
||||
scannedAt = Date.now(),
|
||||
sourceKinds?: readonly SkillSourceKind[],
|
||||
names?: readonly string[]
|
||||
): SkillDiscoveryResult {
|
||||
return projectWslSkillDiscovery(
|
||||
readWslSkillDiscoveryObservation(output, roots, scannedAt),
|
||||
sourceKinds,
|
||||
names
|
||||
)
|
||||
}
|
||||
@@ -7,6 +7,7 @@ const runWslProcessMock = vi.hoisted(() => vi.fn())
|
||||
vi.mock('../wsl/wsl-runner', () => ({ runWslProcess: runWslProcessMock }))
|
||||
|
||||
import { buildSkillDiscoverySources } from './skill-discovery-sources'
|
||||
import { rootMayContainSourceKind } from './skill-discovery-source-filter'
|
||||
import { discoverSkillsInWsl } from './skill-discovery-wsl'
|
||||
|
||||
function record(...fields: string[]): string {
|
||||
@@ -17,66 +18,131 @@ function wslResult(stdout: string): WslResult {
|
||||
return { environmentResolved: true, code: 0, stdout, stderr: '', timedOut: false }
|
||||
}
|
||||
|
||||
function recordedScript(index: number): string {
|
||||
const script: unknown = runWslProcessMock.mock.calls[index]?.[0].script
|
||||
if (typeof script !== 'string') {
|
||||
throw new Error('Expected a generated WSL script')
|
||||
}
|
||||
return script
|
||||
}
|
||||
|
||||
describe('WSL Claude plugin skill discovery', () => {
|
||||
beforeEach(() => runWslProcessMock.mockReset())
|
||||
afterEach(() => vi.unstubAllEnvs())
|
||||
|
||||
it('reads enabled plugin metadata and scans the selected install inside the distro', async () => {
|
||||
const homeDir = '/home/alice'
|
||||
const cwd = '/work/orca'
|
||||
// Why: a Windows host's own Hermes location says nothing about the distro's,
|
||||
// so neither variable may reach the posix scan script.
|
||||
vi.stubEnv('HERMES_HOME', 'C:\\Users\\alice\\hermes')
|
||||
vi.stubEnv('LOCALAPPDATA', 'C:\\Users\\alice\\AppData\\Local')
|
||||
const pluginId = 'compound-engineering@compound-engineering-plugin'
|
||||
const installPath = '/home/alice/.claude/plugins/cache/compound/3.14.3'
|
||||
const installed = JSON.stringify({
|
||||
plugins: {
|
||||
[pluginId]: [{ scope: 'project', projectPath: cwd, installPath }]
|
||||
}
|
||||
it('skips workspace roots and plugin metadata for home-only discovery without cwd', async () => {
|
||||
runWslProcessMock.mockResolvedValueOnce(wslResult(''))
|
||||
|
||||
const result = await discoverSkillsInWsl({
|
||||
distro: 'Ubuntu',
|
||||
homeDir: '/home/alice',
|
||||
sourceKinds: ['home']
|
||||
})
|
||||
const settings = JSON.stringify({ enabledPlugins: { [pluginId]: true } })
|
||||
const metadataOutput = [
|
||||
record('F', '0', '1', Buffer.from(installed).toString('base64')),
|
||||
record('F', '1', '1', Buffer.from(settings).toString('base64')),
|
||||
record('F', '2', '0', ''),
|
||||
record('F', '3', '0', '')
|
||||
].join('')
|
||||
const baseRootCount = buildSkillDiscoverySources({
|
||||
homeDir,
|
||||
cwd,
|
||||
|
||||
expect(runWslProcessMock).toHaveBeenCalledTimes(1)
|
||||
const scanScript = recordedScript(0)
|
||||
expect(scanScript.match(/'\/home\/alice\/\.agents\/skills'/g)).toHaveLength(1)
|
||||
expect(scanScript.match(/'\/home\/alice\/\.claude\/skills'/g)).toHaveLength(1)
|
||||
const expectedRoots = buildSkillDiscoverySources({
|
||||
homeDir: '/home/alice',
|
||||
cwd: undefined,
|
||||
repos: [],
|
||||
includeCwd: false,
|
||||
pathApi: pathPosix
|
||||
}).length
|
||||
const skillPath = `${installPath}/skills/ce-plan/SKILL.md`
|
||||
const markdown = Buffer.from('---\nname: ce-plan\ndescription: Plan work.\n---\n').toString(
|
||||
'base64'
|
||||
)
|
||||
const scanOutput = [
|
||||
record('R', String(baseRootCount), '1'),
|
||||
record('S', String(baseRootCount), skillPath, skillPath, '1700000000', markdown)
|
||||
].join('')
|
||||
runWslProcessMock.mockResolvedValueOnce(wslResult(metadataOutput))
|
||||
runWslProcessMock.mockResolvedValueOnce(wslResult(scanOutput))
|
||||
|
||||
const result = await discoverSkillsInWsl({ distro: 'Ubuntu', homeDir, cwd })
|
||||
|
||||
expect(runWslProcessMock).toHaveBeenCalledTimes(2)
|
||||
const scanScript = runWslProcessMock.mock.calls[1]?.[0].script as string
|
||||
expect(scanScript).toContain('/home/alice/.hermes/skills')
|
||||
expect(scanScript).not.toContain('AppData')
|
||||
expect(scanScript).toContain(`${installPath}/skills`)
|
||||
expect(result.skills).toEqual([
|
||||
expect.objectContaining({
|
||||
name: 'ce-plan',
|
||||
sourceKind: 'plugin',
|
||||
rootPath: `${installPath}/skills`
|
||||
})
|
||||
])
|
||||
expect(result.sources).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ path: `${installPath}/skills`, owner: 'claude', exists: true })
|
||||
])
|
||||
})
|
||||
expect(result.sources).toHaveLength(
|
||||
expectedRoots.filter((root) => rootMayContainSourceKind(root, ['home'])).length
|
||||
)
|
||||
})
|
||||
|
||||
it('skips plugin metadata and unrelated roots for filtered home discovery', async () => {
|
||||
runWslProcessMock.mockResolvedValueOnce(wslResult(''))
|
||||
|
||||
const result = await discoverSkillsInWsl({
|
||||
distro: 'Ubuntu',
|
||||
homeDir: '/home/alice',
|
||||
cwd: '/work/orca',
|
||||
names: ['orchestration'],
|
||||
sourceKinds: ['home']
|
||||
})
|
||||
|
||||
expect(runWslProcessMock).toHaveBeenCalledTimes(1)
|
||||
const scanScript = recordedScript(0)
|
||||
expect(scanScript).not.toContain('/work/orca')
|
||||
expect(scanScript).not.toContain("'/home/alice/.codex/plugins/cache'")
|
||||
const expectedRoots = buildSkillDiscoverySources({
|
||||
homeDir: '/home/alice',
|
||||
cwd: '/work/orca',
|
||||
repos: [],
|
||||
includeCwd: true,
|
||||
pathApi: pathPosix
|
||||
}).filter((root) => rootMayContainSourceKind(root, ['home']))
|
||||
expect(result.sources).toHaveLength(expectedRoots.length)
|
||||
})
|
||||
|
||||
it.each([true, false])(
|
||||
'preserves enabled plugins with explicit workspace=%s',
|
||||
async (explicitWorkspace) => {
|
||||
const homeDir = '/home/alice'
|
||||
const cwd = explicitWorkspace ? '/work/orca' : homeDir
|
||||
// Why: a Windows host's own Hermes location says nothing about the distro's,
|
||||
// so neither variable may reach the posix scan script.
|
||||
vi.stubEnv('HERMES_HOME', 'C:\\Users\\alice\\hermes')
|
||||
vi.stubEnv('LOCALAPPDATA', 'C:\\Users\\alice\\AppData\\Local')
|
||||
const pluginId = 'compound-engineering@compound-engineering-plugin'
|
||||
const installPath = '/home/alice/.claude/plugins/cache/compound/3.14.3'
|
||||
const installed = JSON.stringify({
|
||||
plugins: {
|
||||
[pluginId]: [{ scope: 'project', projectPath: cwd, installPath }]
|
||||
}
|
||||
})
|
||||
const settings = JSON.stringify({ enabledPlugins: { [pluginId]: true } })
|
||||
const metadataOutput = [
|
||||
record('F', '0', '1', Buffer.from(installed).toString('base64')),
|
||||
record('F', '1', '1', Buffer.from(settings).toString('base64')),
|
||||
record('F', '2', '0', ''),
|
||||
record('F', '3', '0', '')
|
||||
].join('')
|
||||
const baseRootCount = buildSkillDiscoverySources({
|
||||
homeDir,
|
||||
cwd,
|
||||
repos: [],
|
||||
pathApi: pathPosix
|
||||
}).length
|
||||
const skillPath = `${installPath}/skills/ce-plan/SKILL.md`
|
||||
const markdown = Buffer.from('---\nname: ce-plan\ndescription: Plan work.\n---\n').toString(
|
||||
'base64'
|
||||
)
|
||||
const scanOutput = [
|
||||
record('R', String(baseRootCount), '1'),
|
||||
record('S', String(baseRootCount), skillPath, skillPath, '1700000000', markdown)
|
||||
].join('')
|
||||
runWslProcessMock.mockResolvedValueOnce(wslResult(metadataOutput))
|
||||
runWslProcessMock.mockResolvedValueOnce(wslResult(scanOutput))
|
||||
|
||||
const result = await discoverSkillsInWsl({
|
||||
distro: 'Ubuntu',
|
||||
homeDir,
|
||||
...(explicitWorkspace ? { cwd } : {})
|
||||
})
|
||||
|
||||
expect(runWslProcessMock).toHaveBeenCalledTimes(2)
|
||||
const scanScript = recordedScript(1)
|
||||
expect(scanScript).toContain('/home/alice/.hermes/skills')
|
||||
expect(scanScript).not.toContain('AppData')
|
||||
expect(scanScript).toContain(`${installPath}/skills`)
|
||||
expect(result.skills).toEqual([
|
||||
expect.objectContaining({
|
||||
name: 'ce-plan',
|
||||
sourceKind: 'plugin',
|
||||
rootPath: `${installPath}/skills`
|
||||
})
|
||||
])
|
||||
expect(result.sources).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ path: `${installPath}/skills`, owner: 'claude', exists: true })
|
||||
])
|
||||
)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
@@ -82,6 +82,88 @@ describe('WSL skill discovery', () => {
|
||||
expect(script).toContain(`'/work/alice'\\''s project/.agents/skills'`)
|
||||
})
|
||||
|
||||
it('filters requested names before reading skill payloads', () => {
|
||||
const script = buildWslSkillDiscoveryCommand([homeRoot], ['Orchestration', 'computer-use'])
|
||||
|
||||
expect(script).toContain("'orchestration'|'computer-use') return 0")
|
||||
expect(script).toContain('local normalized_name=${1,,}')
|
||||
expect(script).toContain('metadata_name_known=0')
|
||||
expect(script).toContain('IFS= read -r -n "$remaining" line || read_status=$?')
|
||||
expect(script).toContain('[ "$line_length" -ge "$remaining" ] && return')
|
||||
expect(script).toContain('[[ "$candidate_name" =~ $non_ascii_pattern ]] && continue')
|
||||
expect(script).toContain("line=${line#$'\\xEF\\xBB\\xBF'}")
|
||||
expect(script).toContain('if [ "$metadata_name_known" -eq 1 ]; then')
|
||||
expect(script).toContain('done < "$1"')
|
||||
expect(script).not.toContain("awk '")
|
||||
expect(script).not.toContain("tr '[:upper:]'")
|
||||
expect(script.indexOf('matches_requested_name "$metadata_name" || continue')).toBeLessThan(
|
||||
script.indexOf('encoded_markdown=$(head')
|
||||
)
|
||||
})
|
||||
|
||||
it('filters classified source kinds while parsing', () => {
|
||||
const markdown = Buffer.from('---\nname: Bundled\n---\n').toString('base64')
|
||||
const output = [
|
||||
record('R', '0', '1'),
|
||||
record(
|
||||
'S',
|
||||
'0',
|
||||
'/home/alice/.codex/skills/.system/bundled/SKILL.md',
|
||||
'/home/alice/.codex/skills/.system/bundled/SKILL.md',
|
||||
'1700000000',
|
||||
markdown
|
||||
)
|
||||
].join('')
|
||||
|
||||
expect(parseWslSkillDiscoveryOutput(output, [homeRoot], 42, ['home']).skills).toEqual([])
|
||||
expect(parseWslSkillDiscoveryOutput(output, [homeRoot], 42, []).skills).toHaveLength(1)
|
||||
expect(parseWslSkillDiscoveryOutput(output, [homeRoot], 42, [], [' ']).skills).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('keeps ASCII prefiltering for mixed-locale requested names', () => {
|
||||
const script = buildWslSkillDiscoveryCommand([homeRoot], ['orchestration', 'hébergement'])
|
||||
|
||||
expect(script).toContain("'orchestration') return 0")
|
||||
expect(script).not.toContain('hébergement) return 0')
|
||||
expect(script).toContain('is_ascii_name "$directory_name"')
|
||||
})
|
||||
|
||||
it('uses the TypeScript summary parser for uncertain WSL name candidates', () => {
|
||||
const blockName = Buffer.from('\uFEFF---\nname: >-\n Agent\n Orchestration\n---\n').toString(
|
||||
'base64'
|
||||
)
|
||||
const headingName = Buffer.from('# Computer Use\n\nUse the computer.\n').toString('base64')
|
||||
const output = [
|
||||
record('R', '0', '1'),
|
||||
record(
|
||||
'S',
|
||||
'0',
|
||||
'/home/alice/.agents/skills/renamed-a/SKILL.md',
|
||||
'/home/alice/.agents/skills/renamed-a/SKILL.md',
|
||||
'1700000000',
|
||||
blockName
|
||||
),
|
||||
record(
|
||||
'S',
|
||||
'0',
|
||||
'/home/alice/.agents/skills/renamed-b/SKILL.md',
|
||||
'/home/alice/.agents/skills/renamed-b/SKILL.md',
|
||||
'1700000000',
|
||||
headingName
|
||||
)
|
||||
].join('')
|
||||
|
||||
expect(
|
||||
parseWslSkillDiscoveryOutput(
|
||||
output,
|
||||
[homeRoot],
|
||||
42,
|
||||
['home'],
|
||||
['agent orchestration', 'computer use']
|
||||
).skills.map((skill) => skill.name)
|
||||
).toEqual(['Agent Orchestration', 'Computer Use'])
|
||||
})
|
||||
|
||||
it('rejects malformed host responses instead of reporting an empty scan', () => {
|
||||
expect(() => parseWslSkillDiscoveryOutput(record('S', '9'), [homeRoot])).toThrow(
|
||||
'unknown source'
|
||||
|
||||
@@ -1,21 +1,15 @@
|
||||
import {
|
||||
readWslSkillDiscoveryObservation,
|
||||
projectWslSkillDiscovery,
|
||||
type WslSkillDiscoveryObservation
|
||||
} from './skill-discovery-wsl-observation'
|
||||
export { parseWslSkillDiscoveryOutput } from './skill-discovery-wsl-observation'
|
||||
import { posix as pathPosix } from 'node:path'
|
||||
import { summarizeSkillMarkdown } from '../../shared/skill-metadata'
|
||||
import type {
|
||||
DiscoveredSkill,
|
||||
SkillDiscoveryResult,
|
||||
SkillDiscoverySource
|
||||
} from '../../shared/skills'
|
||||
import type { SkillDiscoveryResult, SkillSourceKind } from '../../shared/skills'
|
||||
import { quoteBashString } from '../wsl-bash-command'
|
||||
import { runWslProcess } from '../wsl/wsl-runner'
|
||||
import {
|
||||
buildSkillDiscoverySources,
|
||||
sortDiscoveredSkills,
|
||||
sortSkillDiscoverySources,
|
||||
sourceKindForSkill,
|
||||
sourceLabelForSkill,
|
||||
stablePathId,
|
||||
type SkillScanRoot
|
||||
} from './skill-discovery-sources'
|
||||
import { buildSkillDiscoverySources, type SkillScanRoot } from './skill-discovery-sources'
|
||||
import { rootMayContainSourceKind } from './skill-discovery-source-filter'
|
||||
import { discoverClaudePluginSkillSourcesInWsl } from './claude-plugin-skill-sources-wsl'
|
||||
import type { SkillProviderRootOverrides } from './skill-provider-destinations'
|
||||
import { SKILL_STAGING_GLOB } from './skill-delete/staging-names'
|
||||
@@ -25,10 +19,95 @@ const MAX_MARKDOWN_BYTES = 256 * 1024
|
||||
const WSL_SCAN_TIMEOUT_MS = 10_000
|
||||
const WSL_SCAN_MAX_OUTPUT_BYTES = 128 * 1024 * 1024
|
||||
|
||||
export function buildWslSkillDiscoveryCommand(roots: readonly SkillScanRoot[]): string {
|
||||
export function buildWslSkillDiscoveryCommand(
|
||||
roots: readonly SkillScanRoot[],
|
||||
names?: readonly string[]
|
||||
): string {
|
||||
const normalizedNames = names?.map((name) => name.trim().toLowerCase()).filter(Boolean)
|
||||
const nameFilterHelpers: string[] = []
|
||||
const nameFilterBody: string[] = []
|
||||
if (normalizedNames?.length) {
|
||||
const asciiNames = [...new Set(normalizedNames.filter((name) => /^[\x20-\x7e]+$/.test(name)))]
|
||||
const matchBody = asciiNames.length
|
||||
? [
|
||||
' case "$normalized_name" in',
|
||||
` ${asciiNames.map(quoteBashString).join('|')}) return 0 ;;`,
|
||||
' *) return 1 ;;',
|
||||
' esac'
|
||||
]
|
||||
: [' return 1']
|
||||
nameFilterHelpers.push(
|
||||
'is_ascii_name() {',
|
||||
" local LC_ALL=C non_ascii_pattern='[^ -~]'",
|
||||
' if [[ "$1" =~ $non_ascii_pattern ]]; then return 1; fi',
|
||||
' return 0',
|
||||
'}',
|
||||
'matches_requested_name() {',
|
||||
' local LC_ALL=C',
|
||||
' local normalized_name=${1,,}',
|
||||
' while [[ "$normalized_name" == \' \'* ]]; do normalized_name=${normalized_name#?}; done',
|
||||
' while [[ "$normalized_name" == *\' \' ]]; do normalized_name=${normalized_name%?}; done',
|
||||
...matchBody,
|
||||
'}',
|
||||
'read_frontmatter_name() {',
|
||||
' metadata_name=',
|
||||
' metadata_name_known=0',
|
||||
` local LC_ALL=C line first_line=1 remaining=${MAX_MARKDOWN_BYTES}`,
|
||||
" local read_status line_length candidate_name= candidate_name_known=0 non_ascii_pattern='[^ -~]'",
|
||||
' while [ "$remaining" -gt 0 ]; do',
|
||||
' line=',
|
||||
' read_status=0',
|
||||
' IFS= read -r -n "$remaining" line || read_status=$?',
|
||||
' line_length=${#line}',
|
||||
' [ "$line_length" -ge "$remaining" ] && return',
|
||||
' [ "$read_status" -eq 0 ] || return',
|
||||
' remaining=$((remaining - line_length - 1))',
|
||||
" line=${line%$'\\r'}",
|
||||
' if [ "$first_line" -eq 1 ]; then',
|
||||
' first_line=0',
|
||||
" line=${line#$'\\xEF\\xBB\\xBF'}",
|
||||
' [[ "$line" =~ ^---[[:space:]]*$ ]] || return',
|
||||
' continue',
|
||||
' fi',
|
||||
' if [[ "$line" =~ ^---[[:space:]]*$ ]]; then',
|
||||
' metadata_name=$candidate_name',
|
||||
' metadata_name_known=$candidate_name_known',
|
||||
' return',
|
||||
' fi',
|
||||
' if [[ "$line" =~ ^name:[[:space:]]*(.*)$ ]]; then',
|
||||
' candidate_name=${BASH_REMATCH[1]}',
|
||||
' candidate_name_known=0',
|
||||
' while [[ "$candidate_name" == [[:space:]]* ]]; do candidate_name=${candidate_name#?}; done',
|
||||
' while [[ "$candidate_name" == *[[:space:]] ]]; do candidate_name=${candidate_name%?}; done',
|
||||
' case "$candidate_name" in ""|"|"|"|-"|">"|">-") continue ;; esac',
|
||||
' local quote=${candidate_name:0:1}',
|
||||
` if [ "\${#candidate_name}" -eq 1 ] && { [ "$quote" = '"' ] || [ "$quote" = "'" ]; }; then continue; fi`,
|
||||
` if [ "\${#candidate_name}" -ge 2 ] && { [ "$quote" = '"' ] || [ "$quote" = "'" ]; } && [ "\${candidate_name: -1}" = "$quote" ]; then`,
|
||||
' candidate_name=${candidate_name:1:${#candidate_name}-2}',
|
||||
' fi',
|
||||
' while [[ "$candidate_name" == [[:space:]]* ]]; do candidate_name=${candidate_name#?}; done',
|
||||
' while [[ "$candidate_name" == *[[:space:]] ]]; do candidate_name=${candidate_name%?}; done',
|
||||
' [ -n "$candidate_name" ] || continue',
|
||||
' [[ "$candidate_name" =~ $non_ascii_pattern ]] && continue',
|
||||
' candidate_name_known=1',
|
||||
' fi',
|
||||
' done < "$1"',
|
||||
'}'
|
||||
)
|
||||
nameFilterBody.push(
|
||||
' directory_name=${directory_path##*/}',
|
||||
' if is_ascii_name "$directory_name" && ! matches_requested_name "$directory_name"; then',
|
||||
' read_frontmatter_name "$skill_file"',
|
||||
' if [ "$metadata_name_known" -eq 1 ]; then',
|
||||
' matches_requested_name "$metadata_name" || continue',
|
||||
' fi',
|
||||
' fi'
|
||||
)
|
||||
}
|
||||
const lines = [
|
||||
'set -u',
|
||||
'set -o pipefail',
|
||||
...nameFilterHelpers,
|
||||
'scan_root() {',
|
||||
' root_index=$1',
|
||||
' root_path=$2',
|
||||
@@ -40,6 +119,8 @@ export function buildWslSkillDiscoveryCommand(roots: readonly SkillScanRoot[]):
|
||||
` printf '%s\\0%s\\0%s\\0' R "$root_index" 1`,
|
||||
` while IFS= read -r -d '' skill_file; do`,
|
||||
` canonical_path=$(realpath -- "$skill_file" 2>/dev/null || printf '%s' "$skill_file")`,
|
||||
` directory_path=\${skill_file%/*}`,
|
||||
...nameFilterBody,
|
||||
` updated_at=$(stat -c '%Y' -- "$skill_file" 2>/dev/null || true)`,
|
||||
` encoded_markdown=$(head -c ${MAX_MARKDOWN_BYTES} -- "$skill_file" 2>/dev/null | base64 | tr -d '\\n') || continue`,
|
||||
` printf '%s\\0%s\\0%s\\0%s\\0%s\\0' S "$root_index" "$skill_file" "$canonical_path" "$updated_at"`,
|
||||
@@ -77,104 +158,28 @@ async function executeWslSkillDiscovery(distro: string, script: string): Promise
|
||||
return result.stdout
|
||||
}
|
||||
|
||||
function readProtocolField(fields: string[], index: number): string {
|
||||
const value = fields[index]
|
||||
if (value === undefined) {
|
||||
throw new Error('WSL skill discovery returned an incomplete response.')
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
export function parseWslSkillDiscoveryOutput(
|
||||
output: string,
|
||||
roots: readonly SkillScanRoot[],
|
||||
scannedAt = Date.now()
|
||||
): SkillDiscoveryResult {
|
||||
const fields = output.split('\0')
|
||||
const rootExists = new Map<number, boolean>()
|
||||
const skillsByCanonicalPath = new Map<string, DiscoveredSkill>()
|
||||
let index = 0
|
||||
while (index < fields.length && fields[index]) {
|
||||
const recordKind = fields[index++]
|
||||
const rootIndex = Number.parseInt(readProtocolField(fields, index++), 10)
|
||||
const root = roots[rootIndex]
|
||||
if (!root) {
|
||||
throw new Error('WSL skill discovery returned an unknown source.')
|
||||
}
|
||||
if (recordKind === 'R') {
|
||||
rootExists.set(rootIndex, readProtocolField(fields, index++) === '1')
|
||||
continue
|
||||
}
|
||||
if (recordKind !== 'S') {
|
||||
throw new Error('WSL skill discovery returned an invalid response.')
|
||||
}
|
||||
|
||||
const skillFilePath = readProtocolField(fields, index++)
|
||||
const canonicalSkillFilePath = readProtocolField(fields, index++)
|
||||
const updatedAtSeconds = Number.parseInt(readProtocolField(fields, index++), 10)
|
||||
const markdown = Buffer.from(readProtocolField(fields, index++), 'base64').toString('utf8')
|
||||
const existing = skillsByCanonicalPath.get(canonicalSkillFilePath)
|
||||
if (existing) {
|
||||
// Why: dedup keeps one row, but every contributing root must survive so
|
||||
// per-agent visibility does not depend on root scan order. providers is
|
||||
// per-agent visibility too, so union it rather than keeping only the first.
|
||||
if (existing.rootPaths && !existing.rootPaths.includes(root.path)) {
|
||||
existing.rootPaths.push(root.path)
|
||||
}
|
||||
// Reassign a fresh array — `providers` aliases the scan root's array, so
|
||||
// pushing in place would mutate the root and sibling skills/sources.
|
||||
const mergedProviders = [...existing.providers]
|
||||
for (const provider of root.providers) {
|
||||
if (!mergedProviders.includes(provider)) {
|
||||
mergedProviders.push(provider)
|
||||
}
|
||||
}
|
||||
existing.providers = mergedProviders
|
||||
continue
|
||||
}
|
||||
const directoryPath = pathPosix.dirname(skillFilePath)
|
||||
const summary = summarizeSkillMarkdown(markdown)
|
||||
const sourceKind = sourceKindForSkill(root, skillFilePath, pathPosix)
|
||||
skillsByCanonicalPath.set(canonicalSkillFilePath, {
|
||||
id: stablePathId(canonicalSkillFilePath),
|
||||
name: summary.name ?? pathPosix.basename(directoryPath),
|
||||
description: summary.description,
|
||||
// Copy: `root.providers` is shared across every skill/source from this
|
||||
// root, so a later in-place merge must not mutate the aliased array.
|
||||
providers: [...root.providers],
|
||||
sourceKind,
|
||||
sourceLabel: sourceLabelForSkill(root, sourceKind),
|
||||
rootPath: root.path,
|
||||
rootPaths: [root.path],
|
||||
directoryPath,
|
||||
skillFilePath,
|
||||
installed: true,
|
||||
updatedAt: Number.isFinite(updatedAtSeconds) ? updatedAtSeconds * 1000 : null
|
||||
})
|
||||
}
|
||||
|
||||
const sources: SkillDiscoverySource[] = roots.map((root, rootIndex) => {
|
||||
const exists = rootExists.get(rootIndex) ?? false
|
||||
return {
|
||||
...root,
|
||||
providers: [...root.providers],
|
||||
exists,
|
||||
skippedReason: exists ? undefined : 'missing'
|
||||
}
|
||||
})
|
||||
return {
|
||||
skills: sortDiscoveredSkills([...skillsByCanonicalPath.values()]),
|
||||
sources: sortSkillDiscoverySources(sources),
|
||||
scannedAt
|
||||
}
|
||||
}
|
||||
|
||||
export async function discoverSkillsInWsl(args: {
|
||||
type WslSkillDiscoveryArgs = {
|
||||
distro: string
|
||||
homeDir: string
|
||||
cwd: string
|
||||
cwd?: string
|
||||
names?: string[]
|
||||
sourceKinds?: SkillSourceKind[]
|
||||
providerRootOverrides?: SkillProviderRootOverrides
|
||||
}): Promise<SkillDiscoveryResult> {
|
||||
}
|
||||
|
||||
export async function discoverSkillsInWsl(
|
||||
args: WslSkillDiscoveryArgs
|
||||
): Promise<SkillDiscoveryResult> {
|
||||
return projectWslSkillDiscovery(
|
||||
await discoverSkillObservationInWsl(args),
|
||||
args.sourceKinds,
|
||||
args.names
|
||||
)
|
||||
}
|
||||
|
||||
export async function discoverSkillObservationInWsl(
|
||||
args: WslSkillDiscoveryArgs
|
||||
): Promise<WslSkillDiscoveryObservation> {
|
||||
// Plugin roots are resolved (in JS) from metadata this first wsl.exe call
|
||||
// reads, then fed to the scan's own wsl.exe call below — two sequential
|
||||
// process boots. That is a deliberate one-time-per-pane cost (the renderer
|
||||
@@ -184,24 +189,31 @@ export async function discoverSkillsInWsl(args: {
|
||||
// Why: plugin-metadata enrichment is optional. A failed/timed-out read must
|
||||
// degrade to zero plugin roots (matching the native readMetadataFile path),
|
||||
// not abort the mandatory native/home/repo/bundled scan.
|
||||
const cwd = args.cwd ?? args.homeDir
|
||||
let pluginRoots: SkillScanRoot[] = []
|
||||
try {
|
||||
pluginRoots = await discoverClaudePluginSkillSourcesInWsl(args)
|
||||
} catch {
|
||||
pluginRoots = []
|
||||
if (!args.sourceKinds?.length || args.sourceKinds.includes('plugin')) {
|
||||
try {
|
||||
pluginRoots = await discoverClaudePluginSkillSourcesInWsl({ ...args, cwd })
|
||||
} catch {
|
||||
pluginRoots = []
|
||||
}
|
||||
}
|
||||
const roots = [
|
||||
...buildSkillDiscoverySources({
|
||||
homeDir: args.homeDir,
|
||||
cwd: args.cwd,
|
||||
cwd,
|
||||
repos: [],
|
||||
includeCwd: true,
|
||||
pathApi: pathPosix,
|
||||
providerRootOverrides: args.providerRootOverrides
|
||||
}),
|
||||
...pluginRoots
|
||||
]
|
||||
].filter((root) => rootMayContainSourceKind(root, args.sourceKinds))
|
||||
// Why: UNC traversal applies Windows casing and symlink rules. The distro
|
||||
// must own enumeration, metadata reads, and canonical path identity.
|
||||
const output = await executeWslSkillDiscovery(args.distro, buildWslSkillDiscoveryCommand(roots))
|
||||
return parseWslSkillDiscoveryOutput(output, roots)
|
||||
const output = await executeWslSkillDiscovery(
|
||||
args.distro,
|
||||
buildWslSkillDiscoveryCommand(roots, args.names)
|
||||
)
|
||||
return readWslSkillDiscoveryObservation(output, roots)
|
||||
}
|
||||
|
||||
@@ -67,8 +67,12 @@ export function clearInstalledAgentSkillDiscoveryCache(): void {
|
||||
cachedDiscoveryByTarget.clear()
|
||||
}
|
||||
|
||||
export function deleteInstalledAgentSkillDiscoveryCache(key: string): void {
|
||||
cachedDiscoveryByTarget.delete(key)
|
||||
export function deleteInstalledAgentSkillDiscoveryCache(matches: (key: string) => boolean): void {
|
||||
for (const key of cachedDiscoveryByTarget.keys()) {
|
||||
if (matches(key)) {
|
||||
cachedDiscoveryByTarget.delete(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function getInstalledAgentSkillDiscoveryCacheSizeForTests(): number {
|
||||
|
||||
@@ -231,6 +231,47 @@ describe('installed agent skill discovery lifecycle', () => {
|
||||
'repo-1:repair:wsl-distro-required:default'
|
||||
)
|
||||
})
|
||||
|
||||
it('normalizes effective filters into local, project, and environment keys', () => {
|
||||
const target = {
|
||||
projectRuntime: resolvedWslProjectRuntime,
|
||||
names: [' Computer-Use ', 'orchestration', 'computer-use'],
|
||||
sourceKinds: ['home' as const]
|
||||
}
|
||||
const normalizedFilters = [['computer-use', 'orchestration'], ['home']]
|
||||
|
||||
expect(getSkillDiscoveryTargetKey(target)).toBe(
|
||||
JSON.stringify(['repo-1:wsl:Ubuntu', ...normalizedFilters])
|
||||
)
|
||||
expect(getRuntimeScopedSkillDiscoveryKey(remote('env-a'), target)).toBe(
|
||||
JSON.stringify(['runtime:env-a', ...normalizedFilters])
|
||||
)
|
||||
expect(getSkillDiscoveryTargetKey(target, [], [])).toBe(
|
||||
JSON.stringify(['repo-1:wsl:Ubuntu', ...normalizedFilters])
|
||||
)
|
||||
expect(getSkillDiscoveryTargetKey(target, ['ORCHESTRATION'], [])).toBe(
|
||||
JSON.stringify(['repo-1:wsl:Ubuntu', ['orchestration'], ['home']])
|
||||
)
|
||||
})
|
||||
|
||||
it('isolates project-runtime caches by target-contained filters', async () => {
|
||||
discoverSkillsForRuntimeTarget.mockResolvedValueOnce(result(1)).mockResolvedValueOnce(result(2))
|
||||
|
||||
await expect(
|
||||
discoverInstalledAgentSkills(false, {
|
||||
projectRuntime: resolvedWslProjectRuntime,
|
||||
names: ['orchestration']
|
||||
})
|
||||
).resolves.toEqual(result(1))
|
||||
await expect(
|
||||
discoverInstalledAgentSkills(false, {
|
||||
projectRuntime: resolvedWslProjectRuntime,
|
||||
names: ['computer-use']
|
||||
})
|
||||
).resolves.toEqual(result(2))
|
||||
|
||||
expect(discoverSkillsForRuntimeTarget).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
it('keys the bounded cache by runtime scope, not by the client target', async () => {
|
||||
// Why: #6887 scopes remote scans by environment. The cap rewrites this same
|
||||
// module, so pin that getRuntimeScopedSkillDiscoveryKey stays the producer —
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import type { SkillDiscoveryResult, SkillDiscoveryTarget } from '../../../shared/skills'
|
||||
import type {
|
||||
SkillDiscoveryResult,
|
||||
SkillDiscoveryTarget,
|
||||
SkillSourceKind
|
||||
} from '../../../shared/skills'
|
||||
import type { RuntimeClientTarget } from '@/runtime/runtime-rpc-client'
|
||||
import { discoverSkillsForRuntimeTarget } from '@/runtime/runtime-skills-client'
|
||||
import { INSTALLED_AGENT_SKILLS_CHANGED_EVENT } from './installed-agent-skills-change-event'
|
||||
@@ -44,9 +48,16 @@ export function evictInstalledAgentSkillDiscoveryForRuntimeEnvironments(
|
||||
): void {
|
||||
for (const environmentId of environmentIds) {
|
||||
const key = getRuntimeScopedSkillDiscoveryKey({ kind: 'environment', environmentId }, undefined)
|
||||
deleteInstalledAgentSkillDiscoveryCache(key)
|
||||
pendingDiscoveryByTarget.delete(key)
|
||||
pendingDiscoverySatisfiesForcedRefreshByTarget.delete(key)
|
||||
const filteredPrefix = `[${JSON.stringify(key)},`
|
||||
const belongsToRuntime = (candidate: string): boolean =>
|
||||
candidate === key || candidate.startsWith(filteredPrefix)
|
||||
deleteInstalledAgentSkillDiscoveryCache(belongsToRuntime)
|
||||
for (const pendingKey of pendingDiscoveryByTarget.keys()) {
|
||||
if (belongsToRuntime(pendingKey)) {
|
||||
pendingDiscoveryByTarget.delete(pendingKey)
|
||||
pendingDiscoverySatisfiesForcedRefreshByTarget.delete(pendingKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,40 +69,78 @@ export function resetSkillDiscoveryCacheForTests(): void {
|
||||
}
|
||||
|
||||
function normalizeSkillDiscoveryTarget(
|
||||
target: SkillDiscoveryTarget | undefined
|
||||
target: SkillDiscoveryTarget | undefined,
|
||||
names?: readonly string[],
|
||||
sourceKinds?: readonly SkillSourceKind[]
|
||||
): SkillDiscoveryTarget | undefined {
|
||||
const requestedNames = names?.map((name) => name.trim().toLowerCase()).filter(Boolean) ?? []
|
||||
const targetNames = target?.names?.map((name) => name.trim().toLowerCase()).filter(Boolean) ?? []
|
||||
const effectiveNames = [...new Set(requestedNames.length > 0 ? requestedNames : targetNames)]
|
||||
const effectiveSourceKinds = [
|
||||
...new Set(sourceKinds?.length ? sourceKinds : (target?.sourceKinds ?? []))
|
||||
]
|
||||
const filters = {
|
||||
...(effectiveNames?.length ? { names: [...effectiveNames] } : {}),
|
||||
...(effectiveSourceKinds?.length ? { sourceKinds: [...effectiveSourceKinds] } : {})
|
||||
}
|
||||
const projectRuntime = target?.projectRuntime
|
||||
if (projectRuntime) {
|
||||
if (projectRuntime.status === 'repair-required') {
|
||||
return { projectRuntime }
|
||||
return { projectRuntime, ...filters }
|
||||
}
|
||||
if (projectRuntime.runtime.kind === 'wsl') {
|
||||
return {
|
||||
runtime: 'wsl',
|
||||
wslDistro: projectRuntime.runtime.distro,
|
||||
projectRuntime
|
||||
projectRuntime,
|
||||
...filters
|
||||
}
|
||||
}
|
||||
return {
|
||||
runtime: 'host',
|
||||
projectRuntime
|
||||
projectRuntime,
|
||||
...filters
|
||||
}
|
||||
}
|
||||
|
||||
if (target?.runtime !== 'wsl') {
|
||||
return undefined
|
||||
return Object.keys(filters).length > 0 ? filters : undefined
|
||||
}
|
||||
return { runtime: 'wsl', wslDistro: target.wslDistro?.trim() || null }
|
||||
return { runtime: 'wsl', wslDistro: target.wslDistro?.trim() || null, ...filters }
|
||||
}
|
||||
|
||||
export function getSkillDiscoveryTargetKey(target: SkillDiscoveryTarget | undefined): string {
|
||||
function appendSkillDiscoveryFiltersToKey(
|
||||
runtimeKey: string,
|
||||
target: SkillDiscoveryTarget | undefined
|
||||
): string {
|
||||
return target?.names?.length || target?.sourceKinds?.length
|
||||
? JSON.stringify([
|
||||
runtimeKey,
|
||||
target.names ? [...target.names].sort() : null,
|
||||
target.sourceKinds ? [...target.sourceKinds].sort() : null
|
||||
])
|
||||
: runtimeKey
|
||||
}
|
||||
|
||||
function getNormalizedSkillDiscoveryRuntimeKey(target: SkillDiscoveryTarget | undefined): string {
|
||||
if (target?.projectRuntime) {
|
||||
return target.projectRuntime.status === 'resolved'
|
||||
? target.projectRuntime.runtime.cacheKey
|
||||
: target.projectRuntime.repair.cacheKey
|
||||
}
|
||||
const normalizedTarget = normalizeSkillDiscoveryTarget(target)
|
||||
return normalizedTarget?.runtime === 'wsl' ? `wsl:${normalizedTarget.wslDistro ?? ''}` : 'host'
|
||||
return target?.runtime === 'wsl' ? `wsl:${target.wslDistro ?? ''}` : 'host'
|
||||
}
|
||||
|
||||
export function getSkillDiscoveryTargetKey(
|
||||
target: SkillDiscoveryTarget | undefined,
|
||||
names?: readonly string[],
|
||||
sourceKinds?: readonly SkillSourceKind[]
|
||||
): string {
|
||||
const normalizedTarget = normalizeSkillDiscoveryTarget(target, names, sourceKinds)
|
||||
return appendSkillDiscoveryFiltersToKey(
|
||||
getNormalizedSkillDiscoveryRuntimeKey(normalizedTarget),
|
||||
normalizedTarget
|
||||
)
|
||||
}
|
||||
|
||||
// Why: a connected remote runtime scans its own disk. Sharing the local key
|
||||
@@ -101,21 +150,28 @@ export function getSkillDiscoveryTargetKey(target: SkillDiscoveryTarget | undefi
|
||||
// remote gets rescanned once per client-side target shape.
|
||||
export function getRuntimeScopedSkillDiscoveryKey(
|
||||
runtimeTarget: RuntimeClientTarget,
|
||||
target: SkillDiscoveryTarget | undefined
|
||||
target: SkillDiscoveryTarget | undefined,
|
||||
names?: readonly string[],
|
||||
sourceKinds?: readonly SkillSourceKind[]
|
||||
): string {
|
||||
return runtimeTarget.kind === 'environment'
|
||||
? `runtime:${runtimeTarget.environmentId}`
|
||||
: getSkillDiscoveryTargetKey(target)
|
||||
const normalizedTarget = normalizeSkillDiscoveryTarget(target, names, sourceKinds)
|
||||
const runtimeKey =
|
||||
runtimeTarget.kind === 'environment'
|
||||
? `runtime:${runtimeTarget.environmentId}`
|
||||
: getNormalizedSkillDiscoveryRuntimeKey(normalizedTarget)
|
||||
return appendSkillDiscoveryFiltersToKey(runtimeKey, normalizedTarget)
|
||||
}
|
||||
|
||||
function startInstalledAgentSkillDiscovery(
|
||||
force: boolean,
|
||||
target: SkillDiscoveryTarget | undefined,
|
||||
runtimeTarget: RuntimeClientTarget,
|
||||
key: string
|
||||
key: string,
|
||||
names?: readonly string[],
|
||||
sourceKinds?: readonly SkillSourceKind[]
|
||||
): Promise<SkillDiscoveryResult> {
|
||||
const generation = discoveryGeneration
|
||||
const normalizedTarget = normalizeSkillDiscoveryTarget(target)
|
||||
const normalizedTarget = normalizeSkillDiscoveryTarget(target, names, sourceKinds)
|
||||
// Why: a forced caller knows disk changed (install finished, explicit recheck),
|
||||
// so it must also bypass the host's shared scans — not just this window's cache.
|
||||
const requestTarget = force ? { ...normalizedTarget, refresh: true } : normalizedTarget
|
||||
@@ -144,9 +200,11 @@ function startInstalledAgentSkillDiscovery(
|
||||
export async function discoverInstalledAgentSkills(
|
||||
force: boolean,
|
||||
target?: SkillDiscoveryTarget,
|
||||
runtimeTarget: RuntimeClientTarget = LOCAL_RUNTIME_TARGET
|
||||
runtimeTarget: RuntimeClientTarget = LOCAL_RUNTIME_TARGET,
|
||||
names?: readonly string[],
|
||||
sourceKinds?: readonly SkillSourceKind[]
|
||||
): Promise<SkillDiscoveryResult> {
|
||||
const key = getRuntimeScopedSkillDiscoveryKey(runtimeTarget, target)
|
||||
const key = getRuntimeScopedSkillDiscoveryKey(runtimeTarget, target, names, sourceKinds)
|
||||
if (!force) {
|
||||
// Why: only a cache-serving read should refresh recency — a forced refresh
|
||||
// discards the entry it would otherwise promote.
|
||||
@@ -173,5 +231,5 @@ export async function discoverInstalledAgentSkills(
|
||||
}
|
||||
}
|
||||
|
||||
return startInstalledAgentSkillDiscovery(force, target, runtimeTarget, key)
|
||||
return startInstalledAgentSkillDiscovery(force, target, runtimeTarget, key, names, sourceKinds)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import type { SkillDiscoveryResult } from '../../../shared/skills'
|
||||
|
||||
const discover = vi.hoisted(() => vi.fn<() => Promise<SkillDiscoveryResult>>())
|
||||
vi.mock('@/runtime/runtime-skills-client', () => ({ discoverSkillsForRuntimeTarget: discover }))
|
||||
|
||||
import {
|
||||
discoverInstalledAgentSkills,
|
||||
evictInstalledAgentSkillDiscoveryForRuntimeEnvironments,
|
||||
getCachedSkillDiscovery,
|
||||
getRuntimeScopedSkillDiscoveryKey,
|
||||
resetSkillDiscoveryCacheForTests
|
||||
} from './installed-agent-skill-discovery'
|
||||
|
||||
const result = (scannedAt: number): SkillDiscoveryResult => ({ skills: [], sources: [], scannedAt })
|
||||
const runtime = (environmentId: string) => ({ kind: 'environment' as const, environmentId })
|
||||
|
||||
afterEach(() => {
|
||||
resetSkillDiscoveryCacheForTests()
|
||||
discover.mockReset()
|
||||
})
|
||||
|
||||
it.each(['env', 'env"[,]'])(
|
||||
'evicts every filtered scan for %s without evicting other owners',
|
||||
async (id) => {
|
||||
const owner = runtime(id)
|
||||
const other = runtime(`${id}-other`)
|
||||
discover.mockResolvedValue(result(1))
|
||||
for (const names of [['alpha'], ['beta'], undefined]) {
|
||||
await discoverInstalledAgentSkills(false, undefined, owner, names, ['home'])
|
||||
}
|
||||
await discoverInstalledAgentSkills(false, undefined, other, ['alpha'], ['home'])
|
||||
await discoverInstalledAgentSkills(false, undefined, { kind: 'local' }, ['alpha'], ['home'])
|
||||
|
||||
evictInstalledAgentSkillDiscoveryForRuntimeEnvironments([id])
|
||||
discover.mockResolvedValue(result(2))
|
||||
for (const names of [['alpha'], ['beta'], undefined]) {
|
||||
const key = getRuntimeScopedSkillDiscoveryKey(owner, undefined, names, ['home'])
|
||||
expect(getCachedSkillDiscovery(key)).toBeNull()
|
||||
await expect(
|
||||
discoverInstalledAgentSkills(false, undefined, owner, names, ['home'])
|
||||
).resolves.toEqual(result(2))
|
||||
}
|
||||
await expect(
|
||||
discoverInstalledAgentSkills(false, undefined, other, ['alpha'], ['home'])
|
||||
).resolves.toEqual(result(1))
|
||||
await expect(
|
||||
discoverInstalledAgentSkills(false, undefined, { kind: 'local' }, ['alpha'], ['home'])
|
||||
).resolves.toEqual(result(1))
|
||||
expect(discover).toHaveBeenCalledTimes(8)
|
||||
}
|
||||
)
|
||||
|
||||
it('keeps a retired filtered response from replacing the new peer cache', async () => {
|
||||
let finishOld!: (value: SkillDiscoveryResult) => void
|
||||
discover.mockReturnValueOnce(
|
||||
new Promise((resolve) => {
|
||||
finishOld = resolve
|
||||
})
|
||||
)
|
||||
const owner = runtime('env')
|
||||
const old = discoverInstalledAgentSkills(false, undefined, owner, ['alpha'], ['home'])
|
||||
evictInstalledAgentSkillDiscoveryForRuntimeEnvironments(['env'])
|
||||
discover.mockResolvedValue(result(2))
|
||||
await expect(
|
||||
discoverInstalledAgentSkills(false, undefined, owner, ['alpha'], ['home'])
|
||||
).resolves.toEqual(result(2))
|
||||
finishOld(result(1))
|
||||
await old
|
||||
await expect(
|
||||
discoverInstalledAgentSkills(false, undefined, owner, ['alpha'], ['home'])
|
||||
).resolves.toEqual(result(2))
|
||||
expect(discover).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
@@ -305,8 +305,16 @@ describe('useInstalledAgentSkill', () => {
|
||||
})
|
||||
|
||||
expect(latestState?.installed).toBe(false)
|
||||
expect(discover).toHaveBeenNthCalledWith(1, undefined)
|
||||
expect(discover).toHaveBeenNthCalledWith(2, { runtime: 'wsl', wslDistro: 'Fedora' })
|
||||
expect(discover).toHaveBeenNthCalledWith(1, {
|
||||
names: ['orca-linear', 'linear-tickets'],
|
||||
sourceKinds: ['home']
|
||||
})
|
||||
expect(discover).toHaveBeenNthCalledWith(2, {
|
||||
runtime: 'wsl',
|
||||
wslDistro: 'Fedora',
|
||||
names: ['orca-linear', 'linear-tickets'],
|
||||
sourceKinds: ['home']
|
||||
})
|
||||
})
|
||||
|
||||
it('ignores same-target background discovery results when a forced refresh is waiting', async () => {
|
||||
@@ -340,9 +348,16 @@ describe('useInstalledAgentSkill', () => {
|
||||
})
|
||||
|
||||
expect(latestState?.installed).toBe(false)
|
||||
expect(discover).toHaveBeenNthCalledWith(1, undefined)
|
||||
expect(discover).toHaveBeenNthCalledWith(1, {
|
||||
names: ['orca-linear', 'linear-tickets'],
|
||||
sourceKinds: ['home']
|
||||
})
|
||||
// A forced refresh must also bypass the host's shared scans, not just this cache.
|
||||
expect(discover).toHaveBeenNthCalledWith(2, { refresh: true })
|
||||
expect(discover).toHaveBeenNthCalledWith(2, {
|
||||
names: ['orca-linear', 'linear-tickets'],
|
||||
sourceKinds: ['home'],
|
||||
refresh: true
|
||||
})
|
||||
})
|
||||
|
||||
it('returns installed from refresh when a legacy Linear skill is discovered', async () => {
|
||||
@@ -390,7 +405,12 @@ describe('useInstalledAgentSkill', () => {
|
||||
})
|
||||
|
||||
expect(latestState?.installed).toBe(true)
|
||||
expect(discover).toHaveBeenCalledWith({ runtime: 'wsl', wslDistro: 'Ubuntu' })
|
||||
expect(discover).toHaveBeenCalledWith({
|
||||
runtime: 'wsl',
|
||||
wslDistro: 'Ubuntu',
|
||||
names: ['orca-linear', 'linear-tickets'],
|
||||
sourceKinds: ['home']
|
||||
})
|
||||
})
|
||||
|
||||
it('detects a legacy Linear install through project-runtime skill discovery', async () => {
|
||||
@@ -411,7 +431,9 @@ describe('useInstalledAgentSkill', () => {
|
||||
expect(discover).toHaveBeenCalledWith({
|
||||
runtime: 'wsl',
|
||||
wslDistro: 'Ubuntu',
|
||||
projectRuntime: projectWslRuntime
|
||||
projectRuntime: projectWslRuntime,
|
||||
names: ['orca-linear', 'linear-tickets'],
|
||||
sourceKinds: ['home']
|
||||
})
|
||||
})
|
||||
|
||||
@@ -479,7 +501,10 @@ describe('useInstalledAgentSkill', () => {
|
||||
|
||||
// The freshness window is what bounds the storm; past it, focus still reads disk.
|
||||
expect(discover).toHaveBeenCalledTimes(2)
|
||||
expect(discover).toHaveBeenLastCalledWith(undefined)
|
||||
expect(discover).toHaveBeenLastCalledWith({
|
||||
names: ['orca-linear', 'linear-tickets'],
|
||||
sourceKinds: ['home']
|
||||
})
|
||||
})
|
||||
|
||||
it('reuses cached discovery when another surface finishes re-checking', async () => {
|
||||
|
||||
@@ -324,6 +324,44 @@ describe('discoverInstalledAgentSkills', () => {
|
||||
expect(discover).toHaveBeenNthCalledWith(2, { runtime: 'wsl', wslDistro: null })
|
||||
})
|
||||
|
||||
it('forwards filters and isolates filtered discovery caches', async () => {
|
||||
const orchestrationResult = discoveryResult([skill({ name: 'orchestration' })])
|
||||
const computerUseResult = discoveryResult([skill({ name: 'computer-use' })])
|
||||
const discover = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(orchestrationResult)
|
||||
.mockResolvedValueOnce(computerUseResult)
|
||||
vi.stubGlobal('window', { api: { skills: { discover } } })
|
||||
|
||||
await _installedAgentSkillDiscoveryInternalsForTests.discoverInstalledAgentSkills(
|
||||
false,
|
||||
{ runtime: 'wsl', wslDistro: 'Ubuntu' },
|
||||
undefined,
|
||||
['orchestration'],
|
||||
GLOBAL_AGENT_SKILL_SOURCE_KINDS
|
||||
)
|
||||
await _installedAgentSkillDiscoveryInternalsForTests.discoverInstalledAgentSkills(
|
||||
false,
|
||||
{ runtime: 'wsl', wslDistro: 'Ubuntu' },
|
||||
undefined,
|
||||
['computer-use'],
|
||||
GLOBAL_AGENT_SKILL_SOURCE_KINDS
|
||||
)
|
||||
|
||||
expect(discover).toHaveBeenNthCalledWith(1, {
|
||||
runtime: 'wsl',
|
||||
wslDistro: 'Ubuntu',
|
||||
names: ['orchestration'],
|
||||
sourceKinds: ['home']
|
||||
})
|
||||
expect(discover).toHaveBeenNthCalledWith(2, {
|
||||
runtime: 'wsl',
|
||||
wslDistro: 'Ubuntu',
|
||||
names: ['computer-use'],
|
||||
sourceKinds: ['home']
|
||||
})
|
||||
})
|
||||
|
||||
it('forwards project runtime targets to skill discovery', async () => {
|
||||
const wslResult = discoveryResult([skill({ name: 'wsl-skill' })])
|
||||
const discover = vi.fn().mockResolvedValueOnce(wslResult)
|
||||
|
||||
@@ -140,7 +140,12 @@ export function useInstalledAgentSkillNames(
|
||||
const candidateSkillNames = useMemo(() => skillNamesKey.split('\n'), [skillNamesKey])
|
||||
const runtimeTarget = useActiveSkillDiscoveryRuntimeTarget()
|
||||
const discoveryTargetKey = runtimeTarget
|
||||
? getRuntimeScopedSkillDiscoveryKey(runtimeTarget, discoveryTarget)
|
||||
? getRuntimeScopedSkillDiscoveryKey(
|
||||
runtimeTarget,
|
||||
discoveryTarget,
|
||||
candidateSkillNames,
|
||||
sourceKinds
|
||||
)
|
||||
: UNRESOLVED_RUNTIME_DISCOVERY_KEY
|
||||
// Why: callers derive the target inside a store-backed useMemo, so unrelated
|
||||
// store writes hand us a new object with the same key. Two targets with the
|
||||
@@ -229,7 +234,13 @@ export function useInstalledAgentSkillNames(
|
||||
}
|
||||
let installedAfterRefresh = false
|
||||
try {
|
||||
const next = await discoverInstalledAgentSkills(force, stableDiscoveryTarget, runtimeTarget)
|
||||
const next = await discoverInstalledAgentSkills(
|
||||
force,
|
||||
stableDiscoveryTarget,
|
||||
runtimeTarget,
|
||||
candidateSkillNames,
|
||||
sourceKinds
|
||||
)
|
||||
installedAfterRefresh = hasInstalledAgentSkillNamed(next.skills, candidateSkillNames, {
|
||||
sourceKinds
|
||||
})
|
||||
|
||||
@@ -80,6 +80,28 @@ describe('discoverSkillsForRuntimeTarget', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('forwards portable inventory filters without client runtime identity', async () => {
|
||||
const result = discoveryResult('orchestration')
|
||||
runtimeEnvironmentCall.mockResolvedValueOnce({ id: 'skills', ok: true, result })
|
||||
|
||||
await discoverSkillsForRuntimeTarget(
|
||||
{ kind: 'environment', environmentId: 'env-1' },
|
||||
{
|
||||
runtime: 'wsl',
|
||||
wslDistro: 'Ubuntu',
|
||||
names: ['orchestration'],
|
||||
sourceKinds: ['home']
|
||||
}
|
||||
)
|
||||
|
||||
expect(runtimeEnvironmentCall).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
method: 'skills.discover',
|
||||
params: { names: ['orchestration'], sourceKinds: ['home'] }
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
// Why: no caller can produce these yet, so the remote params must stay empty
|
||||
// rather than shipping a client-host target the server would misread.
|
||||
it('sends no target at all to a remote runtime', async () => {
|
||||
|
||||
@@ -34,6 +34,7 @@ const SKILL_DISCOVERY_TIMEOUT_MS = 15_000
|
||||
* `refresh` is the exception and must be forwarded: it describes the *request*,
|
||||
* not the client's host, and it is the only way an explicit re-check reaches
|
||||
* past the remote host's shared scans to its disk.
|
||||
* Portable inventory filters also apply to the remote disk being scanned.
|
||||
*/
|
||||
export async function discoverSkillsForRuntimeTarget(
|
||||
runtimeTarget: RuntimeClientTarget,
|
||||
@@ -45,7 +46,11 @@ export async function discoverSkillsForRuntimeTarget(
|
||||
return callRuntimeRpc<SkillDiscoveryResult>(
|
||||
runtimeTarget,
|
||||
'skills.discover',
|
||||
target?.refresh ? { refresh: true } : {},
|
||||
{
|
||||
...(target?.refresh ? { refresh: true } : {}),
|
||||
...(target?.names?.length ? { names: target.names } : {}),
|
||||
...(target?.sourceKinds?.length ? { sourceKinds: target.sourceKinds } : {})
|
||||
},
|
||||
{ timeoutMs: SKILL_DISCOVERY_TIMEOUT_MS }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -31,3 +31,16 @@ Use when reviewing UI implementation quality.
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it.each(['', '\uFEFF'])('preserves the last CRLF frontmatter field with BOM %j', (bom) => {
|
||||
for (const fields of [
|
||||
['name: actual-name', 'description: actual description'],
|
||||
['description: actual description', 'name: actual-name']
|
||||
]) {
|
||||
const markdown = `${bom}${['---', ...fields, '---', '# Fallback heading', '', 'Fallback paragraph'].join('\r\n')}`
|
||||
expect(summarizeSkillMarkdown(markdown)).toEqual({
|
||||
name: 'actual-name',
|
||||
description: 'actual description'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -14,7 +14,7 @@ function stripQuotePair(value: string): string {
|
||||
}
|
||||
|
||||
function parseYamlFrontmatter(raw: string): Record<string, FrontmatterValue> {
|
||||
const lines = raw.replace(/\r\n/g, '\n').split('\n')
|
||||
const lines = raw.replace(/\r\n/g, '\n').replace(/\r$/, '').split('\n')
|
||||
const data: Record<string, FrontmatterValue> = {}
|
||||
let index = 0
|
||||
while (index < lines.length) {
|
||||
|
||||
@@ -54,6 +54,9 @@ export type SkillDiscoveryTarget = {
|
||||
/** Bypass the host's shared scans because the caller knows disk just changed.
|
||||
* Optional so an older host simply ignores it and scans as it always did. */
|
||||
refresh?: boolean
|
||||
/** Optional inventory filter for callers that only need known installed skills. */
|
||||
names?: string[]
|
||||
sourceKinds?: SkillSourceKind[]
|
||||
}
|
||||
|
||||
const ResolvedProjectRuntimeSchema = z.object({
|
||||
@@ -104,7 +107,12 @@ export const SkillDiscoveryTargetSchema: z.ZodType<SkillDiscoveryTarget> = z.obj
|
||||
projectRuntime: z
|
||||
.discriminatedUnion('status', [ResolvedProjectRuntimeSchema, RepairProjectRuntimeSchema])
|
||||
.optional(),
|
||||
refresh: z.boolean().optional()
|
||||
refresh: z.boolean().optional(),
|
||||
names: z.array(z.string().trim().min(1).max(200)).max(100).optional(),
|
||||
sourceKinds: z
|
||||
.array(z.enum(['home', 'repo', 'bundled', 'plugin']))
|
||||
.max(4)
|
||||
.optional()
|
||||
})
|
||||
|
||||
export type SkillFrontmatterSummary = {
|
||||
|
||||
Reference in New Issue
Block a user