@brainervirus/workit-core 2.0.2 → 2.1.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brainervirus/workit-core",
3
- "version": "2.0.2",
3
+ "version": "2.1.0",
4
4
  "private": false,
5
5
  "description": "Workit shared core — task, policy, evidence, review, decision, worker, and writer state for agentic coding workflows",
6
6
  "keywords": [
@@ -1,20 +1,24 @@
1
- // Host presence detection for the CLI setup wizard: which tools are installed
2
- // (CLI on PATH or home config marker — never a subprocess probe) and which of
3
- // the wizard-managed hosts already carry a workit registration. The configured
4
- // signal reuses planUninstall's installed bit so detection and uninstall can
5
- // never disagree; codex/pi are presence-only (their setup runs via cutover).
6
- import { existsSync, readdirSync } from "node:fs";
1
+ // Host presence detection uses actual CLI executables or supported desktop app
2
+ // locations; configuration directories alone never count as installation.
3
+ // Existing registrations reuse uninstall planning so the two paths agree.
4
+ import { readdirSync } from "node:fs";
7
5
  import os from "node:os";
8
6
  import path from "node:path";
9
7
  import { commandOnPath } from "./doctor";
10
8
  import { planUninstall, type UninstallPaths } from "./uninstall";
11
-
12
- export type HostId = "opencode" | "cursor" | "codex" | "pi";
9
+ import {
10
+ findHostExecutable,
11
+ installedHostApp,
12
+ isCodexWorkitInstalled,
13
+ isPiWorkitInstalled,
14
+ type HostId,
15
+ } from "./host-install";
16
+ export type { HostId } from "./host-install";
13
17
 
14
18
  export type HostDetection = {
15
- /** CLI on PATH or a home config marker exists. */
19
+ /** Host executable or supported desktop application is present. */
16
20
  detected: boolean;
17
- /** A workit registration is present (opencode/cursor only; always false). */
21
+ /** A Workit registration is present for this host. */
18
22
  configured: boolean;
19
23
  };
20
24
 
@@ -22,15 +26,8 @@ export type DetectHostsOptions = UninstallPaths;
22
26
 
23
27
  const HOSTS: HostId[] = ["opencode", "cursor", "codex", "pi"];
24
28
 
25
- const HOME_MARKERS: Record<HostId, string[]> = {
26
- opencode: [path.join(".config", "opencode")],
27
- cursor: [".cursor"],
28
- codex: [".codex"],
29
- pi: [".pi"],
30
- };
31
-
32
- /** Hosts the wizard Apply path registers (codex/pi set up via cutover). */
33
- export const WIZARD_HOSTS: HostId[] = ["opencode", "cursor"];
29
+ /** Hosts the setup wizard configures through their native install paths. */
30
+ export const WIZARD_HOSTS: HostId[] = [...HOSTS];
34
31
 
35
32
  /** Detected hosts the wizard can preselect (presence ∩ wizard-managed). */
36
33
  export function preselectedPlatforms(detection: Record<HostId, HostDetection>): string[] {
@@ -51,15 +48,20 @@ export function detectHosts(options: DetectHostsOptions = {}): Record<HostId, Ho
51
48
  // Same chain as uninstall path resolution: explicit home > env.HOME > homedir.
52
49
  const home = options.home ?? env.HOME ?? os.homedir();
53
50
  // Pure reader: classifies the installed state, never writes.
54
- const plan = planUninstall(options);
51
+ const plan = planUninstall({ ...options, home, env });
55
52
  const configuredByHost = new Map(plan.hosts.map((h) => [h.host, h.installed]));
56
53
  const found = emptyDetection();
57
54
  for (const host of HOSTS) {
58
55
  const detected =
59
- cliFound(host, env, home) || HOME_MARKERS[host].some((m) => existsSync(path.join(home, m)));
56
+ findHostExecutable(host, { home, env }) !== null || installedHostApp(host, home, env);
60
57
  found[host] = {
61
58
  detected,
62
- configured: WIZARD_HOSTS.includes(host) && (configuredByHost.get(host) ?? false),
59
+ configured:
60
+ host === "codex"
61
+ ? isCodexWorkitInstalled(home, env) || (configuredByHost.get(host) ?? false)
62
+ : host === "pi"
63
+ ? isPiWorkitInstalled(home, env) || (configuredByHost.get(host) ?? false)
64
+ : (configuredByHost.get(host) ?? false),
63
65
  };
64
66
  }
65
67
  return found;
@@ -217,11 +217,18 @@ const parsesAsConfigObject = (p: string): boolean => {
217
217
  }
218
218
  };
219
219
 
220
- // OpenCode accepts `plugin` as a string or an array; normalize before reading.
220
+ // V2 uses plugins; V1 accepts plugin as a string or array. Inspect both.
221
221
  const pluginEntries = (cfg: Record<string, any> | null): string[] => {
222
- const plugin = cfg?.plugin;
223
- const list = Array.isArray(plugin) ? plugin : typeof plugin === "string" ? [plugin] : [];
224
- return list.map(String).filter(isWorkitPlugin);
222
+ const entries = [cfg?.plugins, cfg?.plugin].flatMap((value) =>
223
+ Array.isArray(value) ? value : typeof value === "string" ? [value] : [],
224
+ );
225
+ return [
226
+ ...new Set(
227
+ entries.filter(
228
+ (entry): entry is string => typeof entry === "string" && isWorkitPlugin(entry),
229
+ ),
230
+ ),
231
+ ];
225
232
  };
226
233
 
227
234
  export const commandOnPath = (name: string, env: NodeJS.ProcessEnv): boolean => {
@@ -0,0 +1,458 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { accessSync, constants, existsSync, readFileSync, readdirSync, statSync } from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+
6
+ export type HostId = "opencode" | "cursor" | "codex" | "pi";
7
+
8
+ /** A reviewed argv vector. Arguments are never composed into a shell string. */
9
+ export type HostInstallCommand = {
10
+ command: string;
11
+ args: string[];
12
+ cwd?: string;
13
+ purpose: string;
14
+ /** Optional managed package tree to stage, for Cursor's local plugin copy. */
15
+ packageRoot?: string;
16
+ packageTarget?: string;
17
+ skipWhenOutputIncludes?: string;
18
+ };
19
+
20
+ export type HostCommandResult = {
21
+ exitCode: number;
22
+ stdout: string;
23
+ stderr: string;
24
+ };
25
+
26
+ export type HostCommandRunner = (step: HostInstallCommand) => HostCommandResult;
27
+ export type HostCommandContext = { home?: string; env?: NodeJS.ProcessEnv };
28
+
29
+ export function hostCommand(
30
+ name: string,
31
+ args: string[],
32
+ options: HostInstallOptions = {},
33
+ ): HostInstallCommand {
34
+ const executable = findHostExecutable(name, options);
35
+ if (!executable)
36
+ throw new Error(`${name} executable was not found on PATH or supported user bin directories`);
37
+ if (process.platform === "win32" && /\.(?:cmd|bat)$/i.test(executable)) {
38
+ const node = findHostExecutable("node", options);
39
+ if (!node) throw new Error(`Node.js is required to run the ${name} Windows shim`);
40
+ const script = resolveWindowsShimScript(executable);
41
+ if (!script)
42
+ throw new Error(`could not resolve the ${name} Windows shim to a JavaScript entrypoint`);
43
+ return { command: node, args: [script, ...args], cwd: options.cwd, purpose: `${name} CLI` };
44
+ }
45
+ return { command: executable, args, cwd: options.cwd, purpose: `${name} CLI` };
46
+ }
47
+
48
+ export function resolveWindowsShimEntry(
49
+ contents: string,
50
+ shim: string,
51
+ exists: (candidate: string) => boolean = existsSync,
52
+ ): string | null {
53
+ const root = path.win32.dirname(shim);
54
+ const matches = contents.matchAll(/(["']?)([^\r\n"']*node_modules[\\/][^\r\n"']+?\.js)\1/gi);
55
+ for (const match of matches) {
56
+ const raw = match[2].replace(/%~?dp0%?/gi, root);
57
+ const candidate = path.win32.isAbsolute(raw) ? raw : path.win32.resolve(root, raw);
58
+ if (exists(candidate)) return candidate;
59
+ }
60
+ return null;
61
+ }
62
+
63
+ const resolveWindowsShimScript = (shim: string): string | null => {
64
+ try {
65
+ const contents = readFileSync(shim, "utf8");
66
+ return resolveWindowsShimEntry(contents, shim);
67
+ } catch {
68
+ return null;
69
+ }
70
+ };
71
+
72
+ export type HostInstallOptions = {
73
+ home?: string;
74
+ cwd?: string;
75
+ env?: NodeJS.ProcessEnv;
76
+ packageVersion?: string;
77
+ alreadyInstalled?: boolean;
78
+ targetDir?: string;
79
+ mode?: "install" | "upgrade";
80
+ };
81
+
82
+ const MARKETPLACE = "https://github.com/BrainerVirus/workit.git";
83
+ const CODEX_MARKETPLACE_NAME = "workflow-toolkit";
84
+ const validPackageVersion = (value: string): boolean =>
85
+ value === "latest" || /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(value);
86
+
87
+ const nodePrerequisite = (options: HostInstallOptions): HostInstallCommand => {
88
+ const node = findHostExecutable("node", options);
89
+ if (!node) throw new Error("Workit host packages require Node.js 24+; install Node.js and retry");
90
+ return {
91
+ command: node,
92
+ args: [
93
+ "-e",
94
+ 'const v=Number(process.versions.node.split(".")[0]);if(v<24){console.error(`Workit host packages require Node.js 24+ (found ${process.versions.node})`);process.exit(1)}',
95
+ ],
96
+ cwd: options.cwd,
97
+ purpose: "Check the Node.js 24+ package runtime requirement",
98
+ };
99
+ };
100
+
101
+ /** Resolve host executables through PATH plus common user-level Node managers. */
102
+ export function findHostExecutable(
103
+ name: string,
104
+ options: { home?: string; env?: NodeJS.ProcessEnv } = {},
105
+ ): string | null {
106
+ const env = options.env ?? process.env;
107
+ const home = options.home ?? env.HOME ?? os.homedir();
108
+ const suffixes = process.platform === "win32" ? ["", ".exe", ".cmd", ".bat"] : [""];
109
+ const dirs = (env.PATH ?? "").split(path.delimiter).filter(Boolean);
110
+ dirs.push(path.join(home, ".local", "bin"), path.join(home, ".asdf", "shims"));
111
+ for (const [base, tail] of [
112
+ [path.join(home, ".local", "share", "fnm", "node-versions"), path.join("installation", "bin")],
113
+ [path.join(home, ".nvm", "versions", "node"), "bin"],
114
+ ] as const) {
115
+ try {
116
+ for (const entry of readdirSync(base, { withFileTypes: true })) {
117
+ if (entry.isDirectory()) dirs.push(path.join(base, entry.name, tail));
118
+ }
119
+ } catch {
120
+ // A missing version-manager directory is normal.
121
+ }
122
+ }
123
+ for (const dir of dirs) {
124
+ for (const suffix of suffixes) {
125
+ const candidate = path.join(dir, `${name}${suffix}`);
126
+ try {
127
+ if (statSync(candidate).isFile()) {
128
+ if (process.platform === "win32") return candidate;
129
+ accessSync(candidate, constants.X_OK);
130
+ return candidate;
131
+ }
132
+ } catch {
133
+ // Continue through PATH entries and platform shims.
134
+ }
135
+ }
136
+ }
137
+ return null;
138
+ }
139
+
140
+ export const isPiWorkitInstalled = (
141
+ home: string,
142
+ env: NodeJS.ProcessEnv = process.env,
143
+ ): boolean => {
144
+ const settings = path.join(
145
+ env.PI_CODING_AGENT_DIR ?? path.join(home, ".pi", "agent"),
146
+ "settings.json",
147
+ );
148
+ try {
149
+ const document = JSON.parse(readFileSync(settings, "utf8")) as { packages?: unknown[] };
150
+ return (document.packages ?? []).some((entry) => {
151
+ const source =
152
+ entry && typeof entry === "object" && !Array.isArray(entry)
153
+ ? (entry as { source?: unknown }).source
154
+ : entry;
155
+ const value = String(source).replaceAll("\\", "/").replace(/\/+$/, "");
156
+ return (
157
+ /^npm:@brainervirus\/workit-pi(?:@[^/]+)?$/.test(value) ||
158
+ value === "@brainervirus/workit-pi" ||
159
+ /(?:^|\/)packages\/workit-pi$/.test(value)
160
+ );
161
+ });
162
+ } catch {
163
+ return false;
164
+ }
165
+ };
166
+
167
+ const isCanonicalCodexPackage = (root: string): boolean => {
168
+ for (const file of [
169
+ path.join(root, ".codex-plugin", "plugin.json"),
170
+ path.join(root, "package.json"),
171
+ ]) {
172
+ try {
173
+ const manifest = JSON.parse(readFileSync(file, "utf8")) as {
174
+ name?: string;
175
+ repository?: string | { url?: string };
176
+ };
177
+ const repository =
178
+ typeof manifest.repository === "string" ? manifest.repository : manifest.repository?.url;
179
+ if (
180
+ (manifest.name === "workit" || manifest.name === "@brainervirus/workit-codex") &&
181
+ repository?.replace(/\.git$/, "").toLowerCase() === "https://github.com/brainervirus/workit"
182
+ )
183
+ return true;
184
+ } catch {
185
+ // Try the other manifest path.
186
+ }
187
+ }
188
+ return false;
189
+ };
190
+
191
+ export const isCodexWorkitInstalled = (
192
+ home: string,
193
+ env: NodeJS.ProcessEnv = process.env,
194
+ ): boolean => {
195
+ const codexHome = env.CODEX_HOME ?? path.join(home, ".codex");
196
+ const isPackageOrVersionDirectory = (root: string): boolean => {
197
+ if (isCanonicalCodexPackage(root)) return true;
198
+ try {
199
+ return readdirSync(root, { withFileTypes: true }).some(
200
+ (entry) => entry.isDirectory() && isCanonicalCodexPackage(path.join(root, entry.name)),
201
+ );
202
+ } catch {
203
+ return false;
204
+ }
205
+ };
206
+ if (isPackageOrVersionDirectory(path.join(codexHome, "plugins", "workit"))) return true;
207
+ const root = path.join(codexHome, "plugins", "cache");
208
+ try {
209
+ for (const marketplace of readdirSync(root, { withFileTypes: true })) {
210
+ if (!marketplace.isDirectory()) continue;
211
+ if (isPackageOrVersionDirectory(path.join(root, marketplace.name, "workit"))) return true;
212
+ }
213
+ } catch {
214
+ return false;
215
+ }
216
+ return false;
217
+ };
218
+
219
+ export function installedHostApp(
220
+ host: HostId,
221
+ home: string,
222
+ env: NodeJS.ProcessEnv = process.env,
223
+ platform: NodeJS.Platform = process.platform,
224
+ exists: (candidate: string) => boolean = existsSync,
225
+ ): boolean {
226
+ const candidates: string[] = [];
227
+ const pathApi = platform === "win32" ? path.win32 : path;
228
+ if (platform === "darwin") {
229
+ const app =
230
+ host === "cursor"
231
+ ? "Cursor.app"
232
+ : host === "codex"
233
+ ? "Codex.app"
234
+ : host === "opencode"
235
+ ? "OpenCode.app"
236
+ : "";
237
+ if (app) candidates.push(`/Applications/${app}`, pathApi.join(home, "Applications", app));
238
+ } else if (platform === "win32") {
239
+ const local = env.LOCALAPPDATA ?? pathApi.join(home, "AppData", "Local");
240
+ if (host === "cursor") candidates.push(pathApi.join(local, "Programs", "Cursor", "Cursor.exe"));
241
+ if (host === "codex") candidates.push(pathApi.join(local, "Programs", "Codex", "Codex.exe"));
242
+ if (host === "opencode")
243
+ candidates.push(pathApi.join(local, "Programs", "OpenCode", "OpenCode.exe"));
244
+ }
245
+ return candidates.some(exists);
246
+ }
247
+
248
+ /** Plan the host-native registration commands used by setup and upgrade. */
249
+ export function planHostInstall(
250
+ host: HostId,
251
+ options: HostInstallOptions = {},
252
+ ): HostInstallCommand[] {
253
+ const env = options.env ?? process.env;
254
+ const home = options.home ?? env.HOME ?? os.homedir();
255
+ const cwd = options.cwd ?? process.cwd();
256
+ const version = options.packageVersion ?? "latest";
257
+ if (!validPackageVersion(version))
258
+ throw new Error(`unsupported package version selector: ${version}`);
259
+ if (options.alreadyInstalled && options.mode !== "upgrade") return [];
260
+ const upgrading = options.mode === "upgrade";
261
+ if (host === "codex") {
262
+ if (!upgrading && isCodexWorkitInstalled(home, env)) return [];
263
+ const base = hostCommand("codex", [], { home, cwd, env });
264
+ return [
265
+ nodePrerequisite({ home, cwd, env }),
266
+ {
267
+ ...base,
268
+ args: [...base.args, "plugin", "marketplace", "list"],
269
+ purpose: "Check whether the Workit marketplace is already registered",
270
+ },
271
+ {
272
+ ...base,
273
+ args: [
274
+ ...base.args,
275
+ "plugin",
276
+ "marketplace",
277
+ upgrading ? "upgrade" : "add",
278
+ ...(upgrading ? [CODEX_MARKETPLACE_NAME] : [MARKETPLACE]),
279
+ ],
280
+ purpose: upgrading
281
+ ? "Refresh the Workit Codex marketplace"
282
+ : "Register the Workit Codex marketplace",
283
+ ...(upgrading ? {} : { skipWhenOutputIncludes: CODEX_MARKETPLACE_NAME }),
284
+ },
285
+ {
286
+ ...base,
287
+ args: [...base.args, "plugin", "add", `workit@${CODEX_MARKETPLACE_NAME}`],
288
+ purpose: upgrading ? "Refresh the Workit Codex plugin" : "Install the Workit Codex plugin",
289
+ },
290
+ ];
291
+ }
292
+ if (host === "pi") {
293
+ if (!upgrading && isPiWorkitInstalled(home, env)) return [];
294
+ const base = hostCommand("pi", [], { home, cwd, env });
295
+ return [
296
+ nodePrerequisite({ home, cwd, env }),
297
+ {
298
+ ...base,
299
+ args: upgrading
300
+ ? [...base.args, "update", "--extension", "npm:@brainervirus/workit-pi"]
301
+ : [...base.args, "install", `npm:@brainervirus/workit-pi@${version}`],
302
+ purpose: upgrading
303
+ ? "Update the Workit Pi package"
304
+ : "Install the Workit Pi package in personal Pi settings",
305
+ },
306
+ ];
307
+ }
308
+ if (host === "cursor") {
309
+ const node = findHostExecutable("node", { home, env });
310
+ if (!node)
311
+ throw new Error("Workit host packages require Node.js 24+; install Node.js and retry");
312
+ const npm = findHostExecutable("npm", { home, env });
313
+ const npmCli = npm ? findNpmCli(npm) : null;
314
+ if (!npmCli)
315
+ throw new Error("npm CLI could not be resolved; install Node.js 24+ and npm, then retry");
316
+ const stage = path.join(home, ".cursor", "plugins", "local", ".workit-package-bootstrap");
317
+ const target = options.targetDir ?? path.join(home, ".cursor", "plugins", "local", "workit");
318
+ return [
319
+ nodePrerequisite({ home, cwd, env }),
320
+ {
321
+ command: node,
322
+ args: [
323
+ npmCli,
324
+ "install",
325
+ "--prefix",
326
+ stage,
327
+ "--no-save",
328
+ "--package-lock=false",
329
+ `@brainervirus/workit-cursor@${version}`,
330
+ ],
331
+ cwd,
332
+ purpose: upgrading
333
+ ? "Download the current Workit Cursor adapter for the managed local plugin directory"
334
+ : "Download the Workit Cursor adapter package for the managed local plugin directory",
335
+ packageRoot: path.join(stage, "node_modules", "@brainervirus", "workit-cursor"),
336
+ packageTarget: target,
337
+ },
338
+ ];
339
+ }
340
+ if (host === "opencode") {
341
+ if (upgrading)
342
+ throw new Error(
343
+ "OpenCode CLI cannot target the Workit server plugin for a scoped update; upgrade is unsupported and the current registration was preserved",
344
+ );
345
+ // The normal setup path merges this package pin into OpenCode's config;
346
+ // OpenCode's native resolver installs and refreshes npm plugin packages.
347
+ return [];
348
+ }
349
+ return [];
350
+ }
351
+
352
+ /** Execute a reviewed plan serially and stop immediately on the first failure. */
353
+ export function runHostInstall(
354
+ commands: HostInstallCommand[],
355
+ runner?: HostCommandRunner,
356
+ context: HostCommandContext = {},
357
+ ): {
358
+ ok: boolean;
359
+ results: Array<{ command: HostInstallCommand; result: HostCommandResult }>;
360
+ packageRoot?: string;
361
+ } {
362
+ const results: Array<{ command: HostInstallCommand; result: HostCommandResult }> = [];
363
+ let packageRoot: string | undefined;
364
+ let outputHistory = "";
365
+ for (const command of commands) {
366
+ if (command.skipWhenOutputIncludes && outputHistory.includes(command.skipWhenOutputIncludes)) {
367
+ results.push({
368
+ command,
369
+ result: { exitCode: 0, stdout: "", stderr: "already registered; skipped" },
370
+ });
371
+ continue;
372
+ }
373
+ const result = runner ? runner(command) : runHostCommand(command, context);
374
+ results.push({ command, result });
375
+ if (result.exitCode !== 0) return { ok: false, results };
376
+ outputHistory += `\n${result.stdout}\n${result.stderr}`;
377
+ if (command.packageRoot && command.packageTarget) {
378
+ try {
379
+ if (!existsSync(command.packageRoot)) {
380
+ return {
381
+ ok: false,
382
+ results: [
383
+ {
384
+ command,
385
+ result: {
386
+ ...result,
387
+ exitCode: 1,
388
+ stderr: "npm completed without producing the expected Cursor package",
389
+ },
390
+ },
391
+ ],
392
+ };
393
+ } else {
394
+ packageRoot = command.packageRoot;
395
+ }
396
+ } catch (error) {
397
+ return {
398
+ ok: false,
399
+ results: [
400
+ {
401
+ command,
402
+ result: {
403
+ ...result,
404
+ exitCode: 1,
405
+ stderr: error instanceof Error ? error.message : String(error),
406
+ },
407
+ },
408
+ ],
409
+ };
410
+ }
411
+ }
412
+ }
413
+ return { ok: true, results, packageRoot };
414
+ }
415
+
416
+ /** Synchronous, bounded command runner for the synchronous setup Apply API. */
417
+ export function runHostCommand(
418
+ step: HostInstallCommand,
419
+ context: HostCommandContext = {},
420
+ ): HostCommandResult {
421
+ if (process.platform === "win32" && /\.(?:cmd|bat)$/i.test(step.command)) {
422
+ return {
423
+ exitCode: 1,
424
+ stdout: "",
425
+ stderr: `Cannot safely execute batch shim ${step.command}; resolve its Node.js CLI entrypoint and retry`,
426
+ };
427
+ }
428
+ const result = spawnSync(step.command, step.args, {
429
+ cwd: step.cwd,
430
+ env: (() => {
431
+ const home = context.home ?? context.env?.HOME ?? os.homedir();
432
+ const env: NodeJS.ProcessEnv = { ...process.env, ...context.env, HOME: home };
433
+ env.CODEX_HOME = context.env?.CODEX_HOME ?? path.join(home, ".codex");
434
+ env.PI_CODING_AGENT_DIR = context.env?.PI_CODING_AGENT_DIR ?? path.join(home, ".pi", "agent");
435
+ env.XDG_CONFIG_HOME = context.env?.XDG_CONFIG_HOME ?? path.join(home, ".config");
436
+ return env;
437
+ })(),
438
+ encoding: "utf8",
439
+ timeout: 60_000,
440
+ windowsHide: true,
441
+ shell: false,
442
+ });
443
+ return {
444
+ exitCode: result.status ?? (result.error ? 1 : 1),
445
+ stdout: result.stdout ?? "",
446
+ stderr: result.stderr ?? result.error?.message ?? "",
447
+ };
448
+ }
449
+
450
+ const findNpmCli = (npm: string): string | null => {
451
+ const dir = path.dirname(npm);
452
+ const candidates = [
453
+ path.join(dir, "node_modules", "npm", "bin", "npm-cli.js"),
454
+ path.join(dir, "..", "node_modules", "npm", "bin", "npm-cli.js"),
455
+ path.join(dir, "..", "lib", "node_modules", "npm", "bin", "npm-cli.js"),
456
+ ];
457
+ return candidates.find((candidate) => existsSync(candidate)) ?? null;
458
+ };
@@ -31,10 +31,9 @@ export function isWorkitPlugin(value: unknown): boolean {
31
31
  const s = String(value).replaceAll("\\", "/");
32
32
  const url = s.startsWith("file://") || s.startsWith("git+file://");
33
33
  const pkgPath =
34
- s.includes("/packages/workit-opencode/") ||
35
- s.includes("/packages/workit-cursor/") ||
36
- s.includes("/node_modules/@brainervirus/workit-opencode/") ||
37
- s.includes("/node_modules/@brainervirus/workit-cursor/");
34
+ /\/(?:packages\/workit-(?:opencode|cursor)|node_modules\/@brainervirus\/workit-(?:opencode|cursor))(?:\/|$)/.test(
35
+ s,
36
+ );
38
37
  return (
39
38
  named(s, "workflow-toolkit") ||
40
39
  named(s, "workflow-toolkit-opencode") ||
package/src/core/setup.ts CHANGED
@@ -22,6 +22,7 @@ import {
22
22
  readConfigFromDir,
23
23
  resolveConfigDir,
24
24
  type BranchPreset,
25
+ type ToolkitConfig,
25
26
  } from "./config";
26
27
  import { detectBranchPolicy } from "./branch-policy";
27
28
  import { readSetupState, type SetupState } from "./setup-state";
@@ -38,13 +39,26 @@ import { planHygieneFiles } from "./hygiene";
38
39
  import { packageRoot } from "./package-root";
39
40
  import {
40
41
  cursorMcpServerEntry,
42
+ CURSOR_RUNTIME_PACKAGE,
43
+ isWorkitPlugin,
41
44
  mergeCursorMcp,
45
+ mergeOpenCodePlugins,
42
46
  mergeCursorSettings,
43
47
  mergeOpenCodeConfig,
44
48
  OPENCODE_NPM_PIN,
45
49
  } from "./registration";
46
50
  import { runDoctor, type DoctorReport } from "./doctor";
47
51
  import { writeFileExclusive } from "./safe-write";
52
+ import {
53
+ isCodexWorkitInstalled,
54
+ isPiWorkitInstalled,
55
+ planHostInstall,
56
+ runHostInstall,
57
+ type HostCommandRunner,
58
+ type HostId,
59
+ type HostInstallCommand,
60
+ runHostCommand,
61
+ } from "./host-install";
48
62
 
49
63
  // Setup preview + apply (WZ-04-WZ-09, WZ-13-WZ-15; CA-12, CA-14, CA-22, CA-23).
50
64
  // buildSetupPreview is a pure reader: it classifies the current setup state and
@@ -82,6 +96,8 @@ export type SetupPreviewInput = {
82
96
  * configured tokenFile, the replacement is planned as its own distinct
83
97
  * reviewed mutation (AR-10). */
84
98
  tokenPaths?: { youtrack?: string; gitlab?: string; github?: string };
99
+ /** Optional global commit policy used as the base for repository overrides. */
100
+ commitPolicy?: ToolkitConfig["commitPolicy"];
85
101
  };
86
102
 
87
103
  export type SetupMutation =
@@ -98,6 +114,7 @@ export type SetupMutation =
98
114
  // during preview so the reviewed mutation set IS the Apply write set.
99
115
  | { type: "register-platform"; platform: Platform; path: string }
100
116
  | { type: "install-adapter"; platform: "cursor"; path: string }
117
+ | { type: "install-host"; platform: HostId; path: string; commands: HostInstallCommand[] }
101
118
  // AR-10: changing a configured tokenFile path is never hidden inside a
102
119
  // generic merge — it is its own reviewed mutation.
103
120
  | { type: "set-token-path"; path: string; key: string; value: string };
@@ -249,6 +266,7 @@ export function buildSetupPreview(
249
266
  locale: values.locale,
250
267
  localeOptions: current.localeOptions,
251
268
  timezone: values.timezone,
269
+ ...(values.commitPolicy === undefined ? {} : { commitPolicy: values.commitPolicy }),
252
270
  branchPolicy: mergePreset(
253
271
  values.branchPreset,
254
272
  {
@@ -344,9 +362,56 @@ export function buildSetupPreview(
344
362
  if (platform === "opencode") {
345
363
  mutations.push({ type: "register-platform", platform, path: paths.opencodeConfig });
346
364
  } else if (platform === "cursor") {
365
+ const cursorRoot = adapterRoot("cursor", {
366
+ ...paths,
367
+ dev: opts.dev ?? env.WORKFLOW_TOOLKIT_DEV ?? null,
368
+ });
369
+ if (cursorRoot === null) {
370
+ const commands = planHostInstall("cursor", {
371
+ home: paths.home,
372
+ cwd: paths.cwd,
373
+ env,
374
+ packageVersion: "latest",
375
+ targetDir: paths.cursorPluginDir,
376
+ });
377
+ mutations.push({
378
+ type: "install-host",
379
+ platform: "cursor",
380
+ path: path.join(
381
+ paths.home,
382
+ ".cursor",
383
+ "plugins",
384
+ "local",
385
+ ".workit-package-bootstrap",
386
+ ),
387
+ commands,
388
+ });
389
+ }
347
390
  mutations.push({ type: "install-adapter", platform, path: paths.cursorPluginDir });
348
391
  mutations.push({ type: "register-platform", platform, path: paths.cursorSettings });
349
392
  mutations.push({ type: "register-platform", platform, path: paths.cursorMcp });
393
+ } else if (platform === "codex" || platform === "pi") {
394
+ const commands = planHostInstall(platform, { home: paths.home, cwd: paths.cwd, env });
395
+ if (commands.length > 0) {
396
+ mutations.push({
397
+ type: "install-host",
398
+ platform,
399
+ path:
400
+ platform === "codex"
401
+ ? path.join(
402
+ env.CODEX_HOME ?? path.join(paths.home, ".codex"),
403
+ "plugins",
404
+ "cache",
405
+ "workflow-toolkit",
406
+ "workit",
407
+ )
408
+ : path.join(
409
+ env.PI_CODING_AGENT_DIR ?? path.join(paths.home, ".pi", "agent"),
410
+ "settings.json",
411
+ ),
412
+ commands,
413
+ });
414
+ }
350
415
  }
351
416
  }
352
417
  }
@@ -391,7 +456,7 @@ export type Platform = "opencode" | "cursor";
391
456
  export type SetupResultStatus = "Installed" | "Configured" | "Skipped" | "Failed";
392
457
 
393
458
  export type SetupResultEntry = {
394
- platform: Platform | "core";
459
+ platform: HostId | "core";
395
460
  file: string;
396
461
  status: SetupResultStatus;
397
462
  detail?: string;
@@ -422,6 +487,10 @@ export type ApplySetupOptions = {
422
487
  cursorMcp?: string;
423
488
  cursorPluginDir?: string;
424
489
  stateDir?: string;
490
+ /** Injectable synchronous host runner; defaults to a bounded no-shell spawn. */
491
+ runHostCommand?: HostCommandRunner;
492
+ /** Prefer staged npm adapter assets, used by the explicit upgrade flow. */
493
+ preferInstalledAdapter?: boolean;
425
494
  };
426
495
 
427
496
  type ResolvedApply = {
@@ -528,7 +597,7 @@ const readFileSafe = (p: string): string | null => {
528
597
  // adapter resolution (AR-09).
529
598
  type CoreMutation = Exclude<
530
599
  SetupMutation,
531
- { type: "register-platform" } | { type: "install-adapter" }
600
+ { type: "register-platform" } | { type: "install-adapter" } | { type: "install-host" }
532
601
  >;
533
602
 
534
603
  const workspaceEntriesAlreadyMatch = (
@@ -735,8 +804,23 @@ const isAdapter = (root: string, platform: Platform): boolean => {
735
804
  }
736
805
  };
737
806
 
738
- function adapterRoot(platform: Platform, res: ResolvedApply): string | null {
807
+ function adapterRoot(
808
+ platform: Platform,
809
+ res: ResolvedApply,
810
+ preferInstalledAdapter = false,
811
+ ): string | null {
739
812
  const candidates: string[] = [];
813
+ const stagedCursor = path.join(
814
+ res.home,
815
+ ".cursor",
816
+ "plugins",
817
+ "local",
818
+ ".workit-package-bootstrap",
819
+ "node_modules",
820
+ "@brainervirus",
821
+ "workit-cursor",
822
+ );
823
+ if (platform === "cursor" && preferInstalledAdapter) candidates.push(stagedCursor);
740
824
  if (res.dev) {
741
825
  // A dev checkout is the complete source of truth for adapter packages: when
742
826
  // WORKFLOW_TOOLKIT_DEV is set, a package missing from it is a real failure,
@@ -757,6 +841,7 @@ function adapterRoot(platform: Platform, res: ResolvedApply): string | null {
757
841
  candidates.push(
758
842
  path.join(res.home, ".local", "share", "workit", "packages", `workit-${platform}`),
759
843
  );
844
+ if (platform === "cursor") candidates.push(stagedCursor);
760
845
  for (const candidate of candidates) {
761
846
  if (isAdapter(candidate, platform)) return candidate;
762
847
  }
@@ -787,8 +872,8 @@ const opencodePin = (root: string, res: ResolvedApply): string | null =>
787
872
  resolveOpenCodePin(root, { dev: res.dev });
788
873
 
789
874
  function applyOpenCode(root: string, res: ResolvedApply): SetupResultEntry {
790
- const pin = opencodePin(root, res);
791
- if (!pin) {
875
+ const resolvedPin = opencodePin(root, res);
876
+ if (!resolvedPin) {
792
877
  return {
793
878
  platform: "opencode",
794
879
  file: res.opencodeConfig,
@@ -807,7 +892,33 @@ function applyOpenCode(root: string, res: ResolvedApply): SetupResultEntry {
807
892
  detail: `cannot merge into malformed config: ${existing.error} — repair or remove the file`,
808
893
  };
809
894
  }
810
- const merged = mergeOpenCodeConfig(existing.kind === "record" ? existing.value : {}, pin);
895
+ const configuredPlugins =
896
+ existing.kind === "record"
897
+ ? Array.isArray(existing.value.plugins)
898
+ ? existing.value.plugins
899
+ : typeof existing.value.plugins === "string"
900
+ ? [existing.value.plugins]
901
+ : typeof existing.value.plugin === "string"
902
+ ? [existing.value.plugin]
903
+ : Array.isArray(existing.value.plugin)
904
+ ? existing.value.plugin
905
+ : []
906
+ : [];
907
+ const configuredPin = configuredPlugins.find(
908
+ (entry): entry is string => typeof entry === "string" && isWorkitPlugin(entry),
909
+ );
910
+ const pin = configuredPin ?? resolvedPin;
911
+ const base = existing.kind === "record" ? existing.value : {};
912
+ const merged =
913
+ existing.kind === "record" && Object.hasOwn(existing.value, "plugins")
914
+ ? (() => {
915
+ const plugins = mergeOpenCodePlugins(existing.value.plugins, pin);
916
+ return {
917
+ config: { ...existing.value, plugins: plugins.config },
918
+ changed: plugins.changed.length > 0 ? ["plugins"] : [],
919
+ };
920
+ })()
921
+ : mergeOpenCodeConfig(base, pin);
811
922
  if (merged.changed.length === 0) {
812
923
  return {
813
924
  platform: "opencode",
@@ -996,11 +1107,21 @@ function applyCursorMcp(root: string, res: ResolvedApply): SetupResultEntry {
996
1107
  detail: `cannot merge into malformed config: ${mcpExisting.error} — repair or remove the file`,
997
1108
  };
998
1109
  }
999
- const mcp = mergeCursorMcp(
1000
- mcpExisting.kind === "record" ? mcpExisting.value : {},
1001
- "workit",
1002
- cursorMcpServerEntry(res.cursorPluginDir),
1110
+ const mcpBase = mcpExisting.kind === "record" ? mcpExisting.value : {};
1111
+ const server = cursorMcpServerEntry(res.cursorPluginDir);
1112
+ const servers = isRecord(mcpBase.mcpServers) ? mcpBase.mcpServers : {};
1113
+ const current = isRecord(servers.workit) ? servers.workit : {};
1114
+ const previousArgs = Array.isArray(current.args) ? current.args.map(String) : [];
1115
+ const previousPackage = previousArgs.find(
1116
+ (arg) =>
1117
+ arg.startsWith("--package=@brainervirus/workit-cursor@") &&
1118
+ arg !== `--package=${CURSOR_RUNTIME_PACKAGE}`,
1003
1119
  );
1120
+ if (previousPackage) {
1121
+ const index = server.args.findIndex((arg) => arg.startsWith("--package="));
1122
+ if (index >= 0) server.args[index] = previousPackage;
1123
+ }
1124
+ const mcp = mergeCursorMcp(mcpBase, "workit", server);
1004
1125
  if (mcp.changed.length === 0) {
1005
1126
  return {
1006
1127
  platform: "cursor",
@@ -1117,6 +1238,77 @@ export function applySetupPreview(
1117
1238
  // legacy identity or its registration.
1118
1239
  let cursorCopyOk = false;
1119
1240
  for (const mutation of preview.mutations) {
1241
+ if (mutation.type === "install-host") {
1242
+ const expectedPath =
1243
+ mutation.platform === "cursor"
1244
+ ? path.join(res.home, ".cursor", "plugins", "local", ".workit-package-bootstrap")
1245
+ : mutation.platform === "codex"
1246
+ ? path.join(
1247
+ res.env.CODEX_HOME ?? path.join(res.home, ".codex"),
1248
+ "plugins",
1249
+ "cache",
1250
+ "workflow-toolkit",
1251
+ "workit",
1252
+ )
1253
+ : mutation.platform === "pi"
1254
+ ? path.join(
1255
+ res.env.PI_CODING_AGENT_DIR ?? path.join(res.home, ".pi", "agent"),
1256
+ "settings.json",
1257
+ )
1258
+ : "";
1259
+ if (mutation.path !== expectedPath) {
1260
+ entries.push({
1261
+ platform: mutation.platform,
1262
+ file: mutation.path,
1263
+ status: "Failed",
1264
+ detail: `preview/apply path mismatch: apply resolved ${expectedPath}, the previewed host install was ${mutation.path} — rebuild the preview with the same options`,
1265
+ });
1266
+ continue;
1267
+ }
1268
+ const run =
1269
+ options.runHostCommand ??
1270
+ ((step: HostInstallCommand) => runHostCommand(step, { home: res.home, env: res.env }));
1271
+ const installed = runHostInstall(mutation.commands, run);
1272
+ for (const { command, result } of installed.results) {
1273
+ entries.push({
1274
+ platform: mutation.platform,
1275
+ file: command.command,
1276
+ status: result.exitCode === 0 ? "Installed" : "Failed",
1277
+ detail:
1278
+ result.exitCode === 0
1279
+ ? `${command.purpose} (${command.args.join(" ")})`
1280
+ : `${command.purpose} failed (${command.args.join(" ")}): ${result.stderr || `exit ${result.exitCode}`}`,
1281
+ });
1282
+ }
1283
+ if (!installed.ok && installed.results.length === 0) {
1284
+ entries.push({
1285
+ platform: mutation.platform,
1286
+ file: mutation.platform,
1287
+ status: "Failed",
1288
+ detail: "host installation did not run",
1289
+ });
1290
+ }
1291
+ if (!installed.ok) continue;
1292
+ if (mutation.platform === "cursor" && installed.packageRoot) {
1293
+ rootFor.set("cursor", installed.packageRoot);
1294
+ }
1295
+ const verified =
1296
+ mutation.platform === "codex"
1297
+ ? isCodexWorkitInstalled(res.home, res.env)
1298
+ : mutation.platform === "pi"
1299
+ ? isPiWorkitInstalled(res.home, res.env)
1300
+ : true;
1301
+ if (!verified) {
1302
+ entries.push({
1303
+ platform: mutation.platform,
1304
+ file: mutation.platform,
1305
+ status: "Failed",
1306
+ detail:
1307
+ "the host command succeeded, but Workit registration was not found; inspect the host's native package list and retry",
1308
+ });
1309
+ }
1310
+ continue;
1311
+ }
1120
1312
  if (mutation.type !== "register-platform" && mutation.type !== "install-adapter") {
1121
1313
  entries.push(applyMutation(mutation));
1122
1314
  continue;
@@ -1141,7 +1333,7 @@ export function applySetupPreview(
1141
1333
  }
1142
1334
  let root = rootFor.get(platform);
1143
1335
  if (root === undefined) {
1144
- root = adapterRoot(platform, res);
1336
+ root = adapterRoot(platform, res, options.preferInstalledAdapter);
1145
1337
  rootFor.set(platform, root);
1146
1338
  }
1147
1339
  if (root === null) {
@@ -191,10 +191,8 @@ export function vcsConfig(mode: "load" | "summary" | "resolve", cwd?: string): R
191
191
  (hasWorkspacePolicy ? policyDefault : (cfg.defaultTargetBranch ?? policyDefault)) ??
192
192
  "develop",
193
193
  );
194
- const linkIssues =
195
- provider === "gitlab" && typeof wsYt.link_issues === "boolean" ? wsYt.link_issues : null;
196
- const youtrackBaseUrl =
197
- provider === "gitlab" && typeof wsYt.baseUrl === "string" ? wsYt.baseUrl : null;
194
+ const linkIssues = typeof wsYt.link_issues === "boolean" ? wsYt.link_issues : null;
195
+ const youtrackBaseUrl = typeof wsYt.baseUrl === "string" ? wsYt.baseUrl : null;
198
196
  // github issues path only when BOTH providers are github (mirrors WorkspaceConfig.issues).
199
197
  let issuesProvider: string | null = null;
200
198
  let linkOnPr: boolean | null = null;
@@ -134,13 +134,6 @@ const workspaceConfigSchema = z
134
134
  })
135
135
  .passthrough()
136
136
  .superRefine((workspace, context) => {
137
- if (workspace.youtrack && workspace.vcs?.provider !== "gitlab") {
138
- context.addIssue({
139
- code: "custom",
140
- path: ["youtrack"],
141
- message: `YouTrack issue linking requires the gitlab provider, got ${workspace.vcs?.provider ?? "unset"}`,
142
- });
143
- }
144
137
  if (workspace.issues && workspace.vcs?.provider !== "github") {
145
138
  context.addIssue({
146
139
  code: "custom",
@@ -446,23 +439,21 @@ const selectWorkspaceMatch = <T extends { name: string; glob: string }>(
446
439
  return mostSpecific[0];
447
440
  };
448
441
 
449
- /** Match a cwd against the workspaces.json under an explicit config dir. */
450
- export const resolveWorkspaceFrom = (
442
+ /** Shared pure workspace matcher for draft previews and disk-backed resolution. */
443
+ export const resolveWorkspaceFromEntries = <T extends { name: string; glob: string }>(
451
444
  cwd: string,
452
- dir: string,
445
+ entries: readonly T[],
453
446
  workspaceName?: string,
454
- ): WorkspaceConfig | null => {
447
+ ): T | null => {
455
448
  // macOS/Windows tmpdir symlinks (/var -> /private/var): git's
456
449
  // --show-toplevel returns the realpath while config globs are usually
457
450
  // written with the logical path, so a workspace would silently stop
458
451
  // matching on macOS. Match both forms on each side — same class as the
459
452
  // docs-migration escape-guard realpath comparison; on Linux both forms
460
453
  // are identical so behavior is unchanged.
461
- const result = readWorkspacesResult(dir);
462
- if (result.status === "malformed" || result.status === "invalid") throw new Error(result.error);
463
454
  const targets = [cwd, realpathOf(cwd)].map((p) => p.replaceAll("\\", "/"));
464
- const matches: WorkspaceConfig[] = [];
465
- for (const ws of result.entries) {
455
+ const matches: T[] = [];
456
+ for (const ws of entries) {
466
457
  const glob = ws.glob.replaceAll("\\", "/");
467
458
  const canonical = canonicalGlob(glob);
468
459
  let matched = false;
@@ -480,6 +471,17 @@ export const resolveWorkspaceFrom = (
480
471
  return selectWorkspaceMatch(matches, cwd, workspaceName) ?? null;
481
472
  };
482
473
 
474
+ /** Match a cwd against the workspaces.json under an explicit config dir. */
475
+ export const resolveWorkspaceFrom = (
476
+ cwd: string,
477
+ dir: string,
478
+ workspaceName?: string,
479
+ ): WorkspaceConfig | null => {
480
+ const result = readWorkspacesResult(dir);
481
+ if (result.status === "malformed" || result.status === "invalid") throw new Error(result.error);
482
+ return resolveWorkspaceFromEntries(cwd, result.entries, workspaceName);
483
+ };
484
+
483
485
  export const resolveWorkspace = (
484
486
  cwd: string,
485
487
  workspaceName = process.env.WORKFLOW_WORKSPACE_NAME?.trim() || undefined,