docks-kit 0.14.2 → 0.14.4

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.
@@ -1,35 +1,198 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks"
2
+
1
3
  /**
2
- * Leveled stderr logger + stdout data writer the Output Policy contract in
3
- * DESIGN.md. Filtering is explicit and synchronous: engine code is imperative,
4
- * so fiber-scoped Effect log levels cannot see these writes. The prefixes and
5
- * ANSI codes are stable golden surface; the level controls visibility only.
4
+ * Leveled stderr logger, one run-scoped terminal lease, and stdout data
5
+ * writer — the Output Policy contract in DESIGN.md. Filtering is explicit and
6
+ * synchronous: engine code is imperative, so fiber-scoped Effect log levels
7
+ * cannot see these writes. Transient progress is active only through an
8
+ * injected progress sink or an interactive stderr. The prefixes and ANSI codes
9
+ * are stable golden surface; the level controls visibility only.
6
10
  */
11
+ export interface TerminalLease {
12
+ /** Replace the coordinator-owned transient line. */
13
+ readonly update: (message: string) => void
14
+ /** Serialize terminal input owners and suspend transient redraw while held. */
15
+ readonly withExclusive: <T>(action: () => T | Promise<T>) => Promise<T>
16
+ /** Clear coordinator progress and return ownership to ordinary progress calls. */
17
+ readonly release: () => void
18
+ }
19
+
7
20
 
8
21
  export interface Logger {
9
22
  /** `[ok]` green — an operation actually mutated something. Always visible. */
10
23
  readonly change: (msg: string) => void
24
+ /** Dim, transient single-line status for blocking work. */
25
+ readonly progress: (msg: string) => void
26
+ /** Erase a pending transient status line. */
27
+ readonly clearProgress: () => void
11
28
  /** `[ok]` green — status-quo confirmation; visible only with verbosity on. */
12
29
  readonly verbose: (msg: string) => void
13
30
  readonly warn: (msg: string) => void
14
31
  readonly err: (msg: string) => void
15
32
  /** stdout data line (dry-run report, summary, usage) — never filtered. */
16
33
  readonly echo: (line: string) => void
34
+ /** Acquire the run-scoped coordinator lease for terminal progress and input. */
35
+ readonly acquireTerminal: (message: string) => TerminalLease
17
36
  }
18
37
 
19
38
  export interface LoggerSinks {
20
39
  readonly stderr?: (chunk: string) => void
40
+ readonly progress?: (chunk: string) => void
21
41
  readonly stdout?: (chunk: string) => void
22
42
  }
23
43
 
