docks-kit 0.14.3 → 0.15.0

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,11 +1,22 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks"
2
+
1
3
  /**
2
- * Leveled stderr logger, transient progress writer, and stdout data writer —
3
- * the Output Policy contract in DESIGN.md. Filtering is explicit and
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
4
6
  * synchronous: engine code is imperative, so fiber-scoped Effect log levels
5
7
  * cannot see these writes. Transient progress is active only through an
6
8
  * injected progress sink or an interactive stderr. The prefixes and ANSI codes
7
9
  * are stable golden surface; the level controls visibility only.
8
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
+
9
20
 
10
21
  export interface Logger {
11
22
  /** `[ok]` green — an operation actually mutated something. Always visible. */
@@ -20,6 +31,8 @@ export interface Logger {
20
31
  readonly err: (msg: string) => void
21
32
  /** stdout data line (dry-run report, summary, usage) — never filtered. */
22
33
  readonly echo: (line: string) => void
34
+ /** Acquire the run-scoped coordinator lease for terminal progress and input. */
35
+ readonly acquireTerminal: (message: string) => TerminalLease
23
36
  }
24
37
 
25
38
  export interface LoggerSinks {
@@ -63,36 +76,123 @@ export function makeLogger(sinks: LoggerSinks): Logger {
63
76
  ? (chunk: string) => writeIgnoringEpipe(process.stderr, chunk)
64
77
  : undefined)
65
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> = []
66
83
 
67
- const clearProgress = (): void => {
84
+
85
+ const eraseProgress = (): void => {
68
86
  if (!progressPending || progressWrite === undefined) return
69
87
  progressWrite("\r\x1b[2K")
70
88
  progressPending = false
71
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
+ }
72
120
  const ok = (msg: string): void => {
73
- clearProgress()
74
- errWrite(`\x1b[1;32m[ok]\x1b[0m ${msg}\n`)
121
+ writeDurable(() => errWrite(`\x1b[1;32m[ok]\x1b[0m ${msg}\n`))
75
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
+
76
178
  return {
77
179
  change: ok,
78
180
  verbose: ok,
79
181
  progress: (msg) => {
80
- if (progressWrite === undefined) return
81
- progressWrite(`\r\x1b[2K\x1b[2m${msg}\x1b[0m`)
82
- progressPending = true
182
+ if (activeLease !== undefined) return
183
+ drawProgress(msg)
83
184
  },
84
185
  clearProgress,
85
186
  warn: (msg) => {
86
- clearProgress()
87
- errWrite(`\x1b[1;33m[warn]\x1b[0m ${msg}\n`)
187
+ writeDurable(() => errWrite(`\x1b[1;33m[warn]\x1b[0m ${msg}\n`))
88
188
  },
89
189
  err: (msg) => {
90
- clearProgress()
190
+ eraseProgress()
91
191
  errWrite(`\x1b[1;31m[err]\x1b[0m ${msg}\n`)
92
192
  },
93
193
  echo: (line) => {
94
- clearProgress()
95
- outWrite(`${line}\n`)
96
- }
194
+ writeDurable(() => outWrite(`${line}\n`))
195
+ },
196
+ acquireTerminal
97
197
  }
98
198
  }
@@ -108,7 +108,7 @@ function tomlModelText(text: string): string {
108
108
  return ""
109
109
  }
110
110
 
111
- export function modeToolchain(ctx: Ctx, args: ReadonlyArray<string>): number {
111
+ export async function modeToolchain(ctx: Ctx, args: ReadonlyArray<string>): Promise<number> {
112
112
  const { err } = ctx.services.logger
113
113
  const words = args.filter((a) => !a.startsWith("--"))
114
114
  const op = words[0] ?? args[0] ?? "check"
@@ -121,7 +121,7 @@ export function modeToolchain(ctx: Ctx, args: ReadonlyArray<string>): number {
121
121
  }
122
122
 
123
123
  if (op === "check") {
124
- report(ctx)
124
+ await report(ctx)
125
125
  return 0
126
126
  }
127
127
  if (op !== "ensure") {
@@ -134,9 +134,9 @@ export function modeToolchain(ctx: Ctx, args: ReadonlyArray<string>): number {
134
134
  }
135
135
  switch (tool) {
136
136
  case "bun":
137
- return bunBootstrap(ctx, ctx.services).kind === "ready" ? 0 : 1
137
+ return (await bunBootstrap(ctx, ctx.services)).kind === "ready" ? 0 : 1
138
138
  case "effect-solutions":
139
- return ensure(ctx, "effect-solutions", effectSolutionsInstall(ctx))
139
+ return await ensure(ctx, "effect-solutions", effectSolutionsInstall(ctx))
140
140
  default:
141
141
  err("toolchain ensure supports managed tools only (bun, effect-solutions)")
142
142
  return 2
@@ -23,7 +23,7 @@ export class ExitError extends Error {
23
23
  }
24
24
  }
25
25
 
26
- const KNOWN_CLAUDE_OPTIN_PLUGINS = ["supabase", "n8n"]
26
+ export const KNOWN_CLAUDE_OPTIN_PLUGINS = ["supabase", "n8n"]
27
27
  const MODIFIER_FLAGS = new Set<ModifierFlag>([
28
28
  "--claude-model",
29
29
  "--claude-effort",
@@ -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)
@@ -4,9 +4,8 @@
4
4
  * the kit-managed snapshot, the effect-solutions toolchain callback, and the
5
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,9 +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
- syncEffectSolutionsCli(ctx)
28
+ await syncUniversal(ctx, state, skillsDir, manifest)
29
+ if (ctx.prune) await reconcileRemovals(ctx, manifest, snapshot)
30
+ await syncEffectSolutionsCli(ctx)
32
31
  updateSnapshot(ctx, manifest, snapshot)
33
32
  return state
34
33
  }
@@ -55,7 +54,7 @@ function readSlugs(file: string): Array<string> {
55
54
  return existsSync(file) ? normalizeManifest(readFileSync(file, "utf8")) : []
56
55
  }
57
56
 
58
- function syncUniversal(ctx: Ctx, state: SkillsState, skillsDir: string, manifest: string): void {
57
+ async function syncUniversal(ctx: Ctx, state: SkillsState, skillsDir: string, manifest: string): Promise<void> {
59
58
  const { change, clearProgress, echo, progress, verbose, warn } = ctx.services.logger
60
59
  if (ctx.services.deps.probe("npx").state === "missing") {
61
60
  ctx.services.deps.warnMissing("npx", ctx.services.logger, "skipping universal skills bootstrap")
@@ -87,11 +86,11 @@ function syncUniversal(ctx: Ctx, state: SkillsState, skillsDir: string, manifest
87
86
  }
88
87
 
89
88
  progress(`Installing universal skill ${slug}...`)
90
- const res = spawnSync("npx", ["--yes", skillsCli(ctx), "add", slug, "-g", "-y", "-a", "claude-code", "codex"], {
89
+ const res = await spawnProcess("npx", ["--yes", skillsCli(ctx), "add", slug, "-g", "-y", "-a", "claude-code", "codex"], {
91
90
  stdio: "ignore"
92
91
  })
93
92
  clearProgress()
94
- if (res.error === undefined && res.status === 0) {
93
+ if (res.error === undefined && res.exitCode === 0) {
95
94
  added++
96
95
  } else {
97
96
  warn(`Failed to install universal skill: ${slug}`)
@@ -221,26 +220,26 @@ function linkOrCopyWithWarnings(target: string, link: string, services: EngineSe
221
220
  /** skills::_effect_solutions_install. */
222
221
  export function effectSolutionsInstall(
223
222
  ctx: Ctx
224
- ): (mode: "install" | "upgrade", version: string, services: EngineServices) => number {
225
- return (mode, version, services) => {
223
+ ): (mode: "install" | "upgrade", version: string, services: EngineServices) => Promise<number> {
224
+ return async (mode, version, services) => {
226
225
  const { change, clearProgress, progress, verbose, warn } = services.logger
227
226
  const verb = mode === "upgrade" ? "Upgrading" : "Installing"
228
227
  const pkg = `effect-solutions@${version !== "" ? version : "latest"}`
229
228
 
230
- const bunState = bunBootstrap(ctx, services)
229
+ const bunState = await bunBootstrap(ctx, services)
231
230
  if (bunState.kind === "deferred") return 1
232
231
  const bun = bunState.executable
233
232
 
234
233
  verbose(`${verb} effect-solutions CLI via bun${version !== "" ? ` (pinned ${version})` : ""}...`)
235
234
  progress(`${verb} effect-solutions CLI...`)
236
- const installResult = spawnSync(bun, ["add", "-g", pkg], { stdio: "ignore" })
235
+ const installResult = await spawnProcess(bun, ["add", "-g", pkg], { stdio: "ignore" })
237
236
  clearProgress()
238
- if (installResult.status !== 0) {
237
+ if (installResult.exitCode !== 0) {
239
238
  warn(`bun add -g ${pkg} failed. Try manually: bun add -g ${pkg}`)
240
239
  return 1
241
240
  }
242
241
 
243
- const location = services.deps.location("effect-solutions")
242
+ const location = await services.deps.location("effect-solutions")
244
243
  const gbin = location.binDir
245
244
  if (location.path !== "") {
246
245
  mkdirSync(p(ctx.home, ".local", "bin"), { recursive: true })
@@ -254,12 +253,12 @@ export function effectSolutionsInstall(
254
253
  }
255
254
  }
256
255
 
257
- function syncEffectSolutionsCli(ctx: Ctx): void {
256
+ async function syncEffectSolutionsCli(ctx: Ctx): Promise<void> {
258
257
  const { clearProgress, progress, warn } = ctx.services.logger
259
258
  if (!/"effect-kit@docks"[ \t]*:[ \t]*true/.test(payloadText("SoT/.claude/settings.json"))) return
260
259
 
261
260
  progress("Checking effect-solutions CLI...")
262
- const result = ensure(ctx, "effect-solutions", effectSolutionsInstall(ctx))
261
+ const result = await ensure(ctx, "effect-solutions", effectSolutionsInstall(ctx))
263
262
  clearProgress()
264
263
  if (result !== 0) {
265
264
  warn("effect-solutions bootstrap failed — continuing sync")
@@ -268,7 +267,7 @@ function syncEffectSolutionsCli(ctx: Ctx): void {
268
267
 
269
268
  // ----------------------------------------------------- prune + snapshot ----
270
269
 
271
- function reconcileRemovals(ctx: Ctx, manifest: string, snapshot: string): void {
270
+ async function reconcileRemovals(ctx: Ctx, manifest: string, snapshot: string): Promise<void> {
272
271
  const { change, clearProgress, echo, progress, warn } = ctx.services.logger
273
272
  if (!existsSync(snapshot)) {
274
273
  if (ctx.dryRun) {
@@ -290,11 +289,11 @@ function reconcileRemovals(ctx: Ctx, manifest: string, snapshot: string): void {
290
289
  continue
291
290
  }
292
291
  progress(`Removing universal skill ${base}...`)
293
- const res = spawnSync("npx", ["--yes", skillsCli(ctx), "remove", "--global", base, "-y"], {
292
+ const res = await spawnProcess("npx", ["--yes", skillsCli(ctx), "remove", "--global", base, "-y"], {
294
293
  stdio: "ignore"
295
294
  })
296
295
  clearProgress()
297
- if (res.error === undefined && res.status === 0) {
296
+ if (res.error === undefined && res.exitCode === 0) {
298
297
  removed++
299
298
  } else {
300
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,40 +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
57
  case "claude":
54
- return firstLineField(version(), 0)
58
+ return firstLineField(await version(), 0)
55
59
  case "codex":
56
- return firstLineField(version(), -1)
60
+ return firstLineField(await version(), -1)
57
61
  case "git":
58
- return firstLineField(version(), 2)
62
+ return firstLineField(await version(), 2)
59
63
  case "node":
60
- return version().replace(/^v/, "")
64
+ return (await version()).replace(/^v/, "")
61
65
  case "jq":
62
- return version().replace(/^jq-/, "")
66
+ return (await version()).replace(/^jq-/, "")
63
67
  case "curl":
64
68
  case "tsc":
65
- return firstLineField(version(), 1)
69
+ return firstLineField(await version(), 1)
66
70
  case "bun":
67
71
  case "effect-solutions":
68
72
  case "npm":
69
- return version()
73
+ return await version()
70
74
  case "bwrap":
71
- return firstLineField(version(), 1)
75
+ return firstLineField(await version(), 1)
72
76
  case "ffplay":
73
- return firstLineField(version(), 2).replace(/-.*$/, "")
77
+ return firstLineField(await version(), 2).replace(/-.*$/, "")
74
78
  case "intelephense":
75
79
  case "typescript-language-server":
76
- return version().trim()
80
+ return (await version()).trim()
77
81
  default:
78
82
  return ""
79
83
  }
80
84
  }
81
85
 
82
- export function latestVersion(ctx: Ctx, tool: ToolId): string {
83
- 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)
84
88
  }
85
89
 
86
90
  /** Blocking TTY prompt matching bash `read -r -p` (prompt on stderr). */
@@ -108,7 +112,12 @@ export function promptLine(
108
112
  }
109
113
 
110
114
  /** toolchain::_gate — { proceed, target } ("" target = latest). */
111
- 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 }> {
112
121
  const { warn } = ctx.services.logger
113
122
  const verified = field(ctx, tool, "verified")
114
123
  const pinnable = field(ctx, tool, "pinnable")
@@ -122,7 +131,11 @@ function gate(ctx: Ctx, tool: string, mode: "install" | "upgrade", latest: strin
122
131
 
123
132
  if (process.stdin.isTTY === true) {
124
133
  ctx.services.logger.warn(`${tool} ${latest} is not kit-verified (verified: ${verified}).`)
125
- 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))
126
139
  if (/^[yY]/.test(answer)) return { proceed: true, target: "" }
127
140
  }
128
141
 
@@ -136,12 +149,12 @@ function gate(ctx: Ctx, tool: string, mode: "install" | "upgrade", latest: strin
136
149
  return { proceed: false, target: "" }
137
150
  }
138
151
 
139
- export function ensure(ctx: Ctx, tool: ToolId, installFn: InstallFn): number {
152
+ export async function ensure(ctx: Ctx, tool: ToolId, installFn: InstallFn): Promise<number> {
140
153
  const { echo, verbose, warn } = ctx.services.logger
141
154
  const policy = field(ctx, tool, "policy")
142
155
 
143
156
  if (!present(ctx, tool)) {
144
- const latest = latestVersion(ctx, tool)
157
+ const latest = await latestVersion(ctx, tool)
145
158
  if (ctx.dryRun) {
146
159
  echo(`[dry-run] would install ${tool} (${latest !== "" ? latest : "latest"}, gated by toolchain.json verified pin)`)
147
160
  return 0
@@ -156,14 +169,14 @@ export function ensure(ctx: Ctx, tool: ToolId, installFn: InstallFn): number {
156
169
  warn(`${tool} latest version unknown (offline?) and not pinnable — installing latest unverified`)
157
170
  }
158
171
  } else {
159
- const g = gate(ctx, tool, "install", latest)
172
+ const g = await gate(ctx, tool, "install", latest)
160
173
  if (!g.proceed) return 0
161
174
  target = g.target
162
175
  }
163
- return installFn("install", target !== "" ? target : latest, ctx.services)
176
+ return await installFn("install", target !== "" ? target : latest, ctx.services)
164
177
  }
165
178
 
166
- const installed = installedVersion(ctx, tool)
179
+ const installed = await installedVersion(ctx, tool)
167
180
  const installedLabel = installed !== "" ? installed : "version unknown"
168
181
 
169
182
  if (policy !== "track") {
@@ -175,7 +188,7 @@ export function ensure(ctx: Ctx, tool: ToolId, installFn: InstallFn): number {
175
188
  return 0
176
189
  }
177
190
 
178
- const latest = latestVersion(ctx, tool)
191
+ const latest = await latestVersion(ctx, tool)
179
192
  if (latest === "") {
180
193
  if (ctx.dryRun) {
181
194
  echo(`[dry-run] ${tool} present (${installedLabel}); latest unknown (offline?) — no action`)
@@ -190,9 +203,9 @@ export function ensure(ctx: Ctx, tool: ToolId, installFn: InstallFn): number {
190
203
  echo(`[dry-run] would upgrade ${tool} (${installed !== "" ? installed : "unknown"} -> ${latest}, gated by toolchain.json verified pin)`)
191
204
  return 0
192
205
  }
193
- const g = gate(ctx, tool, "upgrade", latest)
206
+ const g = await gate(ctx, tool, "upgrade", latest)
194
207
  if (!g.proceed) return 0
195
- return installFn("upgrade", g.target !== "" ? g.target : latest, ctx.services)
208
+ return await installFn("upgrade", g.target !== "" ? g.target : latest, ctx.services)
196
209
  }
197
210
 
198
211
  if (ctx.dryRun) {
@@ -208,7 +221,7 @@ function row(cells: [string, string, string, string, string, string]): string {
208
221
  return cells.map((c, i) => (i < widths.length ? c.padEnd(widths[i]!) : c)).join(" ")
209
222
  }
210
223
 
211
- export function report(ctx: Ctx): void {
224
+ export async function report(ctx: Ctx): Promise<void> {
212
225
  const { echo } = ctx.services.logger
213
226
  echo(row(["TOOL", "KIND", "INSTALLED", "FLOOR", "VERIFIED", "STATUS"]))
214
227
  const pn = ctx.services.platform.name()
@@ -228,7 +241,7 @@ export function report(ctx: Ctx): void {
228
241
  let status: string
229
242
  const toolId = tool as ToolId
230
243
  if (present(ctx, toolId)) {
231
- installed = installedVersion(ctx, toolId)
244
+ installed = await installedVersion(ctx, toolId)
232
245
  status = "ok"
233
246
  if (floor !== "" && installed !== "" && isNewer(floor, installed)) {
234
247
  status = "below-floor"
package/cli/src/engine.ts CHANGED
@@ -7,7 +7,7 @@ import { DependencyManagerService, LoggerService, PlatformService } from "./serv
7
7
 
8
8
  /**
9
9
  * The single seam between the typed CLI and EngineNative. Engine execution
10
- * stays in-process after @effect/cli has parsed pickers and flag spellings.
10
+ * stays in-process after effect/unstable/cli has parsed pickers and flag spellings.
11
11
  */
12
12
  const bashRemovedMessage = "bash engine removed — recover at tag bash-engine-final"
13
13
  const bashEngineRequested = (): boolean => process.env["DOCKS_KIT_ENGINE"] === "bash"
@@ -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
  }
@@ -1,7 +1,7 @@
1
1
  // Generated by cli/scripts/generate-sot-payload.ts. DO NOT EDIT.
2
2
  // Edit SoT/, notification.mp3, or package.json, then run: bun cli/scripts/generate-sot-payload.ts
3
3
 
4
- export const GENERATED_PACKAGE_VERSION = "0.14.3"
4
+ export const GENERATED_PACKAGE_VERSION = "0.15.0"
5
5
 
6
6
  export const GENERATED_PAYLOAD_TEXT = {
7
7
  "SoT/.agents/skills.txt": "# Universal AI-agent skill manifest intentionally empty.\n# Global skill discovery is opt-in: add one <owner>/<repo> slug per line.\n# EngineNative ignores comments and blank lines.\n",
package/cli/src/main.ts CHANGED
@@ -1,8 +1,7 @@
1
1
  #!/usr/bin/env bun
2
- import { Command } from "@effect/cli"
3
- import { BunContext, BunRuntime } from "@effect/platform-bun"
2
+ import { Command, CliOutput } from "effect/unstable/cli"
3
+ import { BunRuntime, BunServices } from "@effect/platform-bun"
4
4
  import { Console, Effect, Layer } from "effect"
5
- import { engine } from "./engine"
6
5
  import { EngineServicesLive } from "./services"
7
6
  import { docsCommand } from "./commands/docs"
8
7
  import { modelCommand } from "./commands/model"
@@ -14,6 +13,7 @@ import { syncCommand } from "./commands/sync"
14
13
  import { toolchainCommand } from "./commands/toolchain"
15
14
  import { updateCommand } from "./commands/update"
16
15
  import { GENERATED_PACKAGE_VERSION } from "./generated/sotPayload"
16
+ import { prepareArgv } from "./argv"
17
17
 
18
18
 
19
19
  const root = Command.make("docks-kit", {}, () =>
@@ -50,37 +50,36 @@ const root = Command.make("docks-kit", {}, () =>
50
50
  ])
51
51
  )
52
52
 
53
+ // v4's default formatter renders `name vversion`, but the `docks-kit` launcher
54
+ // compares `--version` against the bare `package.json` version. The trailing
55
+ // newline reproduces the characterized byte-for-byte output of the v3 CLI.
56
+ const bareVersionFormatter: CliOutput.Formatter = {
57
+ ...CliOutput.defaultFormatter(),
58
+ formatVersion: (_name, version) => `${version}\n`
59
+ }
60
+
53
61
  // Harness-private raw channel:
54
- // `DOCKS_KIT_ENGINE=native-raw` bypasses @effect/cli and hands the raw engine
62
+ // `DOCKS_KIT_ENGINE=native-raw` bypasses effect/unstable/cli and hands the raw engine
55
63
  // argv to EngineNative so golden tests drive the internal vocabulary directly.
56
64
  // PUBLIC engine execution lives at the engine.ts seam after the CLI has
57
65
  // parsed/normalized pickers, --flag value forms, and non-engine commands.
58
66
  if (process.env["DOCKS_KIT_ENGINE"] === "native-raw") {
59
67
  const { runEngineNative } = await import("./engine-native")
60
- process.exit(runEngineNative(process.argv.slice(2)))
68
+ process.exit(await runEngineNative(process.argv.slice(2)))
61
69
  }
62
70
 
63
- const cli = Command.run(root, {
64
- name: "docks-kit",
65
- version: GENERATED_PACKAGE_VERSION
66
- })
67
-
68
- // Normalize the repeatable plugin's documented equals form and exact empty
69
- // text-option assignments that @effect/cli otherwise routes into positional
70
- // targets. EngineNative owns the resulting shared empty-value validation.
71
- const emptyTextOptions = new Set([
72
- "--claude-model=",
73
- "--claude-effort=",
74
- "--claude-advisor=",
75
- "--codex-model=",
76
- "--codex-effort=",
77
- ])
78
- const argv = process.argv.flatMap((a, index, all) => {
79
- if (a.startsWith("--claude-plugin=")) {
80
- return ["--claude-plugin", a.slice("--claude-plugin=".length)]
81
- }
82
- if (emptyTextOptions.has(a)) return [a.slice(0, -1), ""]
83
- return [a]
84
- })
71
+ // Validate and normalize before parsing because the kit refuses to guess at unrecognized
72
+ // or duplicated flags, and Effect 4 would otherwise negate `--no-<flag>` into a real
73
+ // mutating run. This seam owns argument normalization.
74
+ const prepared = prepareArgv(process.argv.slice(2))
75
+ if (prepared.kind === "reject") {
76
+ process.stderr.write(`${prepared.message}\n`)
77
+ process.exit(prepared.exitCode)
78
+ }
85
79
 
86
- cli(argv).pipe(Effect.provide(Layer.mergeAll(BunContext.layer, EngineServicesLive)), BunRuntime.runMain)
80
+ Command.runWith(root, { version: GENERATED_PACKAGE_VERSION })(prepared.args).pipe(
81
+ Effect.provide(
82
+ Layer.mergeAll(BunServices.layer, EngineServicesLive, CliOutput.layer(bareVersionFormatter))
83
+ ),
84
+ BunRuntime.runMain
85
+ )
@@ -8,14 +8,14 @@ import { Context, Layer } from "effect"
8
8
  import { makeLogger, type Logger, type LoggerSinks } from "./engine-native/logger"
9
9
  import { makeEngineServices, makePlatform, type DependencyManager, type Platform } from "./engine-native/services"
10
10
 
11
- export class LoggerService extends Context.Tag("docks-kit/Logger")<LoggerService, Logger>() {}
11
+ export class LoggerService extends Context.Service<LoggerService, Logger>()("docks-kit/Logger") {}
12
12
 
13
- export class DependencyManagerService extends Context.Tag("docks-kit/DependencyManager")<
13
+ export class DependencyManagerService extends Context.Service<
14
14
  DependencyManagerService,
15
15
  DependencyManager
16
- >() {}
16
+ >()("docks-kit/DependencyManager") {}
17
17
 
18
- export class PlatformService extends Context.Tag("docks-kit/Platform")<PlatformService, Platform>() {}
18
+ export class PlatformService extends Context.Service<PlatformService, Platform>()("docks-kit/Platform") {}
19
19
 
20
20
  const live = makeEngineServices()
21
21
 
package/docks-kit CHANGED
@@ -69,7 +69,7 @@ fi
69
69
 
70
70
  # Sentinel is a real dependency dir, not bare node_modules/ — a failed or
71
71
  # partial install leaves node_modules/ present and would suppress the repair.
72
- if [[ ! -d "$REPO_DIR/node_modules/@effect/cli" ]]; then
72
+ if [[ ! -d "$REPO_DIR/node_modules/effect" ]]; then
73
73
  echo "[docks-kit] Installing CLI dependencies (bun install --frozen-lockfile)..." >&2
74
74
  (cd "$REPO_DIR" && "$BUN" install --frozen-lockfile >/dev/null)
75
75
  fi