Skip to content

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

Package listing

@kody/skills

src/skill-save.ts

255 lines · 6.1 KB · TypeScript
import { getBackend } from './backend.ts'
import { isDryRun } from './dry-run.ts'
import {
	getFile,
	getSkill,
	insertSkillRevision,
	nowIso,
	snapshotFile,
	sqlRun,
	ensureSchema,
	upsertSkillsIndex,
} from './db.ts'
import {
	commitAndPublish,
	withSkillsRepoSession,
	parseSkillMeta,
	readRepoFile,
	serializeSkillMeta,
	skillFilePath,
	skillMetaPath,
	writeRepoFiles,
	type SkillMeta,
} from './repo.ts'

export type SkillSaveInput = {
	id: string
	path: string
	content: string
	name?: string
	description?: string
	dryRun?: boolean
}

export type SkillSaveResult = {
	id: string
	path: string
	version_saved: boolean
	dryRun?: true
	would?: {
		action: 'create' | 'update'
		backend: 'legacy' | 'repo'
		commit: false
	}
}

async function saveLegacy(params: {
	id: string
	path: string
	content: string
	name?: string
	description?: string
	dryRun: boolean
}): Promise<SkillSaveResult> {
	await ensureSchema()
	const { id, path, content } = params
	const existing = await getSkill(id)
	const name = params.name
	const description = params.description
	const timestamp = nowIso()

	if (!existing) {
		if (!name || !description) {
			throw new Error(
				`skill_save: creating skill \`${id}\` requires both \`name\` and \`description\`.`,
			)
		}
		if (params.dryRun) {
			return {
				id,
				path,
				version_saved: false,
				dryRun: true,
				would: { action: 'create', backend: 'legacy', commit: false },
			}
		}
		await sqlRun(
			`INSERT INTO skills (id, name, description, created_at, updated_at)
			 VALUES (?, ?, ?, ?, ?)`,
			[id, name, description, timestamp, timestamp],
		)
	} else if (params.dryRun) {
		const previous = await getFile(id, path)
		return {
			id,
			path,
			version_saved: previous != null,
			dryRun: true,
			would: { action: 'update', backend: 'legacy', commit: false },
		}
	} else {
		const nextName = name ?? existing.name
		const nextDescription = description ?? existing.description
		await sqlRun(
			`UPDATE skills
			 SET name = ?, description = ?, updated_at = ?
			 WHERE id = ?`,
			[nextName, nextDescription, timestamp, id],
		)
	}

	const previous = await getFile(id, path)
	let versionSaved = false
	if (previous) {
		await snapshotFile(id, path, previous.content, timestamp)
		versionSaved = true
		await sqlRun(
			`UPDATE skill_files
			 SET content = ?, updated_at = ?
			 WHERE skill_id = ? AND path = ?`,
			[content, timestamp, id, path],
		)
	} else {
		await sqlRun(
			`INSERT INTO skill_files (skill_id, path, content, updated_at)
			 VALUES (?, ?, ?, ?)`,
			[id, path, content, timestamp],
		)
	}

	return { id, path, version_saved: versionSaved }
}

async function saveRepo(params: {
	id: string
	path: string
	content: string
	name?: string
	description?: string
	dryRun: boolean
}): Promise<SkillSaveResult> {
	const { id, path, content } = params
	return withSkillsRepoSession(async (session) => {
		const metaPath = skillMetaPath(id)
		const filePath = skillFilePath(id, path)
		const existingMeta = parseSkillMeta(await readRepoFile(session.id, metaPath))
		const previousContent = await readRepoFile(session.id, filePath)
		const timestamp = nowIso()

		if (!existingMeta) {
			if (!params.name || !params.description) {
				throw new Error(
					`skill_save: creating skill \`${id}\` requires both \`name\` and \`description\`.`,
				)
			}
		}

		if (params.dryRun) {
			return {
				id,
				path,
				version_saved: previousContent != null,
				dryRun: true,
				would: {
					action: existingMeta ? 'update' : 'create',
					backend: 'repo',
					commit: false,
				},
			}
		}

		const files = existingMeta
			? existingMeta.files.includes(path)
				? existingMeta.files
				: [...existingMeta.files, path].sort()
			: [path]

		const meta: SkillMeta = {
			name: params.name ?? existingMeta!.name,
			description: params.description ?? existingMeta!.description,
			created_at: existingMeta?.created_at ?? timestamp,
			updated_at: timestamp,
			files,
		}

		await writeRepoFiles(session.id, [
			{ path: metaPath, content: serializeSkillMeta(meta) },
			{ path: filePath, content },
		])

		const { oid } = await commitAndPublish(
			session.id,
			`skill-save: ${id} ${path}`,
		)

		let versionSaved = false
		if (previousContent != null) {
			await insertSkillRevision({
				commit_oid: session.base_commit,
				skill_id: id,
				path,
				replaced_at: timestamp,
				content_length: previousContent.length,
			})
			versionSaved = true
		}

		await upsertSkillsIndex({
			id,
			name: meta.name,
			description: meta.description,
			files: meta.files,
			created_at: meta.created_at,
			updated_at: meta.updated_at,
		})

		void oid
		return { id, path, version_saved: versionSaved }
	})
}

/**
 * Upsert a skill file. On first create, `name` and `description` are required.
 * Overwriting an existing file records a git revision (repo backend) or
 * snapshots previous content into skill_versions (legacy backend).
 *
 * @example
 * import skillSave from 'kody:@kody/skills/skill-save'
 * const result = await skillSave({
 *   id: 'writing-style',
 *   path: 'SKILL.md',
 *   content: '# ...',
 *   name: 'writing-style',
 *   description: 'How to draft in the account owner voice...',
 *   dryRun: true,
 * })
 */
export default async function skillSave(
	params: SkillSaveInput = {} as SkillSaveInput,
): Promise<SkillSaveResult> {
	const id = String(params.id ?? '').trim()
	const path = String(params.path ?? '').trim()
	const content = params.content == null ? '' : String(params.content)

	if (!id) throw new Error('skill_save requires `id`.')
	if (!path) throw new Error('skill_save requires `path`.')
	if (path.includes('/') || path.includes('\\') || path === 'skill.json') {
		throw new Error(
			`skill_save: invalid path \`${path}\`. Use a skill-relative file name (e.g. SKILL.md), not skill.json.`,
		)
	}

	const name =
		params.name == null ? undefined : String(params.name).trim() || undefined
	const description =
		params.description == null
			? undefined
			: String(params.description).trim() || undefined

	const dryRun = isDryRun(params.dryRun)
	const backend = await getBackend()
	if (backend === 'repo') {
		return saveRepo({ id, path, content, name, description, dryRun })
	}
	return saveLegacy({ id, path, content, name, description, dryRun })
}