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 { 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 { 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 { 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 }) }