docks-kit 0.1.0 → 0.1.1

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 (39) hide show
  1. package/AGENTS.md +14 -15
  2. package/README.md +12 -11
  3. package/SoT/.agents/skills.txt +3 -3
  4. package/SoT/.claude/settings.json +3 -26
  5. package/SoT/models.json +1 -1
  6. package/SoT/toolchain.json +3 -3
  7. package/cli/docs/install.md +39 -12
  8. package/cli/docs/overview.md +3 -4
  9. package/cli/docs/platforms.md +35 -22
  10. package/cli/src/commands/sync.ts +32 -4
  11. package/cli/src/commands/update.ts +116 -0
  12. package/cli/src/engine-native/DESIGN.md +89 -0
  13. package/cli/src/engine-native/claudeModel.ts +48 -0
  14. package/cli/src/engine-native/claudeSync.ts +746 -0
  15. package/cli/src/engine-native/codexSync.ts +439 -0
  16. package/cli/src/engine-native/codexToml.ts +67 -0
  17. package/cli/src/engine-native/exec.ts +54 -0
  18. package/cli/src/engine-native/index.ts +109 -0
  19. package/cli/src/engine-native/jq.ts +63 -0
  20. package/cli/src/engine-native/models.ts +73 -0
  21. package/cli/src/engine-native/modes.ts +133 -0
  22. package/cli/src/engine-native/output.ts +20 -0
  23. package/cli/src/engine-native/parseArgs.ts +218 -0
  24. package/cli/src/engine-native/settings.ts +41 -0
  25. package/cli/src/engine-native/skillsSync.ts +369 -0
  26. package/cli/src/engine-native/toolchain.ts +257 -0
  27. package/cli/src/engine.ts +35 -18
  28. package/cli/src/kitHome.ts +4 -4
  29. package/cli/src/main.ts +14 -1
  30. package/cli/src/manifests.ts +3 -2
  31. package/cli/tsconfig.json +1 -1
  32. package/docks-kit +6 -3
  33. package/package.json +8 -4
  34. package/lib/claude.sh +0 -846
  35. package/lib/codex.sh +0 -518
  36. package/lib/common.sh +0 -229
  37. package/lib/engine.sh +0 -153
  38. package/lib/skills.sh +0 -373
  39. package/lib/toolchain.sh +0 -222
