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.
- package/AGENTS.md +103 -36
- package/README.md +21 -14
- package/cli/docs/sync-layers.md +32 -2
- package/cli/src/commands/harnesses.ts +64 -0
- package/cli/src/commands/sync.ts +2 -2
- package/cli/src/engine-native/deps.ts +7 -0
- package/cli/src/engine-native/harnesses.ts +75 -0
- package/cli/src/engine-native/index.ts +31 -4
- package/cli/src/engine-native/ompPaths.ts +101 -0
- package/cli/src/engine-native/ompSync.ts +421 -0
- package/cli/src/engine-native/ompYaml.ts +107 -0
- package/cli/src/engine-native/parseArgs.ts +36 -11
- package/cli/src/engine-native/toolchain.ts +3 -1
- package/cli/src/generated/sotPayload.ts +12 -4
- package/cli/src/main.ts +4 -1
- package/package.json +3 -2
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* vocabulary directly.
|
|
7
7
|
*/
|
|
8
8
|
import { p } from "./exec"
|
|
9
|
-
import {
|
|
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
|
|
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
|
-
|
|
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
|
+
}
|