import { getBackend } from './backend.ts'
import { isDryRun } from './dry-run.ts'
import {
ensureSchema,
getFile,
getSkillOrThrow,
listFilesForSkill,
nowIso,
snapshotFile,
sqlRun,
deleteSkillsIndex,
insertSkillRevision,
upsertSkillsIndex,
} from './db.ts'
import {
commitAndPublish,
deleteRepoPaths,
withSkillsRepoSession,
parseSkillMeta,
readRepoFile,
serializeSkillMeta,
skillFilePath,
skillMetaPath,
writeRepoFiles,
} from './repo.ts'
export type SkillDeleteInput = {
id: string
path?: string
dryRun?: boolean
}
export type SkillDeleteResult = {
id: string
deleted: 'file' | 'skill'
path?: string
files_deleted: number
versions_saved: number
dryRun?: true
}
async function deleteLegacy(params: {
id: string
path: string
dryRun: boolean
}): Promise<SkillDeleteResult> {
await ensureSchema()
const { id, path } = params
await getSkillOrThrow(id)
const timestamp = nowIso()
if (path) {
const file = await getFile(id, path)
if (!file) {
const files = await listFilesForSkill(id)
const available =
files.length > 0
? files.map((row) => `\`${row.path}\``).join(', ')
: '(none)'
throw new Error(
`File not found: \`${path}\` in skill \`${id}\`. Valid paths: ${available}.`,
)
}
if (params.dryRun) {
return {
id,
deleted: 'file',
path,
files_deleted: 1,
versions_saved: 1,
dryRun: true,
}
}
await snapshotFile(id, path, file.content, timestamp)
await sqlRun(`DELETE FROM skill_files WHERE skill_id = ? AND path = ?`, [
id,
path,
])
await sqlRun(`UPDATE skills SET updated_at = ? WHERE id = ?`, [
timestamp,
id,
])
return {
id,
deleted: 'file',
path,
files_deleted: 1,
versions_saved: 1,
}
}
const files = await listFilesForSkill(id)
if (params.dryRun) {
return {
id,
deleted: 'skill',
files_deleted: files.length,
versions_saved: files.length,
dryRun: true,
}
}
for (const file of files) {
await snapshotFile(id, file.path, file.content, timestamp)
}
await sqlRun(`DELETE FROM skill_files WHERE skill_id = ?`, [id])
await sqlRun(`DELETE FROM skills WHERE id = ?`, [id])
return {
id,
deleted: 'skill',
files_deleted: files.length,
versions_saved: files.length,
}
}
async function deleteRepo(params: {
id: string
path: string
dryRun: boolean
}): Promise<SkillDeleteResult> {
const { id, path } = params
return withSkillsRepoSession(async (session) => {
const meta = parseSkillMeta(await readRepoFile(session.id, skillMetaPath(id)))
if (!meta) {
await getSkillOrThrow(id)
throw new Error(`Skill not found in repo: \`${id}\`.`)
}
const timestamp = nowIso()
if (path) {
const filePath = skillFilePath(id, path)
const previous = await readRepoFile(session.id, filePath)
if (previous == null) {
const available =
meta.files.length > 0
? meta.files.map((value) => `\`${value}\``).join(', ')
: '(none)'
throw new Error(
`File not found: \`${path}\` in skill \`${id}\`. Valid paths: ${available}.`,
)
}
if (params.dryRun) {
return {
id,
deleted: 'file',
path,
files_deleted: 1,
versions_saved: 1,
dryRun: true,
}
}
const nextFiles = meta.files.filter((value) => value !== path)
await deleteRepoPaths(session.id, [filePath])
await writeRepoFiles(session.id, [
{
path: skillMetaPath(id),
content: serializeSkillMeta({
...meta,
files: nextFiles,
updated_at: timestamp,
}),
},
])
await commitAndPublish(session.id, `skill-delete-file: ${id} ${path}`)
await insertSkillRevision({
commit_oid: session.base_commit,
skill_id: id,
path,
replaced_at: timestamp,
content_length: previous.length,
})
await upsertSkillsIndex({
id,
name: meta.name,
description: meta.description,
files: nextFiles,
created_at: meta.created_at,
updated_at: timestamp,
})
return {
id,
deleted: 'file',
path,
files_deleted: 1,
versions_saved: 1,
}
}
const paths = [
skillMetaPath(id),
...meta.files.map((filePath) => skillFilePath(id, filePath)),
]
const contents: Array<{ path: string; content: string }> = []
for (const filePath of meta.files) {
const content = await readRepoFile(session.id, skillFilePath(id, filePath))
if (content != null) contents.push({ path: filePath, content })
}
if (params.dryRun) {
return {
id,
deleted: 'skill',
files_deleted: contents.length,
versions_saved: contents.length,
dryRun: true,
}
}
await deleteRepoPaths(session.id, paths)
await commitAndPublish(session.id, `skill-delete: ${id}`)
for (const file of contents) {
await insertSkillRevision({
commit_oid: session.base_commit,
skill_id: id,
path: file.path,
replaced_at: timestamp,
content_length: file.content.length,
})
}
await deleteSkillsIndex(id)
return {
id,
deleted: 'skill',
files_deleted: contents.length,
versions_saved: contents.length,
}
})
}
/**
* Delete one skill file (`path` set) or an entire skill (no `path`).
* Previous content is recorded as a git revision (repo) or skill_versions row (legacy).
*
* @example
* import skillDelete from 'kody:@kody/skills/skill-delete'
* await skillDelete({ id: 'temp-skill', path: 'notes.md', dryRun: true })
* await skillDelete({ id: 'temp-skill', dryRun: true })
*/
export default async function skillDelete(
params: SkillDeleteInput = {} as SkillDeleteInput,
): Promise<SkillDeleteResult> {
const id = String(params.id ?? '').trim()
if (!id) throw new Error('skill_delete requires `id`.')
const path = params.path == null ? '' : String(params.path).trim()
const dryRun = isDryRun(params.dryRun)
const backend = await getBackend()
return backend === 'repo'
? deleteRepo({ id, path, dryRun })
: deleteLegacy({ id, path, dryRun })
}