orizu 0.5.49 → 0.5.50

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 (74) hide show
  1. package/dist/account-directory.d.ts +2 -2
  2. package/dist/account-directory.d.ts.map +1 -1
  3. package/dist/account-directory.js +7 -32
  4. package/dist/auth-commands.d.ts.map +1 -1
  5. package/dist/auth-commands.js +22 -30
  6. package/dist/credentials.d.ts +5 -0
  7. package/dist/credentials.d.ts.map +1 -1
  8. package/dist/credentials.js +84 -3
  9. package/dist/dataset-transfer-upload.d.ts.map +1 -1
  10. package/dist/dataset-transfer-upload.js +6 -1
  11. package/dist/directory-list-read.d.ts +37 -0
  12. package/dist/directory-list-read.d.ts.map +1 -0
  13. package/dist/directory-list-read.js +87 -0
  14. package/dist/hosted-boot.d.ts.map +1 -1
  15. package/dist/hosted-boot.js +59 -41
  16. package/dist/hosted-harness-opencode.d.ts +31 -0
  17. package/dist/hosted-harness-opencode.d.ts.map +1 -1
  18. package/dist/hosted-harness-opencode.js +45 -7
  19. package/dist/hosted-loop.d.ts +39 -2
  20. package/dist/hosted-loop.d.ts.map +1 -1
  21. package/dist/hosted-loop.js +127 -18
  22. package/dist/hosted-session-seed.d.ts +2 -0
  23. package/dist/hosted-session-seed.d.ts.map +1 -1
  24. package/dist/hosted-session-seed.js +27 -10
  25. package/dist/http.d.ts.map +1 -1
  26. package/dist/http.js +39 -234
  27. package/dist/keyboard-select.d.ts.map +1 -1
  28. package/dist/keyboard-select.js +23 -45
  29. package/dist/project-create-workflow.d.ts +18 -0
  30. package/dist/project-create-workflow.d.ts.map +1 -0
  31. package/dist/project-create-workflow.js +121 -0
  32. package/dist/self-update-command.d.ts +8 -0
  33. package/dist/self-update-command.d.ts.map +1 -0
  34. package/dist/self-update-command.js +390 -0
  35. package/dist/self-update.d.ts +29 -16
  36. package/dist/self-update.d.ts.map +1 -1
  37. package/dist/self-update.js +55 -244
  38. package/dist/setup-account.d.ts.map +1 -1
  39. package/dist/setup-account.js +4 -1
  40. package/dist/setup-and-skills-commands.d.ts.map +1 -1
  41. package/dist/setup-and-skills-commands.js +88 -102
  42. package/dist/skill-install-workflow.d.ts +23 -0
  43. package/dist/skill-install-workflow.d.ts.map +1 -0
  44. package/dist/skill-install-workflow.js +184 -0
  45. package/dist/skill-installer.d.ts +8 -3
  46. package/dist/skill-installer.d.ts.map +1 -1
  47. package/dist/skill-installer.js +26 -33
  48. package/dist/stored-session-requests.d.ts +27 -0
  49. package/dist/stored-session-requests.d.ts.map +1 -0
  50. package/dist/stored-session-requests.js +264 -0
  51. package/dist/terminal-key-session.d.ts +23 -0
  52. package/dist/terminal-key-session.d.ts.map +1 -0
  53. package/dist/terminal-key-session.js +74 -0
  54. package/package.json +1 -1
  55. package/src/account-directory.ts +8 -42
  56. package/src/auth-commands.ts +20 -29
  57. package/src/credentials.ts +83 -3
  58. package/src/dataset-transfer-upload.ts +7 -1
  59. package/src/directory-list-read.ts +123 -0
  60. package/src/hosted-boot.ts +65 -43
  61. package/src/hosted-harness-opencode.ts +65 -9
  62. package/src/hosted-loop.ts +152 -18
  63. package/src/hosted-session-seed.ts +25 -8
  64. package/src/http.ts +39 -317
  65. package/src/keyboard-select.ts +18 -55
  66. package/src/project-create-workflow.ts +139 -0
  67. package/src/self-update-command.ts +419 -0
  68. package/src/self-update.ts +70 -266
  69. package/src/setup-account.ts +4 -1
  70. package/src/setup-and-skills-commands.ts +89 -121
  71. package/src/skill-install-workflow.ts +215 -0
  72. package/src/skill-installer.ts +31 -38
  73. package/src/stored-session-requests.ts +316 -0
  74. package/src/terminal-key-session.ts +88 -0
@@ -2,7 +2,6 @@
2
2
 
3
3
  import { createInterface } from 'readline/promises'
