docks-kit 0.1.5 → 0.2.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.
@@ -0,0 +1,325 @@
1
+ /**
2
+ * DependencyManager — one home for external-tool identity, presence probing,
3
+ * and platform-correct install hints (Output Policy in DESIGN.md).
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.
9
+ */
10
+ import { homedir } from "node:os"
11
+ import { existsSync, readdirSync } from "node:fs"
12
+
13
+ import { capture, commandExists, p, which } from "./exec"
14
+ import { isObject, parseJson } from "./jq"
15
+ import { rawPlatform } from "./os"
16
+
17
+ export type ToolId =
18
+ | "git"
19
+ | "jq"
20
+ | "curl"
21
+ | "node"
22
+ | "npm"
23
+ | "npx"
24
+ | "claude"
25
+ | "codex"
26
+ | "rtk"
27
+ | "bun"
28
+ | "bwrap"
29
+ | "agent-browser"
30
+ | "effect-solutions"
31
+ | "chrome-for-testing"
32
+ | "ffplay"
33
+ | "intelephense"
34
+ | "typescript-language-server"
35
+ | "tsc"
36
+ | "apt-get"
37
+ | "dnf"
38
+ | "pacman"
39
+ | "zypper"
40
+
41
+ /** required = the engine aborts when missing; optional = warn + degrade. */
42
+ export type Requirement = "required" | "optional"
43
+
44
+ export type ProbeResult =
45
+ | { readonly state: "present"; readonly path?: string }
46
+ | { readonly state: "missing" }
47
+
48
+ export interface DependencyLocation {
49
+ readonly path: string
50
+ readonly binDir: string
51
+ }
52
+
53
+ export interface ProbeExecutor {
54
+ readonly commandExists: (name: string) => boolean
55
+ readonly capture: (cmd: string, args: ReadonlyArray<string>) => string
56
+ readonly which: (name: string) => string
57
+ }
58
+
59
+ export interface DependencySpec {
60
+ readonly id: ToolId
61
+ readonly requirement: Requirement
62
+ readonly versionArgs: ReadonlyArray<string>
63
+ /** Platform-correct one-line install command (param injectable for tests). */
64
+ readonly installHint: (platform?: NodeJS.Platform) => string
65
+ readonly resolve?: (exec: ProbeExecutor, platform: NodeJS.Platform) => ProbeResult
66
+ readonly version?: (exec: ProbeExecutor) => string
67
+ readonly locate?: (exec: ProbeExecutor, platform: NodeJS.Platform) => DependencyLocation
68
+ readonly latest?: (exec: ProbeExecutor) => string
69
+ }
70
+
71
+ interface SpecOptions {
72
+ readonly versionArgs?: ReadonlyArray<string>
73
+ readonly resolve?: (exec: ProbeExecutor, platform: NodeJS.Platform) => ProbeResult
74
+ readonly version?: (exec: ProbeExecutor) => string
75
+ readonly locate?: (exec: ProbeExecutor, platform: NodeJS.Platform) => DependencyLocation
76
+ readonly latest?: (exec: ProbeExecutor) => string
77
+ }
78
+
79
+ const spec = (
80
+ id: ToolId,
81
+ requirement: Requirement,
82
+ installHint: (platform?: NodeJS.Platform) => string,
83
+ options: SpecOptions = {}
84
+ ): DependencySpec => ({
85
+ id,
86
+ requirement,
87
+ versionArgs: options.versionArgs ?? ["--version"],
88
+ installHint,
89
+ resolve: options.resolve,
90
+ version: options.version,
91
+ locate: options.locate,
92
+ latest: options.latest
93
+ })
94
+
95
+ const pathProbe = (id: string): ((exec: ProbeExecutor) => ProbeResult) =>
96
+ (exec) =>
97
+ exec.commandExists(id)
98
+ ? { state: "present", path: exec.which(id) }
99
+ : { state: "missing" }
100
+
101
+ const versionProbe = (
102
+ id: string,
103
+ versionArgs: ReadonlyArray<string> = ["--version"],
104
+ parse: (out: string) => string = (out) => out
105
+ ): ((exec: ProbeExecutor) => string) =>
106
+ (exec) => parse(exec.capture(id, versionArgs))
107
+
108
+ const home = (): string => {
109
+ const envHome = process.env["HOME"]
110
+ return envHome !== undefined && envHome !== "" ? envHome : homedir()
111
+ }
112
+
113
+ const findBun = (exec: ProbeExecutor): { command: string; path: string } | undefined => {
114
+ const onPath = exec.which("bun")
115
+ if (onPath !== "") return { command: "bun", path: onPath }
116
+ const root =
117
+ process.env["BUN_INSTALL"] !== undefined && process.env["BUN_INSTALL"] !== ""
118
+ ? process.env["BUN_INSTALL"]!
119
+ : p(home(), ".bun")
120
+ for (const candidate of [p(root, "bin", "bun"), p(home(), ".bun", "bin", "bun")]) {
121
+ if (exec.which(candidate) !== "") return { command: candidate, path: candidate }
122
+ }
123
+ return undefined
124
+ }
125
+
126
+ const resolveBun = (exec: ProbeExecutor): ProbeResult => {
127
+ const bun = findBun(exec)
128
+ return bun === undefined
129
+ ? { state: "missing" }
130
+ : { state: "present", path: bun.path }
131
+ }
132
+
133
+ const resolveEffectSolutions = (exec: ProbeExecutor): ProbeResult => {
134
+ return exec.commandExists("effect-solutions")
135
+ ? { state: "present", path: exec.which("effect-solutions") }
136
+ : { state: "missing" }
137
+ }
138
+
139
+ const versionBunCommand = (exec: ProbeExecutor): string =>
140
+ exec.commandExists("bun") ? "bun" : p(home(), ".bun", "bin", "bun")
141
+
142
+ const versionEffectSolutions = (exec: ProbeExecutor): string => {
143
+ const bun = versionBunCommand(exec)
144
+ if (bun !== "bun" && exec.which(bun) === "") return ""
145
+ const match = /effect-solutions@([0-9][0-9.]*)/.exec(exec.capture(bun, ["pm", "-g", "ls"]))
146
+ return match?.[1] ?? ""
147
+ }
148
+
149
+ const locateEffectSolutions = (exec: ProbeExecutor, platform: NodeJS.Platform): DependencyLocation => {
150
+ const bun = findBun(exec)
151
+ if (bun === undefined) return { path: "", binDir: "" }
152
+ const globalBin = exec.capture(bun.command, ["pm", "-g", "bin"])
153
+ const names =
154
+ platform === "win32"
155
+ ? ["effect-solutions.exe", "effect-solutions.cmd", "effect-solutions.bunx"]
156
+ : ["effect-solutions"]
157
+ const path = names.map((name) => p(globalBin, name)).find((candidate) => globalBin !== "" && exec.which(candidate) !== "")
158
+ return { path: path ?? "", binDir: globalBin }
159
+ }
160
+
161
+ const resolveChrome = (exec: ProbeExecutor, platform: NodeJS.Platform): ProbeResult => {
162
+ const root = p(home(), ".agent-browser", "browsers")
163
+ const relative =
164
+ platform === "win32"
165
+ ? "chrome.exe"
166
+ : platform === "darwin"
167
+ ? "Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing"
168
+ : "chrome"
169
+ if (existsSync(root)) {
170
+ for (const directory of readdirSync(root).filter((name) => name.startsWith("chrome-")).sort().reverse()) {
171
+ const path = exec.which(p(root, directory, relative))
172
+ if (path !== "") return { state: "present", path }
173
+ }
174
+ }
175
+ for (const command of ["chrome-for-testing", "google-chrome-for-testing", "google-chrome", "chromium", "chromium-browser", "brave-browser", "brave"]) {
176
+ const path = exec.which(command)
177
+ if (path !== "") return { state: "present", path }
178
+ }
179
+ return { state: "missing" }
180
+ }
181
+
182
+ const latestRtk = (exec: ProbeExecutor): string => {
183
+ const doc = parseJson(
184
+ exec.capture("curl", ["-fsSL", "--max-time", "5", "https://api.github.com/repos/rtk-ai/rtk/releases/latest"])
185
+ )
186
+ const tag = doc !== undefined && isObject(doc) && typeof doc["tag_name"] === "string" ? doc["tag_name"] : ""
187
+ return tag.replace(/^v/, "")
188
+ }
189
+
190
+ const latestNpm = (id: "agent-browser" | "effect-solutions") => (exec: ProbeExecutor): string =>
191
+ exec.commandExists("npm") ? exec.capture("npm", ["view", id, "version"]) : ""
192
+
193
+ export const defaultProbeExecutor: ProbeExecutor = { commandExists, capture, which }
194
+
195
+ export const DEPENDENCIES: Record<ToolId, DependencySpec> = {
196
+ git: spec(
197
+ "git",
198
+ "optional",
199
+ (pf = rawPlatform()) =>
200
+ pf === "win32"
201
+ ? "winget install Git.Git (then open a new terminal)"
202
+ : pf === "darwin"
203
+ ? "brew install git"
204
+ : "sudo apt install -y git (or your distro's package manager)",
205
+ { version: versionProbe("git") }
206
+ ),
207
+ jq: spec("jq", "required", (pf = rawPlatform()) =>
208
+ pf === "win32"
209
+ ? "winget install jqlang.jq (then open a new terminal)"
210
+ : pf === "darwin"
211
+ ? "brew install jq"
212
+ : "sudo apt install -y jq",
213
+ { version: versionProbe("jq") }
214
+ ),
215
+ curl: spec("curl", "required", (pf = rawPlatform()) =>
216
+ pf === "win32" ? "winget install cURL.cURL" : pf === "darwin" ? "brew install curl" : "sudo apt install -y curl",
217
+ { version: versionProbe("curl") }
218
+ ),
219
+ node: spec("node", "optional", () => "install Node.js via https://nodejs.org (or your package manager)", {
220
+ version: versionProbe("node")
221
+ }),
222
+ npm: spec("npm", "optional", () => "ships with Node.js — install via https://nodejs.org (or your package manager)"),
223
+ npx: spec("npx", "optional", () => "ships with Node.js — install via https://nodejs.org (or your package manager)"),
224
+ claude: spec(
225
+ "claude",
226
+ "optional",
227
+ (pf = rawPlatform()) =>
228
+ pf === "win32"
229
+ ? "winget install Anthropic.ClaudeCode"
230
+ : "curl -fsSL https://claude.ai/install.sh -o /tmp/claude-install.sh && bash /tmp/claude-install.sh",
231
+ { version: versionProbe("claude") }
232
+ ),
233
+ codex: spec(
234
+ "codex",
235
+ "optional",
236
+ (pf = rawPlatform()) =>
237
+ pf === "win32"
238
+ ? `powershell -ExecutionPolicy ByPass -c "irm https://chatgpt.com/codex/install.ps1 | iex"`
239
+ : 'tmp=$(mktemp) && curl -fsSL https://chatgpt.com/codex/install.sh -o "$tmp" && CODEX_NON_INTERACTIVE=1 sh "$tmp"',
240
+ { version: versionProbe("codex") }
241
+ ),
242
+ rtk: spec("rtk", "optional", () => "see https://github.com/rtk-ai/rtk (kit auto-install is Unix-only)", {
243
+ version: versionProbe("rtk"),
244
+ latest: latestRtk
245
+ }),
246
+ bun: spec(
247
+ "bun",
248
+ "optional",
249
+ (pf = rawPlatform()) =>
250
+ pf === "win32" ? `powershell -c "irm bun.sh/install.ps1 | iex"` : "curl -fsSL https://bun.sh/install | bash",
251
+ {
252
+ resolve: resolveBun,
253
+ version: (exec) => exec.capture(versionBunCommand(exec), ["--version"]),
254
+ locate: (exec) => ({ path: findBun(exec)?.path ?? "", binDir: "" })
255
+ }
256
+ ),
257
+ bwrap: spec("bwrap", "optional", () => "sudo apt install -y bubblewrap (or dnf/pacman/zypper equivalent)"),
258
+ "agent-browser": spec("agent-browser", "optional", () => "npm install -g agent-browser", {
259
+ version: versionProbe("agent-browser"),
260
+ latest: latestNpm("agent-browser")
261
+ }),
262
+ "effect-solutions": spec("effect-solutions", "optional", () => "bun add -g effect-solutions", {
263
+ resolve: resolveEffectSolutions,
264
+ version: versionEffectSolutions,
265
+ locate: locateEffectSolutions,
266
+ latest: latestNpm("effect-solutions")
267
+ }),
268
+ "chrome-for-testing": spec(
269
+ "chrome-for-testing",
270
+ "optional",
271
+ (pf = rawPlatform()) => (pf === "linux" ? "agent-browser install --with-deps" : "agent-browser install"),
272
+ { resolve: resolveChrome }
273
+ ),
274
+ ffplay: spec(
275
+ "ffplay",
276
+ "optional",
277
+ (pf = rawPlatform()) =>
278
+ pf === "win32" ? "winget install Gyan.FFmpeg" : pf === "darwin" ? "brew install ffmpeg" : "sudo apt install -y ffmpeg",
279
+ { versionArgs: ["-version"], resolve: pathProbe("ffplay") }
280
+ ),
281
+ intelephense: spec("intelephense", "optional", () => "npm install -g intelephense", {
282
+ resolve: pathProbe("intelephense")
283
+ }),
284
+ "typescript-language-server": spec(
285
+ "typescript-language-server",
286
+ "optional",
287
+ () => "npm install -g typescript-language-server typescript",
288
+ { resolve: pathProbe("typescript-language-server") }
289
+ ),
290
+ tsc: spec("tsc", "optional", () => "npm install -g typescript", {
291
+ resolve: pathProbe("tsc"),
292
+ version: versionProbe("tsc")
293
+ }),
294
+ "apt-get": spec("apt-get", "optional", () => "install apt via your Linux distribution"),
295
+ dnf: spec("dnf", "optional", () => "install dnf via your Linux distribution"),
296
+ pacman: spec("pacman", "optional", () => "install pacman via your Linux distribution"),
297
+ zypper: spec("zypper", "optional", () => "install zypper via your Linux distribution")
298
+ }
299
+
300
+ export function resolveDependency(
301
+ specification: DependencySpec,
302
+ exec: ProbeExecutor,
303
+ platform: NodeJS.Platform = rawPlatform()
304
+ ): ProbeResult {
305
+ return (specification.resolve ?? pathProbe(specification.id))(exec, platform)
306
+ }
307
+
308
+ export function resolveVersion(specification: DependencySpec, exec: ProbeExecutor): string {
309
+ if (resolveDependency(specification, exec).state !== "present") return ""
310
+ return (specification.version ?? versionProbe(specification.id, specification.versionArgs))(exec)
311
+ }
312
+
313
+ export function resolveLocation(
314
+ specification: DependencySpec,
315
+ exec: ProbeExecutor,
316
+ platform: NodeJS.Platform = rawPlatform()
317
+ ): DependencyLocation {
318
+ if (specification.locate !== undefined) return specification.locate(exec, platform)
319
+ const result = resolveDependency(specification, exec, platform)
320
+ return { path: result.state === "present" ? (result.path ?? exec.which(specification.id)) : "", binDir: "" }
321
+ }
322
+
323
+ export function resolvePath(specification: DependencySpec, exec: ProbeExecutor, platform?: NodeJS.Platform): string {
324
+ return resolveLocation(specification, exec, platform).path
325
+ }
@@ -4,8 +4,8 @@
4
4
  * substitution: stdout with trailing newlines stripped, empty on failure.
