docks-kit 0.16.4 → 0.16.6

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.
@@ -22,14 +22,14 @@ import {
22
22
  syncClaudeModel
23
23
  } from "./claudeSettingsModifiers"
24
24
  import { claudeRuntimePaths, materializeClaudeSettings, type ClaudeRuntimePaths } from "./claudeRuntime"
25
+ import { syncLspServers, syncOptionalPlugins, syncPlugins } from "./claudePlugins"
25
26
  import { RETIRED_PERMISSION_RULES } from "./claudeRetired"
26
27
  import { p, spawnProcess, writeBytesIfChanged, writeTextIfChanged } from "./exec"
27
28
  import type { Ctx } from "./index"
28
- import { compareCodepoints, deepMerge, isObject, jqStringify, parseJson, type Json } from "./jq"
29
+ import { deepMerge, isObject, jqStringify, parseJson, readJsonFile, type Json } from "./jq"
29
30
  import { ExitError } from "./parseArgs"
30
31
  import { hostOs } from "./os"
31
32
  import { mergeSettings, reconcileSettings } from "./settings"
32
- import { field } from "./toolchain"
33
33
  import { payloadBytes, payloadDisplayPath, payloadText } from "../payload"
34
34
 
35
35
  export type ClaudeRuntimeState =
@@ -556,391 +556,6 @@ function syncRemovals(ctx: Ctx, claudeDir: string, runtime: ClaudeRuntimeState):
556
556
  }
557
557
  }
558
558
 
