docks-kit 0.17.0 → 0.17.2

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 (84) hide show
  1. package/AGENTS.md +37 -33
  2. package/README.md +1 -0
  3. package/cli/docs/omp-models.md +114 -58
  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 +57 -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 +178 -88
  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 +93 -53
  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/ompSync.ts +64 -408
  52. package/cli/src/engine-native/ompYaml.ts +43 -41
  53. package/cli/src/engine-native/os/darwin.ts +5 -5
  54. package/cli/src/engine-native/os/index.ts +15 -15
  55. package/cli/src/engine-native/os/linux.ts +5 -5
  56. package/cli/src/engine-native/os/posix.ts +28 -24
  57. package/cli/src/engine-native/os/targets.ts +17 -17
  58. package/cli/src/engine-native/os/types.ts +31 -31
  59. package/cli/src/engine-native/os/windows.ts +61 -61
  60. package/cli/src/engine-native/parseArgs.ts +122 -372
  61. package/cli/src/engine-native/parseHelp.ts +66 -0
  62. package/cli/src/engine-native/parseModifiers.ts +222 -0
  63. package/cli/src/engine-native/services.ts +34 -34
  64. package/cli/src/engine-native/settings.ts +15 -15
  65. package/cli/src/engine-native/sharedTypes.d.ts +46 -0
  66. package/cli/src/engine-native/skillsInstall.ts +105 -0
  67. package/cli/src/engine-native/skillsLinks.ts +203 -0
  68. package/cli/src/engine-native/skillsManifest.ts +16 -0
  69. package/cli/src/engine-native/skillsPrune.ts +108 -0
  70. package/cli/src/engine-native/skillsSync.ts +28 -362
  71. package/cli/src/engine-native/syncConcurrency.ts +55 -0
  72. package/cli/src/engine-native/syncDispatch.ts +141 -0
  73. package/cli/src/engine-native/toolchain.ts +65 -62
  74. package/cli/src/engine.ts +65 -57
  75. package/cli/src/generated/sotPayload.ts +7 -7
  76. package/cli/src/kitHome.ts +44 -40
  77. package/cli/src/main.ts +63 -56
  78. package/cli/src/manifests.ts +53 -54
  79. package/cli/src/md.d.ts +2 -2
  80. package/cli/src/payload.ts +9 -9
  81. package/cli/src/services.ts +21 -13
  82. package/cli/tsconfig.json +4 -1
  83. package/package.json +9 -2
  84. package/cli/src/engine-native/claudePlugins.ts +0 -510
@@ -3,14 +3,14 @@
3
3
  * config for one run only, so every key that can cause paid spend is set here
4
4
  * and every other key falls through to the deployed paid config.
5
5
  */
6
- import { createHash } from "node:crypto"
7
- import type { OmpSessionModel } from "./harnesses"
6
+ import { createHash } from "node:crypto";
7
+ import type { OmpSessionModel } from "./harnesses";
8
8
 
9
9
  export interface CatalogModel {
10
- readonly selector: string
11
- readonly name: string
12
- readonly thinking: ReadonlyArray<string>
13
- readonly contextWindow: number
10
+ readonly selector: string;
11
+ readonly name: string;
12
+ readonly thinking: ReadonlyArray<string>;
13
+ readonly contextWindow: number;
14
14
  }
15
15
 
16
16
  /** Thinking levels from lowest to highest effort. */
@@ -21,7 +21,7 @@ export const THINKING_LADDER: ReadonlyArray<string> = [
21
21
  "high",
22
22
  "xhigh",
23
23
  "max",
24
- ]
24
+ ];
25
25
 
26
26
  /**
27
27
  * One overlay file per model, so a second launcher on another model cannot
@@ -32,9 +32,9 @@ export const THINKING_LADDER: ReadonlyArray<string> = [
32
32
  * two distinct models on two distinct files.
33
33
  */
34
34
  export function overlayFileName(selector: string): string {
35
- const safe = selector.replace(/[^A-Za-z0-9._-]/g, "-")
36
- const digest = createHash("sha256").update(selector).digest("hex").slice(0, 8)
37
- return `omp-free-${safe}-${digest}.yml`
35
+ const safe = selector.replace(/[^A-Za-z0-9._-]/g, "-");
36
+ const digest = createHash("sha256").update(selector).digest("hex").slice(0, 8);
37
+ return `omp-free-${safe}-${digest}.yml`;
38
38
  }
