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.
@@ -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: all three)")
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
- function selectTarget(ctx: Ctx, target: string): void {
201
- if (target === "claude") ctx.syncClaude = true
202
- else if (target === "codex") ctx.syncCodex = true
203
- else ctx.syncAgents = true
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
- if (!ctx.targetFilterSet) {
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
- echo(row([tool, kind, "(npx)", dash(floor), dash(verified), "pinned"]))
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