@astrale-os/cli 1.0.0-beta.3 → 1.0.0-beta.31

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 (345) hide show
  1. package/README.md +84 -6
  2. package/dist/astrale.js +57586 -60753
  3. package/dist/public/connect-core.js +2158 -30706
  4. package/dist/public/keys/index.js +1016 -29541
  5. package/dist/public/paths/index.js +370 -28654
  6. package/dist/types/admin/contract.d.ts +26 -0
  7. package/dist/types/admin/instance/client.d.ts +20 -0
  8. package/dist/types/admin/instance/index.d.ts +2 -0
  9. package/dist/types/admin/instance/model.d.ts +6 -10
  10. package/dist/types/connection/auth.d.ts +3 -0
  11. package/dist/types/connection/call.d.ts +3 -0
  12. package/dist/types/connection/command.d.ts +27 -0
  13. package/dist/types/connection/credential.d.ts +14 -0
  14. package/dist/types/connection/errors.d.ts +1 -0
  15. package/dist/types/connection/exchange.d.ts +9 -0
  16. package/dist/types/connection/failure/classify.d.ts +2 -0
  17. package/dist/types/connection/failure/index.d.ts +5 -0
  18. package/dist/types/connection/failure/model.d.ts +25 -0
  19. package/dist/types/connection/failure/render.d.ts +3 -0
  20. package/dist/types/connection/index.d.ts +8 -0
  21. package/dist/types/connection/reasons.d.ts +37 -0
  22. package/dist/types/connection/self.d.ts +37 -0
  23. package/dist/types/connection/session.d.ts +31 -0
  24. package/dist/types/connection/target.d.ts +38 -0
  25. package/dist/types/errors.d.ts +1 -1
  26. package/dist/types/lib/admin-instance.d.ts +17 -0
  27. package/dist/types/lib/admin-target.d.ts +11 -36
  28. package/dist/types/lib/config.d.ts +13 -59
  29. package/dist/types/lib/failure-debug.d.ts +1 -0
  30. package/dist/types/lib/idp.d.ts +25 -173
  31. package/dist/types/lib/instance-target.d.ts +1 -1
  32. package/dist/types/lib/instance.d.ts +30 -88
  33. package/dist/types/lib/log.d.ts +5 -3
  34. package/dist/types/lib/output.d.ts +1 -1
  35. package/dist/types/lib/proc.d.ts +43 -0
  36. package/dist/types/lib/self.d.ts +9 -0
  37. package/dist/types/lib/update.d.ts +109 -0
  38. package/dist/types/lib/validation.d.ts +4 -1
  39. package/dist/types/state/exchange-credentials.d.ts +6 -2
  40. package/dist/types/state/files.d.ts +2 -0
  41. package/dist/types/state/index.d.ts +3 -2
  42. package/dist/types/state/paths.d.ts +2 -0
  43. package/dist/types/state/session-routes.d.ts +10 -0
  44. package/package.json +16 -19
  45. package/src/__tests__/fixtures/publication.ts +20 -0
  46. package/src/admin/.spec/architecture.md +12 -5
  47. package/src/admin/__tests__/fixture.ts +25 -0
  48. package/src/admin/catalog/.spec/api.d.ts +1 -3
  49. package/src/admin/catalog/.spec/architecture.md +5 -4
  50. package/src/admin/catalog/__tests__/client.test.ts +143 -69
  51. package/src/admin/catalog/client.ts +40 -54
  52. package/src/admin/catalog/model.ts +3 -4
  53. package/src/admin/contract.ts +47 -0
  54. package/src/admin/graph/.spec/api.d.ts +4 -10
  55. package/src/admin/graph/nodes.ts +1 -1
  56. package/src/admin/instance/.spec/api.d.ts +5 -9
  57. package/src/admin/instance/.spec/architecture.md +7 -7
  58. package/src/admin/instance/__tests__/client.test.ts +157 -175
  59. package/src/admin/instance/client.ts +39 -128
  60. package/src/admin/instance/index.ts +1 -2
  61. package/src/admin/instance/model.ts +12 -18
  62. package/src/commands/__tests__/admin-instance.test.ts +11 -8
  63. package/src/commands/__tests__/call-describe.test.ts +46 -33
  64. package/src/commands/__tests__/call.test.ts +34 -0
  65. package/src/commands/__tests__/domain-install-operation.test.ts +30 -22
  66. package/src/commands/__tests__/domain-install-owned.test.ts +1 -1
  67. package/src/commands/__tests__/domain-list.test.ts +5 -25
  68. package/src/commands/__tests__/install-direct.test.ts +115 -8
  69. package/src/commands/__tests__/install-identity-override.test.ts +6 -17
  70. package/src/commands/__tests__/instance-list-rows.test.ts +1 -1
  71. package/src/commands/__tests__/instance-status.test.ts +139 -0
  72. package/src/commands/__tests__/introspect-parse.test.ts +80 -1
  73. package/src/commands/__tests__/logs.test.ts +250 -2
  74. package/src/commands/__tests__/read-commands.test.ts +119 -11
  75. package/src/commands/__tests__/token-ttl.test.ts +49 -4
  76. package/src/commands/__tests__/update.test.ts +25 -3
  77. package/src/commands/__tests__/view.test.ts +50 -2
  78. package/src/commands/auth/logout.ts +2 -1
  79. package/src/commands/browser.ts +29 -0
  80. package/src/commands/call-describe.ts +60 -68
  81. package/src/commands/call.ts +71 -59
  82. package/src/commands/domain/install.ts +73 -46
  83. package/src/commands/domain/list.ts +1 -1
  84. package/src/commands/domain/publish.ts +14 -11
  85. package/src/commands/domain/uninstall.ts +2 -2
  86. package/src/commands/get.ts +15 -16
  87. package/src/commands/identity/__tests__/register.test.ts +3 -3
  88. package/src/commands/identity/register.ts +8 -8
  89. package/src/commands/instance/create.ts +8 -16
  90. package/src/commands/instance/delete.ts +12 -9
  91. package/src/commands/instance/list.ts +2 -2
  92. package/src/commands/instance/status.ts +66 -20
  93. package/src/commands/instance/use.ts +16 -12
  94. package/src/commands/introspect.ts +53 -41
  95. package/src/commands/logs.ts +123 -36
  96. package/src/commands/mutate.ts +6 -5
  97. package/src/commands/query.ts +39 -36
  98. package/src/commands/session/analyze.ts +26 -4
  99. package/src/commands/token.ts +42 -27
  100. package/src/commands/ui/__tests__/commands.test.ts +152 -0
  101. package/src/commands/ui/add.ts +51 -0
  102. package/src/commands/ui/doctor.ts +21 -0
  103. package/src/commands/ui/init.ts +38 -0
  104. package/src/commands/ui/list.ts +26 -0
  105. package/src/commands/ui/preset-apply.ts +19 -0
  106. package/src/commands/ui/preset-list.ts +12 -0
  107. package/src/commands/ui/shared.ts +25 -0
  108. package/src/commands/update.ts +90 -48
  109. package/src/commands/view.ts +82 -37
  110. package/src/connection/.spec/api.d.ts +7 -8
  111. package/src/connection/.spec/architecture.md +14 -5
  112. package/src/connection/.spec/flows/session.ts +2 -2
  113. package/src/connection/.spec/laws/connection.ts +6 -2
  114. package/src/connection/.spec/layout.ts +1 -0
  115. package/src/connection/__tests__/auth.test.ts +1 -0
  116. package/src/connection/__tests__/credential.test.ts +21 -1
  117. package/src/connection/__tests__/errors.test.ts +27 -20
  118. package/src/connection/__tests__/exchange.test.ts +64 -11
  119. package/src/connection/__tests__/failure-fixtures.ts +39 -0
  120. package/src/connection/__tests__/failure-safety.test.ts +75 -0
  121. package/src/connection/__tests__/self.test.ts +31 -11
  122. package/src/connection/__tests__/session.test.ts +8 -4
  123. package/src/connection/__tests__/target.test.ts +2 -0
  124. package/src/connection/auth.ts +13 -0
  125. package/src/connection/call.ts +1 -1
  126. package/src/connection/credential.ts +2 -2
  127. package/src/connection/errors.ts +1 -355
  128. package/src/connection/exchange.ts +110 -61
  129. package/src/connection/failure/classify.ts +110 -0
  130. package/src/connection/failure/index.ts +18 -0
  131. package/src/connection/failure/model.ts +28 -0
  132. package/src/connection/failure/render.ts +116 -0
  133. package/src/connection/reasons.ts +16 -0
  134. package/src/connection/self.ts +39 -21
  135. package/src/connection/session.ts +27 -9
  136. package/src/errors.ts +3 -2
  137. package/src/graph/.spec/api.d.ts +13 -6
  138. package/src/graph/.spec/laws/documents.ts +7 -7
  139. package/src/graph/.spec/layout.ts +9 -1
  140. package/src/graph/__tests__/mutation.test.ts +2 -2
  141. package/src/graph/__tests__/query.test.ts +35 -9
  142. package/src/graph/class.ts +30 -0
  143. package/src/graph/index.ts +1 -0
  144. package/src/graph/query.ts +27 -40
  145. package/src/identity/.spec/api.d.ts +2 -2
  146. package/src/identity/__tests__/fixtures/registry-journey.ts +13 -1
  147. package/src/identity/__tests__/registration.test.ts +1 -1
  148. package/src/identity/__tests__/registry.test.ts +4 -0
  149. package/src/identity/registration.ts +3 -3
  150. package/src/identity/registry.ts +2 -0
  151. package/src/keys/__tests__/keys.test.ts +4 -0
  152. package/src/keys/credential.ts +1 -0
  153. package/src/lib/__tests__/admin-target.test.ts +12 -0
  154. package/src/lib/__tests__/binary.test.ts +16 -1
  155. package/src/lib/__tests__/browser-retention.test.ts +212 -0
  156. package/src/lib/__tests__/command-dx.test.ts +1 -1
  157. package/src/lib/__tests__/config.test.ts +27 -0
  158. package/src/lib/__tests__/domain-publication.test.ts +10 -16
  159. package/src/lib/__tests__/idp.test.ts +34 -4
  160. package/src/lib/__tests__/instance-candidates.test.ts +1 -3
  161. package/src/lib/__tests__/instance-target.test.ts +7 -39
  162. package/src/lib/__tests__/log-errors.test.ts +64 -0
  163. package/src/lib/__tests__/skills.test.ts +834 -0
  164. package/src/lib/__tests__/studio-server-deps.test.ts +1 -1
  165. package/src/lib/__tests__/update.test.ts +264 -11
  166. package/src/lib/__tests__/view-assets.test.ts +25 -0
  167. package/src/lib/__tests__/view-external-open-intent.test.ts +150 -0
  168. package/src/lib/__tests__/view-external-open-origins.test.ts +27 -0
  169. package/src/lib/__tests__/view-open-intent.test.ts +0 -2
  170. package/src/lib/__tests__/view-server.test.ts +26 -3
  171. package/src/lib/__tests__/view-session.test.ts +2 -1
  172. package/src/lib/admin-instance.ts +14 -8
  173. package/src/lib/admin-target.ts +21 -2
  174. package/src/lib/binary.ts +22 -5
  175. package/src/lib/browser-retention.ts +210 -0
  176. package/src/lib/command-dx.ts +9 -9
  177. package/src/lib/config.ts +12 -1
  178. package/src/lib/domain-publication.ts +9 -7
  179. package/src/lib/failure-debug.ts +26 -0
  180. package/src/lib/idp.ts +34 -3
  181. package/src/lib/instance-candidates.ts +2 -2
  182. package/src/lib/instance-target.ts +3 -14
  183. package/src/lib/instance.ts +16 -11
  184. package/src/lib/log.ts +16 -9
  185. package/src/lib/output.ts +1 -1
  186. package/src/lib/proc.ts +7 -2
  187. package/src/lib/provision-instance.ts +34 -57
  188. package/src/lib/skills/lock.ts +117 -0
  189. package/src/lib/skills/sync.ts +878 -0
  190. package/src/lib/skills.ts +1 -52
  191. package/src/lib/update.ts +298 -45
  192. package/src/lib/validation.ts +2 -2
  193. package/src/lib/view/assets.ts +11 -2
  194. package/src/lib/view/external-open-intent.ts +41 -0
  195. package/src/lib/view/external-open-origins.ts +37 -0
  196. package/src/lib/view/host-capabilities.ts +17 -0
  197. package/src/lib/view/resolve.ts +1 -1
  198. package/src/lib/view/server.ts +18 -3
  199. package/src/lib/view/session.ts +2 -0
  200. package/src/program/.spec/api.d.ts +1 -0
  201. package/src/program/__tests__/program.test.ts +201 -7
  202. package/src/program/argv.ts +14 -0
  203. package/src/program/build.ts +26 -4
  204. package/src/program/command.ts +1 -0
  205. package/src/program/index.ts +1 -0
  206. package/src/program/registry.ts +2 -1
  207. package/src/setup/__tests__/instance-step.test.ts +27 -10
  208. package/src/setup/steps/instance.ts +22 -15
  209. package/src/setup/steps/skills.ts +40 -38
  210. package/src/state/.spec/api.d.ts +21 -2
  211. package/src/state/.spec/architecture.md +18 -4
  212. package/src/state/.spec/layout.ts +1 -0
  213. package/src/state/__tests__/exchange-credentials.test.ts +88 -42
  214. package/src/state/__tests__/files.test.ts +24 -1
  215. package/src/state/__tests__/fixtures/session-route-process.ts +93 -0
  216. package/src/state/__tests__/paths.test.ts +1 -0
  217. package/src/state/__tests__/session-routes.test.ts +138 -0
  218. package/src/state/exchange-credentials.ts +22 -9
  219. package/src/state/files.ts +41 -0
  220. package/src/state/index.ts +3 -1
  221. package/src/state/paths.ts +3 -0
  222. package/src/state/session-routes.ts +34 -0
  223. package/src/telemetry/__tests__/analyze-log.test.ts +38 -0
  224. package/src/telemetry/__tests__/recorder.test.ts +1 -0
  225. package/src/telemetry/__tests__/retention.test.ts +267 -0
  226. package/src/telemetry/__tests__/settings.test.ts +102 -0
  227. package/src/telemetry/__tests__/store-scan.test.ts +128 -0
  228. package/src/telemetry/__tests__/trigger.test.ts +33 -4
  229. package/src/telemetry/analyze.ts +24 -2
  230. package/src/telemetry/recorder.ts +15 -4
  231. package/src/telemetry/retention.ts +176 -0
  232. package/src/telemetry/session.ts +18 -12
  233. package/src/telemetry/settings.ts +64 -11
  234. package/src/telemetry/store.ts +92 -9
  235. package/src/telemetry/trigger.ts +16 -28
  236. package/src/ui/.spec/api.d.ts +14 -0
  237. package/src/ui/.spec/architecture.md +14 -0
  238. package/src/ui/.spec/laws.ts +50 -0
  239. package/src/ui/.spec/layout.ts +16 -0
  240. package/src/ui/__tests__/ui.test.ts +1742 -0
  241. package/src/ui/index.ts +13 -0
  242. package/src/ui/lock.ts +89 -0
  243. package/src/ui/model.ts +83 -0
  244. package/src/ui/operations.ts +1044 -0
  245. package/src/ui/project.ts +333 -0
  246. package/src/ui/release.ts +398 -0
  247. package/src/ui/runner.ts +18 -0
  248. package/studio/client/dist/assets/elk-api-DjmKsHXc.js +1 -0
  249. package/studio/client/dist/assets/index-BFVFs_8x.js +81 -0
  250. package/studio/client/dist/assets/index-DuB9iUdu.css +2 -0
  251. package/studio/client/dist/assets/rolldown-runtime-Dd_uD5pT.js +1 -0
  252. package/studio/client/dist/assets/schema-studio--a0kX3QU.css +1 -0
  253. package/studio/client/dist/assets/schema-studio-DH6oEWME.js +8 -0
  254. package/studio/client/dist/index.html +4 -3
  255. package/studio/package.json +8 -14
  256. package/studio/server/agent/ask.test.ts +1 -1
  257. package/studio/server/agent/bridge/grant.test.ts +1 -1
  258. package/studio/server/agent/bridge/routes.test.ts +1 -1
  259. package/studio/server/agent/harness/claude/adapter.test.ts +15 -5
  260. package/studio/server/agent/harness/gateway/token.test.ts +1 -1
  261. package/studio/server/agent/harness/gateway/token.ts +3 -3
  262. package/studio/server/agent/prompts/anchors.test.ts +41 -32
  263. package/studio/server/agent/prompts/anchors.ts +23 -51
  264. package/studio/server/agent/prompts/system.test.ts +4 -4
  265. package/studio/server/agent/prompts/system.ts +5 -5
  266. package/studio/server/agent/routes.test.ts +1 -1
  267. package/studio/server/agent/run/coordinator.test.ts +1 -1
  268. package/studio/server/agent/run/preparation.ts +1 -1
  269. package/studio/server/api/canvas.ts +0 -1
  270. package/studio/server/api/context.ts +1 -1
  271. package/studio/server/api/deployment.ts +1 -1
  272. package/studio/server/api/views.ts +2 -2
  273. package/studio/server/api/workspace.ts +1 -1
  274. package/studio/server/api-agent-loadout.test.ts +1 -1
  275. package/studio/server/api.test.ts +1 -1
  276. package/studio/server/cache.test.ts +24 -18
  277. package/studio/server/cache.ts +10 -54
  278. package/studio/server/cli-consumers.test.ts +32 -4
  279. package/studio/server/cli.test.ts +2 -2
  280. package/studio/server/domain.test.ts +50 -68
  281. package/studio/server/domain.ts +82 -63
  282. package/studio/server/handoff/copy.ts +2 -2
  283. package/studio/server/index.ts +1 -1
  284. package/studio/server/instances/probe.test.ts +1 -1
  285. package/studio/server/introspect/anatomy/views/routes.ts +46 -37
  286. package/studio/server/introspect/anatomy/views.ts +10 -16
  287. package/studio/server/introspect/anatomy-extras.test.ts +29 -56
  288. package/studio/server/introspect/anatomy.test.ts +22 -14
  289. package/studio/server/introspect/anatomy.ts +6 -9
  290. package/studio/server/introspect/bundle.ts +0 -3
  291. package/studio/server/introspect/canonical-schema.test.ts +155 -424
  292. package/studio/server/introspect/canonical-schema.ts +248 -601
  293. package/studio/server/introspect/core.ts +14 -22
  294. package/studio/server/introspect/diff.test.ts +21 -9
  295. package/studio/server/introspect/diff.ts +19 -64
  296. package/studio/server/introspect/extractor.ts +23 -122
  297. package/studio/server/introspect/overlay-tsmorph.test.ts +116 -241
  298. package/studio/server/introspect/overlay-tsmorph.ts +0 -1
  299. package/studio/server/introspect/overlay.ts +3 -51
  300. package/studio/server/introspect/revision.test.ts +24 -28
  301. package/studio/server/introspect/revision.ts +11 -22
  302. package/studio/server/introspect/runtime.test.ts +77 -195
  303. package/studio/server/introspect/runtime.ts +5 -67
  304. package/studio/server/introspect/schema-ir-json.test.ts +81 -0
  305. package/studio/server/introspect/schema-ir-json.ts +39 -69
  306. package/studio/server/introspect/schema-refs.test.ts +43 -88
  307. package/studio/server/introspect/schema-refs.ts +14 -49
  308. package/studio/server/introspect/source-overlay/handlers.ts +116 -636
  309. package/studio/server/introspect/source-overlay/kernel-calls.ts +0 -3
  310. package/studio/server/introspect/source-overlay/spans.ts +15 -45
  311. package/studio/server/lifecycle.ts +4 -1
  312. package/studio/server/state/baseline.test.ts +14 -9
  313. package/studio/server/state/baseline.ts +3 -5
  314. package/studio/server/state/documents.test.ts +69 -0
  315. package/studio/server/state/documents.ts +57 -10
  316. package/studio/server/state/visibility.ts +1 -5
  317. package/studio/server/views/model.ts +3 -3
  318. package/studio/server/views/target.test.ts +18 -76
  319. package/studio/server/views/target.ts +25 -51
  320. package/studio/server/watch.test.ts +11 -11
  321. package/studio/server/watch.ts +8 -4
  322. package/studio/server/workspace/updates.ts +23 -1
  323. package/studio/server/workspace-watch.ts +2 -2
  324. package/studio/shared/contracts/runtime.ts +4 -0
  325. package/studio/shared/contracts/schema.ts +44 -131
  326. package/studio/shared/contracts/surface.test.ts +15 -17
  327. package/studio/shared/contracts/workspace.ts +5 -11
  328. package/studio/shared/schema/identity.test.ts +20 -44
  329. package/studio/shared/schema/identity.ts +9 -24
  330. package/studio/tsconfig.json +2 -1
  331. package/tsconfig.json +1 -1
  332. package/viewer/dist/index.html +21 -0
  333. package/viewer/dist/main.js +41 -62
  334. package/.check-workspace.cjs +0 -40
  335. package/src/admin/binding.ts +0 -168
  336. package/src/commands/__tests__/instance-create-hosts.test.ts +0 -29
  337. package/studio/client/dist/assets/elk-api-lwhFo7vB.js +0 -1
  338. package/studio/client/dist/assets/index-B-c-smo9.js +0 -8
  339. package/studio/client/dist/assets/index-BckHuAWk.js +0 -81
  340. package/studio/client/dist/assets/index-C4aNQ6dz.css +0 -1
  341. package/studio/client/dist/assets/index-ChOr3yP0.css +0 -1
  342. package/studio/server/introspect/anatomy/views/legacy.ts +0 -232
  343. package/studio/server/introspect/core-extractor.ts +0 -139
  344. package/studio/server/introspect/overlay.test.ts +0 -72
  345. package/studio/server/introspect/source-overlay/annotations.ts +0 -14
