docks-kit 0.15.5 → 0.16.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 +106 -36
- package/README.md +21 -14
- package/cli/docs/sync-layers.md +32 -2
- package/cli/src/commands/harnesses.ts +64 -0
- package/cli/src/commands/sync.ts +2 -2
- package/cli/src/engine-native/claudeRetired.ts +165 -0
- package/cli/src/engine-native/claudeSync.ts +11 -4
- package/cli/src/engine-native/codexSync.ts +69 -0
- package/cli/src/engine-native/deps.ts +7 -0
- package/cli/src/engine-native/harnesses.ts +75 -0
- package/cli/src/engine-native/index.ts +31 -4
- package/cli/src/engine-native/ompPaths.ts +101 -0
- package/cli/src/engine-native/ompSync.ts +421 -0
- package/cli/src/engine-native/ompYaml.ts +107 -0
- package/cli/src/engine-native/parseArgs.ts +36 -11
- package/cli/src/engine-native/toolchain.ts +3 -1
- package/cli/src/generated/sotPayload.ts +14 -6
- package/cli/src/main.ts +4 -1
- package/package.json +3 -2
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* EngineNative `sync omp` pipeline. config.yml merges through mergeOmpConfig
|
|
3
|
+
* because omp serialises that file itself. Paths come from ompPaths because
|
|
4
|
+
* profiles, PI_CONFIG_DIR, PI_CODING_AGENT_DIR, and XDG roots each move them.
|
|
5
|
+
* Resolution stays within the environment and filesystem probes so no omp
|
|
6
|
+
* subcommand runs under ctx.dryRun.
|
|
7
|
+
*/
|
|
8
|
+
import { chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"
|
|
9
|
+
import { isAbsolute, resolve } from "node:path"
|
|
10
|
+
|
|
11
|
+
import { payloadDisplayPath, payloadText, type PayloadPath } from "../payload"
|
|
12
|
+
import { bunBootstrap } from "./bun"
|
|
13
|
+
import { commandExists, p, spawnProcess, type AsyncProcessResult } from "./exec"
|
|
14
|
+
import type { Ctx } from "./index"
|
|
15
|
+
import { isObject, parseJson } from "./jq"
|
|
16
|
+
import { ompPaths } from "./ompPaths"
|
|
17
|
+
import { mergeOmpConfig } from "./ompYaml"
|
|
18
|
+
import { field } from "./toolchain"
|
|
19
|
+
|
|
20
|
+
const MARKETPLACE_NAME = "docks"
|
|
21
|
+
const MARKETPLACE_SOURCE = "https://github.com/DocksDocks/docks.git"
|
|
22
|
+
const MARKETPLACE_PLUGIN_IDS = ["docks@docks", "plan-lifecycle@docks"] as const
|
|
23
|
+
type OmpTextPayloadPath = Extract<PayloadPath, `SoT/.omp/${string}`>
|
|
24
|
+
|
|
25
|
+
export interface OmpState {
|
|
26
|
+
readonly pluginsInstalled: number
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function ompSync(ctx: Ctx): Promise<OmpState> {
|
|
30
|
+
await bunBootstrap(ctx, ctx.services)
|
|
31
|
+
|
|
32
|
+
const paths = ompPaths({ home: ctx.home, env: process.env, platform: ctx.services.platform.raw() })
|
|
33
|
+
const agentDir = paths.agentDir
|
|
34
|
+
if (!ctx.dryRun) ensureDirectory(agentDir)
|
|
35
|
+
|
|
36
|
+
syncWholeFile(ctx, "SoT/.omp/AGENTS.md", p(agentDir, "AGENTS.md"), "omp AGENTS.md already in sync", "omp AGENTS.md synced")
|
|
37
|
+
syncWholeFile(ctx, "SoT/.omp/mcp.json", p(agentDir, "mcp.json"), "omp mcp.json already in sync", "omp mcp.json synced")
|
|
38
|
+
syncConfig(ctx, p(agentDir, "config.yml"))
|
|
39
|
+
|
|
40
|
+
const intercomRootSetting = process.env["PI_CODING_AGENT_DIR"]
|
|
41
|
+
const intercomRoot = intercomRootSetting !== undefined && intercomRootSetting !== ""
|
|
42
|
+
? isAbsolute(intercomRootSetting)
|
|
43
|
+
? intercomRootSetting
|
|
44
|
+
: resolve(process.cwd(), intercomRootSetting)
|
|
45
|
+
: p(ctx.home, ".pi", "agent")
|
|
46
|
+
const intercomDir = p(intercomRoot, "intercom")
|
|
47
|
+
if (!ctx.dryRun) ensureDirectory(intercomDir)
|
|
48
|
+
syncWholeFile(
|
|
49
|
+
ctx,
|
|
50
|
+
"SoT/.omp/intercom.json",
|
|
51
|
+
p(intercomDir, "config.json"),
|
|
52
|
+
"omp intercom configuration already in sync",
|
|
53
|
+
"omp intercom configuration synced"
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
const legacyRegistryFile = paths.dataRoot === paths.configRoot
|
|
57
|
+
? undefined
|
|
58
|
+
: p(paths.configRoot, "marketplaces.json")
|
|
59
|
+
await syncMarketplace(ctx, p(paths.dataRoot, "marketplaces.json"), legacyRegistryFile)
|
|
60
|
+
const pluginsInstalled = await syncPlugins(ctx)
|
|
61
|
+
return { pluginsInstalled }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ------------------------------------------------------------ file modes ----
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* `mkdirSync` and `writeFileSync` apply their `mode` only when they create the
|
|
68
|
+
* path, so an existing world-readable directory or file would keep its mode.
|
|
69
|
+
* The explicit chmod reproduces `install -d -m 0700` / `install -m 0600`.
|
|
70
|
+
*/
|
|
71
|
+
function ensureDirectory(directory: string): void {
|
|
72
|
+
mkdirSync(directory, { recursive: true, mode: 0o700 })
|
|
73
|
+
chmodSync(directory, 0o700)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function writePrivateFile(target: string, content: string): void {
|
|
77
|
+
writeFileSync(target, content, { mode: 0o600 })
|
|
78
|
+
chmodSync(target, 0o600)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function backupPrivateFile(target: string): void {
|
|
82
|
+
copyFileSync(target, `${target}.bak`)
|
|
83
|
+
chmodSync(`${target}.bak`, 0o600)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// --------------------------------------------------------------- config ----
|
|
87
|
+
|
|
88
|
+
function syncWholeFile(
|
|
89
|
+
ctx: Ctx,
|
|
90
|
+
sourcePath: OmpTextPayloadPath,
|
|
91
|
+
target: string,
|
|
92
|
+
alreadyMessage: string,
|
|
93
|
+
syncedMessage: string
|
|
94
|
+
): void {
|
|
95
|
+
const { change, echo, verbose } = ctx.services.logger
|
|
96
|
+
const source = payloadDisplayPath(sourcePath)
|
|
97
|
+
const content = payloadText(sourcePath)
|
|
98
|
+
|
|
99
|
+
if (ctx.dryRun) {
|
|
100
|
+
echo(`[dry-run] cp ${source} -> ${target}`)
|
|
101
|
+
return
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (existsSync(target) && readFileSync(target, "utf8") === content) {
|
|
105
|
+
verbose(alreadyMessage)
|
|
106
|
+
return
|
|
107
|
+
}
|
|
108
|
+
if (existsSync(target)) backupPrivateFile(target)
|
|
109
|
+
writePrivateFile(target, content)
|
|
110
|
+
change(syncedMessage)
|
|
111
|
+
ctx.nextStepTriggers.ompRestart = true
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function syncConfig(ctx: Ctx, target: string): void {
|
|
115
|
+
const { change, echo, verbose } = ctx.services.logger
|
|
116
|
+
const source = payloadDisplayPath("SoT/.omp/config.yml")
|
|
117
|
+
const sotText = payloadText("SoT/.omp/config.yml")
|
|
118
|
+
|
|
119
|
+
if (!existsSync(target)) {
|
|
120
|
+
if (ctx.dryRun) {
|
|
121
|
+
echo(`[dry-run] cp ${source} -> ${target}`)
|
|
122
|
+
return
|
|
123
|
+
}
|
|
124
|
+
writePrivateFile(target, sotText)
|
|
125
|
+
change("omp config.yml installed")
|
|
126
|
+
ctx.nextStepTriggers.ompRestart = true
|
|
127
|
+
return
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const deployedText = readFileSync(target, "utf8")
|
|
131
|
+
const merged = mergeOmpConfig(sotText, deployedText)
|
|
132
|
+
if (merged === deployedText) {
|
|
133
|
+
verbose("omp config.yml already in sync")
|
|
134
|
+
return
|
|
135
|
+
}
|
|
136
|
+
if (ctx.dryRun) {
|
|
137
|
+
echo(`[dry-run] merge ${source} -> ${target} (backup at ${target}.bak)`)
|
|
138
|
+
return
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
backupPrivateFile(target)
|
|
142
|
+
writePrivateFile(`${target}.tmp`, merged)
|
|
143
|
+
renameSync(`${target}.tmp`, target)
|
|
144
|
+
chmodSync(target, 0o600)
|
|
145
|
+
change("omp config.yml merged (backup at config.yml.bak)")
|
|
146
|
+
ctx.nextStepTriggers.ompRestart = true
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ---------------------------------------------------------- marketplace ----
|
|
150
|
+
|
|
151
|
+
function registryHasDocks(registryFile: string): boolean {
|
|
152
|
+
if (!existsSync(registryFile)) return false
|
|
153
|
+
|
|
154
|
+
let registryText: string
|
|
155
|
+
try {
|
|
156
|
+
registryText = readFileSync(registryFile, "utf8")
|
|
157
|
+
} catch {
|
|
158
|
+
return false
|
|
159
|
+
}
|
|
160
|
+
const registry = parseJson(registryText)
|
|
161
|
+
if (registry === undefined) return false
|
|
162
|
+
if (isObject(registry) && Object.hasOwn(registry, MARKETPLACE_NAME)) return true
|
|
163
|
+
|
|
164
|
+
const entries = Array.isArray(registry)
|
|
165
|
+
? registry
|
|
166
|
+
: isObject(registry) && Array.isArray(registry["marketplaces"])
|
|
167
|
+
? registry["marketplaces"]
|
|
168
|
+
: []
|
|
169
|
+
return entries.some((entry) => isObject(entry) && entry["name"] === MARKETPLACE_NAME)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function firstOutputLine(result: AsyncProcessResult): string {
|
|
173
|
+
const output = `${result.stdout}${result.stderr}`
|
|
174
|
+
return output.split("\n")[0] || "unknown error"
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Three states, because upstream `getMarketplacesRegistryPath` copies a legacy
|
|
179
|
+
* `configRoot` registry forward the first time it resolves an XDG data root:
|
|
180
|
+
* the active registry lists docks, only the legacy registry lists it, or
|
|
181
|
+
* neither does. In the middle state the kit never copies user data itself; it
|
|
182
|
+
* takes the refresh path, whose own resolution inside omp performs that
|
|
183
|
+
* adoption and leaves the active registry present.
|
|
184
|
+
*/
|
|
185
|
+
async function syncMarketplace(ctx: Ctx, registryFile: string, legacyRegistryFile?: string): Promise<void> {
|
|
186
|
+
const { change, clearProgress, echo, progress, verbose, warn } = ctx.services.logger
|
|
187
|
+
const registered = registryHasDocks(registryFile)
|
|
188
|
+
const adoptable = !registered && legacyRegistryFile !== undefined && registryHasDocks(legacyRegistryFile)
|
|
189
|
+
|
|
190
|
+
if (ctx.dryRun) {
|
|
191
|
+
if (adoptable) {
|
|
192
|
+
echo(
|
|
193
|
+
ctx.skipPluginRefresh === true
|
|
194
|
+
? "[dry-run] omp plugin marketplace list"
|
|
195
|
+
: `[dry-run] omp plugin marketplace update ${MARKETPLACE_NAME}`
|
|
196
|
+
)
|
|
197
|
+
} else if (registered) verbose("omp docks marketplace already registered")
|
|
198
|
+
else echo(`[dry-run] omp plugin marketplace add ${MARKETPLACE_SOURCE}`)
|
|
199
|
+
return
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (!registered && !adoptable) {
|
|
203
|
+
progress("Registering omp docks marketplace...")
|
|
204
|
+
const result = await spawnProcess("omp", ["plugin", "marketplace", "add", MARKETPLACE_SOURCE], {
|
|
205
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
206
|
+
})
|
|
207
|
+
clearProgress()
|
|
208
|
+
if (result.error === undefined && result.exitCode === 0) {
|
|
209
|
+
change("omp docks marketplace registered")
|
|
210
|
+
} else {
|
|
211
|
+
warn(
|
|
212
|
+
`omp docks marketplace registration failed: ${firstOutputLine(result)}; run manually: omp plugin marketplace add ${MARKETPLACE_SOURCE}`
|
|
213
|
+
)
|
|
214
|
+
}
|
|
215
|
+
return
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (ctx.skipPluginRefresh === true) {
|
|
219
|
+
if (!adoptable) {
|
|
220
|
+
verbose("omp docks marketplace already registered; refresh-only update skipped")
|
|
221
|
+
return
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Adoption only needs omp to resolve the registry path. `marketplace list`
|
|
225
|
+
// does that and fetches nothing, so it stays inside the flag's contract
|
|
226
|
+
// while leaving the active registry present.
|
|
227
|
+
progress("Adopting omp docks marketplace registry...")
|
|
228
|
+
const listed = await spawnProcess("omp", ["plugin", "marketplace", "list"], {
|
|
229
|
+
stdio: ["ignore", "ignore", "pipe"]
|
|
230
|
+
})
|
|
231
|
+
clearProgress()
|
|
232
|
+
if (listed.error === undefined && listed.exitCode === 0) {
|
|
233
|
+
change("omp docks marketplace registry adopted; refresh-only update skipped")
|
|
234
|
+
} else {
|
|
235
|
+
warn(
|
|
236
|
+
`omp docks marketplace adoption failed: ${firstOutputLine(listed)}; run manually: omp plugin marketplace list`
|
|
237
|
+
)
|
|
238
|
+
}
|
|
239
|
+
return
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
progress("Updating omp docks marketplace...")
|
|
243
|
+
const result = await spawnProcess("omp", ["plugin", "marketplace", "update", MARKETPLACE_NAME], {
|
|
244
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
245
|
+
})
|
|
246
|
+
clearProgress()
|
|
247
|
+
if (result.error === undefined && result.exitCode === 0) {
|
|
248
|
+
verbose("omp docks marketplace refreshed")
|
|
249
|
+
} else {
|
|
250
|
+
warn(
|
|
251
|
+
`omp docks marketplace update failed: ${firstOutputLine(result)}; run manually: omp plugin marketplace update ${MARKETPLACE_NAME}`
|
|
252
|
+
)
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// -------------------------------------------------------------- plugins ----
|
|
257
|
+
|
|
258
|
+
interface InstalledPlugins {
|
|
259
|
+
readonly marketplace: Set<string>
|
|
260
|
+
readonly npm: Map<string, string>
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
async function installedPluginIdsFromCli(): Promise<InstalledPlugins | undefined> {
|
|
264
|
+
const result = await spawnProcess("omp", ["plugin", "list", "--json"], {
|
|
265
|
+
stdio: ["ignore", "pipe", "ignore"]
|
|
266
|
+
})
|
|
267
|
+
if (result.error !== undefined || result.exitCode !== 0) return undefined
|
|
268
|
+
|
|
269
|
+
const value = parseJson(result.stdout)
|
|
270
|
+
if (
|
|
271
|
+
value === undefined ||
|
|
272
|
+
!isObject(value) ||
|
|
273
|
+
!Array.isArray(value["marketplace"]) ||
|
|
274
|
+
!Array.isArray(value["npm"])
|
|
275
|
+
) {
|
|
276
|
+
return undefined
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// `omp plugin list --json` reports marketplace rows as
|
|
280
|
+
// `{ id: "<plugin>@<marketplace>", scope, entries: [...] }` - the composite id
|
|
281
|
+
// is already the token `omp plugin install/upgrade` takes. omp emits one row
|
|
282
|
+
// per scope holding the plugin, so a match found only in a `project` row
|
|
283
|
+
// means the user scope is empty and `upgrade --scope user` would fail; only a
|
|
284
|
+
// `user` row counts as installed for this pipeline.
|
|
285
|
+
const marketplace = new Set<string>()
|
|
286
|
+
for (const row of value["marketplace"]) {
|
|
287
|
+
if (!isObject(row) || typeof row["id"] !== "string" || row["scope"] !== "user") continue
|
|
288
|
+
marketplace.add(row["id"])
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const npm = new Map<string, string>()
|
|
292
|
+
for (const row of value["npm"]) {
|
|
293
|
+
if (!isObject(row) || typeof row["name"] !== "string" || typeof row["version"] !== "string") continue
|
|
294
|
+
npm.set(row["name"], row["version"])
|
|
295
|
+
}
|
|
296
|
+
return { marketplace, npm }
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
async function runPluginCommand(
|
|
300
|
+
ctx: Ctx,
|
|
301
|
+
plugin: string,
|
|
302
|
+
args: ReadonlyArray<string>
|
|
303
|
+
): Promise<boolean> {
|
|
304
|
+
const { clearProgress, progress, warn } = ctx.services.logger
|
|
305
|
+
progress(`Updating omp plugin ${plugin}...`)
|
|
306
|
+
const result = await spawnProcess("omp", args, { stdio: ["ignore", "pipe", "pipe"] })
|
|
307
|
+
clearProgress()
|
|
308
|
+
if (result.error === undefined && result.exitCode === 0) return true
|
|
309
|
+
|
|
310
|
+
warn(
|
|
311
|
+
`omp plugin operation failed for ${plugin}: ${firstOutputLine(result)}; run manually: omp ${args.join(" ")}`
|
|
312
|
+
)
|
|
313
|
+
return false
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
async function syncPlugins(ctx: Ctx): Promise<number> {
|
|
317
|
+
const { change, clearProgress, echo, progress, verbose, warn } = ctx.services.logger
|
|
318
|
+
|
|
319
|
+
if (ctx.dryRun) {
|
|
320
|
+
const piIntercomPin = field(ctx, "pi-intercom", "verified")
|
|
321
|
+
for (const pluginId of MARKETPLACE_PLUGIN_IDS) {
|
|
322
|
+
echo(`[dry-run] omp plugin install --scope user ${pluginId}`)
|
|
323
|
+
}
|
|
324
|
+
if (piIntercomPin === "") {
|
|
325
|
+
warn("pi-intercom install skipped because SoT/toolchain.json has no verified pi-intercom pin")
|
|
326
|
+
} else {
|
|
327
|
+
echo(`[dry-run] omp install pi-intercom@${piIntercomPin}`)
|
|
328
|
+
}
|
|
329
|
+
return 0
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
if (ctx.services.deps.probe("omp").state === "missing") {
|
|
333
|
+
ctx.services.deps.warnMissing(
|
|
334
|
+
"omp",
|
|
335
|
+
ctx.services.logger,
|
|
336
|
+
"deployed omp config only — marketplace and plugin passes skipped; re-run sync after installing"
|
|
337
|
+
)
|
|
338
|
+
return 0
|
|
339
|
+
}
|
|
340
|
+
if (ctx.services.deps.probe("git").state === "missing") {
|
|
341
|
+
ctx.services.deps.warnMissing(
|
|
342
|
+
"git",
|
|
343
|
+
ctx.services.logger,
|
|
344
|
+
"plugin marketplaces are git repos — omp plugin refresh skipped; re-run sync after installing"
|
|
345
|
+
)
|
|
346
|
+
return 0
|
|
347
|
+
}
|
|
348
|
+
const piIntercomPin = field(ctx, "pi-intercom", "verified")
|
|
349
|
+
|
|
350
|
+
progress("Checking installed omp plugins...")
|
|
351
|
+
const installed = await installedPluginIdsFromCli()
|
|
352
|
+
clearProgress()
|
|
353
|
+
if (installed === undefined) {
|
|
354
|
+
warn("omp plugin inventory unavailable — falling back to the full refresh path")
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
let pluginsInstalled = installed === undefined
|
|
358
|
+
? 0
|
|
359
|
+
: MARKETPLACE_PLUGIN_IDS.filter((pluginId) => installed.marketplace.has(pluginId)).length +
|
|
360
|
+
(piIntercomPin !== "" && installed.npm.has("pi-intercom") ? 1 : 0)
|
|
361
|
+
let operationsSucceeded = 0
|
|
362
|
+
|
|
363
|
+
for (const pluginId of MARKETPLACE_PLUGIN_IDS) {
|
|
364
|
+
const present = installed?.marketplace.has(pluginId) === true
|
|
365
|
+
if (present && ctx.skipPluginRefresh === true) {
|
|
366
|
+
verbose(`omp plugin ${pluginId} already installed; refresh-only update skipped`)
|
|
367
|
+
continue
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
const args = present
|
|
371
|
+
? ["plugin", "upgrade", "--scope", "user", pluginId]
|
|
372
|
+
: ["plugin", "install", "--scope", "user", pluginId]
|
|
373
|
+
if (await runPluginCommand(ctx, pluginId, args)) {
|
|
374
|
+
operationsSucceeded++
|
|
375
|
+
if (!present) pluginsInstalled++
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
if (piIntercomPin === "") {
|
|
380
|
+
warn("pi-intercom install skipped because SoT/toolchain.json has no verified pi-intercom pin")
|
|
381
|
+
} else {
|
|
382
|
+
const installedVersion = installed?.npm.get("pi-intercom")
|
|
383
|
+
const present = installedVersion !== undefined
|
|
384
|
+
if (installedVersion === piIntercomPin) {
|
|
385
|
+
verbose(`omp npm plugin pi-intercom already installed at ${piIntercomPin}`)
|
|
386
|
+
} else if (present && ctx.skipPluginRefresh === true) {
|
|
387
|
+
verbose("omp npm plugin pi-intercom already installed; refresh-only update skipped")
|
|
388
|
+
} else {
|
|
389
|
+
const args = present
|
|
390
|
+
? ["install", "--force", `pi-intercom@${piIntercomPin}`]
|
|
391
|
+
: ["install", `pi-intercom@${piIntercomPin}`]
|
|
392
|
+
if (await runPluginCommand(ctx, "pi-intercom", args)) {
|
|
393
|
+
operationsSucceeded++
|
|
394
|
+
if (!present) pluginsInstalled++
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
if (operationsSucceeded > 0) {
|
|
400
|
+
change(`omp plugins synced (plugins: ~${operationsSucceeded})`)
|
|
401
|
+
ctx.nextStepTriggers.ompRestart = true
|
|
402
|
+
} else {
|
|
403
|
+
verbose("omp plugins already in sync")
|
|
404
|
+
}
|
|
405
|
+
return pluginsInstalled
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// -------------------------------------------------------------- summary ----
|
|
409
|
+
|
|
410
|
+
export function ompSummary(ctx: Ctx, state: OmpState): void {
|
|
411
|
+
const { echo } = ctx.services.logger
|
|
412
|
+
const agentDir = ompPaths({ home: ctx.home, env: process.env, platform: ctx.services.platform.raw() }).agentDir
|
|
413
|
+
echo(`omp: ${agentDir}`)
|
|
414
|
+
if (!ctx.dryRun) echo(`omp plugins: ${state.pluginsInstalled} installed`)
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
export function ompNextSteps(ctx: Ctx): Array<string> {
|
|
418
|
+
return ctx.verbose || ctx.nextStepTriggers.ompRestart
|
|
419
|
+
? ["Restart omp to load any refreshed plugins, skills, or tools."]
|
|
420
|
+
: []
|
|
421
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* YAML merge for omp config.yml. omp serialises this file itself, so the kit
|
|
3
|
+
* uses the yaml package rather than a line-based merge. Deployed-only keys are
|
|
4
|
+
* retained except for slash-bearing keys directly under retry.fallbackChains:
|
|
5
|
+
* omp treats those as model or provider wildcards ahead of role chains, so a
|
|
6
|
+
* stale wildcard would silently override the kit-managed role chains.
|
|
7
|
+
*/
|
|
8
|
+
import { isMap, isScalar, parseDocument, type YAMLMap } from "yaml"
|
|
9
|
+
|
|
10
|
+
const fallbackChainsPath = ["retry", "fallbackChains"] as const
|
|
11
|
+
|
|
12
|
+
function stringKey(key: unknown): string | undefined {
|
|
13
|
+
if (typeof key === "string") return key
|
|
14
|
+
if (isScalar(key) && typeof key.value === "string") return key.value
|
|
15
|
+
return undefined
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function isFallbackChains(path: ReadonlyArray<string>): boolean {
|
|
19
|
+
return (
|
|
20
|
+
path.length === fallbackChainsPath.length &&
|
|
21
|
+
path[0] === fallbackChainsPath[0] &&
|
|
22
|
+
path[1] === fallbackChainsPath[1]
|
|
23
|
+
)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function pruneFallbackWildcards(mapping: YAMLMap, path: ReadonlyArray<string>): void {
|
|
27
|
+
const underFallbackChains = isFallbackChains(path)
|
|
28
|
+
for (let index = mapping.items.length - 1; index >= 0; index--) {
|
|
29
|
+
const pair = mapping.items[index]
|
|
30
|
+
if (pair === undefined) continue
|
|
31
|
+
|
|
32
|
+
const keyName = isScalar(pair.key) ? String(pair.key.value) : String(pair.key)
|
|
33
|
+
if (underFallbackChains && keyName.includes("/")) {
|
|
34
|
+
mapping.items.splice(index, 1)
|
|
35
|
+
continue
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const key = stringKey(pair.key)
|
|
39
|
+
if (key !== undefined && isMap(pair.value)) {
|
|
40
|
+
pruneFallbackWildcards(pair.value, [...path, key])
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function mergeMappings(
|
|
46
|
+
sotMapping: YAMLMap,
|
|
47
|
+
deployedMapping: YAMLMap,
|
|
48
|
+
path: ReadonlyArray<string>
|
|
49
|
+
): void {
|
|
50
|
+
for (const sotPair of sotMapping.items) {
|
|
51
|
+
const key = stringKey(sotPair.key)
|
|
52
|
+
if (key === undefined) continue
|
|
53
|
+
|
|
54
|
+
const deployedPair = deployedMapping.items.find((candidate) => stringKey(candidate.key) === key)
|
|
55
|
+
if (deployedPair !== undefined && isMap(sotPair.value) && isMap(deployedPair.value)) {
|
|
56
|
+
mergeMappings(sotPair.value, deployedPair.value, [...path, key])
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
for (const deployedPair of deployedMapping.items) {
|
|
61
|
+
const key = stringKey(deployedPair.key)
|
|
62
|
+
const isManaged =
|
|
63
|
+
key !== undefined && sotMapping.items.some((candidate) => stringKey(candidate.key) === key)
|
|
64
|
+
if (isManaged) continue
|
|
65
|
+
|
|
66
|
+
const keyName = isScalar(deployedPair.key)
|
|
67
|
+
? String(deployedPair.key.value)
|
|
68
|
+
: String(deployedPair.key)
|
|
69
|
+
if (isFallbackChains(path) && keyName.includes("/")) continue
|
|
70
|
+
if (key !== undefined && isMap(deployedPair.value)) {
|
|
71
|
+
pruneFallbackWildcards(deployedPair.value, [...path, key])
|
|
72
|
+
}
|
|
73
|
+
sotMapping.items.push(deployedPair)
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function mergeOmpConfig(sotText: string, deployedText: string): string {
|
|
78
|
+
const deployedDoc = parseDocument(deployedText)
|
|
79
|
+
const deployedError = deployedDoc.errors[0]
|
|
80
|
+
if (deployedError !== undefined) {
|
|
81
|
+
throw new Error(`Invalid deployed omp config YAML: ${deployedError.message}`)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const deployedContents = deployedDoc.contents
|
|
85
|
+
if (
|
|
86
|
+
deployedContents === null ||
|
|
87
|
+
deployedContents === undefined ||
|
|
88
|
+
(isScalar(deployedContents) && deployedContents.value === null)
|
|
89
|
+
) {
|
|
90
|
+
return sotText
|
|
91
|
+
}
|
|
92
|
+
if (!isMap(deployedContents)) {
|
|
93
|
+
throw new Error("Deployed omp config YAML root must be a mapping")
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const sotDoc = parseDocument(sotText)
|
|
97
|
+
const sotError = sotDoc.errors[0]
|
|
98
|
+
if (sotError !== undefined) {
|
|
99
|
+
throw new Error(`Invalid SoT omp config YAML: ${sotError.message}`)
|
|
100
|
+
}
|
|
101
|
+
if (!isMap(sotDoc.contents)) {
|
|
102
|
+
throw new Error("SoT omp config YAML root must be a mapping")
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
mergeMappings(sotDoc.contents, deployedContents, [])
|
|
106
|
+
return sotDoc.toString()
|
|
107
|
+
}
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import type { Ctx, ModifierFlag } from "./index"
|
|
8
|
+
import { LEGACY_SELECTION, readHarnessSelection, type Harness } from "./harnesses"
|
|
8
9
|
import {
|
|
9
10
|
CLAUDE_ADVISOR_STATES,
|
|
10
11
|
advisorCatalog,
|
|
@@ -118,12 +119,13 @@ const SCALAR_MODIFIER_FLAGS: Record<ScalarModifierFlag, true> = {
|
|
|
118
119
|
function usage(ctx: Ctx): void {
|
|
119
120
|
const { echo } = ctx.services.logger
|
|
120
121
|
const argv0 = "docks-kit sync"
|
|
121
|
-
echo(`Usage: ${argv0} [claude] [codex] [agents] [flags]`)
|
|
122
|
+
echo(`Usage: ${argv0} [claude] [codex] [agents] [omp] [flags]`)
|
|
122
123
|
echo("")
|
|
123
|
-
echo("Targets (positional; default:
|
|
124
|
+
echo("Targets (positional; default: this machine's harness selection)")
|
|
124
125
|
echo(" claude sync the Claude Code SoT")
|
|
125
126
|
echo(" codex sync the Codex SoT")
|
|
126
127
|
echo(" agents sync universal agent skills")
|
|
128
|
+
echo(" omp sync the Oh My Pi SoT")
|
|
127
129
|
echo("")
|
|
128
130
|
echo("Global flags")
|
|
129
131
|
echo(" --dry-run preview without applying")
|
|
@@ -197,13 +199,38 @@ function addClaudePlugin(ctx: Ctx, name: string): void {
|
|
|
197
199
|
markModifier(ctx, "--claude-plugin")
|
|
198
200
|
}
|
|
199
201
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
202
|
+
type TargetFlag = "syncClaude" | "syncCodex" | "syncAgents" | "syncOmp"
|
|
203
|
+
|
|
204
|
+
const TARGET_FLAGS = {
|
|
205
|
+
claude: "syncClaude",
|
|
206
|
+
codex: "syncCodex",
|
|
207
|
+
agents: "syncAgents",
|
|
208
|
+
omp: "syncOmp"
|
|
209
|
+
} satisfies Record<Harness, TargetFlag>
|
|
210
|
+
|
|
211
|
+
function selectTarget(ctx: Ctx, target: Harness): void {
|
|
212
|
+
ctx[TARGET_FLAGS[target]] = true
|
|
204
213
|
ctx.targetFilterSet = true
|
|
205
214
|
}
|
|
206
215
|
|
|
216
|
+
function applySelection(ctx: Ctx, selection: ReadonlyArray<Harness>): void {
|
|
217
|
+
ctx.syncClaude = selection.includes("claude")
|
|
218
|
+
ctx.syncCodex = selection.includes("codex")
|
|
219
|
+
ctx.syncAgents = selection.includes("agents")
|
|
220
|
+
ctx.syncOmp = selection.includes("omp")
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function applyDefaultSelection(ctx: Ctx): void {
|
|
224
|
+
if (ctx.targetFilterSet) return
|
|
225
|
+
|
|
226
|
+
const storedSelection = readHarnessSelection(ctx.home)
|
|
227
|
+
applySelection(ctx, storedSelection ?? LEGACY_SELECTION)
|
|
228
|
+
if (storedSelection !== undefined || !ctx.interactive) return
|
|
229
|
+
|
|
230
|
+
ctx.services.logger.echo("No harness selection stored; syncing claude, codex, agents")
|
|
231
|
+
ctx.services.logger.echo("Choose harnesses with: docks-kit harnesses")
|
|
232
|
+
}
|
|
233
|
+
|
|
207
234
|
function setModifier(ctx: Ctx, flag: ScalarModifierFlag, value: string): void {
|
|
208
235
|
switch (flag) {
|
|
209
236
|
case "--claude-model":
|
|
@@ -243,6 +270,7 @@ export function parseArgs(ctx: Ctx, args: ReadonlyArray<string>): void {
|
|
|
243
270
|
case "claude":
|
|
244
271
|
case "codex":
|
|
245
272
|
case "agents":
|
|
273
|
+
case "omp":
|
|
246
274
|
selectTarget(ctx, arg)
|
|
247
275
|
continue
|
|
248
276
|
case "--dry-run":
|
|
@@ -296,6 +324,7 @@ export function parseArgs(ctx: Ctx, args: ReadonlyArray<string>): void {
|
|
|
296
324
|
case "--claude":
|
|
297
325
|
case "--codex":
|
|
298
326
|
case "--agents":
|
|
327
|
+
case "--omp":
|
|
299
328
|
err(`${arg} was renamed: pass the target as a word, e.g. 'sync ${arg.slice(2)}'`)
|
|
300
329
|
throw new ExitError(2)
|
|
301
330
|
case "--force":
|
|
@@ -355,11 +384,7 @@ export function parseArgs(ctx: Ctx, args: ReadonlyArray<string>): void {
|
|
|
355
384
|
}
|
|
356
385
|
}
|
|
357
386
|
|
|
358
|
-
|
|
359
|
-
ctx.syncClaude = true
|
|
360
|
-
ctx.syncCodex = true
|
|
361
|
-
ctx.syncAgents = true
|
|
362
|
-
}
|
|
387
|
+
applyDefaultSelection(ctx)
|
|
363
388
|
}
|
|
364
389
|
|
|
365
390
|
function printCatalog(ctx: Ctx, catalog: string): void {
|
|
@@ -60,6 +60,7 @@ export async function installedVersion(ctx: Ctx, tool: ToolId): Promise<string>
|
|
|
60
60
|
case "tsc":
|
|
61
61
|
return firstLineField(await version(), 1)
|
|
62
62
|
case "bun":
|
|
63
|
+
case "omp":
|
|
63
64
|
case "npm":
|
|
64
65
|
return await version()
|
|
65
66
|
case "bwrap":
|
|
@@ -92,7 +93,8 @@ export async function report(ctx: Ctx): Promise<void> {
|
|
|
92
93
|
const verified = field(ctx, tool, "verified")
|
|
93
94
|
const dash = (v: string): string => (v !== "" ? v : "-")
|
|
94
95
|
if (kind === "pin") {
|
|
95
|
-
|
|
96
|
+
const via = field(ctx, tool, "via")
|
|
97
|
+
echo(row([tool, kind, `(${via !== "" ? via : "npx"})`, dash(floor), dash(verified), "pinned"]))
|
|
96
98
|
continue
|
|
97
99
|
}
|
|
98
100
|
let installed: string
|