@astrale-os/cli 1.0.0-beta.26 → 1.0.0-beta.28

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