@skillsmith/core 0.11.4 → 0.11.6

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.
Files changed (240) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/README.md +10 -7
  3. package/dist/.tsbuildinfo +1 -1
  4. package/dist/src/api/client.d.ts.map +1 -1
  5. package/dist/src/api/client.js +20 -5
  6. package/dist/src/api/client.js.map +1 -1
  7. package/dist/src/api/client.private-registry.d.ts +80 -0
  8. package/dist/src/api/client.private-registry.d.ts.map +1 -0
  9. package/dist/src/api/client.private-registry.js +97 -0
  10. package/dist/src/api/client.private-registry.js.map +1 -0
  11. package/dist/src/api/client.private-registry.test.d.ts +9 -0
  12. package/dist/src/api/client.private-registry.test.d.ts.map +1 -0
  13. package/dist/src/api/client.private-registry.test.js +127 -0
  14. package/dist/src/api/client.private-registry.test.js.map +1 -0
  15. package/dist/src/api/client.test.js +39 -0
  16. package/dist/src/api/client.test.js.map +1 -1
  17. package/dist/src/api/client.toSkill.test.d.ts +25 -0
  18. package/dist/src/api/client.toSkill.test.d.ts.map +1 -0
  19. package/dist/src/api/client.toSkill.test.js +97 -0
  20. package/dist/src/api/client.toSkill.test.js.map +1 -0
  21. package/dist/src/api/client.token-refresh.d.ts +8 -0
  22. package/dist/src/api/client.token-refresh.d.ts.map +1 -1
  23. package/dist/src/api/client.token-refresh.js +26 -0
  24. package/dist/src/api/client.token-refresh.js.map +1 -1
  25. package/dist/src/api/client.token-refresh.test.js +82 -0
  26. package/dist/src/api/client.token-refresh.test.js.map +1 -1
  27. package/dist/src/api/index.d.ts +2 -0
  28. package/dist/src/api/index.d.ts.map +1 -1
  29. package/dist/src/api/index.js +9 -0
  30. package/dist/src/api/index.js.map +1 -1
  31. package/dist/src/api/security-summary.d.ts +73 -0
  32. package/dist/src/api/security-summary.d.ts.map +1 -0
  33. package/dist/src/api/security-summary.js +88 -0
  34. package/dist/src/api/security-summary.js.map +1 -0
  35. package/dist/src/api/security-summary.test.d.ts +9 -0
  36. package/dist/src/api/security-summary.test.d.ts.map +1 -0
  37. package/dist/src/api/security-summary.test.js +94 -0
  38. package/dist/src/api/security-summary.test.js.map +1 -0
  39. package/dist/src/compatibility/slugs.d.ts +7 -3
  40. package/dist/src/compatibility/slugs.d.ts.map +1 -1
  41. package/dist/src/compatibility/slugs.js +11 -4
  42. package/dist/src/compatibility/slugs.js.map +1 -1
  43. package/dist/src/config/config-atomic-write.d.ts +30 -10
  44. package/dist/src/config/config-atomic-write.d.ts.map +1 -1
  45. package/dist/src/config/config-atomic-write.js +33 -80
  46. package/dist/src/config/config-atomic-write.js.map +1 -1
  47. package/dist/src/config/config-atomic-write.test.d.ts +8 -0
  48. package/dist/src/config/config-atomic-write.test.d.ts.map +1 -1
  49. package/dist/src/config/config-atomic-write.test.js +64 -12
  50. package/dist/src/config/config-atomic-write.test.js.map +1 -1
  51. package/dist/src/config/owned-lock.acquire.d.ts +53 -0
  52. package/dist/src/config/owned-lock.acquire.d.ts.map +1 -0
  53. package/dist/src/config/owned-lock.acquire.js +140 -0
  54. package/dist/src/config/owned-lock.acquire.js.map +1 -0
  55. package/dist/src/config/owned-lock.claim.d.ts +84 -0
  56. package/dist/src/config/owned-lock.claim.d.ts.map +1 -0
  57. package/dist/src/config/owned-lock.claim.js +310 -0
  58. package/dist/src/config/owned-lock.claim.js.map +1 -0
  59. package/dist/src/config/owned-lock.d.ts +113 -0
  60. package/dist/src/config/owned-lock.d.ts.map +1 -0
  61. package/dist/src/config/owned-lock.js +121 -0
  62. package/dist/src/config/owned-lock.js.map +1 -0
  63. package/dist/src/config/owned-lock.test.d.ts +12 -0
  64. package/dist/src/config/owned-lock.test.d.ts.map +1 -0
  65. package/dist/src/config/owned-lock.test.js +412 -0
  66. package/dist/src/config/owned-lock.test.js.map +1 -0
  67. package/dist/src/config/owned-lock.types.d.ts +78 -0
  68. package/dist/src/config/owned-lock.types.d.ts.map +1 -0
  69. package/dist/src/config/owned-lock.types.js +30 -0
  70. package/dist/src/config/owned-lock.types.js.map +1 -0
  71. package/dist/src/db/createDatabase.d.ts.map +1 -1
  72. package/dist/src/db/createDatabase.js +19 -7
  73. package/dist/src/db/createDatabase.js.map +1 -1
  74. package/dist/src/db/drivers/sqljsDriver.d.ts +8 -0
  75. package/dist/src/db/drivers/sqljsDriver.d.ts.map +1 -1
  76. package/dist/src/db/drivers/sqljsDriver.js +24 -2
  77. package/dist/src/db/drivers/sqljsDriver.js.map +1 -1
  78. package/dist/src/db/migration-runner.d.ts.map +1 -1
  79. package/dist/src/db/migration-runner.js +23 -2
  80. package/dist/src/db/migration-runner.js.map +1 -1
  81. package/dist/src/db/schema.d.ts.map +1 -1
  82. package/dist/src/db/schema.js +29 -3
  83. package/dist/src/db/schema.js.map +1 -1
  84. package/dist/src/embeddings/hnsw-store.exports.d.ts +11 -0
  85. package/dist/src/embeddings/hnsw-store.exports.d.ts.map +1 -0
  86. package/dist/src/embeddings/hnsw-store.exports.js +10 -0
  87. package/dist/src/embeddings/hnsw-store.exports.js.map +1 -0
  88. package/dist/src/embeddings/index.d.ts +1 -2
  89. package/dist/src/embeddings/index.d.ts.map +1 -1
  90. package/dist/src/embeddings/index.js +17 -3
  91. package/dist/src/embeddings/index.js.map +1 -1
  92. package/dist/src/embeddings/probe.d.ts.map +1 -1
  93. package/dist/src/embeddings/probe.js +4 -8
  94. package/dist/src/embeddings/probe.js.map +1 -1
  95. package/dist/src/exports/services.d.ts +5 -1
  96. package/dist/src/exports/services.d.ts.map +1 -1
  97. package/dist/src/exports/services.js +27 -0
  98. package/dist/src/exports/services.js.map +1 -1
  99. package/dist/src/index.d.ts +3 -3
  100. package/dist/src/index.d.ts.map +1 -1
  101. package/dist/src/index.js +9 -3
  102. package/dist/src/index.js.map +1 -1
  103. package/dist/src/install/index.d.ts +2 -2
  104. package/dist/src/install/index.d.ts.map +1 -1
  105. package/dist/src/install/index.js +1 -1
  106. package/dist/src/install/index.js.map +1 -1
  107. package/dist/src/install/paths.d.ts +165 -1
  108. package/dist/src/install/paths.d.ts.map +1 -1
  109. package/dist/src/install/paths.js +254 -1
  110. package/dist/src/install/paths.js.map +1 -1
  111. package/dist/src/install/paths.test.js +174 -2
  112. package/dist/src/install/paths.test.js.map +1 -1
  113. package/dist/src/security/scanner/multiline-category-closure.test.d.ts +80 -0
  114. package/dist/src/security/scanner/multiline-category-closure.test.d.ts.map +1 -0
  115. package/dist/src/security/scanner/multiline-category-closure.test.js +287 -0
  116. package/dist/src/security/scanner/multiline-category-closure.test.js.map +1 -0
  117. package/dist/src/services/context-words.d.ts +40 -0
  118. package/dist/src/services/context-words.d.ts.map +1 -0
  119. package/dist/src/services/context-words.js +178 -0
  120. package/dist/src/services/context-words.js.map +1 -0
  121. package/dist/src/services/context-words.test.d.ts +2 -0
  122. package/dist/src/services/context-words.test.d.ts.map +1 -0
  123. package/dist/src/services/context-words.test.js +94 -0
  124. package/dist/src/services/context-words.test.js.map +1 -0
  125. package/dist/src/services/recommend-guard.d.ts +28 -0
  126. package/dist/src/services/recommend-guard.d.ts.map +1 -0
  127. package/dist/src/services/recommend-guard.js +34 -0
  128. package/dist/src/services/recommend-guard.js.map +1 -0
  129. package/dist/src/services/recommend-guard.test.d.ts +2 -0
  130. package/dist/src/services/recommend-guard.test.d.ts.map +1 -0
  131. package/dist/src/services/recommend-guard.test.js +28 -0
  132. package/dist/src/services/recommend-guard.test.js.map +1 -0
  133. package/dist/src/services/skill-installation.content.d.ts +96 -0
  134. package/dist/src/services/skill-installation.content.d.ts.map +1 -0
  135. package/dist/src/services/skill-installation.content.js +319 -0
  136. package/dist/src/services/skill-installation.content.js.map +1 -0
  137. package/dist/src/services/skill-installation.helpers.d.ts +32 -1
  138. package/dist/src/services/skill-installation.helpers.d.ts.map +1 -1
  139. package/dist/src/services/skill-installation.helpers.js +62 -7
  140. package/dist/src/services/skill-installation.helpers.js.map +1 -1
  141. package/dist/src/services/skill-installation.helpers.test.js +108 -2
  142. package/dist/src/services/skill-installation.helpers.test.js.map +1 -1
  143. package/dist/src/services/skill-installation.io.d.ts +22 -1
  144. package/dist/src/services/skill-installation.io.d.ts.map +1 -1
  145. package/dist/src/services/skill-installation.io.js +110 -6
  146. package/dist/src/services/skill-installation.io.js.map +1 -1
  147. package/dist/src/services/skill-installation.service.d.ts +22 -1
  148. package/dist/src/services/skill-installation.service.d.ts.map +1 -1
  149. package/dist/src/services/skill-installation.service.js +30 -5
  150. package/dist/src/services/skill-installation.service.js.map +1 -1
  151. package/dist/src/services/skill-installation.types.d.ts +42 -1
  152. package/dist/src/services/skill-installation.types.d.ts.map +1 -1
  153. package/dist/src/services/skill-installation.types.js.map +1 -1
  154. package/dist/src/services/skill-manifest.d.ts +21 -0
  155. package/dist/src/services/skill-manifest.d.ts.map +1 -1
  156. package/dist/src/services/skill-manifest.js +51 -6
  157. package/dist/src/services/skill-manifest.js.map +1 -1
  158. package/dist/src/services/skill-manifest.test.d.ts +2 -0
  159. package/dist/src/services/skill-manifest.test.d.ts.map +1 -0
  160. package/dist/src/services/skill-manifest.test.js +202 -0
  161. package/dist/src/services/skill-manifest.test.js.map +1 -0
  162. package/dist/src/services/skill-resolution.d.ts +67 -0
  163. package/dist/src/services/skill-resolution.d.ts.map +1 -0
  164. package/dist/src/services/skill-resolution.js +67 -0
  165. package/dist/src/services/skill-resolution.js.map +1 -0
  166. package/dist/src/services/skill-resolution.test.d.ts +2 -0
  167. package/dist/src/services/skill-resolution.test.d.ts.map +1 -0
  168. package/dist/src/services/skill-resolution.test.js +143 -0
  169. package/dist/src/services/skill-resolution.test.js.map +1 -0
  170. package/dist/src/types/skill.d.ts +10 -0
  171. package/dist/src/types/skill.d.ts.map +1 -1
  172. package/dist/src/types.d.ts +9 -5
  173. package/dist/src/types.d.ts.map +1 -1
  174. package/dist/src/utils/index.d.ts +1 -0
  175. package/dist/src/utils/index.d.ts.map +1 -1
  176. package/dist/src/utils/index.js +4 -0
  177. package/dist/src/utils/index.js.map +1 -1
  178. package/dist/src/utils/quiet-mode.d.ts +21 -0
  179. package/dist/src/utils/quiet-mode.d.ts.map +1 -0
  180. package/dist/src/utils/quiet-mode.js +26 -0
  181. package/dist/src/utils/quiet-mode.js.map +1 -0
  182. package/dist/tests/EmbeddingService.test.js +58 -1
  183. package/dist/tests/EmbeddingService.test.js.map +1 -1
  184. package/dist/tests/SecurityScanner.performance.test.js +5 -2
  185. package/dist/tests/SecurityScanner.performance.test.js.map +1 -1
  186. package/dist/tests/db/database-abstraction.test.js +45 -1
  187. package/dist/tests/db/database-abstraction.test.js.map +1 -1
  188. package/dist/tests/db/migration-runner-race.test.d.ts +2 -0
  189. package/dist/tests/db/migration-runner-race.test.d.ts.map +1 -0
  190. package/dist/tests/db/migration-runner-race.test.js +75 -0
  191. package/dist/tests/db/migration-runner-race.test.js.map +1 -0
  192. package/dist/tests/db/schema-async.test.js +37 -1
  193. package/dist/tests/db/schema-async.test.js.map +1 -1
  194. package/dist/tests/db/sqljsDriver.test.js +63 -1
  195. package/dist/tests/db/sqljsDriver.test.js.map +1 -1
  196. package/dist/tests/helpers/deterministic-dead-pid.d.ts +13 -0
  197. package/dist/tests/helpers/deterministic-dead-pid.d.ts.map +1 -0
  198. package/dist/tests/helpers/deterministic-dead-pid.js +31 -0
  199. package/dist/tests/helpers/deterministic-dead-pid.js.map +1 -0
  200. package/dist/tests/integration/owned-lock-lost-update.test.d.ts +12 -0
  201. package/dist/tests/integration/owned-lock-lost-update.test.d.ts.map +1 -0
  202. package/dist/tests/integration/owned-lock-lost-update.test.js +52 -0
  203. package/dist/tests/integration/owned-lock-lost-update.test.js.map +1 -0
  204. package/dist/tests/integration/owned-lock-reclaim-race.test.d.ts +19 -0
  205. package/dist/tests/integration/owned-lock-reclaim-race.test.d.ts.map +1 -0
  206. package/dist/tests/integration/owned-lock-reclaim-race.test.js +177 -0
  207. package/dist/tests/integration/owned-lock-reclaim-race.test.js.map +1 -0
  208. package/dist/tests/schema.test.js +43 -1
  209. package/dist/tests/schema.test.js.map +1 -1
  210. package/dist/tests/unit/services/skill-installation.content.test.d.ts +10 -0
  211. package/dist/tests/unit/services/skill-installation.content.test.d.ts.map +1 -0
  212. package/dist/tests/unit/services/skill-installation.content.test.js +290 -0
  213. package/dist/tests/unit/services/skill-installation.content.test.js.map +1 -0
  214. package/dist/tests/unit/services/skill-installation.io.client-agent-path.test.d.ts +34 -0
  215. package/dist/tests/unit/services/skill-installation.io.client-agent-path.test.d.ts.map +1 -0
  216. package/dist/tests/unit/services/skill-installation.io.client-agent-path.test.js +246 -0
  217. package/dist/tests/unit/services/skill-installation.io.client-agent-path.test.js.map +1 -0
  218. package/dist/tests/unit/services/skill-installation.io.symlink.test.d.ts +8 -0
  219. package/dist/tests/unit/services/skill-installation.io.symlink.test.d.ts.map +1 -0
  220. package/dist/tests/unit/services/skill-installation.io.symlink.test.js +72 -0
  221. package/dist/tests/unit/services/skill-installation.io.symlink.test.js.map +1 -0
  222. package/dist/tests/unit/services/skill-installation.service.companion-basedir.test.d.ts +17 -0
  223. package/dist/tests/unit/services/skill-installation.service.companion-basedir.test.d.ts.map +1 -0
  224. package/dist/tests/unit/services/skill-installation.service.companion-basedir.test.js +112 -0
  225. package/dist/tests/unit/services/skill-installation.service.companion-basedir.test.js.map +1 -0
  226. package/dist/tests/unit/services/skill-installation.service.error-codes.test.d.ts +15 -0
  227. package/dist/tests/unit/services/skill-installation.service.error-codes.test.d.ts.map +1 -0
  228. package/dist/tests/unit/services/skill-installation.service.error-codes.test.js +190 -0
  229. package/dist/tests/unit/services/skill-installation.service.error-codes.test.js.map +1 -0
  230. package/dist/tests/unit/services/skill-installation.service.multi-client.test.d.ts +17 -0
  231. package/dist/tests/unit/services/skill-installation.service.multi-client.test.d.ts.map +1 -0
  232. package/dist/tests/unit/services/skill-installation.service.multi-client.test.js +126 -0
  233. package/dist/tests/unit/services/skill-installation.service.multi-client.test.js.map +1 -0
  234. package/dist/tests/unit/services/skill-installation.service.test.js +7 -112
  235. package/dist/tests/unit/services/skill-installation.service.test.js.map +1 -1
  236. package/dist/tests/utils/quiet-mode.test.d.ts +12 -0
  237. package/dist/tests/utils/quiet-mode.test.d.ts.map +1 -0
  238. package/dist/tests/utils/quiet-mode.test.js +42 -0
  239. package/dist/tests/utils/quiet-mode.test.js.map +1 -0
  240. package/package.json +17 -5
