docks-kit 0.15.2 → 0.15.4
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 +21 -15
- package/README.md +33 -31
- package/cli/docs/flags.md +0 -1
- package/cli/docs/install.md +41 -30
- package/cli/docs/overview.md +2 -2
- package/cli/docs/platforms.md +5 -2
- package/cli/docs/sync-layers.md +3 -4
- package/cli/docs/toolchain.md +28 -34
- package/cli/src/commands/docs.ts +3 -3
- package/cli/src/commands/sync.ts +0 -5
- package/cli/src/commands/toolchain.ts +4 -7
- package/cli/src/commands/update.ts +77 -26
- package/cli/src/engine-native/DESIGN.md +31 -22
- package/cli/src/engine-native/bun.ts +10 -8
- package/cli/src/engine-native/claudeRuntime.ts +17 -9
- package/cli/src/engine-native/claudeSync.ts +52 -24
- package/cli/src/engine-native/codexSync.ts +10 -5
- package/cli/src/engine-native/deps.ts +27 -88
- package/cli/src/engine-native/exec.ts +41 -10
- package/cli/src/engine-native/index.ts +0 -2
- package/cli/src/engine-native/modes.ts +23 -14
- package/cli/src/engine-native/os/darwin.ts +62 -0
- package/cli/src/engine-native/os/index.ts +42 -0
- package/cli/src/engine-native/os/linux.ts +62 -0
- package/cli/src/engine-native/os/targets.ts +73 -0
- package/cli/src/engine-native/os/types.ts +75 -0
- package/cli/src/engine-native/os/windows.ts +176 -0
- package/cli/src/engine-native/parseArgs.ts +0 -4
- package/cli/src/engine-native/services.ts +0 -6
- package/cli/src/engine-native/skillsSync.ts +125 -77
- package/cli/src/engine-native/toolchain.ts +4 -150
- package/cli/src/engine.ts +8 -7
- package/cli/src/generated/sotPayload.ts +6 -6
- package/cli/src/manifests.ts +12 -2
- package/docks-kit +43 -2
- package/docks-kit.ps1 +153 -0
- package/package.json +11 -7
- 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
|
|
5
|
-
* snapshot write.
|
|
4
|
+
* the kit-managed snapshot, and the snapshot write.
|
|
6
5
|
*/
|
|
7
|
-
import {
|
|
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
|
|
13
|
-
import type
|
|
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
|
-
|
|
156
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
184
|
-
|
|
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
|
-
|
|
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
|
-
|
|
230
|
+
return readlinkSync(path)
|
|
200
231
|
} catch {
|
|
201
|
-
return
|
|
232
|
+
return ""
|
|
202
233
|
}
|
|
203
|
-
return lstat(link)?.isSymbolicLink() === true
|
|
204
234
|
}
|
|
205
235
|
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
):
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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
|
|
285
|
+
return "failed"
|
|
249
286
|
}
|
|
250
287
|
}
|
|
251
288
|
|
|
252
|
-
|
|
253
|
-
const
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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
|
|
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) {
|