diff --git a/.changeset/delete-skill.md b/.changeset/delete-skill.md new file mode 100644 index 000000000..ec7bae665 --- /dev/null +++ b/.changeset/delete-skill.md @@ -0,0 +1,5 @@ +--- +"@truefoundry/trueforge": minor +--- + +Add `DELETE /api/v1/settings/skills/{name}` to permanently remove a configured skill. Idempotent if already gone. diff --git a/packages/trueforge/src/apis/skills.ts b/packages/trueforge/src/apis/skills.ts index d5b6b9aa3..7c1251fb1 100644 --- a/packages/trueforge/src/apis/skills.ts +++ b/packages/trueforge/src/apis/skills.ts @@ -3,6 +3,7 @@ import { SkillNameConflictError, type ISkillStore, type SkillRecord } from '../d import type { WithTransaction } from '../db/transaction'; import { createSkillRoute, + deleteSkillRoute, listAvailableSkillsRoute, listConfiguredSkillsRoute, putSkillRoute, @@ -59,10 +60,17 @@ export function createSkillsRouter(deps: SkillsRouterDeps = async c => { + const { name } = c.req.valid('param'); + await deps.skillStore.deleteSkill({ tenant_id: TENANT_ID, name }); + return c.json({}, 200); + }; + const router = new OpenAPIHono(); router.openapi(listConfiguredSkillsRoute, listConfiguredHandler); router.openapi(createSkillRoute, createHandler); router.openapi(putSkillRoute, putHandler); + router.openapi(deleteSkillRoute, deleteHandler); return router; } diff --git a/packages/trueforge/src/db/postgres/skill-store/PostgresSkillStore.ts b/packages/trueforge/src/db/postgres/skill-store/PostgresSkillStore.ts index 02ee203fc..0040cb254 100644 --- a/packages/trueforge/src/db/postgres/skill-store/PostgresSkillStore.ts +++ b/packages/trueforge/src/db/postgres/skill-store/PostgresSkillStore.ts @@ -97,4 +97,9 @@ export class PostgresSkillStore implements ISkillStore> { .executeTakeFirstOrThrow(); return toRecord(row); } + + async deleteSkill(input: GetSkillInput, transaction?: Transaction): Promise { + const db = transaction ?? this.#db; + await db.deleteFrom('skill').where('tenant_id', '=', input.tenant_id).where('name', '=', input.name).execute(); + } } diff --git a/packages/trueforge/src/db/skillStore.ts b/packages/trueforge/src/db/skillStore.ts index 312ec14a7..614eb98e8 100644 --- a/packages/trueforge/src/db/skillStore.ts +++ b/packages/trueforge/src/db/skillStore.ts @@ -56,4 +56,6 @@ export interface ISkillStore { createSkill(input: CreateSkillInput, transaction?: TTransaction): Promise; /** Single-row write: creates the skill or replaces the whole manifest. */ upsertSkill(input: UpsertSkillInput, transaction?: TTransaction): Promise; + /** Permanently removes the skill row. Idempotent if already gone. */ + deleteSkill(input: GetSkillInput, transaction?: TTransaction): Promise; } diff --git a/packages/trueforge/src/db/sqlite/skill-store/SqliteSkillStore.ts b/packages/trueforge/src/db/sqlite/skill-store/SqliteSkillStore.ts index c82b593a8..5426807d5 100644 --- a/packages/trueforge/src/db/sqlite/skill-store/SqliteSkillStore.ts +++ b/packages/trueforge/src/db/sqlite/skill-store/SqliteSkillStore.ts @@ -97,4 +97,9 @@ export class SqliteSkillStore implements ISkillStore> { .returning(recordColumns) .executeTakeFirstOrThrow(); } + + async deleteSkill(input: GetSkillInput, transaction?: Transaction): Promise { + const db = transaction ?? this.#db; + await db.deleteFrom('skill').where('tenant_id', '=', input.tenant_id).where('name', '=', input.name).execute(); + } } diff --git a/packages/trueforge/src/routes/skillRoutes.ts b/packages/trueforge/src/routes/skillRoutes.ts index 5770c6d75..c5fd63981 100644 --- a/packages/trueforge/src/routes/skillRoutes.ts +++ b/packages/trueforge/src/routes/skillRoutes.ts @@ -4,10 +4,11 @@ * /api/v1/skills. * Discovery catalog lives at GET /api/v1/catalogs/skills. */ -import { createRoute } from '@hono/zod-openapi'; +import { createRoute, z } from '@hono/zod-openapi'; import { RequestErrorResponseSchema } from '../schemas/errors'; import { CreateSkillRequestSchema, + DeleteSkillResponseSchema, GetSkillResponseSchema, ListAvailableSkillsResponseSchema, ListSkillsResponseSchema, @@ -115,3 +116,34 @@ export const putSkillRoute = createRoute({ }, }, }); + +const SkillNameParamsSchema = z.object({ + name: z.string().min(1).describe('Skill name.'), +}); + +export const deleteSkillRoute = createRoute({ + method: 'delete', + path: '/{name}', + tags: [OpenApiTag.SKILLS], + summary: 'Delete a skill', + description: 'Permanently removes the configured skill by name. Idempotent if already gone.', + 'x-fern-sdk-group-name': ['settings', 'skills'], + 'x-fern-sdk-method-name': 'delete', + request: { + params: SkillNameParamsSchema, + }, + responses: { + 200: { + content: { 'application/json': { schema: DeleteSkillResponseSchema } }, + description: 'Skill deleted.', + }, + 401: { + content: { 'application/json': { schema: RequestErrorResponseSchema } }, + description: 'OIDC is configured and the request has no valid session cookie.', + }, + 403: { + content: { 'application/json': { schema: RequestErrorResponseSchema } }, + description: 'OIDC is configured and the caller is authenticated but not an admin.', + }, + }, +}); diff --git a/packages/trueforge/src/schemas/skill.ts b/packages/trueforge/src/schemas/skill.ts index 008c7f68f..5555fb979 100644 --- a/packages/trueforge/src/schemas/skill.ts +++ b/packages/trueforge/src/schemas/skill.ts @@ -98,6 +98,7 @@ export const GetSkillResponseSchema = z.object({ data: ConfiguredSkillSchema }). export const ListSkillsResponseSchema = z .object({ data: z.array(ConfiguredSkillSchema) }) .openapi('ListSkillsResponse'); +export const DeleteSkillResponseSchema = z.object({}).openapi('DeleteSkillResponse'); /** Chat/composer read view — discovery fields only. */ export const AvailableSkillSchema = z diff --git a/packages/trueforge/tests/db/skillStoreContractSuite.ts b/packages/trueforge/tests/db/skillStoreContractSuite.ts index 499121547..e4afb37e5 100644 --- a/packages/trueforge/tests/db/skillStoreContractSuite.ts +++ b/packages/trueforge/tests/db/skillStoreContractSuite.ts @@ -134,4 +134,25 @@ export function runSkillStoreContractSuite(getStore: () => ISkillStore): void { await expect(store.listSkills({ tenant_id: TENANT, names: [] })).resolves.toEqual([]); }); + + it('deleteSkill removes the row', async () => { + const store = getStore(); + await store.upsertSkill({ tenant_id: TENANT, name: 'algorithmic-art', manifest: manifest() }); + + await store.deleteSkill({ tenant_id: TENANT, name: 'algorithmic-art' }); + + await expect(store.getSkill({ tenant_id: TENANT, name: 'algorithmic-art' })).resolves.toBeUndefined(); + }); + + it('deleteSkill is idempotent for an unknown skill and leaves other tenants untouched', async () => { + const store = getStore(); + const otherTenant = await store.upsertSkill({ + tenant_id: 'other-tenant', + name: 'algorithmic-art', + manifest: manifest(), + }); + + await expect(store.deleteSkill({ tenant_id: TENANT, name: 'algorithmic-art' })).resolves.toBeUndefined(); + await expect(store.getSkill({ tenant_id: 'other-tenant', name: 'algorithmic-art' })).resolves.toEqual(otherTenant); + }); } diff --git a/packages/trueforge/tests/unit/apis/skills.test.ts b/packages/trueforge/tests/unit/apis/skills.test.ts index 2aaecdf41..8ecd48979 100644 --- a/packages/trueforge/tests/unit/apis/skills.test.ts +++ b/packages/trueforge/tests/unit/apis/skills.test.ts @@ -129,4 +129,20 @@ describe('skills routers', () => { ); expect(badUrl.status).toBe(400); }); + + it('DELETE /{name} removes the skill', async () => { + const response = await settingsRouter.request('/create-only-skill', { method: 'DELETE' }); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({}); + + const list = await settingsRouter.request('/'); + const names = ((await list.json()) as { data: { name: string }[] }).data.map(skill => skill.name); + expect(names).not.toContain('create-only-skill'); + }); + + it('DELETE /{name} is idempotent for an unknown skill', async () => { + const response = await settingsRouter.request('/never-existed', { method: 'DELETE' }); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({}); + }); });