import assert from 'node:assert/strict' import { lstat, mkdir, readFile, rm, symlink, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import test from 'node:test' import { prepareUserSkillRoots, UserSkillConflictError, UserSkillManager } from '../../control-plane/services/management/user-skills.mjs' async function writeDirectorySkill(root, entry, name = entry, description = `${name} description`) { const path = join(root, entry) await mkdir(path, { recursive: true }) await writeFile(join(path, 'SKILL.md'), `---\nname: ${name}\ndescription: ${description}\n---\n\nUse ${name}.\n`) return path } async function fixture() { const root = await import('node:fs/promises').then(fs => fs.mkdtemp(join(tmpdir(), 'dsh-user-skills-'))) const dshHome = join(root, 'dsh') const agentsHome = join(root, 'agents') await mkdir(join(dshHome, 'skills'), { recursive: true }) await mkdir(join(agentsHome, 'skills'), { recursive: true }) return { root, dshHome, agentsHome, manager: new UserSkillManager({ dshHome, agentsHome }) } } test('prepares real user Skill roots before privileged tools create entries', async t => { const root = await import('node:fs/promises').then(fs => fs.mkdtemp(join(tmpdir(), 'dsh-user-skill-roots-'))) t.after(() => rm(root, { recursive: true, force: true })) const dshHome = join(root, 'dsh') const agentsHome = join(root, 'agents') const roots = await prepareUserSkillRoots({ dshHome, agentsHome }) assert.deepEqual(roots, [join(dshHome, 'skills'), join(agentsHome, 'skills')]) assert.equal((await lstat(roots[0])).isDirectory(), true) assert.equal((await lstat(roots[1])).isDirectory(), true) assert.equal((await lstat(roots[0])).mode & 0o2777, 0o2775) assert.equal((await lstat(roots[1])).mode & 0o2777, 0o2775) await rm(roots[0], { recursive: true }) await symlink(root, roots[0]) await assert.rejects(prepareUserSkillRoots({ dshHome, agentsHome }), /must be a directory/) }) test('inventories native directory and flat user skills from both user roots', async t => { const value = await fixture() t.after(() => rm(value.root, { recursive: true, force: true })) await writeDirectorySkill(join(value.dshHome, 'skills'), 'directory-entry', 'shared-name', 'Directory guidance') await writeFile(join(value.agentsHome, 'skills/flat-entry.md'), '---\nname: shared-name\ndescription: Flat guidance\n---\n\nFlat body.\n') await writeFile(join(value.dshHome, 'skills/notes.txt'), 'not a skill') await mkdir(join(value.dshHome, 'skills/.system/ignored'), { recursive: true }) const inventory = await value.manager.list() assert.match(inventory.revision, /^sha256:[0-9a-f]{64}$/) assert.deepEqual(inventory.skills.map(skill => ({ source: skill.source, entryName: skill.entryName, name: skill.name, kind: skill.kind, enabled: skill.enabled, description: skill.description, })), [ { source: 'user-agents', entryName: 'flat-entry.md', name: 'shared-name', kind: 'file', enabled: true, description: 'Flat guidance' }, { source: 'user-dsh', entryName: 'directory-entry', name: 'shared-name', kind: 'directory', enabled: true, description: 'Directory guidance' }, ]) }) test('keeps malformed user skills visible for disable and delete', async t => { const value = await fixture() t.after(() => rm(value.root, { recursive: true, force: true })) await mkdir(join(value.dshHome, 'skills/broken'), { recursive: true }) await writeFile(join(value.dshHome, 'skills/broken/SKILL.md'), '---\nname: Broken_Name\ndescription: broken\n---\n') const inventory = await value.manager.list() assert.equal(inventory.skills.length, 1) assert.equal(inventory.skills[0].damaged, true) assert.match(inventory.skills[0].metadataError, /name is invalid/i) const disabled = await value.manager.configure({ entryId: inventory.skills[0].entryId, revision: inventory.revision, action: 'disable', }) assert.equal(disabled.skills[0].enabled, false) const deleted = await value.manager.configure({ entryId: disabled.skills[0].entryId, revision: disabled.revision, action: 'delete', }) assert.deepEqual(deleted.skills, []) }) test('atomically disables and enables exact entries without confusing duplicate names', async t => { const value = await fixture() t.after(() => rm(value.root, { recursive: true, force: true })) await writeDirectorySkill(join(value.dshHome, 'skills'), 'first-entry', 'duplicate-name') await writeDirectorySkill(join(value.agentsHome, 'skills'), 'second-entry', 'duplicate-name') const before = await value.manager.list() const selected = before.skills.find(skill => skill.source === 'user-dsh') const disabled = await value.manager.configure({ entryId: selected.entryId, revision: before.revision, action: 'disable' }) assert.equal(disabled.skills.find(skill => skill.source === 'user-dsh').enabled, false) assert.equal(disabled.skills.find(skill => skill.source === 'user-agents').enabled, true) await assert.rejects(lstat(join(value.dshHome, 'skills/first-entry')), error => error.code === 'ENOENT') assert.equal((await lstat(join(value.dshHome, 'skills/.disabled/first-entry'))).isDirectory(), true) const afterManagementRestart = await new UserSkillManager({ dshHome: value.dshHome, agentsHome: value.agentsHome }).list() assert.equal(afterManagementRestart.skills.find(skill => skill.source === 'user-dsh').enabled, false) const disabledEntry = disabled.skills.find(skill => skill.source === 'user-dsh') const enabled = await value.manager.configure({ entryId: disabledEntry.entryId, revision: disabled.revision, action: 'enable' }) assert.equal(enabled.skills.find(skill => skill.source === 'user-dsh').enabled, true) assert.match(await readFile(join(value.dshHome, 'skills/first-entry/SKILL.md'), 'utf8'), /duplicate-name/) }) test('accepts a directory skill whose SKILL.md is a symlink like native discovery', async t => { const value = await fixture() t.after(() => rm(value.root, { recursive: true, force: true })) const sharedDocument = join(value.root, 'shared-skill.md') await writeFile(sharedDocument, '---\nname: linked-document\ndescription: Linked document\n---\n\nLinked body.\n') await mkdir(join(value.dshHome, 'skills/linked-document'), { recursive: true }) await symlink(sharedDocument, join(value.dshHome, 'skills/linked-document/SKILL.md')) const inventory = await value.manager.list() assert.equal(inventory.skills[0].name, 'linked-document') assert.equal(inventory.skills[0].damaged, false) }) test('rejects stale revisions and destination conflicts', async t => { const value = await fixture() t.after(() => rm(value.root, { recursive: true, force: true })) await writeDirectorySkill(join(value.dshHome, 'skills'), 'example') const before = await value.manager.list() await writeDirectorySkill(join(value.agentsHome, 'skills'), 'concurrent') await assert.rejects(value.manager.configure({ entryId: before.skills[0].entryId, revision: before.revision, action: 'disable', }), UserSkillConflictError) const current = await value.manager.list() const selected = current.skills.find(skill => skill.entryName === 'example') await mkdir(join(value.dshHome, 'skills/.disabled/example'), { recursive: true }) await assert.rejects(value.manager.configure({ entryId: selected.entryId, revision: current.revision, action: 'disable', }), UserSkillConflictError) }) test('deletes a symlink entry without touching its external target', async t => { const value = await fixture() t.after(() => rm(value.root, { recursive: true, force: true })) const external = await writeDirectorySkill(value.root, 'external-skill', 'linked-skill') await symlink(external, join(value.dshHome, 'skills/linked-skill'), 'dir') const before = await value.manager.list() assert.equal(before.skills[0].symbolicLink, true) const after = await value.manager.configure({ entryId: before.skills[0].entryId, revision: before.revision, action: 'delete', }) assert.deepEqual(after.skills, []) assert.match(await readFile(join(external, 'SKILL.md'), 'utf8'), /linked-skill/) })