44
+ interface WritableStreamLike {
45
+ write: (chunk: string) => unknown
46
+ on?: (event: "error", listener: (error: unknown) => void) => unknown
47
+ }
48
+
49
+ const epipeGuarded = new WeakSet<WritableStreamLike>()
50
+
51
+ /**
52
+ * A downstream reader may close the pipe early (`docks-kit toolchain check |
53
+ * head`). That is a normal end of consumption, not a CLI failure, so both the
54
+ * synchronous throw and the asynchronous error event are ignored for EPIPE.
55
+ */
56
+ export function writeIgnoringEpipe(stream: WritableStreamLike, chunk: string): void {
57
+ if (!epipeGuarded.has(stream)) {
58
+ epipeGuarded.add(stream)
59
+ stream.on?.("error", (error) => {
60
+ if ((error as NodeJS.ErrnoException | null)?.code !== "EPIPE") throw error
61
+ })
62
+ }
63
+ try {
64
+ stream.write(chunk)
65
+ } catch (error) {
66
+ if ((error as NodeJS.ErrnoException | null)?.code !== "EPIPE") throw error
67
+ }
68
+ }
69
+
24
70
  export function makeLogger(sinks: LoggerSinks): Logger {
25
- const errWrite = sinks.stderr ?? ((chunk: string) => void process.stderr.write(chunk))
26
- const outWrite = sinks.stdout ?? ((chunk: string) => void process.stdout.write(chunk))
27
- const ok = (msg: string): void => errWrite(`\x1b[1;32m[ok]\x1b[0m ${msg}\n`)
71
+ const errWrite = sinks.stderr ?? ((chunk: string) => writeIgnoringEpipe(process.stderr, chunk))
72
+ const outWrite = sinks.stdout ?? ((chunk: string) => writeIgnoringEpipe(process.stdout, chunk))
73
+ const progressWrite =
74
+ sinks.progress ??
75
+ (sinks.stderr === undefined && process.stderr.isTTY === true
76
+ ? (chunk: string) => writeIgnoringEpipe(process.stderr, chunk)
77
+ : undefined)
78
+ let progressPending = false
79
+ let activeLease: TerminalLease | undefined
80
+ let exclusiveActive = false
81
+ const exclusiveContext = new AsyncLocalStorage<{ active: boolean }>()
82
+ const durableBuffer: Array<() => void> = []
83
+
84
+
85
+ const eraseProgress = (): void => {
86
+ if (!progressPending || progressWrite === undefined) return
87
+ progressWrite("\r\x1b[2K")
88
+ progressPending = false
89
+ }
90
+ const drawProgress = (message: string): void => {
91
+ if (progressWrite === undefined) return
92
+ progressWrite(`\r\x1b[2K\x1b[2m${message}\x1b[0m`)
93
+ progressPending = true
94
+ }
95
+ const clearProgress = (): void => {
96
+ if (activeLease !== undefined) return
97
+ eraseProgress()
98
+ }
99
+ const writeDurable = (write: () => void): void => {
100
+ if (exclusiveActive) {
101
+ durableBuffer.push(write)
102
+ return
103
+ }
104
+ eraseProgress()
105
+ write()
106
+ }
107
+ const flushDurable = (): void => {
108
+ let firstError: unknown
109
+ while (durableBuffer.length > 0) {
110
+ for (const write of durableBuffer.splice(0)) {
111
+ try {
112
+ write()
113
+ } catch (error) {
114
+ firstError ??= error
115
+ }
116
+ }
117
+ }
118
+ if (firstError !== undefined) throw firstError
119
+ }
120
+ const ok = (msg: string): void => {
121
+ writeDurable(() => errWrite(`\x1b[1;32m[ok]\x1b[0m ${msg}\n`))
122
+ }
123
+
124
+ const acquireTerminal = (message: string): TerminalLease => {
125
+ if (activeLease !== undefined) throw new Error("terminal lease already acquired")
126
+
127
+ let released = false
128
+ let coordinatorMessage = message
129
+ let exclusiveTail = Promise.resolve()
130
+ let exclusivePending = 0
131
+ const lease: TerminalLease = {
132
+ update: (nextMessage) => {
133
+ if (released) return
134
+ coordinatorMessage = nextMessage
135
+ if (exclusivePending === 0) drawProgress(coordinatorMessage)
136
+ },
137
+ withExclusive: async <T>(action: () => T | Promise<T>): Promise<T> => {
138
+ if (exclusiveContext.getStore()?.active === true) {
139
+ throw new Error("terminal lease withExclusive cannot be re-entered")
140
+ }
141
+ if (released) return await action()
142
+ exclusivePending += 1
143
+ const previous = exclusiveTail
144
+ let unlock!: () => void
145
+ exclusiveTail = new Promise<void>((resolve) => {
146
+ unlock = resolve
147
+ })
148
+ await previous
149
+ eraseProgress()
150
+ exclusiveActive = true
151
+ const owner = { active: true }
152
+ try {
153
+ return await exclusiveContext.run(owner, action)
154
+ } finally {
155
+ owner.active = false
156
+ try {
157
+ flushDurable()
158
+ } finally {
159
+ exclusiveActive = false
160
+ exclusivePending -= 1
161
+ unlock()
162
+ if (!released && exclusivePending === 0) drawProgress(coordinatorMessage)
163
+ }
164
+ }
165
+ },
166
+ release: () => {
167
+ if (released) return
168
+ released = true
169
+ if (activeLease === lease) activeLease = undefined
170
+ eraseProgress()
171
+ }
172
+ }
173
+ activeLease = lease
174
+ drawProgress(coordinatorMessage)
175
+ return lease
176
+ }
177
+
28
178
  return {
29
179
  change: ok,
30
180
  verbose: ok,
31
- warn: (msg) => errWrite(`\x1b[1;33m[warn]\x1b[0m ${msg}\n`),
32
- err: (msg) => errWrite(`\x1b[1;31m[err]\x1b[0m ${msg}\n`),
33
- echo: (line) => outWrite(`${line}\n`)
181
+ progress: (msg) => {
182
+ if (activeLease !== undefined) return
183
+ drawProgress(msg)
184
+ },
185
+ clearProgress,
186
+ warn: (msg) => {
187
+ writeDurable(() => errWrite(`\x1b[1;33m[warn]\x1b[0m ${msg}\n`))
188
+ },
189
+ err: (msg) => {
190
+ eraseProgress()
191
+ errWrite(`\x1b[1;31m[err]\x1b[0m ${msg}\n`)
192
+ },
193
+ echo: (line) => {
194
+ writeDurable(() => outWrite(`${line}\n`))
195
+ },
196
+ acquireTerminal
34
197
  }
35
198
  }
