docks-kit 0.15.1 → 0.15.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/AGENTS.md +57 -17
  2. package/README.md +33 -31
  3. package/cli/docs/flags.md +0 -1
  4. package/cli/docs/install.md +28 -13
  5. package/cli/docs/overview.md +2 -2
  6. package/cli/docs/platforms.md +5 -2
  7. package/cli/docs/sync-layers.md +3 -4
  8. package/cli/docs/toolchain.md +26 -34
  9. package/cli/src/commands/docs.ts +3 -3
  10. package/cli/src/commands/model.ts +3 -0
  11. package/cli/src/commands/models.ts +5 -3
  12. package/cli/src/commands/status.ts +145 -32
  13. package/cli/src/commands/sync.ts +4 -5
  14. package/cli/src/commands/toolchain.ts +4 -7
  15. package/cli/src/commands/update.ts +177 -32
  16. package/cli/src/efforts.ts +5 -5
  17. package/cli/src/engine-native/DESIGN.md +31 -22
  18. package/cli/src/engine-native/bun.ts +42 -14
  19. package/cli/src/engine-native/claudeRuntime.ts +17 -9
  20. package/cli/src/engine-native/claudeSettingsModifiers.ts +29 -11
  21. package/cli/src/engine-native/claudeSync.ts +91 -55
  22. package/cli/src/engine-native/codexSync.ts +173 -49
  23. package/cli/src/engine-native/codexToml.ts +12 -7
  24. package/cli/src/engine-native/deps.ts +36 -95
  25. package/cli/src/engine-native/exec.ts +42 -24
  26. package/cli/src/engine-native/index.ts +17 -6
  27. package/cli/src/engine-native/models.ts +2 -9
  28. package/cli/src/engine-native/modes.ts +54 -35
  29. package/cli/src/engine-native/os/darwin.ts +62 -0
  30. package/cli/src/engine-native/os/index.ts +42 -0
  31. package/cli/src/engine-native/os/linux.ts +62 -0
  32. package/cli/src/engine-native/os/targets.ts +73 -0
  33. package/cli/src/engine-native/os/types.ts +75 -0
  34. package/cli/src/engine-native/os/windows.ts +176 -0
  35. package/cli/src/engine-native/parseArgs.ts +147 -48
  36. package/cli/src/engine-native/services.ts +1 -11
  37. package/cli/src/engine-native/settings.ts +3 -2
  38. package/cli/src/engine-native/skillsSync.ts +141 -89
  39. package/cli/src/engine-native/toolchain.ts +5 -147
  40. package/cli/src/engine.ts +41 -11
  41. package/cli/src/generated/sotPayload.ts +7 -7
  42. package/cli/src/kitHome.ts +42 -5
  43. package/cli/src/main.ts +12 -2
  44. package/cli/src/manifests.ts +28 -11
  45. package/cli/src/payload.ts +2 -5
  46. package/docks-kit +4 -4
  47. package/docks-kit.ps1 +123 -0
  48. package/package.json +9 -5
  49. package/cli/src/engine-native/os.ts +0 -24
@@ -9,6 +9,7 @@ import { syncCodexEffort, syncCodexModel, replaceTopLevelSettingInFile } from ".
9
9
  import { p, spawnProcess } from "./exec"
10
10
  import type { Ctx } from "./index"
11
11
  import { compareCodepoints, isObject, jqStringify, parseJson, type Json } from "./jq"
12
+ import { hostOs } from "./os"
12
13
  import { payloadBytes, payloadDisplayPath, payloadPaths, payloadText, type PayloadPath } from "../payload"
13
14
 