4
4
  import { stdin as input, stdout as output } from 'process'
5
- import { emitKeypressEvents } from 'readline'
6
5
  import { join, relative, resolve } from 'path'
7
6
  import { mkdirSync, writeFileSync } from 'fs'
8
7
  import { randomBytes } from 'crypto'
@@ -25,6 +24,7 @@ import {
25
24
  printJson,
26
25
  printBannerIfInteractive,
27
26
  } from './cli-console-output.js'
27
+ import { datasetTransferTimeoutMs } from './dataset-transfer-policy.js'
28
28
  import { sanitizeTerminalText } from './json-response.js'
29
29
  import {
30
30
  SKILL_INSTALL_AGENTS,
@@ -34,6 +34,8 @@ import {
34
34
  getTargetForAgent,
35
35
  getSkillInstallPath,
36
36
  SkillInstallMode,
37
+ SkillInstallOptions,
38
+ SkillInstallResult,
37
39
  isSkillInstallAgent,
38
40
  isSkillInstallTarget,
39
41
  installSkillTarget,
@@ -112,50 +114,18 @@ async function askText(question: string, defaultValue: string): Promise<string>
112
114
  }
113
115
  }
114
116
 
115
- type KeypressInfo = {
116
- name?: string
117
- sequence?: string
118
- ctrl?: boolean
119
- }
120
-
121
- type RawInput = typeof input & {
122
- setRawMode?: (mode: boolean) => void
123
- isRaw?: boolean
124
- }
125
-
126
117
  async function waitForAnyKeyOrEscape(message: string): Promise<boolean> {
127
118
  if (!isInteractiveTerminal()) {
128
119
  throw new Error('Interactive terminal required.')
129
120
  }
130
121
 
131
122
  printLine(message)
132
- const rawInput = input as RawInput
133
- const wasRaw = Boolean(rawInput.isRaw)
134
- emitKeypressEvents(input)
135
-
136
- return new Promise((resolvePromise, rejectPromise) => {
137
- function cleanup() {
138
- input.off('keypress', onKeypress)
139
- if (rawInput.setRawMode && !wasRaw) {
140
- rawInput.setRawMode(false)
141
- }
142
- input.pause()
143
- printLine('')
144
- }
145
-
146
- function onKeypress(_chunk: string, key: KeypressInfo) {
147
- cleanup()
148
- if (key.ctrl && key.name === 'c') {
149
- rejectPromise(new Error('Setup cancelled.'))
150
- return
151
- }
152
- resolvePromise(key.name !== 'escape')
153
- }
154
-
155
- input.on('keypress', onKeypress)
156
- input.resume()
157
- rawInput.setRawMode?.(true)
158
- })
123
+ const { readKeys } = await import('./terminal-key-session.js')
124
+ try {
125
+ return await readKeys(key => ({ kind: 'done', value: key.name !== 'escape' }), { hideCursor: false })
126
+ } finally {
127
+ printLine('')
128
+ }
159
129
  }
160
130
 
161
131
  interface MultiSelectOptions {
@@ -190,9 +160,6 @@ async function promptKeyboardMultiSelect<T>(
190
160
  checked.add(index)
191
161
  }
192
162
  })
193
- const rawInput = input as RawInput
194
- const wasRaw = Boolean(rawInput.isRaw)
195
- emitKeypressEvents(input)
196
163
 