@@ -0,0 +1,878 @@
1
+ import { createHash } from 'node:crypto'
2
+ import {
3
+ cp,
4
+ lstat,
5
+ mkdir,
6
+ mkdtemp,
7
+ readFile,
8
+ readdir,
9
+ readlink,
10
+ rename,
11
+ rm,
12
+ symlink,
13
+ writeFile,
14
+ } from 'node:fs/promises'
15
+ import { homedir, tmpdir } from 'node:os'
16
+ import { dirname, join, relative, resolve } from 'node:path'
17
+
18
+ import { AstraleError } from '../../errors'
19
+ import { run, type RunResult } from '../proc'
20
+ import { withFileLock } from './lock'
21
+
22
+ /**
23
+ * Astrale-owned agent skill reconciliation. The ecosystem installer owns agent links;
24
+ * Astrale owns source freshness, local integrity, repair, rollback, and outcomes.
25
+ */
26
+
27
+ /** Published source whose top-level skill directories Astrale owns as one cohort. */
28
+ export const ASTRALE_CLI_SKILL_SOURCE = 'astrale-os/cli'
29
+ export const ASTRALE_SKILL_REPAIR_COMMAND = 'astrale update --yes --no-deps'
30
+
31
+ /** Deliberately pinned: installer behavior is part of the verified update path. */
32
+ export const SKILLS_INSTALLER_PACKAGE = 'skills@1.5.23'
33
+
34
+ /** Human-facing recovery stays on the Astrale-owned, verified path. */
35
+ export const SKILL_INSTALL_HINT = ASTRALE_SKILL_REPAIR_COMMAND
36
+
37
+ export type SkillCheckStatus =
38
+ | 'current'
39
+ | 'update-available'
40
+ | 'repair-needed'
41
+ | 'unavailable'
42
+ | 'skipped'
43
+
44
+ export type SkillApplyStatus =
45
+ | 'unchanged'
46
+ | 'installed'
47
+ | 'updated'
48
+ | 'repaired'
49
+ | 'failed'
50
+ | 'skipped'
51
+
52
+ export type SkillCheckResult = {
53
+ status: SkillCheckStatus
54
+ error?: string
55
+ source?: {
56
+ repository: typeof ASTRALE_CLI_SKILL_SOURCE
57
+ revision: string
58
+ skills: Array<{ name: string; tree: string; entrypoint: string }>
59
+ }
60
+ }
61
+
62
+ export type SkillApplyResult = {
63
+ status: SkillApplyStatus
64
+ }
65
+
66
+ type SourceSkill = { name: string; path: string; tree: string }
67
+ export type AstraleSkillSourceSnapshot = { ref: 'main'; revision: string; skills: SourceSkill[] }
68
+
69
+ type SkillLockEntry = {
70
+ source?: string
71
+ sourceType?: string
72
+ sourceUrl?: string
73
+ ref?: string
74
+ skillPath?: string
75
+ skillFolderHash?: string
76
+ installedAt?: string
77
+ updatedAt?: string
78
+ astraleSourceRevision?: string
79
+ astraleSourceTree?: string
80
+ }
81
+
82
+ type SkillLock = {
83
+ version: number
84
+ skills: Record<string, SkillLockEntry>
85
+ lastSelectedAgents?: string[]
86
+ dismissed?: Record<string, unknown>
87
+ }
88
+
89
+ type SkillState = 'absent' | 'current' | 'outdated' | 'unhealthy'
90
+
91
+ type SkillInspection = {
92
+ state: SkillState
93
+ sourceCurrent: boolean
94
+ managedNames: string[]
95
+ managedFolders: string[]
96
+ }
97
+
98
+ export type SkillSyncDependencies = {
99
+ home?: string
100
+ lockPath?: string
101
+ resolveSource?: () => Promise<AstraleSkillSourceSnapshot>
102
+ run?: (file: string, args?: string[]) => Promise<RunResult>
103
+ }
104
+
105
+ const SOURCE_REPOSITORY_URL = 'https://github.com/astrale-os/cli.git'
106
+ const SAFE_NAME = /^[a-z0-9][a-z0-9._-]*$/iu
107
+
108
+ function resolvedDependencies(overrides: SkillSyncDependencies = {}) {
109
+ const home = overrides.home ?? homedir()
110
+ const xdgStateHome = process.env.XDG_STATE_HOME
111
+ return {
112
+ home,
113
+ lockPath:
114
+ overrides.lockPath ??
115
+ (xdgStateHome
116
+ ? join(xdgStateHome, 'skills', '.skill-lock.json')
117
+ : join(home, '.agents', '.skill-lock.json')),
118
+ run: overrides.run ?? run,
119
+ resolveSource: overrides.resolveSource,
120
+ }
121
+ }
122
+
123
+ function sourceOwned(entry: SkillLockEntry): boolean {
124
+ return (
125
+ entry.source === ASTRALE_CLI_SKILL_SOURCE ||
126
+ entry.sourceUrl === SOURCE_REPOSITORY_URL ||
127
+ entry.sourceUrl === `https://github.com/${ASTRALE_CLI_SKILL_SOURCE}`
128
+ )
129
+ }
130
+
131
+ function folderName(entry: SkillLockEntry): string | null {
132
+ const match = entry.skillPath?.match(/^skills\/([^/]+)\/SKILL\.md$/u)
133
+ return match?.[1] ?? null
134
+ }
135
+
136
+ async function resolveAstraleSkillSource(
137
+ execute: ReturnType<typeof resolvedDependencies>['run'],
138
+ ): Promise<AstraleSkillSourceSnapshot> {
139
+ const checkout = await mkdtemp(join(tmpdir(), 'astrale-skill-source-'))
140
+ try {
141
+ const cloned = await execute('git', [
142
+ 'clone',
143
+ '--quiet',
144
+ '--depth',
145
+ '1',
146
+ '--filter=blob:none',
147
+ '--no-checkout',
148
+ '--single-branch',
149
+ '--branch',
150
+ 'main',
151
+ SOURCE_REPOSITORY_URL,
152
+ checkout,
153
+ ])
154
+ if (cloned.code !== 0) throw new Error(cloned.stderr || cloned.stdout || 'git clone failed')
155
+ const revision = await execute('git', ['-C', checkout, 'rev-parse', 'HEAD'])
156
+ if (revision.code !== 0) {
157
+ throw new Error(revision.stderr || revision.stdout || 'git rev-parse failed')
158
+ }
159
+ const folders = await execute('git', ['-C', checkout, 'ls-tree', 'HEAD:skills'])
160
+ const files = await execute('git', [
161
+ '-C',
162
+ checkout,
163
+ 'ls-tree',
164
+ '-r',
165
+ '--name-only',
166
+ 'HEAD:skills',
167
+ ])
168
+ if (folders.code !== 0 || files.code !== 0) {
169
+ throw new Error(folders.stderr || files.stderr || 'git ls-tree failed')
170
+ }
171
+ const skillFiles = new Set(
172
+ files.stdout
173
+ .split('\n')
174
+ .filter((path) => /^[^/]+\/SKILL\.md$/u.test(path))
175
+ .map((path) => path.slice(0, -'/SKILL.md'.length)),
176
+ )
177
+ const skills = folders.stdout.split('\n').flatMap((line): SourceSkill[] => {
178
+ const match = line.match(/^040000 tree ([0-9a-f]{40})\t([^/]+)$/u)
179
+ if (!match || !SAFE_NAME.test(match[2]) || !skillFiles.has(match[2])) return []
180
+ return [{ name: match[2], path: `skills/${match[2]}/SKILL.md`, tree: match[1] }]
181
+ })
182
+ skills.sort((a, b) => a.name.localeCompare(b.name))
183
+ if (skills.length === 0) throw new Error('astrale-os/cli publishes no top-level skills')
184
+ return { ref: 'main', revision: revision.stdout.trim(), skills }
185
+ } finally {
186
+ await rm(checkout, { recursive: true, force: true })
187
+ }
188
+ }
189
+
190
+ async function readSkillLock(
191
+ path: string,
192
+ ): Promise<{ lock: SkillLock | null; raw: string | null }> {
193
+ let raw: string
194
+ try {
195
+ raw = await readFile(path, 'utf8')
196
+ } catch (error) {
197
+ if ((error as { code?: string }).code === 'ENOENT') return { lock: null, raw: null }
198
+ throw error
199
+ }
200
+ try {
201
+ const parsed: unknown = JSON.parse(raw)
202
+ if (
203
+ parsed === null ||
204
+ typeof parsed !== 'object' ||
205
+ !('skills' in parsed) ||
206
+ (parsed as { skills?: unknown }).skills === null ||
207
+ typeof (parsed as { skills?: unknown }).skills !== 'object'
208
+ ) {
209
+ return { lock: null, raw }
210
+ }
211
+ return { lock: parsed as SkillLock, raw }
212
+ } catch {
213
+ return { lock: null, raw }
214
+ }
215
+ }
216
+
217
+ async function writeSkillLock(path: string, lock: SkillLock): Promise<void> {
218
+ await mkdir(dirname(path), { recursive: true })
219
+ const next = `${path}.next`
220
+ await writeFile(next, `${JSON.stringify(lock, null, 2)}\n`, { mode: 0o600 })
221
+ await rename(next, path)
222
+ }
223
+
224
+ function gitObjectHash(type: 'blob' | 'tree', body: Buffer): Buffer {
225
+ return createHash('sha1')
226
+ .update(Buffer.from(`${type} ${body.length}\0`))
227
+ .update(body)
228
+ .digest()
229
+ }
230
+
231
+ /** Git tree identity of the bytes actually installed on disk. */
232
+ export async function computeSkillTreeHash(root: string): Promise<string> {
233
+ async function treeHash(directory: string): Promise<Buffer> {
234
+ const entries = await Promise.all(
235
+ (await readdir(directory)).map(async (name) => {
236
+ const path = join(directory, name)
237
+ const stat = await lstat(path)
238
+ if (stat.isDirectory()) {
239
+ return { name, sort: `${name}/`, mode: '40000', hash: await treeHash(path) }
240
+ }
241
+ if (stat.isSymbolicLink()) {
242
+ return {
243
+ name,
244
+ sort: name,
245
+ mode: '120000',
246
+ hash: gitObjectHash('blob', Buffer.from(await readlink(path))),
247
+ }
248
+ }
249
+ return {
250
+ name,
251
+ sort: name,
252
+ mode: stat.mode & 0o111 ? '100755' : '100644',
253
+ hash: gitObjectHash('blob', await readFile(path)),
254
+ }
255
+ }),
256
+ )
257
+ entries.sort((a, b) => Buffer.compare(Buffer.from(a.sort), Buffer.from(b.sort)))
258
+ const body = Buffer.concat(
259
+ entries.flatMap((entry) => [Buffer.from(`${entry.mode} ${entry.name}\0`), entry.hash]),
260
+ )
261
+ return gitObjectHash('tree', body)
262
+ }
263
+ return (await treeHash(root)).toString('hex')
264
+ }
265
+
266
+ async function computeInstallerFolderHash(root: string): Promise<string> {
267
+ const files: Array<{ path: string; content: Buffer }> = []
268
+ async function collect(directory: string): Promise<void> {
269
+ await Promise.all(
270
+ (await readdir(directory, { withFileTypes: true })).map(async (entry) => {
271
+ if (entry.name === '.git' || entry.name === 'node_modules') return
272
+ const path = join(directory, entry.name)
273
+ if (entry.isDirectory()) await collect(path)
274
+ else if (entry.isFile()) {
275
+ files.push({
276
+ path: relative(root, path).split('\\').join('/'),
277
+ content: await readFile(path),
278
+ })
279
+ }
280
+ }),
281
+ )
282
+ }
283
+ await collect(root)
284
+ files.sort((a, b) => a.path.localeCompare(b.path))
285
+ const hash = createHash('sha256')
286
+ for (const file of files) hash.update(file.path).update(file.content)
287
+ return hash.digest('hex')
288
+ }
289
+
290
+ async function inspectAstraleSkills(
291
+ snapshot: AstraleSkillSourceSnapshot,
292
+ home: string,
293
+ lockPath: string,
294
+ ): Promise<SkillInspection> {
295
+ const { lock } = await readSkillLock(lockPath)
296
+ const managed = Object.entries(lock?.skills ?? {}).filter(([, entry]) => sourceOwned(entry))
297
+ const expected = new Map(snapshot.skills.map((skill) => [skill.name, skill]))
298
+ const expectedPresence = await Promise.all(
299
+ snapshot.skills.map(async (skill) => {
300
+ try {
301
+ return (await lstat(join(home, '.agents', 'skills', skill.name))).isDirectory()
302
+ } catch {
303
+ return false
304
+ }
305
+ }),
306
+ )
307
+ if (managed.length === 0 && expectedPresence.every((present) => !present)) {
308
+ return { state: 'absent', sourceCurrent: false, managedNames: [], managedFolders: [] }
309
+ }
310
+
311
+ const folders = managed.flatMap(([name, entry]) => {
312
+ const folder = folderName(entry)
313
+ return folder ? [{ key: name, folder, entry }] : []
314
+ })
315
+ const uniqueFolders = new Set(folders.map((entry) => entry.folder))
316
+ let coherent = folders.length === managed.length && uniqueFolders.size === folders.length
317
+ const actualHashes = new Map<string, string>()
318
+ for (const item of folders) {
319
+ try {
320
+ const root = join(home, '.agents', 'skills', item.folder)
321
+ const actual = await computeSkillTreeHash(root)
322
+ actualHashes.set(item.folder, actual)
323
+ const receiptHash = item.entry.skillFolderHash
324
+ if (
325
+ !receiptHash ||
326
+ (receiptHash.length === 40
327
+ ? actual !== receiptHash
328
+ : receiptHash.length === 64
329
+ ? (await computeInstallerFolderHash(root)) !== receiptHash
330
+ : true)
331
+ ) {
332
+ coherent = false
333
+ }
334
+ if (item.entry.skillPath !== `skills/${item.folder}/SKILL.md`) coherent = false
335
+ } catch {
336
+ coherent = false
337
+ }
338
+ }
339
+ for (const [index, skill] of snapshot.skills.entries()) {
340
+ if (expectedPresence[index] && !uniqueFolders.has(skill.name)) coherent = false
341
+ }
342
+ if (await directoryExists(join(home, '.claude'))) {
343
+ for (const skill of snapshot.skills) {
344
+ const link = join(home, '.claude', 'skills', skill.name)
345
+ try {
346
+ if (!(await lstat(link)).isSymbolicLink()) coherent = false
347
+ else if (
348
+ resolve(dirname(link), await readlink(link)) !==
349
+ join(home, '.agents', 'skills', skill.name)
350
+ ) {
351
+ coherent = false
352
+ }
353
+ } catch {
354
+ coherent = false
355
+ }
356
+ }
357
+ }
358
+
359
+ const sourceCurrent =
360
+ coherent &&
361
+ managed.length === snapshot.skills.length &&
362
+ folders.every(({ key, folder, entry }) => {
363
+ const skill = expected.get(folder)
364
+ return (
365
+ key === folder &&
366
+ skill !== undefined &&
367
+ entry.ref === snapshot.ref &&
368
+ actualHashes.get(folder) === skill.tree
369
+ )
370
+ })
371
+ const exactCurrent =
372
+ sourceCurrent &&
373
+ folders.every(({ folder, entry }) => {
374
+ const skill = expected.get(folder)
375
+ return (
376
+ skill !== undefined &&
377
+ entry.astraleSourceRevision === snapshot.revision &&
378
+ entry.astraleSourceTree === skill.tree
379
+ )
380
+ })
381
+
382
+ return {
383
+ state: exactCurrent ? 'current' : coherent ? 'outdated' : 'unhealthy',
384
+ sourceCurrent,
385
+ managedNames: managed.map(([name]) => name),
386
+ managedFolders: [...uniqueFolders],
387
+ }
388
+ }
389
+
390
+ async function directoryExists(path: string): Promise<boolean> {
391
+ try {
392
+ return (await lstat(path)).isDirectory()
393
+ } catch {
394
+ return false
395
+ }
396
+ }
397
+
398
+ async function sourceSnapshot(dependencies: ReturnType<typeof resolvedDependencies>) {
399
+ return dependencies.resolveSource
400
+ ? await dependencies.resolveSource()
401
+ : await resolveAstraleSkillSource(dependencies.run)
402
+ }
403
+
404
+ function installerArgs(...args: string[]): string[] {
405
+ return ['--yes', SKILLS_INSTALLER_PACKAGE, ...args]
406
+ }
407
+
408
+ async function installSnapshot(
409
+ snapshot: AstraleSkillSourceSnapshot,
410
+ execute: ReturnType<typeof resolvedDependencies>['run'],
411
+ selectedAgents: string[],
412
+ ): Promise<RunResult> {
413
+ return await execute(
414
+ 'npx',
415
+ installerArgs(
416
+ 'add',
417
+ `${ASTRALE_CLI_SKILL_SOURCE}#${snapshot.ref}`,
418
+ '-g',
419
+ '-y',
420
+ '--skill',
421
+ ...snapshot.skills.map((skill) => skill.name),
422
+ ...(selectedAgents.length > 0 ? ['--agent', ...selectedAgents] : []),
423
+ ),
424
+ )
425
+ }
426
+
427
+ async function stampAstraleSource(
428
+ snapshot: AstraleSkillSourceSnapshot,
429
+ lockPath: string,
430
+ ): Promise<void> {
431
+ const { lock } = await readSkillLock(lockPath)
432
+ if (!lock) throw new Error('skill installer receipt is unavailable after installation')
433
+ for (const skill of snapshot.skills) {
434
+ const entry = lock.skills[skill.name]
435
+ if (
436
+ !entry ||
437
+ !sourceOwned(entry) ||
438
+ entry.ref !== snapshot.ref ||
439
+ entry.skillPath !== skill.path
440
+ ) {
441
+ throw new Error(`skill installer receipt is incomplete for ${skill.name}`)
442
+ }
443
+ entry.astraleSourceRevision = snapshot.revision
444
+ entry.astraleSourceTree = skill.tree
445
+ }
446
+ await writeSkillLock(lockPath, lock)
447
+ }
448
+
449
+ async function selectedAgents(home: string, lockPath: string): Promise<string[]> {
450
+ const { lock } = await readSkillLock(lockPath)
451
+ const agents = new Set(
452
+ (lock?.lastSelectedAgents ?? []).filter(
453
+ (agent): agent is string => typeof agent === 'string' && SAFE_NAME.test(agent),
454
+ ),
455
+ )
456
+ agents.add('codex')
457
+ if (await directoryExists(join(home, '.claude'))) agents.add('claude-code')
458
+ return [...agents]
459
+ }
460
+
461
+ async function cleanManagedState(names: string[], home: string, lockPath: string): Promise<void> {
462
+ for (const name of names) {
463
+ await rm(join(home, '.agents', 'skills', name), { recursive: true, force: true })
464
+ }
465
+ const { lock, raw } = await readSkillLock(lockPath)
466
+ if (!lock) {
467
+ if (raw !== null) await rm(lockPath, { force: true })
468
+ return
469
+ }
470
+ for (const [name, entry] of Object.entries(lock.skills)) {
471
+ if (sourceOwned(entry)) delete lock.skills[name]
472
+ }
473
+ await writeSkillLock(lockPath, lock)
474
+ }
475
+
476
+ async function removeKnownAgentLinks(names: string[], home: string): Promise<void> {
477
+ const roots = await readdir(home, { withFileTypes: true }).catch(() => [])
478
+ for (const root of roots) {
479
+ if (!root.isDirectory() || !root.name.startsWith('.') || root.name === '.agents') continue
480
+ for (const name of names) {
481
+ const link = join(home, root.name, 'skills', name)
482
+ try {
483
+ const stat = await lstat(link)
484
+ if (!stat.isSymbolicLink()) continue
485
+ const target = await readlink(link)
486
+ if (resolve(dirname(link), target) === join(home, '.agents', 'skills', name)) {
487
+ await rm(link, { force: true })
488
+ }
489
+ } catch {
490
+ // Missing or foreign links are not part of the Astrale-owned repair.
491
+ }
492
+ }
493
+ }
494
+ }
495
+
496
+ async function pruneObsoleteEntries(
497
+ snapshot: AstraleSkillSourceSnapshot,
498
+ home: string,
499
+ lockPath: string,
500
+ execute: ReturnType<typeof resolvedDependencies>['run'],
501
+ knownRetired: string[],
502
+ ): Promise<void> {
503
+ const expected = new Set(snapshot.skills.map((skill) => skill.name))
504
+ const { lock } = await readSkillLock(lockPath)
505
+ const retiredEntries = Object.entries(lock?.skills ?? {}).filter(([, entry]) => {
506
+ const folder = folderName(entry)
507
+ return sourceOwned(entry) && folder !== null && !expected.has(folder)
508
+ })
509
+ const retired = [
510
+ ...new Set([
511
+ ...knownRetired,
512
+ ...retiredEntries.flatMap(([, entry]) => folderName(entry) ?? []),
513
+ ]),
514
+ ]
515
+ if (retired.length > 0) {
516
+ const result = await execute('npx', installerArgs('remove', ...retired, '-g', '-y'))
517
+ if (result.code !== 0) throw new Error(result.stderr || result.stdout || 'skill removal failed')
518
+ }
519
+ const refreshed = await readSkillLock(lockPath)
520
+ if (!refreshed.lock) return
521
+ let changed = false
522
+ for (const [name, entry] of Object.entries(refreshed.lock.skills)) {
523
+ if (!sourceOwned(entry)) continue
524
+ const folder = folderName(entry)
525
+ if (folder === null || name !== folder || !expected.has(folder)) {
526
+ delete refreshed.lock.skills[name]
527
+ changed = true
528
+ }
529
+ }
530
+ if (changed) await writeSkillLock(lockPath, refreshed.lock)
531
+
532
+ await removeKnownAgentLinks(retired, home)
533
+ for (const name of retired) {
534
+ await rm(join(home, '.agents', 'skills', name), { recursive: true, force: true })
535
+ }
536
+ }
537
+
538
+ type SkillBackup = {
539
+ root: string
540
+ names: string[]
541
+ copied: string[]
542
+ links: AgentLinkBackup[]
543
+ lockRaw: string | null
544
+ createdAt: string
545
+ phase: 'prepared' | 'verified'
546
+ }
547
+
548
+ type AgentLinkBackup = { root: string; name: string; target: string }
549
+
550
+ const BACKUP_PREFIX = '.astrale-skill-backup-'
551
+ const BACKUP_MANIFEST = '.manifest.json'
552
+
553
+ async function captureAgentLinks(home: string, names: string[]): Promise<AgentLinkBackup[]> {
554
+ const links: AgentLinkBackup[] = []
555
+ const roots = await readdir(home, { withFileTypes: true }).catch(() => [])
556
+ for (const root of roots) {
557
+ if (!root.isDirectory() || !root.name.startsWith('.') || root.name === '.agents') continue
558
+ for (const name of names) {
559
+ const path = join(home, root.name, 'skills', name)
560
+ try {
561
+ if ((await lstat(path)).isSymbolicLink()) {
562
+ links.push({ root: root.name, name, target: await readlink(path) })
563
+ }
564
+ } catch {
565
+ // Only existing symlinks need transactional restoration.
566
+ }
567
+ }
568
+ }
569
+ return links
570
+ }
571
+
572
+ async function writeBackupManifest(backup: SkillBackup): Promise<void> {
573
+ const next = join(backup.root, `${BACKUP_MANIFEST}.next`)
574
+ await writeFile(next, JSON.stringify(backup), { mode: 0o600 })
575
+ await rename(next, join(backup.root, BACKUP_MANIFEST))
576
+ }
577
+
578
+ async function captureBackup(home: string, lockPath: string, names: string[]) {
579
+ const agentsRoot = join(home, '.agents')
580
+ await mkdir(agentsRoot, { recursive: true })
581
+ const root = await mkdtemp(join(agentsRoot, BACKUP_PREFIX))
582
+ const copied: string[] = []
583
+ for (const name of names) {
584
+ const source = join(agentsRoot, 'skills', name)
585
+ try {
586
+ await cp(source, join(root, name), { recursive: true, dereference: false })
587
+ copied.push(name)
588
+ } catch (error) {
589
+ if ((error as { code?: string }).code !== 'ENOENT') throw error
590
+ }
591
+ }
592
+ const lock = await readSkillLock(lockPath)
593
+ const backup: SkillBackup = {
594
+ root,
595
+ names,
596
+ copied,
597
+ links: await captureAgentLinks(home, names),
598
+ lockRaw: lock.raw,
599
+ createdAt: new Date().toISOString(),
600
+ phase: 'prepared',
601
+ }
602
+ await writeBackupManifest(backup)
603
+ return backup
604
+ }
605
+
606
+ async function extendBackup(backup: SkillBackup, home: string, names: string[]): Promise<void> {
607
+ const additions = names.filter((name) => !backup.names.includes(name))
608
+ if (additions.length === 0) return
609
+ for (const name of additions) {
610
+ const source = join(home, '.agents', 'skills', name)
611
+ try {
612
+ await cp(source, join(backup.root, name), { recursive: true, dereference: false })
613
+ backup.copied.push(name)
614
+ } catch (error) {
615
+ if ((error as { code?: string }).code !== 'ENOENT') throw error
616
+ }
617
+ }
618
+ backup.names.push(...additions)
619
+ backup.links.push(...(await captureAgentLinks(home, additions)))
620
+ await writeBackupManifest(backup)
621
+ }
622
+
623
+ async function restoreAgentLinks(
624
+ backup: SkillBackup,
625
+ home: string,
626
+ onlyForeign = false,
627
+ ): Promise<void> {
628
+ for (const link of backup.links) {
629
+ const path = join(home, link.root, 'skills', link.name)
630
+ const canonical = join(home, '.agents', 'skills', link.name)
631
+ if (onlyForeign && resolve(dirname(path), link.target) === canonical) continue
632
+ await mkdir(dirname(path), { recursive: true })
633
+ await rm(path, { recursive: true, force: true })
634
+ await symlink(link.target, path)
635
+ }
636
+ }
637
+
638
+ async function restoreBackup(
639
+ backup: Awaited<ReturnType<typeof captureBackup>>,
640
+ home: string,
641
+ lockPath: string,
642
+ ): Promise<void> {
643
+ for (const name of backup.names) {
644
+ await rm(join(home, '.agents', 'skills', name), { recursive: true, force: true })
645
+ }
646
+ await mkdir(join(home, '.agents', 'skills'), { recursive: true })
647
+ for (const name of backup.copied) {
648
+ await cp(join(backup.root, name), join(home, '.agents', 'skills', name), {
649
+ recursive: true,
650
+ dereference: false,
651
+ })
652
+ }
653
+ if (backup.lockRaw === null) await rm(lockPath, { force: true })
654
+ else {
655
+ await mkdir(dirname(lockPath), { recursive: true })
656
+ const next = `${lockPath}.restore`
657
+ await writeFile(next, backup.lockRaw, { mode: 0o600 })
658
+ await rename(next, lockPath)
659
+ }
660
+ await removeKnownAgentLinks(backup.names, home)
661
+ await restoreAgentLinks(backup, home)
662
+ }
663
+
664
+ async function recoverInterruptedBackup(home: string, lockPath: string): Promise<void> {
665
+ const agentsRoot = join(home, '.agents')
666
+ const candidates = (await readdir(agentsRoot, { withFileTypes: true }).catch(() => []))
667
+ .filter((entry) => entry.isDirectory() && entry.name.startsWith(BACKUP_PREFIX))
668
+ .map((entry) => join(agentsRoot, entry.name))
669
+ const backups: SkillBackup[] = []
670
+ for (const root of candidates) {
671
+ try {
672
+ const parsed = JSON.parse(await readFile(join(root, BACKUP_MANIFEST), 'utf8')) as SkillBackup
673
+ if (
674
+ Array.isArray(parsed.names) &&
675
+ parsed.names.every((name) => typeof name === 'string' && SAFE_NAME.test(name)) &&
676
+ Array.isArray(parsed.copied) &&
677
+ parsed.copied.every((name) => typeof name === 'string' && parsed.names.includes(name)) &&
678
+ Array.isArray(parsed.links) &&
679
+ (typeof parsed.lockRaw === 'string' || parsed.lockRaw === null) &&
680
+ typeof parsed.createdAt === 'string' &&
681
+ (parsed.phase === 'prepared' || parsed.phase === 'verified') &&
682
+ parsed.links.every(
683
+ (link) =>
684
+ link !== null &&
685
+ typeof link === 'object' &&
686
+ typeof link.root === 'string' &&
687
+ /^\.[^/]+$/u.test(link.root) &&
688
+ typeof link.name === 'string' &&
689
+ parsed.names.includes(link.name) &&
690
+ typeof link.target === 'string',
691
+ )
692
+ ) {
693
+ const backup = { ...parsed, root }
694
+ if (backup.phase === 'verified') await rm(root, { recursive: true, force: true })
695
+ else backups.push(backup)
696
+ } else {
697
+ await rm(root, { recursive: true, force: true })
698
+ }
699
+ } catch {
700
+ // The live cohort is untouched until the manifest is complete.
701
+ await rm(root, { recursive: true, force: true })
702
+ }
703
+ }
704
+ if (backups.length === 0) return
705
+ backups.sort((a, b) => a.createdAt.localeCompare(b.createdAt))
706
+ await restoreBackup(backups[0], home, lockPath)
707
+ await Promise.all(candidates.map((root) => rm(root, { recursive: true, force: true })))
708
+ }
709
+
710
+ async function markBackupVerified(backup: SkillBackup): Promise<void> {
711
+ backup.phase = 'verified'
712
+ await writeBackupManifest(backup)
713
+ }
714
+
715
+ function skillFailure(error: unknown): AstraleError {
716
+ const detail = error instanceof Error && error.message.trim() ? `: ${error.message}` : ''
717
+ return new AstraleError(
718
+ 'SKILL_UPDATE_FAILED',
719
+ `Astrale could not install and verify its agent skills${detail}`,
720
+ `Retry with \`${ASTRALE_SKILL_REPAIR_COMMAND}\`.`,
721
+ error instanceof Error ? { cause: error } : undefined,
722
+ )
723
+ }
724
+
725
+ /** Read-only source, freshness, and local integrity check. */
726
+ export async function checkAstraleSkills(
727
+ overrides: SkillSyncDependencies = {},
728
+ ): Promise<SkillCheckResult> {
729
+ const dependencies = resolvedDependencies(overrides)
730
+ try {
731
+ const snapshot = await sourceSnapshot(dependencies)
732
+ const inspection = await inspectAstraleSkills(
733
+ snapshot,
734
+ dependencies.home,
735
+ dependencies.lockPath,
736
+ )
737
+ const status =
738
+ inspection.state === 'current'
739
+ ? 'current'
740
+ : inspection.state === 'unhealthy'
741
+ ? 'repair-needed'
742
+ : 'update-available'
743
+ return {
744
+ status,
745
+ ...(status === 'current'
746
+ ? {
747
+ source: {
748
+ repository: ASTRALE_CLI_SKILL_SOURCE,
749
+ revision: snapshot.revision,
750
+ skills: snapshot.skills.map(({ name, tree, path }) => ({
751
+ name,
752
+ tree,
753
+ entrypoint: path,
754
+ })),
755
+ },
756
+ }
757
+ : {}),
758
+ }
759
+ } catch (error) {
760
+ return { status: 'unavailable', error: error instanceof Error ? error.message : String(error) }
761
+ }
762
+ }
763
+
764
+ /**
765
+ * Ensure the complete Astrale-owned cohort is current and internally consistent.
766
+ * A clean second attempt follows any failed ordinary refresh; a failed operation
767
+ * restores the exact prior cohort and never reports success.
768
+ */
769
+ export async function syncAstraleSkills(
770
+ overrides: SkillSyncDependencies = {},
771
+ ): Promise<SkillApplyResult> {
772
+ const dependencies = resolvedDependencies(overrides)
773
+ let snapshot: AstraleSkillSourceSnapshot
774
+ try {
775
+ snapshot = await sourceSnapshot(dependencies)
776
+ } catch (error) {
777
+ throw skillFailure(error)
778
+ }
779
+ const lockFile = join(
780
+ process.env.ASTRALE_HOME ?? join(dependencies.home, '.astrale'),
781
+ 'locks',
782
+ 'skills-update.lock',
783
+ )
784
+
785
+ try {
786
+ return await withFileLock(lockFile, async () => {
787
+ await recoverInterruptedBackup(dependencies.home, dependencies.lockPath)
788
+ const initial = await inspectAstraleSkills(snapshot, dependencies.home, dependencies.lockPath)
789
+ if (initial.state === 'current') return { status: 'unchanged' }
790
+
791
+ let expectedNames = snapshot.skills.map((skill) => skill.name)
792
+ let managedFolders = [...new Set([...expectedNames, ...initial.managedFolders])]
793
+ let knownRetired = initial.managedFolders.filter((name) => !expectedNames.includes(name))
794
+ const agents = await selectedAgents(dependencies.home, dependencies.lockPath)
795
+ const backup = await captureBackup(dependencies.home, dependencies.lockPath, managedFolders)
796
+ let lastError: unknown
797
+ try {
798
+ for (let attempt = 0; attempt < 2; attempt++) {
799
+ try {
800
+ if (attempt === 1) {
801
+ snapshot = await sourceSnapshot(dependencies)
802
+ expectedNames = snapshot.skills.map((skill) => skill.name)
803
+ managedFolders = [...new Set([...managedFolders, ...expectedNames])]
804
+ await extendBackup(backup, dependencies.home, managedFolders)
805
+ knownRetired = [
806
+ ...new Set([
807
+ ...knownRetired,
808
+ ...managedFolders.filter((name) => !expectedNames.includes(name)),
809
+ ]),
810
+ ]
811
+ }
812
+ if (initial.state === 'unhealthy' || attempt === 1) {
813
+ await cleanManagedState(managedFolders, dependencies.home, dependencies.lockPath)
814
+ }
815
+ const installed = await installSnapshot(snapshot, dependencies.run, agents)
816
+ if (installed.code !== 0) {
817
+ throw new Error(installed.stderr || installed.stdout || 'skill installer failed')
818
+ }
819
+ await pruneObsoleteEntries(
820
+ snapshot,
821
+ dependencies.home,
822
+ dependencies.lockPath,
823
+ dependencies.run,
824
+ knownRetired,
825
+ )
826
+ const installedInspection = await inspectAstraleSkills(
827
+ snapshot,
828
+ dependencies.home,
829
+ dependencies.lockPath,
830
+ )
831
+ if (!installedInspection.sourceCurrent) {
832
+ throw new Error('installed Astrale skills do not match the resolved source')
833
+ }
834
+ await stampAstraleSource(snapshot, dependencies.lockPath)
835
+ const verified = await inspectAstraleSkills(
836
+ snapshot,
837
+ dependencies.home,
838
+ dependencies.lockPath,
839
+ )
840
+ if (verified.state !== 'current') {
841
+ throw new Error('installed Astrale skills did not pass verification')
842
+ }
843
+ const latest = await sourceSnapshot(dependencies)
844
+ if (latest.revision !== snapshot.revision) {
845
+ throw new Error('Astrale skill source changed during installation')
846
+ }
847
+ await markBackupVerified(backup)
848
+ await rm(backup.root, { recursive: true, force: true })
849
+ return {
850
+ status:
851
+ initial.state === 'absent'
852
+ ? 'installed'
853
+ : initial.state === 'outdated'
854
+ ? 'updated'
855
+ : 'repaired',
856
+ }
857
+ } catch (error) {
858
+ lastError = error
859
+ }
860
+ }
861
+ throw lastError
862
+ } catch (error) {
863
+ if (initial.state === 'absent' || initial.state === 'unhealthy') {
864
+ await cleanManagedState(managedFolders, dependencies.home, dependencies.lockPath)
865
+ await removeKnownAgentLinks(managedFolders, dependencies.home)
866
+ await restoreAgentLinks(backup, dependencies.home, true)
867
+ } else {
868
+ await restoreBackup(backup, dependencies.home, dependencies.lockPath)
869
+ }
870
+ await rm(backup.root, { recursive: true, force: true })
871
+ throw skillFailure(error)
872
+ }
873
+ })
874
+ } catch (error) {
875
+ if (error instanceof AstraleError) throw error
876
+ throw skillFailure(error)
877
+ }
878
+ }