5
5
  */
6
6
  import { spawnSync } from "node:child_process"
7
- import { accessSync, constants, existsSync, statSync } from "node:fs"
8
- import { delimiter, join } from "node:path"
7
+ import { accessSync, chmodSync, constants, copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs"
8
+ import { delimiter, isAbsolute, join } from "node:path"
9
9
 
10
10
  /**
11
11
  * Engine paths are built with "/" because they appear verbatim in output
@@ -24,6 +24,9 @@ export function capture(cmd: string, args: ReadonlyArray<string>): string {
24
24
 
25
25
  /** `command -v` — resolve a name on PATH (PATHEXT-aware on Windows). */
26
26
  export function which(name: string): string {
27
+ if (isAbsolute(name) || name.includes("/") || name.includes("\\")) {
28
+ return isExecutable(name) ? name : ""
29
+ }
27
30
  const exts = process.platform === "win32" ? (process.env["PATHEXT"] ?? ".EXE;.CMD;.BAT;.COM").split(";").concat("") : [""]
28
31
  for (const dir of (process.env["PATH"] ?? "").split(delimiter)) {
29
32
  if (dir === "") continue
@@ -52,3 +55,48 @@ export function isExecutable(p: string): boolean {
52
55
  export function fileExists(p: string): boolean {
53
56
  return existsSync(p)
54
57
  }
58
+
59
+ // Change-detection primitives (Output Policy in DESIGN.md): operations report
60
+ // changed:boolean so unchanged repeat runs log at verbose instead of [ok].
61
+
62
+ /** Write only when the content differs; returns whether a write happened. */
63
+ /** Add missing +x bits; returns whether a repair actually happened. */
64
+ export function ensureExecutable(path: string): boolean {
65
+ const mode = statSync(path).mode
66
+ const want = mode | 0o111
67
+ if (mode === want) return false
68
+ chmodSync(path, want)
69
+ return true
70
+ }
71
+
72
+ export function writeFileIfChanged(path: string, content: string): boolean {
73
+ if (existsSync(path) && readFileSync(path, "utf8") === content) return false
74
+ writeFileSync(path, content)
75
+ return true
76
+ }
77
+
78
+ /** Copy only when dest differs from src; returns whether a copy happened. */
79
+ export function copyFileIfChanged(src: string, dest: string): boolean {
80
+ if (existsSync(dest) && readFileSync(dest).equals(readFileSync(src))) return false
81
+ copyFileSync(src, dest)
82
+ return true
83
+ }
84
+
85
+ /** Recursive copy via copyFileIfChanged; returns whether anything changed. */
86
+ export function copyTreeIfChanged(srcDir: string, destDir: string): boolean {
87
+ let changed = false
88
+ for (const e of readdirSync(srcDir, { withFileTypes: true })) {
89
+ const src = p(srcDir, e.name)
90
+ const dest = p(destDir, e.name)
91
+ if (e.isDirectory()) {
92
+ if (!existsSync(dest)) {
93
+ mkdirSync(dest, { recursive: true })
94
+ changed = true
95
+ }
96
+ if (copyTreeIfChanged(src, dest)) changed = true
97
+ } else if (copyFileIfChanged(src, dest)) {
98
+ changed = true
99
+ }
100
+ }
101
+ return changed
102
+ }
@@ -10,11 +10,11 @@ import { existsSync } from "node:fs"
10
10
  import { homedir } from "node:os"
11
11
 
12
12
  import { kitHome } from "../kitHome"
13
+ import { makeEngineServices, type EngineServices, type Logger } from "./services"
13
14
  import { claudeNextSteps, claudeSummary, claudeSync } from "./claudeSync"
14
15
  import { codexNextSteps, codexSummary, codexSync } from "./codexSync"
15
16
  import { skillsNextSteps, skillsSummary, skillsSync } from "./skillsSync"
16
17
  import { modeModel, modeToolchain } from "./modes"
17
- import { echo } from "./output"
18
18
  import { ExitError, parseArgs, preflight, validateModelFlags } from "./parseArgs"
19
19
 
20
20
  export interface Ctx {
@@ -22,6 +22,7 @@ export interface Ctx {
22
22
  readonly home: string
23
23
  readonly agentsDir: string
24
24
  dryRun: boolean
25
+ verbose: boolean
25
26
  skipRtk: boolean
26
27
  reconcile: boolean
27
28
  prune: boolean
@@ -31,14 +32,23 @@ export interface Ctx {
31
32
  claudePlugins: Array<string>
32
33
  claudeModel: string
33
34
  codexModel: string
35
+ /** Injected capability seam (logger/deps/platform) — see services.ts. */
36
+ readonly services: EngineServices
34
37
  targetFilterSet: boolean
35
38
  syncClaude: boolean
36
39
  syncCodex: boolean
37
40
  syncAgents: boolean
41
+ /** Per-run next-step triggers (Output Policy): advice prints only when its trigger changed or --verbose. */
42
+ readonly nextStepTriggers: {
43
+ claudePlugins: boolean
44
+ claudeRestart: boolean
45
+ codexRestart: boolean
46
+ skillsRestart: boolean
47
+ }
38
48
  }
39
49
 
40
50
  /** Globals default from env using the historical ${VAR:-default} contract. */
41
- function makeCtx(): Ctx {
51
+ function makeCtx(services: EngineServices): Ctx {
42
52
  const env = process.env
43
53
  const home = env["HOME"] !== undefined && env["HOME"] !== "" ? env["HOME"] : homedir()
44
54
  return {
@@ -46,6 +56,7 @@ function makeCtx(): Ctx {
46
56
  home,
47
57
  agentsDir: env["AGENTS_DIR"] !== undefined && env["AGENTS_DIR"] !== "" ? env["AGENTS_DIR"] : p(home, ".agents"),
48
58
  dryRun: env["DRY_RUN"] === "1",
59
+ verbose: env["DOCKS_KIT_VERBOSE"] === "1",
49
60
  skipRtk: env["SKIP_RTK"] === "1",
50
61
  reconcile: env["RECONCILE"] === "1",
51
62
  prune: env["PRUNE"] === "1",
@@ -55,14 +66,17 @@ function makeCtx(): Ctx {
55
66
  claudePlugins: (env["CLAUDE_PLUGINS"] ?? "").split(" ").filter((s) => s !== ""),
56
67
  claudeModel: env["CLAUDE_MODEL"] ?? "",
57
68
  codexModel: env["CODEX_MODEL"] ?? "",
69
+ services,
58
70
  targetFilterSet: false,
59
71
  syncClaude: false,
60
72
  syncCodex: false,
61
- syncAgents: false
73
+ syncAgents: false,
74
+ nextStepTriggers: { claudePlugins: false, claudeRestart: false, codexRestart: false, skillsRestart: false }
62
75
  }
63
76
  }
64
77
 
65
78
  function engineSync(ctx: Ctx, args: ReadonlyArray<string>): number {
79
+ const { echo } = ctx.services.logger
66
80
  parseArgs(ctx, args)
67
81
  preflight(ctx)
68
82
  validateModelFlags(ctx)
@@ -82,15 +96,37 @@ function engineSync(ctx: Ctx, args: ReadonlyArray<string>): number {
82
96
  if (codexRan) codexSummary(ctx)
83
97
  if (skillsState !== undefined) skillsSummary(ctx, skillsState)
84
98
 
85
- echo("")
86
- if (claudeRan) claudeNextSteps()
87
- if (codexRan) codexNextSteps()
88
- if (skillsState !== undefined) skillsNextSteps()
99
+ const advice = [
100
+ ...(claudeRan ? claudeNextSteps(ctx) : []),
101
+ ...(codexRan ? codexNextSteps(ctx) : []),
102
+ ...(skillsState !== undefined ? skillsNextSteps(ctx) : [])
103
+ ]
104
+ if (advice.length > 0) {
105
+ echo("")
106
+ for (const line of advice) echo(line)
107
+ }
89
108
  return 0
90
109
  }
91
110
 
92
- export function runEngineNative(argv: ReadonlyArray<string>): number {
93
- const ctx = makeCtx()
111
+ export function runEngineNative(argv: ReadonlyArray<string>, services?: EngineServices): number {
112
+ let ctx!: Ctx
113
+ const baseServices = services ?? makeEngineServices()
114
+ const baseLogger = baseServices.logger
115
+ const logger: Logger = {
116
+ change: (msg) => baseLogger.change(msg),
117
+ verbose: (msg) => {
118
+ if (ctx.verbose) baseLogger.verbose(msg)
119
+ },
120
+ warn: (msg) => baseLogger.warn(msg),
121
+ err: (msg) => baseLogger.err(msg),
122
+ echo: (line) => baseLogger.echo(line)
123
+ }
124
+ const runServices: EngineServices = {
125
+ logger,
126
+ deps: baseServices.deps,
127
+ platform: baseServices.platform
128
+ }
129
+ ctx = makeCtx(runServices)
94
130
  try {
95
131
  switch (argv[0]) {
96
132
  case "model":
@@ -0,0 +1,35 @@
1
+ /**
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.
6
+ */
7
+
8
+ export interface Logger {
9
+ /** `[ok]` green — an operation actually mutated something. Always visible. */
10
+ readonly change: (msg: string) => void
11
+ /** `[ok]` green — status-quo confirmation; visible only with verbosity on. */
12
+ readonly verbose: (msg: string) => void
13
+ readonly warn: (msg: string) => void
14
+ readonly err: (msg: string) => void
15
+ /** stdout data line (dry-run report, summary, usage) — never filtered. */
16
+ readonly echo: (line: string) => void
17
+ }
18
+
19
+ export interface LoggerSinks {
20
+ readonly stderr?: (chunk: string) => void
21
+ readonly stdout?: (chunk: string) => void
22
+ }
23
+
24
+ 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`)
28
+ return {
29
+ change: ok,
30
+ 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`)
34
+ }
35
+ }
@@ -5,8 +5,8 @@
5
5
  import { p } from "./exec"
6
6
  import { readFileSync } from "node:fs"
7
7
 
8
+ import type { Ctx } from "./index"
8
9
  import { isObject, parseJson, type Json } from "./jq"
9
- import { warn } from "./output"
10
10
 
11
11
  function catalog(repoDir: string): Json | undefined {
12
12
  try {
@@ -35,37 +35,38 @@ export function modelsFromManifest(repoDir: string, tool: string): Array<string>
35
35
  .filter((id): id is string => typeof id === "string")
36
36
  }
37
37
 
38
- export function printModels(repoDir: string, tool: string): void {
39
- const entry = toolEntry(repoDir, tool)
38
+ export function printModels(ctx: Ctx, tool: string): void {
39
+ const { echo, warn } = ctx.services.logger
40
+ const entry = toolEntry(ctx.repoDir, tool)
40
41
  if (entry === undefined) {
41
- warn(`Model catalog unavailable (${p(repoDir, "SoT", "models.json")})`)
42
+ warn(`Model catalog unavailable (${p(ctx.repoDir, "SoT", "models.json")})`)
42
43
  return
43
44
  }
44
45
  const verified = typeof entry["verified"] === "string" ? entry["verified"] : "?"
45
46
  const lines = [`Available ${tool} models (kit-verified ${verified} — SoT/models.json):`]
46
- for (const m of modelEntries(repoDir, tool)) {
47
+ for (const m of modelEntries(ctx.repoDir, tool)) {
47
48
  const note = typeof m["note"] === "string" ? ` — ${m["note"]}` : ""
48
49
  lines.push(` ${String(m["id"] ?? "")}${note}`)
49
50
  }
50
51
  if (tool === "claude") lines.push(" (full claude-* model IDs outside the catalog are accepted with a warning)")
51
52
  if (tool === "codex") lines.push(" (well-formed IDs outside the catalog are accepted with a warning)")
52
- process.stderr.write(`${lines.join("\n")}\n`)
53
+ for (const line of lines) echo(line)
53
54
  }
54
55
 
55
- export function validateClaudeModel(repoDir: string, m: string): boolean {
56
+ export function validateClaudeModel(ctx: Ctx, m: string): boolean {
56
57
  if (m === "") return false
57
- if (modelsFromManifest(repoDir, "claude").includes(m)) return true
58
+ if (modelsFromManifest(ctx.repoDir, "claude").includes(m)) return true
58
59
  if (m.startsWith("claude-")) {
59
- warn(`Claude model '${m}' is not in the kit-verified catalog (SoT/models.json) — applying anyway`)
60
+ ctx.services.logger.warn(`Claude model '${m}' is not in the kit-verified catalog (SoT/models.json) — applying anyway`)
60
61
  return true
61
62
  }
62
63
  return false
63
64
  }
64
65
 
65
- export function validateCodexModel(repoDir: string, m: string): boolean {
66
+ export function validateCodexModel(ctx: Ctx, m: string): boolean {
66
67
  if (!/^[A-Za-z0-9._-]+$/.test(m)) return false
67
- if (!modelsFromManifest(repoDir, "codex").includes(m)) {
68
- warn(
68
+ if (!modelsFromManifest(ctx.repoDir, "codex").includes(m)) {
69
+ ctx.services.logger.warn(
69
70
  `Codex model '${m}' is not in the kit-verified catalog (SoT/models.json) — applying anyway (check ~/.codex/config.toml if Codex rejects it)`
70
71
  )
71
72
  }
@@ -10,17 +10,19 @@ import { syncCodexModel } from "./codexToml"
10
10
  import type { Ctx } from "./index"
11
11
  import { isObject, parseJson, type Json } from "./jq"
12
12
  import { printModels, validateClaudeModel, validateCodexModel } from "./models"
13
- import { echo, err, warn } from "./output"
14
13
  import { rtkInstall } from "./claudeSync"
15
14
  import { agentBrowserInstall, bunBootstrap, effectSolutionsInstall } from "./skillsSync"
16
15
  import { ensure, report } from "./toolchain"
17
16
 
18
17
  export function modeModel(ctx: Ctx, args: ReadonlyArray<string>): number {
18
+ const { echo, err, warn } = ctx.services.logger
19
19
  let tool = ""
20
20
  let value = ""
21
21
  for (const arg of args) {
22
22
  if (arg === "--dry-run") ctx.dryRun = true
23
- else if (arg === "claude" || arg === "codex") tool = arg
23
+ else if (arg === "--verbose") {
24
+ ctx.verbose = true
25
+ } else if (arg === "claude" || arg === "codex") tool = arg
24
26
  else if (arg.startsWith("-")) {
25
27
  err(`Unknown flag for model: ${arg}`)
26
28
  return 2
@@ -49,20 +51,20 @@ export function modeModel(ctx: Ctx, args: ReadonlyArray<string>): number {
49
51
  echo(`deployed: ${tomlModelField(deployed)}`)
50
52
  echo(`SoT: ${tomlModelField(p(ctx.repoDir, "SoT", ".codex", "config.toml"))}`)
51
53
  }
52
- printModels(ctx.repoDir, tool)
54
+ printModels(ctx, tool)
53
55
  return 0
54
56
  }
55
57
 
56
58
  if (tool === "claude") {
57
- if (!validateClaudeModel(ctx.repoDir, value)) {
58
- printModels(ctx.repoDir, "claude")
59
+ if (!validateClaudeModel(ctx, value)) {
60
+ printModels(ctx, "claude")
59
61
  err(`Invalid Claude model '${value}'`)
60
62
  return 2
61
63
  }
62
64
  syncClaudeModel(ctx, value)
63
65
  } else {
64
- if (!validateCodexModel(ctx.repoDir, value)) {
65
- printModels(ctx.repoDir, "codex")
66
+ if (!validateCodexModel(ctx, value)) {
67
+ printModels(ctx, "codex")
66
68
  err(`Invalid Codex model '${value}'`)
67
69
  return 2
68
70
  }
@@ -98,10 +100,15 @@ function tomlModelField(file: string): string {
98
100
  }
99
101
 
100
102
  export function modeToolchain(ctx: Ctx, args: ReadonlyArray<string>): number {
101
- const op = args[0] ?? "check"
102
- const tool = args[1] ?? ""
103
+ const { err } = ctx.services.logger
104
+ const words = args.filter((a) => !a.startsWith("--"))
105
+ const op = words[0] ?? args[0] ?? "check"
106
+ const tool = words[1] ?? args[1] ?? ""
103
107
  for (const arg of args) {
104
108
  if (arg === "--yes") ctx.assumeYes = true
109
+ else if (arg === "--verbose") {
110
+ ctx.verbose = true
111
+ }
105
112
  }
106
113
 
107
114
  if (op === "check") {
@@ -121,7 +128,7 @@ export function modeToolchain(ctx: Ctx, args: ReadonlyArray<string>): number {
121
128
  return ensure(ctx, "rtk", rtkInstall(ctx))
122
129
  case "bun":
123
130
  // skills::_bun_bootstrap >/dev/null — the found-bun stdout is discarded.
124
- return bunBootstrap(ctx) !== "" ? 0 : 1
131
+ return bunBootstrap(ctx, ctx.services) !== "" ? 0 : 1
125
132
  case "effect-solutions":
126
133
  return ensure(ctx, "effect-solutions", effectSolutionsInstall(ctx))
127
134
  case "agent-browser":