14
15
  export async function codexSync(ctx: Ctx): Promise<void> {
@@ -70,7 +71,10 @@ async function ensureBubblewrap(ctx: Ctx): Promise<void> {
70
71
  return
71
72
  }
72
73
 
73
- if ((await spawnProcess("unshare", ["-Ur", "true"], { stdio: "ignore" })).exitCode === 0) {
74
+ const namespaceProbe = await spawnProcess("unshare", ["-Ur", "true"], { stdio: "ignore" })
75
+ if (namespaceProbe.error !== undefined) {
76
+ warn(`Could not run unshare to check user namespaces: ${namespaceProbe.error.message}`)
77
+ } else if (namespaceProbe.exitCode === 0) {
74
78
  change(`bubblewrap installed and functional (${await ctx.services.deps.version("bwrap")})`)
75
79
  } else {
76
80
  warn(
@@ -81,10 +85,11 @@ async function ensureBubblewrap(ctx: Ctx): Promise<void> {
81
85
 
82
86
  function bwrapSupportedOs(ctx: Ctx): boolean {
83
87
  const { warn } = ctx.services.logger
84
- const pn = ctx.services.platform.name()
85
- if (pn === "linux") return true
86
- if (pn === "darwin") return false
87
- warn("Unknown OS — skipping bubblewrap check; Codex sandbox may not work")
88
+ const os = hostOs(ctx.services.platform.name())
89
+ if (os.supportsBubblewrap) return true
90
+ if (os.id === "unknown") {
91
+ warn("Unknown OS — skipping bubblewrap check; Codex sandbox may not work")
92
+ }
88
93
  return false
89
94
  }
90
95
 
@@ -100,10 +105,14 @@ function bwrapDetectPmInstallCmd(ctx: Ctx): string {
100
105
 
101
106
  function syncConfig(ctx: Ctx, sotConfigText: string, userConfig: string): void {
102
107
  const { change, echo, verbose } = ctx.services.logger
103
- const sotConfig = payloadDisplayPath("SoT/.codex/config.toml", ctx.repoDir)
108
+ const sotConfig = payloadDisplayPath("SoT/.codex/config.toml")
104
109
 
105
110
  if (ctx.dryRun) {
106
- echo(`[dry-run] merge ${sotConfig} -> ${userConfig}`)
111
+ if (existsSync(userConfig)) {
112
+ echo(`[dry-run] merge ${sotConfig} -> ${userConfig}`)
113
+ } else {
114
+ echo(`[dry-run] install ${sotConfig} -> ${userConfig}`)
115
+ }
107
116
  return
108
117
  }
109
118
 
@@ -119,7 +128,8 @@ function syncConfig(ctx: Ctx, sotConfigText: string, userConfig: string): void {
119
128
  // overwrite the recovery copy with already-merged content.
120
129
  const before = readFileSync(userConfig, "utf8")
121
130
  const staging = `${userConfig}.merge.tmp`
122
- writeFileSync(staging, before)
131
+ // Normalize once before record transforms because CR bytes change table-header identity.
132
+ writeFileSync(staging, before.replace(/\r\n/g, "\n"))
123
133
 
124
134
  scrubDeprecatedFeatures(ctx, staging)
125
135
  removeRetiredPluginTables(ctx, staging)
@@ -146,15 +156,20 @@ export function scrubDeprecatedFeaturesText(content: string): string {
146
156
  let header = ""
147
157
  let body = ""
148
158
  let keep = false
159
+ let changed = false
149
160
  for (const line of lines) {
150
161
  if (inFeatures) {
151
162
  if (line.startsWith("[")) {
152
163
  inFeatures = false
153
164
  if (keep) out += `${header}\n${body}`
165
+ else changed = true
154
166
  out += `${line}\n`
155
167
  continue
156
168
  }
157
- if (/^use_legacy_landlock[ \t]*=/.test(line)) continue
169
+ if (/^use_legacy_landlock[ \t]*=/.test(line)) {
170
+ changed = true
171
+ continue
172
+ }
158
173
  body += `${line}\n`
159
174
  if (/[^ \t\f\v\r]/.test(line)) keep = true
160
175
  continue
@@ -168,24 +183,31 @@ export function scrubDeprecatedFeaturesText(content: string): string {
168
183
  }
169
184
  out += `${line}\n`
170
185
  }
171
- if (inFeatures && keep) out += `${header}\n${body}`
172
- return out
186
+ if (inFeatures) {
187
+ if (keep) out += `${header}\n${body}`
188
+ else changed = true
189
+ }
190
+ return changed ? out : content
173
191
  }
174
192
 
175
193
  function scrubDeprecatedFeatures(ctx: Ctx, userConfig: string): void {
176
194
  const { change } = ctx.services.logger
177
195
  if (!existsSync(userConfig)) return
178
196
  const content = readFileSync(userConfig, "utf8")
179
- if (!content.split("\n").some((l) => /^use_legacy_landlock[ \t]*=/.test(l))) return
197
+ const next = scrubDeprecatedFeaturesText(content)
198
+ if (next === content) return
180
199
 
181
- writeFileSync(`${userConfig}.tmp`, scrubDeprecatedFeaturesText(content))
200
+ writeFileSync(`${userConfig}.tmp`, next)
182
201
  renameSync(`${userConfig}.tmp`, userConfig)
183
202
  change("Codex: scrubbed deprecated [features].use_legacy_landlock")
184
203
  }
185
204
 
186
205
  const PLUGIN_TABLE_HEADER = /^\[plugins\."([^"]+)"\][ \t]*$/
187
206
  /** Plugin ids the kit retired; their deployed tables are stripped on every sync. */
188
- const RETIRED_PLUGIN_IDS: Readonly<Record<string, true>> = { "session-relay@docks": true }
207
+ const RETIRED_PLUGIN_IDS: Readonly<Record<string, true>> = {
208
+ "effect-kit@docks": true,
209
+ "session-relay@docks": true
210
+ }
189
211
 
190
212
  /** codex::remove_retired_plugin_tables — drop [plugins."<id>"] blocks for retired ids. */
191
213
  export function removeRetiredPluginTablesText(content: string): string {
@@ -236,38 +258,134 @@ function mergeTopLevelSettings(sotConfigText: string, userConfig: string): void
236
258
  }
237
259
  }
238
260
 
239
- function mergeTableSettings(sotConfigText: string, userConfig: string): void {
261
+ interface TomlTableHeader {
262
+ readonly path: string
263
+ }
264
+
265
+ const TOML_BASIC_ESCAPES: Readonly<Record<string, string>> = {
266
+ b: "\b",
267
+ t: "\t",
268
+ n: "\n",
269
+ f: "\f",
270
+ r: "\r",
271
+ '"': '"',
272
+ "\\": "\\"
273
+ }
274
+
275
+ function tomlBasicEscape(line: string, offset: number): { readonly next: number; readonly value: string } | undefined {
276
+ const escaped = line[offset]
277
+ const simple = escaped === undefined ? undefined : TOML_BASIC_ESCAPES[escaped]
278
+ if (simple !== undefined) return { next: offset + 1, value: simple }
279
+ const digits = escaped === "u" ? 4 : escaped === "U" ? 8 : 0
280
+ if (digits === 0) return undefined
281
+ const hex = line.slice(offset + 1, offset + 1 + digits)
282
+ if (hex.length !== digits || !/^[0-9A-Fa-f]+$/.test(hex)) return undefined
283
+ const codePoint = Number.parseInt(hex, 16)
284
+ if (codePoint > 0x10ffff || (codePoint >= 0xd800 && codePoint <= 0xdfff)) return undefined
285
+ return { next: offset + 1 + digits, value: String.fromCodePoint(codePoint) }
286
+ }
287
+
288
+ /** Decode a table header to the TOML path that determines managed ownership. */
289
+ function tomlTableHeader(line: string): TomlTableHeader | undefined {
290
+ let offset = 0
291
+ const skipWhitespace = (): void => {
292
+ while (line[offset] === " " || line[offset] === "\t") offset++
293
+ }
294
+
295
+ skipWhitespace()
296
+ if (line[offset] !== "[") return undefined
297
+ offset++
298
+ const array = line[offset] === "["
299
+ if (array) offset++
300
+
301
+ const keys: Array<string> = []
302
+ while (true) {
303
+ skipWhitespace()
304
+ const quote = line[offset]
305
+ let key = ""
306
+ if (quote === '"' || quote === "'") {
307
+ offset++
308
+ let closed = false
309
+ while (offset < line.length) {
310
+ const char = line[offset]!
311
+ if (char === quote) {
312
+ offset++
313
+ closed = true
314
+ break
315
+ }
316
+ if (quote === '"' && char === "\\") {
317
+ const escape = tomlBasicEscape(line, offset + 1)
318
+ if (escape === undefined) return undefined
319
+ key += escape.value
320
+ offset = escape.next
321
+ continue
322
+ }
323
+ if (char === "\n" || char === "\r") return undefined
324
+ key += char
325
+ offset++
326
+ }
327
+ if (!closed) return undefined
328
+ } else {
329
+ const start = offset
330
+ while (offset < line.length && /[A-Za-z0-9_-]/.test(line[offset]!)) offset++
331
+ if (offset === start) return undefined
332
+ key = line.slice(start, offset)
333
+ }
334
+ keys.push(key)
335
+
336
+ skipWhitespace()
337
+ if (line[offset] === ".") {
338
+ offset++
339
+ continue
340
+ }
341
+ if (line[offset] !== "]") return undefined
342
+ offset++
343
+ if (array) {
344
+ if (line[offset] !== "]") return undefined
345
+ offset++
346
+ }
347
+ skipWhitespace()
348
+ if (offset < line.length && line[offset] !== "#") return undefined
349
+ return { path: JSON.stringify(keys) }
350
+ }
351
+ }
352
+
353
+ function mergeTableSettingsText(sotConfigText: string, userConfigText: string): string {
240
354
  const sotLines = sotConfigText.split("\n")
241
- for (const tableHeader of sotLines.filter((l) => /^\[[^\]]+\]/.test(l))) {
242
- // Extract the SoT block: from the exact header line to (excluding) the
243
- // next table header; `$(...)` strips trailing newlines.
244
- let printing = false
355
+ let merged = userConfigText
356
+ for (let tableOffset = 0; tableOffset < sotLines.length; tableOffset++) {
357
+ const managedHeader = tomlTableHeader(sotLines[tableOffset]!)
358
+ if (managedHeader === undefined) continue
359
+
245
360
  const block: Array<string> = []
246
- for (const line of sotLines) {
247
- if (line === tableHeader) printing = true
248
- else if (printing && line.startsWith("[")) break
249
- if (printing) block.push(line)
361
+ for (let blockOffset = tableOffset; blockOffset < sotLines.length; blockOffset++) {
362
+ const line = sotLines[blockOffset]!
363
+ if (blockOffset !== tableOffset && tomlTableHeader(line) !== undefined) break
364
+ block.push(line)
250
365
  }
251
366
  const tableBlock = block.join("\n").replace(/\n+$/, "")
252
367
 
253
- // Remove the existing block from the user config…
254
- const userLines = readFileSync(userConfig, "utf8").split("\n")
368
+ const userLines = merged.split("\n")
255
369
  if (userLines[userLines.length - 1] === "") userLines.pop()
256
370
  let skip = false
257
371
  const kept: Array<string> = []
258
372
  for (const line of userLines) {
259
- if (line === tableHeader) {
260
- skip = true
261
- continue
373
+ const header = tomlTableHeader(line)
374
+ if (header !== undefined) {
375
+ skip = header.path === managedHeader.path
376
+ if (skip) continue
262
377
  }
263
- if (skip && line.startsWith("[")) skip = false
264
378
  if (!skip) kept.push(line)
265
379
  }
266
- // …and append the SoT block (printf '\n'; printf '%s\n' "$block").
267
- const next = `${kept.join("\n")}\n\n${tableBlock}\n`
268
- writeFileSync(`${userConfig}.tmp`, next)
269
- renameSync(`${userConfig}.tmp`, userConfig)
380
+ merged = `${kept.join("\n")}\n\n${tableBlock}\n`
270
381
  }
382
+ return merged
383
+ }
384
+
385
+ function mergeTableSettings(sotConfigText: string, userConfig: string): void {
386
+ const next = mergeTableSettingsText(sotConfigText, readFileSync(userConfig, "utf8"))
387
+ writeFileSync(`${userConfig}.tmp`, next)
388
+ renameSync(`${userConfig}.tmp`, userConfig)
271
389
  }
272
390
 
273
391
  // ------------------------------------------------------- rules + agents ----
@@ -276,7 +394,7 @@ function syncRules(ctx: Ctx, sotRules: ReadonlyArray<PayloadPath>, userRulesDir:
276
394
  const { change, echo, verbose } = ctx.services.logger
277
395
  const firstRule = sotRules[0]
278
396
  if (firstRule === undefined) return
279
- const firstDisplay = payloadDisplayPath(firstRule, ctx.repoDir)
397
+ const firstDisplay = payloadDisplayPath(firstRule)
280
398
  const sotRulesDir = firstDisplay.slice(0, firstDisplay.lastIndexOf("/"))
281
399
 
282
400
  if (ctx.dryRun) {
@@ -306,7 +424,7 @@ function syncRules(ctx: Ctx, sotRules: ReadonlyArray<PayloadPath>, userRulesDir:
306
424
 
307
425
  function syncAgentsMd(ctx: Ctx, sotAgentsMdText: string, userAgentsMd: string): void {
308
426
  const { change, echo, verbose } = ctx.services.logger
309
- const sotAgentsMd = payloadDisplayPath("SoT/.codex/AGENTS.md", ctx.repoDir)
427
+ const sotAgentsMd = payloadDisplayPath("SoT/.codex/AGENTS.md")
310
428
 
311
429
  if (ctx.dryRun) {
312
430
  echo(`[dry-run] cp ${sotAgentsMd} -> ${userAgentsMd}`)
@@ -326,26 +444,32 @@ function syncAgentsMd(ctx: Ctx, sotAgentsMdText: string, userAgentsMd: string):
326
444
  // ---------------------------------------------------------- marketplace ----
327
445
 
328
446
  function syncMarketplace(ctx: Ctx, sotMarketplaceText: string, userMarketplace: string): void {
329
- const { change, echo, err, verbose } = ctx.services.logger
330
- const sotMarketplace = payloadDisplayPath("SoT/.codex/plugins/marketplace.json", ctx.repoDir)
447
+ const { change, echo, verbose } = ctx.services.logger
448
+ const sotMarketplace = payloadDisplayPath("SoT/.codex/plugins/marketplace.json")
449
+ const repo = parseJson(sotMarketplaceText)
450
+ if (repo === undefined) throw new Error(`invalid SoT marketplace JSON: ${sotMarketplace}`)
451
+
452
+ const userText = existsSync(userMarketplace) ? readFileSync(userMarketplace, "utf8") : undefined
453
+ const user = userText === undefined ? undefined : parseJson(userText)
454
+ if (userText !== undefined && user === undefined) {
455
+ throw new Error(`invalid deployed Codex marketplace JSON: ${userMarketplace}. Fix or delete it.`)
456
+ }
457
+ const out = user === undefined ? undefined : jqStringify(mergeMarketplace(repo, user))
331
458
 
332
459
  if (ctx.dryRun) {
333
- echo(`[dry-run] cp ${sotMarketplace} -> ${userMarketplace}`)
460
+ if (userText === undefined) {
461
+ echo(`[dry-run] cp ${sotMarketplace} -> ${userMarketplace}`)
462
+ } else if (out === userText) {
463
+ verbose("Codex marketplace already in sync")
464
+ } else {
465
+ echo(`[dry-run] merge ${sotMarketplace} -> ${userMarketplace} (backup at ${userMarketplace}.bak)`)
466
+ }
334
467
  return
335
468
  }
336
469
 
337
470
  mkdirSync(p(ctx.agentsDir, "plugins"), { recursive: true })
338
- const repo = parseJson(sotMarketplaceText)
339
- if (repo === undefined) throw new Error(`invalid SoT marketplace JSON: ${sotMarketplace}`)
340
-
341
- if (existsSync(userMarketplace)) {
342
- const user = parseJson(readFileSync(userMarketplace, "utf8"))
343
- if (user === undefined) {
344
- err(`Skipping marketplace sync: ${userMarketplace} is not valid JSON. Fix or delete it.`)
345
- return
346
- }
347
- const out = jqStringify(mergeMarketplace(repo, user))
348
- if (out === readFileSync(userMarketplace, "utf8")) {
471
+ if (userText !== undefined && out !== undefined) {
472
+ if (out === userText) {
349
473
  verbose("Codex marketplace already in sync")
350
474
  return
351
475
  }
@@ -5,7 +5,7 @@
5
5
  * historical record-oriented behavior.
6
6
  */
7
7
  import { p } from "./exec"
8
- import { readFileSync, renameSync, writeFileSync } from "node:fs"
8
+ import { existsSync, readFileSync, renameSync, writeFileSync } from "node:fs"
9
9
 
10
10
  import { resolveEffort } from "../efforts"
11
11
  import type { Ctx } from "./index"
@@ -61,17 +61,22 @@ function syncCodexSetting(ctx: Ctx, edit: CodexSettingEdit): void {
61
61
  const { change, echo, verbose, warn } = ctx.services.logger
62
62
  const userCodexSettings = p(ctx.home, ".codex", "config.toml")
63
63
 
64
- if (ctx.dryRun) {
65
- echo(`[dry-run] (${edit.tag}) set ${edit.key} = "${edit.value}" in ${userCodexSettings}`)
64
+ if (!existsSync(userCodexSettings)) {
65
+ // A dry-run sync already previewed installing the file, so the edit is not skipped.
66
+ // The `docks-kit model codex <m>` command creates no file, so the skip stands.
67
+ if (ctx.dryRun && ctx.syncCodex) {
68
+ echo(`[dry-run] (${edit.tag}) set ${edit.key} = "${edit.value}" in ${userCodexSettings}`)
69
+ return
70
+ }
71
+ warn(`(${edit.tag}) ${userCodexSettings} missing — skipped`)
66
72
  return
67
73
  }
68
74
 
69
- try {
70
- readFileSync(userCodexSettings)
71
- } catch {
72
- warn(`(${edit.tag}) ${userCodexSettings} missing — skipped`)
75
+ if (ctx.dryRun) {
76
+ echo(`[dry-run] (${edit.tag}) set ${edit.key} = "${edit.value}" in ${userCodexSettings}`)
73
77
  return
74
78
  }
79
+
75
80
  if (replaceTopLevelSettingInFile(userCodexSettings, edit.key, `${edit.key} = "${edit.value}"`)) {
76
81
  change(edit.changed)
77
82
  ctx.nextStepTriggers.codexRestart = true
@@ -2,17 +2,17 @@
2
2
  * DependencyManager — one home for external-tool identity, presence probing,
3
3
  * and platform-correct install hints (Output Policy in DESIGN.md).
4
4
  *
5
- * Ownership split: SoT/toolchain.json + toolchain.ts keep version floors,
6
- * pin policy, and managed install/upgrade orchestration; this registry owns
7
- * WHICH external tools exist, whether they are required, and the one-line
8
- * command that installs a missing one.
5
+ * Ownership split: SoT/toolchain.json + toolchain.ts keep version floors, pin
6
+ * policy, and the doctor report, while bun.ts bunBootstrap owns the one managed
7
+ * install; this registry owns WHICH external tools exist, whether they are
8
+ * required, and the one-line command that installs a missing one.
9
9
  */
10
10
  import { homedir } from "node:os"
11
11
  import { isAbsolute } from "node:path"
12
12
 
13
13
  import { capture, commandExists, p, which } from "./exec"
14
14
  import { isObject, parseJson } from "./jq"
15
- import { rawPlatform } from "./os"
15
+ import { hostOs, platformName, rawPlatform } from "./os"
16
16
 
17
17
  export type ToolId =
18
18
  | "git"
@@ -25,7 +25,6 @@ export type ToolId =
25
25
  | "codex"
26
26
  | "bun"
27
27
  | "bwrap"
28
- | "effect-solutions"
29
28
  | "ffplay"
30
29
  | "intelephense"
31
30
  | "typescript-language-server"
@@ -42,11 +41,6 @@ export type ProbeResult =
42
41
  | { readonly state: "present"; readonly path?: string }
43
42
  | { readonly state: "missing" }
44
43
 
45
- export interface DependencyLocation {
46
- readonly path: string
47
- readonly binDir: string
48
- }
49
-
50
44
  export interface ProbeExecutor {
51
45
  readonly commandExists: (name: string) => boolean
52
46
  readonly capture: (cmd: string, args: ReadonlyArray<string>) => Promise<string>
@@ -61,16 +55,12 @@ export interface DependencySpec {
61
55
  readonly installHint: (platform?: NodeJS.Platform) => string
62
56
  readonly resolve?: (exec: ProbeExecutor, platform: NodeJS.Platform) => ProbeResult
63
57
  readonly version?: (exec: ProbeExecutor) => Promise<string>
64
- readonly locate?: (exec: ProbeExecutor, platform: NodeJS.Platform) => Promise<DependencyLocation>
65
- readonly latest?: (exec: ProbeExecutor) => Promise<string>
66
58
  }
67
59
 
68
60
  interface SpecOptions {
69
61
  readonly versionArgs?: ReadonlyArray<string>
70
62
  readonly resolve?: (exec: ProbeExecutor, platform: NodeJS.Platform) => ProbeResult
71
63
  readonly version?: (exec: ProbeExecutor) => Promise<string>
72
- readonly locate?: (exec: ProbeExecutor, platform: NodeJS.Platform) => Promise<DependencyLocation>
73
- readonly latest?: (exec: ProbeExecutor) => Promise<string>
74
64
  }
75
65
 
76
66
  const spec = (
@@ -84,16 +74,17 @@ const spec = (
84
74
  versionArgs: options.versionArgs ?? ["--version"],
85
75
  installHint,
86
76
  resolve: options.resolve,
87
- version: options.version,
88
- locate: options.locate,
89
- latest: options.latest
77
+ version: options.version
90
78
  })
91
79
 
80
+ // Presence is the injected executor's verdict; `which` only resolves WHERE the
81
+ // tool is, which on Windows is the `.cmd`/`.exe` candidate rather than the name.
92
82
  const pathProbe = (id: string): ((exec: ProbeExecutor) => ProbeResult) =>
93
- (exec) =>
94
- exec.commandExists(id)
95
- ? { state: "present", path: exec.which(id) }
96
- : { state: "missing" }
83
+ (exec) => {
84
+ if (!exec.commandExists(id)) return { state: "missing" }
85
+ const path = exec.which(id)
86
+ return path === "" ? { state: "present" } : { state: "present", path }
87
+ }
97
88
 
98
89
  const versionProbe = (
99
90
  id: string,
@@ -107,11 +98,11 @@ const home = (): string => {
107
98
  return envHome !== undefined && envHome !== "" ? envHome : homedir()
108
99
  }
109
100
 
110
-
111
- // The resolved path gets persisted into global direct-exec hooks, so a
112
- // relative `which` hit (relative PATH entry, relative BUN_INSTALL) would
113
- // break outside the sync working directory.
114
-
101
+ /**
102
+ * The resolved path gets persisted into global direct-exec hooks, so a
103
+ * relative `which` hit (relative PATH entry, relative BUN_INSTALL) would
104
+ * break outside the sync working directory.
105
+ */
115
106
  const findBun = (exec: ProbeExecutor): { command: string; path: string } | undefined => {
116
107
  const onPath = exec.which("bun")
117
108
  if (onPath !== "" && isAbsolute(onPath)) {
@@ -135,33 +126,6 @@ const resolveBun = (exec: ProbeExecutor): ProbeResult => {
135
126
  : { state: "present", path: bun.path }
136
127
  }
137
128
 
138
- const resolveEffectSolutions = (exec: ProbeExecutor): ProbeResult => {
139
- return exec.commandExists("effect-solutions")
140
- ? { state: "present", path: exec.which("effect-solutions") }
141
- : { state: "missing" }
142
- }
143
-
144
- const versionBunCommand = (exec: ProbeExecutor): string =>
145
- exec.commandExists("bun") ? "bun" : p(home(), ".bun", "bin", "bun")
146
-
147
- const versionEffectSolutions = async (exec: ProbeExecutor): Promise<string> => {
148
- const bun = versionBunCommand(exec)
149
- if (bun !== "bun" && exec.which(bun) === "") return ""
150
- const match = /effect-solutions@([0-9][0-9.]*)/.exec(await exec.capture(bun, ["pm", "-g", "ls"]))
151
- return match?.[1] ?? ""
152
- }
153
-
154
- const locateEffectSolutions = async (exec: ProbeExecutor): Promise<DependencyLocation> => {
155
- const strictBun = findBun(exec)
156
- const pathBun = exec.which("bun")
157
- const bun = strictBun ?? (pathBun !== "" ? { command: "bun", path: pathBun } : undefined)
158
- if (bun === undefined) return { path: "", binDir: "" }
159
- const globalBin = await exec.capture(bun.command, ["pm", "-g", "bin"])
160
- const path = globalBin !== "" ? p(globalBin, "effect-solutions") : ""
161
- const resolved = path !== "" && exec.which(path) !== "" ? path : ""
162
- return { path: resolved, binDir: globalBin }
163
- }
164
-
165
129
  const npmGlobalCache = new WeakMap<ProbeExecutor, Promise<{ [k: string]: string }>>()
166
130
 
167
131
  const npmGlobalVersions = (exec: ProbeExecutor): Promise<{ [k: string]: string }> => {
@@ -185,31 +149,21 @@ const npmGlobalVersions = (exec: ProbeExecutor): Promise<{ [k: string]: string }
185
149
  const versionNpmGlobal = (pkg: string) => async (exec: ProbeExecutor): Promise<string> =>
186
150
  (await npmGlobalVersions(exec))[pkg] ?? ""
187
151
 
188
- const latestNpm = (id: "effect-solutions") => async (exec: ProbeExecutor): Promise<string> =>
189
- exec.commandExists("npm") ? await exec.capture("npm", ["view", id, "version"]) : ""
190
-
191
152
  export const defaultProbeExecutor: ProbeExecutor = { commandExists, capture, which }
192
153
 
193
154
  export const DEPENDENCIES: Record<ToolId, DependencySpec> = {
194
155
  git: spec(
195
156
  "git",
196
157
  "optional",
197
- (pf = rawPlatform()) =>
198
- pf === "darwin"
199
- ? "brew install git"
200
- : "sudo apt install -y git (or your distro's package manager)",
158
+ (pf = rawPlatform()) => hostOs(platformName(pf)).installHint("git"),
201
159
  { version: versionProbe("git") }
202
160
  ),
203
- jq: spec("jq", "optional", (pf = rawPlatform()) =>
204
- pf === "darwin"
205
- ? "brew install jq"
206
- : "sudo apt install -y jq",
207
- { version: versionProbe("jq") }
208
- ),
209
- curl: spec("curl", "optional", (pf = rawPlatform()) =>
210
- pf === "darwin" ? "brew install curl" : "sudo apt install -y curl",
211
- { version: versionProbe("curl") }
212
- ),
161
+ jq: spec("jq", "optional", (pf = rawPlatform()) => hostOs(platformName(pf)).installHint("jq"), {
162
+ version: versionProbe("jq")
163
+ }),
164
+ curl: spec("curl", "optional", (pf = rawPlatform()) => hostOs(platformName(pf)).installHint("curl"), {
165
+ version: versionProbe("curl")
166
+ }),
213
167
  node: spec("node", "optional", () => "install Node.js via https://nodejs.org (or your package manager)", {
214
168
  version: versionProbe("node")
215
169
  }),
@@ -218,13 +172,13 @@ export const DEPENDENCIES: Record<ToolId, DependencySpec> = {
218
172
  claude: spec(
219
173
  "claude",
220
174
  "optional",
221
- () => "curl -fsSL https://claude.ai/install.sh -o /tmp/claude-install.sh && bash /tmp/claude-install.sh",
175
+ (pf = rawPlatform()) => hostOs(platformName(pf)).installHint("claude"),
222
176
  { version: versionProbe("claude") }
223
177
  ),
224
178
  codex: spec(
225
179
  "codex",
226
180
  "optional",
227
- () => 'tmp=$(mktemp) && curl -fsSL https://chatgpt.com/codex/install.sh -o "$tmp" && CODEX_NON_INTERACTIVE=1 sh "$tmp"',
181
+ (pf = rawPlatform()) => hostOs(platformName(pf)).installHint("codex"),
228
182
  { version: versionProbe("codex") }
229
183
  ),
230
184
  bun: spec(
@@ -233,24 +187,20 @@ export const DEPENDENCIES: Record<ToolId, DependencySpec> = {
233
187
  () => "curl -fsSL https://bun.sh/install | bash",
234
188
  {
235
189
  resolve: resolveBun,
236
- version: (exec) => exec.capture(versionBunCommand(exec), ["--version"]),
237
- locate: async (exec) => ({ path: findBun(exec)?.path ?? "", binDir: "" })
190
+ version: async (exec) => {
191
+ const bun = findBun(exec)
192
+ if (bun === undefined) return ""
193
+ return await exec.capture(bun.command, ["--version"])
194
+ }
238
195
  }
239
196
  ),
240
197
  bwrap: spec("bwrap", "optional", () => "sudo apt install -y bubblewrap (or dnf/pacman/zypper equivalent)", {
241
198
  version: versionProbe("bwrap")
242
199
  }),
243
- "effect-solutions": spec("effect-solutions", "optional", () => "bun add -g effect-solutions", {
244
- resolve: resolveEffectSolutions,
245
- version: versionEffectSolutions,
246
- locate: locateEffectSolutions,
247
- latest: latestNpm("effect-solutions")
248
- }),
249
200
  ffplay: spec(
250
201
  "ffplay",
251
202
  "optional",
252
- (pf = rawPlatform()) =>
253
- pf === "darwin" ? "brew install ffmpeg" : "sudo apt install -y ffmpeg",
203
+ (pf = rawPlatform()) => hostOs(platformName(pf)).installHint("ffplay"),
254
204
  { versionArgs: ["-version"], version: versionProbe("ffplay", ["-version"]), resolve: pathProbe("ffplay") }
255
205
  ),
256
206
  intelephense: spec("intelephense", "optional", () => "npm install -g intelephense", {
@@ -286,20 +236,11 @@ export async function resolveVersion(specification: DependencySpec, exec: ProbeE
286
236
  return await (specification.version ?? versionProbe(specification.id, specification.versionArgs))(exec)
287
237
  }
288
238
 
289
- export async function resolveLocation(
290
- specification: DependencySpec,
291
- exec: ProbeExecutor,
292
- platform: NodeJS.Platform = rawPlatform()
293
- ): Promise<DependencyLocation> {
294
- if (specification.locate !== undefined) return await specification.locate(exec, platform)
295
- const result = resolveDependency(specification, exec, platform)
296
- return { path: result.state === "present" ? (result.path ?? exec.which(specification.id)) : "", binDir: "" }
297
- }
298
-
299
239
  export async function resolvePath(
300
240
  specification: DependencySpec,
301
241
  exec: ProbeExecutor,
302
- platform?: NodeJS.Platform
242
+ platform: NodeJS.Platform = rawPlatform()
303
243
  ): Promise<string> {
304
- return (await resolveLocation(specification, exec, platform)).path
244
+ const result = resolveDependency(specification, exec, platform)
245
+ return result.state === "present" ? (result.path ?? exec.which(specification.id)) : ""
305
246
  }