docks-kit 0.17.1 → 0.17.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/AGENTS.md +41 -33
  2. package/cli/docs/omp-context.md +104 -0
  3. package/cli/docs/omp-models.md +106 -56
  4. package/cli/src/argv.ts +38 -469
  5. package/cli/src/argvSurface.ts +371 -0
  6. package/cli/src/argvValidate.ts +122 -0
  7. package/cli/src/commands/docs.ts +62 -42
  8. package/cli/src/commands/harnesses.ts +38 -45
  9. package/cli/src/commands/model.ts +48 -53
  10. package/cli/src/commands/models.ts +24 -26
  11. package/cli/src/commands/omp.ts +132 -112
  12. package/cli/src/commands/plugins.ts +20 -20
  13. package/cli/src/commands/skills.ts +22 -20
  14. package/cli/src/commands/status.ts +87 -80
  15. package/cli/src/commands/sync.ts +113 -87
  16. package/cli/src/commands/toolchain.ts +21 -21
  17. package/cli/src/commands/update.ts +139 -117
  18. package/cli/src/efforts.ts +35 -34
  19. package/cli/src/engine-native/DESIGN.md +30 -30
  20. package/cli/src/engine-native/bun.ts +77 -55
  21. package/cli/src/engine-native/claudeLsp.ts +154 -0
  22. package/cli/src/engine-native/claudeOptionalPlugins.ts +113 -0
  23. package/cli/src/engine-native/claudePluginPasses.ts +348 -0
  24. package/cli/src/engine-native/claudeRemovals.ts +195 -0
  25. package/cli/src/engine-native/claudeRetired.ts +4 -4
  26. package/cli/src/engine-native/claudeRuntime.ts +94 -68
  27. package/cli/src/engine-native/claudeSettings.ts +174 -0
  28. package/cli/src/engine-native/claudeSettingsModifiers.ts +50 -54
  29. package/cli/src/engine-native/claudeSync.ts +176 -456
  30. package/cli/src/engine-native/codexConfig.ts +158 -0
  31. package/cli/src/engine-native/codexHooks.ts +91 -0
  32. package/cli/src/engine-native/codexPlugins.ts +290 -0
  33. package/cli/src/engine-native/codexStatus.ts +24 -0
  34. package/cli/src/engine-native/codexSync.ts +110 -568
  35. package/cli/src/engine-native/codexToml.ts +140 -138
  36. package/cli/src/engine-native/deps.ts +144 -107
  37. package/cli/src/engine-native/engineCtx.ts +126 -0
  38. package/cli/src/engine-native/exec.ts +98 -87
  39. package/cli/src/engine-native/failures.ts +3 -3
  40. package/cli/src/engine-native/harnesses.ts +62 -72
  41. package/cli/src/engine-native/index.ts +34 -315
  42. package/cli/src/engine-native/jq.ts +25 -24
  43. package/cli/src/engine-native/logger.ts +98 -100
  44. package/cli/src/engine-native/models.ts +50 -45
  45. package/cli/src/engine-native/modes.ts +83 -80
  46. package/cli/src/engine-native/ompFileDeploy.ts +111 -0
  47. package/cli/src/engine-native/ompMarketplace.ts +136 -0
  48. package/cli/src/engine-native/ompOverlay.ts +67 -64
  49. package/cli/src/engine-native/ompPaths.ts +49 -38
  50. package/cli/src/engine-native/ompPlugins.ts +170 -0
  51. package/cli/src/engine-native/ompRemovals.ts +159 -0
  52. package/cli/src/engine-native/ompSync.ts +69 -409
  53. package/cli/src/engine-native/ompYaml.ts +43 -41
  54. package/cli/src/engine-native/os/darwin.ts +5 -5
  55. package/cli/src/engine-native/os/index.ts +15 -15
  56. package/cli/src/engine-native/os/linux.ts +5 -5
  57. package/cli/src/engine-native/os/posix.ts +28 -24
  58. package/cli/src/engine-native/os/targets.ts +17 -17
  59. package/cli/src/engine-native/os/types.ts +31 -31
  60. package/cli/src/engine-native/os/windows.ts +61 -61
  61. package/cli/src/engine-native/parseArgs.ts +122 -372
  62. package/cli/src/engine-native/parseHelp.ts +66 -0
  63. package/cli/src/engine-native/parseModifiers.ts +222 -0
  64. package/cli/src/engine-native/services.ts +34 -34
  65. package/cli/src/engine-native/settings.ts +15 -15
  66. package/cli/src/engine-native/sharedTypes.d.ts +46 -0
  67. package/cli/src/engine-native/skillsInstall.ts +105 -0
  68. package/cli/src/engine-native/skillsLinks.ts +203 -0
  69. package/cli/src/engine-native/skillsManifest.ts +16 -0
  70. package/cli/src/engine-native/skillsPrune.ts +108 -0
  71. package/cli/src/engine-native/skillsSync.ts +28 -362
  72. package/cli/src/engine-native/syncConcurrency.ts +55 -0
  73. package/cli/src/engine-native/syncDispatch.ts +141 -0
  74. package/cli/src/engine-native/toolchain.ts +65 -62
  75. package/cli/src/engine.ts +65 -57
  76. package/cli/src/generated/sotPayload.ts +7 -7
  77. package/cli/src/kitHome.ts +44 -40
  78. package/cli/src/main.ts +63 -56
  79. package/cli/src/manifests.ts +53 -54
  80. package/cli/src/md.d.ts +2 -2
  81. package/cli/src/payload.ts +9 -9
  82. package/cli/src/services.ts +21 -13
  83. package/cli/tsconfig.json +4 -1
  84. package/docks-kit +13 -2
  85. package/docks-kit.ps1 +21 -6
  86. package/package.json +10 -2
  87. package/cli/src/engine-native/claudePlugins.ts +0 -510
