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.
- package/README.md +1 -1
- package/SoT/.codex/config.toml +1 -1
- package/SoT/models.json +5 -2
- package/cli/docs/flags.md +1 -0
- package/cli/docs/overview.md +3 -0
- package/cli/docs/platforms.md +6 -5
- package/cli/src/commands/model.ts +7 -3
- package/cli/src/commands/sync.ts +14 -3
- package/cli/src/commands/toolchain.ts +7 -3
- package/cli/src/engine-native/DESIGN.md +75 -2
- package/cli/src/engine-native/claudeModel.ts +9 -3
- package/cli/src/engine-native/claudeSync.ts +203 -97
- package/cli/src/engine-native/codexSync.ts +95 -47
- package/cli/src/engine-native/codexToml.ts +12 -5
- package/cli/src/engine-native/deps.ts +325 -0
- package/cli/src/engine-native/exec.ts +50 -2
- package/cli/src/engine-native/index.ts +45 -9
- package/cli/src/engine-native/logger.ts +35 -0
- package/cli/src/engine-native/models.ts +13 -12
- package/cli/src/engine-native/modes.ts +17 -10
- package/cli/src/engine-native/os.ts +29 -0
- package/cli/src/engine-native/parseArgs.ts +19 -17
- package/cli/src/engine-native/services.ts +96 -0
- package/cli/src/engine-native/skillsSync.ts +70 -51
- package/cli/src/engine-native/toolchain.ts +45 -63
- package/cli/src/engine.ts +11 -2
- package/cli/src/main.ts +3 -2
- package/cli/src/services.ts +34 -0
- package/package.json +1 -1
- package/cli/src/engine-native/output.ts +0 -20
|
@@ -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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
|
93
|
-
|
|
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(
|
|
39
|
-
const
|
|
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
|
-
|
|
53
|
+
for (const line of lines) echo(line)
|
|
53
54
|
}
|
|
54
55
|
|
|
55
|
-
export function validateClaudeModel(
|
|
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(
|
|
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 === "
|
|
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
|
|
54
|
+
printModels(ctx, tool)
|
|
53
55
|
return 0
|
|
54
56
|
}
|
|
55
57
|
|
|
56
58
|
if (tool === "claude") {
|
|
57
|
-
if (!validateClaudeModel(ctx
|
|
58
|
-
printModels(ctx
|
|
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
|
|
65
|
-
printModels(ctx
|
|
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
|
|
102
|
-
const
|
|
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":
|