docks-kit 0.1.5 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Platform capability seam (Output Policy in DESIGN.md) — the only engine
3
+ * module that reads process.platform besides exec.ts's PATH/executability
4
+ * primitives. Per-tool package identifiers stay in deps.ts; symlink handling
5
+ * stays try-then-fallback at the call site (capability-driven, not predicted).
6
+ */
7
+
8
+ export type PlatformName = "linux" | "darwin" | "windows" | "unknown"
9
+
10
+ export function rawPlatform(): NodeJS.Platform {
11
+ return process.platform
12
+ }
13
+
14
+ export function platformName(pf: NodeJS.Platform = rawPlatform()): PlatformName {
15
+ return pf === "linux" ? "linux" : pf === "darwin" ? "darwin" : pf === "win32" ? "windows" : "unknown"
16
+ }
17
+
18
+ export function isWindows(): boolean {
19
+ return rawPlatform() === "win32"
20
+ }
21
+
22
+ export function isLinux(): boolean {
23
+ return rawPlatform() === "linux"
24
+ }
25
+
26
+ /** Shell-rc exports (bashrc/zshrc) apply only off Windows. */
27
+ export function shellRcApplicable(): boolean {
28
+ return !isWindows()
29
+ }
@@ -4,10 +4,8 @@
4
4
  * early parser exit and is caught once in runEngineNative.
5
5
  */
6
6
 
7
- import { commandExists } from "./exec"
8
7
  import type { Ctx } from "./index"
9
8
  import { printModels, validateClaudeModel, validateCodexModel } from "./models"
10
- import { echo, err, warn } from "./output"
11
9
 
