Skip to content

Built for people who want to own their automations. Join the waitlist for an invite.

Package listing

@kody/skills

src/skill-delete.ts

255 lines · 5.8 KB · TypeScript
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 })
}