docks-kit 0.1.0 → 0.1.1
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/AGENTS.md +14 -15
- package/README.md +12 -11
- package/SoT/.agents/skills.txt +3 -3
- package/SoT/.claude/settings.json +3 -26
- package/SoT/models.json +1 -1
- package/SoT/toolchain.json +3 -3
- package/cli/docs/install.md +39 -12
- package/cli/docs/overview.md +3 -4
- package/cli/docs/platforms.md +35 -22
- package/cli/src/commands/sync.ts +32 -4
- package/cli/src/commands/update.ts +116 -0
- package/cli/src/engine-native/DESIGN.md +89 -0
- package/cli/src/engine-native/claudeModel.ts +48 -0
- package/cli/src/engine-native/claudeSync.ts +746 -0
- package/cli/src/engine-native/codexSync.ts +439 -0
- package/cli/src/engine-native/codexToml.ts +67 -0
- package/cli/src/engine-native/exec.ts +54 -0
- package/cli/src/engine-native/index.ts +109 -0
- package/cli/src/engine-native/jq.ts +63 -0
- package/cli/src/engine-native/models.ts +73 -0
- package/cli/src/engine-native/modes.ts +133 -0
- package/cli/src/engine-native/output.ts +20 -0
- package/cli/src/engine-native/parseArgs.ts +218 -0
- package/cli/src/engine-native/settings.ts +41 -0
- package/cli/src/engine-native/skillsSync.ts +369 -0
- package/cli/src/engine-native/toolchain.ts +257 -0
- package/cli/src/engine.ts +35 -18
- package/cli/src/kitHome.ts +4 -4
- package/cli/src/main.ts +14 -1
- package/cli/src/manifests.ts +3 -2
- package/cli/tsconfig.json +1 -1
- package/docks-kit +6 -3
- package/package.json +8 -4
- package/lib/claude.sh +0 -846
- package/lib/codex.sh +0 -518
- package/lib/common.sh +0 -229
- package/lib/engine.sh +0 -153
- package/lib/skills.sh +0 -373
- package/lib/toolchain.sh +0 -222
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* EngineNative `sync agents` pipeline: universal-skill bootstrap
|
|
3
|
+
* (`npx skills@<pin> add`), Claude symlink healing, --prune reconcile against
|
|
4
|
+
* the kit-managed snapshot, agent-browser/effect-solutions toolchain callbacks,
|
|
5
|
+
* and the snapshot write.
|
|
6
|
+
*/
|
|
7
|
+
import { spawnSync } from "node:child_process"
|
|
8
|
+
import { cpSync, existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs"
|
|
9
|
+
import { tmpdir } from "node:os"
|
|
10
|
+
import { capture, commandExists, isExecutable, p, which } from "./exec"
|
|
11
|
+
import type { Ctx } from "./index"
|
|
12
|
+
import { compareCodepoints } from "./jq"
|
|
13
|
+
import { echo, log, warn } from "./output"
|
|
14
|
+
import { ensure, field } from "./toolchain"
|
|
15
|
+
|
|
16
|
+
export interface SkillsState {
|
|
17
|
+
present: number
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function skillsSync(ctx: Ctx): SkillsState {
|
|
21
|
+
const state: SkillsState = { present: 0 }
|
|
22
|
+
const skillsDir = p(ctx.agentsDir, "skills")
|
|
23
|
+
const manifest = p(ctx.repoDir, "SoT", ".agents", "skills.txt")
|
|
24
|
+
const snapshot = p(ctx.agentsDir, ".kit-managed-skills")
|
|
25
|
+
|
|
26
|
+
if (!existsSync(manifest)) return state
|
|
27
|
+
|
|
28
|
+
if (!ctx.dryRun) mkdirSync(skillsDir, { recursive: true })
|
|
29
|
+
|
|
30
|
+
syncUniversal(ctx, state, skillsDir, manifest)
|
|
31
|
+
if (ctx.prune) reconcileRemovals(ctx, manifest, snapshot)
|
|
32
|
+
syncAgentBrowserCli(ctx, manifest)
|
|
33
|
+
syncEffectSolutionsCli(ctx)
|
|
34
|
+
updateSnapshot(ctx, manifest, snapshot)
|
|
35
|
+
return state
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** skills::_skills_cli — the pinned npx package spec. */
|
|
39
|
+
function skillsCli(ctx: Ctx): string {
|
|
40
|
+
const v = field(ctx, "skills-cli", "verified")
|
|
41
|
+
return v !== "" ? `skills@${v}` : "skills"
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** skills::_normalize_manifest — cleaned slugs, one per line. */
|
|
45
|
+
export function normalizeManifest(content: string): Array<string> {
|
|
46
|
+
const out: Array<string> = []
|
|
47
|
+
for (const line of content.split("\n")) {
|
|
48
|
+
if (/^[ \t]*#/.test(line)) continue
|
|
49
|
+
if (/^[ \t]*$/.test(line)) continue
|
|
50
|
+
const cleaned = line.replace(/[ \t]*#.*$/, "").replace(/[ \t\r]+/g, "")
|
|
51
|
+
if (cleaned.length > 0) out.push(cleaned)
|
|
52
|
+
}
|
|
53
|
+
return out
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function readSlugs(file: string): Array<string> {
|
|
57
|
+
return existsSync(file) ? normalizeManifest(readFileSync(file, "utf8")) : []
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function syncUniversal(ctx: Ctx, state: SkillsState, skillsDir: string, manifest: string): void {
|
|
61
|
+
if (!commandExists("node")) {
|
|
62
|
+
warn("node/npx not in PATH — skipping universal skills bootstrap (install Node.js to enable)")
|
|
63
|
+
return
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
let added = 0
|
|
67
|
+
let already = 0
|
|
68
|
+
let failed = 0
|
|
69
|
+
let healed = 0
|
|
70
|
+
|
|
71
|
+
for (const slug of readSlugs(manifest)) {
|
|
72
|
+
const base = slug.slice(slug.lastIndexOf("/") + 1)
|
|
73
|
+
|
|
74
|
+
if (ctx.dryRun) {
|
|
75
|
+
if (isDir(p(skillsDir, base))) {
|
|
76
|
+
echo(`[dry-run] universal skill present: ${base}`)
|
|
77
|
+
healClaudeSymlink(ctx, skillsDir, base)
|
|
78
|
+
} else {
|
|
79
|
+
echo(`[dry-run] npx ${skillsCli(ctx)} add ${slug} -g -y -a claude-code codex`)
|
|
80
|
+
}
|
|
81
|
+
continue
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (isDir(p(skillsDir, base))) {
|
|
85
|
+
already++
|
|
86
|
+
if (healClaudeSymlink(ctx, skillsDir, base)) healed++
|
|
87
|
+
continue
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const res = spawnSync("npx", ["--yes", skillsCli(ctx), "add", slug, "-g", "-y", "-a", "claude-code", "codex"], {
|
|
91
|
+
stdio: "ignore"
|
|
92
|
+
})
|
|
93
|
+
if (res.error === undefined && res.status === 0) {
|
|
94
|
+
added++
|
|
95
|
+
} else {
|
|
96
|
+
warn(`Failed to install universal skill: ${slug}`)
|
|
97
|
+
failed++
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (ctx.dryRun) return
|
|
102
|
+
|
|
103
|
+
state.present = added + already
|
|
104
|
+
|
|
105
|
+
if (added > 0) {
|
|
106
|
+
log(`Universal skills synced (+${added} new, ${already} already present)`)
|
|
107
|
+
} else {
|
|
108
|
+
log(`Universal skills already in sync (${already} present)`)
|
|
109
|
+
}
|
|
110
|
+
if (healed > 0) {
|
|
111
|
+
log(`Claude per-tool symlinks healed (+${healed}) — canonical present, ~/.claude/skills/<name> was missing or broken`)
|
|
112
|
+
}
|
|
113
|
+
if (failed > 0) {
|
|
114
|
+
warn(`${failed} skill install(s) failed — re-run sync or install manually with: npx skills add <slug> -g -y -a claude-code codex`)
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function isDir(path: string): boolean {
|
|
119
|
+
try {
|
|
120
|
+
return statSync(path).isDirectory()
|
|
121
|
+
} catch {
|
|
122
|
+
return false
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** skills::heal_claude_symlink — true when a heal occurred. */
|
|
127
|
+
function healClaudeSymlink(ctx: Ctx, skillsDir: string, base: string): boolean {
|
|
128
|
+
const canonical = p(skillsDir, base)
|
|
129
|
+
const claudeSkillsDir = p(ctx.home, ".claude", "skills")
|
|
130
|
+
const claudeLink = p(claudeSkillsDir, base)
|
|
131
|
+
const relTarget = `../../.agents/skills/${base}`
|
|
132
|
+
|
|
133
|
+
if (!isDir(canonical)) return false
|
|
134
|
+
|
|
135
|
+
const linkStat = lstat(claudeLink)
|
|
136
|
+
if (linkStat?.isSymbolicLink() === true) {
|
|
137
|
+
const current = safeReadlink(claudeLink)
|
|
138
|
+
if (current === relTarget) return false
|
|
139
|
+
if (ctx.dryRun) {
|
|
140
|
+
echo(`[dry-run] would replace stale Claude symlink: ~/.claude/skills/${base} -> ${current} (correct: ${relTarget})`)
|
|
141
|
+
return true
|
|
142
|
+
}
|
|
143
|
+
rmSync(claudeLink, { force: true })
|
|
144
|
+
} else if (linkStat !== undefined) {
|
|
145
|
+
warn(`~/.claude/skills/${base} exists as a real path (not a symlink) — leaving alone; remove manually if it's stale`)
|
|
146
|
+
return false
|
|
147
|
+
} else if (ctx.dryRun) {
|
|
148
|
+
echo(`[dry-run] would create missing Claude symlink: ~/.claude/skills/${base} -> ${relTarget}`)
|
|
149
|
+
return true
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
mkdirSync(claudeSkillsDir, { recursive: true })
|
|
153
|
+
return linkOrCopy(relTarget, claudeLink)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function lstat(path: string): ReturnType<typeof lstatSync> | undefined {
|
|
157
|
+
try {
|
|
158
|
+
return lstatSync(path)
|
|
159
|
+
} catch {
|
|
160
|
+
return undefined
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function safeReadlink(path: string): string {
|
|
165
|
+
try {
|
|
166
|
+
return readlinkSync(path)
|
|
167
|
+
} catch {
|
|
168
|
+
return ""
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** skills::_link_or_copy — real symlink preferred, copy fallback (Windows). */
|
|
173
|
+
export function linkOrCopy(target: string, link: string): boolean {
|
|
174
|
+
rmSync(link, { force: true })
|
|
175
|
+
try {
|
|
176
|
+
symlinkSync(target, link, process.platform === "win32" ? "dir" : undefined)
|
|
177
|
+
} catch {
|
|
178
|
+
// fall through to the copy fallback below
|
|
179
|
+
}
|
|
180
|
+
if (lstat(link)?.isSymbolicLink() === true) return true
|
|
181
|
+
try {
|
|
182
|
+
// Resolve a relative target against the link's parent, like ln does.
|
|
183
|
+
const resolved = target.startsWith("/") ? target : p(link.slice(0, link.lastIndexOf("/")), target)
|
|
184
|
+
cpSync(resolved, link, { recursive: true })
|
|
185
|
+
} catch {
|
|
186
|
+
// fall through to the existence check below
|
|
187
|
+
}
|
|
188
|
+
if (existsSync(link)) {
|
|
189
|
+
warn(`symlinks unsupported here — ${link} is a copy (refreshed on sync; enable Windows Developer Mode for real links)`)
|
|
190
|
+
return true
|
|
191
|
+
}
|
|
192
|
+
warn(`could not create ${link} (symlink and copy both failed)`)
|
|
193
|
+
return false
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// ------------------------------------------------- toolchain callbacks ----
|
|
197
|
+
|
|
198
|
+
/** skills::_agent_browser_install. */
|
|
199
|
+
export function agentBrowserInstall(mode: "install" | "upgrade", version: string): number {
|
|
200
|
+
const verb = mode === "upgrade" ? "Upgrading" : "Installing"
|
|
201
|
+
const pkg = version !== "" ? `agent-browser@${version}` : "agent-browser"
|
|
202
|
+
const installFlags = process.platform === "linux" ? ["--with-deps"] : []
|
|
203
|
+
|
|
204
|
+
log(`${verb} agent-browser CLI via npm${version !== "" ? ` (pinned ${version})` : ""}...`)
|
|
205
|
+
if (spawnSync("npm", ["install", "-g", pkg], { stdio: "ignore" }).status !== 0) {
|
|
206
|
+
warn(`npm install -g ${pkg} failed. Try manually: npm install -g ${pkg}`)
|
|
207
|
+
return 1
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (mode === "install") {
|
|
211
|
+
log("Downloading Chrome for Testing (~175 MB; sudo may be requested for system libs on Linux)...")
|
|
212
|
+
if (spawnSync("agent-browser", ["install", ...installFlags], { stdio: "inherit" }).status !== 0) {
|
|
213
|
+
warn(`agent-browser install failed. Re-run manually: agent-browser install ${installFlags.join(" ")}`)
|
|
214
|
+
return 1
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
const out = capture("agent-browser", ["--version"])
|
|
218
|
+
const fields = (out.split("\n")[0] ?? "").trim().split(/[ \t]+/)
|
|
219
|
+
const version2 = out !== "" ? fields[fields.length - 1] ?? "version unknown" : "version unknown"
|
|
220
|
+
log(`agent-browser CLI ready (${version2})`)
|
|
221
|
+
return 0
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function syncAgentBrowserCli(ctx: Ctx, manifest: string): void {
|
|
225
|
+
if (!existsSync(manifest) || !readFileSync(manifest, "utf8").split("\n").includes("vercel-labs/agent-browser")) return
|
|
226
|
+
|
|
227
|
+
if (!commandExists("npm")) {
|
|
228
|
+
if (!ctx.dryRun) warn("npm not found — cannot auto-install agent-browser CLI. Install Node.js, then re-run sync.")
|
|
229
|
+
return
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (ensure(ctx, "agent-browser", agentBrowserInstall) !== 0) {
|
|
233
|
+
warn("agent-browser bootstrap failed — continuing sync")
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** skills::_find_bun — resolved bun path or "". */
|
|
238
|
+
function findBun(ctx: Ctx): string {
|
|
239
|
+
const onPath = which("bun")
|
|
240
|
+
if (onPath !== "") return onPath
|
|
241
|
+
const bunInstall = process.env["BUN_INSTALL"] !== undefined && process.env["BUN_INSTALL"] !== "" ? process.env["BUN_INSTALL"] : p(ctx.home, ".bun")
|
|
242
|
+
for (const cand of [p(bunInstall, "bin", "bun"), p(ctx.home, ".bun", "bin", "bun")]) {
|
|
243
|
+
if (isExecutable(cand)) return cand
|
|
244
|
+
}
|
|
245
|
+
return ""
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** skills::_bun_bootstrap — bun path or "" after a failed bootstrap. */
|
|
249
|
+
export function bunBootstrap(ctx: Ctx): string {
|
|
250
|
+
let bun = findBun(ctx)
|
|
251
|
+
if (bun !== "") return bun
|
|
252
|
+
|
|
253
|
+
if (!commandExists("curl")) {
|
|
254
|
+
warn("Bun and curl both missing — cannot bootstrap Bun. Install Bun manually, then re-run sync.")
|
|
255
|
+
return ""
|
|
256
|
+
}
|
|
257
|
+
const pin = field(ctx, "bun", "verified")
|
|
258
|
+
warn(`Bun not found — installing Bun${pin !== "" ? ` ${pin} (kit-verified)` : ""}...`)
|
|
259
|
+
const installer = p(tmpdir(), `bun-install-${process.pid}.sh`)
|
|
260
|
+
const dl = spawnSync("curl", ["-fsSL", "https://bun.sh/install", "-o", installer], { stdio: "ignore" })
|
|
261
|
+
if (dl.error === undefined && dl.status === 0) {
|
|
262
|
+
spawnSync("bash", [installer, ...(pin !== "" ? [`bun-v${pin}`] : [])], { stdio: "ignore" })
|
|
263
|
+
}
|
|
264
|
+
rmSync(installer, { force: true })
|
|
265
|
+
bun = findBun(ctx)
|
|
266
|
+
if (bun === "") {
|
|
267
|
+
warn("Bun install failed. Install manually: curl -fsSL https://bun.sh/install -o /tmp/bun.sh && bash /tmp/bun.sh")
|
|
268
|
+
return ""
|
|
269
|
+
}
|
|
270
|
+
const v = capture(bun, ["--version"])
|
|
271
|
+
log(`Bun installed (${v !== "" ? v : "version unknown"})`)
|
|
272
|
+
return bun
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** skills::_effect_solutions_install. */
|
|
276
|
+
export function effectSolutionsInstall(ctx: Ctx): (mode: "install" | "upgrade", version: string) => number {
|
|
277
|
+
return (mode, version) => {
|
|
278
|
+
const verb = mode === "upgrade" ? "Upgrading" : "Installing"
|
|
279
|
+
const pkg = `effect-solutions@${version !== "" ? version : "latest"}`
|
|
280
|
+
|
|
281
|
+
const bun = bunBootstrap(ctx)
|
|
282
|
+
if (bun === "") return 1
|
|
283
|
+
|
|
284
|
+
log(`${verb} effect-solutions CLI via bun${version !== "" ? ` (pinned ${version})` : ""}...`)
|
|
285
|
+
if (spawnSync(bun, ["add", "-g", pkg], { stdio: "ignore" }).status !== 0) {
|
|
286
|
+
warn(`bun add -g ${pkg} failed. Try manually: bun add -g ${pkg}`)
|
|
287
|
+
return 1
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const gbin = capture(bun, ["pm", "-g", "bin"])
|
|
291
|
+
if (gbin !== "" && isExecutable(p(gbin, "effect-solutions"))) {
|
|
292
|
+
mkdirSync(p(ctx.home, ".local", "bin"), { recursive: true })
|
|
293
|
+
linkOrCopy(bun, p(ctx.home, ".local", "bin", "bun"))
|
|
294
|
+
linkOrCopy(p(gbin, "effect-solutions"), p(ctx.home, ".local", "bin", "effect-solutions"))
|
|
295
|
+
log("effect-solutions CLI ready (linked bun + effect-solutions into ~/.local/bin)")
|
|
296
|
+
} else {
|
|
297
|
+
warn(`effect-solutions installed but binary not found under '${gbin !== "" ? gbin : "<unknown>"}' — link it onto PATH manually`)
|
|
298
|
+
}
|
|
299
|
+
return 0
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function syncEffectSolutionsCli(ctx: Ctx): void {
|
|
304
|
+
const settings = p(ctx.repoDir, "SoT", ".claude", "settings.json")
|
|
305
|
+
if (!existsSync(settings)) return
|
|
306
|
+
if (!/"effect-kit@docks"[ \t]*:[ \t]*true/.test(readFileSync(settings, "utf8"))) return
|
|
307
|
+
|
|
308
|
+
if (ensure(ctx, "effect-solutions", effectSolutionsInstall(ctx)) !== 0) {
|
|
309
|
+
warn("effect-solutions bootstrap failed — continuing sync")
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// ----------------------------------------------------- prune + snapshot ----
|
|
314
|
+
|
|
315
|
+
function reconcileRemovals(ctx: Ctx, manifest: string, snapshot: string): void {
|
|
316
|
+
if (!existsSync(snapshot)) {
|
|
317
|
+
if (ctx.dryRun) {
|
|
318
|
+
echo(
|
|
319
|
+
`[dry-run] (--prune) no kit-managed-skills snapshot yet; first real sync writes ${snapshot}, then future --prune runs reconcile against it`
|
|
320
|
+
)
|
|
321
|
+
}
|
|
322
|
+
return
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const current = readSlugs(manifest)
|
|
326
|
+
let removed = 0
|
|
327
|
+
let failed = 0
|
|
328
|
+
for (const slug of readSlugs(snapshot)) {
|
|
329
|
+
if (current.includes(slug)) continue
|
|
330
|
+
const base = slug.slice(slug.lastIndexOf("/") + 1)
|
|
331
|
+
if (ctx.dryRun) {
|
|
332
|
+
echo(`[dry-run] kit-managed skill no longer in SoT — would remove: ${base}`)
|
|
333
|
+
continue
|
|
334
|
+
}
|
|
335
|
+
const res = spawnSync("npx", ["--yes", skillsCli(ctx), "remove", "--global", "-y", "-a", "*", "-s", base], {
|
|
336
|
+
stdio: "ignore"
|
|
337
|
+
})
|
|
338
|
+
if (res.error === undefined && res.status === 0) {
|
|
339
|
+
removed++
|
|
340
|
+
} else {
|
|
341
|
+
warn(`Failed to remove kit-managed skill: ${base}`)
|
|
342
|
+
failed++
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
if (removed > 0) log(`Kit-managed skills removed (-${removed})`)
|
|
347
|
+
if (failed > 0) warn(`${failed} skill remove(s) failed — re-run with --prune or run: npx skills remove -g -y -a '*' -s <name>`)
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function updateSnapshot(ctx: Ctx, manifest: string, snapshot: string): void {
|
|
351
|
+
if (ctx.dryRun) return
|
|
352
|
+
|
|
353
|
+
mkdirSync(ctx.agentsDir, { recursive: true })
|
|
354
|
+
const sorted = [...new Set(readSlugs(manifest))].sort(compareCodepoints)
|
|
355
|
+
writeFileSync(snapshot, sorted.length > 0 ? `${sorted.join("\n")}\n` : "")
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// -------------------------------------------------------------- summary ----
|
|
359
|
+
|
|
360
|
+
export function skillsSummary(ctx: Ctx, state: SkillsState): void {
|
|
361
|
+
echo(`Skills: ${p(ctx.agentsDir, "skills")}`)
|
|
362
|
+
if (!ctx.dryRun) {
|
|
363
|
+
echo(` ${state.present} universal skill(s) installed`)
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
export function skillsNextSteps(): void {
|
|
368
|
+
echo("Restart Claude Code (and Codex) to discover newly installed universal skills.")
|
|
369
|
+
}
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Verified-version-floor layer over SoT/toolchain.json. Probe/install commands
|
|
3
|
+
* spawn deterministic argv arrays and are covered by golden regression cases.
|
|
4
|
+
*/
|
|
5
|
+
import { readFileSync, readSync } from "node:fs"
|
|
6
|
+
|
|
7
|
+
import { capture, commandExists, isExecutable, p } from "./exec"
|
|
8
|
+
import type { Ctx } from "./index"
|
|
9
|
+
import { compareCodepoints, isObject, parseJson, type Json } from "./jq"
|
|
10
|
+
import { echo, log, warn } from "./output"
|
|
11
|
+
|
|
12
|
+
type InstallFn = (mode: "install" | "upgrade", version: string) => number
|
|
13
|
+
|
|
14
|
+
function manifest(ctx: Ctx): { [k: string]: Json } {
|
|
15
|
+
const doc = parseJson(readFileSync(p(ctx.repoDir, "SoT", "toolchain.json"), "utf8"))
|
|
16
|
+
const tools = doc !== undefined && isObject(doc) ? doc["tools"] : undefined
|
|
17
|
+
return tools !== undefined && isObject(tools) ? tools : {}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function field(ctx: Ctx, tool: string, name: string): string {
|
|
21
|
+
const entry = manifest(ctx)[tool]
|
|
22
|
+
if (entry === undefined || !isObject(entry)) return ""
|
|
23
|
+
const v = entry[name]
|
|
24
|
+
return v === undefined || v === null ? "" : String(v)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** toolchain::_is_newer — numeric per dotted field, GNU-sort last-resort tie-break. */
|
|
28
|
+
export function isNewer(a: string, b: string): boolean {
|
|
29
|
+
if (a === "" || b === "" || a === b) return false
|
|
30
|
+
const fa = a.split(".")
|
|
31
|
+
const fb = b.split(".")
|
|
32
|
+
for (let i = 0; i < 3; i++) {
|
|
33
|
+
const na = parseInt(fa[i] ?? "", 10) || 0
|
|
34
|
+
const nb = parseInt(fb[i] ?? "", 10) || 0
|
|
35
|
+
if (na !== nb) return na > nb
|
|
36
|
+
}
|
|
37
|
+
return compareCodepoints(a, b) > 0
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function present(ctx: Ctx, tool: string): boolean {
|
|
41
|
+
if (tool === "bun") {
|
|
42
|
+
return (
|
|
43
|
+
commandExists("bun") ||
|
|
44
|
+
isExecutable(p(process.env["BUN_INSTALL"] ?? p(ctx.home, ".bun"), "bin", "bun")) ||
|
|
45
|
+
isExecutable(p(ctx.home, ".bun", "bin", "bun"))
|
|
46
|
+
)
|
|
47
|
+
}
|
|
48
|
+
return commandExists(tool)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function firstLineField(out: string, index: number): string {
|
|
52
|
+
const fields = (out.split("\n")[0] ?? "").trim().split(/[ \t]+/)
|
|
53
|
+
return fields[index === -1 ? fields.length - 1 : index] ?? ""
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function installedVersion(ctx: Ctx, tool: string): string {
|
|
57
|
+
if (!present(ctx, tool)) return ""
|
|
58
|
+
switch (tool) {
|
|
59
|
+
case "rtk":
|
|
60
|
+
return firstLineField(capture("rtk", ["--version"]), 1)
|
|
61
|
+
case "claude":
|
|
62
|
+
return firstLineField(capture("claude", ["--version"]), 0)
|
|
63
|
+
case "codex":
|
|
64
|
+
return firstLineField(capture("codex", ["--version"]), -1)
|
|
65
|
+
case "bun":
|
|
66
|
+
return commandExists("bun") ? capture("bun", ["--version"]) : capture(p(ctx.home, ".bun", "bin", "bun"), ["--version"])
|
|
67
|
+
case "agent-browser":
|
|
68
|
+
return firstLineField(capture("agent-browser", ["--version"]), -1)
|
|
69
|
+
case "effect-solutions": {
|
|
70
|
+
const bunbin = commandExists("bun") ? "bun" : p(ctx.home, ".bun", "bin", "bun")
|
|
71
|
+
if (bunbin !== "bun" && !isExecutable(bunbin)) return ""
|
|
72
|
+
const m = /effect-solutions@([0-9][0-9.]*)/.exec(capture(bunbin, ["pm", "-g", "ls"]))
|
|
73
|
+
return m?.[1] ?? ""
|
|
74
|
+
}
|
|
75
|
+
case "node":
|
|
76
|
+
return capture("node", ["--version"]).replace(/^v/, "")
|
|
77
|
+
case "npm":
|
|
78
|
+
return capture("npm", ["--version"])
|
|
79
|
+
case "jq":
|
|
80
|
+
return capture("jq", ["--version"]).replace(/^jq-/, "")
|
|
81
|
+
case "curl":
|
|
82
|
+
return firstLineField(capture("curl", ["--version"]), 1)
|
|
83
|
+
case "tsc":
|
|
84
|
+
return firstLineField(capture("tsc", ["--version"]), 1)
|
|
85
|
+
default:
|
|
86
|
+
return ""
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function latestVersion(tool: string): string {
|
|
91
|
+
switch (tool) {
|
|
92
|
+
case "rtk": {
|
|
93
|
+
const body = capture("curl", ["-fsSL", "--max-time", "5", "https://api.github.com/repos/rtk-ai/rtk/releases/latest"])
|
|
94
|
+
const doc = parseJson(body)
|
|
95
|
+
const tag = doc !== undefined && isObject(doc) && typeof doc["tag_name"] === "string" ? doc["tag_name"] : ""
|
|
96
|
+
return tag.replace(/^v/, "")
|
|
97
|
+
}
|
|
98
|
+
case "agent-browser":
|
|
99
|
+
case "effect-solutions":
|
|
100
|
+
return commandExists("npm") ? capture("npm", ["view", tool, "version"]) : ""
|
|
101
|
+
default:
|
|
102
|
+
return ""
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Blocking TTY prompt matching bash `read -r -p` (prompt on stderr). */
|
|
107
|
+
function promptLine(prompt: string): string {
|
|
108
|
+
process.stderr.write(prompt)
|
|
109
|
+
const buf = Buffer.alloc(1)
|
|
110
|
+
let line = ""
|
|
111
|
+
for (;;) {
|
|
112
|
+
let n: number
|
|
113
|
+
try {
|
|
114
|
+
n = readSync(0, buf, 0, 1, null)
|
|
115
|
+
} catch {
|
|
116
|
+
break
|
|
117
|
+
}
|
|
118
|
+
if (n === 0) break
|
|
119
|
+
const ch = buf.toString("utf8")
|
|
120
|
+
if (ch === "\n") break
|
|
121
|
+
line += ch
|
|
122
|
+
}
|
|
123
|
+
return line.replace(/\r$/, "")
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** toolchain::_gate — { proceed, target } ("" target = latest). */
|
|
127
|
+
function gate(ctx: Ctx, tool: string, mode: "install" | "upgrade", latest: string): { proceed: boolean; target: string } {
|
|
128
|
+
const verified = field(ctx, tool, "verified")
|
|
129
|
+
const pinnable = field(ctx, tool, "pinnable")
|
|
130
|
+
|
|
131
|
+
if (verified === "" || !isNewer(latest, verified)) return { proceed: true, target: "" }
|
|
132
|
+
|
|
133
|
+
if (ctx.assumeYes) {
|
|
134
|
+
warn(`${tool} ${latest} is newer than kit-verified ${verified} — proceeding (--yes)`)
|
|
135
|
+
return { proceed: true, target: "" }
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (process.stdin.isTTY === true) {
|
|
139
|
+
process.stderr.write(`\x1b[1;33m[warn]\x1b[0m ${tool} ${latest} is not kit-verified (verified: ${verified}).\n`)
|
|
140
|
+
const answer = promptLine(`Install ${tool} ${latest} anyway? [y/N] `)
|
|
141
|
+
if (/^[yY]/.test(answer)) return { proceed: true, target: "" }
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (mode === "install" && pinnable === "true") {
|
|
145
|
+
warn(`installing kit-verified ${tool} ${verified} instead of ${latest}`)
|
|
146
|
+
return { proceed: true, target: verified }
|
|
147
|
+
}
|
|
148
|
+
warn(
|
|
149
|
+
`skipping ${tool} ${mode} (latest ${latest} is above kit-verified ${verified}; pass --yes to accept, or update SoT/toolchain.json after testing)`
|
|
150
|
+
)
|
|
151
|
+
return { proceed: false, target: "" }
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function ensure(ctx: Ctx, tool: string, installFn: InstallFn): number {
|
|
155
|
+
const policy = field(ctx, tool, "policy")
|
|
156
|
+
|
|
157
|
+
if (!present(ctx, tool)) {
|
|
158
|
+
const latest = latestVersion(tool)
|
|
159
|
+
if (ctx.dryRun) {
|
|
160
|
+
echo(`[dry-run] would install ${tool} (${latest !== "" ? latest : "latest"}, gated by toolchain.json verified pin)`)
|
|
161
|
+
return 0
|
|
162
|
+
}
|
|
163
|
+
let target: string
|
|
164
|
+
if (latest === "") {
|
|
165
|
+
target = field(ctx, tool, "verified")
|
|
166
|
+
if (target !== "" && field(ctx, tool, "pinnable") === "true") {
|
|
167
|
+
warn(`${tool} latest version unknown (offline?) — installing kit-verified ${target} instead`)
|
|
168
|
+
} else {
|
|
169
|
+
target = ""
|
|
170
|
+
warn(`${tool} latest version unknown (offline?) and not pinnable — installing latest unverified`)
|
|
171
|
+
}
|
|
172
|
+
} else {
|
|
173
|
+
const g = gate(ctx, tool, "install", latest)
|
|
174
|
+
if (!g.proceed) return 0
|
|
175
|
+
target = g.target
|
|
176
|
+
}
|
|
177
|
+
return installFn("install", target !== "" ? target : latest)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const installed = installedVersion(ctx, tool)
|
|
181
|
+
const installedLabel = installed !== "" ? installed : "version unknown"
|
|
182
|
+
|
|
183
|
+
if (policy !== "track") {
|
|
184
|
+
if (ctx.dryRun) {
|
|
185
|
+
echo(`[dry-run] ${tool} present (${installedLabel})`)
|
|
186
|
+
return 0
|
|
187
|
+
}
|
|
188
|
+
log(`${tool} present (${installedLabel})`)
|
|
189
|
+
return 0
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const latest = latestVersion(tool)
|
|
193
|
+
if (latest === "") {
|
|
194
|
+
if (ctx.dryRun) {
|
|
195
|
+
echo(`[dry-run] ${tool} present (${installedLabel}); latest unknown (offline?) — no action`)
|
|
196
|
+
return 0
|
|
197
|
+
}
|
|
198
|
+
log(`${tool} present (${installedLabel}; latest unknown — no action)`)
|
|
199
|
+
return 0
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (installed === "" || isNewer(latest, installed)) {
|
|
203
|
+
if (ctx.dryRun) {
|
|
204
|
+
echo(`[dry-run] would upgrade ${tool} (${installed !== "" ? installed : "unknown"} -> ${latest}, gated by toolchain.json verified pin)`)
|
|
205
|
+
return 0
|
|
206
|
+
}
|
|
207
|
+
const g = gate(ctx, tool, "upgrade", latest)
|
|
208
|
+
if (!g.proceed) return 0
|
|
209
|
+
return installFn("upgrade", g.target !== "" ? g.target : latest)
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (ctx.dryRun) {
|
|
213
|
+
echo(`[dry-run] ${tool} up to date (${installed})`)
|
|
214
|
+
return 0
|
|
215
|
+
}
|
|
216
|
+
log(`${tool} up to date (${installed})`)
|
|
217
|
+
return 0
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function row(cells: [string, string, string, string, string, string]): string {
|
|
221
|
+
const widths = [28, 9, 14, 9, 9]
|
|
222
|
+
return cells.map((c, i) => (i < widths.length ? c.padEnd(widths[i]!) : c)).join(" ")
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export function report(ctx: Ctx): void {
|
|
226
|
+
echo(row(["TOOL", "KIND", "INSTALLED", "FLOOR", "VERIFIED", "STATUS"]))
|
|
227
|
+
const platformOs =
|
|
228
|
+
process.platform === "linux" ? "linux" : process.platform === "darwin" ? "darwin" : process.platform === "win32" ? "windows" : ""
|
|
229
|
+
for (const tool of Object.keys(manifest(ctx)).sort(compareCodepoints)) {
|
|
230
|
+
const os = field(ctx, tool, "os")
|
|
231
|
+
if (os !== "" && platformOs !== "" && os !== platformOs) continue
|
|
232
|
+
const kind = field(ctx, tool, "kind")
|
|
233
|
+
const floor = field(ctx, tool, "floor")
|
|
234
|
+
const verified = field(ctx, tool, "verified")
|
|
235
|
+
const dash = (v: string): string => (v !== "" ? v : "-")
|
|
236
|
+
if (kind === "pin") {
|
|
237
|
+
echo(row([tool, kind, "(npx)", dash(floor), dash(verified), "pinned"]))
|
|
238
|
+
continue
|
|
239
|
+
}
|
|
240
|
+
let installed: string
|
|
241
|
+
let status: string
|
|
242
|
+
if (present(ctx, tool)) {
|
|
243
|
+
installed = installedVersion(ctx, tool)
|
|
244
|
+
status = "ok"
|
|
245
|
+
if (floor !== "" && installed !== "" && isNewer(floor, installed)) {
|
|
246
|
+
status = "below-floor"
|
|
247
|
+
} else if (verified !== "" && installed !== "" && isNewer(installed, verified)) {
|
|
248
|
+
status = "above-verified"
|
|
249
|
+
}
|
|
250
|
+
installed = installed !== "" ? installed : "?"
|
|
251
|
+
} else {
|
|
252
|
+
installed = "-"
|
|
253
|
+
status = "missing"
|
|
254
|
+
}
|
|
255
|
+
echo(row([tool, dash(kind), installed, dash(floor), dash(verified), status]))
|
|
256
|
+
}
|
|
257
|
+
}
|