docks-kit 0.1.5 → 0.3.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.
Files changed (50) hide show
  1. package/AGENTS.md +1 -1
  2. package/README.md +7 -5
  3. package/cli/docs/flags.md +1 -0
  4. package/cli/docs/install.md +9 -11
  5. package/cli/docs/overview.md +6 -0
  6. package/cli/docs/platforms.md +9 -10
  7. package/cli/src/commands/model.ts +7 -3
  8. package/cli/src/commands/sync.ts +14 -3
  9. package/cli/src/commands/toolchain.ts +7 -3
  10. package/cli/src/engine-native/DESIGN.md +79 -2
  11. package/cli/src/engine-native/claudeModel.ts +9 -3
  12. package/cli/src/engine-native/claudeSync.ts +220 -116
  13. package/cli/src/engine-native/codexSync.ts +131 -77
  14. package/cli/src/engine-native/codexToml.ts +12 -5
  15. package/cli/src/engine-native/deps.ts +325 -0
  16. package/cli/src/engine-native/exec.ts +35 -2
  17. package/cli/src/engine-native/index.ts +48 -13
  18. package/cli/src/engine-native/logger.ts +35 -0
  19. package/cli/src/engine-native/models.ts +22 -23
  20. package/cli/src/engine-native/modes.ts +30 -14
  21. package/cli/src/engine-native/os.ts +29 -0
  22. package/cli/src/engine-native/parseArgs.ts +19 -17
  23. package/cli/src/engine-native/services.ts +96 -0
  24. package/cli/src/engine-native/skillsSync.ts +77 -61
  25. package/cli/src/engine-native/toolchain.ts +50 -68
  26. package/cli/src/engine.ts +11 -2
  27. package/cli/src/generated/sotPayload.ts +41 -0
  28. package/cli/src/kitHome.ts +15 -11
  29. package/cli/src/main.ts +3 -2
  30. package/cli/src/manifests.ts +17 -15
  31. package/cli/src/payload.ts +28 -0
  32. package/cli/src/services.ts +34 -0
  33. package/docks-kit +6 -6
  34. package/package.json +2 -3
  35. package/SoT/.agents/skills.txt +0 -14
  36. package/SoT/.claude/CLAUDE.md +0 -146
  37. package/SoT/.claude/fetch-usage.sh +0 -66
  38. package/SoT/.claude/hooks/notify.sh +0 -14
  39. package/SoT/.claude/mcp-servers.json +0 -10
  40. package/SoT/.claude/settings.json +0 -235
  41. package/SoT/.claude/statusline.sh +0 -175
  42. package/SoT/.codex/AGENTS.md +0 -75
  43. package/SoT/.codex/agents/.gitkeep +0 -1
  44. package/SoT/.codex/config.toml +0 -45
  45. package/SoT/.codex/plugins/marketplace.json +0 -50
  46. package/SoT/.codex/rules/docks.rules +0 -116
  47. package/SoT/models.json +0 -28
  48. package/SoT/toolchain.json +0 -27
  49. package/cli/src/engine-native/output.ts +0 -20
  50. package/notification.mp3 +0 -0