39
39
 
40
40
  /** Role keys in the order used by SoT/.omp/config.yml for readable diffs. */
@@ -51,7 +51,8 @@ const ROLE_ORDER: ReadonlyArray<string> = [
51
51
  "slow",
52
52
  "fable",
53
53
  "switch_fable",
54
- ]
54
+ "astra",
55
+ ];
55
56
 
56
57
  /** Retry chain keys in the order used by SoT/.omp/config.yml. */
57
58
  const FALLBACK_ORDER: ReadonlyArray<string> = [
@@ -64,7 +65,8 @@ const FALLBACK_ORDER: ReadonlyArray<string> = [
64
65
  "commit",
65
66
  "switch_fable",
66
67
  "fable",
67
- ]
68
+ "astra",
69
+ ];
68
70
 
69
71
  /**
70
72
  * Collect free models from parsed `omp models --json` output. The function is
@@ -73,36 +75,36 @@ const FALLBACK_ORDER: ReadonlyArray<string> = [
73
75
  */
74
76
  export function parseFreeModels(catalogJson: unknown): ReadonlyArray<CatalogModel> {
75
77
  if (typeof catalogJson !== "object" || catalogJson === null || Array.isArray(catalogJson)) {
76
- return []
78
+ return [];
77
79
  }
78
- const models = (catalogJson as Record<string, unknown>)["models"]
79
- if (!Array.isArray(models)) return []
80
- const found: Array<CatalogModel> = []
80
+ const models = (catalogJson as Record<string, unknown>)["models"];
81
+ if (!Array.isArray(models)) return [];
82
+ const found: Array<CatalogModel> = [];
81
83
  for (const row of models) {
82
- if (typeof row !== "object" || row === null || Array.isArray(row)) continue
83
- const record = row as Record<string, unknown>
84
- const selector = record["selector"]
85
- if (typeof selector !== "string" || selector.length === 0) continue
86
- const cost = record["cost"]
87
- if (typeof cost !== "object" || cost === null || Array.isArray(cost)) continue
88
- const charges = cost as Record<string, unknown>
89
- if (charges["input"] !== 0 || charges["output"] !== 0) continue
90
- const rawName = record["name"]
91
- const name = typeof rawName === "string" && rawName.length > 0 ? rawName : selector
92
- const rawLevels = record["thinking"]
84
+ if (typeof row !== "object" || row === null || Array.isArray(row)) continue;
85
+ const record = row as Record<string, unknown>;
86
+ const selector = record["selector"];
87
+ if (typeof selector !== "string" || selector.length === 0) continue;
88
+ const cost = record["cost"];
89
+ if (typeof cost !== "object" || cost === null || Array.isArray(cost)) continue;
90
+ const charges = cost as Record<string, unknown>;
91
+ if (charges["input"] !== 0 || charges["output"] !== 0) continue;
92
+ const rawName = record["name"];
93
+ const name = typeof rawName === "string" && rawName.length > 0 ? rawName : selector;
94
+ const rawLevels = record["thinking"];
93
95
  // Keep only known ladder levels in ladder order so an unknown future
94
96
  // level never reaches a role value that omp would reject.
95
97
  const thinking = THINKING_LADDER.filter(
96
98
  (level) => Array.isArray(rawLevels) && (rawLevels as Array<unknown>).includes(level),
97
- )
98
- const rawWindow = record["contextWindow"]
99
+ );
100
+ const rawWindow = record["contextWindow"];
99
101
  const contextWindow =
100
- typeof rawWindow === "number" && Number.isFinite(rawWindow) ? rawWindow : 0
101
- found.push({ selector, name, thinking, contextWindow })
102
+ typeof rawWindow === "number" && Number.isFinite(rawWindow) ? rawWindow : 0;
103
+ found.push({ selector, name, thinking, contextWindow });
102
104
  }
103
105
  // Sort by selector so the picker list is stable across catalog orderings.