@@ -0,0 +1,159 @@
1
+ /**
2
+ * EngineNative `sync omp` retired-key pruning. `ompYaml.ts mergeOmpConfig` is
3
+ * additive: its `mergeMappings, deployed-key retention loop` returns every
4
+ * deployed key absent from the SoT to the merged result. Removing a key from
5
+ * `SoT/.omp/config.yml` therefore never removes it from an existing
6
+ * `~/.omp/agent/config.yml`. This pass force-prunes an inventory of retired
7
+ * kit-owned keys on every sync, without `--reconcile`. Message strings and
8
+ * prune semantics are part of the contract.
9
+ */
10
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
11
+ import { isMap, isScalar, parseDocument, type YAMLMap } from "yaml";
12
+ import type { Ctx } from "./index";
13
+
14
+ type RetiredScalar = string | number | boolean;
15
+
16
+ /**
17
+ * Keys the kit used to deploy, each paired with the exact value it deployed. A
18
+ * key is pruned only while the deployed value still equals that value, so a
19
+ * user who changed the setting keeps it. Every recorded value also equals omp's
20
+ * current default, so pruning changes no behavior today and lets a later
21
+ * upstream default change take effect.
22
+ */
23
+ const OMP_RETIRED_VALUES: ReadonlyArray<readonly [string, RetiredScalar]> = [
24
+ ["symbolPreset", "unicode"],
25
+ ["theme.dark", "titanium"],
26
+ ["statusLine.preset", "default"],
27
+ ["statusLine.showHookStatus", true],
28
+ ["statusLine.sessionAccent", true],
29
+ ["statusLine.transparent", false],
30
+ ["terminal.showProgress", false],
31
+ ["tui.textSizing", false],
32
+ ["tui.tight", false],
33
+ ["display.shimmer", "classic"],
34
+ ["omitThinking", false],
35
+ ["hideThinkingBlock", false],
36
+ ["autocompleteMaxVisible", 10],
37
+ ["emojiAutocomplete", true],
38
+ ["readLineNumbers", false],
39
+ ["interruptMode", "immediate"],
40
+ ["autoResume", false],
41
+ ["startup.changelogMode", "summary"],
42
+ ];
43
+
44
+ /**
45
+ * Keys retired outright and pruned at any value, because the key itself no
46
+ * longer means anything. omp migrates `providers.webSearchOrder` in memory into
47
+ * `modelRoles.web` plus `retry.fallbackChains.web` and then drops it, but never
48
+ * writes that expansion back to the file.
49
+ */
50
+ const OMP_RETIRED_KEYS: ReadonlyArray<string> = ["providers.webSearchOrder"];
51
+
52
+ function indexOfKey(mapping: YAMLMap, key: string): number {
53
+ return mapping.items.findIndex((pair) => {
54
+ const name: unknown = pair.key;
55
+ if (typeof name === "string") return name === key;
56
+ return isScalar(name) && name.value === key;
57
+ });
58
+ }
59
+
60
+ /**
61
+ * Deletes `segments` when `matches` accepts the leaf value, then removes every
62
+ * ancestor mapping the deletion left empty. An ancestor holding another key
63
+ * survives, so `statusLine` keeps `compactThinkingLevel` while `tui` disappears
64
+ * once both of its keys are gone.
65
+ */
66
+ function deletePath(
67
+ root: YAMLMap,
68
+ segments: ReadonlyArray<string>,
69
+ matches: (value: unknown) => boolean,
70
+ ): boolean {
71
+ const owners: Array<YAMLMap> = [root];
72
+ let cursor: YAMLMap = root;
73
+ for (const segment of segments.slice(0, -1)) {
74
+ const index = indexOfKey(cursor, segment);
75
+ const pair = index < 0 ? undefined : cursor.items[index];
76
+ if (pair === undefined || !isMap(pair.value)) return false;
77
+ cursor = pair.value;
78
+ owners.push(cursor);
79
+ }
80
+
81
+ const leaf = segments[segments.length - 1];
82
+ if (leaf === undefined) return false;
83
+ const leafIndex = indexOfKey(cursor, leaf);
84
+ if (leafIndex < 0) return false;
85
+ const leafPair = cursor.items[leafIndex];
86
+ if (leafPair === undefined || !matches(leafPair.value)) return false;
87
+
88
+ // `commentBefore` can hold several blocks separated by a blank line. Only the
89
+ // final block sits against this key; anything above it is a file or section
90
+ // header that happens to precede the key, so it survives the removal instead
91
+ // of leaving with it.
92
+ const leafKey: unknown = leafPair.key;
93
+ const priorComment =
94
+ isScalar(leafKey) && typeof leafKey.commentBefore === "string"
95
+ ? leafKey.commentBefore.slice(0, Math.max(0, leafKey.commentBefore.lastIndexOf("\n\n")))
96
+ : "";
97
+ cursor.items.splice(leafIndex, 1);
98
+ if (priorComment !== "") {
99
+ const heir: unknown = cursor.items[leafIndex]?.key;
100
+ if (isScalar(heir)) {
101
+ heir.commentBefore =
102
+ typeof heir.commentBefore === "string"
103
+ ? `${priorComment}\n\n${heir.commentBefore}`
104
+ : priorComment;
105
+ } else {
106
+ // The key was last in its mapping, so there is no later key to carry the
107
+ // header. A trailing mapping comment keeps the text in the file.
108
+ cursor.comment =
109
+ typeof cursor.comment === "string" ? `${cursor.comment}\n${priorComment}` : priorComment;
110
+ }
111
+ }
112
+
113
+ for (let depth = owners.length - 1; depth >= 1; depth--) {
114
+ const mapping = owners[depth];
115
+ if (mapping === undefined || mapping.items.length > 0) break;
116
+ const owner = owners[depth - 1];
117
+ const segment = segments[depth - 1];
118
+ if (owner === undefined || segment === undefined) break;
119
+ const index = indexOfKey(owner, segment);
120
+ if (index >= 0) owner.items.splice(index, 1);
121
+ }
122
+ return true;
123
+ }
124
+
125
+ /** Prunes retired kit-owned keys from a deployed omp config.yml. */
126
+ export function syncOmpRemovals(ctx: Ctx, configFile: string): number {
127
+ const { change, echo, verbose, warn } = ctx.services.logger;
128
+ if (!existsSync(configFile)) return 0;
129
+
130
+ const doc = parseDocument(readFileSync(configFile, "utf8"));
131
+ const parseError = doc.errors[0];
132
+ if (parseError !== undefined) {
133
+ warn(`omp config.yml unreadable, retired keys not pruned: ${parseError.message}`);
134
+ return 0;
135
+ }
136
+ const contents = doc.contents;
137
+ if (!isMap(contents)) return 0;
138
+
139
+ const pruned: Array<string> = [];
140
+ for (const [path, value] of OMP_RETIRED_VALUES) {
141
+ const matches = (node: unknown): boolean => isScalar(node) && node.value === value;
142
+ if (deletePath(contents, path.split("."), matches)) pruned.push(path);
143
+ }
144
+ for (const path of OMP_RETIRED_KEYS) {
145
+ if (deletePath(contents, path.split("."), () => true)) pruned.push(path);
146
+ }
147
+
148
+ if (pruned.length === 0) {
149
+ verbose("omp config.yml carries no retired keys");
150
+ return 0;
151
+ }
152
+ if (ctx.dryRun) {
153
+ echo(`[dry-run] prune ${configFile}: ${pruned.join(", ")}`);
154
+ return pruned.length;
155
+ }
156
+ writeFileSync(configFile, String(doc));
157
+ change(`omp config.yml pruned ${pruned.length} retired key(s): ${pruned.join(", ")}`);
158
+ return pruned.length;
159
+ }
@@ -5,437 +5,97 @@
5
5
  * PI_CODING_AGENT_DIR, and XDG roots each move them.
