@hasna/skills 0.9.2 → 0.9.3

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/dist/index.js CHANGED
@@ -11448,7 +11448,7 @@ import { isAbsolute as isAbsolute10, join as join20, resolve as resolve10 } from
11448
11448
  // package.json
11449
11449
  var package_default = {
11450
11450
  name: "@hasna/skills",
11451
- version: "0.9.2",
11451
+ version: "0.9.3",
11452
11452
  description: "Skills library for AI coding agents",
11453
11453
  type: "module",
11454
11454
  bin: {
@@ -11457,6 +11457,7 @@ var package_default = {
11457
11457
  "skills-serve": "bin/server.js",
11458
11458
  "skills-server": "bin/server.js",
11459
11459
  "skills-worker": "bin/worker.js",
11460
+ "skills-maintenance": "bin/maintenance.js",
11460
11461
  "skills-migrate": "bin/migrate.js"
11461
11462
  },
11462
11463
  exports: {
@@ -11497,7 +11498,7 @@ var package_default = {
11497
11498
  types: "./dist/index.d.ts",
11498
11499
  scripts: {
11499
11500
  clean: "rm -rf bin/ dist/",
11500
- build: "bun run clean && bun build ./src/cli/index.tsx --outdir ./bin --target bun && bun build ./src/mcp/index.ts --outfile ./bin/mcp.js --target bun && bun build ./src/server/index.ts --outfile ./bin/server.js --target bun && bun build ./src/server/worker.ts --outfile ./bin/worker.js --target bun && bun build ./src/server/migrate.ts --outfile ./bin/migrate.js --target bun && bun build ./src/index.ts ./src/storage.ts ./src/sdk/index.ts ./src/admin-contract.ts --outdir ./dist --target bun && tsc --emitDeclarationOnly --declaration --outDir dist",
11501
+ build: "bun run clean && bun build ./src/cli/index.tsx --outdir ./bin --target bun && bun build ./src/mcp/index.ts --outfile ./bin/mcp.js --target bun && bun build ./src/server/index.ts --outfile ./bin/server.js --target bun && bun build ./src/server/worker.ts --outfile ./bin/worker.js --target bun && bun build ./src/server/migrate.ts --outfile ./bin/migrate.js --target bun && bun build ./src/server/maintenance.ts --outfile ./bin/maintenance.js --target bun && bun build ./src/index.ts ./src/storage.ts ./src/sdk/index.ts ./src/admin-contract.ts --outdir ./dist --target bun && tsc --emitDeclarationOnly --declaration --outDir dist",
11501
11502
  "build:js": "rm -rf dist && bun build ./src/index.ts ./src/storage.ts ./src/sdk/index.ts ./src/admin-contract.ts --outdir ./dist --target bun && tsc --emitDeclarationOnly --declaration --outDir dist",
11502
11503
  test: "bun test --timeout 30000",
11503
11504
  dev: "bun run ./src/cli/index.tsx",
@@ -15784,10 +15785,12 @@ function projectAncestorDirectories(projects) {
15784
15785
  }
15785
15786
  function inventoryNativeSkills(home = homedir3(), options = {}) {
15786
15787
  const aliases = rootAliases(home, options.allowRootAliases);
15787
- const roots = ROOTS.map(([agent, path]) => [agent, canonicalAgentPath(join25(home, path), aliases)]);
15788
+ const selectedAgents = options.agents ? new Set(options.agents) : undefined;
15789
+ const rootDefinitions = selectedAgents ? ROOTS.filter(([agent, path]) => selectedAgents.has(agent) || selectedAgents.has("hermes") && agent === "codex" && path === ".agents/skills") : ROOTS;
15790
+ const roots = rootDefinitions.map(([agent, path]) => [agent, canonicalAgentPath(join25(home, path), aliases)]);
15788
15791
  const bridgePaths = Object.values(AGENT_ADAPTERS).map((adapter) => canonicalAgentPath(join25(home, adapter.root, CLI_BRIDGE_NAME), aliases));
15789
15792
  for (const project of projectAncestorDirectories([...options.projectDirs ?? [], ...options.projectDir ? [options.projectDir] : []])) {
15790
- for (const [agent, path] of ROOTS)
15793
+ for (const [agent, path] of rootDefinitions)
15791
15794
  roots.push([agent, canonicalAgentPath(join25(project, path), aliases)]);
15792
15795
  }
15793
15796
  const entries = [], seen = new Set;
@@ -16043,7 +16046,7 @@ function planAgentIntegration(options) {
16043
16046
  return [agent, { command, profileId: profileId2 }];
16044
16047
  }));
16045
16048
  const discoveries = [...new Set(options.agents)].map((agent) => resolveAgentDiscovery({ home, agent, reviewed: options.discoveryInputs, canonical: (path) => canonicalAgentPath(path, aliases) }));
16046
- const nativeSkills = inventoryNativeSkills(home, { includeVendor: true, guardHermes: options.agents.includes("hermes"), projectDir: options.projectDir, agentRoots: discoveries.flatMap((binding) => binding.roots.map((path) => ({ agent: binding.agent, path }))), allowRootAliases: options.allowRootAliases });
16049
+ const nativeSkills = inventoryNativeSkills(home, { includeVendor: true, guardHermes: options.agents.includes("hermes"), agents: options.agents, projectDir: options.projectDir, agentRoots: discoveries.flatMap((binding) => binding.roots.map((path) => ({ agent: binding.agent, path }))), allowRootAliases: options.allowRootAliases });
16047
16050
  const changes = [];
16048
16051
  for (const agent of [...new Set(options.agents)]) {
16049
16052
  if (!INTEGRATION_AGENTS.includes(agent))
@@ -65,6 +65,7 @@ export declare function inventoryNativeSkills(home?: string, options?: {
65
65
  guardHermes?: boolean;
66
66
  projectDir?: string;
67
67
  projectDirs?: string[];
68
+ agents?: readonly IntegrationAgent[];
68
69
  agentRoots?: Array<{
69
70
  agent: string;
70
71
  path: string;
@@ -1,4 +1,4 @@
1
- export declare const SUPPORTED_CODEX_HOOK_VERSIONS: readonly ["codex-cli 0.153.0", "codex-cli 0.154.0", "codex-cli 0.155.0"];
1
+ export declare const SUPPORTED_CODEX_HOOK_VERSIONS: readonly ["codex-cli 0.153.0", "codex-cli 0.154.0", "codex-cli 0.155.0", "codex-cli 0.155.1"];
2
2
  export interface CodexHookRpc {
3
3
  version: string;
4
4
  processId?: number;
package/dist/sdk/index.js CHANGED
@@ -32373,7 +32373,7 @@ var init_event_sink = __esm(() => {
32373
32373
  // package.json
32374
32374
  var package_default = {
32375
32375
  name: "@hasna/skills",
32376
- version: "0.9.2",
32376
+ version: "0.9.3",
32377
32377
  description: "Skills library for AI coding agents",
32378
32378
  type: "module",
32379
32379
  bin: {
@@ -32382,6 +32382,7 @@ var package_default = {
32382
32382
  "skills-serve": "bin/server.js",
32383
32383
  "skills-server": "bin/server.js",
32384
32384
  "skills-worker": "bin/worker.js",
32385
+ "skills-maintenance": "bin/maintenance.js",
32385
32386
  "skills-migrate": "bin/migrate.js"
32386
32387
  },
32387
32388
  exports: {
@@ -32422,7 +32423,7 @@ var package_default = {
32422
32423
  types: "./dist/index.d.ts",
32423
32424
  scripts: {
32424
32425
  clean: "rm -rf bin/ dist/",
32425
- build: "bun run clean && bun build ./src/cli/index.tsx --outdir ./bin --target bun && bun build ./src/mcp/index.ts --outfile ./bin/mcp.js --target bun && bun build ./src/server/index.ts --outfile ./bin/server.js --target bun && bun build ./src/server/worker.ts --outfile ./bin/worker.js --target bun && bun build ./src/server/migrate.ts --outfile ./bin/migrate.js --target bun && bun build ./src/index.ts ./src/storage.ts ./src/sdk/index.ts ./src/admin-contract.ts --outdir ./dist --target bun && tsc --emitDeclarationOnly --declaration --outDir dist",
32426
+ build: "bun run clean && bun build ./src/cli/index.tsx --outdir ./bin --target bun && bun build ./src/mcp/index.ts --outfile ./bin/mcp.js --target bun && bun build ./src/server/index.ts --outfile ./bin/server.js --target bun && bun build ./src/server/worker.ts --outfile ./bin/worker.js --target bun && bun build ./src/server/migrate.ts --outfile ./bin/migrate.js --target bun && bun build ./src/server/maintenance.ts --outfile ./bin/maintenance.js --target bun && bun build ./src/index.ts ./src/storage.ts ./src/sdk/index.ts ./src/admin-contract.ts --outdir ./dist --target bun && tsc --emitDeclarationOnly --declaration --outDir dist",
32426
32427
  "build:js": "rm -rf dist && bun build ./src/index.ts ./src/storage.ts ./src/sdk/index.ts ./src/admin-contract.ts --outdir ./dist --target bun && tsc --emitDeclarationOnly --declaration --outDir dist",
32427
32428
  test: "bun test --timeout 30000",
32428
32429
  dev: "bun run ./src/cli/index.tsx",
@@ -40707,10 +40708,12 @@ function projectAncestorDirectories(projects) {
40707
40708
  }
40708
40709
  function inventoryNativeSkills(home = homedir3(), options = {}) {
40709
40710
  const aliases = rootAliases(home, options.allowRootAliases);
40710
- const roots = ROOTS.map(([agent, path]) => [agent, canonicalAgentPath(join21(home, path), aliases)]);
40711
+ const selectedAgents = options.agents ? new Set(options.agents) : undefined;
40712
+ const rootDefinitions = selectedAgents ? ROOTS.filter(([agent, path]) => selectedAgents.has(agent) || selectedAgents.has("hermes") && agent === "codex" && path === ".agents/skills") : ROOTS;
40713
+ const roots = rootDefinitions.map(([agent, path]) => [agent, canonicalAgentPath(join21(home, path), aliases)]);
40711
40714
  const bridgePaths = Object.values(AGENT_ADAPTERS).map((adapter) => canonicalAgentPath(join21(home, adapter.root, CLI_BRIDGE_NAME), aliases));
40712
40715
  for (const project of projectAncestorDirectories([...options.projectDirs ?? [], ...options.projectDir ? [options.projectDir] : []])) {
40713
- for (const [agent, path] of ROOTS)
40716
+ for (const [agent, path] of rootDefinitions)
40714
40717
  roots.push([agent, canonicalAgentPath(join21(project, path), aliases)]);
40715
40718
  }
40716
40719
  const entries = [], seen = new Set;
@@ -40966,7 +40969,7 @@ function planAgentIntegration(options) {
40966
40969
  return [agent, { command, profileId: profileId2 }];
40967
40970
  }));
40968
40971
  const discoveries = [...new Set(options.agents)].map((agent) => resolveAgentDiscovery({ home, agent, reviewed: options.discoveryInputs, canonical: (path) => canonicalAgentPath(path, aliases) }));
40969
- const nativeSkills = inventoryNativeSkills(home, { includeVendor: true, guardHermes: options.agents.includes("hermes"), projectDir: options.projectDir, agentRoots: discoveries.flatMap((binding) => binding.roots.map((path) => ({ agent: binding.agent, path }))), allowRootAliases: options.allowRootAliases });
40972
+ const nativeSkills = inventoryNativeSkills(home, { includeVendor: true, guardHermes: options.agents.includes("hermes"), agents: options.agents, projectDir: options.projectDir, agentRoots: discoveries.flatMap((binding) => binding.roots.map((path) => ({ agent: binding.agent, path }))), allowRootAliases: options.allowRootAliases });
40970
40973
  const changes = [];
40971
40974
  for (const agent of [...new Set(options.agents)]) {
40972
40975
  if (!INTEGRATION_AGENTS.includes(agent))
@@ -55225,6 +55228,10 @@ class SkillLifecycleConflictError extends Error {
55225
55228
  this.name = "SkillLifecycleConflictError";
55226
55229
  }
55227
55230
  }
55231
+ function validOperatorScopeEnrollmentInput(input) {
55232
+ const bounded = (value, max = 256) => typeof value === "string" && value.length > 0 && value.length <= max && !/[\u0000-\u001f\u007f]/.test(value);
55233
+ return bounded(input.keyId) && bounded(input.stationId) && bounded(input.orgId) && bounded(input.operationId) && bounded(input.manifestDigest, 64) && /^[a-f0-9]{64}$/.test(input.manifestDigest) && bounded(input.operatorJobId) && bounded(input.operatorTaskArn, 512) && input.expectedScopes.length > 0 && input.expectedScopes.length <= 32 && input.expectedScopes.every((scope) => bounded(scope, 128) && /^[a-z][a-z0-9_-]*:[a-z][a-z0-9_-]*$/.test(scope));
55234
+ }
55228
55235
 
55229
55236
  // src/lib/revision.ts
55230
55237
  import { createHash as createHash20 } from "crypto";
@@ -55373,6 +55380,54 @@ class SqliteSkillsStore {
55373
55380
  }
55374
55381
  return { kind: "updated", scopes };
55375
55382
  }
55383
+ async enrollPublishScopeByOperator(input) {
55384
+ if (!validOperatorScopeEnrollmentInput(input))
55385
+ return { kind: "invalid" };
55386
+ if (input.expectedScopes.includes("skills:publish"))
55387
+ return { kind: "stale", scopes: input.expectedScopes };
55388
+ const operation = this.get("SELECT org_id, target_id FROM skills_audit_events WHERE action = ? AND operator_operation_id = ? LIMIT 1", ["api_key_scopes_added", input.operationId]);
55389
+ if (operation && (String(operation.org_id) !== input.orgId || String(operation.target_id) !== input.keyId))
55390
+ return { kind: "target_mismatch" };
55391
+ const row = this.get("SELECT id, org_id, name, scopes_json FROM api_keys WHERE id = ? AND revoked_at IS NULL LIMIT 1", [input.keyId]);
55392
+ if (!row)
55393
+ return { kind: "not_found" };
55394
+ if (String(row.org_id) !== input.orgId)
55395
+ return { kind: "target_mismatch" };
55396
+ const current = parseScopes(row.scopes_json);
55397
+ const prior = this.get("SELECT metadata_json FROM skills_audit_events WHERE action = ? AND target_type = ? AND target_id = ? AND json_extract(metadata_json, '$.operator_operation_id') = ? LIMIT 1", ["api_key_scopes_added", "api_key", input.keyId, input.operationId]);
55398
+ if (prior) {
55399
+ try {
55400
+ if (JSON.parse(String(prior.metadata_json)).target_manifest_digest !== input.manifestDigest)
55401
+ return { kind: "target_mismatch" };
55402
+ } catch {
55403
+ return { kind: "target_mismatch" };
55404
+ }
55405
+ return current.includes("skills:publish") ? { kind: "already_applied", scopes: current } : { kind: "stale", scopes: current };
55406
+ }
55407
+ if (current.length !== input.expectedScopes.length || current.some((scope, index) => scope !== input.expectedScopes[index]))
55408
+ return { kind: "stale", scopes: current };
55409
+ const scopes = [...current, "skills:publish"];
55410
+ const tx = this.db.transaction(() => {
55411
+ 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), input.keyId, input.orgId, JSON.stringify(input.expectedScopes)]);
55412
+ if (changed.changes !== 1)
55413
+ return false;
55414
+ this.db.run("INSERT INTO skills_audit_events (org_id, user_id, api_key_id, action, target_type, target_id, operator_operation_id, metadata_json) VALUES (?, NULL, NULL, ?, ?, ?, ?, ?)", [input.orgId, "api_key_scopes_added", "api_key", input.keyId, input.operationId, JSON.stringify({ added: ["skills:publish"], scopes, operator_operation_id: input.operationId, operator_job_id: input.operatorJobId, operator_task_arn: input.operatorTaskArn, target_manifest_digest: input.manifestDigest, station_id: input.stationId })]);
55415
+ return true;
55416
+ });
55417
+ if (!tx()) {
55418
+ const latest = this.get("SELECT scopes_json FROM api_keys WHERE id = ? AND org_id = ? AND revoked_at IS NULL LIMIT 1", [input.keyId, input.orgId]);
55419
+ return { kind: "stale", scopes: latest ? parseScopes(latest.scopes_json) : current };
55420
+ }
55421
+ return { kind: "updated", scopes };
55422
+ }
55423
+ async inspectOperatorScopeTarget(keyId, orgId) {
55424
+ const row = this.get("SELECT org_id, scopes_json FROM api_keys WHERE id = ? AND revoked_at IS NULL LIMIT 1", [keyId]);
55425
+ if (!row)
55426
+ return { kind: "not_found" };
55427
+ if (String(row.org_id) !== orgId)
55428
+ return { kind: "target_mismatch" };
55429
+ return { kind: "found", scopes: parseScopes(row.scopes_json) };
55430
+ }
55376
55431
  async createRun(input) {
55377
55432
  if (input.idempotencyKey) {
55378
55433
  const existing = this.get("SELECT * FROM skills_runs WHERE org_id = ? AND idempotency_key = ? LIMIT 1", [input.principal.orgId, input.idempotencyKey]);
@@ -62620,6 +62675,7 @@ class MemorySkillsStore {
62620
62675
  bundles = new Map;
62621
62676
  versions = new Map;
62622
62677
  pins = new Map;
62678
+ operatorEnrollments = new Map;
62623
62679
  constructor(apiKeys = []) {
62624
62680
  for (const key of apiKeys)
62625
62681
  this.addApiKey(key.token, key.principal);
@@ -62649,6 +62705,41 @@ class MemorySkillsStore {
62649
62705
  }
62650
62706
  return { kind: "not_found" };
62651
62707
  }
62708
+ async enrollPublishScopeByOperator(input) {
62709
+ if (!validOperatorScopeEnrollmentInput(input))
62710
+ return { kind: "invalid" };
62711
+ const previous = this.operatorEnrollments.get(input.operationId);
62712
+ if (previous) {
62713
+ if (previous.keyId !== input.keyId || previous.manifestDigest !== input.manifestDigest)
62714
+ return { kind: "target_mismatch" };
62715
+ for (const target of this.apiKeys.values()) {
62716
+ if (target.apiKeyId === input.keyId && target.orgId === input.orgId && target.scopes.includes("skills:publish")) {
62717
+ return { kind: "already_applied", scopes: [...target.scopes] };
62718
+ }
62719
+ }
62720
+ return { kind: "stale", scopes: [] };
62721
+ }
62722
+ for (const [hash3, target] of this.apiKeys) {
62723
+ if (target.apiKeyId !== input.keyId || target.orgId !== input.orgId)
62724
+ continue;
62725
+ if (target.scopes.length !== input.expectedScopes.length || target.scopes.some((scope2, index) => scope2 !== input.expectedScopes[index])) {
62726
+ return { kind: "stale", scopes: [...target.scopes] };
62727
+ }
62728
+ const scopes = target.scopes.includes("skills:publish") ? [...target.scopes] : [...target.scopes, "skills:publish"];
62729
+ this.apiKeys.set(hash3, { ...target, scopes });
62730
+ this.operatorEnrollments.set(input.operationId, { keyId: input.keyId, manifestDigest: input.manifestDigest });
62731
+ return { kind: "updated", scopes };
62732
+ }
62733
+ return { kind: "not_found" };
62734
+ }
62735
+ async inspectOperatorScopeTarget(keyId, orgId) {
62736
+ for (const target of this.apiKeys.values()) {
62737
+ if (target.apiKeyId !== keyId)
62738
+ continue;
62739
+ return target.orgId === orgId ? { kind: "found", scopes: [...target.scopes] } : { kind: "target_mismatch" };
62740
+ }
62741
+ return { kind: "not_found" };
62742
+ }
62652
62743
  async createRun(input) {
62653
62744
  const idemKey = input.idempotencyKey?.trim();
62654
62745
  const idemMapKey = idemKey ? `${input.principal.orgId}:${idemKey}` : undefined;
@@ -63063,24 +63154,98 @@ class PostgresSkillsStore {
63063
63154
  if (current.length !== expectedScopes.length || current.some((scope2, index) => scope2 !== expectedScopes[index])) {
63064
63155
  return { kind: "stale", scopes: current };
63065
63156
  }
63066
- const scopes = [...current, ...addScopes.filter((scope2) => !current.includes(scope2))];
63067
- const updated = await tx`
63068
- UPDATE api_keys SET scopes_json = ${JSON.stringify(scopes)}::jsonb
63069
- WHERE id = ${keyId} AND org_id = ${actor.orgId} AND revoked_at IS NULL
63070
- AND scopes_json = ${JSON.stringify(expectedScopes)}::jsonb
63071
- RETURNING scopes_json
63072
- `;
63157
+ const updated = await this.casUpdateApiKeyScopes(tx, keyId, actor.orgId, expectedScopes, addScopes);
63073
63158
  if (!updated[0]) {
63074
63159
  const latest = await tx`SELECT scopes_json FROM api_keys WHERE id = ${keyId} AND org_id = ${actor.orgId} AND revoked_at IS NULL LIMIT 1`;
63075
63160
  return { kind: "stale", scopes: latest[0] ? parseJsonArray(latest[0].scopes_json) : current };
63076
63161
  }
63077
63162
  await tx`
63078
63163
  INSERT INTO skills_audit_events (org_id, user_id, api_key_id, action, target_type, target_id, metadata_json)
63079
- VALUES (${actor.orgId}, ${actor.userId}, ${actor.apiKeyId}, ${"api_key_scopes_added"}, ${"api_key"}, ${keyId}, ${JSON.stringify({ added: addScopes, scopes })}::jsonb)
63164
+ VALUES (${actor.orgId}, ${actor.userId}, ${actor.apiKeyId}, ${"api_key_scopes_added"}, ${"api_key"}, ${keyId}, ${JSON.stringify({ added: addScopes, scopes: parseJsonArray(updated[0].scopes_json) })}::jsonb)
63165
+ `;
63166
+ return { kind: "updated", scopes: parseJsonArray(updated[0].scopes_json) };
63167
+ });
63168
+ }
63169
+ async casUpdateApiKeyScopes(tx, keyId, orgId, expectedScopes, addScopes) {
63170
+ const current = expectedScopes;
63171
+ const scopes = [...current, ...addScopes.filter((scope2) => !current.includes(scope2))];
63172
+ return tx`
63173
+ UPDATE api_keys SET scopes_json = ${JSON.stringify(scopes)}::jsonb
63174
+ WHERE id = ${keyId} AND org_id = ${orgId} AND revoked_at IS NULL
63175
+ AND scopes_json = ${JSON.stringify(expectedScopes)}::jsonb
63176
+ RETURNING scopes_json
63177
+ `;
63178
+ }
63179
+ async enrollPublishScopeByOperator(input) {
63180
+ if (!validOperatorScopeEnrollmentInput(input))
63181
+ return { kind: "invalid" };
63182
+ if (input.expectedScopes.includes("skills:publish"))
63183
+ return { kind: "stale", scopes: input.expectedScopes };
63184
+ return this.sql.begin(async (tx) => {
63185
+ const operation = await tx`
63186
+ SELECT org_id, target_id FROM skills_audit_events
63187
+ WHERE action = ${"api_key_scopes_added"} AND operator_operation_id = ${input.operationId}
63188
+ LIMIT 1
63189
+ `;
63190
+ if (operation[0] && (String(operation[0].org_id) !== input.orgId || String(operation[0].target_id) !== input.keyId))
63191
+ return { kind: "target_mismatch" };
63192
+ const rows = await tx`
63193
+ SELECT id, org_id, name, scopes_json FROM api_keys
63194
+ WHERE id = ${input.keyId} AND revoked_at IS NULL
63195
+ FOR UPDATE
63196
+ `;
63197
+ const row = rows[0];
63198
+ if (!row)
63199
+ return { kind: "not_found" };
63200
+ if (String(row.org_id) !== input.orgId)
63201
+ return { kind: "target_mismatch" };
63202
+ const current = parseJsonArray(row.scopes_json);
63203
+ const prior = await tx`
63204
+ SELECT metadata_json, metadata_json->>'target_manifest_digest' AS manifest_digest FROM skills_audit_events
63205
+ WHERE action = ${"api_key_scopes_added"} AND target_type = ${"api_key"} AND target_id = ${input.keyId}
63206
+ AND operator_operation_id = ${input.operationId}
63207
+ LIMIT 1
63208
+ `;
63209
+ if (prior[0]) {
63210
+ const priorMetadata = typeof prior[0].metadata_json === "string" ? JSON.parse(prior[0].metadata_json) : prior[0].metadata_json;
63211
+ const priorDigest = prior[0].manifest_digest ?? priorMetadata?.target_manifest_digest;
63212
+ if (String(priorDigest) !== input.manifestDigest)
63213
+ return { kind: "target_mismatch" };
63214
+ if (!current.includes("skills:publish"))
63215
+ return { kind: "stale", scopes: current };
63216
+ return { kind: "already_applied", scopes: current };
63217
+ }
63218
+ if (current.length !== input.expectedScopes.length || current.some((scope2, index) => scope2 !== input.expectedScopes[index])) {
63219
+ return { kind: "stale", scopes: current };
63220
+ }
63221
+ const updated = await this.casUpdateApiKeyScopes(tx, input.keyId, input.orgId, input.expectedScopes, ["skills:publish"]);
63222
+ if (!updated[0])
63223
+ return { kind: "stale", scopes: current };
63224
+ const scopes = parseJsonArray(updated[0].scopes_json);
63225
+ await tx`
63226
+ INSERT INTO skills_audit_events (org_id, user_id, api_key_id, action, target_type, target_id, operator_operation_id, metadata_json)
63227
+ VALUES (${input.orgId}, NULL, NULL, ${"api_key_scopes_added"}, ${"api_key"}, ${input.keyId}, ${input.operationId}, ${JSON.stringify({
63228
+ added: ["skills:publish"],
63229
+ scopes,
63230
+ operator_operation_id: input.operationId,
63231
+ operator_job_id: input.operatorJobId,
63232
+ operator_task_arn: input.operatorTaskArn,
63233
+ target_manifest_digest: input.manifestDigest,
63234
+ station_id: input.stationId
63235
+ })}::jsonb)
63080
63236
  `;
63081
63237
  return { kind: "updated", scopes };
63082
63238
  });
63083
63239
  }
63240
+ async inspectOperatorScopeTarget(keyId, orgId) {
63241
+ const rows = await this.sql`SELECT org_id, scopes_json FROM api_keys WHERE id = ${keyId} AND revoked_at IS NULL LIMIT 1`;
63242
+ const row = rows[0];
63243
+ if (!row)
63244
+ return { kind: "not_found" };
63245
+ if (String(row.org_id) !== orgId)
63246
+ return { kind: "target_mismatch" };
63247
+ return { kind: "found", scopes: parseJsonArray(row.scopes_json) };
63248
+ }
63084
63249
  async withContext(orgId, worker, fn) {
63085
63250
  return this.sql.begin(async (tx) => {
63086
63251
  await tx`SELECT set_config('app.skills_org_id', ${orgId ?? ""}, true)`;
@@ -0,0 +1,2 @@
1
+ import { Command } from "commander";
2
+ export declare function registerMaintenance(parent: Command): void;
@@ -0,0 +1 @@
1
+ export {};
@@ -15,7 +15,7 @@ import { SqliteExecutionGrantStore } from "./execution-grant-store.js";
15
15
  */
16
16
  import { Database } from "bun:sqlite";
17
17
  import { SqliteSkillSelectionStore } from "./selection-store.js";
18
- import type { ApiPrincipal, ClaimRunInput, CreateRunInput, PublishSkillInput, RunTransitionPatch, ServerArtifact, ServerPin, ServerRunLog, ServerRunRecord, ServerSkillBundle, ServerSkillVersion, ApiKeyScopeUpdateResult, ServerSkillRecord, SkillLifecyclePatch, SkillsProductStore, StoreBackendInfo, UpdateSkillPatch } from "./types.js";
18
+ import type { ApiPrincipal, ClaimRunInput, CreateRunInput, PublishSkillInput, RunTransitionPatch, ServerArtifact, ServerPin, ServerRunLog, ServerRunRecord, ServerSkillBundle, ServerSkillVersion, ApiKeyScopeUpdateResult, OperatorScopeEnrollmentInput, OperatorScopeEnrollmentResult, OperatorScopeTargetSnapshot, ServerSkillRecord, SkillLifecyclePatch, SkillsProductStore, StoreBackendInfo, UpdateSkillPatch } from "./types.js";
19
19
  export interface SqliteStoreOptions {
20
20
  /** Apply pending migrations on open. Default true - it is what makes zero-config work. */
21
21
  migrate?: boolean;
@@ -53,6 +53,8 @@ export declare class SqliteSkillsStore implements SkillsProductStore {
53
53
  ensureBootstrapApiKey(token: string, principal?: Partial<ApiPrincipal>): Promise<void>;
54
54
  authenticateApiKeyHash(hash: string): Promise<ApiPrincipal | null>;
55
55
  updateApiKeyScopes(actor: ApiPrincipal, keyId: string, expectedScopes: string[], addScopes: string[]): Promise<ApiKeyScopeUpdateResult>;
56
+ enrollPublishScopeByOperator(input: OperatorScopeEnrollmentInput): Promise<OperatorScopeEnrollmentResult>;
57
+ inspectOperatorScopeTarget(keyId: string, orgId: string): Promise<OperatorScopeTargetSnapshot>;
56
58
  createRun(input: CreateRunInput): Promise<ServerRunRecord>;
57
59
  listRuns(principal: ApiPrincipal, limit: number): Promise<ServerRunRecord[]>;
58
60
  getRun(principal: ApiPrincipal, id: string): Promise<ServerRunRecord | null>;
@@ -1,6 +1,6 @@
1
1
  import { MemoryExecutionGrantStore, PostgresExecutionGrantStore } from "./execution-grant-store.js";
2
2
  import { MemorySkillSelectionStore, PostgresSkillSelectionStore } from "./selection-store.js";
3
- import type { ApiPrincipal, ClaimRunInput, CreateRunInput, PublishSkillInput, RunTransitionPatch, ServerArtifact, ServerPin, ServerRunLog, ServerRunRecord, ServerSkillBundle, ServerSkillRecord, ServerSkillVersion, ApiKeyScopeUpdateResult, SkillsProductStore, StoreBackendInfo, UpdateSkillPatch } from "./types.js";
3
+ import type { ApiPrincipal, ClaimRunInput, CreateRunInput, PublishSkillInput, RunTransitionPatch, ServerArtifact, ServerPin, ServerRunLog, ServerRunRecord, ServerSkillBundle, ServerSkillRecord, ServerSkillVersion, ApiKeyScopeUpdateResult, OperatorScopeEnrollmentInput, OperatorScopeEnrollmentResult, OperatorScopeTargetSnapshot, SkillsProductStore, StoreBackendInfo, UpdateSkillPatch } from "./types.js";
4
4
  import { type SkillLifecyclePatch } from "./types.js";
5
5
  import { type SqliteStoreOptions } from "./sqlite-store.js";
6
6
  export declare function createArtifactId(): string;
@@ -38,6 +38,7 @@ export declare class MemorySkillsStore implements SkillsProductStore {
38
38
  private bundles;
39
39
  private versions;
40
40
  private pins;
41
+ private operatorEnrollments;
41
42
  constructor(apiKeys?: Array<{
42
43
  token: string;
43
44
  principal?: Partial<ApiPrincipal>;
@@ -46,6 +47,8 @@ export declare class MemorySkillsStore implements SkillsProductStore {
46
47
  ensureBootstrapApiKey(token: string, principal?: Partial<ApiPrincipal>): Promise<void>;
47
48
  authenticateApiKeyHash(hash: string): Promise<ApiPrincipal | null>;
48
49
  updateApiKeyScopes(actor: ApiPrincipal, keyId: string, expectedScopes: string[], addScopes: string[]): Promise<ApiKeyScopeUpdateResult>;
50
+ enrollPublishScopeByOperator(input: OperatorScopeEnrollmentInput): Promise<OperatorScopeEnrollmentResult>;
51
+ inspectOperatorScopeTarget(keyId: string, orgId: string): Promise<OperatorScopeTargetSnapshot>;
49
52
  createRun(input: CreateRunInput): Promise<ServerRunRecord>;
50
53
  listRuns(principal: ApiPrincipal, limit: number): Promise<ServerRunRecord[]>;
51
54
  getRun(principal: ApiPrincipal, runId: string): Promise<ServerRunRecord | null>;
@@ -122,6 +125,9 @@ export declare class PostgresSkillsStore implements SkillsProductStore {
122
125
  ensureBootstrapApiKey(token: string, principal?: Partial<ApiPrincipal>): Promise<void>;
123
126
  authenticateApiKeyHash(hash: string): Promise<ApiPrincipal | null>;
124
127
  updateApiKeyScopes(actor: ApiPrincipal, keyId: string, expectedScopes: string[], addScopes: string[]): Promise<ApiKeyScopeUpdateResult>;
128
+ private casUpdateApiKeyScopes;
129
+ enrollPublishScopeByOperator(input: OperatorScopeEnrollmentInput): Promise<OperatorScopeEnrollmentResult>;
130
+ inspectOperatorScopeTarget(keyId: string, orgId: string): Promise<OperatorScopeTargetSnapshot>;
125
131
  /**
126
132
  * Run a callback under an RLS tenant or worker context, on one pooled
127
133
  * connection, for the duration of one transaction only.
@@ -69,6 +69,47 @@ export type ApiKeyScopeUpdateResult = {
69
69
  kind: "stale";
70
70
  scopes: string[];
71
71
  };
72
+ /**
73
+ * A maintenance task's provenance is deliberately separate from ApiPrincipal.
74
+ * An ECS task is not a user and must never be represented by impersonating an
75
+ * API key, user id, or role in the audit row.
76
+ */
77
+ export interface OperatorScopeEnrollmentInput {
78
+ keyId: string;
79
+ stationId: string;
80
+ orgId: string;
81
+ expectedScopes: string[];
82
+ operationId: string;
83
+ manifestDigest: string;
84
+ operatorJobId: string;
85
+ operatorTaskArn: string;
86
+ }
87
+ export declare function validOperatorScopeEnrollmentInput(input: OperatorScopeEnrollmentInput): boolean;
88
+ export declare function validOperatorScopeList(scopes: unknown): scopes is string[];
89
+ export type OperatorScopeEnrollmentResult = {
90
+ kind: "updated";
91
+ scopes: string[];
92
+ } | {
93
+ kind: "already_applied";
94
+ scopes: string[];
95
+ } | {
96
+ kind: "not_found";
97
+ } | {
98
+ kind: "target_mismatch";
99
+ } | {
100
+ kind: "invalid";
101
+ } | {
102
+ kind: "stale";
103
+ scopes: string[];
104
+ };
105
+ export type OperatorScopeTargetSnapshot = {
106
+ kind: "not_found";
107
+ } | {
108
+ kind: "target_mismatch";
109
+ } | {
110
+ kind: "found";
111
+ scopes: string[];
112
+ };
72
113
  export interface ServerRunRecord {
73
114
  id: string;
74
115
  orgId: string;
@@ -339,6 +380,9 @@ export interface SkillsProductStore {
339
380
  authenticateApiKeyHash(hash: string): Promise<ApiPrincipal | null>;
340
381
  /** Guarded metadata-only scope admission for an existing key. */
341
382
  updateApiKeyScopes?(actor: ApiPrincipal, keyId: string, expectedScopes: string[], addScopes: string[]): Promise<ApiKeyScopeUpdateResult>;
383
+ /** Add only skills:publish to an existing key from a protected maintenance task. */
384
+ enrollPublishScopeByOperator?(input: OperatorScopeEnrollmentInput): Promise<OperatorScopeEnrollmentResult>;
385
+ inspectOperatorScopeTarget?(keyId: string, orgId: string): Promise<OperatorScopeTargetSnapshot>;
342
386
  ensureBootstrapApiKey?(token: string, principal?: Partial<ApiPrincipal>): Promise<void>;
343
387
  createRun(input: CreateRunInput): Promise<ServerRunRecord>;
344
388
  listRuns(principal: ApiPrincipal, limit: number): Promise<ServerRunRecord[]>;
@@ -0,0 +1,9 @@
1
+ ALTER TABLE skills_audit_events ADD COLUMN operator_operation_id text;
2
+
3
+ -- One operation may be retried, but it must produce one immutable receipt per
4
+ -- tenant/key. The partial expression index makes that property atomic under
5
+ -- concurrent maintenance tasks; application pre-reads are not the guard.
6
+ CREATE UNIQUE INDEX IF NOT EXISTS skills_operator_scope_receipt_idx
7
+ ON skills_audit_events (operator_operation_id)
8
+ WHERE action = 'api_key_scopes_added'
9
+ AND operator_operation_id IS NOT NULL;
@@ -0,0 +1,7 @@
1
+ ALTER TABLE skills_audit_events ADD COLUMN operator_operation_id text;
2
+
3
+ -- SQLite equivalent of the PostgreSQL operation-receipt uniqueness fence.
4
+ CREATE UNIQUE INDEX IF NOT EXISTS skills_operator_scope_receipt_idx
5
+ ON skills_audit_events (operator_operation_id)
6
+ WHERE action = 'api_key_scopes_added'
7
+ AND operator_operation_id IS NOT NULL;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hasna/skills",
3
- "version": "0.9.2",
3
+ "version": "0.9.3",
4
4
  "description": "Skills library for AI coding agents",
5
5
  "type": "module",
6
6
  "bin": {
@@ -9,6 +9,7 @@
9
9
  "skills-serve": "bin/server.js",
10
10
  "skills-server": "bin/server.js",
11
11
  "skills-worker": "bin/worker.js",
12
+ "skills-maintenance": "bin/maintenance.js",
12
13
  "skills-migrate": "bin/migrate.js"
13
14
  },
14
15
  "exports": {
@@ -49,7 +50,7 @@
49
50
  "types": "./dist/index.d.ts",
50
51
  "scripts": {
51
52
  "clean": "rm -rf bin/ dist/",
52
- "build": "bun run clean && bun build ./src/cli/index.tsx --outdir ./bin --target bun && bun build ./src/mcp/index.ts --outfile ./bin/mcp.js --target bun && bun build ./src/server/index.ts --outfile ./bin/server.js --target bun && bun build ./src/server/worker.ts --outfile ./bin/worker.js --target bun && bun build ./src/server/migrate.ts --outfile ./bin/migrate.js --target bun && bun build ./src/index.ts ./src/storage.ts ./src/sdk/index.ts ./src/admin-contract.ts --outdir ./dist --target bun && tsc --emitDeclarationOnly --declaration --outDir dist",
53
+ "build": "bun run clean && bun build ./src/cli/index.tsx --outdir ./bin --target bun && bun build ./src/mcp/index.ts --outfile ./bin/mcp.js --target bun && bun build ./src/server/index.ts --outfile ./bin/server.js --target bun && bun build ./src/server/worker.ts --outfile ./bin/worker.js --target bun && bun build ./src/server/migrate.ts --outfile ./bin/migrate.js --target bun && bun build ./src/server/maintenance.ts --outfile ./bin/maintenance.js --target bun && bun build ./src/index.ts ./src/storage.ts ./src/sdk/index.ts ./src/admin-contract.ts --outdir ./dist --target bun && tsc --emitDeclarationOnly --declaration --outDir dist",
53
54
  "build:js": "rm -rf dist && bun build ./src/index.ts ./src/storage.ts ./src/sdk/index.ts ./src/admin-contract.ts --outdir ./dist --target bun && tsc --emitDeclarationOnly --declaration --outDir dist",
54
55
  "test": "bun test --timeout 30000",
55
56
  "dev": "bun run ./src/cli/index.tsx",