From 0766080dc126bc07db16734b4cd4298c78cd08aa Mon Sep 17 00:00:00 2001 From: Duy Nguyen Date: Thu, 15 Oct 2026 19:32:47 +0700 Subject: [PATCH 1/4] feat(db): add skills.compatibility_json under its production migration name Add the nullable compatibility_json text column to the skills schema and generate its drizzle snapshot and journal entry. Production D1 already has the column and records 0010_greedy_famine.sql in d1_migrations, and wrangler decides pending migrations by file name. The migration keeps that name so production has nothing to apply and fresh databases still create the column. A test pins the name, the SQL and the journal tag, and the deployment guide records the reconciliation. --- apps/web/app/lib/db/schema.ts | 3 + .../drizzle/migrations/0010_greedy_famine.sql | 1 + .../migrations/meta/0017_snapshot.json | 2693 +++++++++++++++++ .../web/drizzle/migrations/meta/_journal.json | 7 + .../db/compatibility-column-migration.test.ts | 36 + docs/deployment-guide.md | 27 + 6 files changed, 2767 insertions(+) create mode 100644 apps/web/drizzle/migrations/0010_greedy_famine.sql create mode 100644 apps/web/drizzle/migrations/meta/0017_snapshot.json create mode 100644 apps/web/tests/db/compatibility-column-migration.test.ts diff --git a/apps/web/app/lib/db/schema.ts b/apps/web/app/lib/db/schema.ts index 079a987..f740a64 100644 --- a/apps/web/app/lib/db/schema.ts +++ b/apps/web/app/lib/db/schema.ts @@ -33,6 +33,9 @@ export const skills = sqliteTable( downvote_count: integer("downvote_count").default(0), net_votes: integer("net_votes").default(0), scripts: text("scripts"), // JSON: [{name, command, url}] + // JSON: publisher-declared runtime compatibility map. A listing has no artifact + // digest, so a declaration here can reach `declared` but never `verified`. + compatibility_json: text("compatibility_json"), fts_content: text("fts_content"), // Computed: content + ref titles (for FTS5) risk_label: text("risk_label").default("unknown"), created_at: integer("created_at", { mode: "timestamp_ms" }).notNull(), diff --git a/apps/web/drizzle/migrations/0010_greedy_famine.sql b/apps/web/drizzle/migrations/0010_greedy_famine.sql new file mode 100644 index 0000000..21a71f8 --- /dev/null +++ b/apps/web/drizzle/migrations/0010_greedy_famine.sql @@ -0,0 +1 @@ +ALTER TABLE `skills` ADD `compatibility_json` text; \ No newline at end of file diff --git a/apps/web/drizzle/migrations/meta/0017_snapshot.json b/apps/web/drizzle/migrations/meta/0017_snapshot.json new file mode 100644 index 0000000..060e3d2 --- /dev/null +++ b/apps/web/drizzle/migrations/meta/0017_snapshot.json @@ -0,0 +1,2693 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "187b8911-9ea8-40e1-8816-6ad0b8abb8fe", + "prevId": "c1bdbcc0-95ac-4b8f-aecc-ad42fe20a16f", + "tables": { + "account": { + "name": "account", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "accountId": { + "name": "accountId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "providerId": { + "name": "providerId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "accessToken": { + "name": "accessToken", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refreshToken": { + "name": "refreshToken", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "accessTokenExpiresAt": { + "name": "accessTokenExpiresAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refreshTokenExpiresAt": { + "name": "refreshTokenExpiresAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "idToken": { + "name": "idToken", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updatedAt": { + "name": "updatedAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "account_userId_user_id_fk": { + "name": "account_userId_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "api_keys": { + "name": "api_keys", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'Default'" + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key_prefix": { + "name": "key_prefix", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scopes": { + "name": "scopes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "api_keys_key_hash_unique": { + "name": "api_keys_key_hash_unique", + "columns": [ + "key_hash" + ], + "isUnique": true + }, + "idx_api_keys_user": { + "name": "idx_api_keys_user", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "idx_api_keys_hash": { + "name": "idx_api_keys_hash", + "columns": [ + "key_hash" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "favorites": { + "name": "favorites", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "skill_id": { + "name": "skill_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_favorites_pk": { + "name": "idx_favorites_pk", + "columns": [ + "user_id", + "skill_id" + ], + "isUnique": true + }, + "idx_favorites_skill": { + "name": "idx_favorites_skill", + "columns": [ + "skill_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "favorites_skill_id_skills_id_fk": { + "name": "favorites_skill_id_skills_id_fk", + "tableFrom": "favorites", + "tableTo": "skills", + "columnsFrom": [ + "skill_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "installs": { + "name": "installs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "skill_id": { + "name": "skill_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "device_id": { + "name": "device_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_installs_skill": { + "name": "idx_installs_skill", + "columns": [ + "skill_id" + ], + "isUnique": false + }, + "idx_installs_user": { + "name": "idx_installs_user", + "columns": [ + "skill_id", + "user_id" + ], + "isUnique": true, + "where": "user_id IS NOT NULL" + }, + "idx_installs_device": { + "name": "idx_installs_device", + "columns": [ + "skill_id", + "device_id" + ], + "isUnique": true, + "where": "device_id IS NOT NULL" + } + }, + "foreignKeys": { + "installs_skill_id_skills_id_fk": { + "name": "installs_skill_id_skills_id_fk", + "tableFrom": "installs", + "tableTo": "skills", + "columnsFrom": [ + "skill_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ratings": { + "name": "ratings", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "skill_id": { + "name": "skill_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "score": { + "name": "score", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_agent": { + "name": "is_agent", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_ratings_user_skill": { + "name": "idx_ratings_user_skill", + "columns": [ + "user_id", + "skill_id" + ], + "isUnique": true + }, + "idx_ratings_skill": { + "name": "idx_ratings_skill", + "columns": [ + "skill_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "ratings_skill_id_skills_id_fk": { + "name": "ratings_skill_id_skills_id_fk", + "tableFrom": "ratings", + "tableTo": "skills", + "columnsFrom": [ + "skill_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "reviews": { + "name": "reviews", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "skill_id": { + "name": "skill_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_agent": { + "name": "is_agent", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_reviews_skill": { + "name": "idx_reviews_skill", + "columns": [ + "skill_id" + ], + "isUnique": false + }, + "idx_reviews_user": { + "name": "idx_reviews_user", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "reviews_skill_id_skills_id_fk": { + "name": "reviews_skill_id_skills_id_fk", + "tableFrom": "reviews", + "tableTo": "skills", + "columnsFrom": [ + "skill_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "session": { + "name": "session", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expiresAt": { + "name": "expiresAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ipAddress": { + "name": "ipAddress", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "userAgent": { + "name": "userAgent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updatedAt": { + "name": "updatedAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "session_token_unique": { + "name": "session_token_unique", + "columns": [ + "token" + ], + "isUnique": true + } + }, + "foreignKeys": { + "session_userId_user_id_fk": { + "name": "session_userId_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "skill_aliases": { + "name": "skill_aliases", + "columns": { + "slug": { + "name": "slug", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "skill_id": { + "name": "skill_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'legacy'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_skill_aliases_skill": { + "name": "idx_skill_aliases_skill", + "columns": [ + "skill_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "skill_aliases_skill_id_skills_id_fk": { + "name": "skill_aliases_skill_id_skills_id_fk", + "tableFrom": "skill_aliases", + "tableTo": "skills", + "columnsFrom": [ + "skill_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "skills": { + "name": "skills", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "author": { + "name": "author", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_url": { + "name": "source_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_repo": { + "name": "source_repo", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_path": { + "name": "source_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "install_command": { + "name": "install_command", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'1.0.0'" + }, + "is_paid": { + "name": "is_paid", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "price_cents": { + "name": "price_cents", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "avg_rating": { + "name": "avg_rating", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "rating_count": { + "name": "rating_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "github_stars": { + "name": "github_stars", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "install_count": { + "name": "install_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "composite_score": { + "name": "composite_score", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "bayesian_rating": { + "name": "bayesian_rating", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "trending_score": { + "name": "trending_score", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "favorite_count": { + "name": "favorite_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "upvote_count": { + "name": "upvote_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "downvote_count": { + "name": "downvote_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "net_votes": { + "name": "net_votes", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "scripts": { + "name": "scripts", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "compatibility_json": { + "name": "compatibility_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "fts_content": { + "name": "fts_content", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "risk_label": { + "name": "risk_label", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'unknown'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "skills_slug_unique": { + "name": "skills_slug_unique", + "columns": [ + "slug" + ], + "isUnique": true + }, + "idx_skills_category": { + "name": "idx_skills_category", + "columns": [ + "category" + ], + "isUnique": false + }, + "idx_skills_author": { + "name": "idx_skills_author", + "columns": [ + "author" + ], + "isUnique": false + }, + "idx_skills_avg_rating": { + "name": "idx_skills_avg_rating", + "columns": [ + "avg_rating" + ], + "isUnique": false + }, + "idx_skills_composite_score": { + "name": "idx_skills_composite_score", + "columns": [ + "composite_score" + ], + "isUnique": false + }, + "idx_skills_trending_score": { + "name": "idx_skills_trending_score", + "columns": [ + "trending_score" + ], + "isUnique": false + }, + "idx_skills_net_votes": { + "name": "idx_skills_net_votes", + "columns": [ + "net_votes" + ], + "isUnique": false + }, + "idx_skills_source_identity": { + "name": "idx_skills_source_identity", + "columns": [ + "source_repo", + "source_path" + ], + "isUnique": true, + "where": "source_repo IS NOT NULL" + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "usage_stats": { + "name": "usage_stats", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "skill_id": { + "name": "skill_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "outcome": { + "name": "outcome", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_usage_skill": { + "name": "idx_usage_skill", + "columns": [ + "skill_id" + ], + "isUnique": false + }, + "idx_usage_created": { + "name": "idx_usage_created", + "columns": [ + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "usage_stats_skill_id_skills_id_fk": { + "name": "usage_stats_skill_id_skills_id_fk", + "tableFrom": "usage_stats", + "tableTo": "skills", + "columnsFrom": [ + "skill_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user": { + "name": "user", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "emailVerified": { + "name": "emailVerified", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updatedAt": { + "name": "updatedAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "user_email_unique": { + "name": "user_email_unique", + "columns": [ + "email" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "verification": { + "name": "verification", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expiresAt": { + "name": "expiresAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updatedAt": { + "name": "updatedAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "votes": { + "name": "votes", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "skill_id": { + "name": "skill_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "vote_type": { + "name": "vote_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_votes_user_skill": { + "name": "idx_votes_user_skill", + "columns": [ + "user_id", + "skill_id" + ], + "isUnique": true + }, + "idx_votes_skill": { + "name": "idx_votes_skill", + "columns": [ + "skill_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "votes_skill_id_skills_id_fk": { + "name": "votes_skill_id_skills_id_fk", + "tableFrom": "votes", + "tableTo": "skills", + "columnsFrom": [ + "skill_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "package_releases": { + "name": "package_releases", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "package_id": { + "name": "package_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "channel": { + "name": "channel", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'stable'" + }, + "release_state": { + "name": "release_state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'draft'" + }, + "manifest_schema_version": { + "name": "manifest_schema_version", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "artifact_key": { + "name": "artifact_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "artifact_digest": { + "name": "artifact_digest", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "artifact_size": { + "name": "artifact_size", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "release_digest": { + "name": "release_digest", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "file_count": { + "name": "file_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tree_size": { + "name": "tree_size", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "signed_manifest_key": { + "name": "signed_manifest_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_provenance": { + "name": "source_provenance", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "compatibility_json": { + "name": "compatibility_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "published_at": { + "name": "published_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "yanked_at": { + "name": "yanked_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "quarantined_at": { + "name": "quarantined_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status_sequence": { + "name": "status_sequence", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status_issued_at": { + "name": "status_issued_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status_key": { + "name": "status_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "exec_summary_json": { + "name": "exec_summary_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_package_releases_version": { + "name": "idx_package_releases_version", + "columns": [ + "package_id", + "version" + ], + "isUnique": true + }, + "idx_package_releases_state": { + "name": "idx_package_releases_state", + "columns": [ + "release_state" + ], + "isUnique": false + }, + "idx_package_releases_digest": { + "name": "idx_package_releases_digest", + "columns": [ + "release_digest" + ], + "isUnique": false + } + }, + "foreignKeys": { + "package_releases_package_id_packages_id_fk": { + "name": "package_releases_package_id_packages_id_fk", + "tableFrom": "package_releases", + "tableTo": "packages", + "columnsFrom": [ + "package_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "packages": { + "name": "packages", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "publisher_id": { + "name": "publisher_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "visibility": { + "name": "visibility", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'public'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_packages_name": { + "name": "idx_packages_name", + "columns": [ + "name" + ], + "isUnique": true + }, + "idx_packages_publisher": { + "name": "idx_packages_publisher", + "columns": [ + "publisher_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "packages_publisher_id_publishers_id_fk": { + "name": "packages_publisher_id_publishers_id_fk", + "tableFrom": "packages", + "tableTo": "publishers", + "columnsFrom": [ + "publisher_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "publishers": { + "name": "publishers", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "handle": { + "name": "handle", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "publishers_handle_unique": { + "name": "publishers_handle_unique", + "columns": [ + "handle" + ], + "isUnique": true + }, + "idx_publishers_owner": { + "name": "idx_publishers_owner", + "columns": [ + "owner_user_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "release_state_audit": { + "name": "release_state_audit", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "subject_kind": { + "name": "subject_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subject_id": { + "name": "subject_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "actor_kind": { + "name": "actor_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "from_state": { + "name": "from_state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "to_state": { + "name": "to_state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "outcome": { + "name": "outcome", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_release_state_audit_subject": { + "name": "idx_release_state_audit_subject", + "columns": [ + "subject_kind", + "subject_id", + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "release_verification_evidence": { + "name": "release_verification_evidence", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "release_id": { + "name": "release_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "release_digest": { + "name": "release_digest", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "harness": { + "name": "harness", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "harness_version": { + "name": "harness_version", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "os": { + "name": "os", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "probe_id": { + "name": "probe_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "verified_at": { + "name": "verified_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "verifier": { + "name": "verifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_release_evidence_release": { + "name": "idx_release_evidence_release", + "columns": [ + "release_id" + ], + "isUnique": false + }, + "idx_release_evidence_probe": { + "name": "idx_release_evidence_probe", + "columns": [ + "release_id", + "harness", + "probe_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "release_verification_evidence_release_id_package_releases_id_fk": { + "name": "release_verification_evidence_release_id_package_releases_id_fk", + "tableFrom": "release_verification_evidence", + "tableTo": "package_releases", + "columnsFrom": [ + "release_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "entitlements": { + "name": "entitlements", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "subject_id": { + "name": "subject_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "package_id": { + "name": "package_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "release_id": { + "name": "release_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "order_id": { + "name": "order_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "granted_at": { + "name": "granted_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_entitlements_subject": { + "name": "idx_entitlements_subject", + "columns": [ + "subject_id" + ], + "isUnique": false + }, + "idx_entitlements_package": { + "name": "idx_entitlements_package", + "columns": [ + "package_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "entitlements_package_id_packages_id_fk": { + "name": "entitlements_package_id_packages_id_fk", + "tableFrom": "entitlements", + "tableTo": "packages", + "columnsFrom": [ + "package_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "entitlements_release_id_package_releases_id_fk": { + "name": "entitlements_release_id_package_releases_id_fk", + "tableFrom": "entitlements", + "tableTo": "package_releases", + "columnsFrom": [ + "release_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "collection_revisions": { + "name": "collection_revisions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "revision_number": { + "name": "revision_number", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "members_json": { + "name": "members_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "config_json": { + "name": "config_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_collection_revisions_number": { + "name": "idx_collection_revisions_number", + "columns": [ + "collection_id", + "revision_number" + ], + "isUnique": true + }, + "idx_collection_revisions_collection": { + "name": "idx_collection_revisions_collection", + "columns": [ + "collection_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "collection_revisions_collection_id_collections_id_fk": { + "name": "collection_revisions_collection_id_collections_id_fk", + "tableFrom": "collection_revisions", + "tableTo": "collections", + "columnsFrom": [ + "collection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "collections": { + "name": "collections", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "visibility": { + "name": "visibility", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'private'" + }, + "latest_revision_number": { + "name": "latest_revision_number", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_collections_owner_slug": { + "name": "idx_collections_owner_slug", + "columns": [ + "owner_id", + "slug" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "personal_kit_audit": { + "name": "personal_kit_audit", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "kit_id": { + "name": "kit_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "actor_kind": { + "name": "actor_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "revision_id": { + "name": "revision_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "detail": { + "name": "detail", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_personal_kit_audit_kit": { + "name": "idx_personal_kit_audit_kit", + "columns": [ + "kit_id", + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "personal_kit_conflicts": { + "name": "personal_kit_conflicts", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "kit_id": { + "name": "kit_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "from_revision_id": { + "name": "from_revision_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "report_json": { + "name": "report_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'open'" + }, + "resolution_json": { + "name": "resolution_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "resolved_by": { + "name": "resolved_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "resolved_revision_id": { + "name": "resolved_revision_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reeval_required": { + "name": "reeval_required", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "resolved_at": { + "name": "resolved_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_personal_kit_conflicts_kit": { + "name": "idx_personal_kit_conflicts_kit", + "columns": [ + "kit_id", + "status" + ], + "isUnique": false + }, + "idx_personal_kit_conflicts_idempotency": { + "name": "idx_personal_kit_conflicts_idempotency", + "columns": [ + "kit_id", + "idempotency_key" + ], + "isUnique": true + } + }, + "foreignKeys": { + "personal_kit_conflicts_kit_id_personal_kits_id_fk": { + "name": "personal_kit_conflicts_kit_id_personal_kits_id_fk", + "tableFrom": "personal_kit_conflicts", + "tableTo": "personal_kits", + "columnsFrom": [ + "kit_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "personal_kit_proposals": { + "name": "personal_kit_proposals", + "columns": { + "revision_id": { + "name": "revision_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "kit_id": { + "name": "kit_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "identity": { + "name": "identity", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "declared_permissions_json": { + "name": "declared_permissions_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "required_permissions_json": { + "name": "required_permissions_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "evidence_json": { + "name": "evidence_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scan_json": { + "name": "scan_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_personal_kit_proposals_kit": { + "name": "idx_personal_kit_proposals_kit", + "columns": [ + "kit_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "personal_kit_proposals_revision_id_personal_kit_revisions_id_fk": { + "name": "personal_kit_proposals_revision_id_personal_kit_revisions_id_fk", + "tableFrom": "personal_kit_proposals", + "tableTo": "personal_kit_revisions", + "columnsFrom": [ + "revision_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "personal_kit_revisions": { + "name": "personal_kit_revisions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "kit_id": { + "name": "kit_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "revision_number": { + "name": "revision_number", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "parent_revision_id": { + "name": "parent_revision_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "revision_digest": { + "name": "revision_digest", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "document_json": { + "name": "document_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'draft'" + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "author_user_id": { + "name": "author_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "approval_json": { + "name": "approval_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reviewed_at": { + "name": "reviewed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "activated_at": { + "name": "activated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rejected_at": { + "name": "rejected_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "quarantined_at": { + "name": "quarantined_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_personal_kit_revisions_number": { + "name": "idx_personal_kit_revisions_number", + "columns": [ + "kit_id", + "revision_number" + ], + "isUnique": true + }, + "idx_personal_kit_revisions_idempotency": { + "name": "idx_personal_kit_revisions_idempotency", + "columns": [ + "kit_id", + "idempotency_key" + ], + "isUnique": true + }, + "idx_personal_kit_revisions_digest": { + "name": "idx_personal_kit_revisions_digest", + "columns": [ + "kit_id", + "revision_digest" + ], + "isUnique": false + } + }, + "foreignKeys": { + "personal_kit_revisions_kit_id_personal_kits_id_fk": { + "name": "personal_kit_revisions_kit_id_personal_kits_id_fk", + "tableFrom": "personal_kit_revisions", + "tableTo": "personal_kits", + "columnsFrom": [ + "kit_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "personal_kits": { + "name": "personal_kits", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "publisher_id": { + "name": "publisher_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "local_id": { + "name": "local_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "visibility": { + "name": "visibility", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'private'" + }, + "generation": { + "name": "generation", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "effective_revision_id": { + "name": "effective_revision_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "latest_revision_number": { + "name": "latest_revision_number", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_personal_kits_name": { + "name": "idx_personal_kits_name", + "columns": [ + "name" + ], + "isUnique": true + }, + "idx_personal_kits_owner": { + "name": "idx_personal_kits_owner", + "columns": [ + "owner_user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "personal_kits_publisher_id_publishers_id_fk": { + "name": "personal_kits_publisher_id_publishers_id_fk", + "tableFrom": "personal_kits", + "tableTo": "publishers", + "columnsFrom": [ + "publisher_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/apps/web/drizzle/migrations/meta/_journal.json b/apps/web/drizzle/migrations/meta/_journal.json index a4f362d..4a9285f 100644 --- a/apps/web/drizzle/migrations/meta/_journal.json +++ b/apps/web/drizzle/migrations/meta/_journal.json @@ -120,6 +120,13 @@ "when": 1791994326116, "tag": "0016_personal_kit_proposals", "breakpoints": true + }, + { + "idx": 17, + "version": "6", + "when": 1792066514896, + "tag": "0010_greedy_famine", + "breakpoints": true } ] } \ No newline at end of file diff --git a/apps/web/tests/db/compatibility-column-migration.test.ts b/apps/web/tests/db/compatibility-column-migration.test.ts new file mode 100644 index 0000000..3b859fa --- /dev/null +++ b/apps/web/tests/db/compatibility-column-migration.test.ts @@ -0,0 +1,36 @@ +/** + * The `skills.compatibility_json` migration keeps the name production recorded. + * + * Production D1 already has the column and lists `0010_greedy_famine.sql` in + * `d1_migrations` (it was applied from an earlier branch). `wrangler d1 + * migrations apply` decides what is pending by file name, so the migration must + * keep exactly that name: a new name would be re-applied and fail with a + * duplicate column. See docs/deployment-guide.md. + */ +import { describe, expect, it } from "vitest"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { FakeD1 } from "../releases/fake-d1"; + +const MIGRATIONS_DIR = new URL("../../drizzle/migrations/", import.meta.url); + +describe("compatibility_json migration", () => { + it("is the file production already recorded, adding only the nullable column", () => { + const sql = readFileSync(fileURLToPath(new URL("0010_greedy_famine.sql", MIGRATIONS_DIR)), "utf-8"); + expect(sql.trim()).toBe("ALTER TABLE `skills` ADD `compatibility_json` text;"); + }); + + it("is registered in the drizzle journal under the same tag", () => { + const journal = JSON.parse(readFileSync(fileURLToPath(new URL("meta/_journal.json", MIGRATIONS_DIR)), "utf-8")) as { + entries: Array<{ tag: string }>; + }; + expect(journal.entries.filter((e) => e.tag === "0010_greedy_famine")).toHaveLength(1); + }); + + it("applies on a fresh database as a nullable text column", () => { + const db = new FakeD1(); + const columns = db.raw.prepare("PRAGMA table_info(skills)").all() as Array<{ name: string; type: string; notnull: number }>; + const column = columns.find((c) => c.name === "compatibility_json"); + expect(column).toMatchObject({ type: "TEXT", notnull: 0 }); + }); +}); diff --git a/docs/deployment-guide.md b/docs/deployment-guide.md index 122932e..c4b4d3f 100644 --- a/docs/deployment-guide.md +++ b/docs/deployment-guide.md @@ -175,6 +175,33 @@ wrangler d1 migrations apply skillx-db --remote wrangler d1 execute skillx-db --remote --command "SELECT COUNT(*) FROM skills" ``` +#### `skills.compatibility_json` (migration `0010_greedy_famine.sql`) + +The nullable `skills.compatibility_json` column (a listing's publisher-declared +runtime map, read by `/api/mcp`, `/skills/.md`, `/llms*.txt` and the +`compatible` search filter) is created by `drizzle/migrations/0010_greedy_famine.sql`. +The file sorts between `0009` and `0010_sour_runaways` but is the newest entry +(`idx` 17) in `meta/_journal.json`; it depends only on `0000`. + +The name is deliberate. Production D1 already has the column and already records +`0010_greedy_famine.sql` in `d1_migrations` (applied 2026-09-22 from an earlier +branch). `wrangler d1 migrations apply` decides what is pending by file name, so: + +- production: nothing to apply and no manual step; `migrations list --remote` + shows no pending entry for it. Do not rename the file — a new name would be + re-applied and fail with `duplicate column name: compatibility_json`; +- a fresh or local database applies it with the rest of the chain. + +Check before a deploy that relies on it (read-only): + +```bash +wrangler d1 execute skillx-db --remote --command "PRAGMA table_info(skills)" +wrangler d1 migrations list skillx-db --remote +``` + +Nothing writes the column yet; it is `NULL` (compatibility `unknown`) until a +publisher write path exists. + #### Seed Production Data ```bash From bb8e34fbbb0bc9f31e224d4fc94f2dcb0a8a667b Mon Sep 17 00:00:00 2001 From: Duy Nguyen Date: Thu, 15 Oct 2026 19:32:55 +0700 Subject: [PATCH 2/4] feat(cli): add inspect and check commands and a compatible search filter skillx inspect shows a skill's metadata, requirements and declared compatibility; skillx check gates on a runtime target and answers with its exit code (0 actionable, 4 not, 3 missing, 2 bad input). skillx search takes --compatible and --json and prints the filter report. Lookups reuse use.ts identity parsing, so org/repo/skill resolves through the repo-path endpoint. The contracts package gains the catalog compatibility resolver shared by the web app and the CLI. --- packages/cli/src/commands/check.ts | 83 +++++++++ packages/cli/src/commands/inspect.ts | 114 ++++++++++++ packages/cli/src/commands/search.ts | 118 ++++++++---- packages/cli/src/commands/use.ts | 2 +- packages/cli/src/index.ts | 4 + packages/cli/src/lib/output.test.ts | 93 ++++++++++ packages/cli/src/lib/output.ts | 129 +++++++++++++ packages/cli/src/lib/search-api.ts | 58 +++++- packages/cli/src/lib/skill-lookup.test.ts | 80 ++++++++ packages/cli/src/lib/skill-lookup.ts | 145 +++++++++++++++ .../src/catalog-compatibility.test.ts | 172 ++++++++++++++++++ packages/contracts/src/compatibility.test.ts | 15 ++ .../contracts/src/compatibility/catalog.ts | 128 +++++++++++++ packages/contracts/src/compatibility/index.ts | 1 + 14 files changed, 1099 insertions(+), 43 deletions(-) create mode 100644 packages/cli/src/commands/check.ts create mode 100644 packages/cli/src/commands/inspect.ts create mode 100644 packages/cli/src/lib/output.test.ts create mode 100644 packages/cli/src/lib/output.ts create mode 100644 packages/cli/src/lib/skill-lookup.test.ts create mode 100644 packages/cli/src/lib/skill-lookup.ts create mode 100644 packages/contracts/src/catalog-compatibility.test.ts create mode 100644 packages/contracts/src/compatibility/catalog.ts diff --git a/packages/cli/src/commands/check.ts b/packages/cli/src/commands/check.ts new file mode 100644 index 0000000..75c4e28 --- /dev/null +++ b/packages/cli/src/commands/check.ts @@ -0,0 +1,83 @@ +import { Command } from 'commander'; +import chalk from 'chalk'; +import { fetchSkillInspection, isActionable } from '../lib/skill-lookup.js'; +import { EXIT_CODES, exitCodeFor, failure, fromError, serialize, success } from '../lib/output.js'; + +interface CheckOptions { + target: string; + json?: boolean; +} + +/** + * `check` is a gate, so its exit code is the answer: 0 when the target may be + * acted on, 4 when it may not. A caller can branch on that without reading text. + */ +export const checkCommand = new Command('check') + .description('Decide whether a skill can run on a target, and print the reason') + .argument('', 'slug, author/skill, or org/repo/skill') + .requiredOption('--target ', 'runtime to evaluate, optionally runtime@version') + .option('--json', 'emit a machine-readable envelope instead of text') + .action(async (identifier: string, options: CheckOptions) => { + const useJson = options.json === true; + + try { + const { slug, inspection } = await fetchSkillInspection(identifier, options.target); + const answer = inspection.compatibility?.target; + + if (!answer) { + // The API returns no target answer only when the request never carried one. + const envelope = failure('unexpected_error', 'The API did not return a target answer.', { + details: { slug, target: options.target }, + }); + if (useJson) console.log(serialize(envelope)); + else console.error(chalk.red(`\n✗ ${envelope.error.message}`)); + process.exit(exitCodeFor(envelope)); + } + + const actionable = isActionable(answer.status); + const payload = { + slug, + target: { runtime: answer.runtime, version: options.target.includes('@') ? options.target.split('@')[1] : null }, + status: answer.status, + versions: answer.versions, + requirements: answer.requirements, + evidence: answer.evidence, + reasons: answer.reasons, + actionable, + }; + + if (useJson) { + const envelope = success(payload); + console.log(serialize(envelope)); + process.exit(actionable ? EXIT_CODES.ok : EXIT_CODES.incompatible); + } + + const paint = actionable ? chalk.green : chalk.red; + console.log(`\n${paint(actionable ? '✓' : '✗')} ${slug} on ${answer.runtime}: ${paint(answer.status)}`); + if (answer.versions) console.log(chalk.dim(` declared range ${answer.versions}`)); + for (const reason of answer.reasons) { + console.log(` ${chalk.bold(reason.code)} ${reason.message}`); + } + if (answer.evidence) { + console.log( + chalk.dim( + ` evidence ${answer.evidence.probeId} by ${answer.evidence.verifier} at ${answer.evidence.verifiedAt}`, + ), + ); + } + if (!actionable) { + console.log( + chalk.dim( + '\nOnly `declared` and `verified` mean the skill will run. A missing declaration is not support.', + ), + ); + } + console.log(); + process.exit(actionable ? EXIT_CODES.ok : EXIT_CODES.incompatible); + } catch (error) { + const envelope = fromError(error); + if (useJson) console.log(serialize(envelope)); + else console.error(chalk.red(`\n✗ ${envelope.error.message}`)); + process.exit(exitCodeFor(envelope)); + } + }); diff --git a/packages/cli/src/commands/inspect.ts b/packages/cli/src/commands/inspect.ts new file mode 100644 index 0000000..4eb6b2f --- /dev/null +++ b/packages/cli/src/commands/inspect.ts @@ -0,0 +1,114 @@ +import { Command } from 'commander'; +import chalk from 'chalk'; +import ora from 'ora'; +import { fetchSkillInspection } from '../lib/skill-lookup.js'; +import type { SkillInspection } from '../lib/skill-lookup.js'; +import { exitCodeFor, fromError, serialize, success } from '../lib/output.js'; + +interface InspectOptions { + json?: boolean; + target?: string; +} + +const STATUS_COLOR: Record string> = { + verified: chalk.green, + declared: chalk.cyan, + unknown: chalk.yellow, + unsupported: chalk.red, + blocked: chalk.red, +}; + +function statusText(status: string, versions: string | null): string { + const paint = STATUS_COLOR[status] ?? chalk.white; + return `${paint(status)}${versions ? chalk.dim(` ${versions}`) : ''}`; +} + +function line(label: string, value: string | number | null | undefined): void { + if (value === null || value === undefined || value === '') return; + console.log(`${chalk.dim(label.padEnd(14))}${value}`); +} + +function render(inspection: SkillInspection, slug: string): void { + const skill = inspection.skill; + console.log(chalk.bold.green(`\n${skill.name}`)); + console.log(chalk.dim(` ${skill.author}/${skill.name} · ${slug}\n`)); + + line('Description', skill.description); + line('Category', skill.category); + line('Version', skill.version); + line('Rating', skill.avg_rating === null ? null : `${skill.avg_rating} (${skill.rating_count ?? 0})`); + line('Installs', skill.install_count); + line('Risk', skill.risk_label); + line('Source', skill.source_url); + line('Install', skill.install_command); + + console.log(chalk.bold('\nCompatibility')); + const declared = inspection.compatibility?.declared ?? []; + if (declared.length === 0) { + console.log(chalk.yellow(' No runtime is declared. An agent cannot assume support.')); + } else { + for (const entry of declared) { + console.log(` ${entry.runtime.padEnd(16)}${statusText(entry.status, entry.versions)}`); + } + } + + const target = inspection.compatibility?.target; + if (target) { + console.log(chalk.bold(`\nTarget ${target.runtime}`)); + line('Status', statusText(target.status, target.versions)); + if (target.scopes.length > 0) line('Scopes', target.scopes.join(', ')); + const required = target.requirements.filter((r) => !r.optional); + if (required.length > 0) line('Requires', required.map((r) => r.capability).join(', ')); + if (target.evidence) { + line('Evidence', `${target.evidence.probeId} by ${target.evidence.verifier} (${target.evidence.harness} ${target.evidence.harnessVersion})`); + line('Verified at', target.evidence.verifiedAt); + } else { + line('Evidence', chalk.yellow('none bound to this artifact')); + } + for (const reason of target.reasons) { + console.log(` ${chalk.dim('·')} ${chalk.bold(reason.code)} ${reason.message}`); + } + } + + const references = inspection.references ?? []; + const scripts = inspection.scripts ?? []; + + if (references.length > 0) { + console.log(chalk.bold(`\nReferences (${references.length})`)); + for (const ref of references) console.log(` ${ref.title}`); + } + if (scripts.length > 0) { + console.log(chalk.bold(`\nScripts (${scripts.length})`)); + for (const script of scripts) console.log(` ${script.name}`); + } + console.log(); +} + +export const inspectCommand = new Command('inspect') + .description("Inspect a skill's metadata, requirements, compatibility and evidence") + .argument('', 'slug, author/skill, or org/repo/skill') + .option('--target ', 'also answer for one specific runtime') + .option('--json', 'emit a machine-readable envelope instead of text') + .action(async (identifier: string, options: InspectOptions) => { + const useJson = options.json === true; + const spinner = useJson ? null : ora(`Inspecting ${identifier}...`).start(); + + try { + const { slug, inspection } = await fetchSkillInspection(identifier, options.target); + spinner?.stop(); + + const payload = { slug, skill: inspection.skill, compatibility: inspection.compatibility ?? { declared: [] } }; + if (useJson) { + const envelope = success(payload); + console.log(serialize(envelope)); + process.exit(exitCodeFor(envelope)); + } + render(inspection, slug); + } catch (error) { + spinner?.stop(); + const envelope = fromError(error); + if (useJson) console.log(serialize(envelope)); + else console.error(chalk.red(`\n✗ ${envelope.error.message}`)); + process.exit(exitCodeFor(envelope)); + } + }); diff --git a/packages/cli/src/commands/search.ts b/packages/cli/src/commands/search.ts index c022ffb..d8e5c90 100644 --- a/packages/cli/src/commands/search.ts +++ b/packages/cli/src/commands/search.ts @@ -1,70 +1,116 @@ import { Command } from 'commander'; import chalk from 'chalk'; import ora from 'ora'; -import { ApiError } from '../lib/api-client.js'; import { searchSkills } from '../lib/search-api.js'; +import type { CompatibilityFilterReport, SearchResult } from '../lib/search-api.js'; +import { exitCodeFor, fromError, serialize, success } from '../lib/output.js'; import { resolveAndUseSkill } from './use.js'; +interface SearchOptions { + use?: boolean; + compatible?: string; + json?: boolean; +} + +const STATUS_COLOR: Record string> = { + verified: chalk.green, + declared: chalk.cyan, + unknown: chalk.yellow, + unsupported: chalk.red, + blocked: chalk.red, +}; + +function compatibilityCell(skill: SearchResult, runtime: string): string { + const entry = skill.compatibility?.find((summary) => summary.runtime === runtime); + if (!entry) return chalk.dim('—'); + const paint = STATUS_COLOR[entry.status] ?? chalk.white; + return paint(entry.status); +} + +/** States plainly what the filter did, so a short page is never a mystery. */ +function printFilterReport(report: CompatibilityFilterReport): void { + console.log( + chalk.dim( + `\nCompatibility filter '${report.runtime}': ${report.matched} matched, ` + + `${report.undecided} declare nothing, ${report.refused} refuse it ` + + `(of ${report.evaluated} examined).`, + ), + ); + if (report.poolExhausted) { + console.log(chalk.yellow(' The candidate pool filled up; more matches may exist.')); + } +} + export const searchCommand = new Command('search') .description('Search for skills in the SkillX marketplace') .argument('', 'Search query') .option('-u, --use', 'Auto-pick the top result and show its details') - .action(async (query: string, options: { use?: boolean }) => { - const spinner = ora('Searching for skills...').start(); + .option('-c, --compatible ', 'only return skills that declare support for this runtime') + .option('--json', 'emit a machine-readable envelope instead of text') + .action(async (query: string, options: SearchOptions) => { + const useJson = options.json === true; + const spinner = useJson ? null : ora('Searching for skills...').start(); try { - const results = await searchSkills(query); - spinner.stop(); + const outcome = await searchSkills(query, { + ...(options.compatible ? { compatible: options.compatible } : {}), + }); + spinner?.stop(); + + if (useJson) { + const envelope = success({ + query, + results: outcome.results, + count: outcome.results.length, + ...(outcome.compatibilityFilter ? { compatibilityFilter: outcome.compatibilityFilter } : {}), + ...(outcome.note ? { note: outcome.note } : {}), + }); + console.log(serialize(envelope)); + process.exit(exitCodeFor(envelope)); + } - if (results.length === 0) { + if (outcome.results.length === 0) { console.log(chalk.yellow('\nNo skills found matching your query.')); - console.log(chalk.dim('Try different search terms or browse all skills at https://skillx.sh')); + if (outcome.note) console.log(chalk.dim(outcome.note)); + else console.log(chalk.dim('Try different search terms or browse all skills at https://skillx.sh')); + if (outcome.compatibilityFilter) printFilterReport(outcome.compatibilityFilter); return; } - // --use flag: auto-pick top result and show details if (options.use) { - const top = results[0]; + const top = outcome.results[0]; console.log(chalk.dim(`Top result for "${query}": ${chalk.cyan(`${top.author}/${top.name}`)}\n`)); await resolveAndUseSkill(`${top.author}/${top.name}`, { raw: false }); return; } - console.log(chalk.bold.green(`\n✓ Found ${results.length} skill(s)\n`)); - - const colWidths = { skill: 40, category: 15, rating: 8, description: 40 }; + console.log(chalk.bold.green(`\n✓ Found ${outcome.results.length} skill(s)\n`)); - console.log( - chalk.bold( - `${padRight('SKILL', colWidths.skill)} ${padRight('CATEGORY', colWidths.category)} ${padRight('RATING', colWidths.rating)} DESCRIPTION` - ) - ); - console.log(chalk.dim('─'.repeat(105))); + const runtime = options.compatible; + const header = `${padRight('SKILL', 40)} ${padRight('CATEGORY', 15)} ${padRight('RATING', 8)}${runtime ? ` ${padRight(runtime.toUpperCase(), 12)}` : ''} DESCRIPTION`; + console.log(chalk.bold(header)); + console.log(chalk.dim('─'.repeat(header.length))); - results.forEach((skill) => { + outcome.results.forEach((skill) => { const displayId = `${skill.author}/${skill.name}`; - const skillCol = chalk.cyan(padRight(displayId, colWidths.skill)); - const categoryCol = chalk.magenta(padRight(skill.category, colWidths.category)); const rating = skill.avg_rating ?? 0; - const ratingCol = chalk.yellow(padRight(`⭐ ${rating.toFixed(1)}`, colWidths.rating)); - const descCol = truncate(skill.description, colWidths.description); - - console.log(`${skillCol} ${categoryCol} ${ratingCol} ${descCol}`); + const cols = [ + chalk.cyan(padRight(displayId, 40)), + chalk.magenta(padRight(skill.category, 15)), + chalk.yellow(padRight(`⭐ ${rating.toFixed(1)}`, 8)), + ]; + if (runtime) cols.push(padRight(compatibilityCell(skill, runtime), 12)); + console.log(`${cols.join(' ')} ${truncate(skill.description, 40)}`); }); + if (outcome.compatibilityFilter) printFilterReport(outcome.compatibilityFilter); console.log(chalk.dim(`\nUse ${chalk.cyan('skillx use ')} to view and install a skill`)); } catch (error) { - spinner.stop(); - - if (error instanceof ApiError) { - console.error(chalk.red(`\n✗ API Error: ${error.message}`)); - } else if (error instanceof Error) { - console.error(chalk.red(`\n✗ Network Error: ${error.message}`)); - console.error(chalk.dim('Check your internet connection or try again later.')); - } else { - console.error(chalk.red('\n✗ An unexpected error occurred')); - } - process.exit(1); + spinner?.stop(); + const envelope = fromError(error); + if (useJson) console.log(serialize(envelope)); + else console.error(chalk.red(`\n✗ ${envelope.error.message}`)); + process.exit(exitCodeFor(envelope)); } }); diff --git a/packages/cli/src/commands/use.ts b/packages/cli/src/commands/use.ts index 5535d6a..a6fd6e6 100644 --- a/packages/cli/src/commands/use.ts +++ b/packages/cli/src/commands/use.ts @@ -213,7 +213,7 @@ async function handleRegisterResult( /** Search for a keyword and use the top result */ async function searchAndUse(query: string, options: DisplayOptions): Promise { const spinner = ora(`Searching for "${query}"...`).start(); - const results = await searchSkills(query); + const { results } = await searchSkills(query); spinner.stop(); if (results.length === 0) { diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 0f8fcb4..e3f9d35 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -3,6 +3,8 @@ import { Command } from 'commander'; import { searchCommand } from './commands/search.js'; import { useCommand } from './commands/use.js'; import { findCommand } from './commands/find.js'; +import { inspectCommand } from './commands/inspect.js'; +import { checkCommand } from './commands/check.js'; import { reportCommand } from './commands/report.js'; import { configCommand } from './commands/config.js'; import { publishCommand } from './commands/publish.js'; @@ -21,6 +23,8 @@ program program.addCommand(searchCommand); program.addCommand(useCommand); program.addCommand(findCommand); +program.addCommand(inspectCommand); +program.addCommand(checkCommand); program.addCommand(reportCommand); program.addCommand(configCommand); program.addCommand(publishCommand); diff --git a/packages/cli/src/lib/output.test.ts b/packages/cli/src/lib/output.test.ts new file mode 100644 index 0000000..b04ff8c --- /dev/null +++ b/packages/cli/src/lib/output.test.ts @@ -0,0 +1,93 @@ +/** + * The CLI result envelope and its exit codes. + * + * An agent branches on the exit code, so the codes have to stay distinguishable + * and stay documented. The four cases below are the ones a caller makes different + * decisions about: the skill is absent, the credentials are wrong, the network is + * down, or the target is not compatible. + */ + +import { describe, expect, it } from "vitest"; +import { ApiError } from "./api-client.js"; +import { + EXIT_CODES, + exitCodeFor, + failure, + fromError, + serialize, + success, +} from "./output.js"; + +describe("exit codes", () => { + it("uses distinct codes for decisions a caller makes differently", () => { + const codes = Object.values(EXIT_CODES); + expect(new Set(codes).size).toBe(codes.length); + }); + + it("is 0 for success", () => { + expect(exitCodeFor(success({ anything: true }))).toBe(EXIT_CODES.ok); + }); + + it("is 4 for an incompatible target, which is not a generic failure", () => { + expect(exitCodeFor(failure("incompatible", "no"))).toBe(EXIT_CODES.incompatible); + }); + + it("is 2 for a usage error and 3 for a missing skill", () => { + expect(exitCodeFor(failure("usage_error", "no"))).toBe(EXIT_CODES.usage); + expect(exitCodeFor(failure("not_found", "no"))).toBe(EXIT_CODES.notFound); + }); +}); + +describe("fromError", () => { + it("maps a 404 to not_found", () => { + const envelope = fromError(new ApiError(404, "API error 404: {}")); + expect(envelope.error.code).toBe("not_found"); + expect(exitCodeFor(envelope)).toBe(EXIT_CODES.notFound); + }); + + it.each([401, 403])("maps %s to auth_error with a hint", (status) => { + const envelope = fromError(new ApiError(status, "denied")); + expect(envelope.error.code).toBe("auth_error"); + expect(envelope.error.hint).toContain("apiKey"); + expect(exitCodeFor(envelope)).toBe(EXIT_CODES.auth); + }); + + it("maps a fetch TypeError to network_error", () => { + const envelope = fromError(new TypeError("fetch failed")); + expect(envelope.error.code).toBe("network_error"); + expect(exitCodeFor(envelope)).toBe(EXIT_CODES.network); + }); + + it("maps any other API status to api_error", () => { + expect(fromError(new ApiError(500, "boom")).error.code).toBe("api_error"); + }); + + it("maps an unknown throw to unexpected_error without losing the message", () => { + const envelope = fromError(new Error("something odd")); + expect(envelope.error.code).toBe("unexpected_error"); + expect(envelope.error.message).toBe("something odd"); + }); + + it("survives a non-Error throw", () => { + expect(fromError("plain string").error.code).toBe("unexpected_error"); + }); +}); + +describe("serialize", () => { + it("emits an envelope a program can parse", () => { + const parsed = JSON.parse(serialize(success({ count: 2 }))); + expect(parsed).toEqual({ ok: true, data: { count: 2 } }); + }); + + it("keeps details and hint out of the envelope when they were not supplied", () => { + const parsed = JSON.parse(serialize(failure("api_error", "boom"))); + expect(parsed).toEqual({ ok: false, error: { code: "api_error", message: "boom" } }); + }); + + it("carries machine-readable details when present", () => { + const parsed = JSON.parse( + serialize(failure("incompatible", "blocked", { details: { reasonCodes: ["COMPAT_RANGE_MISMATCH"] } })), + ); + expect(parsed.error.details.reasonCodes).toEqual(["COMPAT_RANGE_MISMATCH"]); + }); +}); diff --git a/packages/cli/src/lib/output.ts b/packages/cli/src/lib/output.ts new file mode 100644 index 0000000..ea7f6f0 --- /dev/null +++ b/packages/cli/src/lib/output.ts @@ -0,0 +1,129 @@ +/** + * CLI result envelope and exit codes. + * + * Agents drive this CLI, so every command has to answer in a shape a program can + * read: one envelope, one error vocabulary, and an exit code that distinguishes + * "you called me wrong" from "the skill is not compatible" from "the network was + * down". A caller must be able to branch without parsing human text. + * + * Human-readable output stays the default; `--json` selects the envelope. + */ + +import { ApiError } from './api-client.js'; + +export const EXIT_CODES = { + ok: 0, + /** Unexpected failure, or a failure the API reported without a better class. */ + error: 1, + /** The command was called with invalid input. */ + usage: 2, + /** The skill does not exist. */ + notFound: 3, + /** The skill exists but cannot run on the requested target. */ + incompatible: 4, + /** Credentials are missing or rejected. */ + auth: 5, + /** The API could not be reached. */ + network: 6, +} as const; + +export type ExitCode = (typeof EXIT_CODES)[keyof typeof EXIT_CODES]; + +export type CliErrorCode = + | 'usage_error' + | 'not_found' + | 'incompatible' + | 'auth_error' + | 'network_error' + | 'api_error' + | 'unexpected_error'; + +const EXIT_BY_CODE: Record = { + usage_error: EXIT_CODES.usage, + not_found: EXIT_CODES.notFound, + incompatible: EXIT_CODES.incompatible, + auth_error: EXIT_CODES.auth, + network_error: EXIT_CODES.network, + api_error: EXIT_CODES.error, + unexpected_error: EXIT_CODES.error, +}; + +export interface CliSuccess { + ok: true; + data: T; +} + +export interface CliFailure { + ok: false; + error: { + code: CliErrorCode; + message: string; + /** Machine-readable context, e.g. the failing target or a reason code. */ + details?: unknown; + /** What the caller should do next, when there is a concrete action. */ + hint?: string; + }; +} + +export type CliEnvelope = CliSuccess | CliFailure; + +export function success(data: T): CliSuccess { + return { ok: true, data }; +} + +export function failure( + code: CliErrorCode, + message: string, + extra: { details?: unknown; hint?: string } = {}, +): CliFailure { + return { + ok: false, + error: { + code, + message, + ...(extra.details !== undefined ? { details: extra.details } : {}), + ...(extra.hint ? { hint: extra.hint } : {}), + }, + }; +} + +export function exitCodeFor(envelope: CliEnvelope): ExitCode { + return envelope.ok ? EXIT_CODES.ok : EXIT_BY_CODE[envelope.error.code]; +} + +/** + * Maps a thrown error onto the envelope vocabulary. + * + * A 404 is "the skill does not exist" and a 401/403 is "your credentials are + * wrong"; both are decisions a caller makes differently, so they do not share a + * code with a generic API failure. + */ +export function fromError(error: unknown): CliFailure { + if (error instanceof ApiError) { + if (error.status === 404) { + return failure('not_found', 'The requested skill was not found.', { details: { status: 404 } }); + } + if (error.status === 401 || error.status === 403) { + return failure('auth_error', 'The API rejected this request as unauthenticated.', { + details: { status: error.status }, + hint: 'Run `skillx config set apiKey ` or check the key has not been revoked.', + }); + } + return failure('api_error', error.message, { details: { status: error.status } }); + } + + if (error instanceof TypeError) { + // `fetch` rejects with TypeError when the host cannot be reached. + return failure('network_error', error.message, { + hint: 'Check the network connection and the configured base URL.', + }); + } + + if (error instanceof Error) return failure('unexpected_error', error.message); + return failure('unexpected_error', 'An unknown error occurred.'); +} + +/** Serializes an envelope for stdout. */ +export function serialize(envelope: CliEnvelope): string { + return JSON.stringify(envelope, null, 2); +} diff --git a/packages/cli/src/lib/search-api.ts b/packages/cli/src/lib/search-api.ts index 3edc44f..f34c11e 100644 --- a/packages/cli/src/lib/search-api.ts +++ b/packages/cli/src/lib/search-api.ts @@ -1,5 +1,14 @@ import { apiRequest } from './api-client.js'; +export interface CompatibilitySummary { + runtime: string; + status: string; + versions: string | null; + reasonCodes: string[]; + verifiedAt: string | null; + probeId: string | null; +} + export interface SearchResult { slug: string; name: string; @@ -7,18 +16,55 @@ export interface SearchResult { category: string; avg_rating: number | null; description: string; + compatibility?: CompatibilitySummary[]; } -interface SearchResponse { +/** + * What a `--compatible` run did, so a caller never has to guess why the page is + * short. `undecided` counts listings that declare nothing, which is not support. + */ +export interface CompatibilityFilterReport { + runtime: string; + evaluated: number; + matched: number; + undecided: number; + refused: number; + poolExhausted: boolean; +} + +export interface SearchOutcome { results: SearchResult[]; - count: number; + compatibilityFilter?: CompatibilityFilterReport; + note?: string; +} + +interface SearchResponse { + results?: SearchResult[]; + count?: number; + compatibilityFilter?: CompatibilityFilterReport; + note?: string; +} + +export interface SearchOptions { + /** Runtime name; `runtime@version` is accepted and the version is not evaluated. */ + compatible?: string; + limit?: number; } -/** Search the SkillX API and return results */ -export async function searchSkills(query: string): Promise { +/** Search the SkillX API and return the page plus any filter report. */ +export async function searchSkills(query: string, options: SearchOptions = {}): Promise { + const body: Record = { query }; + if (options.compatible) body.compatible = options.compatible; + if (options.limit) body.limit = options.limit; + const response = await apiRequest('/api/search', { method: 'POST', - body: JSON.stringify({ query }), + body: JSON.stringify(body), }); - return response.results || []; + + return { + results: response.results ?? [], + ...(response.compatibilityFilter ? { compatibilityFilter: response.compatibilityFilter } : {}), + ...(response.note ? { note: response.note } : {}), + }; } diff --git a/packages/cli/src/lib/skill-lookup.test.ts b/packages/cli/src/lib/skill-lookup.test.ts new file mode 100644 index 0000000..1ce60b6 --- /dev/null +++ b/packages/cli/src/lib/skill-lookup.test.ts @@ -0,0 +1,80 @@ +/** + * Read-only skill lookup. + * + * Two properties matter: the slug queried is the same one `use` resolves, and the + * `--target` value reaches the API so the version-aware answer is the server's, + * not one the CLI invents. + */ + +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const h = vi.hoisted(() => ({ calls: [] as string[] })); + +vi.mock("./api-client.js", () => ({ + apiRequest: async (endpoint: string) => { + h.calls.push(endpoint); + return { skill: { slug: "x", name: "X" } }; + }, +})); + +import { fetchSkillInspection, isActionable } from "./skill-lookup.js"; + +beforeEach(() => { + h.calls.length = 0; +}); + +describe("fetchSkillInspection", () => { + it("queries the mapped slug for a two-part identifier", async () => { + const result = await fetchSkillInspection("vercel-labs/find-skills"); + expect(h.calls).toEqual(["/api/skills/vercel-labs-find-skills"]); + expect(result.slug).toBe("vercel-labs-find-skills"); + }); + + it("pins a repo-path identifier to its full source identity, as use does", async () => { + await fetchSkillInspection("org/repo/skills/deep/skill"); + expect(h.calls).toEqual(["/api/skills/org-skill?repo=org%2Frepo&path=skills%2Fdeep%2Fskill"]); + }); + + it("appends the target after a source identity query", async () => { + await fetchSkillInspection("org/repo/skill", "agentkit"); + expect(h.calls).toEqual(["/api/skills/org-skill?repo=org%2Frepo&path=skill&target=agentkit"]); + }); + + it("passes a bare target through", async () => { + await fetchSkillInspection("find-skills", "agentkit"); + expect(h.calls).toEqual(["/api/skills/find-skills?target=agentkit"]); + }); + + it("url-encodes a versioned target", async () => { + await fetchSkillInspection("find-skills", "agentkit@1.4.0"); + expect(h.calls).toEqual(["/api/skills/find-skills?target=agentkit%401.4.0"]); + }); + + it("omits the query string when no target was asked for", async () => { + await fetchSkillInspection("find-skills"); + expect(h.calls[0]).not.toContain("?"); + }); + + it("refuses a search phrase instead of guessing a slug", async () => { + await expect(fetchSkillInspection("ui ux design")).rejects.toThrow(/search phrase/); + expect(h.calls).toEqual([]); + }); +}); + +describe("isActionable", () => { + it("permits action only for declared and verified", () => { + expect(isActionable("declared")).toBe(true); + expect(isActionable("verified")).toBe(true); + }); + + it("never permits action for an undecidable or refused answer", () => { + expect(isActionable("unknown")).toBe(false); + expect(isActionable("unsupported")).toBe(false); + expect(isActionable("blocked")).toBe(false); + }); + + it("treats a missing answer as not actionable", () => { + expect(isActionable(null)).toBe(false); + expect(isActionable(undefined)).toBe(false); + }); +}); diff --git a/packages/cli/src/lib/skill-lookup.ts b/packages/cli/src/lib/skill-lookup.ts new file mode 100644 index 0000000..fba78fe --- /dev/null +++ b/packages/cli/src/lib/skill-lookup.ts @@ -0,0 +1,145 @@ +/** + * Read-only skill lookup shared by `inspect` and `check`. + * + * Both commands answer questions about a skill that already exists, so neither + * registers or scans anything: a read must not mutate the catalog. Resolution + * uses `use`'s identifier parsing and endpoint builder, so a slug (or source + * identity) that `use` finds is the one `inspect` reports. + * + * The compatibility shapes here mirror the API's JSON. They are declared locally + * because this package is a thin HTTP client and does not depend on + * `@skillx/contracts`; a contract test guards the two from drifting. + */ + +import { apiRequest } from './api-client.js'; +import { buildSkillEndpoint, parseIdentifier, parseRepoPath } from '../commands/use.js'; +import type { SkillReference, SkillScript } from '../commands/use-display.js'; + +export interface CompatibilityRequirement { + capability: string; + optional?: boolean; +} + +export interface CompatibilityEvidence { + harness: string; + harnessVersion: string; + probeId: string; + verifiedAt: string; + verifier: string; + os?: string; +} + +export interface CompatibilityReason { + code: string; + message: string; + detail?: string; +} + +/** The version-aware answer for one requested target. */ +export interface CompatibilityAnswer { + runtime: string; + status: string; + versions: string | null; + scopes: string[]; + requirements: CompatibilityRequirement[]; + evidence: CompatibilityEvidence | null; + reasons: CompatibilityReason[]; +} + +/** Compact per-runtime summary carried on listing payloads. */ +export interface CompatibilitySummary { + runtime: string; + status: string; + versions: string | null; + reasonCodes: string[]; + verifiedAt: string | null; + probeId: string | null; +} + +export interface SkillCompatibility { + declared: CompatibilitySummary[]; + target?: CompatibilityAnswer; +} + +/** + * The public detail payload. + * + * These are the fields the API returns after the protected boundary runs: the + * boundary drops `content` and nothing else, so the metadata below is always + * present and `content` is present only when the viewer may read it. + */ +export interface SkillMetadata { + slug: string; + name: string; + description: string; + author: string; + category: string; + version: string | null; + source_url: string | null; + install_command: string | null; + risk_label: string | null; + is_paid: boolean | null; + price_cents: number | null; + avg_rating: number | null; + rating_count: number | null; + install_count: number | null; + favorite_count: number | null; + net_votes: number | null; + updated_at: string | number | null; + content?: string; +} + +export interface SkillInspection { + skill: SkillMetadata; + references?: SkillReference[]; + scripts?: SkillScript[]; + compatibility?: SkillCompatibility; +} + +export interface InspectionResult { + /** The slug actually queried, so a caller can log what it asked for. */ + slug: string; + inspection: SkillInspection; +} + +/** + * Fetches one skill's public detail, optionally asking for a target answer. + * + * Only `declared` and `verified` mean a skill will run. `unknown` is the absence + * of a declaration, which is why callers must read the reason codes rather than + * treat a missing entry as permission. + */ +export async function fetchSkillInspection( + identifier: string, + target?: string, +): Promise { + const { slug, identity } = resolveLookupTarget(identifier); + const endpoint = buildSkillEndpoint(slug, identity); + const query = target ? `${endpoint.includes('?') ? '&' : '?'}target=${encodeURIComponent(target)}` : ''; + const inspection = await apiRequest(`${endpoint}${query}`); + + return { slug, inspection }; +} + +/** Maps an identifier onto the slug (and source identity) `use` would query. */ +function resolveLookupTarget(identifier: string): { slug: string; identity?: { repo: string; path: string } } { + const parsed = parseIdentifier(identifier); + + switch (parsed.type) { + case 'search': + throw new Error( + `"${identifier}" looks like a search phrase. Pass a slug, author/skill, or org/repo/skill.`, + ); + case 'repo-path': + return parseRepoPath(parsed.parts); + case 'two-part': + return { slug: `${parsed.parts[0]}-${parsed.parts[1]}`.toLowerCase() }; + default: + return { slug: parsed.parts[0] }; + } +} + +/** True when the answer permits action without further evidence. */ +export function isActionable(status: string | null | undefined): boolean { + return status === 'declared' || status === 'verified'; +} diff --git a/packages/contracts/src/catalog-compatibility.test.ts b/packages/contracts/src/catalog-compatibility.test.ts new file mode 100644 index 0000000..a3dac5e --- /dev/null +++ b/packages/contracts/src/catalog-compatibility.test.ts @@ -0,0 +1,172 @@ +/** + * Catalog-level compatibility resolution. + * + * The precedence rule is the thing under test: release declaration beats listing + * declaration, a listing can never reach `verified`, and a release declaration + * that arrives without a release is ignored rather than trusted. + */ + +import { describe, expect, it } from "vitest"; +import { + isCompatibilityActionable, + resolveCatalogCompatibility, + resolveDeclaredRuntimes, + selectDeclaration, + toCompatibilitySummary, + type CatalogCompatibilityInputs, +} from "./compatibility/catalog"; +import type { VerificationEvidence } from "./verification-evidence"; + +const DIGEST = `sha256:${"a".repeat(64)}`; + +function evidence(overrides: Partial = {}): VerificationEvidence { + return { + releaseDigest: DIGEST, + harness: "agentkit", + harnessVersion: "1.4.0", + probeId: "probe_001", + verifiedAt: "2026-09-20T10:00:00.000Z", + verifier: "ci", + ...overrides, + }; +} + +const LISTING = { + agentkit: { status: "declared" as const, versions: ">=1.0.0 <2.0.0" }, +}; + +const RELEASE = { + agentkit: { status: "declared" as const, versions: ">=2.0.0" }, +}; + +describe("selectDeclaration — the single precedence rule", () => { + it("prefers the release declaration when the row really has a release", () => { + const selected = selectDeclaration({ hasRelease: true, releaseDeclaration: RELEASE, listingDeclaration: LISTING }); + expect(selected.tier).toBe("release"); + expect(selected.declaration).toBe(RELEASE); + }); + + it("falls back to the listing declaration without a release", () => { + const selected = selectDeclaration({ listingDeclaration: LISTING }); + expect(selected.tier).toBe("listing"); + expect(selected.declaration).toBe(LISTING); + }); + + it("ignores a release declaration when the row has no release", () => { + // Trusting this would let a surface claim release-grade provenance it cannot prove. + const selected = selectDeclaration({ hasRelease: false, releaseDeclaration: RELEASE, listingDeclaration: LISTING }); + expect(selected.tier).toBe("listing"); + expect(selected.declaration).toBe(LISTING); + }); + + it("reports no tier when neither tier declares anything", () => { + expect(selectDeclaration({}).tier).toBeNull(); + expect(selectDeclaration({ hasRelease: true, evidence: [evidence()] }).tier).toBeNull(); + }); +}); + +describe("resolveCatalogCompatibility — listing tier", () => { + const inputs: CatalogCompatibilityInputs = { listingDeclaration: LISTING }; + + it("resolves a declared listing to `declared`", () => { + const result = resolveCatalogCompatibility({ runtime: "agentkit" }, inputs); + expect(result.status).toBe("declared"); + expect(result.versions).toBe(">=1.0.0 <2.0.0"); + }); + + it("never reaches `verified` from matching evidence, because a listing has no digest", () => { + const result = resolveCatalogCompatibility({ runtime: "agentkit" }, { + ...inputs, + evidence: [evidence({ releaseDigest: "sha256:" + "b".repeat(64) })], + }); + expect(result.status).toBe("declared"); + expect(result.reasons.map((reason) => reason.code)).toContain("COMPAT_EVIDENCE_UNBOUND"); + }); + + it("stays unknown for a runtime the listing never declared", () => { + const result = resolveCatalogCompatibility({ runtime: "claude-code" }, inputs); + expect(result.status).toBe("unknown"); + expect(result.reasons[0]?.code).toBe("COMPAT_DECLARATION_MISSING"); + }); + + it("blocks a version outside the declared range", () => { + const result = resolveCatalogCompatibility({ runtime: "agentkit", version: "2.5.0" }, inputs); + expect(result.status).toBe("blocked"); + expect(result.reasons.map((reason) => reason.code)).toContain("COMPAT_RANGE_MISMATCH"); + }); +}); + +describe("resolveCatalogCompatibility — release tier", () => { + const inputs: CatalogCompatibilityInputs = { + hasRelease: true, + releaseDeclaration: RELEASE, + releaseDigest: DIGEST, + evidence: [evidence()], + }; + + it("reaches `verified` only with digest-bound evidence", () => { + const result = resolveCatalogCompatibility({ runtime: "agentkit" }, inputs); + expect(result.status).toBe("verified"); + expect(result.evidence?.probeId).toBe("probe_001"); + }); + + it("stays declared when the evidence is for a different digest", () => { + const result = resolveCatalogCompatibility({ runtime: "agentkit" }, { + ...inputs, + evidence: [evidence({ releaseDigest: "sha256:" + "c".repeat(64) })], + }); + expect(result.status).toBe("declared"); + expect(result.reasons.map((reason) => reason.code)).toContain("COMPAT_EVIDENCE_DIGEST_MISMATCH"); + }); + + it("uses the release range, not the listing range", () => { + const result = resolveCatalogCompatibility({ runtime: "agentkit", version: "2.5.0" }, { + ...inputs, + listingDeclaration: LISTING, + }); + expect(result.status).toBe("verified"); + }); +}); + +describe("resolveDeclaredRuntimes", () => { + it("enumerates exactly the declared runtimes", () => { + const inputs: CatalogCompatibilityInputs = { + listingDeclaration: { ...LISTING, "claude-code": { status: "unsupported" } }, + }; + const resolved = resolveDeclaredRuntimes(inputs).map((entry) => `${entry.runtime}:${entry.status}`); + expect(resolved.sort()).toEqual(["agentkit:declared", "claude-code:unsupported"]); + }); + + it("returns nothing when no tier declares anything", () => { + expect(resolveDeclaredRuntimes({})).toEqual([]); + }); +}); + +describe("toCompatibilitySummary", () => { + it("carries the reason codes an agent needs to explain a refusal", () => { + const result = resolveCatalogCompatibility({ runtime: "agentkit", version: "9.9.9" }, { + listingDeclaration: LISTING, + }); + const summary = toCompatibilitySummary(result); + expect(summary).toMatchObject({ runtime: "agentkit", status: "blocked" }); + expect(summary.reasonCodes).toContain("COMPAT_RANGE_MISMATCH"); + expect(summary.verifiedAt).toBeNull(); + }); + + it("exposes the probe identity only for a verified answer", () => { + const declared = toCompatibilitySummary( + resolveCatalogCompatibility({ runtime: "agentkit" }, { listingDeclaration: LISTING }), + ); + expect(declared.probeId).toBeNull(); + }); +}); + +describe("isCompatibilityActionable", () => { + it("treats only declared and verified as actionable", () => { + expect(isCompatibilityActionable("declared")).toBe(true); + expect(isCompatibilityActionable("verified")).toBe(true); + expect(isCompatibilityActionable("unknown")).toBe(false); + expect(isCompatibilityActionable("unsupported")).toBe(false); + expect(isCompatibilityActionable("blocked")).toBe(false); + }); +}); diff --git a/packages/contracts/src/compatibility.test.ts b/packages/contracts/src/compatibility.test.ts index 3c69722..9296e4a 100644 --- a/packages/contracts/src/compatibility.test.ts +++ b/packages/contracts/src/compatibility.test.ts @@ -167,4 +167,19 @@ describe("compatibility engine — five states", () => { it("validates the shipped compatibility fixture", () => { expect(validateCompatibilityDeclaration(COMPATIBILITY_DECLARATION_FIXTURE).valid).toBe(true); }); + + it("pins the vocabulary, because the CLI mirrors it by hand", () => { + // `packages/cli/src/lib/skill-lookup.ts` does not import this package: its + // tsconfig sets `rootDir: ./src` and its bundler does not resolve a + // cross-package path, so `isActionable` enumerates these literals instead. + // Adding a status here would otherwise leave that mirror silently wrong, and + // no test in either package would notice. + expect([...COMPATIBILITY_STATUSES].sort()).toEqual([ + "blocked", + "declared", + "unknown", + "unsupported", + "verified", + ]); + }); }); diff --git a/packages/contracts/src/compatibility/catalog.ts b/packages/contracts/src/compatibility/catalog.ts new file mode 100644 index 0000000..7ed89a3 --- /dev/null +++ b/packages/contracts/src/compatibility/catalog.ts @@ -0,0 +1,128 @@ +/** + * Catalog-level compatibility resolution. + * + * Phase 0 stores compatibility declarations on an immutable release + * (`package_releases.compatibility_json`). Every catalog row that exists today is + * a legacy *listing*: it has no release, no artifact, and no digest, so a surface + * built only on release declarations would answer `unknown` for the entire + * catalog and every filter would be inert. + * + * This module owns the ONE precedence rule that bridges the two storage tiers, so + * web, CLI, and MCP cannot each invent their own: + * + * 1. an immutable release declaration wins, and only it can reach `verified`; + * 2. otherwise the listing declaration is used, which can reach at most + * `declared` because a listing has no digest to bind evidence to; + * 3. neither present -> `unknown`. + * + * The engine still decides; this module only chooses the inputs. Phase 2 deletes + * step 2 when the immutable registry lands, and that removal is local to here. + */ + +import { normalizeCompatibility } from "./normalize"; +import type { + CompatibilityDeclarationMap, + CompatibilityReasonCode, + CompatibilityStatus, + CompatibilityTarget, + NormalizedCompatibility, +} from "./types"; +import type { VerificationEvidence } from "../verification-evidence"; + +/** Which storage tier the declaration was read from. `null` means neither had one. */ +export type DeclarationTier = "release" | "listing"; + +export interface CatalogCompatibilityInputs { + /** Declaration on an immutable release, when the row has one. */ + releaseDeclaration?: CompatibilityDeclarationMap | null; + /** Declaration on the mutable listing. */ + listingDeclaration?: CompatibilityDeclarationMap | null; + /** True only when the row is backed by an immutable release. */ + hasRelease?: boolean; + /** The release artifact digest. Required before any `verified` answer. */ + releaseDigest?: string | null; + /** Persisted `release_verification_evidence` rows. */ + evidence?: VerificationEvidence[]; +} + +/** + * Picks the declaration the engine should consume. + * + * A release declaration is only reachable when the row actually has a release; + * passing one without `hasRelease` would let a surface claim release-grade + * provenance it cannot prove. + */ +export function selectDeclaration(inputs: CatalogCompatibilityInputs): { + declaration: CompatibilityDeclarationMap | undefined; + tier: DeclarationTier | null; +} { + if (inputs.hasRelease && inputs.releaseDeclaration) { + return { declaration: inputs.releaseDeclaration, tier: "release" }; + } + if (inputs.listingDeclaration) { + return { declaration: inputs.listingDeclaration, tier: "listing" }; + } + return { declaration: undefined, tier: null }; +} + +/** + * Resolves one target against the catalog inputs. + * + * A listing-tier answer is deliberately given no release digest, so the engine + * reports any evidence as `unbound` and the status cannot exceed `declared`. + */ +export function resolveCatalogCompatibility( + target: CompatibilityTarget, + inputs: CatalogCompatibilityInputs, +): NormalizedCompatibility { + const { declaration, tier } = selectDeclaration(inputs); + const digest = tier === "release" ? inputs.releaseDigest ?? undefined : undefined; + + return normalizeCompatibility(target, { + ...(declaration ? { declaration } : {}), + ...(inputs.evidence ? { evidence: inputs.evidence } : {}), + ...(digest ? { releaseDigest: digest } : {}), + }); +} + +/** Resolves every runtime named by the selected declaration. */ +export function resolveDeclaredRuntimes( + inputs: CatalogCompatibilityInputs, +): NormalizedCompatibility[] { + const { declaration } = selectDeclaration(inputs); + if (!declaration) return []; + return Object.keys(declaration).map((runtime) => resolveCatalogCompatibility({ runtime }, inputs)); +} + +/** Compact per-runtime result carried on search rows and listing payloads. */ +export interface CompatibilitySummary { + runtime: string; + status: CompatibilityStatus; + versions: string | null; + reasonCodes: CompatibilityReasonCode[]; + /** Present only for a digest-bound `verified` answer. */ + verifiedAt: string | null; + probeId: string | null; +} + +export function toCompatibilitySummary(result: NormalizedCompatibility): CompatibilitySummary { + return { + runtime: result.runtime, + status: result.status, + versions: result.versions, + reasonCodes: result.reasons.map((reason) => reason.code), + verifiedAt: result.status === "verified" ? result.evidence?.verifiedAt ?? null : null, + probeId: result.status === "verified" ? result.evidence?.probeId ?? null : null, + }; +} + +/** + * True when an agent may act on this answer without further evidence. + * + * `unknown` is never actionable: absence of a declaration is not a claim of + * support, and treating it as one is how an agent installs something that cannot + * run. + */ +export function isCompatibilityActionable(status: CompatibilityStatus): boolean { + return status === "declared" || status === "verified"; +} diff --git a/packages/contracts/src/compatibility/index.ts b/packages/contracts/src/compatibility/index.ts index d49da31..e43f2e5 100644 --- a/packages/contracts/src/compatibility/index.ts +++ b/packages/contracts/src/compatibility/index.ts @@ -5,3 +5,4 @@ export * from "./types"; export * from "./validate"; export * from "./normalize"; +export * from "./catalog"; From cd95125c74cfcf5621560628847a56c8d26ff19e Mon Sep 17 00:00:00 2001 From: Duy Nguyen Date: Thu, 15 Oct 2026 19:33:04 +0700 Subject: [PATCH 3/4] feat(web): serve agent-native discovery surfaces Add the read-only agent surfaces: - POST /api/mcp: MCP over JSON-RPC with search_packages, get_package, get_release and check_compatibility. get_release reads through the release access module as the anonymous viewer, so private, draft and quarantined releases are never served. Slug tools resolve aliases. Bodies over 64 KiB get 413, callers are counted on RELEASE_READ_LIMITER under an mcp: key, and handler failures return a generic internal_error while the cause is only logged. - /skills/.md: the skill as Markdown, always anonymous and publicly cached. React Router has no partial dynamic segment, so the worker entry matches the path before routing; a route-table test pins that. - /llms.txt and /llms-full.txt: a column-limited, bounded query with a probe row instead of a catalog count; ?limit= snaps to fixed sizes and the KV key uses the normalized size. - compatible filter on /api/search, compatibility on the detail API and page, WebMCP read tools, and the harness and publisher docs pages. Runtime ids are validated against one grammar and never echoed, and the pool-exhausted flag now holds for any filled pool. --- CLAUDE.md | 4 + .../app/components/compatibility-panel.tsx | 123 ++++++++++ .../app/components/copy-markdown-button.tsx | 58 +++++ apps/web/app/components/docs/docs-sub-nav.tsx | 4 +- .../app/components/risk-warning-banner.tsx | 32 +++ apps/web/app/components/webmcp-bridge.tsx | 27 +++ .../catalog/payload-boundary.guard.test.ts | 1 + .../lib/catalog/public-catalog-index.test.ts | 111 +++++++++ .../app/lib/catalog/public-catalog-index.ts | 89 +++++++ apps/web/app/lib/catalog/public-dto.ts | 2 + .../app/lib/catalog/skill-detail-data.test.ts | 97 ++++++++ apps/web/app/lib/catalog/skill-detail-data.ts | 8 + .../catalog-compatibility.test.ts | 124 ++++++++++ .../compatibility/catalog-compatibility.ts | 122 ++++++++++ apps/web/app/lib/markdown/llms-limit.test.ts | 70 ++++++ apps/web/app/lib/markdown/llms-limit.ts | 54 +++++ apps/web/app/lib/markdown/llms-txt.test.ts | 130 +++++++++++ apps/web/app/lib/markdown/llms-txt.ts | 124 ++++++++++ .../markdown/skill-markdown-handler.test.ts | 219 +++++++++++++++++ .../lib/markdown/skill-markdown-handler.ts | 87 +++++++ .../app/lib/markdown/skill-markdown.test.ts | 150 ++++++++++++ apps/web/app/lib/markdown/skill-markdown.ts | 155 ++++++++++++ apps/web/app/lib/mcp/args.ts | 30 +++ apps/web/app/lib/mcp/handlers-packages.ts | 105 +++++++++ apps/web/app/lib/mcp/handlers-releases.ts | 102 ++++++++ apps/web/app/lib/mcp/handlers.ts | 31 +++ apps/web/app/lib/mcp/mcp.test.ts | 211 +++++++++++++++++ apps/web/app/lib/mcp/protocol.ts | 125 ++++++++++ apps/web/app/lib/mcp/request-body.ts | 49 ++++ apps/web/app/lib/mcp/tools.ts | 126 ++++++++++ .../lib/parity/compatibility-parity.test.ts | 197 ++++++++++++++++ .../lib/search/compatibility-filter.test.ts | 197 ++++++++++++++++ .../app/lib/search/compatibility-filter.ts | 135 +++++++++++ apps/web/app/lib/search/search-executor.ts | 57 ++++- .../lib/search/search-result-projection.ts | 26 ++- apps/web/app/lib/webmcp/model-context.ts | 77 ++++++ apps/web/app/lib/webmcp/read-tools.ts | 114 +++++++++ apps/web/app/lib/webmcp/webmcp.test.ts | 173 ++++++++++++++ apps/web/app/root.tsx | 9 +- apps/web/app/routes-config.test.ts | 50 ++++ apps/web/app/routes.ts | 6 + apps/web/app/routes/api.mcp.test.ts | 182 +++++++++++++++ apps/web/app/routes/api.mcp.ts | 144 ++++++++++++ apps/web/app/routes/api.search.test.ts | 220 ++++++++++++++++++ apps/web/app/routes/api.search.ts | 52 ++++- apps/web/app/routes/api.skill-detail.ts | 15 ++ apps/web/app/routes/docs-harness.tsx | 167 +++++++++++++ apps/web/app/routes/docs-publishers.tsx | 161 +++++++++++++ apps/web/app/routes/llms-full-txt.ts | 53 +++++ apps/web/app/routes/llms-txt.ts | 51 ++++ apps/web/app/routes/skill-detail.tsx | 29 +-- apps/web/tests/db/mcp-alias-lookup.test.ts | 77 ++++++ .../tests/releases/mcp-get-release.test.ts | 114 +++++++++ apps/web/workers/app.ts | 7 + docs/api-reference.md | 49 +++- docs/cli-reference.md | 24 ++ 56 files changed, 4922 insertions(+), 34 deletions(-) create mode 100644 apps/web/app/components/compatibility-panel.tsx create mode 100644 apps/web/app/components/copy-markdown-button.tsx create mode 100644 apps/web/app/components/risk-warning-banner.tsx create mode 100644 apps/web/app/components/webmcp-bridge.tsx create mode 100644 apps/web/app/lib/catalog/public-catalog-index.test.ts create mode 100644 apps/web/app/lib/catalog/public-catalog-index.ts create mode 100644 apps/web/app/lib/catalog/skill-detail-data.test.ts create mode 100644 apps/web/app/lib/compatibility/catalog-compatibility.test.ts create mode 100644 apps/web/app/lib/compatibility/catalog-compatibility.ts create mode 100644 apps/web/app/lib/markdown/llms-limit.test.ts create mode 100644 apps/web/app/lib/markdown/llms-limit.ts create mode 100644 apps/web/app/lib/markdown/llms-txt.test.ts create mode 100644 apps/web/app/lib/markdown/llms-txt.ts create mode 100644 apps/web/app/lib/markdown/skill-markdown-handler.test.ts create mode 100644 apps/web/app/lib/markdown/skill-markdown-handler.ts create mode 100644 apps/web/app/lib/markdown/skill-markdown.test.ts create mode 100644 apps/web/app/lib/markdown/skill-markdown.ts create mode 100644 apps/web/app/lib/mcp/args.ts create mode 100644 apps/web/app/lib/mcp/handlers-packages.ts create mode 100644 apps/web/app/lib/mcp/handlers-releases.ts create mode 100644 apps/web/app/lib/mcp/handlers.ts create mode 100644 apps/web/app/lib/mcp/mcp.test.ts create mode 100644 apps/web/app/lib/mcp/protocol.ts create mode 100644 apps/web/app/lib/mcp/request-body.ts create mode 100644 apps/web/app/lib/mcp/tools.ts create mode 100644 apps/web/app/lib/parity/compatibility-parity.test.ts create mode 100644 apps/web/app/lib/search/compatibility-filter.test.ts create mode 100644 apps/web/app/lib/search/compatibility-filter.ts create mode 100644 apps/web/app/lib/webmcp/model-context.ts create mode 100644 apps/web/app/lib/webmcp/read-tools.ts create mode 100644 apps/web/app/lib/webmcp/webmcp.test.ts create mode 100644 apps/web/app/routes-config.test.ts create mode 100644 apps/web/app/routes/api.mcp.test.ts create mode 100644 apps/web/app/routes/api.mcp.ts create mode 100644 apps/web/app/routes/api.search.test.ts create mode 100644 apps/web/app/routes/docs-harness.tsx create mode 100644 apps/web/app/routes/docs-publishers.tsx create mode 100644 apps/web/app/routes/llms-full-txt.ts create mode 100644 apps/web/app/routes/llms-txt.ts create mode 100644 apps/web/tests/db/mcp-alias-lookup.test.ts create mode 100644 apps/web/tests/releases/mcp-get-release.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index 3fed869..ddcd069 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -81,6 +81,10 @@ pnpm db:migrate:remote # Apply migrations to remote D1 |---------|------|------| | `/` | home.tsx | None | | `/skills/:slug` | skill-detail.tsx | None | +| `/skills/.md` | lib/markdown/skill-markdown-handler.ts, dispatched in `workers/app.ts` (React Router has no partial dynamic segment) | None; always anonymous, public cache | +| `/llms.txt`, `/llms-full.txt` | llms-txt.ts, llms-full-txt.ts (`?limit=` snaps to fixed sizes; KV-cached per origin and size) | None; protected bodies withheld | +| `/docs/harness`, `/docs/publishers` | docs-harness.tsx, docs-publishers.tsx | None | +| `/api/mcp` | api.mcp.ts (read-only MCP over JSON-RPC: `search_packages`, `get_package`, `get_release`, `check_compatibility`; 64 KiB body cap, `RELEASE_READ_LIMITER` under `mcp:`) | Optional Key/Session; `get_release` is always anonymous | | `/profile` | profile.tsx | Session | | `/settings` | settings.tsx | Session | | `/api/auth/*` | auth-catchall.tsx | Better Auth | diff --git a/apps/web/app/components/compatibility-panel.tsx b/apps/web/app/components/compatibility-panel.tsx new file mode 100644 index 0000000..648fa6a --- /dev/null +++ b/apps/web/app/components/compatibility-panel.tsx @@ -0,0 +1,123 @@ +/** + * Compatibility badge and its evidence detail. + * + * The badge deliberately distinguishes `declared` from `verified`: a publisher + * saying a skill works is not the same as someone having probed that exact + * artifact. `unknown` is shown as an explicit state rather than hidden, because a + * visitor who sees no badge would otherwise assume support. + * + * Tokens: dark-only theme, mint accent, `bg-slate-900` surfaces (see + * docs/design-guidelines.md). + */ + +import { CheckCircle2, AlertTriangle, Ban, HelpCircle, FlaskConical } from "lucide-react"; +import type { CompatibilitySummary } from "@skillx/contracts"; + +interface BadgeTone { + label: string; + className: string; + Icon: typeof CheckCircle2; +} + +const TONES: Record = { + verified: { + label: "Verified", + className: "border-mint/40 bg-mint/10 text-mint", + Icon: FlaskConical, + }, + declared: { + label: "Declared", + className: "border-sky-400/40 bg-sky-400/10 text-sky-300", + Icon: CheckCircle2, + }, + unknown: { + label: "Unknown", + className: "border-amber-400/30 bg-amber-400/10 text-amber-300", + Icon: HelpCircle, + }, + unsupported: { + label: "Unsupported", + className: "border-red-400/30 bg-red-400/10 text-red-300", + Icon: Ban, + }, + blocked: { + label: "Blocked", + className: "border-red-400/30 bg-red-400/10 text-red-300", + Icon: AlertTriangle, + }, +}; + +const FALLBACK: BadgeTone = TONES.unknown; + +export function CompatibilityBadge({ summary }: { summary: CompatibilitySummary }) { + const tone = TONES[summary.status] ?? FALLBACK; + const { Icon } = tone; + + return ( + + + ); +} + +/** + * The panel under the badges. + * + * It states what each tier means, because a badge alone cannot tell a reader + * whether anyone actually ran the thing on the artifact they are about to install. + */ +export function CompatibilityPanel({ summaries }: { summaries: CompatibilitySummary[] }) { + if (summaries.length === 0) { + return ( +
+

Compatibility not declared

+

+ The publisher has not declared which agent runtimes this skill supports. That is not a claim + that it works everywhere — an agent should treat it as unknown until evidence exists. +

+
+ ); + } + + return ( +
+

Compatibility

+
+ {summaries.map((summary) => ( + + ))} +
+
+ {summaries.map((summary) => ( +
+
{summary.runtime}
+
+ {summary.verifiedAt ? ( + <> + Verified against this artifact on{" "} + + {summary.probeId ? <> · probe {summary.probeId} : null} + + ) : ( + <> + Declared by the publisher. No probe of this exact artifact is recorded + {summary.reasonCodes.length > 0 ? ( + <> · {summary.reasonCodes.join(", ")} + ) : null} + + )} +
+
+ ))} +
+
+ ); +} diff --git a/apps/web/app/components/copy-markdown-button.tsx b/apps/web/app/components/copy-markdown-button.tsx new file mode 100644 index 0000000..5b11a2f --- /dev/null +++ b/apps/web/app/components/copy-markdown-button.tsx @@ -0,0 +1,58 @@ +/** + * "Copy as Markdown" for the skill page. + * + * The button copies the response of the page's own `/skills/:slug.md` resource + * rather than assembling Markdown in the browser. That choice is the security + * property: the route applies the protected payload boundary server-side, so a + * viewer without entitlement copies the withheld-body document and cannot + * accidentally be handed a payload the server would not have served them. It + * also means there is exactly one Markdown renderer, not two that can drift. + */ + +import { useState } from "react"; +import { Check, Copy } from "lucide-react"; + +type CopyState = "idle" | "copied" | "error"; + +export function CopyMarkdownButton({ + url, + label = "Copy as Markdown", +}: { + url: string; + label?: string; +}) { + const [state, setState] = useState("idle"); + + async function copy() { + try { + const response = await fetch(url); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + // Fetched as text and copied verbatim: the server decided what this + // viewer may read, and the button must not reshape that answer. + const markdown = await response.text(); + await navigator.clipboard.writeText(markdown); + setState("copied"); + } catch (error) { + // A failed copy is reported, not swallowed: silently doing nothing looks + // like a successful copy of an empty document. + console.error("Copy as Markdown failed:", error); + setState("error"); + } finally { + setTimeout(() => setState("idle"), 2000); + } + } + + const text = state === "copied" ? "Copied" : state === "error" ? "Copy failed" : label; + + return ( + + ); +} diff --git a/apps/web/app/components/docs/docs-sub-nav.tsx b/apps/web/app/components/docs/docs-sub-nav.tsx index 72d656e..7da0b24 100644 --- a/apps/web/app/components/docs/docs-sub-nav.tsx +++ b/apps/web/app/components/docs/docs-sub-nav.tsx @@ -4,13 +4,15 @@ const TABS = [ { to: "/docs", label: "CLI Guide" }, { to: "/docs/agents", label: "Coding Agents Guide" }, { to: "/docs/api", label: "API Reference" }, + { to: "/docs/publishers", label: "Publisher Guide" }, + { to: "/docs/harness", label: "Harness Integration" }, ] as const; export function DocsSubNav() { const { pathname } = useLocation(); return ( -