104
- found.sort((a, b) => (a.selector < b.selector ? -1 : a.selector > b.selector ? 1 : 0))
105
- return found
106
+ found.sort((a, b) => (a.selector < b.selector ? -1 : a.selector > b.selector ? 1 : 0));
107
+ return found;
106
108
  }
107
109
 
108
110
  /**
@@ -112,9 +114,9 @@ export function parseFreeModels(catalogJson: unknown): ReadonlyArray<CatalogMode
112
114
  */
113
115
  export function ladderCeiling(levels: ReadonlyArray<string>): string | undefined {
114
116
  for (let index = THINKING_LADDER.length - 1; index >= 0; index -= 1) {
115
- if (levels.includes(THINKING_LADDER[index] as string)) return THINKING_LADDER[index] as string
117
+ if (levels.includes(THINKING_LADDER[index] as string)) return THINKING_LADDER[index] as string;
116
118
  }
117
- return undefined
119
+ return undefined;
118
120
  }
119
121
 
120
122
  /**
@@ -124,14 +126,51 @@ export function ladderCeiling(levels: ReadonlyArray<string>): string | undefined
124
126
  * never exceeds the ladder ceiling by construction.
125
127
  */
126
128
  export function advisorLevelFor(levels: ReadonlyArray<string>): string | undefined {
127
- const known = THINKING_LADDER.filter((level) => levels.includes(level))
128
- if (known.length === 0) return undefined
129
- const mediumIndex = THINKING_LADDER.indexOf("medium")
130
- let advisor: string | undefined
129
+ const known = THINKING_LADDER.filter((level) => levels.includes(level));
130
+ if (known.length === 0) return undefined;
131
+ const mediumIndex = THINKING_LADDER.indexOf("medium");
132
+ let advisor: string | undefined;
131
133
  for (const level of known) {
132
- if ((THINKING_LADDER.indexOf(level) as number) <= (mediumIndex as number)) advisor = level
134
+ if ((THINKING_LADDER.indexOf(level) as number) <= (mediumIndex as number)) advisor = level;
133
135
  }
134
- return advisor ?? (known[0] as string)
136
+ return advisor ?? (known[0] as string);
137
+ }
138
+
139
+ /**
140
+ * What the picker may ask about thinking levels for one model. omp accepts a
141
+ * `:level` suffix on a role only for a level the model publishes, so the
142
+ * question set comes from the model own ladder: no ladder means no question,
143
+ * a single level means no choice to make, and two or more levels mean the
144
+ * user can split the advisor from the other roles.
145
+ */
146
+ export type EffortPlan =
147
+ | { readonly kind: "none" }
148
+ | { readonly kind: "fixed"; readonly level: string }
149
+ | { readonly kind: "choose"; readonly levels: ReadonlyArray<string>; readonly highest: string };
150
+
151
+ export function planEffortChoice(levels: ReadonlyArray<string>): EffortPlan {
152
+ const known = THINKING_LADDER.filter((level) => levels.includes(level));
153
+ const only = known[0];
154
+ if (only === undefined) return { kind: "none" };
155
+ if (known.length === 1) return { kind: "fixed", level: only };
156
+ return { kind: "choose", levels: known, highest: known[known.length - 1] as string };
157
+ }
158
+
159
+ /**
160
+ * Recommend an advisor level for a chosen main level. The advisor runs quick
161
+ * review passes, so it sits two steps down the model own ladder, and it
162
+ * clamps to the lowest level the model publishes. Steps count positions in
163
+ * that model ladder, not the global ladder, so a sparse ladder such as
164
+ * `high, max` still recommends a real level.
165
+ */
166
+ export function advisorRecommendation(
167
+ levels: ReadonlyArray<string>,
168
+ main: string,
169
+ ): string | undefined {
170
+ const known = THINKING_LADDER.filter((level) => levels.includes(level));
171
+ const index = known.indexOf(main);
172
+ if (index < 0) return known[0];
173
+ return known[Math.max(0, index - 2)];
135
174
  }
136
175
 