12
10
  export class ExitError extends Error {
13
11
  constructor(readonly code: number) {
@@ -18,6 +16,7 @@ export class ExitError extends Error {
18
16
  const KNOWN_CLAUDE_OPTIN_PLUGINS = ["supabase", "n8n"]
19
17
 
20
18
  function usage(ctx: Ctx): void {
19
+ const { echo } = ctx.services.logger
21
20
  const argv0 = "docks-kit sync"
22
21
  echo(`Usage: ${argv0} [claude] [codex] [agents] [flags]`)
23
22
  echo("")
@@ -36,6 +35,7 @@ function usage(ctx: Ctx): void {
36
35
  )
37
36
  echo(" --skip-rtk skip optional tool bootstrap (RTK, bubblewrap)")
38
37
  echo(" --yes auto-accept toolchain prompts (containers/CI)")
38
+ echo(" --verbose also print no-op confirmations (already in sync, up to date, left as-is)")
39
39
  echo("")
40
40
  echo("Deploy-time modifiers (deployed config only; SoT untouched; a later flag-less sync reverts)")
41
41
  echo(
@@ -66,6 +66,7 @@ export function parseCompactWindow(v: string): string | undefined {
66
66
  }
67
67
 
68
68
  function addClaudePlugin(ctx: Ctx, name: string): void {
69
+ const { err } = ctx.services.logger
69
70
  if (!KNOWN_CLAUDE_OPTIN_PLUGINS.includes(name)) {
70
71
  err(`Unknown opt-in plugin '${name}'. Known: ${KNOWN_CLAUDE_OPTIN_PLUGINS.join(", ")}`)
71
72
  throw new ExitError(2)
@@ -81,6 +82,7 @@ function selectTarget(ctx: Ctx, target: string): void {
81
82
  }
82
83
 
83
84
  export function parseArgs(ctx: Ctx, args: ReadonlyArray<string>): void {
85
+ const { err } = ctx.services.logger
84
86
  for (const arg of args) {
85
87
  switch (arg) {
86
88
  case "claude":
@@ -103,12 +105,15 @@ export function parseArgs(ctx: Ctx, args: ReadonlyArray<string>): void {
103
105
  case "--yes":
104
106
  ctx.assumeYes = true
105
107
  continue
108
+ case "--verbose":
109
+ ctx.verbose = true
110
+ continue
106
111
  case "--claude-model":
107
- printModels(ctx.repoDir, "claude")
112
+ printModels(ctx, "claude")
108
113
  err("--claude-model requires a value: --claude-model=<model>")
109
114
  throw new ExitError(2)
110
115
  case "--codex-model":
111
- printModels(ctx.repoDir, "codex")
116
+ printModels(ctx, "codex")
112
117
  err("--codex-model requires a value: --codex-model=<model>")
113
118
  throw new ExitError(2)
114
119
  case "--claude-compact-window":
@@ -180,32 +185,29 @@ export function parseArgs(ctx: Ctx, args: ReadonlyArray<string>): void {
180
185
  }
181
186
 
182
187
  export function preflight(ctx: Ctx): void {
188
+ const { err } = ctx.services.logger
183
189
  if (ctx.syncClaude || ctx.syncCodex) {
184
- if (!commandExists("jq")) {
185
- const hint = process.platform === "win32"
186
- ? "winget install jqlang.jq (then open a new terminal)"
187
- : process.platform === "darwin"
188
- ? "brew install jq"
189
- : "sudo apt install -y jq"
190
- err(`jq is required (deployed statusline/hooks call it). Install: ${hint}`)
190
+ if (ctx.services.deps.probe("jq").state === "missing") {
191
+ err(`jq is required (deployed statusline/hooks call it). Install: ${ctx.services.deps.spec("jq").installHint()}`)
191
192
  throw new ExitError(1)
192
193
  }
193
194
  }
194
195
  if (ctx.syncClaude) {
195
- if (!commandExists("curl")) {
196
- err("curl is required.")
196
+ if (ctx.services.deps.probe("curl").state === "missing") {
197
+ err(`curl is required. Install: ${ctx.services.deps.spec("curl").installHint()}`)
197
198
  throw new ExitError(1)
198
199
  }
199
200
  }
200
201
  }
201
202
 
202
203
  export function validateModelFlags(ctx: Ctx): void {
204
+ const { err, warn } = ctx.services.logger
203
205
  if (ctx.claudeModel !== "") {
204
206
  if (!ctx.syncClaude) {
205
207
  warn("--claude-model ignored: claude target not selected")
206
208
  ctx.claudeModel = ""
207
- } else if (!validateClaudeModel(ctx.repoDir, ctx.claudeModel)) {
208
- printModels(ctx.repoDir, "claude")
209
+ } else if (!validateClaudeModel(ctx, ctx.claudeModel)) {
210
+ printModels(ctx, "claude")
209
211
  err(`Invalid Claude model '${ctx.claudeModel}' — use an alias above or a full claude-* ID`)
210
212
  throw new ExitError(2)
211
213
  }
@@ -214,8 +216,8 @@ export function validateModelFlags(ctx: Ctx): void {
214
216
  if (!ctx.syncCodex) {
215
217
  warn("--codex-model ignored: codex target not selected")
216
218
  ctx.codexModel = ""
217
- } else if (!validateCodexModel(ctx.repoDir, ctx.codexModel)) {
218
- printModels(ctx.repoDir, "codex")
219
+ } else if (!validateCodexModel(ctx, ctx.codexModel)) {
220
+ printModels(ctx, "codex")
219
221
  err(`Invalid Codex model '${ctx.codexModel}' — must match ^[A-Za-z0-9._-]+$`)
220
222
  throw new ExitError(2)
221
223
  }
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Shared service factory — the single construction point for the engine's
3
+ * injectable capabilities (Output Policy in DESIGN.md). The Effect rim wraps
4
+ * these in Layers (cli/src/services.ts); the harness-private native-raw entry
5
+ * and engineCapture's parent-side warn call it directly, so every execution
6
+ * path shares one implementation.
7
+ */
8
+ import {
9
+ DEPENDENCIES,
10
+ defaultProbeExecutor,
11
+ resolveDependency,
12
+ resolveLocation,
13
+ resolvePath,
14
+ resolveVersion,
15
+ type DependencySpec,
16
+ type DependencyLocation,
17
+ type ProbeExecutor,
18
+ type ProbeResult,
19
+ type ToolId
20
+ } from "./deps"
21
+ import { makeLogger, type Logger, type LoggerSinks } from "./logger"
22
+ import { platformName, rawPlatform, type PlatformName } from "./os"
23
+
24
+ export type { Logger } from "./logger"
25
+
26
+ export interface DependencyManager {
27
+ readonly spec: (id: ToolId) => DependencySpec
28
+ readonly probe: (id: ToolId) => ProbeResult
29
+ readonly version: (id: ToolId) => string
30
+ readonly path: (id: ToolId) => string
31
+ readonly location: (id: ToolId) => DependencyLocation
32
+ readonly latest: (id: ToolId) => string
33
+ readonly warnMissing: (id: ToolId, logger: Logger, context?: string) => void
34
+ }
35
+
36
+ export interface Platform {
37
+ readonly raw: () => NodeJS.Platform
38
+ readonly name: () => PlatformName
39
+ readonly isWindows: () => boolean
40
+ readonly isLinux: () => boolean
41
+ readonly shellRcApplicable: () => boolean
42
+ }
43
+
44
+ export interface EngineServices {
45
+ readonly logger: Logger
46
+ readonly deps: DependencyManager
47
+ readonly platform: Platform
48
+ }
49
+
50
+ export interface EngineServiceOptions {
51
+ readonly sinks?: LoggerSinks
52
+ }
53
+
54
+ /** Platform view over an injectable platform id (tests pass e.g. "win32"). */
55
+ export const makePlatform = (pf: NodeJS.Platform = rawPlatform()): Platform => ({
56
+ raw: () => pf,
57
+ name: () => platformName(pf),
58
+ isWindows: () => pf === "win32",
59
+ isLinux: () => pf === "linux",
60
+ shellRcApplicable: () => pf !== "win32"
61
+ })
62
+
63
+ /** DependencyManager whose hints default to the INJECTED platform, not the host. */
64
+ export const makeDependencyManager = (
65
+ platform: Platform,
66
+ exec: ProbeExecutor = defaultProbeExecutor
67
+ ): DependencyManager => {
68
+ const warned = new Set<ToolId>()
69
+ return {
70
+ spec: (id) => {
71
+ const s = DEPENDENCIES[id]
72
+ return { ...s, installHint: (pf = platform.raw()) => s.installHint(pf) }
73
+ },
74
+ probe: (id) => resolveDependency(DEPENDENCIES[id], exec, platform.raw()),
75
+ version: (id) => resolveVersion(DEPENDENCIES[id], exec),
76
+ path: (id) => resolvePath(DEPENDENCIES[id], exec, platform.raw()),
77
+ location: (id) => resolveLocation(DEPENDENCIES[id], exec, platform.raw()),
78
+ latest: (id) => DEPENDENCIES[id].latest?.(exec) ?? "",
79
+ warnMissing: (id, logger, context) => {
80
+ if (warned.has(id)) return
81
+ warned.add(id)
82
+ const suffix = context !== undefined && context !== "" ? ` (${context})` : ""
83
+ logger.warn(`${id} not installed — ${DEPENDENCIES[id].installHint(platform.raw())}${suffix}`)
84
+ }
85
+ }
86
+ }
87
+
88
+ export const makeEngineServices = (opts?: EngineServiceOptions): EngineServices => {
89
+ const platform = makePlatform()
90
+ const logger = makeLogger(opts?.sinks ?? {})
91
+ return {
92
+ logger,
93
+ deps: makeDependencyManager(platform),
94
+ platform
95
+ }
96
+ }
@@ -7,10 +7,10 @@
7
7
  import { spawnSync } from "node:child_process"
8
8
  import { cpSync, existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, realpathSync, rmdirSync, rmSync, statSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs"
9
9
  import { tmpdir } from "node:os"
10
- import { capture, commandExists, isExecutable, p, which } from "./exec"
10
+ import { p, writeFileIfChanged } from "./exec"
11
11
  import type { Ctx } from "./index"
12
12
  import { compareCodepoints } from "./jq"
13
- import { echo, log, warn } from "./output"
13
+ import type { EngineServices, Platform } from "./services"
14
14
  import { ensure, field } from "./toolchain"
15
15
 
16
16
  export interface SkillsState {
@@ -58,8 +58,9 @@ function readSlugs(file: string): Array<string> {
58
58
  }
59
59
 
60
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)")
61
+ const { change, echo, verbose, warn } = ctx.services.logger
62
+ if (ctx.services.deps.probe("npx").state === "missing") {
63
+ ctx.services.deps.warnMissing("npx", ctx.services.logger, "skipping universal skills bootstrap")
63
64
  return
64
65
  }
65
66
 
@@ -103,12 +104,14 @@ function syncUniversal(ctx: Ctx, state: SkillsState, skillsDir: string, manifest
103
104
  state.present = added + already
104
105
 
105
106
  if (added > 0) {
106
- log(`Universal skills synced (+${added} new, ${already} already present)`)
107
+ change(`Universal skills synced (+${added} new, ${already} already present)`)
108
+ ctx.nextStepTriggers.skillsRestart = true
107
109
  } else {
108
- log(`Universal skills already in sync (${already} present)`)
110
+ verbose(`Universal skills already in sync (${already} present)`)
109
111
  }
110
112
  if (healed > 0) {
111
- log(`Claude per-tool symlinks healed (+${healed}) — canonical present, ~/.claude/skills/<name> was missing or broken`)
113
+ change(`Claude per-tool symlinks healed (+${healed}) — canonical present, ~/.claude/skills/<name> was missing or broken`)
114
+ ctx.nextStepTriggers.skillsRestart = true
112
115
  }
113
116
  if (failed > 0) {
114
117
  warn(`${failed} skill install(s) failed — re-run sync or install manually with: npx skills add <slug> -g -y -a claude-code codex`)
@@ -125,6 +128,7 @@ function isDir(path: string): boolean {
125
128
 
126
129
  /** skills::heal_claude_symlink — true when a heal occurred. */
127
130
  function healClaudeSymlink(ctx: Ctx, skillsDir: string, base: string): boolean {
131
+ const { echo, warn } = ctx.services.logger
128
132
  const canonical = p(skillsDir, base)
129
133
  const claudeSkillsDir = p(ctx.home, ".claude", "skills")
130
134
  const claudeLink = p(claudeSkillsDir, base)
@@ -138,7 +142,7 @@ function healClaudeSymlink(ctx: Ctx, skillsDir: string, base: string): boolean {
138
142
  if (current === relTarget) return false
139
143
  // win32: `npx skills add` creates absolute symlinks/junctions — any link
140
144
  // that RESOLVES to the canonical dir is healthy, not stale.
141
- if (process.platform === "win32" && realpathEquals(claudeLink, canonical)) return false
145
+ if (ctx.services.platform.isWindows() && realpathEquals(claudeLink, canonical)) return false
142
146
  if (ctx.dryRun) {
143
147
  echo(`[dry-run] would replace stale Claude symlink: ~/.claude/skills/${base} -> ${current} (correct: ${relTarget})`)
144
148
  return true
@@ -156,7 +160,7 @@ function healClaudeSymlink(ctx: Ctx, skillsDir: string, base: string): boolean {
156
160
  }
157
161
 
158
162
  mkdirSync(claudeSkillsDir, { recursive: true })
159
- return linkOrCopy(relTarget, claudeLink)
163
+ return linkOrCopyWithWarnings(relTarget, claudeLink, ctx.services)
160
164
  }
161
165
 
162
166
  function lstat(path: string): ReturnType<typeof lstatSync> | undefined {
@@ -201,10 +205,10 @@ function removeLink(path: string): boolean {
201
205
  }
202
206
 
203
207
  /** skills::_link_or_copy — real symlink preferred, copy fallback (Windows). */
204
- export function linkOrCopy(target: string, link: string): boolean {
208
+ export function linkOrCopy(target: string, link: string, platform: Platform): boolean {
205
209
  removeLink(link)
206
210
  try {
207
- symlinkSync(target, link, process.platform === "win32" ? "dir" : undefined)
211
+ symlinkSync(target, link, platform.isWindows() ? "dir" : undefined)
208
212
  } catch {
209
213
  // fall through to the copy fallback below
210
214
  }
@@ -216,47 +220,56 @@ export function linkOrCopy(target: string, link: string): boolean {
216
220
  } catch {
217
221
  // fall through to the existence check below
218
222
  }
219
- if (existsSync(link)) {
220
- warn(`symlinks unsupported here — ${link} is a copy (refreshed on sync; enable Windows Developer Mode for real links)`)
221
- return true
223
+ return existsSync(link)
224
+ }
225
+
226
+ function linkOrCopyWithWarnings(target: string, link: string, services: EngineServices): boolean {
227
+ const linked = linkOrCopy(target, link, services.platform)
228
+ if (!linked) {
229
+ services.logger.warn(`could not create ${link} (symlink and copy both failed)`)
230
+ } else if (lstat(link)?.isSymbolicLink() !== true) {
231
+ services.logger.warn(`symlinks unsupported here — ${link} is a copy (refreshed on sync; enable Windows Developer Mode for real links)`)
222
232
  }
223
- warn(`could not create ${link} (symlink and copy both failed)`)
224
- return false
233
+ return linked
225
234
  }
226
235
 
227
236
  // ------------------------------------------------- toolchain callbacks ----
228
237
 
229
238
  /** skills::_agent_browser_install. */
230
- export function agentBrowserInstall(mode: "install" | "upgrade", version: string): number {
239
+ export function agentBrowserInstall(mode: "install" | "upgrade", version: string, services: EngineServices): number {
240
+ const { change, verbose, warn } = services.logger
231
241
  const verb = mode === "upgrade" ? "Upgrading" : "Installing"
232
242
  const pkg = version !== "" ? `agent-browser@${version}` : "agent-browser"
233
- const installFlags = process.platform === "linux" ? ["--with-deps"] : []
243
+ const installFlags = services.platform.isLinux() ? ["--with-deps"] : []
234
244
 
235
- log(`${verb} agent-browser CLI via npm${version !== "" ? ` (pinned ${version})` : ""}...`)
245
+ verbose(`${verb} agent-browser CLI via npm${version !== "" ? ` (pinned ${version})` : ""}...`)
236
246
  if (spawnSync("npm", ["install", "-g", pkg], { stdio: "ignore" }).status !== 0) {
237
247
  warn(`npm install -g ${pkg} failed. Try manually: npm install -g ${pkg}`)
238
248
  return 1
239
249
  }
240
250
 
241
251
  if (mode === "install") {
242
- log("Downloading Chrome for Testing (~175 MB; sudo may be requested for system libs on Linux)...")
252
+ warn("Downloading Chrome for Testing (~175 MB; sudo may be requested for system libs on Linux)...")
243
253
  if (spawnSync("agent-browser", ["install", ...installFlags], { stdio: "inherit" }).status !== 0) {
244
254
  warn(`agent-browser install failed. Re-run manually: agent-browser install ${installFlags.join(" ")}`)
245
255
  return 1
246
256
  }
247
257
  }
248
- const out = capture("agent-browser", ["--version"])
258
+ const out = services.deps.version("agent-browser")
249
259
  const fields = (out.split("\n")[0] ?? "").trim().split(/[ \t]+/)
250
260
  const version2 = out !== "" ? fields[fields.length - 1] ?? "version unknown" : "version unknown"
251
- log(`agent-browser CLI ready (${version2})`)
261
+ change(`agent-browser CLI ready (${version2})`)
252
262
  return 0
253
263
  }
254
264
 
255
265
  function syncAgentBrowserCli(ctx: Ctx, manifest: string): void {
266
+ const { warn } = ctx.services.logger
256
267
  if (!existsSync(manifest) || !readFileSync(manifest, "utf8").split("\n").includes("vercel-labs/agent-browser")) return
257
268
 
258
- if (!commandExists("npm")) {
259
- if (!ctx.dryRun) warn("npm not found — cannot auto-install agent-browser CLI. Install Node.js, then re-run sync.")
269
+ if (ctx.services.deps.probe("npm").state === "missing") {
270
+ if (!ctx.dryRun) {
271
+ ctx.services.deps.warnMissing("npm", ctx.services.logger, "cannot auto-install agent-browser CLI; re-run sync after installing")
272
+ }
260
273
  return
261
274
  }
262
275
 
@@ -267,21 +280,16 @@ function syncAgentBrowserCli(ctx: Ctx, manifest: string): void {
267
280
 
268
281
  /** skills::_find_bun — resolved bun path or "". */
269
282
  function findBun(ctx: Ctx): string {
270
- const onPath = which("bun")
271
- if (onPath !== "") return onPath
272
- const bunInstall = process.env["BUN_INSTALL"] !== undefined && process.env["BUN_INSTALL"] !== "" ? process.env["BUN_INSTALL"] : p(ctx.home, ".bun")
273
- for (const cand of [p(bunInstall, "bin", "bun"), p(ctx.home, ".bun", "bin", "bun")]) {
274
- if (isExecutable(cand)) return cand
275
- }
276
- return ""
283
+ return ctx.services.deps.path("bun")
277
284
  }
278
285
 
279
286
  /** skills::_bun_bootstrap — bun path or "" after a failed bootstrap. */
280
- export function bunBootstrap(ctx: Ctx): string {
287
+ export function bunBootstrap(ctx: Ctx, services: EngineServices): string {
288
+ const { change, warn } = services.logger
281
289
  let bun = findBun(ctx)
282
290
  if (bun !== "") return bun
283
291
 
284
- if (!commandExists("curl")) {
292
+ if (services.deps.probe("curl").state === "missing") {
285
293
  warn("Bun and curl both missing — cannot bootstrap Bun. Install Bun manually, then re-run sync.")
286
294
  return ""
287
295
  }
@@ -298,41 +306,44 @@ export function bunBootstrap(ctx: Ctx): string {
298
306
  warn("Bun install failed. Install manually: curl -fsSL https://bun.sh/install -o /tmp/bun.sh && bash /tmp/bun.sh")
299
307
  return ""
300
308
  }
301
- const v = capture(bun, ["--version"])
302
- log(`Bun installed (${v !== "" ? v : "version unknown"})`)
309
+ const version = services.deps.version("bun")
310
+ change(`Bun installed (${version !== "" ? version : "version unknown"})`)
303
311
  return bun
304
312
  }
305
313
 
306
314
  /** skills::_effect_solutions_install. */
307
- export function effectSolutionsInstall(ctx: Ctx): (mode: "install" | "upgrade", version: string) => number {
308
- return (mode, version) => {
315
+ export function effectSolutionsInstall(
316
+ ctx: Ctx
317
+ ): (mode: "install" | "upgrade", version: string, services: EngineServices) => number {
318
+ return (mode, version, services) => {
319
+ const { change, verbose, warn } = services.logger
309
320
  const verb = mode === "upgrade" ? "Upgrading" : "Installing"
310
321
  const pkg = `effect-solutions@${version !== "" ? version : "latest"}`
311
322
 
312
- const bun = bunBootstrap(ctx)
323
+ const bun = bunBootstrap(ctx, services)
313
324
  if (bun === "") return 1
314
325
 
315
- log(`${verb} effect-solutions CLI via bun${version !== "" ? ` (pinned ${version})` : ""}...`)
326
+ verbose(`${verb} effect-solutions CLI via bun${version !== "" ? ` (pinned ${version})` : ""}...`)
316
327
  if (spawnSync(bun, ["add", "-g", pkg], { stdio: "ignore" }).status !== 0) {
317
328
  warn(`bun add -g ${pkg} failed. Try manually: bun add -g ${pkg}`)
318
329
  return 1
319
330
  }
320
331
 
321
- const gbin = capture(bun, ["pm", "-g", "bin"])
332
+ const location = services.deps.location("effect-solutions")
333
+ const gbin = location.binDir
322
334
  // win32: bun writes an .exe shim (not the bare Unix name), and the
323
335
  // ~/.local/bin link step below is Unix-only plumbing (non-interactive
324
336
  // agent PATH) — bun's global bin is already the Windows PATH entry.
325
- if (process.platform === "win32") {
326
- const found = gbin !== "" && ["exe", "cmd", "bunx"].some((ext) => existsSync(p(gbin, `effect-solutions.${ext}`)))
327
- if (found) log(`effect-solutions CLI ready (${gbin})`)
337
+ if (services.platform.isWindows()) {
338
+ if (location.path !== "") change(`effect-solutions CLI ready (${gbin})`)
328
339
  else warn(`effect-solutions installed but no shim found under '${gbin !== "" ? gbin : "<unknown>"}' — check bun pm -g bin`)
329
340
  return 0
330
341
  }
331
- if (gbin !== "" && isExecutable(p(gbin, "effect-solutions"))) {
342
+ if (location.path !== "") {
332
343
  mkdirSync(p(ctx.home, ".local", "bin"), { recursive: true })
333
- linkOrCopy(bun, p(ctx.home, ".local", "bin", "bun"))
334
- linkOrCopy(p(gbin, "effect-solutions"), p(ctx.home, ".local", "bin", "effect-solutions"))
335
- log("effect-solutions CLI ready (linked bun + effect-solutions into ~/.local/bin)")
344
+ linkOrCopyWithWarnings(bun, p(ctx.home, ".local", "bin", "bun"), services)
345
+ linkOrCopyWithWarnings(location.path, p(ctx.home, ".local", "bin", "effect-solutions"), services)
346
+ change("effect-solutions CLI ready (linked bun + effect-solutions into ~/.local/bin)")
336
347
  } else {
337
348
  warn(`effect-solutions installed but binary not found under '${gbin !== "" ? gbin : "<unknown>"}' — link it onto PATH manually`)
338
349
  }
@@ -341,6 +352,7 @@ export function effectSolutionsInstall(ctx: Ctx): (mode: "install" | "upgrade",
341
352
  }
342
353
 
343
354
  function syncEffectSolutionsCli(ctx: Ctx): void {
355
+ const { warn } = ctx.services.logger
344
356
  const settings = p(ctx.repoDir, "SoT", ".claude", "settings.json")
345
357
  if (!existsSync(settings)) return
346
358
  if (!/"effect-kit@docks"[ \t]*:[ \t]*true/.test(readFileSync(settings, "utf8"))) return
@@ -353,6 +365,7 @@ function syncEffectSolutionsCli(ctx: Ctx): void {
353
365
  // ----------------------------------------------------- prune + snapshot ----
354
366
 
355
367
  function reconcileRemovals(ctx: Ctx, manifest: string, snapshot: string): void {
368
+ const { change, echo, warn } = ctx.services.logger
356
369
  if (!existsSync(snapshot)) {
357
370
  if (ctx.dryRun) {
358
371
  echo(
@@ -383,7 +396,10 @@ function reconcileRemovals(ctx: Ctx, manifest: string, snapshot: string): void {
383
396
  }
384
397
  }
385
398
 
386
- if (removed > 0) log(`Kit-managed skills removed (-${removed})`)
399
+ if (removed > 0) {
400
+ change(`Kit-managed skills removed (-${removed})`)
401
+ ctx.nextStepTriggers.skillsRestart = true
402
+ }
387
403
  if (failed > 0) warn(`${failed} skill remove(s) failed — re-run with --prune or run: npx skills remove -g -y -a '*' -s <name>`)
388
404
  }
389
405
 
@@ -392,18 +408,21 @@ function updateSnapshot(ctx: Ctx, manifest: string, snapshot: string): void {
392
408
 
393
409
  mkdirSync(ctx.agentsDir, { recursive: true })
394
410
  const sorted = [...new Set(readSlugs(manifest))].sort(compareCodepoints)
395
- writeFileSync(snapshot, sorted.length > 0 ? `${sorted.join("\n")}\n` : "")
411
+ writeFileIfChanged(snapshot, sorted.length > 0 ? `${sorted.join("\n")}\n` : "")
396
412
  }
397
413
 
398
414
  // -------------------------------------------------------------- summary ----
399
415
 
400
416
  export function skillsSummary(ctx: Ctx, state: SkillsState): void {
417
+ const { echo } = ctx.services.logger
401
418
  echo(`Skills: ${p(ctx.agentsDir, "skills")}`)
402
419
  if (!ctx.dryRun) {
403
420
  echo(` ${state.present} universal skill(s) installed`)
404
421
  }
405
422
  }
406
423
 
407
- export function skillsNextSteps(): void {
408
- echo("Restart Claude Code (and Codex) to discover newly installed universal skills.")
424
+ export function skillsNextSteps(ctx: Ctx): Array<string> {
425
+ return ctx.verbose || ctx.nextStepTriggers.skillsRestart
426
+ ? ["Restart Claude Code (and Codex) to discover newly installed universal skills."]
427
+ : []
409
428
  }