6
6
  * Resolution stays within the environment and filesystem probes so no omp
7
7
  * subcommand runs under ctx.dryRun.
8
+ *
9
+ * Split: file deploy lives in `ompFileDeploy.ts`, the marketplace pass in
10
+ * `ompMarketplace.ts`, the plugin pass in `ompPlugins.ts`, and the retired-key
11
+ * prune in `ompRemovals.ts`. This module keeps the orchestration plus the
12
+ * summary surface.
8
13
  */
9
- import { chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"
10
- import { basename, isAbsolute, resolve } from "node:path"
11
-
12
- import { payloadDisplayPath, payloadText, type PayloadPath } from "../payload"
13
- import { bunBootstrap } from "./bun"
14
- import { p, spawnProcess, type AsyncProcessResult } from "./exec"
15
- import { recordFailure } from "./failures"
16
- import type { Ctx } from "./index"
17
- import { isObject, parseJson } from "./jq"
18
- import { ompPaths } from "./ompPaths"
19
- import { mergeOmpConfig, mergeOmpModels } from "./ompYaml"
20
- import { field } from "./toolchain"
21
-
22
- const MARKETPLACE_NAME = "docks"
23
- const MARKETPLACE_SOURCE = "https://github.com/DocksDocks/docks.git"
24
- const MARKETPLACE_PLUGIN_IDS = ["docks@docks", "plan-lifecycle@docks"] as const
25
- type OmpTextPayloadPath = Extract<PayloadPath, `SoT/.omp/${string}`>
14
+ import { isAbsolute, resolve } from "node:path";
15
+
16
+ import { bunBootstrap } from "./bun";
17
+ import { p } from "./exec";
18
+ import type { Ctx } from "./index";
19
+ import { ompPaths } from "./ompPaths";
20
+ import { mergeOmpConfig, mergeOmpModels } from "./ompYaml";
21
+ import { ensureDirectory, syncMergedYaml, syncWholeFile } from "./ompFileDeploy";
22
+ import { syncMarketplace } from "./ompMarketplace";
23
+ import { syncPlugins } from "./ompPlugins";
24
+ import { syncOmpRemovals } from "./ompRemovals";
26
25
 
27
26
  export interface OmpState {
28
- readonly pluginsInstalled: number
27
+ readonly pluginsInstalled: number;
29
28
  }
30
29
 
31
30
  export async function ompSync(ctx: Ctx): Promise<OmpState> {
32
- await bunBootstrap(ctx, ctx.services)
33
-
34
- const paths = ompPaths({ home: ctx.home, env: process.env, platform: ctx.services.platform.raw() })
35
- const agentDir = paths.agentDir
36
- if (!ctx.dryRun) ensureDirectory(agentDir)
31
+ await bunBootstrap(ctx, ctx.services);
37
32
 
38
- syncWholeFile(ctx, "SoT/.omp/AGENTS.md", p(agentDir, "AGENTS.md"), "omp AGENTS.md already in sync", "omp AGENTS.md synced")
39
- syncWholeFile(ctx, "SoT/.omp/mcp.json", p(agentDir, "mcp.json"), "omp mcp.json already in sync", "omp mcp.json synced")
40
- syncMergedYaml(ctx, "SoT/.omp/config.yml", p(agentDir, "config.yml"), mergeOmpConfig)
41
- syncMergedYaml(ctx, "SoT/.omp/models.yml", p(agentDir, "models.yml"), mergeOmpModels)
33
+ const paths = ompPaths({
34
+ home: ctx.home,
35
+ env: process.env,
36
+ platform: ctx.services.platform.raw(),
37
+ });
38
+ const agentDir = paths.agentDir;
39
+ if (!ctx.dryRun) ensureDirectory(agentDir);
42
40
 
43
- const intercomRootSetting = process.env["PI_CODING_AGENT_DIR"]
44
- const intercomRoot = intercomRootSetting !== undefined && intercomRootSetting !== ""
45
- ? isAbsolute(intercomRootSetting)
46
- ? intercomRootSetting
47
- : resolve(process.cwd(), intercomRootSetting)
48
- : p(ctx.home, ".pi", "agent")
49
- const intercomDir = p(intercomRoot, "intercom")
50
- if (!ctx.dryRun) ensureDirectory(intercomDir)
41
+ syncWholeFile(
42
+ ctx,
43
+ "SoT/.omp/AGENTS.md",
44
+ p(agentDir, "AGENTS.md"),
45
+ "omp AGENTS.md already in sync",
46
+ "omp AGENTS.md synced",
47
+ );
48
+ syncWholeFile(
49
+ ctx,
50
+ "SoT/.omp/mcp.json",
51
+ p(agentDir, "mcp.json"),
52
+ "omp mcp.json already in sync",
53
+ "omp mcp.json synced",
54
+ );
55
+ syncMergedYaml(ctx, "SoT/.omp/config.yml", p(agentDir, "config.yml"), mergeOmpConfig);
56
+ // mergeOmpConfig is additive, so the prune must run on the merged result.
57
+ syncOmpRemovals(ctx, p(agentDir, "config.yml"));
58
+ syncMergedYaml(ctx, "SoT/.omp/models.yml", p(agentDir, "models.yml"), mergeOmpModels);
59
+
60
+ const intercomRootSetting = process.env["PI_CODING_AGENT_DIR"];
61
+ const intercomRoot =
62
+ intercomRootSetting !== undefined && intercomRootSetting !== ""
63
+ ? isAbsolute(intercomRootSetting)
64
+ ? intercomRootSetting
65
+ : resolve(process.cwd(), intercomRootSetting)
66
+ : p(ctx.home, ".pi", "agent");
67
+ const intercomDir = p(intercomRoot, "intercom");
68
+ if (!ctx.dryRun) ensureDirectory(intercomDir);
51
69
  syncWholeFile(
52
70
  ctx,
53
71
  "SoT/.omp/intercom.json",
54
72
  p(intercomDir, "config.json"),
55
73
  "omp intercom configuration already in sync",
56
- "omp intercom configuration synced"
57
- )
58
-
59
- const legacyRegistryFile = paths.dataRoot === paths.configRoot
60
- ? undefined
61
- : p(paths.configRoot, "marketplaces.json")
62
- await syncMarketplace(ctx, p(paths.dataRoot, "marketplaces.json"), legacyRegistryFile)
63
- const pluginsInstalled = await syncPlugins(ctx)
64
- return { pluginsInstalled }
65
- }
66
-
67
- // ------------------------------------------------------------ file modes ----
68
-
69
- /**
70
- * `mkdirSync` and `writeFileSync` apply their `mode` only when they create the
71
- * path, so an existing world-readable directory or file would keep its mode.
72
- * The explicit chmod reproduces `install -d -m 0700` / `install -m 0600`.
73
- */
74
- function ensureDirectory(directory: string): void {
75
- mkdirSync(directory, { recursive: true, mode: 0o700 })
76
- chmodSync(directory, 0o700)
77
- }
78
-
79
- function writePrivateFile(target: string, content: string): void {
80
- writeFileSync(target, content, { mode: 0o600 })
81
- chmodSync(target, 0o600)
82
- }
83
-
84
- function backupPrivateFile(target: string): void {
85
- copyFileSync(target, `${target}.bak`)
86
- chmodSync(`${target}.bak`, 0o600)
87
- }
88
-
89
- // --------------------------------------------------------------- config ----
90
-
91
- function syncWholeFile(
92
- ctx: Ctx,
93
- sourcePath: OmpTextPayloadPath,
94
- target: string,
95
- alreadyMessage: string,
96
- syncedMessage: string
97
- ): void {
98
- const { change, echo, verbose } = ctx.services.logger
99
- const source = payloadDisplayPath(sourcePath)
100
- const content = payloadText(sourcePath)
101
-
102
- if (ctx.dryRun) {
103
- echo(`[dry-run] cp ${source} -> ${target}`)
104
- return
105
- }
106
-
107
- if (existsSync(target) && readFileSync(target, "utf8") === content) {
108
- verbose(alreadyMessage)
109
- return
110
- }
111
- if (existsSync(target)) backupPrivateFile(target)
112
- writePrivateFile(target, content)
113
- change(syncedMessage)
114
- ctx.nextStepTriggers.ompRestart = true
115
- }
116
-
117
- function syncMergedYaml(
118
- ctx: Ctx,
119
- sourcePath: OmpTextPayloadPath,
120
- target: string,
121
- merge: (sotText: string, deployedText: string) => string
122
- ): void {
123
- const { change, echo, verbose } = ctx.services.logger
124
- const source = payloadDisplayPath(sourcePath)
125
- const sotText = payloadText(sourcePath)
126
- const name = basename(sourcePath)
127
-
128
- if (!existsSync(target)) {
129
- if (ctx.dryRun) {
130
- echo(`[dry-run] cp ${source} -> ${target}`)
131
- return
132
- }
133
- writePrivateFile(target, sotText)
134
- change(`omp ${name} installed`)
135
- ctx.nextStepTriggers.ompRestart = true
136
- return
137
- }
138
-
139
- const deployedText = readFileSync(target, "utf8")
140
- const merged = merge(sotText, deployedText)
141
- if (merged === deployedText) {
142
- verbose(`omp ${name} already in sync`)
143
- return
144
- }
145
- if (ctx.dryRun) {
146
- echo(`[dry-run] merge ${source} -> ${target} (backup at ${target}.bak)`)
147
- return
148
- }
149
-
150
- backupPrivateFile(target)
151
- writePrivateFile(`${target}.tmp`, merged)
152
- renameSync(`${target}.tmp`, target)
153
- chmodSync(target, 0o600)
154
- change(`omp ${name} merged (backup at ${name}.bak)`)
155
- ctx.nextStepTriggers.ompRestart = true
156
- }
157
-
158
- // ---------------------------------------------------------- marketplace ----
159
-
160
- function registryHasDocks(registryFile: string): boolean {
161
- if (!existsSync(registryFile)) return false
162
-
163
- let registryText: string
164
- try {
165
- registryText = readFileSync(registryFile, "utf8")
166
- } catch {
167
- return false
168
- }
169
- const registry = parseJson(registryText)
170
- if (registry === undefined) return false
171
- if (isObject(registry) && Object.hasOwn(registry, MARKETPLACE_NAME)) return true
172
-
173
- const entries = Array.isArray(registry)
174
- ? registry
175
- : isObject(registry) && Array.isArray(registry["marketplaces"])
176
- ? registry["marketplaces"]
177
- : []
178
- return entries.some((entry) => isObject(entry) && entry["name"] === MARKETPLACE_NAME)
179
- }
180
-
181
- function firstOutputLine(result: AsyncProcessResult): string {
182
- const output = `${result.stdout}${result.stderr}`
183
- return output.split("\n")[0] || "unknown error"
184
- }
185
-
186
- /**
187
- * Three states, because upstream `getMarketplacesRegistryPath` copies a legacy
188
- * `configRoot` registry forward the first time it resolves an XDG data root:
189
- * the active registry lists docks, only the legacy registry lists it, or
190
- * neither does. In the middle state the kit never copies user data itself; it
191
- * takes the refresh path, whose own resolution inside omp performs that
192
- * adoption and leaves the active registry present.
193
- */
194
- async function syncMarketplace(ctx: Ctx, registryFile: string, legacyRegistryFile?: string): Promise<void> {
195
- const { change, clearProgress, echo, progress, verbose } = ctx.services.logger
196
- const registered = registryHasDocks(registryFile)
197
- const adoptable = !registered && legacyRegistryFile !== undefined && registryHasDocks(legacyRegistryFile)
198
-
199
- if (ctx.dryRun) {
200
- if (adoptable) {
201
- echo(
202
- ctx.skipPluginRefresh === true
203
- ? "[dry-run] omp plugin marketplace list"
204
- : `[dry-run] omp plugin marketplace update ${MARKETPLACE_NAME}`
205
- )
206
- } else if (registered) verbose("omp docks marketplace already registered")
207
- else echo(`[dry-run] omp plugin marketplace add ${MARKETPLACE_SOURCE}`)
208
- return
209
- }
210
-
211
- // syncPlugins owns the single skip message for both missing CLIs, and it runs
212
- // right after this pass. Without omp no marketplace command can run at all,
213
- // and without git a marketplace clone cannot resolve, so both are deliberate
214
- // skips: return silently instead of spawning and recording a failure.
215
- if (ctx.services.deps.probe("omp").state === "missing") return
216
- if (ctx.services.deps.probe("git").state === "missing") return
217
-
218
- if (!registered && !adoptable) {
219
- progress("Registering omp docks marketplace...")
220
- const result = await spawnProcess("omp", ["plugin", "marketplace", "add", MARKETPLACE_SOURCE], {
221
- stdio: ["ignore", "pipe", "pipe"]
222
- })
223
- clearProgress()
224
- if (result.error === undefined && result.exitCode === 0) {
225
- change("omp docks marketplace registered")
226
- } else {
227
- recordFailure(
228
- ctx,
229
- `omp docks marketplace registration failed: ${firstOutputLine(result)}; run manually: omp plugin marketplace add ${MARKETPLACE_SOURCE}`
230
- )
231
- }
232
- return
233
- }
234
-
235
- if (ctx.skipPluginRefresh === true) {
236
- if (!adoptable) {
237
- verbose("omp docks marketplace already registered; refresh-only update skipped")
238
- return
239
- }
240
-
241
- // Adoption only needs omp to resolve the registry path. `marketplace list`
242
- // does that and fetches nothing, so it stays inside the flag's contract
243
- // while leaving the active registry present.
244
- progress("Adopting omp docks marketplace registry...")
245
- const listed = await spawnProcess("omp", ["plugin", "marketplace", "list"], {
246
- stdio: ["ignore", "ignore", "pipe"]
247
- })
248
- clearProgress()
249
- if (listed.error === undefined && listed.exitCode === 0) {
250
- change("omp docks marketplace registry adopted; refresh-only update skipped")
251
- } else {
252
- recordFailure(
253
- ctx,
254
- `omp docks marketplace adoption failed: ${firstOutputLine(listed)}; run manually: omp plugin marketplace list`
255
- )
256
- }
257
- return
258
- }
259
-
260
- progress("Updating omp docks marketplace...")
261
- const result = await spawnProcess("omp", ["plugin", "marketplace", "update", MARKETPLACE_NAME], {
262
- stdio: ["ignore", "pipe", "pipe"]
263
- })
264
- clearProgress()
265
- if (result.error === undefined && result.exitCode === 0) {
266
- verbose("omp docks marketplace refreshed")
267
- } else {
268
- recordFailure(
269
- ctx,
270
- `omp docks marketplace update failed: ${firstOutputLine(result)}; run manually: omp plugin marketplace update ${MARKETPLACE_NAME}`
271
- )
272
- }
273
- }
274
-
275
- // -------------------------------------------------------------- plugins ----
276
-
277
- interface InstalledPlugins {
278
- readonly marketplace: Set<string>
279
- readonly npm: Map<string, string>
280
- }
281
-
282
- async function installedPluginIdsFromCli(): Promise<InstalledPlugins | undefined> {
283
- const result = await spawnProcess("omp", ["plugin", "list", "--json"], {
284
- stdio: ["ignore", "pipe", "ignore"]
285
- })
286
- if (result.error !== undefined || result.exitCode !== 0) return undefined
287
-
288
- const value = parseJson(result.stdout)
289
- if (
290
- value === undefined ||
291
- !isObject(value) ||
292
- !Array.isArray(value["marketplace"]) ||
293
- !Array.isArray(value["npm"])
294
- ) {
295
- return undefined
296
- }
297
-
298
- // `omp plugin list --json` reports marketplace rows as
299
- // `{ id: "<plugin>@<marketplace>", scope, entries: [...] }` - the composite id
300
- // is already the token `omp plugin install/upgrade` takes. omp emits one row
301
- // per scope holding the plugin, so a match found only in a `project` row
302
- // means the user scope is empty and `upgrade --scope user` would fail; only a
303
- // `user` row counts as installed for this pipeline.
304
- const marketplace = new Set<string>()
305
- for (const row of value["marketplace"]) {
306
- if (!isObject(row) || typeof row["id"] !== "string" || row["scope"] !== "user") continue
307
- marketplace.add(row["id"])
308
- }
309
-
310
- const npm = new Map<string, string>()
311
- for (const row of value["npm"]) {
312
- if (!isObject(row) || typeof row["name"] !== "string" || typeof row["version"] !== "string") continue
313
- npm.set(row["name"], row["version"])
314
- }
315
- return { marketplace, npm }
316
- }
317
-
318
- async function runPluginCommand(
319
- ctx: Ctx,
320
- plugin: string,
321
- args: ReadonlyArray<string>
322
- ): Promise<boolean> {
323
- const { clearProgress, progress } = ctx.services.logger
324
- progress(`Updating omp plugin ${plugin}...`)
325
- const result = await spawnProcess("omp", args, { stdio: ["ignore", "pipe", "pipe"] })
326
- clearProgress()
327
- if (result.error === undefined && result.exitCode === 0) return true
328
-
329
- recordFailure(
330
- ctx,
331
- `omp plugin operation failed for ${plugin}: ${firstOutputLine(result)}; run manually: omp ${args.join(" ")}`
332
- )
333
- return false
334
- }
335
-
336
- async function syncPlugins(ctx: Ctx): Promise<number> {
337
- const { change, clearProgress, echo, progress, verbose, warn } = ctx.services.logger
338
-
339
- if (ctx.dryRun) {
340
- const piIntercomPin = field("pi-intercom", "verified")
341
- for (const pluginId of MARKETPLACE_PLUGIN_IDS) {
342
- echo(`[dry-run] omp plugin install --scope user ${pluginId}`)
343
- }
344
- if (piIntercomPin === "") {
345
- warn("pi-intercom install skipped because SoT/toolchain.json has no verified pi-intercom pin")
346
- } else {
347
- echo(`[dry-run] omp install pi-intercom@${piIntercomPin}`)
348
- }
349
- return 0
350
- }
351
-
352
- if (ctx.services.deps.probe("omp").state === "missing") {
353
- ctx.services.deps.warnMissing(
354
- "omp",
355
- ctx.services.logger,
356
- "deployed omp config only — marketplace and plugin passes skipped; re-run sync after installing"
357
- )
358
- return 0
359
- }
360
- if (ctx.services.deps.probe("git").state === "missing") {
361
- ctx.services.deps.warnMissing(
362
- "git",
363
- ctx.services.logger,
364
- "plugin marketplaces are git repos — omp plugin refresh skipped; re-run sync after installing"
365
- )
366
- return 0
367
- }
368
- const piIntercomPin = field("pi-intercom", "verified")
369
-
370
- progress("Checking installed omp plugins...")
371
- const installed = await installedPluginIdsFromCli()
372
- clearProgress()
373
- if (installed === undefined) {
374
- warn("omp plugin inventory unavailable — falling back to the full refresh path")
375
- }
376
-
377
- let pluginsInstalled = installed === undefined
378
- ? 0
379
- : MARKETPLACE_PLUGIN_IDS.filter((pluginId) => installed.marketplace.has(pluginId)).length +
380
- (piIntercomPin !== "" && installed.npm.has("pi-intercom") ? 1 : 0)
381
- let operationsSucceeded = 0
382
-
383
- for (const pluginId of MARKETPLACE_PLUGIN_IDS) {
384
- const present = installed?.marketplace.has(pluginId) === true
385
- if (present && ctx.skipPluginRefresh === true) {
386
- verbose(`omp plugin ${pluginId} already installed; refresh-only update skipped`)
387
- continue
388
- }
389
-
390
- const args = present
391
- ? ["plugin", "upgrade", "--scope", "user", pluginId]
392
- : ["plugin", "install", "--scope", "user", pluginId]
393
- if (await runPluginCommand(ctx, pluginId, args)) {
394
- operationsSucceeded++
395
- if (!present) pluginsInstalled++
396
- }
397
- }
398
-
399
- if (piIntercomPin === "") {
400
- warn("pi-intercom install skipped because SoT/toolchain.json has no verified pi-intercom pin")
401
- } else {
402
- const installedVersion = installed?.npm.get("pi-intercom")
403
- const present = installedVersion !== undefined
404
- if (installedVersion === piIntercomPin) {
405
- verbose(`omp npm plugin pi-intercom already installed at ${piIntercomPin}`)
406
- } else if (present && ctx.skipPluginRefresh === true) {
407
- verbose("omp npm plugin pi-intercom already installed; refresh-only update skipped")
408
- } else {
409
- const args = present
410
- ? ["install", "--force", `pi-intercom@${piIntercomPin}`]
411
- : ["install", `pi-intercom@${piIntercomPin}`]
412
- if (await runPluginCommand(ctx, "pi-intercom", args)) {
413
- operationsSucceeded++
414
- if (!present) pluginsInstalled++
415
- }
416
- }
417
- }
74
+ "omp intercom configuration synced",
75
+ );
418
76
 
419
- if (operationsSucceeded > 0) {
420
- change(`omp plugins synced (plugins: ~${operationsSucceeded})`)
421
- ctx.nextStepTriggers.ompRestart = true
422
- } else {
423
- verbose("omp plugins already in sync")
424
- }
425
- return pluginsInstalled
77
+ const legacyRegistryFile =
78
+ paths.dataRoot === paths.configRoot ? undefined : p(paths.configRoot, "marketplaces.json");
79
+ await syncMarketplace(ctx, p(paths.dataRoot, "marketplaces.json"), legacyRegistryFile);
80
+ const pluginsInstalled = await syncPlugins(ctx);
81
+ return { pluginsInstalled };
426
82
  }
427
83
 
428
84
  // -------------------------------------------------------------- summary ----
429
85
 
430
86
  export function ompSummary(ctx: Ctx, state: OmpState): void {
431
- const { echo } = ctx.services.logger
432
- const agentDir = ompPaths({ home: ctx.home, env: process.env, platform: ctx.services.platform.raw() }).agentDir
433
- echo(`omp: ${agentDir}`)
434
- if (!ctx.dryRun) echo(`omp plugins: ${state.pluginsInstalled} installed`)
87
+ const { echo } = ctx.services.logger;
88
+ const agentDir = ompPaths({
89
+ home: ctx.home,
90
+ env: process.env,
91
+ platform: ctx.services.platform.raw(),
92
+ }).agentDir;
93
+ echo(`omp: ${agentDir}`);
94
+ if (!ctx.dryRun) echo(`omp plugins: ${state.pluginsInstalled} installed`);
435
95
  }
436
96
 
437
97
  export function ompNextSteps(ctx: Ctx): Array<string> {
438
98
  return ctx.verbose || ctx.nextStepTriggers.ompRestart
439
99
  ? ["Restart omp to load any refreshed plugins, skills, or tools."]
440
- : []
100
+ : [];
441
101
  }