137
176
  /**
@@ -140,29 +179,30 @@ export function advisorLevelFor(levels: ReadonlyArray<string>): string | undefin
140
179
  * selectors with no thinking keys so the deployed values apply.
141
180
  */
142
181
  export function renderFreeOverlay(model: OmpSessionModel): string {
143
- const thinking = typeof model.thinking === "string" && model.thinking.trim() !== "" ? model.thinking : undefined
144
- const lines: Array<string> = ["# Generated per run by docks-kit omp. Edits are overwritten."]
182
+ const thinking =
183
+ typeof model.thinking === "string" && model.thinking.trim() !== "" ? model.thinking : undefined;
184
+ const lines: Array<string> = ["# Generated per run by docks-kit omp. Edits are overwritten."];
145
185
  if (thinking !== undefined) {
146
- lines.push(`defaultThinkingLevel: ${thinking}`, "modelRoles:")
186
+ lines.push(`defaultThinkingLevel: ${thinking}`, "modelRoles:");
147
187
  const advisor =
148
188
  typeof model.advisorThinking === "string" && model.advisorThinking.trim() !== ""
149
189
  ? model.advisorThinking
150
- : thinking
190
+ : thinking;
151
191
  for (const role of ROLE_ORDER) {
152
- lines.push(` ${role}: ${model.selector}:${role === "advisor" ? advisor : thinking}`)
192
+ lines.push(` ${role}: ${model.selector}:${role === "advisor" ? advisor : thinking}`);
153
193
  }
154
- lines.push("task:", ` maxEffort: ${thinking}`)
194
+ lines.push("task:", ` maxEffort: ${thinking}`);
155
195
  } else {
156
- lines.push("modelRoles:")
196
+ lines.push("modelRoles:");
157
197
  for (const role of ROLE_ORDER) {
158
- lines.push(` ${role}: ${model.selector}`)
198
+ lines.push(` ${role}: ${model.selector}`);
159
199
  }
160
200
  }
161
- lines.push("retry:", " fallbackChains:")
201
+ lines.push("retry:", " fallbackChains:");
162
202
  for (const chain of FALLBACK_ORDER) {
163
- lines.push(` ${chain}: []`)
203
+ lines.push(` ${chain}: []`);
164
204
  }
165
- return `${lines.join("\n")}\n`
205
+ return `${lines.join("\n")}\n`;
166
206
  }
167
207
 
168
208
  /** Build the omp argv with the overlay flag ahead of the forwarded args. */
@@ -170,5 +210,5 @@ export function buildOmpArgs(
170
210
  overlayPath: string,
171
211
  passthrough: ReadonlyArray<string>,
172
212
  ): ReadonlyArray<string> {
173
- return ["--config", overlayPath, ...passthrough]
213
+ return ["--config", overlayPath, ...passthrough];
174
214
  }
@@ -7,35 +7,35 @@
7
7
  * Resolution reads the environment and probes directory existence only, so a
8
8
  * dry run stays free of omp subcommands.
9
9
  */
10
- import { existsSync } from "node:fs"
11
- import { isAbsolute, resolve } from "node:path"
10
+ import { existsSync } from "node:fs";
11
+ import { isAbsolute, resolve } from "node:path";
12
12
 
13
- import { p } from "./exec"
13
+ import { p } from "./exec";
14
14
 
15
15
  /** Upstream CONFIG_DIR_NAME; PI_CONFIG_DIR renames this directory under home. */
16
- const DEFAULT_CONFIG_DIR_NAME = ".omp"
16
+ const DEFAULT_CONFIG_DIR_NAME = ".omp";
17
17
  /** Upstream APP_NAME, the fixed segment under an XDG category root. */
18
- const APP_NAME = "omp"
18
+ const APP_NAME = "omp";
19
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}$/
20
+ const PROFILE_NAME_RE = /^[a-z0-9][a-z0-9._-]{0,63}$/;
21
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
22
+ const WINDOWS_RESERVED_BASENAME_RE = /^(?:CON|PRN|AUX|NUL|COM[0-9]|LPT[0-9])(?:\..*)?$/i;
23
23
 