559
- // -------------------------------------------------------------- plugins ----
560
-
561
- async function cli(args: Array<string>): Promise<{ ok: boolean; out: string }> {
562
- const res = await spawnProcess("claude", args, { stdio: ["ignore", "pipe", "pipe"] })
563
- return { ok: res.error === undefined && res.exitCode === 0, out: `${res.stdout}${res.stderr}` }
564
- }
565
-
566
- function readJsonFile(file: string): Json | undefined {
567
- return existsSync(file) ? parseJson(readFileSync(file, "utf8")) : undefined
568
- }
569
-
570
- function sortedKeys(obj: Json | undefined): Array<string> {
571
- return obj !== undefined && isObject(obj) ? Object.keys(obj).sort(compareCodepoints) : []
572
- }
573
-
574
- /** claude::_plugin_user_scope_installed. */
575
- export function pluginUserScopeInstalled(installedPlugins: string, pluginId: string): boolean {
576
- const doc = readJsonFile(installedPlugins)
577
- if (doc === undefined || !isObject(doc) || !isObject(doc["plugins"])) return false
578
- const rec = (doc["plugins"] as { [k: string]: Json })[pluginId]
579
- if (rec === undefined || rec === null) return false
580
- const records = Array.isArray(rec) ? rec : [rec]
581
- return records.some((r) => isObject(r) && r["scope"] === "user")
582
- }
583
-
584
- function nonUserScopeMarketplaces(installedDoc: Json | undefined): Set<string> {
585
- const marketplaces = new Set<string>()
586
- if (installedDoc === undefined || !isObject(installedDoc) || !isObject(installedDoc["plugins"])) {
587
- return marketplaces
588
- }
589
-
590
- for (const [pluginId, value] of Object.entries(installedDoc["plugins"])) {
591
- const records = Array.isArray(value) ? value : [value]
592
- const hasNonUserInstall = records.some(
593
- (record) => isObject(record) && (record["scope"] === "project" || record["scope"] === "local")
594
- )
595
- if (!hasNonUserInstall) continue
596
- const separator = pluginId.lastIndexOf("@")
597
- if (separator > 0) marketplaces.add(pluginId.slice(separator + 1))
598
- }
599
- return marketplaces
600
- }
601
-
602
- async function syncPlugins(ctx: Ctx, claudeDir: string): Promise<void> {
603
- const { change, clearProgress, echo, progress, verbose, warn } = ctx.services.logger
604
- const knownMarketplaces = p(claudeDir, "plugins", "known_marketplaces.json")
605
- const installedPlugins = p(claudeDir, "plugins", "installed_plugins.json")
606
-
607
- if (ctx.dryRun) {
608
- echo(
609
- ctx.skipPluginRefresh
610
- ? "[dry-run] bootstrap + install missing plugins from SoT; skip refresh-only plugin updates"
611
- : "[dry-run] bootstrap + update plugin marketplaces + plugins from SoT"
612
- )
613
- if (ctx.prune) {
614
- echo("[dry-run] (--prune) would also uninstall plugins not in SoT and remove extra marketplaces")
615
- }
616
- return
617
- }
618
-
619
- if (ctx.services.deps.probe("claude").state === "missing") {
620
- warn("claude CLI not in PATH — skipping plugin reconcile (run /plugin marketplace add + /plugin install manually)")
621
- return
622
- }
623
- if (ctx.services.deps.probe("git").state === "missing") {
624
- ctx.services.deps.warnMissing(
625
- "git",
626
- ctx.services.logger,
627
- "plugin marketplaces are git repos — Claude plugin passes skipped; re-run sync after installing"
628
- )
629
- return
630
- }
631
-
632
- const repoSettings = parseJson(payloadText("SoT/.claude/settings.json"))
633
- const repoObj = repoSettings !== undefined && isObject(repoSettings) ? repoSettings : {}
634
- const sotMarketplaces = isObject(repoObj["extraKnownMarketplaces"]) ? repoObj["extraKnownMarketplaces"] : {}
635
- const sotPlugins = isObject(repoObj["enabledPlugins"]) ? repoObj["enabledPlugins"] : {}
636
-
637
- // Pass 1 — add missing marketplaces (SoT insertion order, like to_entries).
638
- let addedMp = 0
639
- let f1 = 0
640
- for (const [mpName, mpValue] of Object.entries(sotMarketplaces)) {
641
- const known = readJsonFile(knownMarketplaces)
642
- if (known !== undefined && isObject(known) && known[mpName] !== undefined && known[mpName] !== null && known[mpName] !== false) continue
643
- const repo = isObject(mpValue) && isObject(mpValue["source"]) ? String((mpValue["source"] as { [k: string]: Json })["repo"] ?? "") : ""
644
- progress(`Adding marketplace ${mpName}...`)
645
- const marketplaceResult = await cli(["plugin", "marketplace", "add", repo])
646
- clearProgress()
647
- if (marketplaceResult.ok) {
648
- addedMp++
649
- } else {
650
- warn(`Failed to add marketplace: ${mpName} (${repo})`)
651
- f1++
652
- }
653
- }
654
-
655
- // Pass 2 — install SoT-enabled plugins missing at user scope (jq keys[] sorts),
656
- // refreshing each source marketplace once so the install resolves a current snapshot.
657
- let addedPl = 0
658
- let f2 = 0
659
- const refreshedMarketplaces = new Set<string>()
660
- for (const pluginId of sortedKeys(sotPlugins)) {
661
- if (pluginUserScopeInstalled(installedPlugins, pluginId)) continue
662
- const separator = pluginId.lastIndexOf("@")
663
- const mpName = separator > 0 ? pluginId.slice(separator + 1) : ""
664
- if (mpName !== "" && !refreshedMarketplaces.has(mpName)) {
665
- progress(`Refreshing marketplace ${mpName}...`)
666
- await cli(["plugin", "marketplace", "update", mpName])
667
- clearProgress()
668
- refreshedMarketplaces.add(mpName)
669
- }
670
- progress(`Installing plugin ${pluginId}...`)
671
- const installResult = await cli(["plugin", "install", pluginId])
672
- clearProgress()
673
- if (installResult.ok) {
674
- addedPl++
675
- } else {
676
- warn(`Failed to install plugin: ${pluginId}`)
677
- f2++
678
- }
679
- }
680
-
681
- let updatedPl = 0
682
- const installedDoc = readJsonFile(installedPlugins)
683
- const installedKeys = installedDoc !== undefined && isObject(installedDoc) ? sortedKeys(installedDoc["plugins"]) : []
684
- const nonUserMarketplaces = nonUserScopeMarketplaces(installedDoc)
685
- // Kit-owned plugin IDs: SoT-declared plus this run's --claude-plugin opt-ins.
686
- const kitPluginIds = new Set<string>(Object.keys(sotPlugins))
687
- if (ctx.claudePlugins.includes("supabase")) kitPluginIds.add("supabase@claude-plugins-official")
688
- if (ctx.claudePlugins.includes("n8n")) kitPluginIds.add("n8n-mcp-skills@n8n-mcp-skills")
689
-
690
- // Kit-owned marketplaces: SoT-declared plus every marketplace those plugins come from.
691
- const kitMarketplaces = new Set<string>(Object.keys(sotMarketplaces))
692
- for (const pluginId of kitPluginIds) {
693
- const separator = pluginId.lastIndexOf("@")
694
- if (separator > 0) kitMarketplaces.add(pluginId.slice(separator + 1))
695
- }
696
-
697
- // Pass 3 — refresh the kit-owned marketplaces unless the update command
698
- // selected its install-missing-only fast path.
699
- if (!ctx.skipPluginRefresh) {
700
- for (const mpName of [...kitMarketplaces].sort(compareCodepoints)) {
701
- progress(`Refreshing marketplace ${mpName}...`)
702
- await cli(["plugin", "marketplace", "update", mpName])
703
- clearProgress()
704
- }
705
- // Pass 4 — update the kit-owned installed plugins.
706
- for (const pluginId of [...kitPluginIds].sort(compareCodepoints)) {
707
- if (!pluginUserScopeInstalled(installedPlugins, pluginId)) continue
708
- progress(`Updating plugin ${pluginId}...`)
709
- const updateResult = await cli(["plugin", "update", pluginId, "--scope", "user"])
710
- clearProgress()
711
- if (updateResult.out.includes("Successfully updated")) updatedPl++
712
- }
713
- }
714
-
715
- // Pass 5 — prune-gated user-scope plugin uninstall.
716
- let removedPl = 0
717
- let removedMp = 0
718
- let f5 = 0
719
- let f6 = 0
720
- if (ctx.prune) {
721
- for (const pluginId of installedKeys) {
722
- if (kitPluginIds.has(pluginId)) continue
723
- if (!pluginUserScopeInstalled(installedPlugins, pluginId)) continue
724
- progress(`Uninstalling plugin ${pluginId}...`)
725
- const uninstallResult = await cli(["plugin", "uninstall", "-y", "--scope", "user", pluginId])
726
- clearProgress()
727
- if (uninstallResult.ok) {
728
- removedPl++
729
- } else {
730
- warn(`Failed to uninstall plugin: ${pluginId}`)
731
- f5++
732
- }
733
- }
734
- // Pass 6 — prune-gated marketplace removal.
735
- const known = readJsonFile(knownMarketplaces)
736
- for (const mpName of sortedKeys(known)) {
737
- if (mpName === "claude-plugins-official") continue
738
- if (nonUserMarketplaces.has(mpName)) continue
739
- if (kitMarketplaces.has(mpName)) continue
740
- progress(`Removing marketplace ${mpName}...`)
741
- const removeResult = await cli(["plugin", "marketplace", "remove", mpName])
742
- clearProgress()
743
- if (removeResult.ok) {
744
- removedMp++
745
- } else {
746
- warn(`Failed to remove marketplace: ${mpName}`)
747
- f6++
748
- }
749
- }
750
- }
751
-
752
- // Pass 7 — re-assert SoT enabled-state in the user settings.
753
- if (await reassertEnabledState(ctx, repoObj, p(claudeDir, "settings.json"))) {
754
- change("Plugin enable-state re-asserted from SoT in settings.json")
755
- ctx.nextStepTriggers.claudePlugins = true
756
- }
757
-
758
- const failed = f1 + f2 + f5 + f6
759
- if (addedMp > 0 || addedPl > 0 || updatedPl > 0 || removedPl > 0 || removedMp > 0) {
760
- change(`Plugins synced (marketplaces: +${addedMp} -${removedMp}, plugins: +${addedPl} ~${updatedPl} -${removedPl})`)
761
- ctx.nextStepTriggers.claudePlugins = true
762
- } else {
763
- verbose("Plugins already in sync")
764
- }
765
- if (failed > 0) {
766
- warn(`${failed} plugin operation(s) failed — re-run sync or install manually`)
767
- }
768
- }
769
-
770
- async function reassertEnabledState(ctx: Ctx, repoObj: { [k: string]: Json }, userSettingsFile: string): Promise<boolean> {
771
- const { warn } = ctx.services.logger
772
- if (!existsSync(userSettingsFile)) return false
773
- const sotPlugins = isObject(repoObj["enabledPlugins"]) ? repoObj["enabledPlugins"] : {}
774
-
775
- let cliDisabled = false
776
- for (const [pluginId, value] of Object.entries(sotPlugins)) {
777
- if (value !== false) continue
778
- const user = readJsonFile(userSettingsFile)
779
- const enabled = user !== undefined && isObject(user) && isObject(user["enabledPlugins"]) ? (user["enabledPlugins"] as { [k: string]: Json })[pluginId] : undefined
780
- if (enabled !== true) continue
781
- if ((await cli(["plugin", "disable", pluginId])).ok) {
782
- cliDisabled = true
783
- } else {
784
- warn(`Failed to disable SoT-false plugin: ${pluginId} (will retry next sync)`)
785
- }
786
- }
787
-
788
- const user = readJsonFile(userSettingsFile)
789
- if (user === undefined || !isObject(user)) {
790
- warn("enabledPlugins re-assert failed — false-keyed plugins may be left enabled")
791
- return false
792
- }
793
- const beforeCanonical = jqStringify(user)
794
- user["enabledPlugins"] = deepMerge(isObject(user["enabledPlugins"]) ? user["enabledPlugins"] : {}, sotPlugins)
795
- const out = jqStringify(user)
796
- if (out === beforeCanonical) return cliDisabled
797
- writeFileSync(`${userSettingsFile}.tmp`, out)
798
- renameSync(`${userSettingsFile}.tmp`, userSettingsFile)
799
- return true
800
- }
801
-
802
- // ------------------------------------------------------ optional plugins ----
803
-
804
- async function enableOptionalPlugin(ctx: Ctx, claudeDir: string, pluginId: string, marketplaceRepo: string): Promise<boolean> {
805
- const { change, clearProgress, progress, verbose, warn } = ctx.services.logger
806
- const installedPlugins = p(claudeDir, "plugins", "installed_plugins.json")
807
- const knownMarketplaces = p(claudeDir, "plugins", "known_marketplaces.json")
808
- const mpName = pluginId.slice(pluginId.lastIndexOf("@") + 1)
809
-
810
- let marketplaceAdded = false
811
- if (marketplaceRepo !== "") {
812
- const known = readJsonFile(knownMarketplaces)
813
- const has = known !== undefined && isObject(known) && known[mpName] !== undefined && known[mpName] !== null && known[mpName] !== false
814
- if (!has) {
815
- if (!(await cli(["plugin", "marketplace", "add", marketplaceRepo])).ok) {
816
- warn(`Failed to add marketplace ${marketplaceRepo} for ${pluginId}`)
817
- return false
818
- }
819
- marketplaceAdded = true
820
- }
821
- }
822
-
823
- const wasInstalled = pluginUserScopeInstalled(installedPlugins, pluginId)
824
- if (!wasInstalled) {
825
- progress(`Installing plugin ${pluginId}...`)
826
- const installResult = await cli(["plugin", "install", pluginId])
827
- clearProgress()
828
- if (!installResult.ok) {
829
- if (marketplaceAdded) change(`Optional plugin ${pluginId}: marketplace added (install failed — will retry next sync)`)
830
- warn(`Failed to install optional plugin ${pluginId}`)
831
- return marketplaceAdded
832
- }
833
- }
834
-
835
- const settingsDoc = readJsonFile(p(claudeDir, "settings.json"))
836
- const wasEnabled =
837
- settingsDoc !== undefined && isObject(settingsDoc) && isObject(settingsDoc["enabledPlugins"])
838
- ? (settingsDoc["enabledPlugins"] as { [k: string]: Json })[pluginId] === true
839
- : false
840
-
841
- if (!(await cli(["plugin", "enable", pluginId])).ok) {
842
- if (marketplaceAdded || !wasInstalled) change(`Optional plugin ${pluginId}: installed (enable failed — will retry next sync)`)
843
- warn(`Failed to enable optional plugin ${pluginId}`)
844
- return marketplaceAdded || !wasInstalled
845
- }
846
- const changed = marketplaceAdded || !wasInstalled || !wasEnabled
847
- if (changed) change(`Optional plugin opted in: ${pluginId}`)
848
- else verbose(`Optional plugin already opted in: ${pluginId} (enable re-asserted)`)
849
- return changed
850
- }
851
-
852
- async function syncOptionalPlugins(ctx: Ctx, claudeDir: string): Promise<void> {
853
- const { echo, warn } = ctx.services.logger
854
- if (ctx.claudePlugins.length === 0) return
855
-
856
- if (ctx.dryRun) {
857
- if (ctx.claudePlugins.includes("supabase")) {
858
- echo("[dry-run] (--claude-plugin=supabase) install + enable supabase@claude-plugins-official in deployed settings")
859
- }
860
- if (ctx.claudePlugins.includes("n8n")) {
861
- echo("[dry-run] (--claude-plugin=n8n) add czlonkowski/n8n-skills marketplace + install + enable n8n-mcp-skills@n8n-mcp-skills")
862
- }
863
- return
864
- }
865
-
866
- if (ctx.services.deps.probe("claude").state === "missing") {
867
- warn("claude CLI not in PATH — cannot opt in optional plugins (--claude-plugin)")
868
- return
869
- }
870
-
871
- if (ctx.claudePlugins.includes("supabase")) {
872
- if (await enableOptionalPlugin(ctx, claudeDir, "supabase@claude-plugins-official", "")) ctx.nextStepTriggers.claudePlugins = true
873
- }
874
- if (ctx.claudePlugins.includes("n8n")) {
875
- if (await enableOptionalPlugin(ctx, claudeDir, "n8n-mcp-skills@n8n-mcp-skills", "czlonkowski/n8n-skills")) ctx.nextStepTriggers.claudePlugins = true
876
- }
877
- }
878
-
879
- // ---------------------------------------------------------- LSP servers ----
880
-
881
- function lspPkg(ctx: Ctx, tool: string, pkg: string): string | undefined {
882
- const version = field(ctx, tool, "verified")
883
- if (version !== "") return `${pkg}@${version}`
884
- ctx.services.logger.warn(`Skipping ${pkg} install: ${tool} has no verified version in SoT/toolchain.json`)
885
- return undefined
886
- }
887
-
888
- async function syncLspServers(ctx: Ctx): Promise<void> {
889
- const { change, clearProgress, echo, progress, verbose, warn } = ctx.services.logger
890
- const sot = parseJson(payloadText("SoT/.claude/settings.json"))
891
- const enabled = sot !== undefined && isObject(sot) && isObject(sot["enabledPlugins"]) ? sot["enabledPlugins"] : undefined
892
- if (enabled === undefined) return
893
- const hasPhp = Object.prototype.hasOwnProperty.call(enabled, "php-lsp@claude-plugins-official")
894
- const hasTs = Object.prototype.hasOwnProperty.call(enabled, "typescript-lsp@claude-plugins-official")
895
- if (!hasPhp && !hasTs) return
896
-
897
- const phpMissing = hasPhp && ctx.services.deps.probe("intelephense").state === "missing"
898
- const tsServerMissing = hasTs && ctx.services.deps.probe("typescript-language-server").state === "missing"
899
- const tscMissing = hasTs && ctx.services.deps.probe("tsc").state === "missing"
900
- const missingToolCount = Number(phpMissing) + Number(tsServerMissing) + Number(tscMissing)
901
- const missing = [
902
- phpMissing ? lspPkg(ctx, "intelephense", "intelephense") : undefined,
903
- tsServerMissing ? lspPkg(ctx, "typescript-language-server", "typescript-language-server") : undefined,
904
- tscMissing ? lspPkg(ctx, "tsc", "typescript") : undefined
905
- ].filter((spec): spec is string => spec !== undefined)
906
-
907
- if (missingToolCount === 0) {
908
- if (ctx.dryRun) {
909
- echo("[dry-run] LSP server binaries present")
910
- } else {
911
- verbose("LSP server binaries present")
912
- }
913
- return
914
- }
915
- if (missing.length === 0) return
916
-
917
- const specs = missing.join(" ")
918
- if (ctx.dryRun) {
919
- echo(`[dry-run] would install: npm install -g ${specs}`)
920
- return
921
- }
922
-
923
- if (ctx.services.deps.probe("npm").state === "missing") {
924
- ctx.services.deps.warnMissing(
925
- "npm",
926
- ctx.services.logger,
927
- `cannot install LSP servers (${specs}); the php-lsp/typescript-lsp plugins stay no-ops`
928
- )
929
- return
930
- }
931
-
932
- verbose(`Installing LSP servers via npm: ${specs}...`)
933
- progress(`Installing LSP servers via npm: ${specs}...`)
934
- const installResult = await spawnProcess("npm", ["install", "-g", ...missing], { stdio: "ignore" })
935
- clearProgress()
936
- if (installResult.exitCode === 0) {
937
- change(`LSP servers installed (${specs})`)
938
- ctx.nextStepTriggers.claudeRestart = true
939
- } else {
940
- warn(`npm install -g ${specs} failed. Try manually: npm install -g ${specs}`)
941
- }
942
- }
943
-
944
559
  // -------------------------------------------------------------- summary ----