@@ -0,0 +1,40 @@
1
+ /**
2
+ * @fileoverview Shared context-word extraction for skill recommendations.
3
+ * @module @skillsmith/core/services/context-words
4
+ * @see SMI-5986: CLI `recommend --context` (`recommend.ts:76`) and MCP
5
+ * `skill_recommend`'s `project_context` (`recommend.ts:127`) each derived a
6
+ * "context words" slice for the recommendation stack via
7
+ * `.filter((w) => w.length > 3)` — a bare length threshold that silently
8
+ * dropped real 2-3 character technical terms ("git", "ci", "aws", "sql")
9
+ * that happen to be short. When a caller's context consisted only of such
10
+ * terms, the resulting derived stack was empty and the SMI-5896
11
+ * empty-stack guard (`buildEmptyStackGuidance`, this module's sibling)
12
+ * fired even though the caller *did* supply usable context.
13
+ *
14
+ * Shared here (not duplicated per-twin) so CLI and MCP can't independently
15
+ * drift on what counts as noise vs. a real short technical term — the same
16
+ * class of duplication risk `buildEmptyStackGuidance` above already closed
17
+ * for the empty-stack message itself (plan-review correction, SMI-5984
18
+ * Wave 1: a bare length threshold "admits noise words" and leaves the two
19
+ * twins free to drift apart again independently).
20
+ */
21
+ /**
22
+ * Extract up to `maxWords` usable technical terms from free-text project
23
+ * context for the recommendation stack.
24
+ *
25
+ * Replaces the old `.filter((w) => w.length > 3)` threshold: real technical
26
+ * terms of 2-3 characters ("git", "ci", "aws", "sql") are now kept, while
27
+ * single-character tokens, punctuation-only tokens (including one that
28
+ * survives edge-stripping because it's made entirely of `+`/`#`, e.g. "++"
29
+ * or "+#" — PR review round 3, SMI-5986), and short English function words
30
+ * ("a", "the", "is", ...) are still dropped as noise. Words of 4+
31
+ * characters are unaffected — this only changes the outcome for the short
32
+ * end of the spectrum the length threshold got wrong.
33
+ *
34
+ * @param projectContext - Free-text project/context description. `null`/
35
+ * `undefined`/empty returns `[]`, mirroring both callers' pre-existing
36
+ * `if (project_context)` guard.
37
+ * @param maxWords - Maximum number of words to return (default 5).
38
+ */
39
+ export declare function extractContextWords(projectContext: string | undefined | null, maxWords?: number): string[];
40
+ //# sourceMappingURL=context-words.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"context-words.d.ts","sourceRoot":"","sources":["../../../src/services/context-words.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAmIH;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,mBAAmB,CACjC,cAAc,EAAE,MAAM,GAAG,SAAS,GAAG,IAAI,EACzC,QAAQ,GAAE,MAA0B,GACnC,MAAM,EAAE,CAcV"}
@@ -0,0 +1,178 @@
1
+ /**
2
+ * @fileoverview Shared context-word extraction for skill recommendations.
3
+ * @module @skillsmith/core/services/context-words
4
+ * @see SMI-5986: CLI `recommend --context` (`recommend.ts:76`) and MCP
5
+ * `skill_recommend`'s `project_context` (`recommend.ts:127`) each derived a
6
+ * "context words" slice for the recommendation stack via
7
+ * `.filter((w) => w.length > 3)` — a bare length threshold that silently
8
+ * dropped real 2-3 character technical terms ("git", "ci", "aws", "sql")
9
+ * that happen to be short. When a caller's context consisted only of such
10
+ * terms, the resulting derived stack was empty and the SMI-5896
11
+ * empty-stack guard (`buildEmptyStackGuidance`, this module's sibling)
12
+ * fired even though the caller *did* supply usable context.
13
+ *
14
+ * Shared here (not duplicated per-twin) so CLI and MCP can't independently
15
+ * drift on what counts as noise vs. a real short technical term — the same
16
+ * class of duplication risk `buildEmptyStackGuidance` above already closed
17
+ * for the empty-stack message itself (plan-review correction, SMI-5984
18
+ * Wave 1: a bare length threshold "admits noise words" and leaves the two
19
+ * twins free to drift apart again independently).
20
+ */
21
+ /** Maximum context words carried into the recommendation stack (matches both callers' pre-existing `.slice(0, 5)`). */
22
+ const MAX_CONTEXT_WORDS = 5;
23
+ /**
24
+ * Common short English function words that would otherwise pass a
25
+ * length-based filter and pollute the recommendation stack as noise. Kept
26
+ * intentionally short and grammar-only — no 2-3 letter technical acronym
27
+ * ("ci", "ai", "ml", "db", "os", "ui", "js", "go", "git", "aws", "sql",
28
+ * "cli", "api", "sdk", "css") appears here, since those are exactly the real
29
+ * terms this fix exists to stop dropping.
30
+ */
31
+ const CONTEXT_STOPWORDS = new Set([
32
+ // 2-letter
33
+ 'an',
34
+ 'is',
35
+ 'it',
36
+ 'be',
37
+ 'to',
38
+ 'of',
39
+ 'in',
40
+ 'on',
41
+ 'at',
42
+ 'as',
43
+ 'or',
44
+ 'if',
45
+ 'so',
46
+ 'no',
47
+ 'do',
48
+ 'am',
49
+ 'we',
50
+ 'us',
51
+ 'he',
52
+ 'my',
53
+ 'up',
54
+ // 3-letter
55
+ 'the',
56
+ 'and',
57
+ 'but',
58
+ 'not',
59
+ 'did',
60
+ 'has',
61
+ 'had',
62
+ 'she',
63
+ 'him',
64
+ 'her',
65
+ 'its',
66
+ 'our',
67
+ 'all',
68
+ 'few',
69
+ 'out',
70
+ 'off',
71
+ 'via',
72
+ 'per',
73
+ 'own',
74
+ 'too',
75
+ 'yet',
76
+ 'nor',
77
+ 'you',
78
+ 'are',
79
+ 'was',
80
+ 'for',
81
+ 'may',
82
+ 'who',
83
+ 'why',
84
+ 'how',
85
+ // Code-review correction (SMI-5986): "any" (TS/SQL keyword), "let"
86
+ // (JS/Rust keyword), and "can" (CAN-bus acronym) were removed from this
87
+ // set — each is a real technical term this fix exists to stop dropping,
88
+ // same class as "git"/"ci"/"aws"/"sql". Leaving them in would have
89
+ // contradicted this list's own documented promise above.
90
+ ]);
91
+ /**
92
+ * Whether a single character counts as "keep" (not strippable edge
93
+ * punctuation) — see `stripEdgePunctuation`. Plain character-code range
94
+ * checks, not a regex — this is the actual "no regex" implementation (PR
95
+ * review round 3, SMI-5986): round 2's fix still called `.test()` against a
96
+ * single-character-class regex, which isn't the polynomial-backtracking
97
+ * shape CodeQL flags, but didn't match its own doc comment's "manual
98
+ * index-scan rather than a regex" claim either.
99
+ */
100
+ function isKeepChar(ch) {
101
+ return (ch >= 'a' && ch <= 'z') || (ch >= '0' && ch <= '9') || ch === '+' || ch === '#';
102
+ }
103
+ /**
104
+ * Whether a word contains at least one alphanumeric character — used to
105
+ * reject punctuation-only tokens ("++", "##", "+#") that `isKeepChar` alone
106
+ * would let through, since `+`/`#` count as "keep" but aren't themselves
107
+ * usable content (PR review round 3, SMI-5986: `extractContextWords('++ ##
108
+ * +#')` was returning `['++', '##', '+#']` — all three passed the length
109
+ * filter with no alphanumeric substance at all).
110
+ */
111
+ function hasAlphaNumeric(word) {
112
+ for (let i = 0; i < word.length; i++) {
113
+ const ch = word[i];
114
+ if ((ch >= 'a' && ch <= 'z') || (ch >= '0' && ch <= '9'))
115
+ return true;
116
+ }
117
+ return false;
118
+ }
119
+ /**
120
+ * Strip leading/trailing punctuation from an already-lowercased token
121
+ * without touching interior characters — "k8s" is untouched, but "git," ->
122
+ * "git" and "(sql)" -> "sql". Real-world context strings aren't always
123
+ * clean whitespace-delimited tokens (trailing commas, wrapping parens),
124
+ * and a stray comma would otherwise make "git," fail to match the real
125
+ * "git" term downstream.
126
+ *
127
+ * `+` and `#` count as "keep" characters (code-review correction, SMI-5986)
128
+ * — they're meaningful trailing characters in real technical terms ("c++",
129
+ * "c#"), not punctuation noise; stripping them turned both into the single
130
+ * character "c", which the length filter then discarded entirely. A
131
+ * punctuation-only token surviving this step (e.g. "++") is rejected
132
+ * downstream by `hasAlphaNumeric`, not here — this function only trims
133
+ * edges, it doesn't judge the result's usability.
134
+ *
135
+ * O(n), two linear index scans, no regex anywhere in the call chain
136
+ * (CodeQL js/polynomial-redos, SMI-5986 PR review round 2 — verified
137
+ * empirically, a 200k-character adversarial input runs in under 1ms).
138
+ */
139
+ function stripEdgePunctuation(word) {
140
+ let start = 0;
141
+ while (start < word.length && !isKeepChar(word[start]))
142
+ start++;
143
+ let end = word.length;
144
+ while (end > start && !isKeepChar(word[end - 1]))
145
+ end--;
146
+ return word.slice(start, end);
147
+ }
148
+ /**
149
+ * Extract up to `maxWords` usable technical terms from free-text project
150
+ * context for the recommendation stack.
151
+ *
152
+ * Replaces the old `.filter((w) => w.length > 3)` threshold: real technical
153
+ * terms of 2-3 characters ("git", "ci", "aws", "sql") are now kept, while
154
+ * single-character tokens, punctuation-only tokens (including one that
155
+ * survives edge-stripping because it's made entirely of `+`/`#`, e.g. "++"
156
+ * or "+#" — PR review round 3, SMI-5986), and short English function words
157
+ * ("a", "the", "is", ...) are still dropped as noise. Words of 4+
158
+ * characters are unaffected — this only changes the outcome for the short
159
+ * end of the spectrum the length threshold got wrong.
160
+ *
161
+ * @param projectContext - Free-text project/context description. `null`/
162
+ * `undefined`/empty returns `[]`, mirroring both callers' pre-existing
163
+ * `if (project_context)` guard.
164
+ * @param maxWords - Maximum number of words to return (default 5).
165
+ */
166
+ export function extractContextWords(projectContext, maxWords = MAX_CONTEXT_WORDS) {
167
+ if (!projectContext)
168
+ return [];
169
+ return projectContext
170
+ .toLowerCase()
171
+ .split(/\s+/)
172
+ .map(stripEdgePunctuation)
173
+ .filter((word) => word.length >= 2 &&
174
+ hasAlphaNumeric(word) &&
175
+ !(word.length <= 3 && CONTEXT_STOPWORDS.has(word)))
176
+ .slice(0, maxWords);
177
+ }
178
+ //# sourceMappingURL=context-words.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"context-words.js","sourceRoot":"","sources":["../../../src/services/context-words.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=context-words.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"context-words.test.d.ts","sourceRoot":"","sources":["../../../src/services/context-words.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,94 @@
1
+ /**
2
+ * @fileoverview Unit tests for the shared context-word extraction helper
3
+ * (SMI-5986). Both CLI `recommend --context` and MCP `skill_recommend`'s
4
+ * `project_context` now call this instead of duplicating
5
+ * `.filter((w) => w.length > 3)`, so their behavior can't drift apart.
6
+ * @module @skillsmith/core/services/context-words.test
7
+ */
8
+ import { describe, expect, it } from 'vitest';
9
+ import { extractContextWords } from './context-words.js';
10
+ describe('extractContextWords (SMI-5986)', () => {
11
+ it('keeps real short technical terms that the old length>3 threshold dropped', () => {
12
+ const words = extractContextWords('git ci aws sql k8s');
13
+ expect(words).toEqual(['git', 'ci', 'aws', 'sql', 'k8s']);
14
+ });
15
+ it('does not derive an empty stack from a context of only short technical terms', () => {
16
+ // SMI-5986 regression: this exact shape used to filter to [], which then
17
+ // tripped the SMI-5896 empty-stack guard even though the caller supplied
18
+ // usable context.
19
+ expect(extractContextWords('git').length).toBeGreaterThan(0);
20
+ expect(extractContextWords('ci').length).toBeGreaterThan(0);
21
+ });
22
+ it('drops single-character tokens as noise', () => {
23
+ expect(extractContextWords('a i x')).toEqual([]);
24
+ });
25
+ it('drops short English stopwords but keeps a real term in the same input', () => {
26
+ const words = extractContextWords('a be api testing');
27
+ expect(words).not.toContain('a');
28
+ expect(words).not.toContain('be');
29
+ expect(words).toContain('api');
30
+ expect(words).toContain('testing');
31
+ });
32
+ it('drops punctuation-only tokens', () => {
33
+ expect(extractContextWords('... -- !!')).toEqual([]);
34
+ });
35
+ it('drops tokens made entirely of + and #, not just other punctuation', () => {
36
+ // PR review round 3 (SMI-5986): + and # count as "keep" characters for
37
+ // stripEdgePunctuation (so "c++"/"c#" survive), but a token with no
38
+ // alphanumeric content at all ("++", "##", "+#") must still be rejected
39
+ // as noise — it isn't a real technical term just because it contains a
40
+ // "keep" character.
41
+ expect(extractContextWords('++ ## +#')).toEqual([]);
42
+ expect(extractContextWords('++')).toEqual([]);
43
+ });
44
+ it('drops a token that is leading-punctuation-only after stripping ("+++x" keeps content, "+++" alone does not)', () => {
45
+ expect(extractContextWords('+++')).toEqual([]);
46
+ expect(extractContextWords('+++git')).toEqual(['+++git']);
47
+ });
48
+ it('strips edge punctuation without mangling the term ("git," -> "git")', () => {
49
+ expect(extractContextWords('git,')).toEqual(['git']);
50
+ expect(extractContextWords('(sql)')).toEqual(['sql']);
51
+ });
52
+ it('is case-insensitive ("Git" -> "git")', () => {
53
+ expect(extractContextWords('Git')).toEqual(['git']);
54
+ expect(extractContextWords('AWS SQL')).toEqual(['aws', 'sql']);
55
+ });
56
+ it('preserves + and # as meaningful trailing characters, not strippable punctuation', () => {
57
+ // Code-review regression (SMI-5986): edge-punctuation stripping used to
58
+ // treat "+" and "#" as noise, turning "c++"/"c#" into the single
59
+ // character "c", which the length filter then discarded entirely.
60
+ expect(extractContextWords('c++')).toEqual(['c++']);
61
+ expect(extractContextWords('c#')).toEqual(['c#']);
62
+ expect(extractContextWords('c++,')).toEqual(['c++']);
63
+ expect(extractContextWords('(c#)')).toEqual(['c#']);
64
+ });
65
+ it('does not filter real technical terms that are also common English words', () => {
66
+ // Code-review regression (SMI-5986): "any" (TS/SQL keyword), "let"
67
+ // (JS/Rust keyword), and "can" (CAN-bus acronym) were previously in the
68
+ // stopword set, contradicting its own documented promise.
69
+ expect(extractContextWords('any')).toEqual(['any']);
70
+ expect(extractContextWords('let')).toEqual(['let']);
71
+ expect(extractContextWords('can')).toEqual(['can']);
72
+ });
73
+ it('leaves 4+ character words unaffected (pre-existing behavior preserved)', () => {
74
+ const words = extractContextWords('testing utilities framework');
75
+ expect(words).toEqual(['testing', 'utilities', 'framework']);
76
+ });
77
+ it('slices to the max word count (default 5)', () => {
78
+ const words = extractContextWords('one two three four five six seven');
79
+ expect(words).toHaveLength(5);
80
+ });
81
+ it('respects a custom maxWords argument', () => {
82
+ const words = extractContextWords('git ci aws sql k8s docker', 3);
83
+ expect(words).toEqual(['git', 'ci', 'aws']);
84
+ });
85
+ it('returns [] for undefined, null, or empty input', () => {
86
+ expect(extractContextWords(undefined)).toEqual([]);
87
+ expect(extractContextWords(null)).toEqual([]);
88
+ expect(extractContextWords('')).toEqual([]);
89
+ });
90
+ it('returns [] for whitespace-only input', () => {
91
+ expect(extractContextWords(' ')).toEqual([]);
92
+ });
93
+ });
94
+ //# sourceMappingURL=context-words.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"context-words.test.js","sourceRoot":"","sources":["../../../src/services/context-words.test.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,28 @@
1
+ /**
2
+ * @fileoverview Shared empty-derived-stack guard for skill recommendations.
3
+ * @module @skillsmith/core/services/recommend-guard
4
+ * @see SMI-5896 (Wave 3, discovery-tool consistency): CLI `recommend` and MCP
5
+ * `skill_recommend` each derive a technology "stack" client-side — CLI from
6
+ * codebase analysis (`buildStackFromAnalysis`), MCP from installed skills +
7
+ * project context keywords — and either can legitimately derive `[]` (a
8
+ * non-Node stack, an all-devDeps project, or an unsupported language). The
9
+ * `skills-recommend` edge function hard-rejects an empty `stack` with a 400
10
+ * (defense in depth, intentionally unchanged by this fix — see the plan's
11
+ * Wave 3 Step 2 "decided contract").
12
+ *
13
+ * Both callers now detect the empty-stack case BEFORE calling the API
14
+ * instead of letting the guaranteed-400 round trip happen and reacting to
15
+ * the failure after the fact — this is the one guidance string they both
16
+ * surface, so it can't drift between CLI and MCP wording the way the two
17
+ * tools' pre-fix behavior already had (a hard `process.exit(1)` crash on
18
+ * CLI vs a silently-swallowed warning on MCP for the exact same input
19
+ * shape).
20
+ */
21
+ /**
22
+ * Guidance shown when no technology stack could be derived for
23
+ * recommendations. Written to read naturally from both a terminal (CLI) and
24
+ * an MCP tool response consumed by a calling agent — neither surface names
25
+ * its own flag here, since the same string serves both.
26
+ */
27
+ export declare function buildEmptyStackGuidance(): string;
28
+ //# sourceMappingURL=recommend-guard.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"recommend-guard.d.ts","sourceRoot":"","sources":["../../../src/services/recommend-guard.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAEH;;;;;GAKG;AACH,wBAAgB,uBAAuB,IAAI,MAAM,CAQhD"}
@@ -0,0 +1,34 @@
1
+ /**
2
+ * @fileoverview Shared empty-derived-stack guard for skill recommendations.
3
+ * @module @skillsmith/core/services/recommend-guard
4
+ * @see SMI-5896 (Wave 3, discovery-tool consistency): CLI `recommend` and MCP
5
+ * `skill_recommend` each derive a technology "stack" client-side — CLI from
6
+ * codebase analysis (`buildStackFromAnalysis`), MCP from installed skills +
7
+ * project context keywords — and either can legitimately derive `[]` (a
8
+ * non-Node stack, an all-devDeps project, or an unsupported language). The
9
+ * `skills-recommend` edge function hard-rejects an empty `stack` with a 400
10
+ * (defense in depth, intentionally unchanged by this fix — see the plan's
11
+ * Wave 3 Step 2 "decided contract").
12
+ *
13
+ * Both callers now detect the empty-stack case BEFORE calling the API
14
+ * instead of letting the guaranteed-400 round trip happen and reacting to
15
+ * the failure after the fact — this is the one guidance string they both
16
+ * surface, so it can't drift between CLI and MCP wording the way the two
17
+ * tools' pre-fix behavior already had (a hard `process.exit(1)` crash on
18
+ * CLI vs a silently-swallowed warning on MCP for the exact same input
19
+ * shape).
20
+ */
21
+ /**
22
+ * Guidance shown when no technology stack could be derived for
23
+ * recommendations. Written to read naturally from both a terminal (CLI) and
24
+ * an MCP tool response consumed by a calling agent — neither surface names
25
+ * its own flag here, since the same string serves both.
26
+ */
27
+ export function buildEmptyStackGuidance() {
28
+ return ('No technology stack could be derived for recommendations — this usually ' +
29
+ 'means a non-Node project, a stack with no production dependencies, or an ' +
30
+ 'unsupported language, not a backend or registry problem. Provide project ' +
31
+ 'context (a short description of the project or its tooling) or an ' +
32
+ 'explicit list of installed/currently-used skills, then try again.');
33
+ }
34
+ //# sourceMappingURL=recommend-guard.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"recommend-guard.js","sourceRoot":"","sources":["../../../src/services/recommend-guard.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAEH;;;;;GAKG;AACH,MAAM,UAAU,uBAAuB;IACrC,OAAO,CACL,0EAA0E;QAC1E,2EAA2E;QAC3E,2EAA2E;QAC3E,oEAAoE;QACpE,mEAAmE,CACpE,CAAA;AACH,CAAC"}
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=recommend-guard.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"recommend-guard.test.d.ts","sourceRoot":"","sources":["../../../src/services/recommend-guard.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,28 @@
1
+ /**
2
+ * @fileoverview Unit tests for the shared empty-derived-stack guidance string
3
+ * (SMI-5896 Wave 3 Step 2). Both CLI `recommend` and MCP `skill_recommend`
4
+ * surface this exact string so their empty-stack messaging can't drift.
5
+ * @module @skillsmith/core/services/recommend-guard.test
6
+ */
7
+ import { describe, expect, it } from 'vitest';
8
+ import { buildEmptyStackGuidance } from './recommend-guard.js';
9
+ describe('buildEmptyStackGuidance (SMI-5896 Wave 3 Step 2)', () => {
10
+ it('returns a non-empty string', () => {
11
+ const guidance = buildEmptyStackGuidance();
12
+ expect(typeof guidance).toBe('string');
13
+ expect(guidance.length).toBeGreaterThan(0);
14
+ });
15
+ it('explains the empty stack is a legitimate under-detection, not a backend fault', () => {
16
+ const guidance = buildEmptyStackGuidance();
17
+ expect(guidance).toContain('No technology stack could be derived');
18
+ });
19
+ it('guides the caller toward providing context or an installed-skills list', () => {
20
+ const guidance = buildEmptyStackGuidance();
21
+ expect(guidance.toLowerCase()).toContain('project context');
22
+ expect(guidance.toLowerCase()).toContain('installed');
23
+ });
24
+ it('is deterministic (identical wording every call) so CLI and MCP never drift apart', () => {
25
+ expect(buildEmptyStackGuidance()).toBe(buildEmptyStackGuidance());
26
+ });
27
+ });
28
+ //# sourceMappingURL=recommend-guard.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"recommend-guard.test.js","sourceRoot":"","sources":["../../../src/services/recommend-guard.test.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,OAAO,EAAE,QAAQ,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,QAAQ,CAAA;AAE7C,OAAO,EAAE,uBAAuB,EAAE,MAAM,sBAAsB,CAAA;AAE9D,QAAQ,CAAC,kDAAkD,EAAE,GAAG,EAAE;IAChE,EAAE,CAAC,4BAA4B,EAAE,GAAG,EAAE;QACpC,MAAM,QAAQ,GAAG,uBAAuB,EAAE,CAAA;QAC1C,MAAM,CAAC,OAAO,QAAQ,CAAC,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAA;QACtC,MAAM,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC,eAAe,CAAC,CAAC,CAAC,CAAA;IAC5C,CAAC,CAAC,CAAA;IAEF,EAAE,CAAC,+EAA+E,EAAE,GAAG,EAAE;QACvF,MAAM,QAAQ,GAAG,uBAAuB,EAAE,CAAA;QAC1C,MAAM,CAAC,QAAQ,CAAC,CAAC,SAAS,CAAC,sCAAsC,CAAC,CAAA;IACpE,CAAC,CAAC,CAAA;IAEF,EAAE,CAAC,wEAAwE,EAAE,GAAG,EAAE;QAChF,MAAM,QAAQ,GAAG,uBAAuB,EAAE,CAAA;QAC1C,MAAM,CAAC,QAAQ,CAAC,WAAW,EAAE,CAAC,CAAC,SAAS,CAAC,iBAAiB,CAAC,CAAA;QAC3D,MAAM,CAAC,QAAQ,CAAC,WAAW,EAAE,CAAC,CAAC,SAAS,CAAC,WAAW,CAAC,CAAA;IACvD,CAAC,CAAC,CAAA;IAEF,EAAE,CAAC,kFAAkF,EAAE,GAAG,EAAE;QAC1F,MAAM,CAAC,uBAAuB,EAAE,CAAC,CAAC,IAAI,CAAC,uBAAuB,EAAE,CAAC,CAAA;IACnE,CAAC,CAAC,CAAA;AACJ,CAAC,CAAC,CAAA"}
@@ -0,0 +1,96 @@
1
+ /**
2
+ * @fileoverview Content-based skill install path (private registry).
3
+ * @module @skillsmith/core/services/skill-installation.content
4
+ * @see SMI-5905 Wave 1: installFromContent() + resolveFreshAccessToken() extraction
5
+ * @see docs/internal/implementation/private-registry-skill-install.md
6
+ *
7
+ * `install()` (skill-installation.service.ts) only knows how to fetch a
8
+ * skill from GitHub. This module adds a second entry point,
9
+ * `installFromContent()`, for skills whose content has already been resolved
10
+ * elsewhere (a private-registry `content` JSONB column, read via a
11
+ * per-team-member Supabase session or the `private-registry-get` Edge
12
+ * Function — Waves 2/3 wire those transports; this module only needs the
13
+ * already-fetched `{skillId, version, content}` triple).
14
+ *
15
+ * SCOPE TRIM (Sol review #5 — read before extending this function):
16
+ * `installFromContent()` deliberately reuses only two things from the
17
+ * `install()` policy chain:
18
+ * 1. `writeInstallFiles()`'s disk-write + rollback-on-partial-write handling
19
+ * (skill-installation.io.ts), plus this module's own manifest-driven
20
+ * already-installed/force gate (mirrors `install()`'s ALREADY_INSTALLED
21
+ * check).
22
+ * 2. A security scan at the `community` trust tier, via the same
23
+ * `classifyBundledFile()`/`isRejectableScan()` policy `install()` uses
24
+ * for optional bundled files (skill-installation.policy.ts) — applied
25
+ * here to every content-map entry (including "SKILL.md" itself, which
26
+ * falls through to the conservative 'structured' default class since it
27
+ * has no BUNDLED_SCAN_FILES entry).
28
+ *
29
+ * It deliberately does NOT route through the rest of `install()`'s chain:
30
+ * - `skipScan` is NOT an accepted option here. Registry-content installs
31
+ * always scan — there is no opt-out, unlike `install()`'s tier-gated
32
+ * skipScan.
33
+ * - Dependency-intelligence extraction/persistence (extractDepIntel,
34
+ * persistDependencies, checkDepsAgainstQuarantine), risk-history
35
+ * recording, AI-defence feedback, and co-install-session recording are
36
+ * all OUT of v1 scope — a known, intentional gap, not a silent drop.
37
+ * Wave 3's MCP `install` action and Wave 4's CLI command do not get
38
+ * these signals for private-registry installs yet.
39
+ * - Frontmatter/manifest validation on `SKILL.md` itself DOES still run
40
+ * (`validateSkillMd()`), same as any other install.
41
+ *
42
+ * PATH VALIDATION (Sol review #2, critical, confirmed exploitable): every key
43
+ * in `content` is attacker-controlled — any team member with publish access
44
+ * chooses these filenames — and flows into `writeInstallFiles()`'s
45
+ * `path.join(installPath, subSkill.filename)` (skill-installation.io.ts:167)
46
+ * with NO containment check of its own. `path.join('/a/b', '../../etc/passwd')`
47
+ * escapes `installPath` after normalization, so every key is validated here,
48
+ * BEFORE any disk write — see `validateContentKeys()`.
49
+ */
50
+ import type { Database } from '../db/database-interface.js';
51
+ import type { ClientId } from '../install/paths.js';
52
+ import type { ProgressCallback, InstallFromContentOptions, InstallResult } from './skill-installation.types.js';
53
+ import type { ManifestManager } from './skill-manifest.js';
54
+ /**
55
+ * Validate every key in a content map before any disk write.
56
+ *
57
+ * Rejects (as a single combined error, first offense wins):
58
+ * - path-traversal (`..` segment), absolute paths (posix or win32/UNC),
59
+ * NUL bytes, backslashes, empty/bare-"."/".git"-adjacent keys;
60
+ * - any key that, after `path.resolve()` against `installPath`, resolves
61
+ * outside `installPath` (a lexical backstop behind the checks above,
62
+ * matching `writeInstallFiles()`'s own lexical escape check);
63
+ * - two keys that `path.normalize()` to the same on-disk path (a
64
+ * collision — e.g. "SKILL.md" and "./SKILL.md").
65
+ */
66
+ export declare function validateContentKeys(content: Record<string, string>, installPath: string): {
67
+ valid: true;
68
+ } | {
69
+ valid: false;
70
+ error: string;
71
+ };
72
+ /** Internal params bag: the caller's InstallFromContentOptions plus the service state
73
+ * installFromContent() needs (mirrors performUninstall()'s flattened-params convention
74
+ * in skill-installation.helpers.ts). */
75
+ export interface InstallFromContentParams extends InstallFromContentOptions {
76
+ db: Database;
77
+ skillsDir: string;
78
+ manifest: ManifestManager;
79
+ client: ClientId;
80
+ onProgress: ProgressCallback;
81
+ /**
82
+ * SMI-5982 code-review fix #1: base dir for resolving a relative companion-agent
83
+ * target (Antigravity only) — see writeInstallFiles()'s own doc comment.
84
+ * PR-review follow-up: optional (not required) — an omitted value must flow
85
+ * through to `resolveCompanionAgentPath()`'s own required-`baseDir` guard as
86
+ * `undefined`, not be silently defaulted anywhere in this chain.
87
+ */
88
+ companionBaseDir?: string;
89
+ }
90
+ /**
91
+ * Install an already-resolved private-registry skill's content to disk.
92
+ *
93
+ * See this file's header doc comment for the full scope trim vs. `install()`.
94
+ */
95
+ export declare function installFromContent(params: InstallFromContentParams): Promise<InstallResult>;
96
+ //# sourceMappingURL=skill-installation.content.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"skill-installation.content.d.ts","sourceRoot":"","sources":["../../../src/services/skill-installation.content.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgDG;AAIH,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,6BAA6B,CAAA;AAC3D,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,qBAAqB,CAAA;AACnD,OAAO,KAAK,EACV,gBAAgB,EAChB,yBAAyB,EACzB,aAAa,EACd,MAAM,+BAA+B,CAAA;AAEtC,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,qBAAqB,CAAA;AA8C1D;;;;;;;;;;;GAWG;AACH,wBAAgB,mBAAmB,CACjC,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,EAC/B,WAAW,EAAE,MAAM,GAClB;IAAE,KAAK,EAAE,IAAI,CAAA;CAAE,GAAG;IAAE,KAAK,EAAE,KAAK,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,CAgCnD;AA8DD;;yCAEyC;AACzC,MAAM,WAAW,wBAAyB,SAAQ,yBAAyB;IACzE,EAAE,EAAE,QAAQ,CAAA;IACZ,SAAS,EAAE,MAAM,CAAA;IACjB,QAAQ,EAAE,eAAe,CAAA;IACzB,MAAM,EAAE,QAAQ,CAAA;IAChB,UAAU,EAAE,gBAAgB,CAAA;IAC5B;;;;;;OAMG;IACH,gBAAgB,CAAC,EAAE,MAAM,CAAA;CAC1B;AAED;;;;GAIG;AACH,wBAAsB,kBAAkB,CAAC,MAAM,EAAE,wBAAwB,GAAG,OAAO,CAAC,aAAa,CAAC,CAqKjG"}