197
164
  function render() {
198
165
  if (rendered) {
@@ -208,60 +175,31 @@ async function promptKeyboardMultiSelect<T>(
208
175
  }
209
176
 
210
177
  render()
211
- output.write('\x1b[?25l')
212
-
213
- return new Promise((resolvePromise, rejectPromise) => {
214
- function cleanup() {
215
- input.off('keypress', onKeypress)
216
- if (rawInput.setRawMode && !wasRaw) {
217
- rawInput.setRawMode(false)
218
- }
219
- input.pause()
220
- output.write('\x1b[?25h')
221
- printLine('')
222
- }
223
-
224
- function onKeypress(_chunk: string, key: KeypressInfo) {
225
- if (key.ctrl && key.name === 'c') {
226
- cleanup()
227
- rejectPromise(new Error('Setup cancelled.'))
228
- return
229
- }
178
+ const { readKeys } = await import('./terminal-key-session.js')
179
+ try {
180
+ return await readKeys<T[]>(key => {
230
181
  if (key.name === 'escape') {
231
- cleanup()
232
- resolvePromise(options.escapeKeepsDefaults ? items.filter((item, index) => defaultSelected(item, index)) : [])
233
- return
182
+ return { kind: 'done', value: options.escapeKeepsDefaults ? items.filter((item, index) => defaultSelected(item, index)) : [] }
234
183
  }
235
- if (key.name === 'up') {
236
- selected = (selected - 1 + items.length) % items.length
184
+ if (key.name === 'up' || key.name === 'down') {
185
+ selected = (selected + (key.name === 'up' ? -1 : 1) + items.length) % items.length
237
186
  render()
238
- return
239
- }
240
- if (key.name === 'down') {
241
- selected = (selected + 1) % items.length
242
- render()
243
- return
187
+ return { kind: 'wait' }
244
188
  }
245
189
  if (key.name === 'space') {
246
- if (checked.has(selected)) {
247
- checked.delete(selected)
248
- } else {
249
- checked.add(selected)
250
- }
190
+ if (checked.has(selected)) checked.delete(selected)
191
+ else checked.add(selected)
251
192
  render()
252
- return
193
+ return { kind: 'wait' }
253
194
  }
254
195
  if (key.name === 'return' || key.name === 'enter') {
255
- const selectedItems = items.filter((_item, index) => checked.has(index))
256
- cleanup()
257
- resolvePromise(selectedItems)
196
+ return { kind: 'done', value: items.filter((_item, index) => checked.has(index)) }
258
197
  }
259
- }
260
-
261
- input.on('keypress', onKeypress)
262
- input.resume()
263
- rawInput.setRawMode?.(true)
264
- })
198
+ return { kind: 'wait' }
199
+ }, { hideCursor: true })
200
+ } finally {
201
+ printLine('')
202
+ }
265
203
  }
266
204
 
267
205
  function formatFindingCount(count: number, label: string): string {
@@ -461,6 +399,10 @@ interface SkillInstallOutcome {
461
399
  action: 'created' | 'updated' | 'skipped' | 'failed'
462
400
  mode: 'link' | 'copy' | 'section' | null
463
401
  error?: string
402
+ /** Where a person's own folder was moved before it was replaced. */
403
+ backup?: string
404
+ /** Why a person's folder, or part of it, is still beside the skill. */
405
+ warning?: string
464
406
  }
465
407
 
466
408
  async function applySkillInstallTargets(
@@ -477,7 +419,7 @@ async function applySkillInstallTargets(
477
419
  ): Promise<SkillInstallOutcome[]> {
478
420
  const cliVersion = getCliVersion()
479
421
  const jsonMode = hasJsonFlag()
480
- const planned = targets.map(target =>
422
+ const planned = await Promise.all(targets.map(target =>
481
423
  installSkillTarget(target, {
482
424
  cwd: options.cwd,
483
425
  homeDir: options.homeDir,
@@ -485,7 +427,7 @@ async function applySkillInstallTargets(
485
427
  mode: options.mode,
486
428
  cliVersion,
487
429
  })
488
- )
430
+ ))
489
431
  const showPlan = options.showPlan ?? true
490
432
  const printResults = options.printResults ?? true
491
433
 
@@ -528,7 +470,7 @@ async function applySkillInstallTargets(
528
470
  }
529
471
 
530
472
  try {
531
- const result = installSkillTarget(target, {
473
+ const result = await installSkillTarget(target, {
532
474
  cwd: options.cwd,
533
475
  homeDir: options.homeDir,
534
476
  overwrite,
@@ -537,13 +479,16 @@ async function applySkillInstallTargets(
537
479
  })
538
480
  if (printResults && !jsonMode) {
539
481
  const suffix = result.mode === 'link' ? ' (symlink)' : ''
540
- printLine(`${formatSkillInstallAction(result.action)} ${result.path}${suffix}`)
482
+ printLine(`${formatSkillInstallAction(result.action)} ${result.path}${suffix}${describeBackup(result.backup)}`)
483
+ if (result.warning) printLine(sanitizeSetupHumanInlineText(`Warning: ${result.warning}`))
541
484
  }
542
485
  outcomes.push({
543
486
  target,
544
487
  path: result.path,
545
488
  action: result.action as 'created' | 'updated',
546
489
  mode: result.mode,
490
+ ...(result.backup ? { backup: result.backup } : {}),
491
+ ...(result.warning ? { warning: result.warning } : {}),
547
492
  })
548
493
  } catch (error: unknown) {
549
494
  const message = getErrorMessage(error)
@@ -557,6 +502,10 @@ async function applySkillInstallTargets(
557
502
  return outcomes
558
503
  }
559
504
 
505
+ function describeBackup(backup: string | undefined): string {
506
+ return backup ? `; moved your earlier folder to ${backup}` : ''
507
+ }
508
+
560
509
  async function installSkillCommand() {
561
510
  const skipConfirm = hasArg('--yes')
562
511
  const dryRun = hasArg('--dry-run')
@@ -578,15 +527,15 @@ async function installSkillCommand() {
578
527
  }
579
528
 
580
529
  const outcomes = await applySkillInstallTargets(targets, { mode, skipConfirm, dryRun })
530
+ if (outcomes.some(outcome => outcome.action === 'failed')) {
531
+ process.exitCode = 1
532
+ }
581
533
  const failedCopy = outcomes.some(outcome =>
582
534
  outcome.action === 'failed' && (PROJECT_SKILL_COPY_TARGETS as readonly string[]).includes(outcome.target))
583
- if (failedCopy) {
584
- process.exitCode = 1
585
- if (!hasJsonFlag()) {
586
- printLine('The two project copies were not both written. Fix the error above and run the command again.')
587
- }
535
+ if (failedCopy && !hasJsonFlag()) {
536
+ printLine('The two project copies were not both written. Fix the error above and run the command again.')
588
537
  }
589
- const legacy = replaceLegacySkillInstalls({
538
+ const legacy = await replaceLegacySkillInstalls({
590
539
  ...legacyScopeAfterInstall(targets, outcomes, dryRun),
591
540
  mode,
592
541
  dryRun,
@@ -753,8 +702,18 @@ function skillsStatusCommand() {
753
702
  async function skillsUpdateCommand() {
754
703
  const dryRun = hasArg('--dry-run')
755
704
  const cliVersion = getCliVersion()
756
- const updates: Array<{ target: SkillInstallTarget, path: string, action: string }> = []
757
- const legacy = replaceLegacySkillInstalls({ dryRun, cliVersion })
705
+ const updates: Array<{ target: SkillInstallTarget, path: string, action: string, error?: string, backup?: string, warning?: string }> = []
706
+ // A failed target is reported with its reason; the old install there stays whole.
707
+ const refresh = async (target: SkillInstallTarget, path: string, options: SkillInstallOptions, action: (result: SkillInstallResult) => string) => {
708
+ try {
709
+ const result = await installSkillTarget(target, options)
710
+ updates.push({ target, path, action: action(result), ...(result.backup ? { backup: result.backup } : {}), ...(result.warning ? { warning: result.warning } : {}) })
711
+ } catch (error: unknown) {
712
+ process.exitCode = 1
713
+ updates.push({ target, path, action: 'failed', error: getErrorMessage(error) })
714
+ }
715
+ }
716
+ const legacy = await replaceLegacySkillInstalls({ dryRun, cliVersion })
758
717
  // Two project copies that differ are both rewritten from the installed CLI
759
718
  // skill, including a hand-made copy the loop below would leave alone.
760
719
  const projectCopies = compareProjectSkillCopies()
@@ -766,8 +725,7 @@ async function skillsUpdateCommand() {
766
725
  updates.push({ target, path, action: 'would-rewrite' })
767
726
  continue
768
727
  }
769
- installSkillTarget(target, { overwrite: true, mode: 'copy', cliVersion })
770
- updates.push({ target, path, action: 'rewrote' })
728
+ await refresh(target, path, { overwrite: true, mode: 'copy', cliVersion }, () => 'rewrote')
771
729
  }
772
730
  }
773
731
 
@@ -791,16 +749,8 @@ async function skillsUpdateCommand() {
791
749
  }
792
750
 
793
751
  const refreshMode: SkillInstallMode = status.mode === 'link' ? 'auto' : 'copy'
794
- const result = installSkillTarget(target, {
795
- overwrite: true,
796
- mode: refreshMode,
797
- cliVersion,
798
- })
799
- updates.push({
800
- target,
801
- path: status.path,
802
- action: status.state === 'broken-link' ? 'relinked' : result.action,
803
- })
752
+ await refresh(target, status.path, { overwrite: true, mode: refreshMode, cliVersion },
753
+ result => status.state === 'broken-link' ? 'relinked' : result.action)
804
754
  }
805
755
 
806
756
  if (hasJsonFlag()) {
@@ -816,23 +766,38 @@ async function skillsUpdateCommand() {
816
766
  }
817
767
 
818
768
  if (rewriteProjectCopies) {
819
- const verb = dryRun ? 'Would rewrite' : 'Rewrote'
820
- printLine(`${verb} both project copies from the installed CLI skill: ${projectCopies.paths.join(' and ')}`)
769
+ const rewrote = updates.filter(update => update.action === 'rewrote')
770
+ if (dryRun) {
771
+ printLine(`Would rewrite both project copies from the installed CLI skill: ${projectCopies.paths.join(' and ')}`)
772
+ } else if (rewrote.length === PROJECT_SKILL_COPY_TARGETS.length) {
773
+ printLine(`Rewrote both project copies from the installed CLI skill: ${projectCopies.paths.join(' and ')}`)
774
+ }
821
775
  }
822
776
 
823
777
  for (const update of updates) {
824
- if (update.action === 'rewrote' || update.action === 'would-rewrite') {
778
+ if (update.action === 'failed') {
779
+ printLine(sanitizeSetupHumanInlineText(`Failed ${update.path}: ${update.error}`))
825
780
  continue
826
781
  }
827
- if (update.action === 'already-current') {
782
+ if (update.action === 'would-rewrite') {
783
+ continue
784
+ }
785
+ if (update.action === 'rewrote') {
786
+ // With both rewritten, the line above names them; otherwise each says so here.
787
+ const bothRewritten = updates.filter(other => other.action === 'rewrote').length === PROJECT_SKILL_COPY_TARGETS.length
788
+ if (!bothRewritten || update.backup) {
789
+ printLine(sanitizeSetupHumanInlineText(`Rewrote ${update.path} from the installed CLI skill${describeBackup(update.backup)}`))
790
+ }
791
+ } else if (update.action === 'already-current') {
828
792
  printLine(`Current ${update.path}`)
829
793
  } else if (update.action === 'would-update') {
830
794
  printLine(`Would update ${update.path}`)
831
795
  } else if (update.action === 'relinked') {
832
796
  printLine(`Relinked ${update.path}`)
833
797
  } else {
834
- printLine(`Updated ${update.path}`)
798
+ printLine(sanitizeSetupHumanInlineText(`Updated ${update.path}${describeBackup(update.backup)}`))
835
799
  }
800
+ if (update.warning) printLine(sanitizeSetupHumanInlineText(`Warning: ${update.warning}`))
836
801
  }
837
802
  }
838
803
 
@@ -878,7 +843,7 @@ async function resolveSetupTeam(
878
843
  if (!name) throw new Error('--create-team requires a non-empty team name.')
879
844
  return await createSetupTeam(name, request)
880
845
  }
881
- const teams = await fetchTeams(request)
846
+ const teams = await fetchTeams(request, datasetTransferTimeoutMs())
882
847
  const normalizedTeamSlug = teamSlugArg ? normalizeSlugInput(teamSlugArg) : null
883
848
  if (normalizedTeamSlug) {
884
849
  const team = teams.find(candidate => candidate.slug === normalizedTeamSlug)
@@ -945,7 +910,7 @@ async function resolveSetupProjects(
945
910
  if (createProjectName && !canCreateProject) {
946
911
  throw new Error(`Only team admins can create projects in '${team.slug}'. Choose an existing project or ask a team admin to create one.`)
947
912
  }
948
- const projects = await fetchProjects(team.slug, request)
913
+ const projects = await fetchProjects(team.slug, request, datasetTransferTimeoutMs())
949
914
  if (createProjectName) {
950
915
  if (dryRun) {
951
916
  throw new Error('--create-project cannot be used with --dry-run because setup cannot preview a server-generated project slug.')
@@ -1154,7 +1119,7 @@ async function setupCommand() {
1154
1119
  throw new Error(`This directory is already an Orizu workspace for team '${workspaceTeamSlug}'. Run setup in another directory to set up team '${setupTeam.slug}'.`)
1155
1120
  }
1156
1121
  if (fix) {
1157
- setupProjects = await fetchProjects(setupTeam.slug, setupRequest)
1122
+ setupProjects = await fetchProjects(setupTeam.slug, setupRequest, datasetTransferTimeoutMs())
1158
1123
  if (setupProjects.length === 0) throw new Error(`Team '${setupTeam.slug}' has no projects to restore.`)
1159
1124
  } else {
1160
1125
  const selection = await resolveSetupProjects(setupTeam, projectArg, createProjectArg, noInput, dryRun, setupRequest)
@@ -1277,7 +1242,7 @@ async function setupCommand() {
1277
1242
  if (dryRun) {
1278
1243
  printLine('Dry run: no skill files were changed.')
1279
1244
  }
1280
- const legacy = replaceLegacySkillInstalls({
1245
+ const legacy = await replaceLegacySkillInstalls({
1281
1246
  ...legacyScopeAfterInstall(targets, installOutcomes, dryRun),
1282
1247
  mode: installMode,
1283
1248
  dryRun,
@@ -1293,7 +1258,8 @@ async function setupCommand() {
1293
1258
  } else if (outcome.action === 'skipped') {
1294
1259
  printLine(`${label} skipped`)
1295
1260
  } else {
1296
- printLine(`${label} installed`)
1261
+ printLine(sanitizeSetupHumanInlineText(`${label} installed${describeBackup(outcome.backup)}`))
1262
+ if (outcome.warning) printLine(sanitizeSetupHumanInlineText(`Warning: ${outcome.warning}`))
1297
1263
  }
1298
1264
  }
1299
1265
  }
@@ -1388,6 +1354,8 @@ async function setupCommand() {
1388
1354
  action: outcome.action,
1389
1355
  mode: outcome.mode,
1390
1356
  error: outcome.error,
1357
+ ...(outcome.backup ? { backup: outcome.backup } : {}),
1358
+ ...(outcome.warning ? { warning: outcome.warning } : {}),
1391
1359
  })),
1392
1360
  skill: {
1393
1361
  root: source.root,
@@ -0,0 +1,215 @@
1
+ // Reads the skill source and the install target it is given.
2
+ // @tia-reads: skills/orizu
3
+ // Replaces one skill folder without ever leaving a half-written one, and never
4
+ // deletes a person's files: they are moved to a backup first. Loaded only
5
+ // through `await import(...)`, so `orizu skills path` never loads Effect.
6
+ import { cp, lstat, mkdir, readdir, readlink, readFile, rename, rm, symlink, unlink, copyFile } from 'node:fs/promises'
7
+ import { basename, dirname, join, resolve } from 'node:path'
8
+
9
+ import { Context, Effect, Exit, Option, Schema } from 'effect'
10
+
11
+ import { computeSkillContentHash, SKILL_META_FILENAME, shouldCopySkillPath } from './skill-installer.js'
12
+
13
+ export interface ReplaceSkillFolderInput {
14
+ sourceRoot: string
15
+ targetPath: string
16
+ mode: 'link' | 'copy'
17
+ /** Where a person's own folder is moved; no agent looks for skills there. */
18
+ backupPath: string
19
+ /** Writes Orizu's marker into the new copy before it is put in place. */
20
+ writeMarker: (dir: string) => void
21
+ }
22
+
23
+ export interface ReplaceSkillFolderResult {
24
+ /** Where a person's own folder was moved, when there was one. */
25
+ backup: string | null
26
+ /** Why a person's folder, or part of it, is still beside the skill. */
27
+ warning?: string
28
+ }
29
+
30
+ /** Every rename the replacement makes. Tests swap it to make one rename fail. */
31
+ const SkillFolderRename = Context.Reference<(from: string, to: string) => Promise<void>>('orizu/SkillFolderRename', {
32
+ defaultValue: () => rename,
33
+ })
34
+
35
+ const renameEntry = (from: string, to: string) => Effect.gen(function*() {
36
+ const move = yield* SkillFolderRename
37
+ yield* attempt(() => move(from, to))
38
+ })
39
+
40
+ const SkillMarker = Schema.Struct({ skillHash: Schema.String })
41
+
42
+ type Existing = 'missing' | 'link' | 'orizu' | 'person'
43
+
44
+ const attempt = <A>(run: () => Promise<A>) =>
45
+ // One file step at a time: an interruption lands between steps, never during one.
46
+ Effect.uninterruptible(Effect.tryPromise({ try: run, catch: cause => cause instanceof Error ? cause : new Error(String(cause)) }))
47
+
48
+ const exists = (path: string) => attempt(() => lstat(path).then(() => true, () => false))
49
+
50
+ // Orizu's own install is a link, or a copy whose files still match its marker
51
+ // or the skill being installed. Anything else may hold a person's work.
52
+ function classify(targetPath: string, sourceRoot: string): Effect.Effect<Existing, Error> {
53
+ return Effect.gen(function*() {
54
+ const stats = yield* attempt(() => lstat(targetPath).then(found => found, () => null))
55
+ if (!stats) return 'missing'
56
+ if (stats.isSymbolicLink()) return 'link'
57
+ if (!stats.isDirectory()) return 'person'
58
+ const marker = yield* attempt(() => readFile(join(targetPath, SKILL_META_FILENAME), 'utf8').then(text => JSON.parse(text) as unknown, () => null))
59
+ const installedHash = computeSkillContentHash(targetPath)
60
+ const recorded = Schema.decodeUnknownOption(SkillMarker)(marker)
61
+ if (Option.isSome(recorded) && recorded.value.skillHash === installedHash) return 'orizu'
62
+ return installedHash === computeSkillContentHash(sourceRoot) ? 'orizu' : 'person'
63
+ })
64
+ }
65
+
66
+ function copyTree(root: string, from: string, to: string): Effect.Effect<void, Error> {
67
+ return Effect.gen(function*() {
68
+ yield* attempt(() => mkdir(to, { recursive: true }))
69
+ const entries = yield* attempt(() => readdir(from, { withFileTypes: true }))
70
+ for (const entry of entries) {
71
+ const source = join(from, entry.name)
72
+ const target = join(to, entry.name)
73
+ if (!shouldCopySkillPath(root, source)) continue
74
+ if (entry.isDirectory()) yield* copyTree(root, source, target)
75
+ else if (entry.isFile()) yield* attempt(() => copyFile(source, target))
76
+ else if (entry.isSymbolicLink()) yield* attempt(async () => symlink(await readlink(source), target))
77
+ else yield* Effect.fail(new Error(`${source} is not a file or folder, so the skill can't be copied.`))
78
+ }
79
+ })
80
+ }
81
+
82
+ // Links are removed as links, never by following them.
83
+ const removeEntry = (path: string) => attempt(async () => {
84
+ const stats = await lstat(path).catch(() => null)
85
+ if (!stats) return
86
+ if (stats.isSymbolicLink()) await unlink(path)
87
+ else await rm(path, { recursive: true, force: true })
88
+ })
89
+
90
+
91
+ /**
92
+ * Moves a person's folder to the backup. Once the copy is whole, the backup
93
+ * is reported even when the original cannot be removed; if nothing could be
94
+ * moved, the folder stays where it is and the warning says why.
95
+ */
96
+ function moveToBackup(from: string, to: string): Effect.Effect<ReplaceSkillFolderResult> {
97
+ const stayed = (cause: Error): ReplaceSkillFolderResult => ({
98
+ backup: resolve(from),
99
+ warning: `Could not move your earlier folder to ${to}: ${cause.message}. It is still at ${from}, where agents may load it as a second skill; move it somewhere else.`,
100
+ })
101
+ return Effect.gen(function*() {
102
+ yield* attempt(() => mkdir(dirname(to), { recursive: true }))
103
+ // A project on another disk cannot be renamed into the home folder, so it is copied.
104
+ const renamed = yield* renameEntry(from, to).pipe(Effect.as(true), Effect.catch(error =>
105
+ (error as { code?: string }).code === 'EXDEV' ? Effect.succeed(false) : Effect.fail(error)))
106
+ if (renamed) return { backup: resolve(to) }
107
+ // Our own half-made copy is removed; the person's folder is untouched.
108
+ yield* attempt(() => cp(from, to, { recursive: true, verbatimSymlinks: true })).pipe(
109
+ Effect.tapError(() => removeEntry(to).pipe(Effect.ignore)))
110
+ return yield* attempt(() => rm(from, { recursive: true })).pipe(
111
+ Effect.as<ReplaceSkillFolderResult>({ backup: resolve(to) }),
112
+ Effect.catch(cause => Effect.succeed<ReplaceSkillFolderResult>({
113
+ backup: resolve(to),
114
+ warning: `Copied your earlier folder to ${to}, but could not remove it from ${from}: ${cause.message}. Agents may load it there as a second skill; remove it once you have checked the copy.`,
115
+ })),
116
+ )
117
+ }).pipe(Effect.catch(cause => Effect.succeed(stayed(cause))))
118
+ }
119
+
120
+ /**
121
+ * Stage the new skill beside the target, move the old folder aside, rename the
122
+ * staged one into place, then drop Orizu's old copy or back up a person's.
123
+ * On a failure or an interruption, staging is removed and the old folder is
124
+ * put back.
125
+ */
126
+ function replaceSkillFolder(input: ReplaceSkillFolderInput): Effect.Effect<ReplaceSkillFolderResult, Error> {
127
+ const { sourceRoot, targetPath, mode } = input
128
+ const id = `${process.pid}-${Date.now().toString(36)}`
129
+ const name = basename(targetPath)
130
+ const staging = join(dirname(targetPath), `${name}.orizu-staging-${id}`)
131
+ const aside = join(dirname(targetPath), `${name}.orizu-previous-${id}`)
132
+
133
+ // Set when the old folder could not be put back after a failure.
134
+ let strandedAt: string | null = null
135
+
136
+ // Only the copy into staging can be interrupted. Once the old folder moves
137
+ // aside, the swap and the clean-up after it always finish, so Ctrl-C can
138
+ // never skip putting the old folder back or moving a person's folder out.
139
+ return Effect.uninterruptibleMask(restore => Effect.gen(function*() {
140
+ const existing = yield* classify(targetPath, sourceRoot)
141
+ yield* attempt(() => mkdir(dirname(targetPath), { recursive: true }))
142
+
143
+ const swapped = yield* Effect.scoped(Effect.gen(function*() {
144
+ // Registered before staging starts, so a copy that stops part way is removed too.
145
+ // Gone already once it has been renamed into place.
146
+ yield* Effect.addFinalizer(() => removeEntry(staging).pipe(Effect.ignore))
147
+ yield* restore(mode === 'link'
148
+ ? attempt(() => symlink(sourceRoot, staging, 'dir'))
149
+ : copyTree(sourceRoot, sourceRoot, staging).pipe(
150
+ Effect.andThen(Effect.try({ try: () => input.writeMarker(staging), catch: cause => cause instanceof Error ? cause : new Error(String(cause)) }))))
151
+ if (existing !== 'missing') {
152
+ yield* Effect.acquireRelease(
153
+ renameEntry(targetPath, aside),
154
+ (_, exit) => Exit.isSuccess(exit)
155
+ ? Effect.void
156
+ // Put the old folder back unless something already holds its place.
157
+ : exists(targetPath).pipe(
158
+ Effect.flatMap(taken => taken ? Effect.fail(new Error(`${targetPath} is taken`)) : renameEntry(aside, targetPath)),
159
+ Effect.catch(() => Effect.sync(() => { strandedAt = aside })),
160
+ ),
161
+ )
162
+ }
163
+ yield* renameEntry(staging, targetPath)
164
+ return existing
165
+ })).pipe(Effect.mapError(error => strandedAt
166
+ ? new Error(`${error.message} Your earlier folder could not be put back at ${targetPath}; it is at ${strandedAt}.`)
167
+ : error))
168
+
169
+ if (swapped === 'missing') return { backup: null }
170
+ if (swapped !== 'person') {
171
+ yield* removeEntry(aside).pipe(Effect.mapError(cause => new Error(`Installed ${targetPath}, but could not remove the earlier copy at ${aside}: ${cause.message}`)))
172
+ return { backup: null }
173
+ }
174
+ // The person's folder is safe beside the target; move it out of agents' sight.
175
+ return yield* moveToBackup(aside, input.backupPath)
176
+ }))
177
+ }
178
+
179
+ /** One replacement whose renames go through `move`, so a test can make one fail. */
180
+ export async function replaceSkillFolderUsing(move: (from: string, to: string) => Promise<void>, input: ReplaceSkillFolderInput): Promise<ReplaceSkillFolderResult> {
181
+ return await Effect.runPromise(replaceSkillFolder(input).pipe(Effect.provideService(SkillFolderRename, move)))
182
+ }
183
+
184
+ const STOP_SIGNALS = ['SIGINT', 'SIGTERM'] as const
185
+
186
+ /** Ends the process by the signal that stopped it, once its handlers are gone. */
187
+ async function endBy(signal: NodeJS.Signals, handlers: ReadonlyArray<readonly [NodeJS.Signals, () => void]>): Promise<never> {
188
+ for (const [name, handler] of handlers) process.removeListener(name, handler)
189
+ process.kill(process.pid, signal)
190
+ // The signal ends the process; if another listener holds it, exit as a shell would.
191
+ setTimeout(() => process.exit(signal === 'SIGINT' ? 130 : 143), 1_000)
192
+ return await new Promise<never>(() => {})
193
+ }
194
+
195
+ /**
196
+ * Runs one replacement. Ctrl-C or SIGTERM interrupts it; once staging is
197
+ * removed and the old folder is back, the process ends by that same signal.
198
+ */
199
+ export async function runSkillFolderReplacement(input: ReplaceSkillFolderInput): Promise<ReplaceSkillFolderResult> {
200
+ const controller = new AbortController()
201
+ const stop: { by: NodeJS.Signals | null } = { by: null }
202
+ const handlers = STOP_SIGNALS.map(signal => [signal, () => { stop.by = signal; controller.abort() }] as const)
203
+ for (const [signal, handler] of handlers) process.once(signal, handler)
204
+ try {
205
+ const result = await Effect.runPromise(replaceSkillFolder(input), { signal: controller.signal })
206
+ // A stop that arrived as the replacement finished still stops the command.
207
+ if (stop.by) return await endBy(stop.by, handlers)
208
+ return result
209
+ } catch (error: unknown) {
210
+ if (!stop.by) throw error
211
+ return await endBy(stop.by, handlers)
212
+ } finally {
213
+ for (const [signal, handler] of handlers) process.removeListener(signal, handler)
214
+ }
215
+ }