945
560
 
946
561
  export function claudeSummary(ctx: Ctx, runtime: ClaudeRuntimeState): void {
@@ -5,7 +5,7 @@
5
5
  */
6
6
  import { copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs"
7
7
 
8
- import { syncCodexEffort, syncCodexModel, replaceTopLevelSettingInFile } from "./codexToml"
8
+ import { mergeTableSettings, mergeTopLevelSettings, syncCodexEffort, syncCodexModel } from "./codexToml"
9
9
  import { p, spawnProcess } from "./exec"
10
10
  import type { Ctx } from "./index"
11
11
  import { compareCodepoints, isObject, jqStringify, parseJson, type Json } from "./jq"
@@ -217,7 +217,7 @@ function syncConfig(ctx: Ctx, sotConfigText: string, userConfig: string): void {
217
217
  }
218
218
 
219
219
  /** codex::scrub_deprecated_features — the [features].use_legacy_landlock awk pass. */
220
- export function scrubDeprecatedFeaturesText(content: string): string {
220
+ function scrubDeprecatedFeaturesText(content: string): string {
221
221
  const lines = content.split("\n")
222
222
  if (lines[lines.length - 1] === "") lines.pop()
223
223
  let out = ""
@@ -317,146 +317,6 @@ function removeRetiredPluginTables(ctx: Ctx, userConfig: string): void {
317
317
  for (const id of present) change(`Codex: removed retired plugin table [plugins."${id}"]`)
318
318
  }
319
319
 
320
- function mergeTopLevelSettings(sotConfigText: string, userConfig: string): void {
321
- for (const line of sotConfigText.split("\n")) {
322
- if (line.startsWith("[")) break
323
- if (/^[ \t]*($|#)/.test(line)) continue
324
- if (!/^[A-Za-z0-9_.-]+[ \t]*=/.test(line)) continue
325
- const key = line.slice(0, line.indexOf("=")).replace(/[ \t]+$/, "")
326
- replaceTopLevelSettingInFile(userConfig, key, line)
327
- }
328
- }
329
-
330
- interface TomlTableHeader {
331
- readonly path: string
332
- }
333
-
334
- const TOML_BASIC_ESCAPES: Readonly<Record<string, string>> = {
335
- b: "\b",
336
- t: "\t",
337
- n: "\n",
338
- f: "\f",
339
- r: "\r",
340
- '"': '"',
341
- "\\": "\\"
342
- }
343
-
344
- function tomlBasicEscape(line: string, offset: number): { readonly next: number; readonly value: string } | undefined {
345
- const escaped = line[offset]
346
- const simple = escaped === undefined ? undefined : TOML_BASIC_ESCAPES[escaped]
347
- if (simple !== undefined) return { next: offset + 1, value: simple }
348
- const digits = escaped === "u" ? 4 : escaped === "U" ? 8 : 0
349
- if (digits === 0) return undefined
350
- const hex = line.slice(offset + 1, offset + 1 + digits)
351
- if (hex.length !== digits || !/^[0-9A-Fa-f]+$/.test(hex)) return undefined
352
- const codePoint = Number.parseInt(hex, 16)
353
- if (codePoint > 0x10ffff || (codePoint >= 0xd800 && codePoint <= 0xdfff)) return undefined
354
- return { next: offset + 1 + digits, value: String.fromCodePoint(codePoint) }
355
- }
356
-
357
- /** Decode a table header to the TOML path that determines managed ownership. */
358
- function tomlTableHeader(line: string): TomlTableHeader | undefined {
359
- let offset = 0
360
- const skipWhitespace = (): void => {
361
- while (line[offset] === " " || line[offset] === "\t") offset++
362
- }
363
-
364
- skipWhitespace()
365
- if (line[offset] !== "[") return undefined
366
- offset++
367
- const array = line[offset] === "["
368
- if (array) offset++
369
-
370
- const keys: Array<string> = []
371
- while (true) {
372
- skipWhitespace()
373
- const quote = line[offset]
374
- let key = ""
375
- if (quote === '"' || quote === "'") {
376
- offset++
377
- let closed = false
378
- while (offset < line.length) {
379
- const char = line[offset]!
380
- if (char === quote) {
381
- offset++
382
- closed = true
383
- break
384
- }
385
- if (quote === '"' && char === "\\") {
386
- const escape = tomlBasicEscape(line, offset + 1)
387
- if (escape === undefined) return undefined
388
- key += escape.value
389
- offset = escape.next
390
- continue
391
- }
392
- if (char === "\n" || char === "\r") return undefined
393
- key += char
394
- offset++
395
- }
396
- if (!closed) return undefined
397
- } else {
398
- const start = offset
399
- while (offset < line.length && /[A-Za-z0-9_-]/.test(line[offset]!)) offset++
400
- if (offset === start) return undefined
401
- key = line.slice(start, offset)
402
- }
403
- keys.push(key)
404
-
405
- skipWhitespace()
406
- if (line[offset] === ".") {
407
- offset++
408
- continue
409
- }
410
- if (line[offset] !== "]") return undefined
411
- offset++
412
- if (array) {
413
- if (line[offset] !== "]") return undefined
414
- offset++
415
- }
416
- skipWhitespace()
417
- if (offset < line.length && line[offset] !== "#") return undefined
418
- return { path: JSON.stringify(keys) }
419
- }
420
- }
421
-
422
- function mergeTableSettingsText(sotConfigText: string, userConfigText: string): string {
423
- const sotLines = sotConfigText.split("\n")
424
- let merged = userConfigText
425
- for (let tableOffset = 0; tableOffset < sotLines.length; tableOffset++) {
426
- const managedHeader = tomlTableHeader(sotLines[tableOffset]!)
427
- if (managedHeader === undefined) continue
428
-
429
- const block: Array<string> = []
430
- for (let blockOffset = tableOffset; blockOffset < sotLines.length; blockOffset++) {
431
- const line = sotLines[blockOffset]!
432
- if (blockOffset !== tableOffset && tomlTableHeader(line) !== undefined) break
433
- block.push(line)
434
- }
435
- const tableBlock = block.join("\n").replace(/\n+$/, "")
436
-
437
- const userLines = merged.split("\n")
438
- if (userLines[userLines.length - 1] === "") userLines.pop()
439
- let skip = false
440
- const kept: Array<string> = []
441
- for (const line of userLines) {
442
- const header = tomlTableHeader(line)
443
- if (header !== undefined) {
444
- skip = header.path === managedHeader.path
445
- if (skip) continue
446
- }
447
- if (!skip) kept.push(line)
448
- }
449
- merged = `${kept.join("\n")}\n\n${tableBlock}\n`
450
- }
451
- return merged
452
- }
453
-
454
- function mergeTableSettings(sotConfigText: string, userConfig: string): void {
455
- const next = mergeTableSettingsText(sotConfigText, readFileSync(userConfig, "utf8"))
456
- writeFileSync(`${userConfig}.tmp`, next)
457
- renameSync(`${userConfig}.tmp`, userConfig)
458
- }
459
-
460
320
  // ------------------------------------------------------- rules + agents ----
461
321
 
462
322
  function syncRules(ctx: Ctx, sotRules: ReadonlyArray<PayloadPath>, userRulesDir: string): void {
@@ -560,7 +420,7 @@ function syncMarketplace(ctx: Ctx, sotMarketplaceText: string, userMarketplace:
560
420
  * wins per plugin name; distinct names end up descending by name, exactly
561
421
  * like jq's unique_by (ascending) followed by reverse.
562
422
  */
563
- export function mergeMarketplace(repo: Json, user: Json): Json {
423
+ function mergeMarketplace(repo: Json, user: Json): Json {
564
424
  const u = isObject(user) ? user : {}
565
425
  const r = isObject(repo) ? repo : {}
566
426
  const coalesce = (a: Json | undefined, b: Json | undefined): Json =>
@@ -586,7 +446,7 @@ export function mergeMarketplace(repo: Json, user: Json): Json {
586
446
  // -------------------------------------------------------------- plugins ----
587
447
 
588
448
  /** codex::_marketplace_source — first `source =` inside [marketplaces.<name>]. */
589
- export function marketplaceSource(marketplace: string, configFile: string): string {
449
+ function marketplaceSource(marketplace: string, configFile: string): string {
590
450
  if (!existsSync(configFile)) return ""
591
451
  let inMarketplace = false
592
452
  for (const line of readFileSync(configFile, "utf8").split("\n")) {
@@ -629,12 +489,12 @@ async function removeLegacyDocksMarketplace(ctx: Ctx, userConfig: string): Promi
629
489
  const standaloneInstallCommand = (ctx: Ctx): string => ctx.services.deps.spec("codex").installHint()
630
490
 
631
491
  /** codex::_enabled_plugin_ids — [plugins."<id>"] tables with enabled = true. */
632
- export function enabledPluginIds(configFile: string): Array<string> {
492
+ function enabledPluginIds(configFile: string): Array<string> {
633
493
  if (!existsSync(configFile)) return []
634
494
  return enabledPluginIdsFromText(readFileSync(configFile, "utf8"))
635
495
  }
636
496
 
637
- export function enabledPluginIdsFromText(configText: string): Array<string> {
497
+ function enabledPluginIdsFromText(configText: string): Array<string> {
638
498
  const ids: Array<string> = []
639
499
  let plugin = ""
640
500
  let enabled = false