Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/delete-skill.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@truefoundry/trueforge": minor
---

Add `DELETE /api/v1/settings/skills/{name}` to permanently remove a configured skill. Idempotent if already gone.
8 changes: 8 additions & 0 deletions packages/trueforge/src/apis/skills.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { SkillNameConflictError, type ISkillStore, type SkillRecord } from '../d
import type { WithTransaction } from '../db/transaction';
import {
createSkillRoute,
deleteSkillRoute,
listAvailableSkillsRoute,
listConfiguredSkillsRoute,
putSkillRoute,
Expand Down Expand Up @@ -59,10 +60,17 @@ export function createSkillsRouter<TTransaction>(deps: SkillsRouterDeps<TTransac
return c.json({ data: toConfiguredSkill(record) }, 200);
};

const deleteHandler: RouteHandler<typeof deleteSkillRoute> = 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;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -97,4 +97,9 @@ export class PostgresSkillStore implements ISkillStore<Transaction<Database>> {
.executeTakeFirstOrThrow();
return toRecord(row);
}

async deleteSkill(input: GetSkillInput, transaction?: Transaction<Database>): Promise<void> {
const db = transaction ?? this.#db;
await db.deleteFrom('skill').where('tenant_id', '=', input.tenant_id).where('name', '=', input.name).execute();
}
}
2 changes: 2 additions & 0 deletions packages/trueforge/src/db/skillStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,4 +56,6 @@ export interface ISkillStore<TTransaction = never> {
createSkill(input: CreateSkillInput, transaction?: TTransaction): Promise<SkillRecord>;
/** Single-row write: creates the skill or replaces the whole manifest. */
upsertSkill(input: UpsertSkillInput, transaction?: TTransaction): Promise<SkillRecord>;
/** Permanently removes the skill row. Idempotent if already gone. */
deleteSkill(input: GetSkillInput, transaction?: TTransaction): Promise<void>;
}
Original file line number Diff line number Diff line change
Expand Up @@ -97,4 +97,9 @@ export class SqliteSkillStore implements ISkillStore<Transaction<Database>> {
.returning(recordColumns)
.executeTakeFirstOrThrow();
}

async deleteSkill(input: GetSkillInput, transaction?: Transaction<Database>): Promise<void> {
const db = transaction ?? this.#db;
await db.deleteFrom('skill').where('tenant_id', '=', input.tenant_id).where('name', '=', input.name).execute();
}
}
34 changes: 33 additions & 1 deletion packages/trueforge/src/routes/skillRoutes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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.',
},
},
});
1 change: 1 addition & 0 deletions packages/trueforge/src/schemas/skill.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 21 additions & 0 deletions packages/trueforge/tests/db/skillStoreContractSuite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
}
16 changes: 16 additions & 0 deletions packages/trueforge/tests/unit/apis/skills.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({});
});
});