24
24
  export interface OmpPaths {
25
25
  /** undefined for the default profile, else the normalized profile name */
26
- readonly profile: string | undefined
26
+ readonly profile: string | undefined;
27
27
  /** config root for the active profile */
28
- readonly configRoot: string
28
+ readonly configRoot: string;
29
29
  /** directory holding AGENTS.md, config.yml, mcp.json - never XDG-redirected */
30
- readonly agentDir: string
30
+ readonly agentDir: string;
31
31
  /** root holding marketplaces.json and plugins/ - XDG-redirected when adopted */
32
- readonly dataRoot: string
32
+ readonly dataRoot: string;
33
33
  }
34
34
 
35
35
  export interface OmpPathInputs {
36
- readonly home: string
37
- readonly env: Record<string, string | undefined>
38
- readonly platform: NodeJS.Platform
36
+ readonly home: string;
37
+ readonly env: Record<string, string | undefined>;
38
+ readonly platform: NodeJS.Platform;
39
39
  }
40
40
 
41
41
  /**
@@ -44,36 +44,47 @@ export interface OmpPathInputs {
44
44
  * var cannot crash a bare import.
45
45
  */
46
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
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
52
  }
53
53
 
54
54
  /** Upstream applies `path.resolve` to PI_CODING_AGENT_DIR, so cwd anchors a relative value. */
55
55
  function resolveAgentOverride(value: string): string {
56
- return isAbsolute(value) ? value : resolve(process.cwd(), value)
56
+ return isAbsolute(value) ? value : resolve(process.cwd(), value);
57
57
  }
58
58
 
59
59
  export function ompPaths({ home, env, platform }: OmpPathInputs): OmpPaths {
60
60
  // OMP_PROFILE wins whenever it is defined, including when explicitly empty;
61
61
  // PI_PROFILE is only the legacy fallback.
62
- const profile = normalizeProfile(env["OMP_PROFILE"] !== undefined ? env["OMP_PROFILE"] : env["PI_PROFILE"])
62
+ const profile = normalizeProfile(
63
+ env["OMP_PROFILE"] !== undefined ? env["OMP_PROFILE"] : env["PI_PROFILE"],
64
+ );
63
65
 
64
66
  // 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)
67
+ const configDirName = env["PI_CONFIG_DIR"];
68
+ const baseRoot = p(
69
+ home,
70
+ configDirName !== undefined && configDirName !== "" ? configDirName : DEFAULT_CONFIG_DIR_NAME,
71
+ );
72
+ const configRoot = profile === undefined ? baseRoot : p(baseRoot, "profiles", profile);
68
73
 
69
74
  // 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
+ const agentOverride = env["PI_CODING_AGENT_DIR"];
76
+ const defaultAgentDir = p(configRoot, "agent");
77
+ const agentDir =
78
+ profile === undefined && agentOverride !== undefined && agentOverride !== ""
79
+ ? resolveAgentOverride(agentOverride)
80
+ : defaultAgentDir;
75
81
 
76
- return { profile, configRoot, agentDir, dataRoot: xdgDataRoot(env, platform, profile, agentDir === defaultAgentDir) ?? configRoot }
82
+ return {
83
+ profile,
84
+ configRoot,
85
+ agentDir,
86
+ dataRoot: xdgDataRoot(env, platform, profile, agentDir === defaultAgentDir) ?? configRoot,
87
+ };
77
88
  }
78
89
 