@@ -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, existsSync, readFileSync, 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,33 @@ 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 writeTextIfChanged(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
+ export function writeBytesIfChanged(path: string, content: Uint8Array): boolean {
79
+ const bytes = Buffer.from(content)
80
+ if (existsSync(path) && readFileSync(path).equals(bytes)) return false
81
+ writeFileSync(path, bytes)
82
+ return true
83
+ }
84
+
85
+ export function writeFileIfChanged(path: string, content: string): boolean {
86
+ return writeTextIfChanged(path, content)
87
+ }
@@ -6,15 +6,14 @@
6
6
  * vocabulary directly.
7
7
  */
8
8
  import { p } from "./exec"
9
- import { existsSync } from "node:fs"
10
9
  import { homedir } from "node:os"
11
10
 
12
11
  import { kitHome } from "../kitHome"
12
+ import { makeEngineServices, type EngineServices, type Logger } from "./services"
13
13
  import { claudeNextSteps, claudeSummary, claudeSync } from "./claudeSync"
14
14
  import { codexNextSteps, codexSummary, codexSync } from "./codexSync"
15
15
  import { skillsNextSteps, skillsSummary, skillsSync } from "./skillsSync"
16
16
  import { modeModel, modeToolchain } from "./modes"
17
- import { echo } from "./output"
18
17
  import { ExitError, parseArgs, preflight, validateModelFlags } from "./parseArgs"
19
18
 
20
19
  export interface Ctx {
@@ -22,6 +21,7 @@ export interface Ctx {
22
21
  readonly home: string
23
22
  readonly agentsDir: string
24
23
  dryRun: boolean
24
+ verbose: boolean
25
25
  skipRtk: boolean
26
26
  reconcile: boolean
27
27
  prune: boolean
@@ -31,14 +31,23 @@ export interface Ctx {
31
31
  claudePlugins: Array<string>
32
32
  claudeModel: string
33
33
  codexModel: string
34
+ /** Injected capability seam (logger/deps/platform) — see services.ts. */
35
+ readonly services: EngineServices
34
36
  targetFilterSet: boolean
35
37
  syncClaude: boolean
36
38
  syncCodex: boolean
37
39
  syncAgents: boolean
40
+ /** Per-run next-step triggers (Output Policy): advice prints only when its trigger changed or --verbose. */
41
+ readonly nextStepTriggers: {
42
+ claudePlugins: boolean
43
+ claudeRestart: boolean
44
+ codexRestart: boolean
45
+ skillsRestart: boolean
46
+ }
38
47
  }
39
48
 
40
49
  /** Globals default from env using the historical ${VAR:-default} contract. */
41
- function makeCtx(): Ctx {
50
+ function makeCtx(services: EngineServices): Ctx {
42
51
  const env = process.env
43
52
  const home = env["HOME"] !== undefined && env["HOME"] !== "" ? env["HOME"] : homedir()
44
53
  return {
@@ -46,6 +55,7 @@ function makeCtx(): Ctx {
46
55
  home,
47
56
  agentsDir: env["AGENTS_DIR"] !== undefined && env["AGENTS_DIR"] !== "" ? env["AGENTS_DIR"] : p(home, ".agents"),
48
57
  dryRun: env["DRY_RUN"] === "1",
58
+ verbose: env["DOCKS_KIT_VERBOSE"] === "1",
49
59
  skipRtk: env["SKIP_RTK"] === "1",
50
60
  reconcile: env["RECONCILE"] === "1",
51
61
  prune: env["PRUNE"] === "1",
@@ -55,25 +65,28 @@ function makeCtx(): Ctx {
55
65
  claudePlugins: (env["CLAUDE_PLUGINS"] ?? "").split(" ").filter((s) => s !== ""),
56
66
  claudeModel: env["CLAUDE_MODEL"] ?? "",
57
67
  codexModel: env["CODEX_MODEL"] ?? "",
68
+ services,
58
69
  targetFilterSet: false,
59
70
  syncClaude: false,
60
71
  syncCodex: false,
61
- syncAgents: false
72
+ syncAgents: false,
73
+ nextStepTriggers: { claudePlugins: false, claudeRestart: false, codexRestart: false, skillsRestart: false }
62
74
  }
63
75
  }
64
76
 
65
77
  function engineSync(ctx: Ctx, args: ReadonlyArray<string>): number {
78
+ const { echo } = ctx.services.logger
66
79
  parseArgs(ctx, args)
67
80
  preflight(ctx)
68
81
  validateModelFlags(ctx)
69
82
 
70
- const claudeRan = ctx.syncClaude && existsSync(p(ctx.repoDir, "SoT", ".claude"))
83
+ const claudeRan = ctx.syncClaude
71
84
  if (claudeRan) claudeSync(ctx)
72
85
 
73
- const codexRan = ctx.syncCodex && existsSync(p(ctx.repoDir, "SoT", ".codex"))
86
+ const codexRan = ctx.syncCodex
74
87
  if (codexRan) codexSync(ctx)
75
88
 
76
- const skillsState = ctx.syncAgents && existsSync(p(ctx.repoDir, "SoT", ".agents")) ? skillsSync(ctx) : undefined
89
+ const skillsState = ctx.syncAgents ? skillsSync(ctx) : undefined
77
90
 
78
91
  echo("")
79
92
  echo("--- Sync complete ---")
@@ -82,15 +95,37 @@ function engineSync(ctx: Ctx, args: ReadonlyArray<string>): number {
82
95
  if (codexRan) codexSummary(ctx)
83
96
  if (skillsState !== undefined) skillsSummary(ctx, skillsState)
84
97
 
85
- echo("")
86
- if (claudeRan) claudeNextSteps()
87
- if (codexRan) codexNextSteps()
88
- if (skillsState !== undefined) skillsNextSteps()
98
+ const advice = [
99
+ ...(claudeRan ? claudeNextSteps(ctx) : []),
100
+ ...(codexRan ? codexNextSteps(ctx) : []),
101
+ ...(skillsState !== undefined ? skillsNextSteps(ctx) : [])
102
+ ]
103
+ if (advice.length > 0) {
104
+ echo("")
105
+ for (const line of advice) echo(line)
106
+ }
89
107
  return 0
90
108
  }
91
109
 
92
- export function runEngineNative(argv: ReadonlyArray<string>): number {
93
- const ctx = makeCtx()
110
+ export function runEngineNative(argv: ReadonlyArray<string>, services?: EngineServices): number {
111
+ let ctx!: Ctx
112
+ const baseServices = services ?? makeEngineServices()
113
+ const baseLogger = baseServices.logger
114
+ const logger: Logger = {
115
+ change: (msg) => baseLogger.change(msg),
116
+ verbose: (msg) => {
117
+ if (ctx.verbose) baseLogger.verbose(msg)
118
+ },
119
+ warn: (msg) => baseLogger.warn(msg),
120
+ err: (msg) => baseLogger.err(msg),
121
+ echo: (line) => baseLogger.echo(line)
122
+ }
123
+ const runServices: EngineServices = {
124
+ logger,
125
+ deps: baseServices.deps,
126
+ platform: baseServices.platform
127
+ }
128
+ ctx = makeCtx(runServices)
94
129
  try {
95
130
  switch (argv[0]) {
96
131
  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
+ }
@@ -2,70 +2,69 @@
2
2
  * Model-catalog helpers: manifest listing plus Claude/Codex model validation.
3
3
  * Message strings are covered by the golden suites.
4
4
  */
5
- import { p } from "./exec"
6
- import { readFileSync } from "node:fs"
7
-
5
+ import type { Ctx } from "./index"
8
6
  import { isObject, parseJson, type Json } from "./jq"
9
- import { warn } from "./output"
7
+ import { payloadDisplayPath, payloadText } from "../payload"
10
8
 
11
- function catalog(repoDir: string): Json | undefined {
9
+ function catalog(): Json | undefined {
12
10
  try {
13
- return parseJson(readFileSync(p(repoDir, "SoT", "models.json"), "utf8"))
11
+ return parseJson(payloadText("SoT/models.json"))
14
12
  } catch {
15
13
  return undefined
16
14
  }
17
15
  }
18
16
 
19
- function toolEntry(repoDir: string, tool: string): { [k: string]: Json } | undefined {
20
- const doc = catalog(repoDir)
17
+ function toolEntry(tool: string): { [k: string]: Json } | undefined {
18
+ const doc = catalog()
21
19
  if (doc === undefined || !isObject(doc)) return undefined
22
20
  const entry = doc[tool]
23
21
  return entry !== undefined && isObject(entry) ? entry : undefined
24
22
  }
25
23
 
26
- function modelEntries(repoDir: string, tool: string): Array<{ [k: string]: Json }> {
27
- const entry = toolEntry(repoDir, tool)
24
+ function modelEntries(tool: string): Array<{ [k: string]: Json }> {
25
+ const entry = toolEntry(tool)
28
26
  const models = entry?.["models"]
29
27
  return Array.isArray(models) ? models.filter(isObject) : []
30
28
  }
31
29
 
32
- export function modelsFromManifest(repoDir: string, tool: string): Array<string> {
33
- return modelEntries(repoDir, tool)
30
+ export function modelsFromManifest(tool: string): Array<string> {
31
+ return modelEntries(tool)
34
32
  .map((m) => m["id"])
35
33
  .filter((id): id is string => typeof id === "string")
36
34
  }
37
35
 
38
- export function printModels(repoDir: string, tool: string): void {
39
- const entry = toolEntry(repoDir, tool)
36
+ export function printModels(ctx: Ctx, tool: string): void {
37
+ const { echo, warn } = ctx.services.logger
38
+ const entry = toolEntry(tool)
40
39
  if (entry === undefined) {
41
- warn(`Model catalog unavailable (${p(repoDir, "SoT", "models.json")})`)
40
+ warn(`Model catalog unavailable (${payloadDisplayPath("SoT/models.json", ctx.repoDir)})`)
42
41
  return
43
42
  }
44
43
  const verified = typeof entry["verified"] === "string" ? entry["verified"] : "?"
45
44
  const lines = [`Available ${tool} models (kit-verified ${verified} — SoT/models.json):`]
46
- for (const m of modelEntries(repoDir, tool)) {
45
+ for (const m of modelEntries(tool)) {
47
46
  const note = typeof m["note"] === "string" ? ` — ${m["note"]}` : ""
48
47
  lines.push(` ${String(m["id"] ?? "")}${note}`)
49
48
  }
50
49
  if (tool === "claude") lines.push(" (full claude-* model IDs outside the catalog are accepted with a warning)")
51
50
  if (tool === "codex") lines.push(" (well-formed IDs outside the catalog are accepted with a warning)")
52
- process.stderr.write(`${lines.join("\n")}\n`)
51
+ for (const line of lines) echo(line)
53
52
  }
54
53
 
55
- export function validateClaudeModel(repoDir: string, m: string): boolean {
54
+ export function validateClaudeModel(ctx: Ctx, m: string): boolean {
56
55
  if (m === "") return false
57
- if (modelsFromManifest(repoDir, "claude").includes(m)) return true
56
+ if (modelsFromManifest("claude").includes(m)) return true
58
57
  if (m.startsWith("claude-")) {
59
- warn(`Claude model '${m}' is not in the kit-verified catalog (SoT/models.json) — applying anyway`)
58
+ ctx.services.logger.warn(`Claude model '${m}' is not in the kit-verified catalog (SoT/models.json) — applying anyway`)
60
59
  return true
61
60
  }
62
61
  return false
63
62
  }
64
63
 
65
- export function validateCodexModel(repoDir: string, m: string): boolean {
64
+ export function validateCodexModel(ctx: Ctx, m: string): boolean {
66
65
  if (!/^[A-Za-z0-9._-]+$/.test(m)) return false
67
- if (!modelsFromManifest(repoDir, "codex").includes(m)) {
68
- warn(
66
+ if (!modelsFromManifest("codex").includes(m)) {
67
+ ctx.services.logger.warn(
69
68
  `Codex model '${m}' is not in the kit-verified catalog (SoT/models.json) — applying anyway (check ~/.codex/config.toml if Codex rejects it)`
70
69
  )
71
70
  }