docks-kit 0.15.2 → 0.15.3

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 (38) hide show
  1. package/AGENTS.md +21 -15
  2. package/README.md +33 -31
  3. package/cli/docs/flags.md +0 -1
  4. package/cli/docs/install.md +28 -13
  5. package/cli/docs/overview.md +2 -2
  6. package/cli/docs/platforms.md +5 -2
  7. package/cli/docs/sync-layers.md +3 -4
  8. package/cli/docs/toolchain.md +26 -34
  9. package/cli/src/commands/docs.ts +3 -3
  10. package/cli/src/commands/sync.ts +0 -5
  11. package/cli/src/commands/toolchain.ts +4 -7
  12. package/cli/src/commands/update.ts +77 -26
  13. package/cli/src/engine-native/DESIGN.md +31 -22
  14. package/cli/src/engine-native/bun.ts +10 -8
  15. package/cli/src/engine-native/claudeRuntime.ts +17 -9
  16. package/cli/src/engine-native/claudeSync.ts +52 -24
  17. package/cli/src/engine-native/codexSync.ts +10 -5
  18. package/cli/src/engine-native/deps.ts +27 -88
  19. package/cli/src/engine-native/exec.ts +41 -10
  20. package/cli/src/engine-native/index.ts +0 -2
  21. package/cli/src/engine-native/modes.ts +23 -14
  22. package/cli/src/engine-native/os/darwin.ts +62 -0
  23. package/cli/src/engine-native/os/index.ts +42 -0
  24. package/cli/src/engine-native/os/linux.ts +62 -0
  25. package/cli/src/engine-native/os/targets.ts +73 -0
  26. package/cli/src/engine-native/os/types.ts +75 -0
  27. package/cli/src/engine-native/os/windows.ts +176 -0
  28. package/cli/src/engine-native/parseArgs.ts +0 -4
  29. package/cli/src/engine-native/services.ts +0 -6
  30. package/cli/src/engine-native/skillsSync.ts +125 -77
  31. package/cli/src/engine-native/toolchain.ts +4 -150
  32. package/cli/src/engine.ts +3 -2
  33. package/cli/src/generated/sotPayload.ts +6 -6
  34. package/cli/src/manifests.ts +12 -2
  35. package/docks-kit +1 -1
  36. package/docks-kit.ps1 +123 -0
  37. package/package.json +9 -5
  38. package/cli/src/engine-native/os.ts +0 -16
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Host modules own only facts that cannot be probed. Anything that can fail at
3
+ * runtime stays try-then-fallback at the call site; a host module contributes
4
+ * only the order of that fallback chain.
5
+ */
6
+
7
+ export type PlatformName = "linux" | "darwin" | "windows" | "unknown"
8
+ /** Directory-link mechanisms, tried in order before the copy fallback. */
9
+ export type DirectoryLinkKind = "symlink" | "junction"
10
+ /** Tools whose install command differs per host OS. */
11
+ export type HintedTool = "git" | "jq" | "curl" | "ffplay" | "claude" | "codex"
12
+
13
+ /** A command plus argv, already shaped for how this host must invoke it. */
14
+ export interface Invocation {
15
+ readonly command: string
16
+ readonly args: ReadonlyArray<string>
17
+ /** Preserve a fully quoted Windows command line instead of applying libuv quoting. */
18
+ readonly windowsVerbatimArguments?: boolean
19
+ }
20
+
21
+ /** How this host persists a user-level environment variable. */
22
+ export type EnvironmentSetting =
23
+ | {
24
+ readonly kind: "profile"
25
+ /** Home-relative files scanned for an existing setting. */
26
+ readonly candidates: ReadonlyArray<string>
27
+ /** Home-relative file that receives the write for this shell. */
28
+ readonly target: (shell: string | undefined) => string
29
+ /** Line appended to the target file. */
30
+ readonly line: string
31
+ }
32
+ | {
33
+ readonly kind: "command"
34
+ /** Exits zero when the variable is already persisted. */
35
+ readonly probe: Invocation
36
+ /** Persists the variable. */
37
+ readonly apply: Invocation
38
+ /** Where the value lands, named for the log line. */
39
+ readonly location: string
40
+ /** Manual recovery when `apply` fails. */
41
+ readonly manualHint: string
42
+ }
43
+
44
+ /** The two steps that fetch and run a pinned Bun installer. */
45
+ export interface BunInstaller {
46
+ readonly scriptPath: string
47
+ readonly download: Invocation
48
+ readonly run: Invocation
49
+ }
50
+
51
+ export interface HostOs {
52
+ readonly id: PlatformName
53
+ /** `SoT/toolchain.json` `os` field this host matches; "" matches every row. */
54
+ readonly toolchainOs: string
55
+ /** Bubblewrap is the Linux-only Codex sandbox runtime. */
56
+ readonly supportsBubblewrap: boolean
57
+ /** Directory-link mechanisms tried in order before `skillsSync.ts linkOrCopy` copies. */
58
+ readonly directoryLinkKinds: ReadonlyArray<DirectoryLinkKind>
59
+ /** One-line install command for a tool whose package differs per OS. */
60
+ readonly installHint: (tool: HintedTool) => string
61
+ /** Filename suffixes tried in order when resolving a bare tool name on PATH. */
62
+ readonly executableSuffixes: ReadonlyArray<string>
63
+ /** Shape the argv that actually runs a resolved executable on this host. */
64
+ readonly invoke: (executablePath: string, args: ReadonlyArray<string>) => Invocation
65
+ /** Bun executable filename inside `<bunRoot>/bin`. */
66
+ readonly bunExecutableName: string
67
+ /** Download-then-run installer for a pinned Bun version placed in `directory`. */
68
+ readonly bunInstaller: (pin: string, directory: string) => BunInstaller
69
+ /** How this host persists a user-level environment variable. */
70
+ readonly environmentSetting: (name: string, value: string) => EnvironmentSetting
71
+ /** Claude `statusLine.command` for a resolved bun executable and script. */
72
+ readonly statusLineCommand: (bun: string, script: string) => string
73
+ /** Reshape the SoT `PostToolUseFailure` command for this host's shell. */
74
+ readonly failureHookCommand: (command: string) => string
75
+ }
@@ -0,0 +1,176 @@
1
+ import { win32 } from "node:path"
2
+ import { p } from "../exec"
3
+ import type { HostOs } from "./types"
4
+
5
+ function powershellLiteral(value: string): string {
6
+ return `'${value.replaceAll("'", "''")}'`
7
+ }
8
+
9
+ function encodedPowerShellCommand(script: string): string {
10
+ const encoded = Buffer.from(script, "utf16le").toString("base64")
11
+ return `powershell.exe -NoProfile -NonInteractive -EncodedCommand ${encoded}`
12
+ }
13
+
14
+ /**
15
+ * Windows shim encoding handles three hazards: Windows argv parsing, cmd
16
+ * metacharacter parsing, and percent/newline values that cmd cannot escape.
17
+ * The encoders are ported from cross-spawn's escape.js and its parseNonShell
18
+ * assembly, based on:
19
+ * https://github.com/moxystudio/node-cross-spawn
20
+ * https://qntm.org/cmd
21
+ * https://flatt.tech/research/posts/batbadbut-you-cant-securely-execute-commands-on-windows/
22
+ * https://learn.microsoft.com/en-us/windows-server/administration/windows-commands/cmd
23
+ */
24
+ const metaCharsRegExp = /([()\][%!^"`<>&|;, *?])/g
25
+
26
+ function escapeCommand(arg: string): string {
27
+ return arg.replace(metaCharsRegExp, "^$1")
28
+ }
29
+
30
+ function escapeArgument(arg: string, doubleEscapeMetaChars: boolean): string {
31
+ arg = `${arg}`
32
+ arg = arg.replace(/(?=(\\+?)?)\1"/g, "$1$1\\\"")
33
+ arg = arg.replace(/(?=(\\+?)?)\1$/, "$1$1")
34
+ arg = `"${arg}"`
35
+ arg = arg.replace(metaCharsRegExp, "^$1")
36
+ if (doubleEscapeMetaChars) {
37
+ arg = arg.replace(metaCharsRegExp, "^$1")
38
+ }
39
+ return arg
40
+ }
41
+
42
+ function assertCommandLineValue(value: string, kind: "executable path" | "argument"): void {
43
+ const unsupported = value.includes("%")
44
+ ? "percent sign (%)"
45
+ : value.includes("\r")
46
+ ? "carriage return (CR)"
47
+ : value.includes("\n")
48
+ ? "line feed (LF)"
49
+ : undefined
50
+ if (unsupported === undefined) return
51
+
52
+ const executablePercentHint =
53
+ kind === "executable path" && unsupported === "percent sign (%)"
54
+ ? " The tool must be reached through its .exe or moved out of a directory whose name contains a percent sign."
55
+ : ""
56
+ throw new Error(
57
+ `${kind} ${JSON.stringify(value)} contains a ${unsupported}, which cannot be escaped on a cmd command line; the caller must pass the value another way.${executablePercentHint}`
58
+ )
59
+ }
60
+
61
+ /**
62
+ * Always absolute. A pathless name lets CreateProcess and libuv search the
63
+ * parent's current directory before System32, so an untrusted checkout holding
64
+ * a cmd.exe would win. ComSpec is the documented interpreter, honoured only
65
+ * when it is an absolute path; otherwise it is rebuilt under SystemRoot.
66
+ */
67
+ function commandInterpreter(environment: NodeJS.ProcessEnv = process.env): string {
68
+ const comSpec = environment["ComSpec"] ?? ""
69
+ if (win32.isAbsolute(comSpec)) return comSpec
70
+ const systemRoot = environment["SystemRoot"] ?? ""
71
+ const root = win32.isAbsolute(systemRoot) ? systemRoot : "C:\\Windows"
72
+ return win32.join(root, "System32", "cmd.exe")
73
+ }
74
+
75
+ function commandShimInvocation(executablePath: string, args: ReadonlyArray<string>) {
76
+ assertCommandLineValue(executablePath, "executable path")
77
+ for (const arg of args) assertCommandLineValue(arg, "argument")
78
+
79
+ const normalizedPath = win32.normalize(executablePath)
80
+ const doubleEscapeMetaChars = /node_modules[\\/]\.bin[\\/][^\\/]+\.cmd$/i.test(normalizedPath)
81
+ const commandLine = [
82
+ escapeCommand(normalizedPath),
83
+ ...args.map((arg) => escapeArgument(arg, doubleEscapeMetaChars))
84
+ ].join(" ")
85
+ return {
86
+ command: commandInterpreter(),
87
+ args: ["/d", "/v:off", "/s", "/c", `"${commandLine}"`],
88
+ windowsVerbatimArguments: true
89
+ } as const
90
+ }
91
+
92
+ // Authored now, but unreachable until the supported-host admission gate opens.
93
+ export const windows: HostOs = {
94
+ id: "windows",
95
+ toolchainOs: "windows",
96
+ supportsBubblewrap: false,
97
+ directoryLinkKinds: ["symlink", "junction"],
98
+ executableSuffixes: [".exe", ".cmd", ".bat", ""],
99
+ invoke: (executablePath, args) =>
100
+ /\.(?:cmd|bat)$/i.test(executablePath)
101
+ ? commandShimInvocation(executablePath, args)
102
+ : { command: executablePath, args },
103
+ bunExecutableName: "bun.exe",
104
+ bunInstaller: (pin, directory) => {
105
+ const scriptPath = p(directory, "install.ps1")
106
+ return {
107
+ scriptPath,
108
+ download: {
109
+ command: "curl",
110
+ args: ["-fsSL", "https://bun.sh/install.ps1", "-o", scriptPath]
111
+ },
112
+ run: {
113
+ command: "powershell.exe",
114
+ args: [
115
+ "-NoProfile",
116
+ "-NonInteractive",
117
+ "-ExecutionPolicy",
118
+ "Bypass",
119
+ "-File",
120
+ scriptPath,
121
+ "-Version",
122
+ pin
123
+ ]
124
+ }
125
+ }
126
+ },
127
+ environmentSetting: (name, value) => ({
128
+ kind: "command",
129
+ probe: {
130
+ command: "reg",
131
+ args: ["query", "HKCU\\Environment", "/v", name]
132
+ },
133
+ apply: {
134
+ command: "setx",
135
+ args: [name, value]
136
+ },
137
+ location: "user environment",
138
+ manualHint: "set it manually in System Properties > Environment Variables"
139
+ }),
140
+ statusLineCommand: (bun, script) => {
141
+ const bunLiteral = powershellLiteral(bun)
142
+ const scriptLiteral = powershellLiteral(script)
143
+ // Module auto-loading reports "Preparing modules for first use" as a
144
+ // progress record, which a redirected host serializes to stderr as CLIXML.
145
+ // Claude runs this command every turn, and the records recur across runs, so
146
+ // silencing progress inside the stored command keeps its streams clean.
147
+ return encodedPowerShellCommand(
148
+ `$ProgressPreference = 'SilentlyContinue'; if ((Test-Path -LiteralPath ${bunLiteral} -PathType Leaf) -and (Test-Path -LiteralPath ${scriptLiteral} -PathType Leaf)) { & ${bunLiteral} ${scriptLiteral} }`
149
+ )
150
+ },
151
+ failureHookCommand: (command) => {
152
+ const prefix = "echo '"
153
+ if (!command.startsWith(prefix) || !command.endsWith("'")) return command
154
+ const quotedPayload = command.slice(prefix.length, -1)
155
+ const posixApostrophe = `'"'"'`
156
+ if (quotedPayload.replaceAll(posixApostrophe, "").includes("'")) return command
157
+ const payload = quotedPayload.replaceAll(posixApostrophe, "'")
158
+ return encodedPowerShellCommand(`Write-Output ${powershellLiteral(payload)}`)
159
+ },
160
+ installHint: (tool) => {
161
+ switch (tool) {
162
+ case "git":
163
+ return "winget install --id Git.Git -e"
164
+ case "jq":
165
+ return "winget install --id jqlang.jq -e"
166
+ case "curl":
167
+ return "winget install --id cURL.cURL -e"
168
+ case "ffplay":
169
+ return "winget install --id Gyan.FFmpeg -e"
170
+ case "claude":
171
+ return "$tmp = Join-Path $env:TEMP 'claude-install.ps1'; curl.exe -fsSL https://claude.ai/install.ps1 -o $tmp; if ($LASTEXITCODE -eq 0) { powershell.exe -NoProfile -ExecutionPolicy Bypass -File $tmp }"
172
+ case "codex":
173
+ return "$tmp = Join-Path $env:TEMP 'codex-install.ps1'; curl.exe -fsSL https://chatgpt.com/codex/install.ps1 -o $tmp; if ($LASTEXITCODE -eq 0) { $env:CODEX_NON_INTERACTIVE = '1'; powershell.exe -NoProfile -ExecutionPolicy Bypass -File $tmp }"
174
+ }
175
+ }
176
+ }
@@ -135,7 +135,6 @@ function usage(ctx: Ctx): void {
135
135
  )
136
136
  echo(" --skip-bubblewrap skip optional bubblewrap bootstrap (Codex Linux sandbox)")
137
137
  echo(" --skip-plugin-refresh install missing plugins but skip refresh-only updates")
138
- echo(" --yes auto-accept toolchain prompts (containers/CI)")
139
138
  echo(" --verbose also print no-op confirmations (already in sync, up to date, left as-is)")
140
139
  echo("")
141
140
  echo("Deploy-time modifiers (deployed config only; SoT untouched; a later flag-less sync reverts)")
@@ -261,9 +260,6 @@ export function parseArgs(ctx: Ctx, args: ReadonlyArray<string>): void {
261
260
  case "--prune":
262
261
  ctx.prune = true
263
262
  continue
264
- case "--yes":
265
- ctx.assumeYes = true
266
- continue
267
263
  case "--verbose":
268
264
  ctx.verbose = true
269
265
  continue
@@ -9,11 +9,9 @@ import {
9
9
  DEPENDENCIES,
10
10
  defaultProbeExecutor,
11
11
  resolveDependency,
12
- resolveLocation,
13
12
  resolvePath,
14
13
  resolveVersion,
15
14
  type DependencySpec,
16
- type DependencyLocation,
17
15
  type ProbeExecutor,
18
16
  type ProbeResult,
19
17
  type ToolId
@@ -28,8 +26,6 @@ export interface DependencyManager {
28
26
  readonly probe: (id: ToolId) => ProbeResult
29
27
  readonly version: (id: ToolId) => Promise<string>
30
28
  readonly path: (id: ToolId) => Promise<string>
31
- readonly location: (id: ToolId) => Promise<DependencyLocation>
32
- readonly latest: (id: ToolId) => Promise<string>
33
29
  readonly warnMissing: (id: ToolId, logger: Logger, context?: string) => void
34
30
  }
35
31
 
@@ -68,8 +64,6 @@ export const makeDependencyManager = (
68
64
  probe: (id) => resolveDependency(DEPENDENCIES[id], exec, platform.raw()),
69
65
  version: (id) => resolveVersion(DEPENDENCIES[id], exec),
70
66
  path: (id) => resolvePath(DEPENDENCIES[id], exec, platform.raw()),
71
- location: (id) => resolveLocation(DEPENDENCIES[id], exec, platform.raw()),
72
- latest: (id) => DEPENDENCIES[id].latest?.(exec) ?? Promise.resolve(""),
73
67
  warnMissing: (id, logger, context) => {
74
68
  if (warned.has(id)) return
75
69
  warned.add(id)
@@ -1,24 +1,39 @@
1
1
  /**
2
2
  * EngineNative `sync agents` pipeline: universal-skill bootstrap
3
3
  * (`npx skills@<pin> add`), Claude symlink healing, --prune reconcile against
4
- * the kit-managed snapshot, the effect-solutions toolchain callback, and the
5
- * snapshot write.
4
+ * the kit-managed snapshot, and the snapshot write.
6
5
  */
7
- import { existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, rmSync, statSync, symlinkSync } from "node:fs"
6
+ import {
7
+ cpSync,
8
+ existsSync,
9
+ lstatSync,
10
+ mkdirSync,
11
+ readFileSync,
12
+ readlinkSync,
13
+ rmSync,
14
+ statSync,
15
+ symlinkSync,
16
+ writeFileSync
17
+ } from "node:fs"
8
18
  import { dirname, relative, resolve } from "node:path"
19
+ import { payloadText } from "../payload"
9
20
  import { p, spawnProcess, writeFileIfChanged } from "./exec"
10
- import { bunBootstrap } from "./bun"
11
21
  import type { Ctx } from "./index"
12
- import { compareCodepoints, isObject, parseJson } from "./jq"
13
- import type { EngineServices } from "./services"
14
- import { ensure, field } from "./toolchain"
15
- import { payloadText } from "../payload"
22
+ import { compareCodepoints } from "./jq"
23
+ import { hostOs, type DirectoryLinkKind } from "./os"
16
24
  import { ExitError } from "./parseArgs"
25
+ import type { EngineServices } from "./services"
26
+ import { field } from "./toolchain"
17
27
 
18
28
  export interface SkillsState {
19
29
  present: number
20
30
  }
21
31
 
32
+ export type LinkOutcome = "symlink" | "junction" | "copy" | "failed"
33
+
34
+ /** Lets heal and prune distinguish a kit-owned copy from a user's real directory. */
35
+ export const COPY_MARKER = ".docks-kit-copied-skill"
36
+
22
37
  export async function skillsSync(ctx: Ctx): Promise<SkillsState> {
23
38
  const state: SkillsState = { present: 0 }
24
39
  const skillsDir = p(ctx.agentsDir, "skills")
@@ -29,7 +44,6 @@ export async function skillsSync(ctx: Ctx): Promise<SkillsState> {
29
44
 
30
45
  await syncUniversal(ctx, state, skillsDir, manifest)
31
46
  const failedRemovals = ctx.prune ? await reconcileRemovals(ctx, manifest, snapshot) : []
32
- await syncEffectSolutionsCli(ctx)
33
47
  updateSnapshot(ctx, manifest, snapshot, failedRemovals)
34
48
  return state
35
49
  }
@@ -152,15 +166,25 @@ function healClaudeSymlink(ctx: Ctx, skillsDir: string, base: string): boolean {
152
166
  return false
153
167
  }
154
168
  } else if (linkStat !== undefined) {
155
- warn(`~/.claude/skills/${base} exists as a real path (not a symlink) — leaving alone; remove manually if it's stale`)
156
- return false
169
+ if (!isKitOwnedCopy(claudeLink)) {
170
+ warn(`~/.claude/skills/${base} exists as a real path (not a symlink) — leaving alone; remove manually if it's stale`)
171
+ return false
172
+ }
173
+ if (ctx.dryRun) {
174
+ echo(`[dry-run] would replace kit-created Claude copy: ~/.claude/skills/${base} -> ${relTarget}`)
175
+ return true
176
+ }
177
+ if (!removeKitOwnedCopy(claudeLink)) {
178
+ warn(`could not remove kit-created copy ~/.claude/skills/${base} — remove it manually, then re-run sync`)
179
+ return false
180
+ }
157
181
  } else if (ctx.dryRun) {
158
182
  echo(`[dry-run] would create missing Claude symlink: ~/.claude/skills/${base} -> ${relTarget}`)
159
183
  return true
160
184
  }
161
185
 
162
186
  mkdirSync(claudeSkillsDir, { recursive: true })
163
- return linkOrCopyWithWarnings(relTarget, claudeLink, ctx.services)
187
+ return linkOrCopyWithWarnings(relTarget, claudeLink, ctx.services) !== "failed"
164
188
  }
165
189
 
166
190
  function lstat(path: string): ReturnType<typeof lstatSync> | undefined {
@@ -171,97 +195,105 @@ function lstat(path: string): ReturnType<typeof lstatSync> | undefined {
171
195
  }
172
196
  }
173
197
 
174
- function safeReadlink(path: string): string {
198
+ /**
199
+ * A link only counts when it RESOLVES to the skill directory. Windows picks a
200
+ * symlink's file-or-directory type by autodetecting the target against the
201
+ * process working directory, not the link's own directory, so a relative
202
+ * target can yield a symlink that exists but resolves to nothing. Checking
203
+ * resolution is what makes the next mechanism in the chain reachable.
204
+ */
205
+ function linksToDirectory(path: string): boolean {
206
+ if (lstat(path)?.isSymbolicLink() !== true) return false
175
207
  try {
176
- return readlinkSync(path)
208
+ return statSync(path).isDirectory()
177
209
  } catch {
178
- return ""
210
+ return false
179
211
  }
180
212
  }
181
213
 
214
+ function isKitOwnedCopy(path: string): boolean {
215
+ return lstat(path)?.isDirectory() === true && existsSync(p(path, COPY_MARKER))
216
+ }
182
217
 
183
- /** Remove a symlink without touching a real directory. */
184
- function removeLink(path: string): boolean {
218
+ function removeKitOwnedCopy(path: string): boolean {
219
+ if (!isKitOwnedCopy(path)) return false
185
220
  try {
186
- rmSync(path, { force: true })
221
+ rmSync(path, { recursive: true, force: true })
187
222
  return true
188
223
  } catch {
189
224
  return lstat(path) === undefined
190
225
  }
191
226
  }
192
227
 
193
- /** skills::_link_or_copy create a symlink without replacing its source. */
194
- export function linkOrCopy(target: string, link: string): boolean {
195
- const resolvedLink = resolve(link)
196
- if (resolve(dirname(resolvedLink), target) === resolvedLink) return true
197
- removeLink(link)
228
+ function safeReadlink(path: string): string {
198
229
  try {
199
- symlinkSync(target, link)
230
+ return readlinkSync(path)
200
231
  } catch {
201
- return false
232
+ return ""
202
233
  }
203
- return lstat(link)?.isSymbolicLink() === true
204
234
  }
205
235
 
206
- function linkOrCopyWithWarnings(target: string, link: string, services: EngineServices): boolean {
207
- const linked = linkOrCopy(target, link)
208
- if (!linked) {
209
- services.logger.warn(`could not create symlink ${link}`)
236
+
237
+ /** Remove a symlink without touching a real directory. */
238
+ function removeLink(path: string): boolean {
239
+ try {
240
+ rmSync(path, { force: true })
241
+ return true
242
+ } catch {
243
+ return lstat(path) === undefined
210
244
  }
211
- return linked
212
245
  }
213
246
 
214
- // ------------------------------------------------- toolchain callbacks ----
215
-
216
- /** skills::_effect_solutions_install. */
217
- export function effectSolutionsInstall(
218
- ctx: Ctx
219
- ): (mode: "install" | "upgrade", version: string, services: EngineServices) => Promise<number> {
220
- return async (mode, version, services) => {
221
- const { change, clearProgress, progress, verbose, warn } = services.logger
222
- const verb = mode === "upgrade" ? "Upgrading" : "Installing"
223
- const pkg = `effect-solutions@${version !== "" ? version : "latest"}`
224
-
225
- const bunState = await bunBootstrap(ctx, services)
226
- if (bunState.kind === "deferred") return 1
227
- const bun = bunState.executable
247
+ /** skills::_link_or_copy try directory links in host order, then a marked copy. */
248
+ export function linkOrCopy(
249
+ target: string,
250
+ link: string,
251
+ kinds: ReadonlyArray<DirectoryLinkKind> = hostOs().directoryLinkKinds
252
+ ): LinkOutcome {
253
+ const resolvedLink = resolve(link)
254
+ const absoluteTarget = resolve(dirname(resolvedLink), target)
255
+ if (absoluteTarget === resolvedLink) return "symlink"
256
+ removeLink(link)
228
257
 
229
- verbose(`${verb} effect-solutions CLI via bun${version !== "" ? ` (pinned ${version})` : ""}...`)
230
- progress(`${verb} effect-solutions CLI...`)
231
- const installResult = await spawnProcess(bun, ["add", "-g", pkg], { stdio: "ignore" })
232
- clearProgress()
233
- if (installResult.exitCode !== 0) {
234
- warn(`bun add -g ${pkg} failed. Try manually: bun add -g ${pkg}`)
235
- return 1
258
+ for (const kind of kinds) {
259
+ try {
260
+ if (kind === "symlink") {
261
+ symlinkSync(target, link)
262
+ } else {
263
+ symlinkSync(absoluteTarget, link, "junction")
264
+ }
265
+ if (linksToDirectory(link)) return kind
266
+ } catch {
267
+ // The runtime decides whether each mechanism works; try the next one.
236
268
  }
269
+ removeLink(link)
270
+ }
237
271
 
238
- const location = await services.deps.location("effect-solutions")
239
- const gbin = location.binDir
240
- if (location.path !== "") {
241
- mkdirSync(p(ctx.home, ".local", "bin"), { recursive: true })
242
- linkOrCopyWithWarnings(bun, p(ctx.home, ".local", "bin", "bun"), services)
243
- linkOrCopyWithWarnings(location.path, p(ctx.home, ".local", "bin", "effect-solutions"), services)
244
- change("effect-solutions CLI ready (linked bun + effect-solutions into ~/.local/bin)")
245
- } else {
246
- warn(`effect-solutions installed but binary not found under '${gbin !== "" ? gbin : "<unknown>"}' — link it onto PATH manually`)
272
+ const copyDestinationWasAbsent = lstat(link) === undefined
273
+ try {
274
+ cpSync(absoluteTarget, link, { recursive: true })
275
+ writeFileSync(p(link, COPY_MARKER), "")
276
+ return "copy"
277
+ } catch {
278
+ if (copyDestinationWasAbsent) {
279
+ try {
280
+ rmSync(link, { recursive: true, force: true })
281
+ } catch {
282
+ // The outcome remains failed; a later sync can retry the destination.
283
+ }
247
284
  }
248
- return 0
285
+ return "failed"
249
286
  }
250
287
  }
251
288
 
252
- async function syncEffectSolutionsCli(ctx: Ctx): Promise<void> {
253
- const { clearProgress, progress, warn } = ctx.services.logger
254
- const settings = parseJson(payloadText("SoT/.claude/settings.json"))
255
- if (settings === undefined || !isObject(settings)) return
256
- const enabledPlugins = settings["enabledPlugins"]
257
- if (enabledPlugins === undefined || !isObject(enabledPlugins) || enabledPlugins["effect-kit@docks"] !== true) return
258
-
259
- progress("Checking effect-solutions CLI...")
260
- const result = await ensure(ctx, "effect-solutions", effectSolutionsInstall(ctx))
261
- clearProgress()
262
- if (result !== 0) {
263
- warn("effect-solutions bootstrap failed — continuing sync")
289
+ function linkOrCopyWithWarnings(target: string, link: string, services: EngineServices): LinkOutcome {
290
+ const outcome = linkOrCopy(target, link)
291
+ if (outcome === "copy") {
292
+ services.logger.warn(`created copy fallback ${link} because directory linking is unavailable — a later sync will restore a real link once linking works`)
293
+ } else if (outcome === "failed") {
294
+ services.logger.warn(`could not create symlink ${link}`)
264
295
  }
296
+ return outcome
265
297
  }
266
298
 
267
299
  // ----------------------------------------------------- prune + snapshot ----
@@ -286,8 +318,13 @@ async function reconcileRemovals(ctx: Ctx, manifest: string, snapshot: string):
286
318
  if (current.includes(slug)) continue
287
319
  const base = slug.slice(slug.lastIndexOf("/") + 1)
288
320
  if (currentBases.has(base)) continue
321
+ const claudeEntry = p(ctx.home, ".claude", "skills", base)
322
+ const managedClaudeEntry = lstat(claudeEntry)?.isSymbolicLink() === true || isKitOwnedCopy(claudeEntry)
289
323
  if (ctx.dryRun) {
290
324
  echo(`[dry-run] kit-managed skill no longer in SoT — would remove: ${base}`)
325
+ if (managedClaudeEntry) {
326
+ echo(`[dry-run] kit-managed Claude skill entry — would remove: ~/.claude/skills/${base}`)
327
+ }
291
328
  continue
292
329
  }
293
330
  progress(`Removing universal skill ${base}...`)
@@ -295,13 +332,24 @@ async function reconcileRemovals(ctx: Ctx, manifest: string, snapshot: string):
295
332
  stdio: "ignore"
296
333
  })
297
334
  clearProgress()
298
- if (res.error === undefined && res.exitCode === 0) {
299
- removed++
300
- } else {
335
+ if (res.error !== undefined || res.exitCode !== 0) {
301
336
  warn(`Failed to remove kit-managed skill: ${base}`)
302
337
  failed++
303
338
  failedSlugs.push(slug)
339
+ continue
340
+ }
341
+ if (managedClaudeEntry) {
342
+ const entryStat = lstat(claudeEntry)
343
+ const removedClaudeEntry = entryStat === undefined
344
+ || (entryStat.isSymbolicLink() ? removeLink(claudeEntry) : removeKitOwnedCopy(claudeEntry))
345
+ if (!removedClaudeEntry) {
346
+ warn(`Failed to remove kit-managed Claude skill entry: ${base}`)
347
+ failed++
348
+ failedSlugs.push(slug)
349
+ continue
350
+ }
304
351
  }
352
+ removed++
305
353
  }
306
354
 
307
355
  if (removed > 0) {