79
90
  /**
@@ -87,15 +98,15 @@ function xdgDataRoot(
87
98
  env: Record<string, string | undefined>,
88
99
  platform: NodeJS.Platform,
89
100
  profile: string | undefined,
90
- agentDirIsDefault: boolean
101
+ agentDirIsDefault: boolean,
91
102
  ): string | undefined {
92
- if (!agentDirIsDefault) return undefined
93
- if (platform !== "linux" && platform !== "darwin") return undefined
103
+ if (!agentDirIsDefault) return undefined;
104
+ if (platform !== "linux" && platform !== "darwin") return undefined;
94
105
 
95
- const dataHome = env["XDG_DATA_HOME"]
96
- if (dataHome === undefined || dataHome === "") return undefined
106
+ const dataHome = env["XDG_DATA_HOME"];
107
+ if (dataHome === undefined || dataHome === "") return undefined;
97
108
 
98
- const appRoot = p(dataHome, APP_NAME)
99
- const candidate = profile === undefined ? appRoot : p(appRoot, "profiles", profile)
100
- return existsSync(candidate) ? candidate : undefined
109
+ const appRoot = p(dataHome, APP_NAME);
110
+ const candidate = profile === undefined ? appRoot : p(appRoot, "profiles", profile);
111
+ return existsSync(candidate) ? candidate : undefined;
101
112
  }
@@ -0,0 +1,170 @@
1
+ /**
2
+ * EngineNative omp plugin pass: marketplace-plugin install/upgrade plus the
3
+ * pinned pi-intercom npm plugin. Split from ompSync.ts; the orchestration
4
+ * still lives there.
5
+ */
6
+ import { spawnProcess } from "./exec";
7
+ import { recordFailure } from "./failures";
8
+ import type { Ctx } from "./index";
9
+ import { isObject, parseJson } from "./jq";
10
+ import { firstOutputLine } from "./ompMarketplace";
11
+ import { field } from "./toolchain";
12
+
13
+ const MARKETPLACE_PLUGIN_IDS = ["docks@docks", "plan-lifecycle@docks"] as const;
14
+
15
+ // -------------------------------------------------------------- plugins ----
16
+
17
+ interface InstalledPlugins {
18
+ readonly marketplace: Set<string>;
19
+ readonly npm: Map<string, string>;
20
+ }
21
+
22
+ async function installedPluginIdsFromCli(): Promise<InstalledPlugins | undefined> {
23
+ const result = await spawnProcess("omp", ["plugin", "list", "--json"], {
24
+ stdio: ["ignore", "pipe", "ignore"],
25
+ });
26
+ if (result.error !== undefined || result.exitCode !== 0) return undefined;
27
+
28
+ const value = parseJson(result.stdout);
29
+ if (
30
+ value === undefined ||
31
+ !isObject(value) ||
32
+ !Array.isArray(value["marketplace"]) ||
33
+ !Array.isArray(value["npm"])
34
+ ) {
35
+ return undefined;
36
+ }
37
+
38
+ // `omp plugin list --json` reports marketplace rows as
39
+ // `{ id: "<plugin>@<marketplace>", scope, entries: [...] }` - the composite id
40
+ // is already the token `omp plugin install/upgrade` takes. omp emits one row
41
+ // per scope holding the plugin, so a match found only in a `project` row
42
+ // means the user scope is empty and `upgrade --scope user` would fail; only a
43
+ // `user` row counts as installed for this pipeline.
44
+ const marketplace = new Set<string>();
45
+ for (const row of value["marketplace"]) {
46
+ if (!isObject(row) || typeof row["id"] !== "string" || row["scope"] !== "user") continue;
47
+ marketplace.add(row["id"]);
48
+ }
49
+
50
+ const npm = new Map<string, string>();
51
+ for (const row of value["npm"]) {
52
+ if (!isObject(row) || typeof row["name"] !== "string" || typeof row["version"] !== "string")
53
+ continue;
54
+ npm.set(row["name"], row["version"]);
55
+ }
56
+ return { marketplace, npm };
57
+ }
58
+
59
+ async function runPluginCommand(
60
+ ctx: Ctx,
61
+ plugin: string,
62
+ args: ReadonlyArray<string>,
63
+ ): Promise<boolean> {
64
+ const { clearProgress, progress } = ctx.services.logger;
65
+ progress(`Updating omp plugin ${plugin}...`);
66
+ const result = await spawnProcess("omp", args, { stdio: ["ignore", "pipe", "pipe"] });
67
+ clearProgress();
68
+ if (result.error === undefined && result.exitCode === 0) return true;
69
+
70
+ recordFailure(
71
+ ctx,
72
+ `omp plugin operation failed for ${plugin}: ${firstOutputLine(result)}; run manually: omp ${args.join(" ")}`,
73
+ );
74
+ return false;
75
+ }
76
+
77
+ export async function syncPlugins(ctx: Ctx): Promise<number> {
78
+ const { change, clearProgress, echo, progress, verbose, warn } = ctx.services.logger;
79
+
80
+ if (ctx.dryRun) {
81
+ const piIntercomPin = field("pi-intercom", "verified");
82
+ for (const pluginId of MARKETPLACE_PLUGIN_IDS) {
83
+ echo(`[dry-run] omp plugin install --scope user ${pluginId}`);
84
+ }
85
+ if (piIntercomPin === "") {
86
+ warn(
87
+ "pi-intercom install skipped because SoT/toolchain.json has no verified pi-intercom pin",
88
+ );
89
+ } else {
90
+ echo(`[dry-run] omp install pi-intercom@${piIntercomPin}`);
91
+ }
92
+ return 0;
93
+ }
94
+
95
+ if (ctx.services.deps.probe("omp").state === "missing") {
96
+ ctx.services.deps.warnMissing(
97
+ "omp",
98
+ ctx.services.logger,
99
+ "deployed omp config only — marketplace and plugin passes skipped; re-run sync after installing",
100
+ );
101
+ return 0;
102
+ }
103
+ if (ctx.services.deps.probe("git").state === "missing") {
104
+ ctx.services.deps.warnMissing(
105
+ "git",
106
+ ctx.services.logger,
107
+ "plugin marketplaces are git repos — omp plugin refresh skipped; re-run sync after installing",
108
+ );
109
+ return 0;
110
+ }
111
+ const piIntercomPin = field("pi-intercom", "verified");
112
+
113
+ progress("Checking installed omp plugins...");
114
+ const installed = await installedPluginIdsFromCli();
115
+ clearProgress();
116
+ if (installed === undefined) {
117
+ warn("omp plugin inventory unavailable — falling back to the full refresh path");
118
+ }
119
+
120
+ let pluginsInstalled =
121
+ installed === undefined
122
+ ? 0
123
+ : MARKETPLACE_PLUGIN_IDS.filter((pluginId) => installed.marketplace.has(pluginId)).length +
124
+ (piIntercomPin !== "" && installed.npm.has("pi-intercom") ? 1 : 0);
125
+ let operationsSucceeded = 0;
126
+
127
+ for (const pluginId of MARKETPLACE_PLUGIN_IDS) {
128
+ const present = installed?.marketplace.has(pluginId) === true;
129
+ if (present && ctx.skipPluginRefresh === true) {
130
+ verbose(`omp plugin ${pluginId} already installed; refresh-only update skipped`);
131
+ continue;
132
+ }
133
+
134
+ const args = present
135
+ ? ["plugin", "upgrade", "--scope", "user", pluginId]
136
+ : ["plugin", "install", "--scope", "user", pluginId];
137
+ if (await runPluginCommand(ctx, pluginId, args)) {
138
+ operationsSucceeded++;
139
+ if (!present) pluginsInstalled++;
140
+ }
141
+ }
142
+
143
+ if (piIntercomPin === "") {
144
+ warn("pi-intercom install skipped because SoT/toolchain.json has no verified pi-intercom pin");
145
+ } else {
146
+ const installedVersion = installed?.npm.get("pi-intercom");
147
+ const present = installedVersion !== undefined;
148
+ if (installedVersion === piIntercomPin) {
149
+ verbose(`omp npm plugin pi-intercom already installed at ${piIntercomPin}`);
150
+ } else if (present && ctx.skipPluginRefresh === true) {
151
+ verbose("omp npm plugin pi-intercom already installed; refresh-only update skipped");
152
+ } else {
153
+ const args = present
154
+ ? ["install", "--force", `pi-intercom@${piIntercomPin}`]
155
+ : ["install", `pi-intercom@${piIntercomPin}`];
156
+ if (await runPluginCommand(ctx, "pi-intercom", args)) {
157
+ operationsSucceeded++;
158
+ if (!present) pluginsInstalled++;
159
+ }
160
+ }
161
+ }
162
+
163
+ if (operationsSucceeded > 0) {
164
+ change(`omp plugins synced (plugins: ~${operationsSucceeded})`);
165
+ ctx.nextStepTriggers.ompRestart = true;
166
+ } else {
167
+ verbose("omp plugins already in sync");
168
+ }
169
+ return pluginsInstalled;
170
+ }