diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7e8cab7..7d8e8ee 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -77,6 +77,9 @@ jobs: - name: Frontend typecheck run: npm run typecheck + - name: OpenAPI codegen drift check + run: npm run codegen:check + - name: Frontend tests run: npm test diff --git a/frontend/src/api/generated.ts b/frontend/src/api/generated.ts new file mode 100644 index 0000000..2f6afea --- /dev/null +++ b/frontend/src/api/generated.ts @@ -0,0 +1,937 @@ +/** + * This file was auto-generated by openapi-typescript. + * Do not make direct changes to the file. + */ + +export interface paths { + "/api/health": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Health */ + get: operations["health_api_health_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/marketplace/install": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Install Marketplace Skill */ + post: operations["install_marketplace_skill_api_marketplace_install_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/marketplace/items/{item_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get Marketplace Detail */ + get: operations["get_marketplace_detail_api_marketplace_items__item_id__get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/marketplace/items/{item_id}/document": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get Marketplace Document */ + get: operations["get_marketplace_document_api_marketplace_items__item_id__document_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/marketplace/popular": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Popular Marketplace */ + get: operations["popular_marketplace_api_marketplace_popular_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/marketplace/search": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Search Marketplace */ + get: operations["search_marketplace_api_marketplace_search_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/settings": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Settings */ + get: operations["settings_api_settings_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/settings/harnesses/{harness}/support": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** Set Harness Support */ + put: operations["set_harness_support_api_settings_harnesses__harness__support_put"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/skills": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List Skills */ + get: operations["list_skills_api_skills_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/skills/manage-all": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Manage All Skills */ + post: operations["manage_all_skills_api_skills_manage_all_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/skills/{skill_ref}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get Skill Detail */ + get: operations["get_skill_detail_api_skills__skill_ref__get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/skills/{skill_ref}/delete": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Delete Skill */ + post: operations["delete_skill_api_skills__skill_ref__delete_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/skills/{skill_ref}/disable": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Disable Skill */ + post: operations["disable_skill_api_skills__skill_ref__disable_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/skills/{skill_ref}/enable": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Enable Skill */ + post: operations["enable_skill_api_skills__skill_ref__enable_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/skills/{skill_ref}/manage": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Manage Skill */ + post: operations["manage_skill_api_skills__skill_ref__manage_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/skills/{skill_ref}/source-status": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get Skill Source Status */ + get: operations["get_skill_source_status_api_skills__skill_ref__source_status_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/skills/{skill_ref}/unmanage": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Unmanage Skill */ + post: operations["unmanage_skill_api_skills__skill_ref__unmanage_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/skills/{skill_ref}/update": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Update Skill */ + post: operations["update_skill_api_skills__skill_ref__update_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; +} +export type webhooks = Record; +export interface components { + schemas: { + /** DisableSkillRequest */ + DisableSkillRequest: { + /** + * Harness + * @description Harness identifier + */ + harness: string; + }; + /** EnableSkillRequest */ + EnableSkillRequest: { + /** + * Harness + * @description Harness identifier + */ + harness: string; + }; + /** HTTPValidationError */ + HTTPValidationError: { + /** Detail */ + detail?: components["schemas"]["ValidationError"][]; + }; + /** InstallMarketplaceSkillRequest */ + InstallMarketplaceSkillRequest: { + /** Installtoken */ + installToken: string; + }; + /** SetHarnessSupportRequest */ + SetHarnessSupportRequest: { + /** Enabled */ + enabled: boolean; + }; + /** ValidationError */ + ValidationError: { + /** Context */ + ctx?: Record; + /** Input */ + input?: unknown; + /** Location */ + loc: (string | number)[]; + /** Message */ + msg: string; + /** Error Type */ + type: string; + }; + }; + responses: never; + parameters: never; + requestBodies: never; + headers: never; + pathItems: never; +} +export type $defs = Record; +export interface operations { + health_api_health_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + [key: string]: unknown; + }; + }; + }; + }; + }; + install_marketplace_skill_api_marketplace_install_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["InstallMarketplaceSkillRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + [key: string]: boolean; + }; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_marketplace_detail_api_marketplace_items__item_id__get: { + parameters: { + query?: never; + header?: never; + path: { + item_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + [key: string]: unknown; + }; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_marketplace_document_api_marketplace_items__item_id__document_get: { + parameters: { + query?: never; + header?: never; + path: { + item_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + [key: string]: unknown; + }; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + popular_marketplace_api_marketplace_popular_get: { + parameters: { + query?: { + limit?: number | null; + offset?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + [key: string]: unknown; + }; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + search_marketplace_api_marketplace_search_get: { + parameters: { + query: { + q: string; + limit?: number | null; + offset?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + [key: string]: unknown; + }; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + settings_api_settings_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + [key: string]: unknown; + }; + }; + }; + }; + }; + set_harness_support_api_settings_harnesses__harness__support_put: { + parameters: { + query?: never; + header?: never; + path: { + harness: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["SetHarnessSupportRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + [key: string]: unknown; + }; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + list_skills_api_skills_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + [key: string]: unknown; + }; + }; + }; + }; + }; + manage_all_skills_api_skills_manage_all_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + [key: string]: unknown; + }; + }; + }; + }; + }; + get_skill_detail_api_skills__skill_ref__get: { + parameters: { + query?: never; + header?: never; + path: { + skill_ref: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + [key: string]: unknown; + }; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + delete_skill_api_skills__skill_ref__delete_post: { + parameters: { + query?: never; + header?: never; + path: { + skill_ref: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + [key: string]: boolean; + }; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + disable_skill_api_skills__skill_ref__disable_post: { + parameters: { + query?: never; + header?: never; + path: { + skill_ref: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["DisableSkillRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + [key: string]: boolean; + }; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + enable_skill_api_skills__skill_ref__enable_post: { + parameters: { + query?: never; + header?: never; + path: { + skill_ref: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["EnableSkillRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + [key: string]: boolean; + }; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + manage_skill_api_skills__skill_ref__manage_post: { + parameters: { + query?: never; + header?: never; + path: { + skill_ref: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + [key: string]: boolean; + }; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_skill_source_status_api_skills__skill_ref__source_status_get: { + parameters: { + query?: never; + header?: never; + path: { + skill_ref: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + [key: string]: unknown; + }; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + unmanage_skill_api_skills__skill_ref__unmanage_post: { + parameters: { + query?: never; + header?: never; + path: { + skill_ref: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + [key: string]: boolean; + }; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + update_skill_api_skills__skill_ref__update_post: { + parameters: { + query?: never; + header?: never; + path: { + skill_ref: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + [key: string]: boolean; + }; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; +} diff --git a/frontend/src/api/http.ts b/frontend/src/api/http.ts index 6560d4c..771d93b 100644 --- a/frontend/src/api/http.ts +++ b/frontend/src/api/http.ts @@ -4,19 +4,31 @@ async function expectJson(responsePromise: Promise): Promise { const response = await responsePromise; const payload = await response.json().catch(() => null); if (!response.ok) { - const message = ( - payload - && typeof payload === "object" - && "error" in payload - && typeof payload.error === "string" - ) - ? payload.error - : `${response.status} ${response.statusText}`; - throw new Error(message); + throw new Error(extractErrorMessage(payload, response)); } return payload as T; } +function extractErrorMessage(payload: unknown, response: Response): string { + if (payload && typeof payload === "object") { + const record = payload as Record; + if (typeof record.error === "string") { + return record.error; + } + if (typeof record.detail === "string") { + return record.detail; + } + if (Array.isArray(record.detail) && record.detail.length > 0) { + const first = record.detail[0] as { msg?: unknown; loc?: unknown }; + if (first && typeof first.msg === "string") { + const field = Array.isArray(first.loc) ? first.loc.join(".") : ""; + return field ? `${field}: ${first.msg}` : first.msg; + } + } + } + return `${response.status} ${response.statusText}`; +} + export async function fetchJson(path: string): Promise { return expectJson(fetch(apiPath(path))); } diff --git a/frontend/src/api/openapi.json b/frontend/src/api/openapi.json new file mode 100644 index 0000000..da92e97 --- /dev/null +++ b/frontend/src/api/openapi.json @@ -0,0 +1,863 @@ +{ + "components": { + "schemas": { + "DisableSkillRequest": { + "properties": { + "harness": { + "description": "Harness identifier", + "minLength": 1, + "title": "Harness", + "type": "string" + } + }, + "required": [ + "harness" + ], + "title": "DisableSkillRequest", + "type": "object" + }, + "EnableSkillRequest": { + "properties": { + "harness": { + "description": "Harness identifier", + "minLength": 1, + "title": "Harness", + "type": "string" + } + }, + "required": [ + "harness" + ], + "title": "EnableSkillRequest", + "type": "object" + }, + "HTTPValidationError": { + "properties": { + "detail": { + "items": { + "$ref": "#/components/schemas/ValidationError" + }, + "title": "Detail", + "type": "array" + } + }, + "title": "HTTPValidationError", + "type": "object" + }, + "InstallMarketplaceSkillRequest": { + "properties": { + "installToken": { + "minLength": 1, + "title": "Installtoken", + "type": "string" + } + }, + "required": [ + "installToken" + ], + "title": "InstallMarketplaceSkillRequest", + "type": "object" + }, + "SetHarnessSupportRequest": { + "properties": { + "enabled": { + "title": "Enabled", + "type": "boolean" + } + }, + "required": [ + "enabled" + ], + "title": "SetHarnessSupportRequest", + "type": "object" + }, + "ValidationError": { + "properties": { + "ctx": { + "title": "Context", + "type": "object" + }, + "input": { + "title": "Input" + }, + "loc": { + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "integer" + } + ] + }, + "title": "Location", + "type": "array" + }, + "msg": { + "title": "Message", + "type": "string" + }, + "type": { + "title": "Error Type", + "type": "string" + } + }, + "required": [ + "loc", + "msg", + "type" + ], + "title": "ValidationError", + "type": "object" + } + } + }, + "info": { + "title": "skill-manager", + "version": "0.1.0" + }, + "openapi": "3.1.0", + "paths": { + "/api/health": { + "get": { + "operationId": "health_api_health_get", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "title": "Response Health Api Health Get", + "type": "object" + } + } + }, + "description": "Successful Response" + } + }, + "summary": "Health" + } + }, + "/api/marketplace/install": { + "post": { + "operationId": "install_marketplace_skill_api_marketplace_install_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InstallMarketplaceSkillRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": { + "type": "boolean" + }, + "title": "Response Install Marketplace Skill Api Marketplace Install Post", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Install Marketplace Skill" + } + }, + "/api/marketplace/items/{item_id}": { + "get": { + "operationId": "get_marketplace_detail_api_marketplace_items__item_id__get", + "parameters": [ + { + "in": "path", + "name": "item_id", + "required": true, + "schema": { + "title": "Item Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "title": "Response Get Marketplace Detail Api Marketplace Items Item Id Get", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Get Marketplace Detail" + } + }, + "/api/marketplace/items/{item_id}/document": { + "get": { + "operationId": "get_marketplace_document_api_marketplace_items__item_id__document_get", + "parameters": [ + { + "in": "path", + "name": "item_id", + "required": true, + "schema": { + "title": "Item Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "title": "Response Get Marketplace Document Api Marketplace Items Item Id Document Get", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Get Marketplace Document" + } + }, + "/api/marketplace/popular": { + "get": { + "operationId": "popular_marketplace_api_marketplace_popular_get", + "parameters": [ + { + "in": "query", + "name": "limit", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Limit" + } + }, + { + "in": "query", + "name": "offset", + "required": false, + "schema": { + "default": 0, + "title": "Offset", + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "title": "Response Popular Marketplace Api Marketplace Popular Get", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Popular Marketplace" + } + }, + "/api/marketplace/search": { + "get": { + "operationId": "search_marketplace_api_marketplace_search_get", + "parameters": [ + { + "in": "query", + "name": "q", + "required": true, + "schema": { + "title": "Q", + "type": "string" + } + }, + { + "in": "query", + "name": "limit", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Limit" + } + }, + { + "in": "query", + "name": "offset", + "required": false, + "schema": { + "default": 0, + "title": "Offset", + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "title": "Response Search Marketplace Api Marketplace Search Get", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Search Marketplace" + } + }, + "/api/settings": { + "get": { + "operationId": "settings_api_settings_get", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "title": "Response Settings Api Settings Get", + "type": "object" + } + } + }, + "description": "Successful Response" + } + }, + "summary": "Settings" + } + }, + "/api/settings/harnesses/{harness}/support": { + "put": { + "operationId": "set_harness_support_api_settings_harnesses__harness__support_put", + "parameters": [ + { + "in": "path", + "name": "harness", + "required": true, + "schema": { + "title": "Harness", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SetHarnessSupportRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "title": "Response Set Harness Support Api Settings Harnesses Harness Support Put", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Set Harness Support" + } + }, + "/api/skills": { + "get": { + "operationId": "list_skills_api_skills_get", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "title": "Response List Skills Api Skills Get", + "type": "object" + } + } + }, + "description": "Successful Response" + } + }, + "summary": "List Skills" + } + }, + "/api/skills/manage-all": { + "post": { + "operationId": "manage_all_skills_api_skills_manage_all_post", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "title": "Response Manage All Skills Api Skills Manage All Post", + "type": "object" + } + } + }, + "description": "Successful Response" + } + }, + "summary": "Manage All Skills" + } + }, + "/api/skills/{skill_ref}": { + "get": { + "operationId": "get_skill_detail_api_skills__skill_ref__get", + "parameters": [ + { + "in": "path", + "name": "skill_ref", + "required": true, + "schema": { + "title": "Skill Ref", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "title": "Response Get Skill Detail Api Skills Skill Ref Get", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Get Skill Detail" + } + }, + "/api/skills/{skill_ref}/delete": { + "post": { + "operationId": "delete_skill_api_skills__skill_ref__delete_post", + "parameters": [ + { + "in": "path", + "name": "skill_ref", + "required": true, + "schema": { + "title": "Skill Ref", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": { + "type": "boolean" + }, + "title": "Response Delete Skill Api Skills Skill Ref Delete Post", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Delete Skill" + } + }, + "/api/skills/{skill_ref}/disable": { + "post": { + "operationId": "disable_skill_api_skills__skill_ref__disable_post", + "parameters": [ + { + "in": "path", + "name": "skill_ref", + "required": true, + "schema": { + "title": "Skill Ref", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DisableSkillRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": { + "type": "boolean" + }, + "title": "Response Disable Skill Api Skills Skill Ref Disable Post", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Disable Skill" + } + }, + "/api/skills/{skill_ref}/enable": { + "post": { + "operationId": "enable_skill_api_skills__skill_ref__enable_post", + "parameters": [ + { + "in": "path", + "name": "skill_ref", + "required": true, + "schema": { + "title": "Skill Ref", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EnableSkillRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": { + "type": "boolean" + }, + "title": "Response Enable Skill Api Skills Skill Ref Enable Post", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Enable Skill" + } + }, + "/api/skills/{skill_ref}/manage": { + "post": { + "operationId": "manage_skill_api_skills__skill_ref__manage_post", + "parameters": [ + { + "in": "path", + "name": "skill_ref", + "required": true, + "schema": { + "title": "Skill Ref", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": { + "type": "boolean" + }, + "title": "Response Manage Skill Api Skills Skill Ref Manage Post", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Manage Skill" + } + }, + "/api/skills/{skill_ref}/source-status": { + "get": { + "operationId": "get_skill_source_status_api_skills__skill_ref__source_status_get", + "parameters": [ + { + "in": "path", + "name": "skill_ref", + "required": true, + "schema": { + "title": "Skill Ref", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "title": "Response Get Skill Source Status Api Skills Skill Ref Source Status Get", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Get Skill Source Status" + } + }, + "/api/skills/{skill_ref}/unmanage": { + "post": { + "operationId": "unmanage_skill_api_skills__skill_ref__unmanage_post", + "parameters": [ + { + "in": "path", + "name": "skill_ref", + "required": true, + "schema": { + "title": "Skill Ref", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": { + "type": "boolean" + }, + "title": "Response Unmanage Skill Api Skills Skill Ref Unmanage Post", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Unmanage Skill" + } + }, + "/api/skills/{skill_ref}/update": { + "post": { + "operationId": "update_skill_api_skills__skill_ref__update_post", + "parameters": [ + { + "in": "path", + "name": "skill_ref", + "required": true, + "schema": { + "title": "Skill Ref", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": { + "type": "boolean" + }, + "title": "Response Update Skill Api Skills Skill Ref Update Post", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Update Skill" + } + } + } +} diff --git a/frontend/src/features/marketplace/api/client.ts b/frontend/src/features/marketplace/api/client.ts index 2750132..637c13b 100644 --- a/frontend/src/features/marketplace/api/client.ts +++ b/frontend/src/features/marketplace/api/client.ts @@ -1,6 +1,11 @@ import { fetchJson, postJson } from "../../../api/http"; -import type { MarketplaceDetailDto, MarketplaceDocumentDto, MarketplacePageResultDto } from "./types"; +import type { + InstallMarketplaceSkillRequest, + MarketplaceDetailDto, + MarketplaceDocumentDto, + MarketplacePageResultDto, +} from "./types"; interface OkResponse { ok: boolean; @@ -28,7 +33,8 @@ export async function fetchMarketplaceDocument(itemId: string): Promise { - return postJson("/marketplace/install", { installToken }); + const body: InstallMarketplaceSkillRequest = { installToken }; + return postJson("/marketplace/install", body); } function withQuery(path: string, params: Record): string { diff --git a/frontend/src/features/marketplace/api/types.ts b/frontend/src/features/marketplace/api/types.ts index a7f5f60..0a55318 100644 --- a/frontend/src/features/marketplace/api/types.ts +++ b/frontend/src/features/marketplace/api/types.ts @@ -1,3 +1,7 @@ +import type { components } from "../../../api/generated"; + +export type InstallMarketplaceSkillRequest = components["schemas"]["InstallMarketplaceSkillRequest"]; + export type MarketplaceInstallationStatus = "installable" | "installed"; export interface MarketplaceInstallationState { diff --git a/frontend/src/features/settings/api/client.ts b/frontend/src/features/settings/api/client.ts index bb9a3b5..6318212 100644 --- a/frontend/src/features/settings/api/client.ts +++ b/frontend/src/features/settings/api/client.ts @@ -1,13 +1,14 @@ import { fetchJson, putJson } from "../../../api/http"; -import type { SettingsData } from "./types"; +import type { SetHarnessSupportRequest, SettingsData } from "./types"; export async function fetchSettings(): Promise { return fetchJson("/settings"); } export async function updateHarnessSupport(harness: string, enabled: boolean): Promise<{ ok: boolean; enabled: boolean }> { + const body: SetHarnessSupportRequest = { enabled }; return putJson<{ ok: boolean; enabled: boolean }>( `/settings/harnesses/${encodeURIComponent(harness)}/support`, - { enabled }, + body, ); } diff --git a/frontend/src/features/settings/api/types.ts b/frontend/src/features/settings/api/types.ts index 43f8649..f24d1d2 100644 --- a/frontend/src/features/settings/api/types.ts +++ b/frontend/src/features/settings/api/types.ts @@ -1,3 +1,7 @@ +import type { components } from "../../../api/generated"; + +export type SetHarnessSupportRequest = components["schemas"]["SetHarnessSupportRequest"]; + export interface SettingsHarness { harness: string; label: string; diff --git a/frontend/src/features/skills/api/client.ts b/frontend/src/features/skills/api/client.ts index 1d0cfe0..6bc16a5 100644 --- a/frontend/src/features/skills/api/client.ts +++ b/frontend/src/features/skills/api/client.ts @@ -1,4 +1,11 @@ -import type { BulkManageResult, SkillDetailDto, SkillsPageDto, SkillSourceStatusDto } from "./types"; +import type { + BulkManageResult, + DisableSkillRequest, + EnableSkillRequest, + SkillDetailDto, + SkillsPageDto, + SkillSourceStatusDto, +} from "./types"; import { fetchJson, postJson } from "../../../api/http"; interface OkResponse { @@ -18,11 +25,13 @@ export async function fetchSkillSourceStatus(skillRef: string): Promise { - return postJson(`/skills/${encodeURIComponent(skillRef)}/enable`, { harness }); + const body: EnableSkillRequest = { harness }; + return postJson(`/skills/${encodeURIComponent(skillRef)}/enable`, body); } export async function disableSkill(skillRef: string, harness: string): Promise { - return postJson(`/skills/${encodeURIComponent(skillRef)}/disable`, { harness }); + const body: DisableSkillRequest = { harness }; + return postJson(`/skills/${encodeURIComponent(skillRef)}/disable`, body); } export async function manageSkill(skillRef: string): Promise { diff --git a/frontend/src/features/skills/api/types.ts b/frontend/src/features/skills/api/types.ts index fc4aa8e..b101a39 100644 --- a/frontend/src/features/skills/api/types.ts +++ b/frontend/src/features/skills/api/types.ts @@ -1,3 +1,8 @@ +import type { components } from "../../../api/generated"; + +export type EnableSkillRequest = components["schemas"]["EnableSkillRequest"]; +export type DisableSkillRequest = components["schemas"]["DisableSkillRequest"]; + export type SkillStatus = "Managed" | "Unmanaged" | "Custom" | "Built-in"; export type HarnessCellState = "enabled" | "disabled" | "found" | "builtin" | "empty"; export type SkillUpdateStatus = "update_available" | "no_update_available" | "no_source_available"; diff --git a/package-lock.json b/package-lock.json index 7d53784..f7ab8a1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,6 +27,7 @@ "@types/react-dom": "^19.1.7", "@vitejs/plugin-react": "^5.0.0", "jsdom": "^26.1.0", + "openapi-typescript": "^7.13.0", "typescript": "^5.9.2", "vite": "^7.1.3", "vitest": "^3.2.4" @@ -1522,6 +1523,52 @@ "integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==", "license": "MIT" }, + "node_modules/@redocly/ajv": { + "version": "8.11.2", + "resolved": "https://registry.npmjs.org/@redocly/ajv/-/ajv-8.11.2.tgz", + "integrity": "sha512-io1JpnwtIcvojV7QKDUSIuMN/ikdOUd1ReEnUnMKGfDVridQZ31J0MmIuqwuRjWDZfmvr+Q0MqCcfHM2gTivOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2", + "uri-js-replace": "^1.0.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@redocly/config": { + "version": "0.22.0", + "resolved": "https://registry.npmjs.org/@redocly/config/-/config-0.22.0.tgz", + "integrity": "sha512-gAy93Ddo01Z3bHuVdPWfCwzgfaYgMdaZPcfL7JZ7hWJoK9V0lXDbigTWkhiPFAaLWzbOJ+kbUQG1+XwIm0KRGQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@redocly/openapi-core": { + "version": "1.34.11", + "resolved": "https://registry.npmjs.org/@redocly/openapi-core/-/openapi-core-1.34.11.tgz", + "integrity": "sha512-V09ayfnb5GyysmvARbt+voFZAjGcf7hSYxOYxSkCc4fbH/DTfq5YWoec8cflvmHHqyIFbqvmGKmYFzqhr9zxDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@redocly/ajv": "8.11.2", + "@redocly/config": "0.22.0", + "colorette": "1.4.0", + "https-proxy-agent": "7.0.6", + "js-levenshtein": "1.1.6", + "js-yaml": "4.1.1", + "minimatch": "5.1.9", + "pluralize": "8.0.0", + "yaml-ast-parser": "0.0.43" + }, + "engines": { + "node": ">=18.17.0", + "npm": ">=9.5.0" + } + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-rc.3", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", @@ -2316,6 +2363,16 @@ "node": ">= 14" } }, + "node_modules/ansi-colors": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", + "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -2341,6 +2398,13 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, "node_modules/aria-hidden": { "version": "1.2.6", "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", @@ -2383,6 +2447,13 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, "node_modules/baseline-browser-mapping": { "version": "2.10.10", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.10.tgz", @@ -2396,6 +2467,16 @@ "node": ">=6.0.0" } }, + "node_modules/brace-expansion": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", + "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, "node_modules/browserslist": { "version": "4.28.1", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", @@ -2488,6 +2569,13 @@ "node": ">=18" } }, + "node_modules/change-case": { + "version": "5.4.4", + "resolved": "https://registry.npmjs.org/change-case/-/change-case-5.4.4.tgz", + "integrity": "sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==", + "dev": true, + "license": "MIT" + }, "node_modules/character-entities": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", @@ -2538,6 +2626,13 @@ "node": ">= 16" } }, + "node_modules/colorette": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-1.4.0.tgz", + "integrity": "sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==", + "dev": true, + "license": "MIT" + }, "node_modules/comma-separated-tokens": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", @@ -2819,6 +2914,13 @@ "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", "license": "MIT" }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -2985,6 +3087,19 @@ "node": ">=8" } }, + "node_modules/index-to-position": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/index-to-position/-/index-to-position-1.2.0.tgz", + "integrity": "sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/inline-style-parser": { "version": "0.2.7", "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", @@ -3054,6 +3169,16 @@ "dev": true, "license": "MIT" }, + "node_modules/js-levenshtein": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/js-levenshtein/-/js-levenshtein-1.1.6.tgz", + "integrity": "sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -3061,6 +3186,19 @@ "dev": true, "license": "MIT" }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, "node_modules/jsdom": { "version": "26.1.0", "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz", @@ -3114,6 +3252,13 @@ "node": ">=6" } }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, "node_modules/json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", @@ -4037,6 +4182,19 @@ "node": ">=4" } }, + "node_modules/minimatch": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -4076,6 +4234,27 @@ "dev": true, "license": "MIT" }, + "node_modules/openapi-typescript": { + "version": "7.13.0", + "resolved": "https://registry.npmjs.org/openapi-typescript/-/openapi-typescript-7.13.0.tgz", + "integrity": "sha512-EFP392gcqXS7ntPvbhBzbF8TyBA+baIYEm791Hy5YkjDYKTnk/Tn5OQeKm5BIZvJihpp8Zzr4hzx0Irde1LNGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@redocly/openapi-core": "^1.34.6", + "ansi-colors": "^4.1.3", + "change-case": "^5.4.4", + "parse-json": "^8.3.0", + "supports-color": "^10.2.2", + "yargs-parser": "^21.1.1" + }, + "bin": { + "openapi-typescript": "bin/cli.js" + }, + "peerDependencies": { + "typescript": "^5.x" + } + }, "node_modules/parse-entities": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", @@ -4101,6 +4280,24 @@ "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", "license": "MIT" }, + "node_modules/parse-json": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-8.3.0.tgz", + "integrity": "sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.26.2", + "index-to-position": "^1.1.0", + "type-fest": "^4.39.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/parse5": { "version": "7.3.0", "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", @@ -4183,6 +4380,16 @@ "node": ">=18" } }, + "node_modules/pluralize": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz", + "integrity": "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/postcss": { "version": "8.5.8", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", @@ -4501,6 +4708,16 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/rollup": { "version": "4.60.0", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.0.tgz", @@ -4701,6 +4918,19 @@ "inline-style-parser": "0.2.7" } }, + "node_modules/supports-color": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", + "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, "node_modules/symbol-tree": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", @@ -4841,6 +5071,19 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -4973,6 +5216,13 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/uri-js-replace": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/uri-js-replace/-/uri-js-replace-1.0.1.tgz", + "integrity": "sha512-W+C9NWNLFOoBI2QWDp4UT9pv65r2w5Cx+3sTYFvtMdDBxkKt1syCqsUdSFAChbEe1uK5TfS04wt/nGwmaeIQ0g==", + "dev": true, + "license": "MIT" + }, "node_modules/use-callback-ref": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", @@ -5354,6 +5604,23 @@ "dev": true, "license": "ISC" }, + "node_modules/yaml-ast-parser": { + "version": "0.0.43", + "resolved": "https://registry.npmjs.org/yaml-ast-parser/-/yaml-ast-parser-0.0.43.tgz", + "integrity": "sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/zwitch": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", diff --git a/package.json b/package.json index 27df7a4..84401a8 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,9 @@ "build": "VITE_API_BASE=/api vite build", "typecheck": "tsc --noEmit", "test": "vitest run", - "test:e2e": "playwright test" + "test:e2e": "playwright test", + "codegen:openapi": "./.venv/bin/python scripts/dump_openapi.py && openapi-typescript frontend/src/api/openapi.json -o frontend/src/api/generated.ts", + "codegen:check": "npm run codegen:openapi && git diff --exit-code frontend/src/api/openapi.json frontend/src/api/generated.ts" }, "dependencies": { "@radix-ui/react-dialog": "^1.1.15", @@ -36,6 +38,7 @@ "@types/react-dom": "^19.1.7", "@vitejs/plugin-react": "^5.0.0", "jsdom": "^26.1.0", + "openapi-typescript": "^7.13.0", "typescript": "^5.9.2", "vite": "^7.1.3", "vitest": "^3.2.4" diff --git a/scripts/dump_openapi.py b/scripts/dump_openapi.py new file mode 100644 index 0000000..a6f60fd --- /dev/null +++ b/scripts/dump_openapi.py @@ -0,0 +1,30 @@ +#!/usr/bin/env python3 +"""Dump the FastAPI OpenAPI schema to frontend/src/api/openapi.json.""" +from __future__ import annotations + +import json +import sys +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(REPO_ROOT)) + +from skill_manager.api.app import create_app # noqa: E402 +from skill_manager.application import build_backend_container # noqa: E402 +from skill_manager.application.marketplace import MarketplaceCatalog # noqa: E402 + + +def main() -> int: + catalog = MarketplaceCatalog(warm_on_init=False) + container = build_backend_container({}, marketplace_catalog=catalog) + app = create_app(container) + schema = app.openapi() + output_path = Path(__file__).resolve().parent.parent / "frontend" / "src" / "api" / "openapi.json" + output_path.parent.mkdir(parents=True, exist_ok=True) + output_path.write_text(json.dumps(schema, indent=2, sort_keys=True) + "\n", encoding="utf-8") + print(f"wrote {output_path.relative_to(Path.cwd()) if output_path.is_relative_to(Path.cwd()) else output_path}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/skill_manager/api/errors.py b/skill_manager/api/errors.py index f925a85..0ca051b 100644 --- a/skill_manager/api/errors.py +++ b/skill_manager/api/errors.py @@ -23,6 +23,12 @@ async def handle_marketplace_upstream_error(_request: Request, exc: MarketplaceU @app.exception_handler(RequestValidationError) async def handle_validation_error(_request: Request, exc: RequestValidationError) -> JSONResponse: - first_error = exc.errors()[0] if exc.errors() else None - message = first_error.get("msg") if isinstance(first_error, dict) else "Invalid request." + errors = exc.errors() + if not errors: + return JSONResponse(status_code=422, content={"error": "Invalid request."}) + first = errors[0] + msg = first.get("msg", "Invalid request.") if isinstance(first, dict) else "Invalid request." + loc = first.get("loc", ()) if isinstance(first, dict) else () + field_path = ".".join(str(part) for part in loc if part != "body") + message = f"{field_path}: {msg}" if field_path else msg return JSONResponse(status_code=422, content={"error": message}) diff --git a/skill_manager/api/routers/marketplace.py b/skill_manager/api/routers/marketplace.py index 9df75e6..7fd66c9 100644 --- a/skill_manager/api/routers/marketplace.py +++ b/skill_manager/api/routers/marketplace.py @@ -1,9 +1,10 @@ from __future__ import annotations -from fastapi import APIRouter, Body, Depends, HTTPException, Query +from fastapi import APIRouter, Depends, HTTPException, Query from skill_manager.application import BackendContainer from skill_manager.api.deps import get_container +from skill_manager.api.schemas import InstallMarketplaceSkillRequest router = APIRouter(prefix="/api/marketplace") @@ -48,10 +49,7 @@ def get_marketplace_detail(item_id: str, container: BackendContainer = Depends(g @router.post("/install") def install_marketplace_skill( - body: dict[str, str] | None = Body(default=None), + body: InstallMarketplaceSkillRequest, container: BackendContainer = Depends(get_container), ) -> dict[str, bool]: - install_token = body.get("installToken", "") if isinstance(body, dict) else "" - if not install_token: - raise HTTPException(status_code=400, detail="missing installToken") - return container.marketplace_installs.install_skill(install_token) + return container.marketplace_installs.install_skill(body.install_token) diff --git a/skill_manager/api/routers/settings.py b/skill_manager/api/routers/settings.py index 0347b42..102000a 100644 --- a/skill_manager/api/routers/settings.py +++ b/skill_manager/api/routers/settings.py @@ -1,9 +1,10 @@ from __future__ import annotations -from fastapi import APIRouter, Body, Depends, HTTPException +from fastapi import APIRouter, Depends from skill_manager.application import BackendContainer from skill_manager.api.deps import get_container +from skill_manager.api.schemas import SetHarnessSupportRequest router = APIRouter(prefix="/api/settings") @@ -16,10 +17,7 @@ def settings(container: BackendContainer = Depends(get_container)) -> dict[str, @router.put("/harnesses/{harness}/support") def set_harness_support( harness: str, - body: dict[str, object] | None = Body(default=None), + body: SetHarnessSupportRequest, container: BackendContainer = Depends(get_container), ) -> dict[str, object]: - enabled = body.get("enabled") if isinstance(body, dict) else None - if not isinstance(enabled, bool): - raise HTTPException(status_code=400, detail="missing boolean 'enabled' in request body") - return container.settings_mutations.set_harness_support(harness, enabled) + return container.settings_mutations.set_harness_support(harness, body.enabled) diff --git a/skill_manager/api/routers/skills.py b/skill_manager/api/routers/skills.py index a3ac205..554792b 100644 --- a/skill_manager/api/routers/skills.py +++ b/skill_manager/api/routers/skills.py @@ -1,9 +1,10 @@ from __future__ import annotations -from fastapi import APIRouter, Body, Depends, HTTPException +from fastapi import APIRouter, Depends, HTTPException from skill_manager.application import BackendContainer from skill_manager.api.deps import get_container +from skill_manager.api.schemas import DisableSkillRequest, EnableSkillRequest router = APIRouter(prefix="/api/skills") @@ -32,25 +33,19 @@ def get_skill_detail(skill_ref: str, container: BackendContainer = Depends(get_c @router.post("/{skill_ref:path}/enable") def enable_skill( skill_ref: str, - body: dict[str, str] | None = Body(default=None), + body: EnableSkillRequest, container: BackendContainer = Depends(get_container), ) -> dict[str, bool]: - harness = body.get("harness", "") if isinstance(body, dict) else "" - if not harness: - raise HTTPException(status_code=400, detail="missing 'harness' in request body") - return container.skills_mutations.enable_skill(skill_ref, harness) + return container.skills_mutations.enable_skill(skill_ref, body.harness) @router.post("/{skill_ref:path}/disable") def disable_skill( skill_ref: str, - body: dict[str, str] | None = Body(default=None), + body: DisableSkillRequest, container: BackendContainer = Depends(get_container), ) -> dict[str, bool]: - harness = body.get("harness", "") if isinstance(body, dict) else "" - if not harness: - raise HTTPException(status_code=400, detail="missing 'harness' in request body") - return container.skills_mutations.disable_skill(skill_ref, harness) + return container.skills_mutations.disable_skill(skill_ref, body.harness) @router.post("/{skill_ref:path}/manage") diff --git a/skill_manager/api/schemas.py b/skill_manager/api/schemas.py new file mode 100644 index 0000000..3c3d063 --- /dev/null +++ b/skill_manager/api/schemas.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +from pydantic import BaseModel, ConfigDict, Field + + +class HarnessTarget(BaseModel): + harness: str = Field(..., min_length=1, description="Harness identifier") + + +class EnableSkillRequest(HarnessTarget): + pass + + +class DisableSkillRequest(HarnessTarget): + pass + + +class InstallMarketplaceSkillRequest(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + install_token: str = Field(..., alias="installToken", min_length=1) + + +class SetHarnessSupportRequest(BaseModel): + enabled: bool diff --git a/skill_manager/app_paths.py b/skill_manager/app_paths.py deleted file mode 100644 index 8003cf7..0000000 --- a/skill_manager/app_paths.py +++ /dev/null @@ -1,48 +0,0 @@ -from __future__ import annotations - -import os -from pathlib import Path -import sys - - -APP_NAME = "skill-manager" - - -def app_config_dir(env: dict[str, str] | None = None) -> Path: - active_env = _active_env(env) - home = Path(active_env.get("HOME", str(Path.home()))) - xdg_config_home = active_env.get("XDG_CONFIG_HOME") - if xdg_config_home: - return Path(xdg_config_home) / APP_NAME - if sys.platform == "darwin": - return home / "Library" / "Application Support" / APP_NAME - return home / ".config" / APP_NAME - - -def app_data_dir(env: dict[str, str] | None = None) -> Path: - active_env = _active_env(env) - home = Path(active_env.get("HOME", str(Path.home()))) - xdg_data_home = active_env.get("XDG_DATA_HOME") - if xdg_data_home: - return Path(xdg_data_home) / APP_NAME - if sys.platform == "darwin": - return home / "Library" / "Application Support" / APP_NAME - return home / ".local" / "share" / APP_NAME - - -def app_state_dir(env: dict[str, str] | None = None) -> Path: - active_env = _active_env(env) - home = Path(active_env.get("HOME", str(Path.home()))) - xdg_state_home = active_env.get("XDG_STATE_HOME") - if xdg_state_home: - return Path(xdg_state_home) / APP_NAME - if sys.platform == "darwin": - return home / "Library" / "Application Support" / APP_NAME - return home / ".local" / "state" / APP_NAME - - -def _active_env(env: dict[str, str] | None) -> dict[str, str]: - active_env = dict(os.environ) - if env is not None: - active_env.update(env) - return active_env diff --git a/skill_manager/application/container.py b/skill_manager/application/container.py index 0385f89..4b0500f 100644 --- a/skill_manager/application/container.py +++ b/skill_manager/application/container.py @@ -1,9 +1,11 @@ from __future__ import annotations -from dataclasses import dataclass import os +from dataclasses import dataclass + +from skill_manager.paths import AppPaths, resolve_app_paths +from skill_manager.store import HarnessSupportStore -from skill_manager.storage_paths import default_harness_support_path from .marketplace import ( MarketplaceCatalog, MarketplaceDocumentService, @@ -14,11 +16,11 @@ from .settings import SettingsMutationService, SettingsQueryService from .skills import SkillsMutationService, SkillsQueryService from .source_fetch_service import SourceFetchService -from skill_manager.store import HarnessSupportStore @dataclass(frozen=True) class BackendContainer: + paths: AppPaths read_models: ReadModelService support_store: HarnessSupportStore source_fetcher: SourceFetchService @@ -42,7 +44,8 @@ def build_backend_container( if env is not None: active_env.update(env) - support_store = HarnessSupportStore(default_harness_support_path(active_env)) + paths = resolve_app_paths(active_env) + support_store = HarnessSupportStore(paths.settings_path) read_models = ReadModelService.from_environment(active_env, support_store=support_store) active_source_fetcher = source_fetcher or SourceFetchService() catalog = marketplace_catalog or MarketplaceCatalog.from_environment(active_env) @@ -54,6 +57,7 @@ def build_backend_container( marketplace_queries = MarketplaceQueryService(read_models, catalog, marketplace_documents) marketplace_installs = MarketplaceInstallService(catalog, skills_mutations) return BackendContainer( + paths=paths, read_models=read_models, support_store=support_store, source_fetcher=active_source_fetcher, diff --git a/skill_manager/application/marketplace/cache.py b/skill_manager/application/marketplace/cache.py index 8e84a95..b20d9ad 100644 --- a/skill_manager/application/marketplace/cache.py +++ b/skill_manager/application/marketplace/cache.py @@ -6,7 +6,7 @@ import time from pathlib import Path -from skill_manager.storage_paths import canonical_marketplace_cache_root +from skill_manager.paths import resolve_app_paths @dataclass(frozen=True) @@ -32,7 +32,7 @@ def __init__(self, root: Path | None = None) -> None: @classmethod def from_environment(cls, env: dict[str, str] | None = None) -> "MarketplaceCache": - return cls(canonical_marketplace_cache_root(env)) + return cls(resolve_app_paths(env).marketplace_cache_root) def read(self, namespace: str, key: str, *, ttl_seconds: int) -> CachedPayload | None: stored = self.load(namespace, key) diff --git a/skill_manager/application/read_model_service.py b/skill_manager/application/read_model_service.py index e16ccf7..6fc2c1a 100644 --- a/skill_manager/application/read_model_service.py +++ b/skill_manager/application/read_model_service.py @@ -7,7 +7,7 @@ from skill_manager.domain import HarnessScan, StoreScan from skill_manager.errors import MutationError from skill_manager.harness import HarnessDriver, HarnessManager, HarnessStatus, collect_harness_statuses, create_default_drivers, scan_all_harnesses, supported_harness_ids -from skill_manager.storage_paths import default_harness_support_path, resolve_shared_store_root +from skill_manager.paths import resolve_app_paths from skill_manager.store import HarnessSupportStore, SharedStore @@ -47,8 +47,9 @@ def from_environment( support_store: HarnessSupportStore | None = None, ) -> "ReadModelService": active_env = env or {} - store = SharedStore(resolve_shared_store_root(active_env)) - active_support_store = support_store or HarnessSupportStore(default_harness_support_path(active_env)) + paths = resolve_app_paths(active_env) + store = SharedStore(paths.shared_store_root, manifest_path=paths.shared_store_manifest) + active_support_store = support_store or HarnessSupportStore(paths.settings_path) drivers = create_default_drivers(active_env) return cls(store=store, harness_drivers=drivers, support_store=active_support_store) diff --git a/skill_manager/cli/main.py b/skill_manager/cli/main.py index 06422ab..9a49f0a 100644 --- a/skill_manager/cli/main.py +++ b/skill_manager/cli/main.py @@ -8,7 +8,7 @@ from skill_manager import __version__ from skill_manager.runtime.browser import maybe_open_browser -from skill_manager.runtime.paths import STATE_DIR_ENV +from skill_manager.paths import STATE_DIR_ENV from skill_manager.runtime.process import is_owned_runtime_process, terminate_process from skill_manager.runtime.state import ( RuntimeState, diff --git a/skill_manager/launcher.py b/skill_manager/launcher.py deleted file mode 100644 index 3032a46..0000000 --- a/skill_manager/launcher.py +++ /dev/null @@ -1,3 +0,0 @@ -from __future__ import annotations - -from .cli import main diff --git a/skill_manager/paths.py b/skill_manager/paths.py new file mode 100644 index 0000000..23f7e72 --- /dev/null +++ b/skill_manager/paths.py @@ -0,0 +1,81 @@ +from __future__ import annotations + +import os +import sys +from dataclasses import dataclass +from pathlib import Path + + +APP_NAME = "skill-manager" + +SETTINGS_PATH_ENV = "SKILL_MANAGER_SETTINGS_PATH" +STATE_DIR_ENV = "SKILL_MANAGER_STATE_DIR" + + +@dataclass(frozen=True) +class AppPaths: + config_dir: Path + data_dir: Path + state_dir: Path + shared_store_root: Path + shared_store_manifest: Path + marketplace_cache_root: Path + settings_path: Path + runtime_state_path: Path + server_log_path: Path + + +def resolve_app_paths(env: dict[str, str] | None = None) -> AppPaths: + active_env = _active_env(env) + config_dir, data_dir, state_dir = _base_dirs(active_env) + settings_override = active_env.get(SETTINGS_PATH_ENV) + settings_path = Path(settings_override) if settings_override else config_dir / "settings.json" + return AppPaths( + config_dir=config_dir, + data_dir=data_dir, + state_dir=state_dir, + shared_store_root=data_dir / "shared", + shared_store_manifest=data_dir / "manifest.json", + marketplace_cache_root=data_dir / "marketplace", + settings_path=settings_path, + runtime_state_path=state_dir / "runtime.json", + server_log_path=state_dir / "server.log", + ) + + +def _base_dirs(env: dict[str, str]) -> tuple[Path, Path, Path]: + home = Path(env.get("HOME", str(Path.home()))) + state_override = env.get(STATE_DIR_ENV) + + if sys.platform == "darwin": + default_macos = home / "Library" / "Application Support" / APP_NAME + config_dir = _xdg_dir(env, "XDG_CONFIG_HOME", default_macos) + data_dir = _xdg_dir(env, "XDG_DATA_HOME", default_macos) + state_dir = ( + Path(state_override) + if state_override + else _xdg_dir(env, "XDG_STATE_HOME", default_macos) + ) + else: + config_dir = _xdg_dir(env, "XDG_CONFIG_HOME", home / ".config" / APP_NAME) + data_dir = _xdg_dir(env, "XDG_DATA_HOME", home / ".local" / "share" / APP_NAME) + state_dir = ( + Path(state_override) + if state_override + else _xdg_dir(env, "XDG_STATE_HOME", home / ".local" / "state" / APP_NAME) + ) + return config_dir, data_dir, state_dir + + +def _xdg_dir(env: dict[str, str], xdg_key: str, fallback: Path) -> Path: + override = env.get(xdg_key) + if override: + return Path(override) / APP_NAME + return fallback + + +def _active_env(env: dict[str, str] | None) -> dict[str, str]: + active_env = dict(os.environ) + if env is not None: + active_env.update(env) + return active_env diff --git a/skill_manager/runtime/paths.py b/skill_manager/runtime/paths.py deleted file mode 100644 index 0d6c751..0000000 --- a/skill_manager/runtime/paths.py +++ /dev/null @@ -1,15 +0,0 @@ -from __future__ import annotations - -from pathlib import Path - -from skill_manager.app_paths import app_state_dir - -STATE_DIR_ENV = "SKILL_MANAGER_STATE_DIR" - - -def state_dir(env: dict[str, str] | None = None) -> Path: - active_env = env or {} - override = active_env.get(STATE_DIR_ENV) - if override: - return Path(override) - return app_state_dir(active_env) diff --git a/skill_manager/runtime/state.py b/skill_manager/runtime/state.py index fd48379..c6ab76c 100644 --- a/skill_manager/runtime/state.py +++ b/skill_manager/runtime/state.py @@ -5,7 +5,7 @@ from pathlib import Path import time -from .paths import state_dir +from skill_manager.paths import resolve_app_paths @dataclass(frozen=True) @@ -20,11 +20,11 @@ class RuntimeState: def runtime_state_path(env: dict[str, str] | None = None) -> Path: - return state_dir(env) / "runtime.json" + return resolve_app_paths(env).runtime_state_path def runtime_log_path(env: dict[str, str] | None = None) -> Path: - return state_dir(env) / "server.log" + return resolve_app_paths(env).server_log_path def load_runtime_state(env: dict[str, str] | None = None) -> RuntimeState | None: diff --git a/skill_manager/storage_paths.py b/skill_manager/storage_paths.py deleted file mode 100644 index fd99603..0000000 --- a/skill_manager/storage_paths.py +++ /dev/null @@ -1,62 +0,0 @@ -from __future__ import annotations - -import os -from pathlib import Path - -from .app_paths import app_config_dir, app_data_dir - - -SETTINGS_PATH_ENV = "SKILL_MANAGER_SETTINGS_PATH" - - -def canonical_shared_store_root(env: dict[str, str] | None = None) -> Path: - active_env = _active_env(env) - return app_data_dir(active_env) / "shared" - - -def legacy_shared_store_root(env: dict[str, str] | None = None) -> Path: - active_env = _active_env(env) - home = Path(active_env.get("HOME", str(Path.home()))) - return home / ".local" / "share" / "skill-manager" / "shared" - - -def resolve_shared_store_root(env: dict[str, str] | None = None) -> Path: - active_env = _active_env(env) - canonical_root = canonical_shared_store_root(active_env) - legacy_root = legacy_shared_store_root(active_env) - if canonical_root == legacy_root: - return canonical_root - if _store_location_initialized(canonical_root): - return canonical_root - if _store_location_initialized(legacy_root): - return legacy_root - return canonical_root - - -def canonical_marketplace_cache_root(env: dict[str, str] | None = None) -> Path: - active_env = _active_env(env) - return app_data_dir(active_env) / "marketplace" - - -def default_harness_support_path(env: dict[str, str] | None = None) -> Path: - active_env = _active_env(env) - override = active_env.get(SETTINGS_PATH_ENV) - if override: - return Path(override) - return app_config_dir(active_env) / "settings.json" - - -def _store_location_initialized(root: Path) -> bool: - manifest_path = root.parent / "manifest.json" - if manifest_path.is_file(): - return True - if not root.is_dir(): - return False - return any(root.iterdir()) - - -def _active_env(env: dict[str, str] | None) -> dict[str, str]: - active_env = dict(os.environ) - if env is not None: - active_env.update(env) - return active_env diff --git a/skill_manager/store/__init__.py b/skill_manager/store/__init__.py index ce0733f..b19db2b 100644 --- a/skill_manager/store/__init__.py +++ b/skill_manager/store/__init__.py @@ -1,8 +1,4 @@ -from .harness_support import ( - HarnessSupportPreferences, - HarnessSupportStore, - SETTINGS_PATH_ENV, -) +from .harness_support import HarnessSupportPreferences, HarnessSupportStore from .manifest import ManifestEntry, StoreManifest, load_manifest, write_manifest from .shared_store import SharedStore @@ -10,7 +6,6 @@ "HarnessSupportPreferences", "HarnessSupportStore", "ManifestEntry", - "SETTINGS_PATH_ENV", "SharedStore", "StoreManifest", "load_manifest", diff --git a/skill_manager/store/_atomic.py b/skill_manager/store/_atomic.py new file mode 100644 index 0000000..287d05e --- /dev/null +++ b/skill_manager/store/_atomic.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +import fcntl +import os +import tempfile +from contextlib import contextmanager +from pathlib import Path +from typing import Iterator + + +def atomic_write_text(path: Path, content: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + fd, tmp_name = tempfile.mkstemp(prefix=f".{path.name}.", suffix=".tmp", dir=path.parent) + tmp_path = Path(tmp_name) + try: + with os.fdopen(fd, "w", encoding="utf-8") as f: + f.write(content) + f.flush() + os.fsync(f.fileno()) + os.replace(tmp_path, path) + except BaseException: + tmp_path.unlink(missing_ok=True) + raise + + +@contextmanager +def file_lock(lock_path: Path) -> Iterator[None]: + lock_path.parent.mkdir(parents=True, exist_ok=True) + with open(lock_path, "w") as lock_fd: + fcntl.flock(lock_fd.fileno(), fcntl.LOCK_EX) + try: + yield + finally: + fcntl.flock(lock_fd.fileno(), fcntl.LOCK_UN) diff --git a/skill_manager/store/harness_support.py b/skill_manager/store/harness_support.py index 439607d..c923919 100644 --- a/skill_manager/store/harness_support.py +++ b/skill_manager/store/harness_support.py @@ -4,7 +4,7 @@ import json from pathlib import Path -from skill_manager.storage_paths import SETTINGS_PATH_ENV, default_harness_support_path +from ._atomic import atomic_write_text, file_lock @dataclass(frozen=True) @@ -29,22 +29,22 @@ def load(self) -> HarnessSupportPreferences: return HarnessSupportPreferences(disabled_harnesses=values) def set_enabled(self, harness: str, enabled: bool) -> HarnessSupportPreferences: - current = set(self.load().disabled_harnesses) - if enabled: - current.discard(harness) - else: - current.add(harness) - next_preferences = HarnessSupportPreferences(disabled_harnesses=tuple(sorted(current))) - self._write(next_preferences) - return next_preferences + with file_lock(self.path.with_suffix(".lock")): + current = set(self.load().disabled_harnesses) + if enabled: + current.discard(harness) + else: + current.add(harness) + next_preferences = HarnessSupportPreferences(disabled_harnesses=tuple(sorted(current))) + self._write(next_preferences) + return next_preferences def enabled_harnesses(self, supported_harnesses: tuple[str, ...]) -> tuple[str, ...]: preferences = self.load() return tuple(harness for harness in supported_harnesses if preferences.is_enabled(harness)) def _write(self, preferences: HarnessSupportPreferences) -> None: - self.path.parent.mkdir(parents=True, exist_ok=True) - self.path.write_text( + atomic_write_text( + self.path, json.dumps({"disabledHarnesses": list(preferences.disabled_harnesses)}, indent=2, sort_keys=True) + "\n", - encoding="utf-8", ) diff --git a/skill_manager/store/manifest.py b/skill_manager/store/manifest.py index 2857def..475b49b 100644 --- a/skill_manager/store/manifest.py +++ b/skill_manager/store/manifest.py @@ -4,6 +4,8 @@ import json from pathlib import Path +from ._atomic import atomic_write_text + @dataclass(frozen=True) class ManifestEntry: @@ -58,8 +60,7 @@ def load_manifest(path: Path) -> StoreManifest: def write_manifest(path: Path, manifest: StoreManifest) -> None: - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text( + atomic_write_text( + path, json.dumps(manifest.to_dict(), ensure_ascii=False, indent=2) + "\n", - encoding="utf-8", ) diff --git a/skill_manager/store/shared_store.py b/skill_manager/store/shared_store.py index 6a20237..cce7268 100644 --- a/skill_manager/store/shared_store.py +++ b/skill_manager/store/shared_store.py @@ -13,13 +13,19 @@ parse_skill_package, ) +from ._atomic import file_lock from .manifest import ManifestEntry, StoreManifest, load_manifest, write_manifest + class SharedStore: def __init__(self, root: Path, manifest_path: Path | None = None) -> None: self.root = root self.manifest_path = manifest_path or root.parent / "manifest.json" + @property + def _lock_path(self) -> Path: + return self.manifest_path.with_suffix(".lock") + def scan(self) -> StoreScan: manifest = load_manifest(self.manifest_path) manifest_index = {entry.package_dir: entry for entry in manifest.entries} @@ -52,23 +58,24 @@ def ingest( ) -> Path: """Copy a skill package into the shared store and update the manifest.""" self.root.mkdir(parents=True, exist_ok=True) - dest = self.root / source_path.name - if dest.exists(): - raise ValueError(f"package directory already exists in store: {source_path.name}") - shutil.copytree(source_path, dest) - fingerprint, _ = fingerprint_package(dest) - manifest = load_manifest(self.manifest_path) - entry = ManifestEntry( - package_dir=source_path.name, - declared_name=declared_name, - source_kind=source_kind, - source_locator=source_locator, - revision=fingerprint, - source_ref=source_ref, - source_path=source_path_hint, - ) - write_manifest(self.manifest_path, StoreManifest(entries=manifest.entries + (entry,))) - return dest + with file_lock(self._lock_path): + dest = self.root / source_path.name + if dest.exists(): + raise ValueError(f"package directory already exists in store: {source_path.name}") + shutil.copytree(source_path, dest) + fingerprint, _ = fingerprint_package(dest) + manifest = load_manifest(self.manifest_path) + entry = ManifestEntry( + package_dir=source_path.name, + declared_name=declared_name, + source_kind=source_kind, + source_locator=source_locator, + revision=fingerprint, + source_ref=source_ref, + source_path=source_path_hint, + ) + write_manifest(self.manifest_path, StoreManifest(entries=manifest.entries + (entry,))) + return dest def update( self, @@ -79,40 +86,42 @@ def update( source_path_hint: str | None = None, ) -> tuple[Path, bool]: """Replace a shared package with a new version. Returns (path, changed).""" - dest = self.root / package_dir - if not dest.is_dir(): - raise ValueError(f"package not in store: {package_dir}") - new_fp, _ = fingerprint_package(source_path) - old_fp, _ = fingerprint_package(dest) - if new_fp == old_fp: - return dest, False - shutil.rmtree(dest) - shutil.copytree(source_path, dest) - manifest = load_manifest(self.manifest_path) - updated = tuple( - ManifestEntry( - e.package_dir, - e.declared_name, - e.source_kind, - e.source_locator, - new_fp, - e.source_ref if source_ref is None else source_ref, - e.source_path if source_path_hint is None else source_path_hint, + with file_lock(self._lock_path): + dest = self.root / package_dir + if not dest.is_dir(): + raise ValueError(f"package not in store: {package_dir}") + new_fp, _ = fingerprint_package(source_path) + old_fp, _ = fingerprint_package(dest) + if new_fp == old_fp: + return dest, False + shutil.rmtree(dest) + shutil.copytree(source_path, dest) + manifest = load_manifest(self.manifest_path) + updated = tuple( + ManifestEntry( + e.package_dir, + e.declared_name, + e.source_kind, + e.source_locator, + new_fp, + e.source_ref if source_ref is None else source_ref, + e.source_path if source_path_hint is None else source_path_hint, + ) + if e.package_dir == package_dir + else e + for e in manifest.entries ) - if e.package_dir == package_dir - else e - for e in manifest.entries - ) - write_manifest(self.manifest_path, StoreManifest(entries=updated)) - return dest, True + write_manifest(self.manifest_path, StoreManifest(entries=updated)) + return dest, True def delete(self, package_dir: str) -> None: - self.ensure_deletable(package_dir) - dest = self.root / package_dir - manifest = load_manifest(self.manifest_path) - shutil.rmtree(dest) - updated = tuple(entry for entry in manifest.entries if entry.package_dir != package_dir) - write_manifest(self.manifest_path, StoreManifest(entries=updated)) + with file_lock(self._lock_path): + self.ensure_deletable(package_dir) + dest = self.root / package_dir + manifest = load_manifest(self.manifest_path) + shutil.rmtree(dest) + updated = tuple(entry for entry in manifest.entries if entry.package_dir != package_dir) + write_manifest(self.manifest_path, StoreManifest(entries=updated)) def ensure_deletable(self, package_dir: str) -> None: dest = self.root / package_dir diff --git a/tests/integration/test_marketplace_api.py b/tests/integration/test_marketplace_api.py index da3eacf..bf70332 100644 --- a/tests/integration/test_marketplace_api.py +++ b/tests/integration/test_marketplace_api.py @@ -216,9 +216,9 @@ def test_marketplace_search_rejects_short_queries(self) -> None: def test_marketplace_install_requires_install_token(self) -> None: with AppTestHarness(marketplace=create_fixture_marketplace_service()) as harness: - payload = harness.post_json("/api/marketplace/install", {}, expected_status=400) + payload = harness.post_json("/api/marketplace/install", {}, expected_status=422) - self.assertEqual(payload["error"], "missing installToken") + self.assertIn("installToken", payload["error"]) if __name__ == "__main__": diff --git a/tests/integration/test_request_validation.py b/tests/integration/test_request_validation.py new file mode 100644 index 0000000..b3c1e98 --- /dev/null +++ b/tests/integration/test_request_validation.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +import unittest + +from tests.support.app_harness import AppTestHarness + + +class RequestValidationTests(unittest.TestCase): + def test_enable_skill_rejects_empty_body(self) -> None: + with AppTestHarness() as harness: + payload = harness.post_json("/api/skills/missing/enable", {}, expected_status=422) + self.assertIn("harness", payload["error"]) + + def test_enable_skill_rejects_blank_harness(self) -> None: + with AppTestHarness() as harness: + payload = harness.post_json("/api/skills/missing/enable", {"harness": ""}, expected_status=422) + self.assertIn("harness", payload["error"]) + + def test_disable_skill_rejects_empty_body(self) -> None: + with AppTestHarness() as harness: + payload = harness.post_json("/api/skills/missing/disable", {}, expected_status=422) + self.assertIn("harness", payload["error"]) + + def test_set_harness_support_requires_boolean_enabled(self) -> None: + with AppTestHarness() as harness: + payload = harness.put_json( + "/api/settings/harnesses/codex/support", + {"enabled": 42}, + expected_status=422, + ) + self.assertIn("enabled", payload["error"]) + + def test_install_marketplace_requires_install_token(self) -> None: + with AppTestHarness() as harness: + payload = harness.post_json("/api/marketplace/install", {}, expected_status=422) + self.assertIn("installToken", payload["error"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_atomic.py b/tests/unit/test_atomic.py new file mode 100644 index 0000000..7d480e3 --- /dev/null +++ b/tests/unit/test_atomic.py @@ -0,0 +1,70 @@ +from __future__ import annotations + +import threading +import unittest +from pathlib import Path +from tempfile import TemporaryDirectory +from unittest import mock + +from skill_manager.store._atomic import atomic_write_text, file_lock + + +class AtomicWriteTextTests(unittest.TestCase): + def test_writes_full_content_to_target_path(self) -> None: + with TemporaryDirectory() as temp: + target = Path(temp) / "out.json" + atomic_write_text(target, "hello\n") + self.assertEqual(target.read_text(encoding="utf-8"), "hello\n") + + def test_creates_parent_directories(self) -> None: + with TemporaryDirectory() as temp: + target = Path(temp) / "deep" / "nested" / "file.txt" + atomic_write_text(target, "x") + self.assertEqual(target.read_text(encoding="utf-8"), "x") + + def test_replaces_existing_file_atomically(self) -> None: + with TemporaryDirectory() as temp: + target = Path(temp) / "out.txt" + target.write_text("old", encoding="utf-8") + atomic_write_text(target, "new") + self.assertEqual(target.read_text(encoding="utf-8"), "new") + + def test_failed_write_leaves_no_temp_files_and_keeps_original(self) -> None: + with TemporaryDirectory() as temp: + target = Path(temp) / "out.txt" + target.write_text("preserved", encoding="utf-8") + with mock.patch("os.replace", side_effect=OSError("boom")): + with self.assertRaises(OSError): + atomic_write_text(target, "broken") + self.assertEqual(target.read_text(encoding="utf-8"), "preserved") + tmp_files = [p for p in Path(temp).iterdir() if p.name != "out.txt"] + self.assertEqual(tmp_files, []) + + +class FileLockTests(unittest.TestCase): + def test_serializes_concurrent_critical_sections(self) -> None: + with TemporaryDirectory() as temp: + lock_path = Path(temp) / "guard.lock" + shared: list[str] = [] + holding = threading.Event() + + def writer(label: str, hold_after_acquire: float) -> None: + with file_lock(lock_path): + shared.append(f"enter:{label}") + if hold_after_acquire: + holding.set() + threading.Event().wait(hold_after_acquire) + shared.append(f"exit:{label}") + + t1 = threading.Thread(target=writer, args=("a", 0.1)) + t1.start() + holding.wait(timeout=1.0) + t2 = threading.Thread(target=writer, args=("b", 0.0)) + t2.start() + t1.join() + t2.join() + self.assertEqual(shared, ["enter:a", "exit:a", "enter:b", "exit:b"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_paths.py b/tests/unit/test_paths.py new file mode 100644 index 0000000..057a5b0 --- /dev/null +++ b/tests/unit/test_paths.py @@ -0,0 +1,92 @@ +from __future__ import annotations + +import sys +import unittest +from contextlib import contextmanager +from pathlib import Path +from tempfile import TemporaryDirectory +from unittest import mock + +from skill_manager.paths import APP_NAME, resolve_app_paths + + +@contextmanager +def isolated_env(platform: str): + """Pin sys.platform and clear inherited XDG/HOME so tests fully control env.""" + cleared = {key: "" for key in ( + "XDG_CONFIG_HOME", + "XDG_DATA_HOME", + "XDG_STATE_HOME", + "HOME", + "SKILL_MANAGER_SETTINGS_PATH", + "SKILL_MANAGER_STATE_DIR", + )} + with mock.patch.object(sys, "platform", platform), mock.patch.dict("os.environ", cleared, clear=False): + yield + + +class ResolveAppPathsTests(unittest.TestCase): + def test_macos_default_layout_collapses_to_application_support(self) -> None: + with isolated_env("darwin"), TemporaryDirectory() as temp: + home = Path(temp) / "home" + paths = resolve_app_paths({"HOME": str(home)}) + base = home / "Library" / "Application Support" / APP_NAME + self.assertEqual(paths.config_dir, base) + self.assertEqual(paths.data_dir, base) + self.assertEqual(paths.state_dir, base) + self.assertEqual(paths.shared_store_root, base / "shared") + self.assertEqual(paths.shared_store_manifest, base / "manifest.json") + self.assertEqual(paths.marketplace_cache_root, base / "marketplace") + self.assertEqual(paths.settings_path, base / "settings.json") + self.assertEqual(paths.runtime_state_path, base / "runtime.json") + self.assertEqual(paths.server_log_path, base / "server.log") + + def test_xdg_overrides_each_dir_independently(self) -> None: + with isolated_env("darwin"), TemporaryDirectory() as temp: + root = Path(temp) + env = { + "HOME": str(root / "home"), + "XDG_CONFIG_HOME": str(root / "cfg"), + "XDG_DATA_HOME": str(root / "data"), + "XDG_STATE_HOME": str(root / "state"), + } + paths = resolve_app_paths(env) + self.assertEqual(paths.config_dir, root / "cfg" / APP_NAME) + self.assertEqual(paths.data_dir, root / "data" / APP_NAME) + self.assertEqual(paths.state_dir, root / "state" / APP_NAME) + self.assertEqual(paths.shared_store_root, root / "data" / APP_NAME / "shared") + self.assertEqual(paths.settings_path, root / "cfg" / APP_NAME / "settings.json") + + def test_settings_path_env_overrides_settings_path(self) -> None: + with isolated_env("darwin"), TemporaryDirectory() as temp: + custom = Path(temp) / "elsewhere" / "settings.json" + env = { + "HOME": str(Path(temp) / "home"), + "SKILL_MANAGER_SETTINGS_PATH": str(custom), + } + paths = resolve_app_paths(env) + self.assertEqual(paths.settings_path, custom) + + def test_state_dir_env_overrides_state_paths(self) -> None: + with isolated_env("darwin"), TemporaryDirectory() as temp: + custom_state = Path(temp) / "runtime" + env = { + "HOME": str(Path(temp) / "home"), + "SKILL_MANAGER_STATE_DIR": str(custom_state), + } + paths = resolve_app_paths(env) + self.assertEqual(paths.state_dir, custom_state) + self.assertEqual(paths.runtime_state_path, custom_state / "runtime.json") + self.assertEqual(paths.server_log_path, custom_state / "server.log") + + def test_linux_defaults_use_xdg_basedir_layout(self) -> None: + with isolated_env("linux"), TemporaryDirectory() as temp: + home = Path(temp) / "home" + paths = resolve_app_paths({"HOME": str(home)}) + self.assertEqual(paths.config_dir, home / ".config" / APP_NAME) + self.assertEqual(paths.data_dir, home / ".local" / "share" / APP_NAME) + self.assertEqual(paths.state_dir, home / ".local" / "state" / APP_NAME) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_shared_store_concurrent.py b/tests/unit/test_shared_store_concurrent.py new file mode 100644 index 0000000..df92287 --- /dev/null +++ b/tests/unit/test_shared_store_concurrent.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +import unittest +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path +from tempfile import TemporaryDirectory + +from skill_manager.store import SharedStore, load_manifest + +from tests.support.fake_home import seed_skill_package + + +class SharedStoreConcurrentIngestTests(unittest.TestCase): + def test_two_threads_ingesting_distinct_packages_persist_both_entries(self) -> None: + for iteration in range(20): + with TemporaryDirectory() as temp: + store_root = Path(temp) / "shared" + store = SharedStore(store_root) + staging = Path(temp) / "staging" + staging.mkdir() + + pkg_a = seed_skill_package(staging, "alpha", "Alpha") + pkg_b = seed_skill_package(staging, "bravo", "Bravo") + + def ingest(source: Path) -> None: + store.ingest( + source_path=source, + declared_name=source.name, + source_kind="github", + source_locator=f"github:test/{source.name}", + ) + + with ThreadPoolExecutor(max_workers=2) as pool: + futures = [pool.submit(ingest, pkg_a), pool.submit(ingest, pkg_b)] + for future in futures: + future.result() + + manifest = load_manifest(store.manifest_path) + names = sorted(entry.package_dir for entry in manifest.entries) + self.assertEqual(names, ["alpha", "bravo"], f"iteration {iteration} dropped entries") + self.assertTrue((store_root / "alpha" / "SKILL.md").is_file()) + self.assertTrue((store_root / "bravo" / "SKILL.md").is_file()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_storage_paths.py b/tests/unit/test_storage_paths.py deleted file mode 100644 index 730e4e3..0000000 --- a/tests/unit/test_storage_paths.py +++ /dev/null @@ -1,68 +0,0 @@ -from __future__ import annotations - -from pathlib import Path -from tempfile import TemporaryDirectory -import unittest -from unittest import mock - -from skill_manager.storage_paths import ( - canonical_marketplace_cache_root, - canonical_shared_store_root, - legacy_shared_store_root, - resolve_shared_store_root, -) - -from tests.support.fake_home import seed_skill_package - - -class SharedStorePathResolutionTests(unittest.TestCase): - def test_prefers_legacy_store_when_canonical_location_is_uninitialized(self) -> None: - with TemporaryDirectory() as temp_dir: - home = Path(temp_dir) / "home" - legacy_root = home / ".local" / "share" / "skill-manager" / "shared" - legacy_root.mkdir(parents=True, exist_ok=True) - seed_skill_package(legacy_root, "audit", "Audit") - canonical_data_dir = home / "Library" / "Application Support" / "skill-manager" - - with mock.patch("skill_manager.storage_paths.app_data_dir", return_value=canonical_data_dir): - resolved = resolve_shared_store_root({"HOME": str(home)}) - canonical_root = canonical_shared_store_root({"HOME": str(home)}) - - self.assertEqual(resolved, legacy_root) - self.assertEqual(canonical_root, canonical_data_dir / "shared") - self.assertEqual(legacy_shared_store_root({"HOME": str(home)}), legacy_root) - - def test_prefers_initialized_canonical_store_over_legacy_store(self) -> None: - with TemporaryDirectory() as temp_dir: - home = Path(temp_dir) / "home" - legacy_root = home / ".local" / "share" / "skill-manager" / "shared" - legacy_root.mkdir(parents=True, exist_ok=True) - seed_skill_package(legacy_root, "legacy-audit", "Legacy Audit") - canonical_data_dir = home / "Library" / "Application Support" / "skill-manager" - canonical_root = canonical_data_dir / "shared" - canonical_root.mkdir(parents=True, exist_ok=True) - seed_skill_package(canonical_root, "audit", "Audit") - - with mock.patch("skill_manager.storage_paths.app_data_dir", return_value=canonical_data_dir): - resolved = resolve_shared_store_root({"HOME": str(home)}) - - self.assertEqual(resolved, canonical_root) - - -class MarketplaceCachePathResolutionTests(unittest.TestCase): - def test_marketplace_cache_uses_canonical_app_data_root_only(self) -> None: - with TemporaryDirectory() as temp_dir: - home = Path(temp_dir) / "home" - canonical_data_dir = home / "Library" / "Application Support" / "skill-manager" - old_cache_root = home / ".local" / "share" / "skill-manager" / "marketplace" - old_cache_root.mkdir(parents=True, exist_ok=True) - - with mock.patch("skill_manager.storage_paths.app_data_dir", return_value=canonical_data_dir): - resolved = canonical_marketplace_cache_root({"HOME": str(home)}) - - self.assertEqual(resolved, canonical_data_dir / "marketplace") - self.assertNotEqual(resolved, old_cache_root) - - -if __name__ == "__main__": - unittest.main()