docks-kit 0.15.5 → 0.16.0

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.
@@ -6,7 +6,7 @@
6
6
  * vocabulary directly.
7
7
  */
8
8
  import { p } from "./exec"
9
- import { homedir } from "node:os"
9
+ import { engineHome } from "./harnesses"
10
10
 
11
11
  import { kitHome } from "../kitHome"
12
12
  import { payloadText } from "../payload"
@@ -15,6 +15,7 @@ import type { TerminalLease } from "./logger"
15
15
  import type { BunRuntimeState } from "./bun"
16
16
  import { claudeNextSteps, claudeSummary, claudeSync, type ClaudeRuntimeState } from "./claudeSync"
17
17
  import { codexNextSteps, codexSummary, codexSync } from "./codexSync"
18
+ import { ompNextSteps, ompSummary, ompSync, type OmpState } from "./ompSync"
18
19
  import { normalizeManifest, skillsNextSteps, skillsSummary, skillsSync, type SkillsState } from "./skillsSync"
19
20
  import { modeModel, modeToolchain } from "./modes"
20
21
  import { ExitError, parseArgs, parseClaudePlugin, parseCompactWindow, validateModifierFlags } from "./parseArgs"
@@ -81,6 +82,7 @@ export interface Ctx {
81
82
  readonly repoDir: string
82
83
  readonly home: string
83
84
  readonly agentsDir: string
85
+ readonly interactive: boolean
84
86
  dryRun: boolean
85
87
  verbose: boolean
86
88
  skipBubblewrap: boolean
@@ -106,19 +108,21 @@ export interface Ctx {
106
108
  syncClaude: boolean
107
109
  syncCodex: boolean
108
110
  syncAgents: boolean
111
+ syncOmp: boolean
109
112
  /** Per-run next-step triggers (Output Policy): advice prints only when its trigger changed or --verbose. */
110
113
  readonly nextStepTriggers: {
111
114
  claudePlugins: boolean
112
115
  claudeRestart: boolean
113
116
  codexRestart: boolean
114
117
  skillsRestart: boolean
118
+ ompRestart: boolean
115
119
  }
116
120
  }
117
121
 
118
122
  /** Globals default from env using the historical ${VAR:-default} contract. */
119
123
  function makeCtx(services: EngineServices): Ctx {
120
124
  const env = process.env
121
- const home = env["HOME"] !== undefined && env["HOME"] !== "" ? env["HOME"] : homedir()
125
+ const home = engineHome(env)
122
126
  const compactWindowSource = env["CLAUDE_COMPACT_WINDOW"] ?? ""
123
127
  const claudeCompactWindow = compactWindowSource === "" ? "" : parseCompactWindow(compactWindowSource)
124
128
  if (claudeCompactWindow === undefined) {
@@ -133,6 +137,11 @@ function makeCtx(services: EngineServices): Ctx {
133
137
  repoDir: kitHome(),
134
138
  home,
135
139
  agentsDir: env["AGENTS_DIR"] !== undefined && env["AGENTS_DIR"] !== "" ? env["AGENTS_DIR"] : p(home, ".agents"),
140
+ interactive:
141
+ env["DOCKS_KIT_INTERACTIVE"] === "1" ||
142
+ (env["DOCKS_KIT_INTERACTIVE"] !== "0" &&
143
+ process.stdout.isTTY === true &&
144
+ process.stdin.isTTY === true),
136
145
  dryRun: env["DRY_RUN"] === "1",
137
146
  verbose: env["DOCKS_KIT_VERBOSE"] === "1",
138
147
  skipBubblewrap: env["SKIP_BUBBLEWRAP"] === "1",
@@ -154,7 +163,14 @@ function makeCtx(services: EngineServices): Ctx {
154
163
  syncClaude: false,
155
164
  syncCodex: false,
156
165
  syncAgents: false,
157
- nextStepTriggers: { claudePlugins: false, claudeRestart: false, codexRestart: false, skillsRestart: false }
166
+ syncOmp: false,
167
+ nextStepTriggers: {
168
+ claudePlugins: false,
169
+ claudeRestart: false,
170
+ codexRestart: false,
171
+ skillsRestart: false,
172
+ ompRestart: false
173
+ }
158
174
  }
159
175
  }
160
176
 
@@ -181,6 +197,7 @@ async function engineSync(ctx: Ctx, args: ReadonlyArray<string>): Promise<number
181
197
  | { readonly kind: "claude"; readonly runtime: ClaudeRuntimeState }
182
198
  | { readonly kind: "codex" }
183
199
  | { readonly kind: "skills"; readonly state: SkillsState }
200
+ | { readonly kind: "omp"; readonly state: OmpState }
184
201
  interface SelectedPipeline {
185
202
  readonly name: string
186
203
  readonly run: SyncTask<PipelineResult>
@@ -208,6 +225,12 @@ async function engineSync(ctx: Ctx, args: ReadonlyArray<string>): Promise<number
208
225
  run: async () => ({ kind: "skills", state: await skillsSync(ctx) })
209
226
  })
210
227
  }
228
+ if (ctx.syncOmp) {
229
+ selected.push({
230
+ name: "omp",
231
+ run: async () => ({ kind: "omp", state: await ompSync(ctx) })
232
+ })
233
+ }
211
234
 
212
235
  // A populated skills manifest deploys with `-a claude-code codex`, and symlink healing also writes into Claude's tree.
213
236
  ctx.syncConcurrency = syncConcurrencyForManifest(
@@ -243,9 +266,11 @@ async function engineSync(ctx: Ctx, args: ReadonlyArray<string>): Promise<number
243
266
  const codexRan = ctx.syncCodex
244
267
  let claudeRuntime: ClaudeRuntimeState | undefined
245
268
  let skillsState: SkillsState | undefined
269
+ let ompState: OmpState | undefined
246
270
  for (const result of results) {
247
271
  if (result.kind === "claude") claudeRuntime = result.runtime
248
272
  else if (result.kind === "skills") skillsState = result.state
273
+ else if (result.kind === "omp") ompState = result.state
249
274
  }
250
275
 
251
276
  echo("")
@@ -254,11 +279,13 @@ async function engineSync(ctx: Ctx, args: ReadonlyArray<string>): Promise<number
254
279
  if (claudeRuntime !== undefined) claudeSummary(ctx, claudeRuntime)
255
280
  if (codexRan) codexSummary(ctx)
256
281
  if (skillsState !== undefined) skillsSummary(ctx, skillsState)
282
+ if (ompState !== undefined) ompSummary(ctx, ompState)
257
283
 
258
284
  const advice = [
259
285
  ...(claudeRan ? claudeNextSteps(ctx) : []),
260
286
  ...(codexRan ? codexNextSteps(ctx) : []),
261
- ...(skillsState !== undefined ? skillsNextSteps(ctx) : [])
287
+ ...(skillsState !== undefined ? skillsNextSteps(ctx) : []),
288
+ ...(ompState !== undefined ? ompNextSteps(ctx) : [])
262
289
  ]
263
290
  if (advice.length > 0) {
264
291
  echo("")
@@ -0,0 +1,101 @@
1
+ /**
2
+ * omp path resolution, mirroring upstream `packages/utils/src/dirs.ts`
3
+ * `DirResolver`. The kit deploys into two different roots and must not guess
4
+ * either one: the agent directory holds `AGENTS.md`, `config.yml`, and
5
+ * `mcp.json`; the data root holds `marketplaces.json` and `plugins/`.
6
+ *
7
+ * Resolution reads the environment and probes directory existence only, so a
8
+ * dry run stays free of omp subcommands.
9
+ */
10
+ import { existsSync } from "node:fs"
11
+ import { isAbsolute, resolve } from "node:path"
12
+
13
+ import { p } from "./exec"
14
+
15
+ /** Upstream CONFIG_DIR_NAME; PI_CONFIG_DIR renames this directory under home. */
16
+ const DEFAULT_CONFIG_DIR_NAME = ".omp"
17
+ /** Upstream APP_NAME, the fixed segment under an XDG category root. */
18
+ const APP_NAME = "omp"
19
+ /** Upstream PROFILE_NAME_RE. An invalid name degrades to the default profile. */
20
+ const PROFILE_NAME_RE = /^[a-z0-9][a-z0-9._-]{0,63}$/
21
+ /** Windows device aliases upstream rejects, bare or with any extension. */
22
+ const WINDOWS_RESERVED_BASENAME_RE = /^(?:CON|PRN|AUX|NUL|COM[0-9]|LPT[0-9])(?:\..*)?$/i
23
+
24
+ export interface OmpPaths {
25
+ /** undefined for the default profile, else the normalized profile name */
26
+ readonly profile: string | undefined
27
+ /** config root for the active profile */
28
+ readonly configRoot: string
29
+ /** directory holding AGENTS.md, config.yml, mcp.json - never XDG-redirected */
30
+ readonly agentDir: string
31
+ /** root holding marketplaces.json and plugins/ - XDG-redirected when adopted */
32
+ readonly dataRoot: string
33
+ }
34
+
35
+ export interface OmpPathInputs {
36
+ readonly home: string
37
+ readonly env: Record<string, string | undefined>
38
+ readonly platform: NodeJS.Platform
39
+ }
40
+
41
+ /**
42
+ * Upstream `normalizeProfileName`, minus the throw: an invalid value reaches
43
+ * `readProfileFromEnvSafe`, which degrades to the default profile so a bad env
44
+ * var cannot crash a bare import.
45
+ */
46
+ function normalizeProfile(value: string | undefined): string | undefined {
47
+ const name = value?.trim()
48
+ if (name === undefined || name === "" || name === "default") return undefined
49
+ if (name === "." || name === ".." || name.endsWith(".")) return undefined
50
+ if (!PROFILE_NAME_RE.test(name) || WINDOWS_RESERVED_BASENAME_RE.test(name)) return undefined
51
+ return name
52
+ }
53
+
54
+ /** Upstream applies `path.resolve` to PI_CODING_AGENT_DIR, so cwd anchors a relative value. */
55
+ function resolveAgentOverride(value: string): string {
56
+ return isAbsolute(value) ? value : resolve(process.cwd(), value)
57
+ }
58
+
59
+ export function ompPaths({ home, env, platform }: OmpPathInputs): OmpPaths {
60
+ // OMP_PROFILE wins whenever it is defined, including when explicitly empty;
61
+ // PI_PROFILE is only the legacy fallback.
62
+ const profile = normalizeProfile(env["OMP_PROFILE"] !== undefined ? env["OMP_PROFILE"] : env["PI_PROFILE"])
63
+
64
+ // PI_CONFIG_DIR is a config root dirname joined under home, not a path.
65
+ const configDirName = env["PI_CONFIG_DIR"]
66
+ const baseRoot = p(home, configDirName !== undefined && configDirName !== "" ? configDirName : DEFAULT_CONFIG_DIR_NAME)
67
+ const configRoot = profile === undefined ? baseRoot : p(baseRoot, "profiles", profile)
68
+
69
+ // A named profile derives its own agent directory and ignores the override.
70
+ const agentOverride = env["PI_CODING_AGENT_DIR"]
71
+ const defaultAgentDir = p(configRoot, "agent")
72
+ const agentDir = profile === undefined && agentOverride !== undefined && agentOverride !== ""
73
+ ? resolveAgentOverride(agentOverride)
74
+ : defaultAgentDir
75
+
76
+ return { profile, configRoot, agentDir, dataRoot: xdgDataRoot(env, platform, profile, agentDir === defaultAgentDir) ?? configRoot }
77
+ }
78
+
79
+ /**
80
+ * XDG is a Linux and macOS convention upstream disables whenever an agent-dir
81
+ * override is active, and adopts a category only once its omp directory
82
+ * exists - `omp config init-xdg` creates it without moving existing data. A
83
+ * named profile keys on its own `profiles/<name>` path and adopts that path,
84
+ * so a profile stays where it was first activated.
85
+ */
86
+ function xdgDataRoot(
87
+ env: Record<string, string | undefined>,
88
+ platform: NodeJS.Platform,
89
+ profile: string | undefined,
90
+ agentDirIsDefault: boolean
91
+ ): string | undefined {
92
+ if (!agentDirIsDefault) return undefined
93
+ if (platform !== "linux" && platform !== "darwin") return undefined
94
+
95
+ const dataHome = env["XDG_DATA_HOME"]
96
+ if (dataHome === undefined || dataHome === "") return undefined
97
+
98
+ const appRoot = p(dataHome, APP_NAME)
99
+ const candidate = profile === undefined ? appRoot : p(appRoot, "profiles", profile)
100
+ return existsSync(candidate) ? candidate : undefined
101
+ }
@@ -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
+ }