docks-kit 0.15.1 → 0.15.2

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.
@@ -17,12 +17,15 @@ import { claudeNextSteps, claudeSummary, claudeSync, type ClaudeRuntimeState } f
17
17
  import { codexNextSteps, codexSummary, codexSync } from "./codexSync"
18
18
  import { normalizeManifest, skillsNextSteps, skillsSummary, skillsSync, type SkillsState } from "./skillsSync"
19
19
  import { modeModel, modeToolchain } from "./modes"
20
- import { ExitError, parseArgs, validateModifierFlags } from "./parseArgs"
20
+ import { ExitError, parseArgs, parseClaudePlugin, parseCompactWindow, validateModifierFlags } from "./parseArgs"
21
21
 
22
22
  export type ModifierFlag =
23
23
  | "--claude-model"
24
24
  | "--claude-effort"
25
25
  | "--claude-advisor"
26
+ | "--claude-compact-window"
27
+ | "--claude-permissive"
28
+ | "--claude-plugin"
26
29
  | "--codex-model"
27
30
  | "--codex-effort"
28
31
 
@@ -117,6 +120,16 @@ export interface Ctx {
117
120
  function makeCtx(services: EngineServices): Ctx {
118
121
  const env = process.env
119
122
  const home = env["HOME"] !== undefined && env["HOME"] !== "" ? env["HOME"] : homedir()
123
+ const compactWindowSource = env["CLAUDE_COMPACT_WINDOW"] ?? ""
124
+ const claudeCompactWindow = compactWindowSource === "" ? "" : parseCompactWindow(compactWindowSource)
125
+ if (claudeCompactWindow === undefined) {
126
+ services.logger.err("CLAUDE_COMPACT_WINDOW expects a token count (e.g. 680000 or 680k)")
127
+ throw new ExitError(2)
128
+ }
129
+ const claudePlugins = (env["CLAUDE_PLUGINS"] ?? "")
130
+ .split(" ")
131
+ .filter((plugin) => plugin !== "")
132
+ .map((plugin) => parseClaudePlugin(plugin, services.logger.err))
120
133
  return {
121
134
  repoDir: kitHome(),
122
135
  home,
@@ -128,9 +141,9 @@ function makeCtx(services: EngineServices): Ctx {
128
141
  reconcile: env["RECONCILE"] === "1",
129
142
  prune: env["PRUNE"] === "1",
130
143
  assumeYes: env["ASSUME_YES"] === "1",
131
- claudeCompactWindow: env["CLAUDE_COMPACT_WINDOW"] ?? "",
144
+ claudeCompactWindow,
132
145
  claudePermissive: env["CLAUDE_PERMISSIVE"] === "1",
133
- claudePlugins: (env["CLAUDE_PLUGINS"] ?? "").split(" ").filter((s) => s !== ""),
146
+ claudePlugins,
134
147
  claudeModel: env["CLAUDE_MODEL"] ?? "",
135
148
  claudeEffort: "",
136
149
  claudeAdvisor: "",
@@ -278,8 +291,8 @@ export async function runEngineNative(argv: ReadonlyArray<string>, services?: En
278
291
  deps: baseServices.deps,
279
292
  platform: baseServices.platform
280
293
  }
281
- ctx = makeCtx(runServices)
282
294
  try {
295
+ ctx = makeCtx(runServices)
283
296
  switch (argv[0]) {
284
297
  case "model":
285
298
  return modeModel(ctx, argv.slice(1))
@@ -6,16 +6,9 @@ import type { Ctx } from "./index"
6
6
  import { isObject, parseJson, type Json } from "./jq"
7
7
  import { payloadDisplayPath, payloadText } from "../payload"
8
8
 
9
- function catalog(): Json | undefined {
10
- try {
11
- return parseJson(payloadText("SoT/models.json"))
12
- } catch {
13
- return undefined
14
- }
15
- }
16
9
 
17
10
  function toolEntry(tool: string): { [k: string]: Json } | undefined {
18
- const doc = catalog()
11
+ const doc = parseJson(payloadText("SoT/models.json"))
19
12
  if (doc === undefined || !isObject(doc)) return undefined
20
13
  const entry = doc[tool]
21
14
  return entry !== undefined && isObject(entry) ? entry : undefined
@@ -37,7 +30,7 @@ export function printModels(ctx: Ctx, tool: string): void {
37
30
  const { echo, warn } = ctx.services.logger
38
31
  const entry = toolEntry(tool)
39
32
  if (entry === undefined) {
40
- warn(`Model catalog unavailable (${payloadDisplayPath("SoT/models.json", ctx.repoDir)})`)
33
+ warn(`Model catalog unavailable (${payloadDisplayPath("SoT/models.json")})`)
41
34
  return
42
35
  }
43
36
  const verified = typeof entry["verified"] === "string" ? entry["verified"] : "?"
@@ -37,19 +37,29 @@ export function modeModel(ctx: Ctx, args: ReadonlyArray<string>): number {
37
37
  if (value === "") {
38
38
  if (tool === "claude") {
39
39
  const deployed = p(ctx.home, ".claude", "settings.json")
40
- if (!fileReadable(deployed)) {
40
+ const result = readConfig(deployed)
41
+ if (result.kind === "missing") {
41
42
  warn("~/.claude/settings.json missing")
42
43
  return 0
43
44
  }
44
- echo(`deployed: ${jsonModelField(deployed)}`)
45
+ if (result.kind === "read-error") {
46
+ err(`Failed to read ~/.claude/settings.json: ${String(result.error)}`)
47
+ return 1
48
+ }
49
+ echo(`deployed: ${jsonModelText(result.data)}`)
45
50
  echo(`SoT: ${jsonModelText(payloadText("SoT/.claude/settings.json"))}`)
46
51
  } else {
47
52
  const deployed = p(ctx.home, ".codex", "config.toml")
48
- if (!fileReadable(deployed)) {
53
+ const result = readConfig(deployed)
54
+ if (result.kind === "missing") {
49
55
  warn("~/.codex/config.toml missing")
50
56
  return 0
51
57
  }
52
- echo(`deployed: ${tomlModelField(deployed)}`)
58
+ if (result.kind === "read-error") {
59
+ err(`Failed to read ~/.codex/config.toml: ${String(result.error)}`)
60
+ return 1
61
+ }
62
+ echo(`deployed: ${tomlModelText(result.data)}`)
53
63
  echo(`SoT: ${tomlModelText(payloadText("SoT/.codex/config.toml"))}`)
54
64
  }
55
65
  printModels(ctx, tool)
@@ -74,20 +84,23 @@ export function modeModel(ctx: Ctx, args: ReadonlyArray<string>): number {
74
84
  return 0
75
85
  }
76
86
 
77
- function fileReadable(p: string): boolean {
87
+ type ConfigReadResult =
88
+ | { readonly kind: "missing" }
89
+ | { readonly kind: "read-error"; readonly error: unknown }
90
+ | { readonly kind: "data"; readonly data: string }
91
+
92
+ function readConfig(file: string): ConfigReadResult {
78
93
  try {
79
- readFileSync(p)
80
- return true
81
- } catch {
82
- return false
94
+ return { kind: "data", data: readFileSync(file, "utf8") }
95
+ } catch (error) {
96
+ const code =
97
+ typeof error === "object" && error !== null && "code" in error && typeof error.code === "string"
98
+ ? error.code
99
+ : undefined
100
+ return code === "ENOENT" ? { kind: "missing" } : { kind: "read-error", error }
83
101
  }
84
102
  }
85
103
 
86
- /** `jq -r '.model // "default (unset)"'` — empty on unparseable input. */
87
- function jsonModelField(file: string): string {
88
- return jsonModelText(readFileSync(file, "utf8"))
89
- }
90
-
91
104
  function jsonModelText(text: string): string {
92
105
  const doc = parseJson(text)
93
106
  if (doc === undefined) return ""
@@ -97,9 +110,6 @@ function jsonModelText(text: string): string {
97
110
  }
98
111
 
99
112
  /** `awk -F'"' '/^model[[:space:]]*=/{print $2; exit}'`. */
100
- function tomlModelField(file: string): string {
101
- return tomlModelText(readFileSync(file, "utf8"))
102
- }
103
113
 
104
114
  function tomlModelText(text: string): string {
105
115
  for (const line of text.split("\n")) {
@@ -110,9 +120,9 @@ function tomlModelText(text: string): string {
110
120
 
111
121
  export async function modeToolchain(ctx: Ctx, args: ReadonlyArray<string>): Promise<number> {
112
122
  const { err } = ctx.services.logger
113
- const words = args.filter((a) => !a.startsWith("--"))
114
- const op = words[0] ?? args[0] ?? "check"
115
- const tool = words[1] ?? args[1] ?? ""
123
+ const words = args.filter((arg) => !arg.startsWith("--"))
124
+ const op = words[0] ?? "check"
125
+ const tool = words[1] ?? ""
116
126
  for (const arg of args) {
117
127
  if (arg === "--yes") ctx.assumeYes = true
118
128
  else if (arg === "--verbose") {
@@ -128,7 +138,7 @@ export async function modeToolchain(ctx: Ctx, args: ReadonlyArray<string>): Prom
128
138
  err("Usage: toolchain [check|ensure <tool>] [--yes]")
129
139
  return 2
130
140
  }
131
- if (tool === "" || tool === "--yes") {
141
+ if (tool === "") {
132
142
  err("Usage: toolchain ensure <tool> [--yes]")
133
143
  return 2
134
144
  }
@@ -14,11 +14,3 @@ export function platformName(pf: NodeJS.Platform = rawPlatform()): PlatformName
14
14
  return pf === "linux" ? "linux" : pf === "darwin" ? "darwin" : "unknown"
15
15
  }
16
16
 
17
- export function isLinux(): boolean {
18
- return rawPlatform() === "linux"
19
- }
20
-
21
- /** Shell-rc exports apply only on supported hosts. */
22
- export function shellRcApplicable(): boolean {
23
- return platformName() !== "unknown"
24
- }
@@ -24,13 +24,96 @@ export class ExitError extends Error {
24
24
  }
25
25
 
26
26
  export const KNOWN_CLAUDE_OPTIN_PLUGINS = ["supabase", "n8n"]
27
- const MODIFIER_FLAGS = new Set<ModifierFlag>([
28
- "--claude-model",
29
- "--claude-effort",
30
- "--claude-advisor",
31
- "--codex-model",
32
- "--codex-effort"
33
- ])
27
+
28
+ interface ModifierMetadata {
29
+ readonly target: "claude" | "codex"
30
+ readonly ignoredWarning: string
31
+ readonly hasValue: (ctx: Ctx) => boolean
32
+ readonly clear: (ctx: Ctx) => void
33
+ }
34
+
35
+ const MODIFIER_METADATA = {
36
+ "--claude-model": {
37
+ target: "claude",
38
+ ignoredWarning: "--claude-model ignored: claude target not selected",
39
+ hasValue: (ctx) => ctx.claudeModel !== "",
40
+ clear: (ctx) => {
41
+ ctx.claudeModel = ""
42
+ }
43
+ },
44
+ "--claude-effort": {
45
+ target: "claude",
46
+ ignoredWarning: "--claude-effort ignored: claude target not selected",
47
+ hasValue: (ctx) => ctx.claudeEffort !== "",
48
+ clear: (ctx) => {
49
+ ctx.claudeEffort = ""
50
+ }
51
+ },
52
+ "--claude-advisor": {
53
+ target: "claude",
54
+ ignoredWarning: "--claude-advisor ignored: claude target not selected",
55
+ hasValue: (ctx) => ctx.claudeAdvisor !== "",
56
+ clear: (ctx) => {
57
+ ctx.claudeAdvisor = ""
58
+ }
59
+ },
60
+ "--claude-compact-window": {
61
+ target: "claude",
62
+ ignoredWarning: "--claude-compact-window ignored: claude target not selected",
63
+ hasValue: (ctx) => ctx.claudeCompactWindow !== "",
64
+ clear: (ctx) => {
65
+ ctx.claudeCompactWindow = ""
66
+ }
67
+ },
68
+ "--claude-permissive": {
69
+ target: "claude",
70
+ ignoredWarning: "--claude-permissive ignored: claude target not selected",
71
+ hasValue: (ctx) => ctx.claudePermissive,
72
+ clear: (ctx) => {
73
+ ctx.claudePermissive = false
74
+ }
75
+ },
76
+ "--claude-plugin": {
77
+ target: "claude",
78
+ ignoredWarning: "--claude-plugin ignored: claude target not selected",
79
+ hasValue: (ctx) => ctx.claudePlugins.length > 0,
80
+ clear: (ctx) => {
81
+ ctx.claudePlugins = []
82
+ }
83
+ },
84
+ "--codex-model": {
85
+ target: "codex",
86
+ ignoredWarning: "--codex-model ignored: codex target not selected",
87
+ hasValue: (ctx) => ctx.codexModel !== "",
88
+ clear: (ctx) => {
89
+ ctx.codexModel = ""
90
+ }
91
+ },
92
+ "--codex-effort": {
93
+ target: "codex",
94
+ ignoredWarning: "--codex-effort ignored: codex target not selected",
95
+ hasValue: (ctx) => ctx.codexEffort !== "",
96
+ clear: (ctx) => {
97
+ ctx.codexEffort = ""
98
+ }
99
+ }
100
+ } satisfies Record<ModifierFlag, ModifierMetadata>
101
+
102
+ type ScalarModifierFlag =
103
+ | "--claude-model"
104
+ | "--claude-effort"
105
+ | "--claude-advisor"
106
+ | "--codex-model"
107
+ | "--codex-effort"
108
+
109
+ const SCALAR_MODIFIER_FLAGS: Record<ScalarModifierFlag, true> = {
110
+ "--claude-model": true,
111
+ "--claude-effort": true,
112
+ "--claude-advisor": true,
113
+ "--codex-model": true,
114
+ "--codex-effort": true
115
+ }
116
+
34
117
 
35
118
  function usage(ctx: Ctx): void {
36
119
  const { echo } = ctx.services.logger
@@ -86,13 +169,33 @@ export function parseCompactWindow(v: string): string | undefined {
86
169
  return /^[0-9]+$/.test(v) ? v : undefined
87
170
  }
88
171
 
89
- function addClaudePlugin(ctx: Ctx, name: string): void {
90
- const { err } = ctx.services.logger
172
+ export function parseClaudePlugin(name: string, err: (message: string) => void): string {
91
173
  if (!KNOWN_CLAUDE_OPTIN_PLUGINS.includes(name)) {
92
174
  err(`Unknown opt-in plugin '${name}'. Known: ${KNOWN_CLAUDE_OPTIN_PLUGINS.join(", ")}`)
93
175
  throw new ExitError(2)
94
176
  }
95
- ctx.claudePlugins.push(name)
177
+ return name
178
+ }
179
+
180
+ function markModifier(ctx: Ctx, flag: ModifierFlag): void {
181
+ const flags = ctx.modifierFlags ?? new Set<ModifierFlag>()
182
+ flags.add(flag)
183
+ ctx.modifierFlags = flags
184
+ }
185
+
186
+ function addClaudePlugin(ctx: Ctx, name: string): void {
187
+ if (name === "") {
188
+ printCatalog(
189
+ ctx,
190
+ `Available Claude optional plugins:\n${KNOWN_CLAUDE_OPTIN_PLUGINS.map((plugin) => ` ${plugin}`).join("\n")}`
191
+ )
192
+ ctx.services.logger.err(
193
+ `Invalid Claude plugin '' — valid: ${KNOWN_CLAUDE_OPTIN_PLUGINS.join("|")}`
194
+ )
195
+ throw new ExitError(2)
196
+ }
197
+ ctx.claudePlugins.push(parseClaudePlugin(name, ctx.services.logger.err))
198
+ markModifier(ctx, "--claude-plugin")
96
199
  }
97
200
 
98
201
  function selectTarget(ctx: Ctx, target: string): void {
@@ -102,7 +205,7 @@ function selectTarget(ctx: Ctx, target: string): void {
102
205
  ctx.targetFilterSet = true
103
206
  }
104
207
 
105
- function setModifier(ctx: Ctx, flag: ModifierFlag, value: string): void {
208
+ function setModifier(ctx: Ctx, flag: ScalarModifierFlag, value: string): void {
106
209
  switch (flag) {
107
210
  case "--claude-model":
108
211
  ctx.claudeModel = value
@@ -120,20 +223,19 @@ function setModifier(ctx: Ctx, flag: ModifierFlag, value: string): void {
120
223
  ctx.codexEffort = value
121
224
  break
122
225
  }
123
- const flags = ctx.modifierFlags ?? new Set<ModifierFlag>()
124
- flags.add(flag)
125
- ctx.modifierFlags = flags
226
+ markModifier(ctx, flag)
126
227
  }
127
228
 
128
- function isModifierFlag(value: string): value is ModifierFlag {
129
- return MODIFIER_FLAGS.has(value as ModifierFlag)
229
+ function isScalarModifierFlag(value: string): value is ScalarModifierFlag {
230
+ return SCALAR_MODIFIER_FLAGS[value as ScalarModifierFlag] === true
130
231
  }
131
232
 
233
+
132
234
  export function parseArgs(ctx: Ctx, args: ReadonlyArray<string>): void {
133
235
  const { err } = ctx.services.logger
134
236
  for (let index = 0; index < args.length; index += 1) {
135
237
  const arg = args[index] ?? ""
136
- if (isModifierFlag(arg) && args[index + 1] === "") {
238
+ if (isScalarModifierFlag(arg) && args[index + 1] === "") {
137
239
  setModifier(ctx, arg, "")
138
240
  index += 1
139
241
  continue
@@ -190,6 +292,7 @@ export function parseArgs(ctx: Ctx, args: ReadonlyArray<string>): void {
190
292
  throw new ExitError(2)
191
293
  case "--claude-permissive":
192
294
  ctx.claudePermissive = true
295
+ markModifier(ctx, "--claude-permissive")
193
296
  continue
194
297
  case "--claude-plugin":
195
298
  err(`--claude-plugin requires a value: --claude-plugin=<${KNOWN_CLAUDE_OPTIN_PLUGINS.join("|")}>`)
@@ -244,8 +347,12 @@ export function parseArgs(ctx: Ctx, args: ReadonlyArray<string>): void {
244
347
  throw new ExitError(2)
245
348
  }
246
349
  ctx.claudeCompactWindow = parsed
350
+ markModifier(ctx, "--claude-compact-window")
247
351
  } else if (arg.startsWith("--claude-plugin=")) {
248
352
  addClaudePlugin(ctx, arg.slice("--claude-plugin=".length))
353
+ } else if (arg.startsWith("--claude-permissive=")) {
354
+ err("--claude-permissive does not take a value")
355
+ throw new ExitError(2)
249
356
  } else {
250
357
  err(`Unknown arg: ${arg}`)
251
358
  throw new ExitError(2)
@@ -266,53 +373,49 @@ function printCatalog(ctx: Ctx, catalog: string): void {
266
373
 
267
374
  export function validateModifierFlags(ctx: Ctx): void {
268
375
  const { err, warn } = ctx.services.logger
269
- const supplied = (flag: ModifierFlag, value: string): boolean =>
270
- value !== "" || ctx.modifierFlags?.has(flag) === true
271
- if (supplied("--claude-model", ctx.claudeModel)) {
272
- if (!ctx.syncClaude) {
273
- warn("--claude-model ignored: claude target not selected")
274
- ctx.claudeModel = ""
275
- } else if (!validateClaudeModel(ctx, ctx.claudeModel)) {
376
+ const supplied = (flag: ModifierFlag): boolean =>
377
+ MODIFIER_METADATA[flag].hasValue(ctx) || ctx.modifierFlags?.has(flag) === true
378
+
379
+ for (const flag of Object.keys(MODIFIER_METADATA) as Array<ModifierFlag>) {
380
+ const metadata = MODIFIER_METADATA[flag]
381
+ const targetSelected = metadata.target === "claude" ? ctx.syncClaude : ctx.syncCodex
382
+ if (!targetSelected && supplied(flag)) {
383
+ warn(metadata.ignoredWarning)
384
+ metadata.clear(ctx)
385
+ ctx.modifierFlags?.delete(flag)
386
+ }
387
+ }
388
+
389
+ if (supplied("--claude-model")) {
390
+ if (!validateClaudeModel(ctx, ctx.claudeModel)) {
276
391
  printModels(ctx, "claude")
277
392
  err(`Invalid Claude model '${ctx.claudeModel}' — use an alias above or a full claude-* ID`)
278
393
  throw new ExitError(2)
279
394
  }
280
395
  }
281
- if (supplied("--claude-effort", ctx.claudeEffort)) {
282
- if (!ctx.syncClaude) {
283
- warn("--claude-effort ignored: claude target not selected")
284
- ctx.claudeEffort = ""
285
- } else if (!isEffortModifierValue("claude", ctx.claudeEffort)) {
396
+ if (supplied("--claude-effort")) {
397
+ if (!isEffortModifierValue("claude", ctx.claudeEffort)) {
286
398
  printCatalog(ctx, effortCatalog("claude"))
287
399
  err(`Invalid Claude effort '${ctx.claudeEffort}' — valid: ${effortValueGrammar("claude")}`)
288
400
  throw new ExitError(2)
289
401
  }
290
402
  }
291
- if (supplied("--claude-advisor", ctx.claudeAdvisor)) {
292
- if (!ctx.syncClaude) {
293
- warn("--claude-advisor ignored: claude target not selected")
294
- ctx.claudeAdvisor = ""
295
- } else if (!CLAUDE_ADVISOR_STATES.some((state) => state === ctx.claudeAdvisor)) {
403
+ if (supplied("--claude-advisor")) {
404
+ if (!CLAUDE_ADVISOR_STATES.some((state) => state === ctx.claudeAdvisor)) {
296
405
  printCatalog(ctx, advisorCatalog())
297
406
  err(`Invalid Claude advisor state '${ctx.claudeAdvisor}' — valid: ${advisorValueGrammar()}`)
298
407
  throw new ExitError(2)
299
408
  }
300
409
  }
301
- if (supplied("--codex-model", ctx.codexModel)) {
302
- if (!ctx.syncCodex) {
303
- warn("--codex-model ignored: codex target not selected")
304
- ctx.codexModel = ""
305
- } else if (!validateCodexModel(ctx, ctx.codexModel)) {
410
+ if (supplied("--codex-model")) {
411
+ if (!validateCodexModel(ctx, ctx.codexModel)) {
306
412
  printModels(ctx, "codex")
307
413
  err(`Invalid Codex model '${ctx.codexModel}' — must match ^[A-Za-z0-9._-]+$`)
308
414
  throw new ExitError(2)
309
415
  }
310
416
  }
311
- if (supplied("--codex-effort", ctx.codexEffort)) {
312
- if (!ctx.syncCodex) {
313
- warn("--codex-effort ignored: codex target not selected")
314
- ctx.codexEffort = ""
315
- } else if (!isEffortModifierValue("codex", ctx.codexEffort)) {
417
+ if (supplied("--codex-effort")) {
418
+ if (!isEffortModifierValue("codex", ctx.codexEffort)) {
316
419
  printCatalog(ctx, effortCatalog("codex"))
317
420
  err(`Invalid Codex effort '${ctx.codexEffort}' — valid: ${effortValueGrammar("codex")}`)
318
421
  throw new ExitError(2)
@@ -36,8 +36,6 @@ export interface DependencyManager {
36
36
  export interface Platform {
37
37
  readonly raw: () => NodeJS.Platform
38
38
  readonly name: () => PlatformName
39
- readonly isLinux: () => boolean
40
- readonly shellRcApplicable: () => boolean
41
39
  }
42
40
 
43
41
  export interface EngineServices {
@@ -53,9 +51,7 @@ export interface EngineServiceOptions {
53
51
  /** Platform view over an injectable platform id. */
54
52
  export const makePlatform = (pf: NodeJS.Platform = rawPlatform()): Platform => ({
55
53
  raw: () => pf,
56
- name: () => platformName(pf),
57
- isLinux: () => pf === "linux",
58
- shellRcApplicable: () => pf === "linux" || pf === "darwin"
54
+ name: () => platformName(pf)
59
55
  })
60
56
 
61
57
  /** DependencyManager whose hints default to the INJECTED platform, not the host. */
@@ -16,8 +16,9 @@ export function reconcileSettings(repo: Json, user: Json): Json {
16
16
  * `unique` — i.e. codepoint-sorted and deduplicated, matching jq).
17
17
  */
18
18
  export function mergeSettings(repo: Json, user: Json): Json {
19
- const merged = deepMerge(user, repo)
20
- if (!isObject(merged)) return merged
19
+ const candidate = deepMerge(user, repo)
20
+ if (!isObject(candidate)) return candidate
21
+ const merged = { ...candidate }
21
22
  const permissions = isObject(merged["permissions"]) ? merged["permissions"] : {}
22
23
  merged["permissions"] = {
23
24
  ...permissions,
@@ -4,14 +4,16 @@
4
4
  * the kit-managed snapshot, the effect-solutions toolchain callback, and the
5
5
  * snapshot write.
6
6
  */
7
- import { cpSync, existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, rmSync, statSync, symlinkSync } from "node:fs"
7
+ import { existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, rmSync, statSync, symlinkSync } from "node:fs"
8
+ import { dirname, relative, resolve } from "node:path"
8
9
  import { p, spawnProcess, writeFileIfChanged } from "./exec"
9
10
  import { bunBootstrap } from "./bun"
10
11
  import type { Ctx } from "./index"
11
- import { compareCodepoints } from "./jq"
12
+ import { compareCodepoints, isObject, parseJson } from "./jq"
12
13
  import type { EngineServices } from "./services"
13
14
  import { ensure, field } from "./toolchain"
14
15
  import { payloadText } from "../payload"
16
+ import { ExitError } from "./parseArgs"
15
17
 
16
18
  export interface SkillsState {
17
19
  present: number
@@ -26,16 +28,18 @@ export async function skillsSync(ctx: Ctx): Promise<SkillsState> {
26
28
  if (!ctx.dryRun) mkdirSync(skillsDir, { recursive: true })
27
29
 
28
30
  await syncUniversal(ctx, state, skillsDir, manifest)
29
- if (ctx.prune) await reconcileRemovals(ctx, manifest, snapshot)
31
+ const failedRemovals = ctx.prune ? await reconcileRemovals(ctx, manifest, snapshot) : []
30
32
  await syncEffectSolutionsCli(ctx)
31
- updateSnapshot(ctx, manifest, snapshot)
33
+ updateSnapshot(ctx, manifest, snapshot, failedRemovals)
32
34
  return state
33
35
  }
34
36
 
35
37
  /** skills::_skills_cli — the pinned npx package spec. */
36
38
  function skillsCli(ctx: Ctx): string {
37
- const v = field(ctx, "skills-cli", "verified")
38
- return v !== "" ? `skills@${v}` : "skills"
39
+ const version = field(ctx, "skills-cli", "verified")
40
+ if (version !== "") return `skills@${version}`
41
+ ctx.services.logger.err("Universal skills sync aborted because SoT/toolchain.json has no verified skills-cli pin")
42
+ throw new ExitError(1)
39
43
  }
40
44
 
41
45
  /** skills::_normalize_manifest — cleaned slugs, one per line. */
@@ -131,7 +135,7 @@ function healClaudeSymlink(ctx: Ctx, skillsDir: string, base: string): boolean {
131
135
  const canonical = p(skillsDir, base)
132
136
  const claudeSkillsDir = p(ctx.home, ".claude", "skills")
133
137
  const claudeLink = p(claudeSkillsDir, base)
134
- const relTarget = `../../.agents/skills/${base}`
138
+ const relTarget = relative(dirname(claudeLink), canonical)
135
139
 
136
140
  if (!isDir(canonical)) return false
137
141
 
@@ -186,31 +190,23 @@ function removeLink(path: string): boolean {
186
190
  }
187
191
  }
188
192
 
189
- /** skills::_link_or_copy — real symlink preferred, copy fallback. */
193
+ /** skills::_link_or_copy — create a symlink without replacing its source. */
190
194
  export function linkOrCopy(target: string, link: string): boolean {
195
+ const resolvedLink = resolve(link)
196
+ if (resolve(dirname(resolvedLink), target) === resolvedLink) return true
191
197
  removeLink(link)
192
198
  try {
193
199
  symlinkSync(target, link)
194
200
  } catch {
195
- // fall through to the copy fallback below
196
- }
197
- if (lstat(link)?.isSymbolicLink() === true) return true
198
- try {
199
- // Resolve a relative target against the link's parent, like ln does.
200
- const resolved = target.startsWith("/") ? target : p(link.slice(0, link.lastIndexOf("/")), target)
201
- cpSync(resolved, link, { recursive: true })
202
- } catch {
203
- // fall through to the existence check below
201
+ return false
204
202
  }
205
- return existsSync(link)
203
+ return lstat(link)?.isSymbolicLink() === true
206
204
  }
207
205
 
208
206
  function linkOrCopyWithWarnings(target: string, link: string, services: EngineServices): boolean {
209
207
  const linked = linkOrCopy(target, link)
210
208
  if (!linked) {
211
- services.logger.warn(`could not create ${link} (symlink and copy both failed)`)
212
- } else if (lstat(link)?.isSymbolicLink() !== true) {
213
- services.logger.warn(`symlinks unsupported here — ${link} is a copy refreshed on sync`)
209
+ services.logger.warn(`could not create symlink ${link}`)
214
210
  }
215
211
  return linked
216
212
  }
@@ -255,7 +251,10 @@ export function effectSolutionsInstall(
255
251
 
256
252
  async function syncEffectSolutionsCli(ctx: Ctx): Promise<void> {
257
253
  const { clearProgress, progress, warn } = ctx.services.logger
258
- if (!/"effect-kit@docks"[ \t]*:[ \t]*true/.test(payloadText("SoT/.claude/settings.json"))) return
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
259
258
 
260
259
  progress("Checking effect-solutions CLI...")
261
260
  const result = await ensure(ctx, "effect-solutions", effectSolutionsInstall(ctx))
@@ -267,7 +266,7 @@ async function syncEffectSolutionsCli(ctx: Ctx): Promise<void> {
267
266
 
268
267
  // ----------------------------------------------------- prune + snapshot ----
269
268
 
270
- async function reconcileRemovals(ctx: Ctx, manifest: string, snapshot: string): Promise<void> {
269
+ async function reconcileRemovals(ctx: Ctx, manifest: string, snapshot: string): Promise<Array<string>> {
271
270
  const { change, clearProgress, echo, progress, warn } = ctx.services.logger
272
271
  if (!existsSync(snapshot)) {
273
272
  if (ctx.dryRun) {
@@ -275,15 +274,18 @@ async function reconcileRemovals(ctx: Ctx, manifest: string, snapshot: string):
275
274
  `[dry-run] (--prune) no kit-managed-skills snapshot yet; first real sync writes ${snapshot}, then future --prune runs reconcile against it`
276
275
  )
277
276
  }
278
- return
277
+ return []
279
278
  }
280
279
 
281
280
  const current = normalizeManifest(manifest)
281
+ const currentBases = new Set(current.map((slug) => slug.slice(slug.lastIndexOf("/") + 1)))
282
282
  let removed = 0
283
283
  let failed = 0
284
+ const failedSlugs: Array<string> = []
284
285
  for (const slug of readSlugs(snapshot)) {
285
286
  if (current.includes(slug)) continue
286
287
  const base = slug.slice(slug.lastIndexOf("/") + 1)
288
+ if (currentBases.has(base)) continue
287
289
  if (ctx.dryRun) {
288
290
  echo(`[dry-run] kit-managed skill no longer in SoT — would remove: ${base}`)
289
291
  continue
@@ -298,6 +300,7 @@ async function reconcileRemovals(ctx: Ctx, manifest: string, snapshot: string):
298
300
  } else {
299
301
  warn(`Failed to remove kit-managed skill: ${base}`)
300
302
  failed++
303
+ failedSlugs.push(slug)
301
304
  }
302
305
  }
303
306
 
@@ -306,13 +309,14 @@ async function reconcileRemovals(ctx: Ctx, manifest: string, snapshot: string):
306
309
  ctx.nextStepTriggers.skillsRestart = true
307
310
  }
308
311
  if (failed > 0) warn(`${failed} skill remove(s) failed — re-run with --prune or run: npx skills remove --global <name> -y`)
312
+ return failedSlugs
309
313
  }
310
314
 
311
- function updateSnapshot(ctx: Ctx, manifest: string, snapshot: string): void {
315
+ function updateSnapshot(ctx: Ctx, manifest: string, snapshot: string, failedRemovals: ReadonlyArray<string>): void {
312
316
  if (ctx.dryRun) return
313
317
 
314
318
  mkdirSync(ctx.agentsDir, { recursive: true })
315
- const sorted = [...new Set(normalizeManifest(manifest))].sort(compareCodepoints)
319
+ const sorted = [...new Set([...normalizeManifest(manifest), ...failedRemovals])].sort(compareCodepoints)
316
320
  writeFileIfChanged(snapshot, sorted.length > 0 ? `${sorted.join("\n")}\n` : "")
317
321
  }
318
322