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.
- package/AGENTS.md +14 -15
- package/README.md +12 -11
- package/SoT/.agents/skills.txt +3 -3
- package/SoT/.claude/settings.json +3 -26
- package/SoT/models.json +1 -1
- package/SoT/toolchain.json +3 -3
- package/cli/docs/install.md +39 -12
- package/cli/docs/overview.md +3 -4
- package/cli/docs/platforms.md +35 -22
- package/cli/src/commands/sync.ts +32 -4
- package/cli/src/commands/update.ts +116 -0
- package/cli/src/engine-native/DESIGN.md +89 -0
- package/cli/src/engine-native/claudeModel.ts +48 -0
- package/cli/src/engine-native/claudeSync.ts +746 -0
- package/cli/src/engine-native/codexSync.ts +439 -0
- package/cli/src/engine-native/codexToml.ts +67 -0
- package/cli/src/engine-native/exec.ts +54 -0
- package/cli/src/engine-native/index.ts +109 -0
- package/cli/src/engine-native/jq.ts +63 -0
- package/cli/src/engine-native/models.ts +73 -0
- package/cli/src/engine-native/modes.ts +133 -0
- package/cli/src/engine-native/output.ts +20 -0
- package/cli/src/engine-native/parseArgs.ts +218 -0
- package/cli/src/engine-native/settings.ts +41 -0
- package/cli/src/engine-native/skillsSync.ts +369 -0
- package/cli/src/engine-native/toolchain.ts +257 -0
- package/cli/src/engine.ts +35 -18
- package/cli/src/kitHome.ts +4 -4
- package/cli/src/main.ts +14 -1
- package/cli/src/manifests.ts +3 -2
- package/cli/tsconfig.json +1 -1
- package/docks-kit +6 -3
- package/package.json +8 -4
- package/lib/claude.sh +0 -846
- package/lib/codex.sh +0 -518
- package/lib/common.sh +0 -229
- package/lib/engine.sh +0 -153
- package/lib/skills.sh +0 -373
- package/lib/toolchain.sh +0 -222
|
@@ -0,0 +1,439 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* EngineNative `sync codex` pipeline. Line-based TOML passes intentionally
|
|
3
|
+
* avoid a TOML library because reformatting user configs would be a behavior
|
|
4
|
+
* change. Guard order, message strings, and backup behavior are golden-tested.
|
|
5
|
+
*/
|
|
6
|
+
import { spawnSync } from "node:child_process"
|
|
7
|
+
import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"
|
|
8
|
+
|
|
9
|
+
import { syncCodexModel, replaceTopLevelSettingInFile } from "./codexToml"
|
|
10
|
+
import { capture, commandExists, p } from "./exec"
|
|
11
|
+
import type { Ctx } from "./index"
|
|
12
|
+
import { compareCodepoints, isObject, jqStringify, parseJson, type Json } from "./jq"
|
|
13
|
+
import { echo, err, log, warn } from "./output"
|
|
14
|
+
|
|
15
|
+
export function codexSync(ctx: Ctx): void {
|
|
16
|
+
const codexDir = p(ctx.home, ".codex")
|
|
17
|
+
const sotConfig = p(ctx.repoDir, "SoT", ".codex", "config.toml")
|
|
18
|
+
const userConfig = p(codexDir, "config.toml")
|
|
19
|
+
|
|
20
|
+
ensureBubblewrap(ctx)
|
|
21
|
+
if (!ctx.dryRun) mkdirSync(codexDir, { recursive: true })
|
|
22
|
+
syncConfig(ctx, sotConfig, userConfig)
|
|
23
|
+
syncCodexModel(ctx, ctx.codexModel)
|
|
24
|
+
syncRules(ctx, p(ctx.repoDir, "SoT", ".codex", "rules"), p(codexDir, "rules"))
|
|
25
|
+
syncAgentsMd(ctx, p(ctx.repoDir, "SoT", ".codex", "AGENTS.md"), p(codexDir, "AGENTS.md"))
|
|
26
|
+
syncMarketplace(ctx, p(ctx.repoDir, "SoT", ".codex", "plugins", "marketplace.json"), p(ctx.agentsDir, "plugins", "marketplace.json"))
|
|
27
|
+
removeLegacyDocksMarketplace(ctx, userConfig)
|
|
28
|
+
syncPlugins(ctx, sotConfig)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// ---------------------------------------------------------- bubblewrap ----
|
|
32
|
+
|
|
33
|
+
function ensureBubblewrap(ctx: Ctx): void {
|
|
34
|
+
if (!bwrapSupportedOs()) return
|
|
35
|
+
|
|
36
|
+
if (ctx.dryRun) {
|
|
37
|
+
echo("[dry-run] verify bubblewrap installed (recommended Codex Linux sandbox runtime)")
|
|
38
|
+
return
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (commandExists("bwrap")) return
|
|
42
|
+
|
|
43
|
+
if (ctx.skipRtk) {
|
|
44
|
+
warn(
|
|
45
|
+
"bubblewrap not installed (--skip-rtk skips auto-install). Codex may use its bundled helper if user namespaces work; recommended install: sudo apt install -y bubblewrap"
|
|
46
|
+
)
|
|
47
|
+
return
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const pmInstall = bwrapDetectPmInstallCmd()
|
|
51
|
+
if (pmInstall === "") {
|
|
52
|
+
warn(
|
|
53
|
+
"bubblewrap not installed and no supported package manager found (apt-get/dnf/pacman/zypper). Codex may use its bundled helper if user namespaces work; install system bubblewrap manually when possible."
|
|
54
|
+
)
|
|
55
|
+
return
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
warn(`bubblewrap not installed - recommended for Codex Linux sandbox. Running: ${pmInstall} (sudo prompt may appear)`)
|
|
59
|
+
const res = spawnSync("bash", ["-c", pmInstall], { stdio: ["inherit", "inherit", "inherit"] })
|
|
60
|
+
if (res.status !== 0) {
|
|
61
|
+
warn(`Failed to auto-install bubblewrap. Install manually: ${pmInstall}`)
|
|
62
|
+
return
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (!commandExists("bwrap")) {
|
|
66
|
+
warn("Package install reported success but bwrap not on PATH — check installation manually")
|
|
67
|
+
return
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (spawnSync("unshare", ["-Ur", "true"], { stdio: "ignore" }).status === 0) {
|
|
71
|
+
const version = capture("bwrap", ["--version"]).split("\n")[0] ?? ""
|
|
72
|
+
log(`bubblewrap installed and functional (${version})`)
|
|
73
|
+
} else {
|
|
74
|
+
warn(
|
|
75
|
+
"bubblewrap installed but unprivileged user namespaces appear blocked. On Ubuntu 24.04+, prefer loading the AppArmor bwrap-userns-restrict profile; fallback: sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0"
|
|
76
|
+
)
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function bwrapSupportedOs(): boolean {
|
|
81
|
+
if (process.platform === "linux") return true
|
|
82
|
+
if (process.platform === "darwin" || process.platform === "win32") return false
|
|
83
|
+
warn("Unknown OS — skipping bubblewrap check; Codex sandbox may not work")
|
|
84
|
+
return false
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function bwrapDetectPmInstallCmd(): string {
|
|
88
|
+
if (commandExists("apt-get")) return "sudo apt-get install -y bubblewrap"
|
|
89
|
+
if (commandExists("dnf")) return "sudo dnf install -y bubblewrap"
|
|
90
|
+
if (commandExists("pacman")) return "sudo pacman -S --noconfirm bubblewrap"
|
|
91
|
+
if (commandExists("zypper")) return "sudo zypper install -y bubblewrap"
|
|
92
|
+
return ""
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// --------------------------------------------------------------- config ----
|
|
96
|
+
|
|
97
|
+
function syncConfig(ctx: Ctx, sotConfig: string, userConfig: string): void {
|
|
98
|
+
if (!existsSync(sotConfig)) return
|
|
99
|
+
|
|
100
|
+
if (ctx.dryRun) {
|
|
101
|
+
echo(`[dry-run] merge ${sotConfig} -> ${userConfig}`)
|
|
102
|
+
return
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (!existsSync(userConfig)) {
|
|
106
|
+
copyFileSync(sotConfig, userConfig)
|
|
107
|
+
log("Codex config installed")
|
|
108
|
+
return
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
copyFileSync(userConfig, `${userConfig}.bak`)
|
|
112
|
+
|
|
113
|
+
scrubDeprecatedFeatures(userConfig)
|
|
114
|
+
mergeTopLevelSettings(sotConfig, userConfig)
|
|
115
|
+
mergeTableSettings(sotConfig, userConfig)
|
|
116
|
+
|
|
117
|
+
log("Codex config merged (backup at config.toml.bak; user-only keys/tables preserved)")
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** codex::scrub_deprecated_features — the [features].use_legacy_landlock awk pass. */
|
|
121
|
+
export function scrubDeprecatedFeaturesText(content: string): string {
|
|
122
|
+
const lines = content.split("\n")
|
|
123
|
+
if (lines[lines.length - 1] === "") lines.pop()
|
|
124
|
+
let out = ""
|
|
125
|
+
let inFeatures = false
|
|
126
|
+
let header = ""
|
|
127
|
+
let body = ""
|
|
128
|
+
let keep = false
|
|
129
|
+
for (const line of lines) {
|
|
130
|
+
if (inFeatures) {
|
|
131
|
+
if (line.startsWith("[")) {
|
|
132
|
+
inFeatures = false
|
|
133
|
+
if (keep) out += `${header}\n${body}`
|
|
134
|
+
out += `${line}\n`
|
|
135
|
+
continue
|
|
136
|
+
}
|
|
137
|
+
if (/^use_legacy_landlock[ \t]*=/.test(line)) continue
|
|
138
|
+
body += `${line}\n`
|
|
139
|
+
if (/[^ \t\f\v\r]/.test(line)) keep = true
|
|
140
|
+
continue
|
|
141
|
+
}
|
|
142
|
+
if (/^\[features\][ \t]*$/.test(line)) {
|
|
143
|
+
inFeatures = true
|
|
144
|
+
header = line
|
|
145
|
+
body = ""
|
|
146
|
+
keep = false
|
|
147
|
+
continue
|
|
148
|
+
}
|
|
149
|
+
out += `${line}\n`
|
|
150
|
+
}
|
|
151
|
+
if (inFeatures && keep) out += `${header}\n${body}`
|
|
152
|
+
return out
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function scrubDeprecatedFeatures(userConfig: string): void {
|
|
156
|
+
if (!existsSync(userConfig)) return
|
|
157
|
+
const content = readFileSync(userConfig, "utf8")
|
|
158
|
+
if (!content.split("\n").some((l) => /^use_legacy_landlock[ \t]*=/.test(l))) return
|
|
159
|
+
|
|
160
|
+
writeFileSync(`${userConfig}.tmp`, scrubDeprecatedFeaturesText(content))
|
|
161
|
+
renameSync(`${userConfig}.tmp`, userConfig)
|
|
162
|
+
log("Codex: scrubbed deprecated [features].use_legacy_landlock")
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function mergeTopLevelSettings(sotConfig: string, userConfig: string): void {
|
|
166
|
+
for (const line of readFileSync(sotConfig, "utf8").split("\n")) {
|
|
167
|
+
if (line.startsWith("[")) break
|
|
168
|
+
if (/^[ \t]*($|#)/.test(line)) continue
|
|
169
|
+
if (!/^[A-Za-z0-9_.-]+[ \t]*=/.test(line)) continue
|
|
170
|
+
const key = line.slice(0, line.indexOf("=")).replace(/[ \t]+$/, "")
|
|
171
|
+
replaceTopLevelSettingInFile(userConfig, key, line)
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function mergeTableSettings(sotConfig: string, userConfig: string): void {
|
|
176
|
+
const sotLines = readFileSync(sotConfig, "utf8").split("\n")
|
|
177
|
+
for (const tableHeader of sotLines.filter((l) => /^\[[^\]]+\]/.test(l))) {
|
|
178
|
+
// Extract the SoT block: from the exact header line to (excluding) the
|
|
179
|
+
// next table header; `$(...)` strips trailing newlines.
|
|
180
|
+
let printing = false
|
|
181
|
+
const block: Array<string> = []
|
|
182
|
+
for (const line of sotLines) {
|
|
183
|
+
if (line === tableHeader) printing = true
|
|
184
|
+
else if (printing && line.startsWith("[")) break
|
|
185
|
+
if (printing) block.push(line)
|
|
186
|
+
}
|
|
187
|
+
const tableBlock = block.join("\n").replace(/\n+$/, "")
|
|
188
|
+
|
|
189
|
+
// Remove the existing block from the user config…
|
|
190
|
+
const userLines = readFileSync(userConfig, "utf8").split("\n")
|
|
191
|
+
if (userLines[userLines.length - 1] === "") userLines.pop()
|
|
192
|
+
let skip = false
|
|
193
|
+
const kept: Array<string> = []
|
|
194
|
+
for (const line of userLines) {
|
|
195
|
+
if (line === tableHeader) {
|
|
196
|
+
skip = true
|
|
197
|
+
continue
|
|
198
|
+
}
|
|
199
|
+
if (skip && line.startsWith("[")) skip = false
|
|
200
|
+
if (!skip) kept.push(line)
|
|
201
|
+
}
|
|
202
|
+
// …and append the SoT block (printf '\n'; printf '%s\n' "$block").
|
|
203
|
+
const next = `${kept.join("\n")}\n\n${tableBlock}\n`
|
|
204
|
+
writeFileSync(`${userConfig}.tmp`, next)
|
|
205
|
+
renameSync(`${userConfig}.tmp`, userConfig)
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// ------------------------------------------------------- rules + agents ----
|
|
210
|
+
|
|
211
|
+
function syncRules(ctx: Ctx, sotRulesDir: string, userRulesDir: string): void {
|
|
212
|
+
if (!existsSync(sotRulesDir)) return
|
|
213
|
+
|
|
214
|
+
if (ctx.dryRun) {
|
|
215
|
+
echo(`[dry-run] cp ${sotRulesDir}/*.rules -> ${userRulesDir}/`)
|
|
216
|
+
return
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
mkdirSync(userRulesDir, { recursive: true })
|
|
220
|
+
let rulesSynced = false
|
|
221
|
+
const ruleFiles = readdirSync(sotRulesDir, { withFileTypes: true })
|
|
222
|
+
.filter((e) => e.isFile() && e.name.endsWith(".rules"))
|
|
223
|
+
.map((e) => p(sotRulesDir, e.name))
|
|
224
|
+
.sort(compareCodepoints)
|
|
225
|
+
for (const ruleFile of ruleFiles) {
|
|
226
|
+
const userRuleFile = p(userRulesDir, ruleFile.slice(ruleFile.lastIndexOf("/") + 1))
|
|
227
|
+
if (existsSync(userRuleFile)) copyFileSync(userRuleFile, `${userRuleFile}.bak`)
|
|
228
|
+
copyFileSync(ruleFile, userRuleFile)
|
|
229
|
+
rulesSynced = true
|
|
230
|
+
}
|
|
231
|
+
if (rulesSynced) log("Codex rules synced")
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function syncAgentsMd(ctx: Ctx, sotAgentsMd: string, userAgentsMd: string): void {
|
|
235
|
+
if (!existsSync(sotAgentsMd)) return
|
|
236
|
+
|
|
237
|
+
if (ctx.dryRun) {
|
|
238
|
+
echo(`[dry-run] cp ${sotAgentsMd} -> ${userAgentsMd}`)
|
|
239
|
+
return
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if (existsSync(userAgentsMd)) copyFileSync(userAgentsMd, `${userAgentsMd}.bak`)
|
|
243
|
+
copyFileSync(sotAgentsMd, userAgentsMd)
|
|
244
|
+
log("Codex AGENTS.md synced")
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// ---------------------------------------------------------- marketplace ----
|
|
248
|
+
|
|
249
|
+
function syncMarketplace(ctx: Ctx, sotMarketplace: string, userMarketplace: string): void {
|
|
250
|
+
if (!existsSync(sotMarketplace)) return
|
|
251
|
+
|
|
252
|
+
if (ctx.dryRun) {
|
|
253
|
+
echo(`[dry-run] cp ${sotMarketplace} -> ${userMarketplace}`)
|
|
254
|
+
return
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
mkdirSync(p(ctx.agentsDir, "plugins"), { recursive: true })
|
|
258
|
+
const repo = parseJson(readFileSync(sotMarketplace, "utf8"))
|
|
259
|
+
if (repo === undefined) throw new Error(`invalid SoT marketplace JSON: ${sotMarketplace}`)
|
|
260
|
+
|
|
261
|
+
if (existsSync(userMarketplace)) {
|
|
262
|
+
const user = parseJson(readFileSync(userMarketplace, "utf8"))
|
|
263
|
+
if (user === undefined) {
|
|
264
|
+
err(`Skipping marketplace sync: ${userMarketplace} is not valid JSON. Fix or delete it.`)
|
|
265
|
+
return
|
|
266
|
+
}
|
|
267
|
+
copyFileSync(userMarketplace, `${userMarketplace}.bak`)
|
|
268
|
+
writeFileSync(`${userMarketplace}.tmp`, jqStringify(mergeMarketplace(repo, user)))
|
|
269
|
+
renameSync(`${userMarketplace}.tmp`, userMarketplace)
|
|
270
|
+
log("Codex marketplace merged (backup at marketplace.json.bak)")
|
|
271
|
+
} else {
|
|
272
|
+
copyFileSync(sotMarketplace, userMarketplace)
|
|
273
|
+
log("Codex marketplace installed")
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* The jq -s marketplace merge: `$user *` a {name, interface} coalesce, then
|
|
279
|
+
* plugins = user+repo | reverse | unique_by(.name) | reverse — SoT (repo)
|
|
280
|
+
* wins per plugin name; distinct names end up descending by name, exactly
|
|
281
|
+
* like jq's unique_by (ascending) followed by reverse.
|
|
282
|
+
*/
|
|
283
|
+
export function mergeMarketplace(repo: Json, user: Json): Json {
|
|
284
|
+
const u = isObject(user) ? user : {}
|
|
285
|
+
const r = isObject(repo) ? repo : {}
|
|
286
|
+
const coalesce = (a: Json | undefined, b: Json | undefined): Json =>
|
|
287
|
+
a !== undefined && a !== null && a !== false ? a : (b ?? null)
|
|
288
|
+
const merged: { [k: string]: Json } = {
|
|
289
|
+
...u,
|
|
290
|
+
name: coalesce(u["name"], r["name"]),
|
|
291
|
+
interface: coalesce(u["interface"], r["interface"])
|
|
292
|
+
}
|
|
293
|
+
const plugins = [
|
|
294
|
+
...(Array.isArray(u["plugins"]) ? u["plugins"] : []),
|
|
295
|
+
...(Array.isArray(r["plugins"]) ? r["plugins"] : [])
|
|
296
|
+
]
|
|
297
|
+
const firstByName = new Map<string, Json>()
|
|
298
|
+
for (const p of [...plugins].reverse()) {
|
|
299
|
+
const name = isObject(p) && typeof p["name"] === "string" ? p["name"] : ""
|
|
300
|
+
if (!firstByName.has(name)) firstByName.set(name, p)
|
|
301
|
+
}
|
|
302
|
+
merged["plugins"] = [...firstByName.keys()].sort(compareCodepoints).map((n) => firstByName.get(n)!).reverse()
|
|
303
|
+
return merged
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// -------------------------------------------------------------- plugins ----
|
|
307
|
+
|
|
308
|
+
/** codex::_marketplace_source — first `source =` inside [marketplaces.<name>]. */
|
|
309
|
+
export function marketplaceSource(marketplace: string, configFile: string): string {
|
|
310
|
+
if (!existsSync(configFile)) return ""
|
|
311
|
+
let inMarketplace = false
|
|
312
|
+
for (const line of readFileSync(configFile, "utf8").split("\n")) {
|
|
313
|
+
if (line === `[marketplaces.${marketplace}]`) {
|
|
314
|
+
inMarketplace = true
|
|
315
|
+
continue
|
|
316
|
+
}
|
|
317
|
+
if (line.startsWith("[")) inMarketplace = false
|
|
318
|
+
if (inMarketplace && /^[ \t]*source[ \t]*=/.test(line)) {
|
|
319
|
+
return line
|
|
320
|
+
.replace(/^[^=]+=[ \t]*/, "")
|
|
321
|
+
.replace(/[ \t]*#.*/, "")
|
|
322
|
+
.replace(/^"|"$/g, "")
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
return ""
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function removeLegacyDocksMarketplace(ctx: Ctx, userConfig: string): void {
|
|
329
|
+
if (ctx.dryRun) {
|
|
330
|
+
echo("[dry-run] remove legacy configured Codex Docks marketplace when personal marketplace is deployed")
|
|
331
|
+
return
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
if (!commandExists("codex")) return
|
|
335
|
+
|
|
336
|
+
const source = marketplaceSource("docks", userConfig)
|
|
337
|
+
if (source !== "https://github.com/DocksDocks/docks.git" && source !== "DocksDocks/docks") return
|
|
338
|
+
const res = spawnSync("codex", ["plugin", "marketplace", "remove", "docks"], { stdio: "ignore" })
|
|
339
|
+
if (res.error === undefined && res.status === 0) {
|
|
340
|
+
log("Removed legacy configured Codex Docks marketplace; using personal marketplace file")
|
|
341
|
+
} else {
|
|
342
|
+
warn("Failed to remove legacy configured Codex Docks marketplace")
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/** codex::_standalone_install_command — per-OS official standalone installer. */
|
|
347
|
+
const standaloneInstallCommand = (): string =>
|
|
348
|
+
process.platform === "win32"
|
|
349
|
+
? `powershell -ExecutionPolicy ByPass -c "irm https://chatgpt.com/codex/install.ps1 | iex"`
|
|
350
|
+
: 'tmp=$(mktemp) && curl -fsSL https://chatgpt.com/codex/install.sh -o "$tmp" && CODEX_NON_INTERACTIVE=1 sh "$tmp"'
|
|
351
|
+
|
|
352
|
+
/** codex::_enabled_plugin_ids — [plugins."<id>"] tables with enabled = true. */
|
|
353
|
+
export function enabledPluginIds(configFile: string): Array<string> {
|
|
354
|
+
if (!existsSync(configFile)) return []
|
|
355
|
+
const ids: Array<string> = []
|
|
356
|
+
let plugin = ""
|
|
357
|
+
let enabled = false
|
|
358
|
+
const flush = (): void => {
|
|
359
|
+
if (plugin !== "" && enabled) ids.push(plugin)
|
|
360
|
+
}
|
|
361
|
+
for (const line of readFileSync(configFile, "utf8").split("\n")) {
|
|
362
|
+
const m = /^\[plugins\."([^"]+)"\][ \t]*$/.exec(line)
|
|
363
|
+
if (m !== null) {
|
|
364
|
+
flush()
|
|
365
|
+
plugin = m[1]!
|
|
366
|
+
enabled = false
|
|
367
|
+
continue
|
|
368
|
+
}
|
|
369
|
+
if (line.startsWith("[")) {
|
|
370
|
+
flush()
|
|
371
|
+
plugin = ""
|
|
372
|
+
enabled = false
|
|
373
|
+
continue
|
|
374
|
+
}
|
|
375
|
+
if (plugin !== "" && /^[ \t]*enabled[ \t]*=[ \t]*true([ \t]*(#.*)?)?$/.test(line)) {
|
|
376
|
+
enabled = true
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
flush()
|
|
380
|
+
return ids
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function manualPluginRefreshCommand(sotConfig: string): string {
|
|
384
|
+
const first = enabledPluginIds(sotConfig)[0]
|
|
385
|
+
return first !== undefined ? `codex plugin add ${first}` : "codex plugin add <plugin@marketplace>"
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function syncPlugins(ctx: Ctx, sotConfig: string): void {
|
|
389
|
+
if (ctx.dryRun) {
|
|
390
|
+
echo("[dry-run] add enabled Codex plugins from SoT")
|
|
391
|
+
return
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
if (!commandExists("codex")) {
|
|
395
|
+
warn(
|
|
396
|
+
`codex CLI not in PATH - deployed config/marketplace only; install Codex with: ${standaloneInstallCommand()} | docs: https://developers.openai.com/codex/cli; then run: ${manualPluginRefreshCommand(sotConfig)}`
|
|
397
|
+
)
|
|
398
|
+
return
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
let refreshed = 0
|
|
402
|
+
let failed = 0
|
|
403
|
+
for (const pluginId of enabledPluginIds(sotConfig)) {
|
|
404
|
+
const res = spawnSync("codex", ["plugin", "add", pluginId], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] })
|
|
405
|
+
const addOut = `${res.stdout ?? ""}${res.stderr ?? ""}`
|
|
406
|
+
if (res.error === undefined && res.status === 0) {
|
|
407
|
+
refreshed++
|
|
408
|
+
} else if (addOut.includes("could not find a Codex CLI binary")) {
|
|
409
|
+
warn(
|
|
410
|
+
`Codex plugin refresh hit a stale launcher/wrapper on PATH - install current standalone Codex with: ${standaloneInstallCommand()}`
|
|
411
|
+
)
|
|
412
|
+
failed++
|
|
413
|
+
} else {
|
|
414
|
+
const failureLine = addOut.split("\n")[0] ?? ""
|
|
415
|
+
warn(
|
|
416
|
+
`Codex plugin refresh failed for ${pluginId}: ${failureLine !== "" ? failureLine : "unknown error"}; run manually: codex plugin add ${pluginId}`
|
|
417
|
+
)
|
|
418
|
+
failed++
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
if (refreshed > 0) log(`Codex plugins synced (plugins: ~${refreshed})`)
|
|
423
|
+
if (failed > 0) warn(`${failed} Codex plugin operation(s) failed — re-run sync or install manually`)
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// -------------------------------------------------------------- summary ----
|
|
427
|
+
|
|
428
|
+
export function codexSummary(ctx: Ctx): void {
|
|
429
|
+
const codexDir = p(ctx.home, ".codex")
|
|
430
|
+
echo(`Codex: ${codexDir}`)
|
|
431
|
+
if (!ctx.dryRun) {
|
|
432
|
+
const count = enabledPluginIds(p(codexDir, "config.toml")).length
|
|
433
|
+
echo(`Codex plugins: ${count} enabled in config.toml`)
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
export function codexNextSteps(): void {
|
|
438
|
+
echo("Restart Codex to load any refreshed plugins, skills, or tools.")
|
|
439
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Top-level Codex TOML setting replacement plus `--codex-model`. The pass is
|
|
3
|
+
* line-based on purpose: a TOML library would reformat user configs and break
|
|
4
|
+
* the golden contract. The key is used as a raw regex fragment, preserving the
|
|
5
|
+
* historical record-oriented behavior.
|
|
6
|
+
*/
|
|
7
|
+
import { p } from "./exec"
|
|
8
|
+
import { readFileSync, renameSync, writeFileSync } from "node:fs"
|
|
9
|
+
|
|
10
|
+
import type { Ctx } from "./index"
|
|
11
|
+
import { echo, log, warn } from "./output"
|
|
12
|
+
|
|
13
|
+
export function replaceTopLevelSetting(content: string, key: string, replacement: string): string {
|
|
14
|
+
const lines = content.split("\n")
|
|
15
|
+
if (lines[lines.length - 1] === "") lines.pop() // awk records exclude a trailing empty split artifact
|
|
16
|
+
const keyRe = new RegExp(`^${key}[ \\t]*=`)
|
|
17
|
+
const out: Array<string> = []
|
|
18
|
+
let inTable = false
|
|
19
|
+
let replaced = false
|
|
20
|
+
for (const line of lines) {
|
|
21
|
+
if (line.startsWith("[")) {
|
|
22
|
+
if (!replaced) {
|
|
23
|
+
out.push(replacement)
|
|
24
|
+
replaced = true
|
|
25
|
+
}
|
|
26
|
+
inTable = true
|
|
27
|
+
out.push(line)
|
|
28
|
+
continue
|
|
29
|
+
}
|
|
30
|
+
if (!inTable && keyRe.test(line)) {
|
|
31
|
+
if (!replaced) {
|
|
32
|
+
out.push(replacement)
|
|
33
|
+
replaced = true
|
|
34
|
+
}
|
|
35
|
+
continue
|
|
36
|
+
}
|
|
37
|
+
out.push(line)
|
|
38
|
+
}
|
|
39
|
+
if (!replaced) out.push(replacement)
|
|
40
|
+
return `${out.join("\n")}\n`
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function replaceTopLevelSettingInFile(file: string, key: string, replacement: string): void {
|
|
44
|
+
const next = replaceTopLevelSetting(readFileSync(file, "utf8"), key, replacement)
|
|
45
|
+
writeFileSync(`${file}.tmp`, next)
|
|
46
|
+
renameSync(`${file}.tmp`, file)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function syncCodexModel(ctx: Ctx, model: string): void {
|
|
50
|
+
const userCodexSettings = p(ctx.home, ".codex", "config.toml")
|
|
51
|
+
|
|
52
|
+
if (model === "") return
|
|
53
|
+
|
|
54
|
+
if (ctx.dryRun) {
|
|
55
|
+
echo(`[dry-run] (--codex-model) set model = "${model}" in ${userCodexSettings}`)
|
|
56
|
+
return
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
try {
|
|
60
|
+
readFileSync(userCodexSettings)
|
|
61
|
+
} catch {
|
|
62
|
+
warn(`(--codex-model) ${userCodexSettings} missing — skipped`)
|
|
63
|
+
return
|
|
64
|
+
}
|
|
65
|
+
replaceTopLevelSettingInFile(userCodexSettings, "model", `model = "${model}"`)
|
|
66
|
+
log(`Model: deployed Codex model set to ${model} (SoT unchanged; flag-less sync reverts)`)
|
|
67
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Child-process + PATH primitives. Golden rule: every external probe spawns
|
|
3
|
+
* the intended binary with deterministic argv, and capture() mirrors command
|
|
4
|
+
* substitution: stdout with trailing newlines stripped, empty on failure.
|
|
5
|
+
*/
|
|
6
|
+
import { spawnSync } from "node:child_process"
|
|
7
|
+
import { accessSync, constants, existsSync, statSync } from "node:fs"
|
|
8
|
+
import { delimiter, join } from "node:path"
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Engine paths are built with "/" because they appear verbatim in output
|
|
12
|
+
* (dry-run lines, warns), where node:path.join would print "\" on Windows
|
|
13
|
+
* and break the golden contract. fs accepts "/" on every platform.
|
|
14
|
+
*/
|
|
15
|
+
export function p(...parts: Array<string>): string {
|
|
16
|
+
return parts.join("/")
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function capture(cmd: string, args: ReadonlyArray<string>): string {
|
|
20
|
+
const res = spawnSync(cmd, args as Array<string>, { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] })
|
|
21
|
+
if (res.error !== undefined || res.status !== 0) return ""
|
|
22
|
+
return (res.stdout ?? "").replace(/[\r\n]+$/, "")
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** `command -v` — resolve a name on PATH (PATHEXT-aware on Windows). */
|
|
26
|
+
export function which(name: string): string {
|
|
27
|
+
const exts = process.platform === "win32" ? (process.env["PATHEXT"] ?? ".EXE;.CMD;.BAT;.COM").split(";").concat("") : [""]
|
|
28
|
+
for (const dir of (process.env["PATH"] ?? "").split(delimiter)) {
|
|
29
|
+
if (dir === "") continue
|
|
30
|
+
for (const ext of exts) {
|
|
31
|
+
const cand = join(dir, name + ext.toLowerCase())
|
|
32
|
+
if (isExecutable(cand)) return cand
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return ""
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function commandExists(name: string): boolean {
|
|
39
|
+
return which(name) !== ""
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function isExecutable(p: string): boolean {
|
|
43
|
+
try {
|
|
44
|
+
if (!statSync(p).isFile()) return false
|
|
45
|
+
if (process.platform !== "win32") accessSync(p, constants.X_OK)
|
|
46
|
+
return true
|
|
47
|
+
} catch {
|
|
48
|
+
return false
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function fileExists(p: string): boolean {
|
|
53
|
+
return existsSync(p)
|
|
54
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* EngineNative — the supported sync/model/toolchain engine.
|
|
3
|
+
*
|
|
4
|
+
* Golden suites drive it through main.ts's harness-private `native-raw`
|
|
5
|
+
* channel, which bypasses @effect/cli so tests see the internal argv
|
|
6
|
+
* vocabulary directly.
|
|
7
|
+
*/
|
|
8
|
+
import { p } from "./exec"
|
|
9
|
+
import { existsSync } from "node:fs"
|
|
10
|
+
import { homedir } from "node:os"
|
|
11
|
+
|
|
12
|
+
import { kitHome } from "../kitHome"
|
|
13
|
+
import { claudeNextSteps, claudeSummary, claudeSync } from "./claudeSync"
|
|
14
|
+
import { codexNextSteps, codexSummary, codexSync } from "./codexSync"
|
|
15
|
+
import { skillsNextSteps, skillsSummary, skillsSync } from "./skillsSync"
|
|
16
|
+
import { modeModel, modeToolchain } from "./modes"
|
|
17
|
+
import { echo } from "./output"
|
|
18
|
+
import { ExitError, parseArgs, preflight, validateModelFlags } from "./parseArgs"
|
|
19
|
+
|
|
20
|
+
export interface Ctx {
|
|
21
|
+
readonly repoDir: string
|
|
22
|
+
readonly home: string
|
|
23
|
+
readonly agentsDir: string
|
|
24
|
+
dryRun: boolean
|
|
25
|
+
skipRtk: boolean
|
|
26
|
+
reconcile: boolean
|
|
27
|
+
prune: boolean
|
|
28
|
+
assumeYes: boolean
|
|
29
|
+
claudeCompactWindow: string
|
|
30
|
+
claudePermissive: boolean
|
|
31
|
+
claudePlugins: Array<string>
|
|
32
|
+
claudeModel: string
|
|
33
|
+
codexModel: string
|
|
34
|
+
targetFilterSet: boolean
|
|
35
|
+
syncClaude: boolean
|
|
36
|
+
syncCodex: boolean
|
|
37
|
+
syncAgents: boolean
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Globals default from env using the historical ${VAR:-default} contract. */
|
|
41
|
+
function makeCtx(): Ctx {
|
|
42
|
+
const env = process.env
|
|
43
|
+
const home = env["HOME"] !== undefined && env["HOME"] !== "" ? env["HOME"] : homedir()
|
|
44
|
+
return {
|
|
45
|
+
repoDir: kitHome(),
|
|
46
|
+
home,
|
|
47
|
+
agentsDir: env["AGENTS_DIR"] !== undefined && env["AGENTS_DIR"] !== "" ? env["AGENTS_DIR"] : p(home, ".agents"),
|
|
48
|
+
dryRun: env["DRY_RUN"] === "1",
|
|
49
|
+
skipRtk: env["SKIP_RTK"] === "1",
|
|
50
|
+
reconcile: env["RECONCILE"] === "1",
|
|
51
|
+
prune: env["PRUNE"] === "1",
|
|
52
|
+
assumeYes: env["ASSUME_YES"] === "1",
|
|
53
|
+
claudeCompactWindow: env["CLAUDE_COMPACT_WINDOW"] ?? "",
|
|
54
|
+
claudePermissive: env["CLAUDE_PERMISSIVE"] === "1",
|
|
55
|
+
claudePlugins: (env["CLAUDE_PLUGINS"] ?? "").split(" ").filter((s) => s !== ""),
|
|
56
|
+
claudeModel: env["CLAUDE_MODEL"] ?? "",
|
|
57
|
+
codexModel: env["CODEX_MODEL"] ?? "",
|
|
58
|
+
targetFilterSet: false,
|
|
59
|
+
syncClaude: false,
|
|
60
|
+
syncCodex: false,
|
|
61
|
+
syncAgents: false
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function engineSync(ctx: Ctx, args: ReadonlyArray<string>): number {
|
|
66
|
+
parseArgs(ctx, args)
|
|
67
|
+
preflight(ctx)
|
|
68
|
+
validateModelFlags(ctx)
|
|
69
|
+
|
|
70
|
+
const claudeRan = ctx.syncClaude && existsSync(p(ctx.repoDir, "SoT", ".claude"))
|
|
71
|
+
if (claudeRan) claudeSync(ctx)
|
|
72
|
+
|
|
73
|
+
const codexRan = ctx.syncCodex && existsSync(p(ctx.repoDir, "SoT", ".codex"))
|
|
74
|
+
if (codexRan) codexSync(ctx)
|
|
75
|
+
|
|
76
|
+
const skillsState = ctx.syncAgents && existsSync(p(ctx.repoDir, "SoT", ".agents")) ? skillsSync(ctx) : undefined
|
|
77
|
+
|
|
78
|
+
echo("")
|
|
79
|
+
echo("--- Sync complete ---")
|
|
80
|
+
echo(`Repo: ${ctx.repoDir}`)
|
|
81
|
+
if (claudeRan) claudeSummary(ctx)
|
|
82
|
+
if (codexRan) codexSummary(ctx)
|
|
83
|
+
if (skillsState !== undefined) skillsSummary(ctx, skillsState)
|
|
84
|
+
|
|
85
|
+
echo("")
|
|
86
|
+
if (claudeRan) claudeNextSteps()
|
|
87
|
+
if (codexRan) codexNextSteps()
|
|
88
|
+
if (skillsState !== undefined) skillsNextSteps()
|
|
89
|
+
return 0
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function runEngineNative(argv: ReadonlyArray<string>): number {
|
|
93
|
+
const ctx = makeCtx()
|
|
94
|
+
try {
|
|
95
|
+
switch (argv[0]) {
|
|
96
|
+
case "model":
|
|
97
|
+
return modeModel(ctx, argv.slice(1))
|
|
98
|
+
case "toolchain":
|
|
99
|
+
return modeToolchain(ctx, argv.slice(1))
|
|
100
|
+
case "sync":
|
|
101
|
+
return engineSync(ctx, argv.slice(1))
|
|
102
|
+
default:
|
|
103
|
+
return engineSync(ctx, argv)
|
|
104
|
+
}
|
|
105
|
+
} catch (e) {
|
|
106
|
+
if (e instanceof ExitError) return e.code
|
|
107
|
+
throw e
|
|
108
|
+
}
|
|
109
|
+
}
|