@hasna/skills 0.8.11 → 0.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +21 -4
- package/bin/index.js +853 -446
- package/bin/mcp.js +309 -155
- package/bin/migrate.js +100 -10
- package/bin/server.js +596 -51
- package/bin/worker.js +283 -20
- package/dist/cli/commands/registry.d.ts +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +498 -184
- package/dist/lib/agent-discovery.d.ts +1 -1
- package/dist/lib/claude-settings-witness.d.ts +19 -0
- package/dist/lib/cloud-executions.d.ts +20 -0
- package/dist/lib/registry-types.d.ts +3 -0
- package/dist/lib/remote-client.d.ts +11 -0
- package/dist/lib/revision.d.ts +3 -0
- package/dist/sdk/execution/admission.d.ts +2 -1
- package/dist/sdk/execution/types.d.ts +9 -0
- package/dist/sdk/index.d.ts +1 -0
- package/dist/sdk/index.js +1755 -752
- package/dist/server/runtime-policy.d.ts +4 -2
- package/dist/server/runtime-pure-contract.d.ts +51 -0
- package/dist/server/runtime-store.d.ts +4 -0
- package/dist/server/runtime-worker.d.ts +2 -1
- package/dist/server/selection-store.d.ts +9 -1
- package/dist/server/skills-api.d.ts +2 -1
- package/dist/server/sqlite-store.d.ts +4 -1
- package/dist/server/store.d.ts +6 -1
- package/dist/server/types.d.ts +27 -0
- package/docs/plugin-admission.md +67 -0
- package/migrations/postgres/0010_skill_archive_lifecycle.sql +5 -0
- package/migrations/sqlite/0010_skill_archive_lifecycle.sql +5 -0
- package/package.json +1 -1
package/bin/migrate.js
CHANGED
|
@@ -7,7 +7,7 @@ import { join as join5 } from "path";
|
|
|
7
7
|
// package.json
|
|
8
8
|
var package_default = {
|
|
9
9
|
name: "@hasna/skills",
|
|
10
|
-
version: "0.
|
|
10
|
+
version: "0.9.1",
|
|
11
11
|
description: "Skills library for AI coding agents",
|
|
12
12
|
type: "module",
|
|
13
13
|
bin: {
|
|
@@ -499,6 +499,9 @@ class MemorySkillSelectionStore {
|
|
|
499
499
|
async getProfile(p, id) {
|
|
500
500
|
return structuredClone(this.profiles.get(this.key(p.orgId, id)) ?? null);
|
|
501
501
|
}
|
|
502
|
+
async profilesReferencingSkill(p, slug) {
|
|
503
|
+
return [...this.profiles.values()].filter((profile2) => profile2.workspaceId === p.orgId && profile2.selections.some((selection) => selection.slug === slug)).map((profile2) => profile2.id).sort();
|
|
504
|
+
}
|
|
502
505
|
async saveProfile(p, id, selected, expected) {
|
|
503
506
|
const key = this.key(p.orgId, id), previous = this.profiles.get(key);
|
|
504
507
|
if ((previous?.revision ?? null) !== expected)
|
|
@@ -536,14 +539,25 @@ class SqliteSkillSelectionStore {
|
|
|
536
539
|
constructor(db) {
|
|
537
540
|
this.db = db;
|
|
538
541
|
}
|
|
542
|
+
async profilesReferencingSkill(p, slug) {
|
|
543
|
+
const rows = this.db.query("SELECT profile_id,selections_json FROM skills_profiles WHERE org_id=?").all(p.orgId);
|
|
544
|
+
return rows.filter((row) => selections(row.selections_json).some((selection) => selection.slug === slug)).map((row) => String(row.profile_id)).sort();
|
|
545
|
+
}
|
|
539
546
|
async getProfile(p, id) {
|
|
540
547
|
const row = this.db.query("SELECT * FROM skills_profiles WHERE org_id=? AND profile_id=?").get(p.orgId, id);
|
|
541
548
|
return row ? profile(row) : null;
|
|
542
549
|
}
|
|
543
550
|
async saveProfile(p, id, selected, expected) {
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
551
|
+
return this.db.transaction(() => {
|
|
552
|
+
for (const selection of [...selected].sort((a, b) => a.slug.localeCompare(b.slug))) {
|
|
553
|
+
const skill = this.db.query("SELECT lifecycle FROM skills_registry WHERE org_id=? AND slug=?").get(p.orgId, selection.slug);
|
|
554
|
+
if (skill?.lifecycle === "archived")
|
|
555
|
+
return null;
|
|
556
|
+
}
|
|
557
|
+
const revision = randomUUID2(), timestamp = new Date().toISOString();
|
|
558
|
+
const row = expected === null ? this.db.query("INSERT INTO skills_profiles(org_id,profile_id,revision,selections_json,updated_at) VALUES(?,?,?,?,?) ON CONFLICT(org_id,profile_id) DO NOTHING RETURNING *").get(p.orgId, id, revision, JSON.stringify(selected), timestamp) : this.db.query("UPDATE skills_profiles SET revision=?,selections_json=?,updated_at=? WHERE org_id=? AND profile_id=? AND revision=? RETURNING *").get(revision, JSON.stringify(selected), timestamp, p.orgId, id, expected);
|
|
559
|
+
return row ? profile(row) : null;
|
|
560
|
+
}).immediate();
|
|
547
561
|
}
|
|
548
562
|
async getStationState(p, id) {
|
|
549
563
|
const row = this.db.query("SELECT * FROM skills_station_state WHERE org_id=? AND actor_id=? AND station_id=?").get(p.orgId, p.userId, id);
|
|
@@ -963,7 +977,11 @@ function rowToSkill(row) {
|
|
|
963
977
|
revisionId: String(row.revision_id ?? ""),
|
|
964
978
|
revisionNumber: Number(row.revision_number ?? 0),
|
|
965
979
|
...row.tombstoned_at ? { tombstonedAt: dateString(row.tombstoned_at) } : {},
|
|
966
|
-
...row.tombstone_purge_after ? { tombstonePurgeAfter: dateString(row.tombstone_purge_after) } : {}
|
|
980
|
+
...row.tombstone_purge_after ? { tombstonePurgeAfter: dateString(row.tombstone_purge_after) } : {},
|
|
981
|
+
lifecycle: row.lifecycle === "archived" ? "archived" : "active",
|
|
982
|
+
...row.archived_at ? { archivedAt: dateString(row.archived_at) } : {},
|
|
983
|
+
...row.archive_reason ? { archiveReason: String(row.archive_reason) } : {},
|
|
984
|
+
...row.replacement_slug ? { replacementSlug: String(row.replacement_slug) } : {}
|
|
967
985
|
};
|
|
968
986
|
}
|
|
969
987
|
function rowToPin(row) {
|
|
@@ -1082,10 +1100,21 @@ class SkillVersionExistsError extends Error {
|
|
|
1082
1100
|
}
|
|
1083
1101
|
}
|
|
1084
1102
|
|
|
1103
|
+
class SkillLifecycleConflictError extends Error {
|
|
1104
|
+
slug;
|
|
1105
|
+
profiles;
|
|
1106
|
+
constructor(slug, profiles) {
|
|
1107
|
+
super(`cannot archive '${slug}' while active profiles reference it: ${profiles.join(", ")}`);
|
|
1108
|
+
this.slug = slug;
|
|
1109
|
+
this.profiles = profiles;
|
|
1110
|
+
this.name = "SkillLifecycleConflictError";
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1085
1114
|
// src/lib/revision.ts
|
|
1086
1115
|
import { createHash as createHash2 } from "crypto";
|
|
1087
1116
|
function revisionIdOf(content) {
|
|
1088
|
-
const
|
|
1117
|
+
const canonicalContent = {
|
|
1089
1118
|
slug: content.slug,
|
|
1090
1119
|
displayName: content.displayName,
|
|
1091
1120
|
description: content.description,
|
|
@@ -1097,7 +1126,13 @@ function revisionIdOf(content) {
|
|
|
1097
1126
|
skillMd: content.skillMd ?? null,
|
|
1098
1127
|
bundleSha256: content.bundleSha256 ?? null,
|
|
1099
1128
|
bundleByteSize: content.bundleByteSize ?? null
|
|
1100
|
-
}
|
|
1129
|
+
};
|
|
1130
|
+
if (content.lifecycle === "archived" || content.archiveReason !== undefined || content.replacementSlug !== undefined) {
|
|
1131
|
+
canonicalContent.lifecycle = content.lifecycle ?? "active";
|
|
1132
|
+
canonicalContent.archiveReason = content.archiveReason ?? null;
|
|
1133
|
+
canonicalContent.replacementSlug = content.replacementSlug ?? null;
|
|
1134
|
+
}
|
|
1135
|
+
const canonical = JSON.stringify(canonicalContent);
|
|
1101
1136
|
return createHash2("sha256").update(canonical).digest("hex");
|
|
1102
1137
|
}
|
|
1103
1138
|
function revisionIdOfRecord(record) {
|
|
@@ -1200,6 +1235,28 @@ class SqliteSkillsStore {
|
|
|
1200
1235
|
scopes: parseScopes(row.scopes_json)
|
|
1201
1236
|
};
|
|
1202
1237
|
}
|
|
1238
|
+
async updateApiKeyScopes(actor, keyId, expectedScopes, addScopes) {
|
|
1239
|
+
const row = this.get("SELECT scopes_json FROM api_keys WHERE id = ? AND org_id = ? AND revoked_at IS NULL LIMIT 1", [keyId, actor.orgId]);
|
|
1240
|
+
if (!row)
|
|
1241
|
+
return { kind: "not_found" };
|
|
1242
|
+
const current = parseScopes(row.scopes_json);
|
|
1243
|
+
if (current.length !== expectedScopes.length || current.some((scope, index) => scope !== expectedScopes[index])) {
|
|
1244
|
+
return { kind: "stale", scopes: current };
|
|
1245
|
+
}
|
|
1246
|
+
const scopes = [...current, ...addScopes.filter((scope) => !current.includes(scope))];
|
|
1247
|
+
const tx = this.db.transaction(() => {
|
|
1248
|
+
const changed = this.db.run("UPDATE api_keys SET scopes_json = ? WHERE id = ? AND org_id = ? AND revoked_at IS NULL AND scopes_json = ?", [JSON.stringify(scopes), keyId, actor.orgId, JSON.stringify(expectedScopes)]);
|
|
1249
|
+
if (changed.changes !== 1)
|
|
1250
|
+
return false;
|
|
1251
|
+
this.db.run("INSERT INTO skills_audit_events (org_id, user_id, api_key_id, action, target_type, target_id, metadata_json) VALUES (?, ?, ?, ?, ?, ?, ?)", [actor.orgId, actor.userId, actor.apiKeyId, "api_key_scopes_added", "api_key", keyId, JSON.stringify({ added: addScopes, scopes })]);
|
|
1252
|
+
return true;
|
|
1253
|
+
});
|
|
1254
|
+
if (!tx()) {
|
|
1255
|
+
const latest = this.get("SELECT scopes_json FROM api_keys WHERE id = ? AND org_id = ? AND revoked_at IS NULL LIMIT 1", [keyId, actor.orgId]);
|
|
1256
|
+
return { kind: "stale", scopes: latest ? parseScopes(latest.scopes_json) : current };
|
|
1257
|
+
}
|
|
1258
|
+
return { kind: "updated", scopes };
|
|
1259
|
+
}
|
|
1203
1260
|
async createRun(input) {
|
|
1204
1261
|
if (input.idempotencyKey) {
|
|
1205
1262
|
const existing = this.get("SELECT * FROM skills_runs WHERE org_id = ? AND idempotency_key = ? LIMIT 1", [input.principal.orgId, input.idempotencyKey]);
|
|
@@ -1521,6 +1578,39 @@ class SqliteSkillsStore {
|
|
|
1521
1578
|
const row = this.get("SELECT * FROM skills_registry WHERE org_id = ? AND slug = ? LIMIT 1", [principal.orgId, slug]);
|
|
1522
1579
|
return row ? rowToSkill(row) : null;
|
|
1523
1580
|
}
|
|
1581
|
+
async setSkillLifecycle(principal, slug, patch, expectedRevisionId) {
|
|
1582
|
+
return this.db.transaction(() => {
|
|
1583
|
+
const rowCurrent = this.get("SELECT * FROM skills_registry WHERE org_id=? AND slug=? LIMIT 1", [principal.orgId, slug]);
|
|
1584
|
+
const current = rowCurrent ? rowToSkill(rowCurrent) : null;
|
|
1585
|
+
if (!current || current.tombstonedAt)
|
|
1586
|
+
return null;
|
|
1587
|
+
if (expectedRevisionId !== current.revisionId)
|
|
1588
|
+
throw new SkillRevisionConflictError(slug, expectedRevisionId, current.revisionId);
|
|
1589
|
+
if (patch.lifecycle === "archived" && current.lifecycle !== "archived") {
|
|
1590
|
+
const profiles = this.db.query("SELECT profile_id,selections_json FROM skills_profiles WHERE org_id=?").all(principal.orgId);
|
|
1591
|
+
const referencing = profiles.filter((profile2) => {
|
|
1592
|
+
const selections2 = typeof profile2.selections_json === "string" ? JSON.parse(profile2.selections_json) : profile2.selections_json;
|
|
1593
|
+
return Array.isArray(selections2) && selections2.some((selection) => selection?.slug === slug);
|
|
1594
|
+
}).map((profile2) => String(profile2.profile_id)).sort();
|
|
1595
|
+
if (referencing.length)
|
|
1596
|
+
throw new SkillLifecycleConflictError(slug, referencing);
|
|
1597
|
+
}
|
|
1598
|
+
const next2 = { ...current, lifecycle: patch.lifecycle, updatedAt: nowIso(), ...patch.lifecycle === "archived" ? { archivedAt: current.archivedAt ?? nowIso(), ...patch.reason ? { archiveReason: patch.reason } : {}, ...patch.replacementSlug ? { replacementSlug: patch.replacementSlug } : {} } : {} };
|
|
1599
|
+
if (patch.lifecycle === "active") {
|
|
1600
|
+
delete next2.archivedAt;
|
|
1601
|
+
delete next2.archiveReason;
|
|
1602
|
+
delete next2.replacementSlug;
|
|
1603
|
+
}
|
|
1604
|
+
const row = this.get("UPDATE skills_registry SET lifecycle=?, archived_at=?, archive_reason=?, replacement_slug=?, revision_id=?, revision_number=revision_number+1, updated_at=? WHERE org_id=? AND slug=? AND tombstoned_at IS NULL AND revision_id=? RETURNING *", [next2.lifecycle, next2.archivedAt ?? null, next2.archiveReason ?? null, next2.replacementSlug ?? null, revisionIdOfRecord(next2), next2.updatedAt, principal.orgId, slug, current.revisionId]);
|
|
1605
|
+
if (!row)
|
|
1606
|
+
throw new SkillRevisionConflictError(slug, expectedRevisionId, this.getSkillSync(principal, slug)?.revisionId ?? null);
|
|
1607
|
+
return rowToSkill(row);
|
|
1608
|
+
}).immediate();
|
|
1609
|
+
}
|
|
1610
|
+
getSkillSync(principal, slug) {
|
|
1611
|
+
const row = this.get("SELECT * FROM skills_registry WHERE org_id=? AND slug=? LIMIT 1", [principal.orgId, slug]);
|
|
1612
|
+
return row ? rowToSkill(row) : null;
|
|
1613
|
+
}
|
|
1524
1614
|
async updateSkill(principal, slug, patch, expectedRevisionId) {
|
|
1525
1615
|
const current = await this.getSkill(principal, slug);
|
|
1526
1616
|
if (!current || current.tombstonedAt)
|
|
@@ -1633,13 +1723,13 @@ class SqliteSkillsStore {
|
|
|
1633
1723
|
}
|
|
1634
1724
|
async listTags(principal) {
|
|
1635
1725
|
await this.purgeExpiredTombstones(principal);
|
|
1636
|
-
return this.all("SELECT DISTINCT tag FROM skills_tags WHERE org_id = ? ORDER BY tag ASC", [principal.orgId]).map((row) => String(row.tag));
|
|
1726
|
+
return this.all("SELECT DISTINCT t.tag FROM skills_tags t JOIN skills_registry s ON s.org_id=t.org_id AND s.slug=t.slug WHERE t.org_id = ? AND s.lifecycle='active' ORDER BY t.tag ASC", [principal.orgId]).map((row) => String(row.tag));
|
|
1637
1727
|
}
|
|
1638
1728
|
async listSkillsByTag(principal, tag) {
|
|
1639
1729
|
await this.purgeExpiredTombstones(principal);
|
|
1640
1730
|
return this.all(`SELECT s.* FROM skills_registry s
|
|
1641
1731
|
JOIN skills_tags t ON t.org_id = s.org_id AND t.slug = s.slug
|
|
1642
|
-
WHERE t.org_id = ? AND t.tag = ? AND s.tombstoned_at IS NULL
|
|
1732
|
+
WHERE t.org_id = ? AND t.tag = ? AND s.tombstoned_at IS NULL AND s.lifecycle = 'active'
|
|
1643
1733
|
ORDER BY s.slug ASC`, [principal.orgId, tag]).map(rowToSkill);
|
|
1644
1734
|
}
|
|
1645
1735
|
async listPinsByTag(principal, tag) {
|
|
@@ -1647,7 +1737,7 @@ class SqliteSkillsStore {
|
|
|
1647
1737
|
return this.all(`SELECT p.* FROM skills_pins p
|
|
1648
1738
|
JOIN skills_tags t ON t.org_id = p.org_id AND t.slug = p.slug
|
|
1649
1739
|
JOIN skills_registry s ON s.org_id = p.org_id AND s.slug = p.slug
|
|
1650
|
-
WHERE p.org_id = ? AND p.principal = ? AND t.tag = ? AND s.tombstoned_at IS NULL
|
|
1740
|
+
WHERE p.org_id = ? AND p.principal = ? AND t.tag = ? AND s.tombstoned_at IS NULL AND s.lifecycle = 'active'
|
|
1651
1741
|
ORDER BY p.slug ASC`, [principal.orgId, principal.apiKeyId, tag]).map(rowToPin);
|
|
1652
1742
|
}
|
|
1653
1743
|
async listPublishedSlugs(principal) {
|