@hasna/skills 0.8.12 → 0.9.2

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/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.8.12",
10
+ version: "0.9.2",
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
- const revision = randomUUID2(), timestamp = new Date().toISOString();
545
- 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);
546
- return row ? profile(row) : null;
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 canonical = JSON.stringify({
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) {