@@ -0,0 +1,63 @@
1
+ /**
2
+ * jq-semantics primitives for EngineNative.
3
+ *
4
+ * Some deployed JSON contracts were defined by jq behavior, so EngineNative
5
+ * reproduces three jq behaviors exactly:
6
+ * - `*` (recursive object merge): right side wins; objects merge
7
+ * recursively; arrays and scalars are REPLACED, never concatenated;
8
+ * merged key order = left object's order, then right-only keys appended.
9
+ * - `unique`: sorts (jq's total order — for our string arrays, Unicode
10
+ * codepoint order) and deduplicates.
11
+ * - output formatting: 2-space indent, UTF-8 raw, trailing newline.
12
+ */
13
+
14
+ export type Json = null | boolean | number | string | Array<Json> | { [key: string]: Json }
15
+
16
+ export function isObject(v: Json): v is { [key: string]: Json } {
17
+ return typeof v === "object" && v !== null && !Array.isArray(v)
18
+ }
19
+
20
+ /** jq `$left * $right`. */
21
+ export function deepMerge(left: Json, right: Json): Json {
22
+ if (!isObject(left) || !isObject(right)) return right
23
+ const out: { [key: string]: Json } = { ...left }
24
+ for (const [k, v] of Object.entries(right)) {
25
+ const lv = out[k]
26
+ out[k] = lv !== undefined && isObject(lv) && isObject(v) ? deepMerge(lv, v) : v
27
+ }
28
+ return out
29
+ }
30
+
31
+ /** jq `unique` over an array of strings: codepoint sort + dedup. */
32
+ export function uniqueStrings(arr: Array<string>): Array<string> {
33
+ return [...new Set(arr)].sort(compareCodepoints)
34
+ }
35
+
36
+ export function compareCodepoints(a: string, b: string): number {
37
+ const ia = a[Symbol.iterator]()
38
+ const ib = b[Symbol.iterator]()
39
+ for (;;) {
40
+ const ra = ia.next()
41
+ const rb = ib.next()
42
+ if (ra.done && rb.done) return 0
43
+ if (ra.done) return -1
44
+ if (rb.done) return 1
45
+ const ca = ra.value.codePointAt(0)!
46
+ const cb = rb.value.codePointAt(0)!
47
+ if (ca !== cb) return ca - cb
48
+ }
49
+ }
50
+
51
+ /** jq's default pretty-printer for the documents this kit writes. */
52
+ export function jqStringify(v: Json): string {
53
+ return `${JSON.stringify(v, null, 2)}\n`
54
+ }
55
+
56
+ /** jq `empty` validation: parse or null. */
57
+ export function parseJson(text: string): Json | undefined {
58
+ try {
59
+ return JSON.parse(text) as Json
60
+ } catch {
61
+ return undefined
62
+ }
63
+ }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Model-catalog helpers: manifest listing plus Claude/Codex model validation.
3
+ * Message strings are covered by the golden suites.
4
+ */
5
+ import { p } from "./exec"
6
+ import { readFileSync } from "node:fs"
7
+
8
+ import { isObject, parseJson, type Json } from "./jq"
9
+ import { warn } from "./output"
10
+
11
+ function catalog(repoDir: string): Json | undefined {
12
+ try {
13
+ return parseJson(readFileSync(p(repoDir, "SoT", "models.json"), "utf8"))
14
+ } catch {
15
+ return undefined
16
+ }
17
+ }
18
+
19
+ function toolEntry(repoDir: string, tool: string): { [k: string]: Json } | undefined {
20
+ const doc = catalog(repoDir)
21
+ if (doc === undefined || !isObject(doc)) return undefined
22
+ const entry = doc[tool]
23
+ return entry !== undefined && isObject(entry) ? entry : undefined
24
+ }
25
+
26
+ function modelEntries(repoDir: string, tool: string): Array<{ [k: string]: Json }> {
27
+ const entry = toolEntry(repoDir, tool)
28
+ const models = entry?.["models"]
29
+ return Array.isArray(models) ? models.filter(isObject) : []
30
+ }
31
+
32
+ export function modelsFromManifest(repoDir: string, tool: string): Array<string> {
33
+ return modelEntries(repoDir, tool)
34
+ .map((m) => m["id"])
35
+ .filter((id): id is string => typeof id === "string")
36
+ }
37
+
38
+ export function printModels(repoDir: string, tool: string): void {
39
+ const entry = toolEntry(repoDir, tool)
40
+ if (entry === undefined) {
41
+ warn(`Model catalog unavailable (${p(repoDir, "SoT", "models.json")})`)
42
+ return
43
+ }
44
+ const verified = typeof entry["verified"] === "string" ? entry["verified"] : "?"
45
+ const lines = [`Available ${tool} models (kit-verified ${verified} — SoT/models.json):`]
46
+ for (const m of modelEntries(repoDir, tool)) {
47
+ const note = typeof m["note"] === "string" ? ` — ${m["note"]}` : ""
48
+ lines.push(` ${String(m["id"] ?? "")}${note}`)
49
+ }
50
+ if (tool === "claude") lines.push(" (full claude-* model IDs outside the catalog are accepted with a warning)")
51
+ if (tool === "codex") lines.push(" (well-formed IDs outside the catalog are accepted with a warning)")
52
+ process.stderr.write(`${lines.join("\n")}\n`)
53
+ }
54
+
55
+ export function validateClaudeModel(repoDir: string, m: string): boolean {
56
+ if (m === "") return false
57
+ if (modelsFromManifest(repoDir, "claude").includes(m)) return true
58
+ if (m.startsWith("claude-")) {
59
+ warn(`Claude model '${m}' is not in the kit-verified catalog (SoT/models.json) — applying anyway`)
60
+ return true
61
+ }
62
+ return false
63
+ }
64
+
65
+ export function validateCodexModel(repoDir: string, m: string): boolean {
66
+ if (!/^[A-Za-z0-9._-]+$/.test(m)) return false
67
+ if (!modelsFromManifest(repoDir, "codex").includes(m)) {
68
+ warn(
69
+ `Codex model '${m}' is not in the kit-verified catalog (SoT/models.json) — applying anyway (check ~/.codex/config.toml if Codex rejects it)`
70
+ )
71
+ }
72
+ return true
73
+ }
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Direct modes for `model` and `toolchain`. Same public argv vocabulary,
3
+ * golden-tested messages, same exit codes.
4
+ */
5
+ import { p } from "./exec"
6
+ import { readFileSync } from "node:fs"
7
+
8
+ import { syncClaudeModel } from "./claudeModel"
9
+ import { syncCodexModel } from "./codexToml"
10
+ import type { Ctx } from "./index"
11
+ import { isObject, parseJson, type Json } from "./jq"
12
+ import { printModels, validateClaudeModel, validateCodexModel } from "./models"
13
+ import { echo, err, warn } from "./output"
14
+ import { rtkInstall } from "./claudeSync"
15
+ import { agentBrowserInstall, bunBootstrap, effectSolutionsInstall } from "./skillsSync"
16
+ import { ensure, report } from "./toolchain"
17
+
18
+ export function modeModel(ctx: Ctx, args: ReadonlyArray<string>): number {
19
+ let tool = ""
20
+ let value = ""
21
+ for (const arg of args) {
22
+ if (arg === "--dry-run") ctx.dryRun = true
23
+ else if (arg === "claude" || arg === "codex") tool = arg
24
+ else if (arg.startsWith("-")) {
25
+ err(`Unknown flag for model: ${arg}`)
26
+ return 2
27
+ } else value = arg
28
+ }
29
+ if (tool === "") {
30
+ err("Usage: model <claude|codex> [value] [--dry-run]")
31
+ return 2
32
+ }
33
+
34
+ if (value === "") {
35
+ if (tool === "claude") {
36
+ const deployed = p(ctx.home, ".claude", "settings.json")
37
+ if (!fileReadable(deployed)) {
38
+ warn("~/.claude/settings.json missing")
39
+ return 0
40
+ }
41
+ echo(`deployed: ${jsonModelField(deployed)}`)
42
+ echo(`SoT: ${jsonModelField(p(ctx.repoDir, "SoT", ".claude", "settings.json"))}`)
43
+ } else {
44
+ const deployed = p(ctx.home, ".codex", "config.toml")
45
+ if (!fileReadable(deployed)) {
46
+ warn("~/.codex/config.toml missing")
47
+ return 0
48
+ }
49
+ echo(`deployed: ${tomlModelField(deployed)}`)
50
+ echo(`SoT: ${tomlModelField(p(ctx.repoDir, "SoT", ".codex", "config.toml"))}`)
51
+ }
52
+ printModels(ctx.repoDir, tool)
53
+ return 0
54
+ }
55
+
56
+ if (tool === "claude") {
57
+ if (!validateClaudeModel(ctx.repoDir, value)) {
58
+ printModels(ctx.repoDir, "claude")
59
+ err(`Invalid Claude model '${value}'`)
60
+ return 2
61
+ }
62
+ syncClaudeModel(ctx, value)
63
+ } else {
64
+ if (!validateCodexModel(ctx.repoDir, value)) {
65
+ printModels(ctx.repoDir, "codex")
66
+ err(`Invalid Codex model '${value}'`)
67
+ return 2
68
+ }
69
+ syncCodexModel(ctx, value)
70
+ }
71
+ return 0
72
+ }
73
+
74
+ function fileReadable(p: string): boolean {
75
+ try {
76
+ readFileSync(p)
77
+ return true
78
+ } catch {
79
+ return false
80
+ }
81
+ }
82
+
83
+ /** `jq -r '.model // "default (unset)"'` — empty on unparseable input. */
84
+ function jsonModelField(file: string): string {
85
+ const doc = parseJson(readFileSync(file, "utf8"))
86
+ if (doc === undefined) return ""
87
+ const v: Json | undefined = isObject(doc) ? doc["model"] : undefined
88
+ if (v === undefined || v === null || v === false) return "default (unset)"
89
+ return typeof v === "string" ? v : JSON.stringify(v)
90
+ }
91
+
92
+ /** `awk -F'"' '/^model[[:space:]]*=/{print $2; exit}'`. */
93
+ function tomlModelField(file: string): string {
94
+ for (const line of readFileSync(file, "utf8").split("\n")) {
95
+ if (/^model[ \t]*=/.test(line)) return line.split('"')[1] ?? ""
96
+ }
97
+ return ""
98
+ }
99
+
100
+ export function modeToolchain(ctx: Ctx, args: ReadonlyArray<string>): number {
101
+ const op = args[0] ?? "check"
102
+ const tool = args[1] ?? ""
103
+ for (const arg of args) {
104
+ if (arg === "--yes") ctx.assumeYes = true
105
+ }
106
+
107
+ if (op === "check") {
108
+ report(ctx)
109
+ return 0
110
+ }
111
+ if (op !== "ensure") {
112
+ err("Usage: toolchain [check|ensure <tool>] [--yes]")
113
+ return 2
114
+ }
115
+ if (tool === "" || tool === "--yes") {
116
+ err("Usage: toolchain ensure <tool> [--yes]")
117
+ return 2
118
+ }
119
+ switch (tool) {
120
+ case "rtk":
121
+ return ensure(ctx, "rtk", rtkInstall(ctx))
122
+ case "bun":
123
+ // skills::_bun_bootstrap >/dev/null — the found-bun stdout is discarded.
124
+ return bunBootstrap(ctx) !== "" ? 0 : 1
125
+ case "effect-solutions":
126
+ return ensure(ctx, "effect-solutions", effectSolutionsInstall(ctx))
127
+ case "agent-browser":
128
+ return ensure(ctx, "agent-browser", agentBrowserInstall)
129
+ default:
130
+ err("toolchain ensure supports managed tools only (rtk, bun, effect-solutions, agent-browser)")
131
+ return 2
132
+ }
133
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Shared log/warn/err and dry-run emitters. Prefixes go to stderr with stable
3
+ * ANSI codes; dry-run lines go to stdout.
4
+ */
5
+
6
+ export function log(msg: string): void {
7
+ process.stderr.write(`\x1b[1;32m[ok]\x1b[0m ${msg}\n`)
8
+ }
9
+
10
+ export function warn(msg: string): void {
11
+ process.stderr.write(`\x1b[1;33m[warn]\x1b[0m ${msg}\n`)
12
+ }
13
+
14
+ export function err(msg: string): void {
15
+ process.stderr.write(`\x1b[1;31m[err]\x1b[0m ${msg}\n`)
16
+ }
17
+
18
+ export function echo(line: string): void {
19
+ process.stdout.write(`${line}\n`)
20
+ }
@@ -0,0 +1,218 @@
1
+ /**
2
+ * EngineNative flag layer: usage / target selection / compact-window parsing /
3
+ * optional-plugin parsing / model validation / preflight. ExitError mirrors an
4
+ * early parser exit and is caught once in runEngineNative.
5
+ */
6
+
7
+ import { commandExists } from "./exec"
8
+ import type { Ctx } from "./index"
9
+ import { printModels, validateClaudeModel, validateCodexModel } from "./models"
10
+ import { echo, err, warn } from "./output"
11
+
12
+ export class ExitError extends Error {
13
+ constructor(readonly code: number) {
14
+ super(`exit ${code}`)
15
+ }
16
+ }
17
+
18
+ const KNOWN_CLAUDE_OPTIN_PLUGINS = ["supabase", "n8n"]
19
+
20
+ function usage(ctx: Ctx): void {
21
+ const argv0 = "docks-kit sync"
22
+ echo(`Usage: ${argv0} [claude] [codex] [agents] [flags]`)
23
+ echo("")
24
+ echo("Targets (positional; default: all three)")
25
+ echo(" claude sync the Claude Code SoT")
26
+ echo(" codex sync the Codex SoT")
27
+ echo(" agents sync universal agent skills")
28
+ echo("")
29
+ echo("Global flags")
30
+ echo(" --dry-run preview without applying")
31
+ echo(
32
+ " --reconcile reconcile kit-owned settings with SoT (SoT keys win; user-only keys preserved; permissions arrays replaced)"
33
+ )
34
+ echo(
35
+ " --prune uninstall kit-managed installs not in SoT (plugins, marketplaces, skills in SoT/.agents/skills.txt)"
36
+ )
37
+ echo(" --skip-rtk skip optional tool bootstrap (RTK, bubblewrap)")
38
+ echo(" --yes auto-accept toolchain prompts (containers/CI)")
39
+ echo("")
40
+ echo("Deploy-time modifiers (deployed config only; SoT untouched; a later flag-less sync reverts)")
41
+ echo(
42
+ " --claude-model=<m> set deployed ~/.claude/settings.json model (aliases: best|opus|fable|sonnet|haiku, full claude-* IDs, or 'default' to unset)"
43
+ )
44
+ echo(
45
+ " --claude-compact-window=<n> set deployed autocompact window in tokens (e.g. 680000 or 680k) for disposable sessions"
46
+ )
47
+ echo(
48
+ " --claude-permissive empty permissions.ask/deny in deployed settings (sandboxes/containers; unattended commits + pushes)"
49
+ )
50
+ echo(" --codex-model=<m> set deployed ~/.codex/config.toml model (e.g. gpt-5.5)")
51
+ echo("")
52
+ echo("Sticky opt-ins (installed + enabled until --prune)")
53
+ echo(
54
+ ` --claude-plugin=<name> opt an optional plugin into this machine (known: ${KNOWN_CLAUDE_OPTIN_PLUGINS.join(", ")}; repeatable)`
55
+ )
56
+ }
57
+
58
+ /** common::parse_compact_window — normalized tokens or undefined on junk. */
59
+ export function parseCompactWindow(v: string): string | undefined {
60
+ if (/[kK]$/.test(v)) {
61
+ const n = v.slice(0, -1)
62
+ if (!/^[0-9]+$/.test(n)) return undefined
63
+ return String(parseInt(n, 10) * 1000)
64
+ }
65
+ return /^[0-9]+$/.test(v) ? v : undefined
66
+ }
67
+
68
+ function addClaudePlugin(ctx: Ctx, name: string): void {
69
+ if (!KNOWN_CLAUDE_OPTIN_PLUGINS.includes(name)) {
70
+ err(`Unknown opt-in plugin '${name}'. Known: ${KNOWN_CLAUDE_OPTIN_PLUGINS.join(", ")}`)
71
+ throw new ExitError(2)
72
+ }
73
+ ctx.claudePlugins.push(name)
74
+ }
75
+
76
+ function selectTarget(ctx: Ctx, target: string): void {
77
+ if (target === "claude") ctx.syncClaude = true
78
+ else if (target === "codex") ctx.syncCodex = true
79
+ else ctx.syncAgents = true
80
+ ctx.targetFilterSet = true
81
+ }
82
+
83
+ export function parseArgs(ctx: Ctx, args: ReadonlyArray<string>): void {
84
+ for (const arg of args) {
85
+ switch (arg) {
86
+ case "claude":
87
+ case "codex":
88
+ case "agents":
89
+ selectTarget(ctx, arg)
90
+ continue
91
+ case "--dry-run":
92
+ ctx.dryRun = true
93
+ continue
94
+ case "--skip-rtk":
95
+ ctx.skipRtk = true
96
+ continue
97
+ case "--reconcile":
98
+ ctx.reconcile = true
99
+ continue
100
+ case "--prune":
101
+ ctx.prune = true
102
+ continue
103
+ case "--yes":
104
+ ctx.assumeYes = true
105
+ continue
106
+ case "--claude-model":
107
+ printModels(ctx.repoDir, "claude")
108
+ err("--claude-model requires a value: --claude-model=<model>")
109
+ throw new ExitError(2)
110
+ case "--codex-model":
111
+ printModels(ctx.repoDir, "codex")
112
+ err("--codex-model requires a value: --codex-model=<model>")
113
+ throw new ExitError(2)
114
+ case "--claude-compact-window":
115
+ err("--claude-compact-window requires a value: --claude-compact-window=<tokens> (e.g. 680k)")
116
+ throw new ExitError(2)
117
+ case "--claude-permissive":
118
+ ctx.claudePermissive = true
119
+ continue
120
+ case "--claude-plugin":
121
+ err(`--claude-plugin requires a value: --claude-plugin=<${KNOWN_CLAUDE_OPTIN_PLUGINS.join("|")}>`)
122
+ throw new ExitError(2)
123
+ case "--claude":
124
+ case "--codex":
125
+ case "--agents":
126
+ err(`${arg} was renamed: pass the target as a word, e.g. 'sync ${arg.slice(2)}'`)
127
+ throw new ExitError(2)
128
+ case "--force":
129
+ err("--force was renamed to --reconcile")
130
+ throw new ExitError(2)
131
+ case "--remove-plugins":
132
+ err("--remove-plugins was renamed to --prune (it also removes marketplaces + kit-managed skills)")
133
+ throw new ExitError(2)
134
+ case "--680k":
135
+ err("--680k was renamed to --claude-compact-window=680k")
136
+ throw new ExitError(2)
137
+ case "--permissive":
138
+ err("--permissive was renamed to --claude-permissive")
139
+ throw new ExitError(2)
140
+ case "--supabase":
141
+ err("--supabase was renamed to --claude-plugin=supabase")
142
+ throw new ExitError(2)
143
+ case "--n8n":
144
+ err("--n8n was renamed to --claude-plugin=n8n")
145
+ throw new ExitError(2)
146
+ case "--no-rtk":
147
+ err("--no-rtk was renamed to --skip-rtk")
148
+ throw new ExitError(2)
149
+ case "-h":
150
+ case "--help":
151
+ usage(ctx)
152
+ throw new ExitError(0)
153
+ default:
154
+ break
155
+ }
156
+ if (arg.startsWith("--claude-model=")) {
157
+ ctx.claudeModel = arg.slice("--claude-model=".length)
158
+ } else if (arg.startsWith("--codex-model=")) {
159
+ ctx.codexModel = arg.slice("--codex-model=".length)
160
+ } else if (arg.startsWith("--claude-compact-window=")) {
161
+ const parsed = parseCompactWindow(arg.slice("--claude-compact-window=".length))
162
+ if (parsed === undefined) {
163
+ err("--claude-compact-window expects a token count (e.g. 680000 or 680k)")
164
+ throw new ExitError(2)
165
+ }
166
+ ctx.claudeCompactWindow = parsed
167
+ } else if (arg.startsWith("--claude-plugin=")) {
168
+ addClaudePlugin(ctx, arg.slice("--claude-plugin=".length))
169
+ } else {
170
+ err(`Unknown arg: ${arg}`)
171
+ throw new ExitError(2)
172
+ }
173
+ }
174
+
175
+ if (!ctx.targetFilterSet) {
176
+ ctx.syncClaude = true
177
+ ctx.syncCodex = true
178
+ ctx.syncAgents = true
179
+ }
180
+ }
181
+
182
+ export function preflight(ctx: Ctx): void {
183
+ if (ctx.syncClaude || ctx.syncCodex) {
184
+ if (!commandExists("jq")) {
185
+ err("jq is required. Install: sudo apt install -y jq (or brew install jq)")
186
+ throw new ExitError(1)
187
+ }
188
+ }
189
+ if (ctx.syncClaude) {
190
+ if (!commandExists("curl")) {
191
+ err("curl is required.")
192
+ throw new ExitError(1)
193
+ }
194
+ }
195
+ }
196
+
197
+ export function validateModelFlags(ctx: Ctx): void {
198
+ if (ctx.claudeModel !== "") {
199
+ if (!ctx.syncClaude) {
200
+ warn("--claude-model ignored: claude target not selected")
201
+ ctx.claudeModel = ""
202
+ } else if (!validateClaudeModel(ctx.repoDir, ctx.claudeModel)) {
203
+ printModels(ctx.repoDir, "claude")
204
+ err(`Invalid Claude model '${ctx.claudeModel}' — use an alias above or a full claude-* ID`)
205
+ throw new ExitError(2)
206
+ }
207
+ }
208
+ if (ctx.codexModel !== "") {
209
+ if (!ctx.syncCodex) {
210
+ warn("--codex-model ignored: codex target not selected")
211
+ ctx.codexModel = ""
212
+ } else if (!validateCodexModel(ctx.repoDir, ctx.codexModel)) {
213
+ printModels(ctx.repoDir, "codex")
214
+ err(`Invalid Codex model '${ctx.codexModel}' — must match ^[A-Za-z0-9._-]+$`)
215
+ throw new ExitError(2)
216
+ }
217
+ }
218
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * `~/.claude/settings.json` merge helpers. Pure functions take the two parsed
3
+ * documents and return the merged document; the FS step (backup, tmp+rename,
4
+ * log line) lives in the sync orchestrator. jq-oracle equivalence is covered by
5
+ * cli/test/unit/settings.test.ts and deployed behavior by the golden suites.
6
+ */
7
+ import { deepMerge, isObject, uniqueStrings, type Json } from "./jq"
8
+
9
+ /** `$user * $repo` — SoT keys win, permissions arrays replaced wholesale. */
10
+ export function reconcileSettings(repo: Json, user: Json): Json {
11
+ return deepMerge(user, repo)
12
+ }
13
+
14
+ /**
15
+ * `($user * $repo)` + permissions.{allow,deny,ask} unioned (user + repo,
16
+ * `unique` — i.e. codepoint-sorted and deduplicated, matching jq).
17
+ */
18
+ export function mergeSettings(repo: Json, user: Json): Json {
19
+ const merged = deepMerge(user, repo)
20
+ if (!isObject(merged)) return merged
21
+ const permissions = isObject(merged["permissions"]) ? merged["permissions"] : {}
22
+ merged["permissions"] = {
23
+ ...permissions,
24
+ allow: unionPermissions(user, repo, "allow"),
25
+ deny: unionPermissions(user, repo, "deny"),
26
+ ask: unionPermissions(user, repo, "ask")
27
+ }
28
+ return merged
29
+ }
30
+
31
+ function unionPermissions(user: Json, repo: Json, key: string): Array<string> {
32
+ return uniqueStrings([...permissionArray(user, key), ...permissionArray(repo, key)])
33
+ }
34
+
35
+ function permissionArray(doc: Json, key: string): Array<string> {
36
+ if (!isObject(doc)) return []
37
+ const permissions = doc["permissions"]
38
+ if (!isObject(permissions)) return []
39
+ const arr = permissions[key]
40
+ return Array.isArray(arr) ? arr.filter((x): x is string => typeof x === "string") : []
41
+ }