@@ -11,9 +11,8 @@ import { syncCodexModel } from "./codexToml"
11
11
  import type { Ctx } from "./index"
12
12
  import { isObject, parseJson, type Json } from "./jq"
13
13
  import { printModels, validateClaudeModel, validateCodexModel } from "./models"
14
- import { ensureRtk } from "./claudeSync"
15
14
  import { bunBootstrap } from "./bun"
16
- import { agentBrowserInstall, effectSolutionsInstall } from "./skillsSync"
15
+ import { effectSolutionsInstall } from "./skillsSync"
17
16
  import { ensure, report } from "./toolchain"
18
17
 
19
18
  export function modeModel(ctx: Ctx, args: ReadonlyArray<string>): number {
@@ -109,7 +108,7 @@ function tomlModelText(text: string): string {
109
108
  return ""
110
109
  }
111
110
 
112
- export function modeToolchain(ctx: Ctx, args: ReadonlyArray<string>): number {
111
+ export async function modeToolchain(ctx: Ctx, args: ReadonlyArray<string>): Promise<number> {
113
112
  const { err } = ctx.services.logger
114
113
  const words = args.filter((a) => !a.startsWith("--"))
115
114
  const op = words[0] ?? args[0] ?? "check"
@@ -122,7 +121,7 @@ export function modeToolchain(ctx: Ctx, args: ReadonlyArray<string>): number {
122
121
  }
123
122
 
124
123
  if (op === "check") {
125
- report(ctx)
124
+ await report(ctx)
126
125
  return 0
127
126
  }
128
127
  if (op !== "ensure") {
@@ -134,16 +133,12 @@ export function modeToolchain(ctx: Ctx, args: ReadonlyArray<string>): number {
134
133
  return 2
135
134
  }
136
135
  switch (tool) {
137
- case "rtk":
138
- return ensureRtk(ctx, "cannot download RTK installer; toolchain ensure rtk aborted", 1)
139
136
  case "bun":
140
- return bunBootstrap(ctx, ctx.services).kind === "ready" ? 0 : 1
137
+ return (await bunBootstrap(ctx, ctx.services)).kind === "ready" ? 0 : 1
141
138
  case "effect-solutions":
142
- return ensure(ctx, "effect-solutions", effectSolutionsInstall(ctx))
143
- case "agent-browser":
144
- return ensure(ctx, "agent-browser", agentBrowserInstall)
139
+ return await ensure(ctx, "effect-solutions", effectSolutionsInstall(ctx))
145
140
  default:
146
- err("toolchain ensure supports managed tools only (rtk, bun, effect-solutions, agent-browser)")
141
+ err("toolchain ensure supports managed tools only (bun, effect-solutions)")
147
142
  return 2
148
143
  }
149
144
  }
@@ -50,7 +50,7 @@ function usage(ctx: Ctx): void {
50
50
  echo(
51
51
  " --prune uninstall kit-managed installs not in SoT (plugins, marketplaces, skills in SoT/.agents/skills.txt)"
52
52
  )
53
- echo(" --skip-rtk skip optional tool bootstrap (RTK, bubblewrap)")
53
+ echo(" --skip-bubblewrap skip optional bubblewrap bootstrap (Codex Linux sandbox)")
54
54
  echo(" --skip-plugin-refresh install missing plugins but skip refresh-only updates")
55
55
  echo(" --yes auto-accept toolchain prompts (containers/CI)")
56
56
  echo(" --verbose also print no-op confirmations (already in sync, up to date, left as-is)")
@@ -147,8 +147,8 @@ export function parseArgs(ctx: Ctx, args: ReadonlyArray<string>): void {
147
147
  case "--dry-run":
148
148
  ctx.dryRun = true
149
149
  continue
150
- case "--skip-rtk":
151
- ctx.skipRtk = true
150
+ case "--skip-bubblewrap":
151
+ ctx.skipBubblewrap = true
152
152
  continue
153
153
  case "--skip-plugin-refresh":
154
154
  ctx.skipPluginRefresh = true
@@ -217,8 +217,8 @@ export function parseArgs(ctx: Ctx, args: ReadonlyArray<string>): void {
217
217
  case "--n8n":
218
218
  err("--n8n was renamed to --claude-plugin=n8n")
219
219
  throw new ExitError(2)
220
- case "--no-rtk":
221
- err("--no-rtk was renamed to --skip-rtk")
220
+ case "--skip-rtk":
221
+ err("--skip-rtk was renamed to --skip-bubblewrap")
222
222
  throw new ExitError(2)
223
223
  case "-h":
224
224
  case "--help":
@@ -26,10 +26,10 @@ export type { Logger } from "./logger"
26
26
  export interface DependencyManager {
27
27
  readonly spec: (id: ToolId) => DependencySpec
28
28
  readonly probe: (id: ToolId) => ProbeResult
29
- readonly version: (id: ToolId) => string
30
- readonly path: (id: ToolId) => string
31
- readonly location: (id: ToolId) => DependencyLocation
32
- readonly latest: (id: ToolId) => string
29
+ readonly version: (id: ToolId) => Promise<string>
30
+ readonly path: (id: ToolId) => Promise<string>
31
+ readonly location: (id: ToolId) => Promise<DependencyLocation>
32
+ readonly latest: (id: ToolId) => Promise<string>
33
33
  readonly warnMissing: (id: ToolId, logger: Logger, context?: string) => void
34
34
  }
35
35
 
@@ -73,7 +73,7 @@ export const makeDependencyManager = (
73
73
  version: (id) => resolveVersion(DEPENDENCIES[id], exec),
74
74
  path: (id) => resolvePath(DEPENDENCIES[id], exec, platform.raw()),
75
75
  location: (id) => resolveLocation(DEPENDENCIES[id], exec, platform.raw()),
76
- latest: (id) => DEPENDENCIES[id].latest?.(exec) ?? "",
76
+ latest: (id) => DEPENDENCIES[id].latest?.(exec) ?? Promise.resolve(""),
77
77
  warnMissing: (id, logger, context) => {
78
78
  if (warned.has(id)) return
79
79
  warned.add(id)
@@ -1,12 +1,11 @@
1
1
  /**
2
2
  * EngineNative `sync agents` pipeline: universal-skill bootstrap
3
3
  * (`npx skills@<pin> add`), Claude symlink healing, --prune reconcile against
4
- * the kit-managed snapshot, agent-browser/effect-solutions toolchain callbacks,
5
- * and the snapshot write.
4
+ * the kit-managed snapshot, the effect-solutions toolchain callback, and the
5
+ * snapshot write.
6
6
  */
7
- import { spawnSync } from "node:child_process"
8
7
  import { cpSync, existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, rmSync, statSync, symlinkSync } from "node:fs"
9
- import { p, writeFileIfChanged } from "./exec"
8
+ import { p, spawnProcess, writeFileIfChanged } from "./exec"
10
9
  import { bunBootstrap } from "./bun"
11
10
  import type { Ctx } from "./index"
12
11
  import { compareCodepoints } from "./jq"
@@ -18,7 +17,7 @@ export interface SkillsState {
18
17
  present: number
19
18
  }
20
19
 
21
- export function skillsSync(ctx: Ctx): SkillsState {
20
+ export async function skillsSync(ctx: Ctx): Promise<SkillsState> {
22
21
  const state: SkillsState = { present: 0 }
23
22
  const skillsDir = p(ctx.agentsDir, "skills")
24
23
  const manifest = payloadText("SoT/.agents/skills.txt")
@@ -26,10 +25,9 @@ export function skillsSync(ctx: Ctx): SkillsState {
26
25
 
27
26
  if (!ctx.dryRun) mkdirSync(skillsDir, { recursive: true })
28
27
 
29
- syncUniversal(ctx, state, skillsDir, manifest)
30
- if (ctx.prune) reconcileRemovals(ctx, manifest, snapshot)
31
- syncAgentBrowserCli(ctx, manifest)
32
- syncEffectSolutionsCli(ctx)
28
+ await syncUniversal(ctx, state, skillsDir, manifest)
29
+ if (ctx.prune) await reconcileRemovals(ctx, manifest, snapshot)
30
+ await syncEffectSolutionsCli(ctx)
33
31
  updateSnapshot(ctx, manifest, snapshot)
34
32
  return state
35
33
  }
@@ -56,8 +54,8 @@ function readSlugs(file: string): Array<string> {
56
54
  return existsSync(file) ? normalizeManifest(readFileSync(file, "utf8")) : []
57
55
  }
58
56
 
59
- function syncUniversal(ctx: Ctx, state: SkillsState, skillsDir: string, manifest: string): void {
60
- const { change, echo, verbose, warn } = ctx.services.logger
57
+ async function syncUniversal(ctx: Ctx, state: SkillsState, skillsDir: string, manifest: string): Promise<void> {
58
+ const { change, clearProgress, echo, progress, verbose, warn } = ctx.services.logger
61
59
  if (ctx.services.deps.probe("npx").state === "missing") {
62
60
  ctx.services.deps.warnMissing("npx", ctx.services.logger, "skipping universal skills bootstrap")
63
61
  return
@@ -87,10 +85,12 @@ function syncUniversal(ctx: Ctx, state: SkillsState, skillsDir: string, manifest
87
85
  continue
88
86
  }
89
87
 
90
- const res = spawnSync("npx", ["--yes", skillsCli(ctx), "add", slug, "-g", "-y", "-a", "claude-code", "codex"], {
88
+ progress(`Installing universal skill ${slug}...`)
89
+ const res = await spawnProcess("npx", ["--yes", skillsCli(ctx), "add", slug, "-g", "-y", "-a", "claude-code", "codex"], {
91
90
  stdio: "ignore"
92
91
  })
93
- if (res.error === undefined && res.status === 0) {
92
+ clearProgress()
93
+ if (res.error === undefined && res.exitCode === 0) {
94
94
  added++
95
95
  } else {
96
96
  warn(`Failed to install universal skill: ${slug}`)
@@ -217,69 +217,29 @@ function linkOrCopyWithWarnings(target: string, link: string, services: EngineSe
217
217
 
218
218
  // ------------------------------------------------- toolchain callbacks ----
219
219
 
220
- /** skills::_agent_browser_install. */
221
- export function agentBrowserInstall(mode: "install" | "upgrade", version: string, services: EngineServices): number {
222
- const { change, verbose, warn } = services.logger
223
- const verb = mode === "upgrade" ? "Upgrading" : "Installing"
224
- const pkg = version !== "" ? `agent-browser@${version}` : "agent-browser"
225
- const installFlags = services.platform.isLinux() ? ["--with-deps"] : []
226
-
227
- verbose(`${verb} agent-browser CLI via npm${version !== "" ? ` (pinned ${version})` : ""}...`)
228
- if (spawnSync("npm", ["install", "-g", pkg], { stdio: "ignore" }).status !== 0) {
229
- warn(`npm install -g ${pkg} failed. Try manually: npm install -g ${pkg}`)
230
- return 1
231
- }
232
-
233
- if (mode === "install") {
234
- warn("Downloading Chrome for Testing (~175 MB; sudo may be requested for system libs on Linux)...")
235
- if (spawnSync("agent-browser", ["install", ...installFlags], { stdio: "inherit" }).status !== 0) {
236
- warn(`agent-browser install failed. Re-run manually: agent-browser install ${installFlags.join(" ")}`)
237
- return 1
238
- }
239
- }
240
- const out = services.deps.version("agent-browser")
241
- const fields = (out.split("\n")[0] ?? "").trim().split(/[ \t]+/)
242
- const version2 = out !== "" ? fields[fields.length - 1] ?? "version unknown" : "version unknown"
243
- change(`agent-browser CLI ready (${version2})`)
244
- return 0
245
- }
246
-
247
- function syncAgentBrowserCli(ctx: Ctx, manifest: string): void {
248
- const { warn } = ctx.services.logger
249
- if (!manifest.split("\n").includes("vercel-labs/agent-browser")) return
250
-
251
- if (ctx.services.deps.probe("npm").state === "missing") {
252
- if (!ctx.dryRun) {
253
- ctx.services.deps.warnMissing("npm", ctx.services.logger, "cannot auto-install agent-browser CLI; re-run sync after installing")
254
- }
255
- return
256
- }
257
-
258
- if (ensure(ctx, "agent-browser", agentBrowserInstall) !== 0) {
259
- warn("agent-browser bootstrap failed — continuing sync")
260
- }
261
- }
262
-
263
220
  /** skills::_effect_solutions_install. */
264
221
  export function effectSolutionsInstall(
265
222
  ctx: Ctx
266
- ): (mode: "install" | "upgrade", version: string, services: EngineServices) => number {
267
- return (mode, version, services) => {
268
- const { change, verbose, warn } = services.logger
223
+ ): (mode: "install" | "upgrade", version: string, services: EngineServices) => Promise<number> {
224
+ return async (mode, version, services) => {
225
+ const { change, clearProgress, progress, verbose, warn } = services.logger
269
226
  const verb = mode === "upgrade" ? "Upgrading" : "Installing"
270
227
  const pkg = `effect-solutions@${version !== "" ? version : "latest"}`
271
228
 
272
- const bunState = bunBootstrap(ctx, services)
229
+ const bunState = await bunBootstrap(ctx, services)
273
230
  if (bunState.kind === "deferred") return 1
274
231
  const bun = bunState.executable
275
232
 
276
233
  verbose(`${verb} effect-solutions CLI via bun${version !== "" ? ` (pinned ${version})` : ""}...`)
277
- if (spawnSync(bun, ["add", "-g", pkg], { stdio: "ignore" }).status !== 0) {
234
+ progress(`${verb} effect-solutions CLI...`)
235
+ const installResult = await spawnProcess(bun, ["add", "-g", pkg], { stdio: "ignore" })
236
+ clearProgress()
237
+ if (installResult.exitCode !== 0) {
278
238
  warn(`bun add -g ${pkg} failed. Try manually: bun add -g ${pkg}`)
279
239
  return 1
280
240
  }
281
241
 
282
- const location = services.deps.location("effect-solutions")
242
+ const location = await services.deps.location("effect-solutions")
283
243
  const gbin = location.binDir
284
244
  if (location.path !== "") {
285
245
  mkdirSync(p(ctx.home, ".local", "bin"), { recursive: true })
@@ -293,19 +253,22 @@ export function effectSolutionsInstall(
293
253
  }
294
254
  }
295
255
 
296
- function syncEffectSolutionsCli(ctx: Ctx): void {
297
- const { warn } = ctx.services.logger
256
+ async function syncEffectSolutionsCli(ctx: Ctx): Promise<void> {
257
+ const { clearProgress, progress, warn } = ctx.services.logger
298
258
  if (!/"effect-kit@docks"[ \t]*:[ \t]*true/.test(payloadText("SoT/.claude/settings.json"))) return
299
259
 
300
- if (ensure(ctx, "effect-solutions", effectSolutionsInstall(ctx)) !== 0) {
260
+ progress("Checking effect-solutions CLI...")
261
+ const result = await ensure(ctx, "effect-solutions", effectSolutionsInstall(ctx))
262
+ clearProgress()
263
+ if (result !== 0) {
301
264
  warn("effect-solutions bootstrap failed — continuing sync")
302
265
  }
303
266
  }
304
267
 
305
268
  // ----------------------------------------------------- prune + snapshot ----
306
269
 
307
- function reconcileRemovals(ctx: Ctx, manifest: string, snapshot: string): void {
308
- const { change, echo, warn } = ctx.services.logger
270
+ async function reconcileRemovals(ctx: Ctx, manifest: string, snapshot: string): Promise<void> {
271
+ const { change, clearProgress, echo, progress, warn } = ctx.services.logger
309
272
  if (!existsSync(snapshot)) {
310
273
  if (ctx.dryRun) {
311
274
  echo(
@@ -325,10 +288,12 @@ function reconcileRemovals(ctx: Ctx, manifest: string, snapshot: string): void {
325
288
  echo(`[dry-run] kit-managed skill no longer in SoT — would remove: ${base}`)
326
289
  continue
327
290
  }
328
- const res = spawnSync("npx", ["--yes", skillsCli(ctx), "remove", "--global", base, "-y"], {
291
+ progress(`Removing universal skill ${base}...`)
292
+ const res = await spawnProcess("npx", ["--yes", skillsCli(ctx), "remove", "--global", base, "-y"], {
329
293
  stdio: "ignore"
330
294
  })
331
- if (res.error === undefined && res.status === 0) {
295
+ clearProgress()
296
+ if (res.error === undefined && res.exitCode === 0) {
332
297
  removed++
333
298
  } else {
334
299
  warn(`Failed to remove kit-managed skill: ${base}`)
@@ -10,7 +10,11 @@ import { compareCodepoints, isObject, parseJson, type Json } from "./jq"
10
10
  import type { EngineServices } from "./services"
11
11
  import { payloadText } from "../payload"
12
12
 
13
- type InstallFn = (mode: "install" | "upgrade", version: string, services: EngineServices) => number
13
+ type InstallFn = (
14
+ mode: "install" | "upgrade",
15
+ version: string,
16
+ services: EngineServices
17
+ ) => number | Promise<number>
14
18
 
15
19
  function manifest(): { [k: string]: Json } {
16
20
  const doc = parseJson(payloadText("SoT/toolchain.json"))
@@ -47,36 +51,40 @@ function firstLineField(out: string, index: number): string {
47
51
  return fields[index === -1 ? fields.length - 1 : index] ?? ""
48
52
  }
49
53
 
50
- export function installedVersion(ctx: Ctx, tool: ToolId): string {
51
- const version = (): string => ctx.services.deps.version(tool)
54
+ export async function installedVersion(ctx: Ctx, tool: ToolId): Promise<string> {
55
+ const version = () => ctx.services.deps.version(tool)
52
56
  switch (tool) {
53
- case "rtk":
54
- return firstLineField(version(), 1)
55
57
  case "claude":
56
- return firstLineField(version(), 0)
58
+ return firstLineField(await version(), 0)
57
59
  case "codex":
58
- case "agent-browser":
59
- return firstLineField(version(), -1)
60
+ return firstLineField(await version(), -1)
60
61
  case "git":
61
- return firstLineField(version(), 2)
62
+ return firstLineField(await version(), 2)
62
63
  case "node":
63
- return version().replace(/^v/, "")
64
+ return (await version()).replace(/^v/, "")
64
65
  case "jq":
65
- return version().replace(/^jq-/, "")
66
+ return (await version()).replace(/^jq-/, "")
66
67
  case "curl":
67
68
  case "tsc":
68
- return firstLineField(version(), 1)
69
+ return firstLineField(await version(), 1)
69
70
  case "bun":
70
71
  case "effect-solutions":
71
72
  case "npm":
72
- return version()
73
+ return await version()
74
+ case "bwrap":
75
+ return firstLineField(await version(), 1)
76
+ case "ffplay":
77
+ return firstLineField(await version(), 2).replace(/-.*$/, "")
78
+ case "intelephense":
79
+ case "typescript-language-server":
80
+ return (await version()).trim()
73
81
  default:
74
82
  return ""
75
83
  }
76
84
  }
77
85
 
78
- export function latestVersion(ctx: Ctx, tool: ToolId): string {
79
- return ctx.services.deps.latest(tool)
86
+ export async function latestVersion(ctx: Ctx, tool: ToolId): Promise<string> {
87
+ return await ctx.services.deps.latest(tool)
80
88
  }
81
89
 
82
90
  /** Blocking TTY prompt matching bash `read -r -p` (prompt on stderr). */
@@ -104,7 +112,12 @@ export function promptLine(
104
112
  }
105
113
 
106
114
  /** toolchain::_gate — { proceed, target } ("" target = latest). */
107
- function gate(ctx: Ctx, tool: string, mode: "install" | "upgrade", latest: string): { proceed: boolean; target: string } {
115
+ async function gate(
116
+ ctx: Ctx,
117
+ tool: string,
118
+ mode: "install" | "upgrade",
119
+ latest: string
120
+ ): Promise<{ proceed: boolean; target: string }> {
108
121
  const { warn } = ctx.services.logger
109
122
  const verified = field(ctx, tool, "verified")
110
123
  const pinnable = field(ctx, tool, "pinnable")
@@ -118,7 +131,11 @@ function gate(ctx: Ctx, tool: string, mode: "install" | "upgrade", latest: strin
118
131
 
119
132
  if (process.stdin.isTTY === true) {
120
133
  ctx.services.logger.warn(`${tool} ${latest} is not kit-verified (verified: ${verified}).`)
121
- const answer = promptLine(`Install ${tool} ${latest} anyway? [y/N] `)
134
+ const question = `Install ${tool} ${latest} anyway? [y/N] `
135
+ const answer =
136
+ ctx.terminalLease === undefined
137
+ ? promptLine(question)
138
+ : await ctx.terminalLease.withExclusive(() => promptLine(question))
122
139
  if (/^[yY]/.test(answer)) return { proceed: true, target: "" }
123
140
  }
124
141
 
@@ -132,12 +149,12 @@ function gate(ctx: Ctx, tool: string, mode: "install" | "upgrade", latest: strin
132
149
  return { proceed: false, target: "" }
133
150
  }
134
151
 
135
- export function ensure(ctx: Ctx, tool: ToolId, installFn: InstallFn): number {
152
+ export async function ensure(ctx: Ctx, tool: ToolId, installFn: InstallFn): Promise<number> {
136
153
  const { echo, verbose, warn } = ctx.services.logger
137
154
  const policy = field(ctx, tool, "policy")
138
155
 
139
156
  if (!present(ctx, tool)) {
140
- const latest = latestVersion(ctx, tool)
157
+ const latest = await latestVersion(ctx, tool)
141
158
  if (ctx.dryRun) {
142
159
  echo(`[dry-run] would install ${tool} (${latest !== "" ? latest : "latest"}, gated by toolchain.json verified pin)`)
143
160
  return 0
@@ -152,14 +169,14 @@ export function ensure(ctx: Ctx, tool: ToolId, installFn: InstallFn): number {
152
169
  warn(`${tool} latest version unknown (offline?) and not pinnable — installing latest unverified`)
153
170
  }
154
171
  } else {
155
- const g = gate(ctx, tool, "install", latest)
172
+ const g = await gate(ctx, tool, "install", latest)
156
173
  if (!g.proceed) return 0
157
174
  target = g.target
158
175
  }
159
- return installFn("install", target !== "" ? target : latest, ctx.services)
176
+ return await installFn("install", target !== "" ? target : latest, ctx.services)
160
177
  }
161
178
 
162
- const installed = installedVersion(ctx, tool)
179
+ const installed = await installedVersion(ctx, tool)
163
180
  const installedLabel = installed !== "" ? installed : "version unknown"
164
181
 
165
182
  if (policy !== "track") {
@@ -171,7 +188,7 @@ export function ensure(ctx: Ctx, tool: ToolId, installFn: InstallFn): number {
171
188
  return 0
172
189
  }
173
190
 
174
- const latest = latestVersion(ctx, tool)
191
+ const latest = await latestVersion(ctx, tool)
175
192
  if (latest === "") {
176
193
  if (ctx.dryRun) {
177
194
  echo(`[dry-run] ${tool} present (${installedLabel}); latest unknown (offline?) — no action`)
@@ -186,9 +203,9 @@ export function ensure(ctx: Ctx, tool: ToolId, installFn: InstallFn): number {
186
203
  echo(`[dry-run] would upgrade ${tool} (${installed !== "" ? installed : "unknown"} -> ${latest}, gated by toolchain.json verified pin)`)
187
204
  return 0
188
205
  }
189
- const g = gate(ctx, tool, "upgrade", latest)
206
+ const g = await gate(ctx, tool, "upgrade", latest)
190
207
  if (!g.proceed) return 0
191
- return installFn("upgrade", g.target !== "" ? g.target : latest, ctx.services)
208
+ return await installFn("upgrade", g.target !== "" ? g.target : latest, ctx.services)
192
209
  }
193
210
 
194
211
  if (ctx.dryRun) {
@@ -204,7 +221,7 @@ function row(cells: [string, string, string, string, string, string]): string {
204
221
  return cells.map((c, i) => (i < widths.length ? c.padEnd(widths[i]!) : c)).join(" ")
205
222
  }
206
223
 
207
- export function report(ctx: Ctx): void {
224
+ export async function report(ctx: Ctx): Promise<void> {
208
225
  const { echo } = ctx.services.logger
209
226
  echo(row(["TOOL", "KIND", "INSTALLED", "FLOOR", "VERIFIED", "STATUS"]))
210
227
  const pn = ctx.services.platform.name()
@@ -224,7 +241,7 @@ export function report(ctx: Ctx): void {
224
241
  let status: string
225
242
  const toolId = tool as ToolId
226
243
  if (present(ctx, toolId)) {
227
- installed = installedVersion(ctx, toolId)
244
+ installed = await installedVersion(ctx, toolId)
228
245
  status = "ok"
229
246
  if (floor !== "" && installed !== "" && isNewer(floor, installed)) {
230
247
  status = "below-floor"
package/cli/src/engine.ts CHANGED
@@ -37,7 +37,7 @@ export const engine = (args: ReadonlyArray<string>) =>
37
37
  const logger = yield* LoggerService
38
38
  const deps = yield* DependencyManagerService
39
39
  const platform = yield* PlatformService
40
- const code = yield* Effect.sync(() => runEngineNative(args, { logger, deps, platform }))
40
+ const code = yield* Effect.promise(() => runEngineNative(args, { logger, deps, platform }))
41
41
  if (code !== 0) {
42
42
  yield* Effect.sync(() => process.exit(code))
43
43
  }