@brainervirus/workit-core 2.4.0 → 2.6.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.
@@ -33,6 +33,11 @@ import {
33
33
  isWorkitPlugin,
34
34
  } from "./registration";
35
35
  import { readWorkspacesResult, resolveWorkspaceFrom } from "./workspaces";
36
+ import {
37
+ CLAUDE_MARKETPLACE_NAME,
38
+ claudeWorkitInstalls,
39
+ type ClaudeWorkitInstall,
40
+ } from "./host-install";
36
41
  import { validateCursorSkills, WORKIT_METHOD_SKILLS } from "./skill-manifests";
37
42
  import {
38
43
  classifyHostGeneration,
@@ -53,6 +58,7 @@ export type DoctorCheckId =
53
58
  | "runtime"
54
59
  | "versions"
55
60
  | "codex_pin"
61
+ | "claude_plugin"
56
62
  | "assets"
57
63
  | "launcher"
58
64
  | "utility"
@@ -1737,10 +1743,65 @@ const checkWorkspaceLock = (res: Resolved): DoctorCheck => {
1737
1743
  };
1738
1744
  };
1739
1745
 
1746
+ const CLAUDE_PLUGIN_PACKAGE = "@brainervirus/workit-claude-code";
1747
+
1748
+ const versionBehind = (version: string, latest: string): boolean =>
1749
+ version !== latest && semverAtLeast(latest, version);
1750
+
1751
+ /**
1752
+ * Claude Code installs the marketplace plugin as a snapshot of the published
1753
+ * package (auto-update is off by default), so an install can lag the release.
1754
+ * It warns only when a newer plugin version is actually published: a plugin
1755
+ * older than this CLI is normal when no plugin payload changed since (the
1756
+ * plugin is republished only when its own or its bundled sources change).
1757
+ * The plugin keeps working; the fix is one native update. A `--plugin-dir`
1758
+ * local pin is per-session, never recorded, and never checked here.
1759
+ */
1760
+ const checkClaudePlugin = (res: Resolved): DoctorCheck & { registryProbed?: boolean } => {
1761
+ // Only installs Claude loads here: user scope, plus this project's.
1762
+ const installs = claudeWorkitInstalls(res.home, res.env, res.cwd);
1763
+ if (installs.length === 0)
1764
+ return {
1765
+ id: "claude_plugin",
1766
+ status: "pass",
1767
+ detail: "no Workit Claude Code plugin install recorded — skipping",
1768
+ };
1769
+ const fix = (install: ClaudeWorkitInstall) =>
1770
+ `claude plugin marketplace update ${CLAUDE_MARKETPLACE_NAME} && ${
1771
+ install.projectPath ? `cd ${JSON.stringify(install.projectPath)} && ` : ""
1772
+ }claude plugin update ${install.id}${install.scope === "user" ? "" : ` --scope ${install.scope}`}`;
1773
+ const latest = registryLatestVersion(res, CLAUDE_PLUGIN_PACKAGE);
1774
+ const problems: string[] = [];
1775
+ let repair: string | undefined;
1776
+ for (const install of installs) {
1777
+ const label = `${install.id} ${install.version ?? "(unknown version)"}${install.scope === "user" ? "" : ` (${install.scope} scope)`}`;
1778
+ if (latest && install.version && versionBehind(install.version, latest)) {
1779
+ problems.push(`stale_install: ${label} is behind published ${latest}`);
1780
+ repair ??= fix(install);
1781
+ }
1782
+ }
1783
+ const registryProbed = latest !== null && !res.env.WORKIT_DOCTOR_STALE_REGISTRY_VERSION;
1784
+ if (problems.length === 0)
1785
+ return {
1786
+ id: "claude_plugin",
1787
+ status: "pass",
1788
+ detail: `Claude Code plugin ${installs.map((i) => `${i.id} ${i.version ?? "?"}`).join(", ")} is current`,
1789
+ registryProbed,
1790
+ };
1791
+ return {
1792
+ id: "claude_plugin",
1793
+ status: "warn",
1794
+ detail: problems.join("; "),
1795
+ fix: repair,
1796
+ registryProbed,
1797
+ };
1798
+ };
1799
+
1740
1800
  const RUN_CHECKS: Array<(res: Resolved) => DoctorCheck> = [
1741
1801
  checkRuntime,
1742
1802
  checkVersions,
1743
1803
  checkCodexPin,
1804
+ checkClaudePlugin,
1744
1805
  checkAssets,
1745
1806
  checkLauncher,
1746
1807
  checkUtility,
@@ -57,6 +57,7 @@ import {
57
57
  import { normalizeChainSteps } from "./authority";
58
58
  import { resolveInside, run as coreRun } from "../core";
59
59
  import { hostingApiHostMatches, vcsCliIdentity, vcsConfig } from "./vcs-config";
60
+ import { deleteRemoteBranch } from "../git/ops";
60
61
  import { assertProductWriteAllowed, currentWriterOwnsTask } from "./workers";
61
62
  import { sameDirectoryIdentity, TaskStore } from "./task-store";
62
63
 
@@ -2128,15 +2129,9 @@ export const executeConcreteExternalAction = async (
2128
2129
  outcome: "not_started",
2129
2130
  });
2130
2131
  const branch = request.payload.branch;
2131
- const result = run(root, [
2132
- "push",
2133
- `--force-with-lease=refs/heads/${branch}:${approvedTip}`,
2134
- "--delete",
2135
- target,
2136
- branch,
2137
- ]);
2138
- if (result.exitCode === 0) return success(null, null, { branch, deletedTip: approvedTip });
2139
- return /stale info|cannot lock ref|fetch first|non-fast-forward/i.test(result.stderr)
2132
+ const result = deleteRemoteBranch(root, target, branch, approvedTip);
2133
+ if (result.ok) return success(null, null, { branch, deletedTip: approvedTip });
2134
+ return result.lease
2140
2135
  ? failure("capability_unavailable", "remote branch tip changed before deletion", {
2141
2136
  outcome: "not_started",
2142
2137
  })
@@ -11,7 +11,7 @@ import {
11
11
  import os from "node:os";
12
12
  import path from "node:path";
13
13
 
14
- export type HostId = "opencode" | "cursor" | "codex" | "pi";
14
+ export type HostId = "opencode" | "cursor" | "codex" | "pi" | "claude-code";
15
15
 
16
16
  /** A reviewed argv vector. Arguments are never composed into a shell string. */
17
17
  export type HostInstallCommand = {
@@ -89,6 +89,12 @@ export type HostInstallOptions = {
89
89
 
90
90
  const MARKETPLACE = "https://github.com/BrainerVirus/workit.git";
91
91
  const CODEX_MARKETPLACE_NAME = "workflow-toolkit";
92
+ /** Claude Code reads the git-hosted marketplace at the repo root
93
+ * (`.claude-plugin/marketplace.json`, name `workit`); its entry installs the
94
+ * published npm package. */
95
+ export const CLAUDE_MARKETPLACE_REPO = "BrainerVirus/workit";
96
+ export const CLAUDE_MARKETPLACE_NAME = "workit";
97
+ export const CLAUDE_PLUGIN_ID = `workit@${CLAUDE_MARKETPLACE_NAME}`;
92
98
  const validPackageVersion = (value: string): boolean =>
93
99
  value === "latest" || /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(value);
94
100
 
@@ -224,6 +230,109 @@ export const isCodexWorkitInstalled = (
224
230
  return false;
225
231
  };
226
232
 
233
+ /** Claude Code's config root (`CLAUDE_CONFIG_DIR`, default `~/.claude`). */
234
+ export const claudeConfigDir = (home: string, env: NodeJS.ProcessEnv = process.env): string =>
235
+ env.CLAUDE_CONFIG_DIR ?? path.join(home, ".claude");
236
+
237
+ export type ClaudeInstallScope = "user" | "project" | "local";
238
+
239
+ export type ClaudeWorkitInstall = {
240
+ id: string;
241
+ version: string | null;
242
+ installPath: string;
243
+ scope: ClaudeInstallScope;
244
+ /** The project a project/local-scope install belongs to; null for user scope. */
245
+ projectPath: string | null;
246
+ };
247
+
248
+ const CLAUDE_SCOPES = new Set<string>(["user", "project", "local"]);
249
+
250
+ /** A project/local install applies to `cwd` when cwd is inside its project. */
251
+ const appliesTo = (install: ClaudeWorkitInstall, cwd: string | null): boolean => {
252
+ if (install.scope === "user") return true;
253
+ if (cwd === null || install.projectPath === null) return false;
254
+ const relative = path.relative(path.resolve(install.projectPath), path.resolve(cwd));
255
+ return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
256
+ };
257
+
258
+ const isCanonicalClaudePlugin = (root: string): boolean => {
259
+ try {
260
+ const manifest = JSON.parse(
261
+ readFileSync(path.join(root, ".claude-plugin", "plugin.json"), "utf8"),
262
+ ) as { name?: string; repository?: string | { url?: string } };
263
+ const repository =
264
+ typeof manifest.repository === "string" ? manifest.repository : manifest.repository?.url;
265
+ return (
266
+ manifest.name === "workit" &&
267
+ repository?.replace(/\.git$/, "").toLowerCase() === "https://github.com/brainervirus/workit"
268
+ );
269
+ } catch {
270
+ return false;
271
+ }
272
+ };
273
+
274
+ /**
275
+ * Workit installs recorded in Claude Code's plugin registry
276
+ * (`<config>/plugins/installed_plugins.json`, v2: `{plugins: {"name@market":
277
+ * [{installPath, version}]}}`). Only entries whose install directory carries
278
+ * the canonical Workit manifest count, whatever marketplace they came from.
279
+ * Only installs that apply here count: user scope always, project/local
280
+ * scope only when `cwd` is inside the recorded project (`cwd: null` keeps
281
+ * user scope only). A `--plugin-dir` pin is per-session and never recorded.
282
+ */
283
+ export const claudeWorkitInstalls = (
284
+ home: string,
285
+ env: NodeJS.ProcessEnv = process.env,
286
+ cwd: string | null = process.cwd(),
287
+ ): ClaudeWorkitInstall[] => {
288
+ const file = path.join(claudeConfigDir(home, env), "plugins", "installed_plugins.json");
289
+ let registry: unknown;
290
+ try {
291
+ registry = JSON.parse(readFileSync(file, "utf8"));
292
+ } catch {
293
+ return [];
294
+ }
295
+ const plugins =
296
+ registry && typeof registry === "object" && "plugins" in registry ? registry.plugins : null;
297
+ if (!plugins || typeof plugins !== "object") return [];
298
+ const found: ClaudeWorkitInstall[] = [];
299
+ for (const [id, entries] of Object.entries(plugins as Record<string, unknown>)) {
300
+ if (!id.startsWith("workit@") || !Array.isArray(entries)) continue;
301
+ for (const entry of entries as Array<{
302
+ installPath?: unknown;
303
+ version?: unknown;
304
+ scope?: unknown;
305
+ projectPath?: unknown;
306
+ }>) {
307
+ if (typeof entry?.installPath !== "string" || !isCanonicalClaudePlugin(entry.installPath))
308
+ continue;
309
+ // Older registries omit scope; Claude's default install scope is user.
310
+ const scope = entry.scope === undefined ? "user" : String(entry.scope);
311
+ if (!CLAUDE_SCOPES.has(scope)) continue; // e.g. managed: not ours to change
312
+ const install: ClaudeWorkitInstall = {
313
+ id,
314
+ installPath: entry.installPath,
315
+ version:
316
+ typeof entry.version === "string" && /^\d+\.\d+\.\d+/.test(entry.version)
317
+ ? entry.version
318
+ : null,
319
+ scope: scope as ClaudeInstallScope,
320
+ projectPath:
321
+ scope !== "user" && typeof entry.projectPath === "string" ? entry.projectPath : null,
322
+ };
323
+ if (appliesTo(install, cwd)) found.push(install);
324
+ }
325
+ }
326
+ return found;
327
+ };
328
+
329
+ /** A Workit install Claude Code loads for this user (any project). */
330
+ export const isClaudeWorkitInstalled = (
331
+ home: string,
332
+ env: NodeJS.ProcessEnv = process.env,
333
+ cwd: string | null = process.cwd(),
334
+ ): boolean => claudeWorkitInstalls(home, env, cwd).length > 0;
335
+
227
336
  export function installedHostApp(
228
337
  host: HostId,
229
338
  home: string,
@@ -297,6 +406,39 @@ export function planHostInstall(
297
406
  },
298
407
  ];
299
408
  }
409
+ if (host === "claude-code") {
410
+ if (!upgrading && isClaudeWorkitInstalled(home, env, null)) return [];
411
+ const base = hostCommand("claude", [], { home, cwd, env });
412
+ return [
413
+ nodePrerequisite({ home, cwd, env }),
414
+ {
415
+ ...base,
416
+ args: [...base.args, "plugin", "marketplace", "list", "--json"],
417
+ purpose: "Check whether the Workit Claude Code marketplace is already registered",
418
+ },
419
+ upgrading
420
+ ? {
421
+ ...base,
422
+ args: [...base.args, "plugin", "marketplace", "update", CLAUDE_MARKETPLACE_NAME],
423
+ purpose: "Refresh the Workit Claude Code marketplace",
424
+ }
425
+ : {
426
+ ...base,
427
+ args: [...base.args, "plugin", "marketplace", "add", CLAUDE_MARKETPLACE_REPO],
428
+ purpose: "Register the Workit Claude Code marketplace",
429
+ skipWhenOutputIncludes: `"name": "${CLAUDE_MARKETPLACE_NAME}"`,
430
+ },
431
+ {
432
+ ...base,
433
+ args: upgrading
434
+ ? [...base.args, "plugin", "update", CLAUDE_PLUGIN_ID]
435
+ : [...base.args, "plugin", "install", CLAUDE_PLUGIN_ID, "--scope", "user"],
436
+ purpose: upgrading
437
+ ? "Update the Workit Claude Code plugin"
438
+ : "Install the Workit Claude Code plugin",
439
+ },
440
+ ];
441
+ }
300
442
  if (host === "pi") {
301
443
  if (!upgrading && isPiWorkitInstalled(home, env)) return [];
302
444
  const base = hostCommand("pi", [], { home, cwd, env });
@@ -440,6 +582,7 @@ export function runHostCommand(
440
582
  const env: NodeJS.ProcessEnv = { ...process.env, ...context.env, HOME: home };
441
583
  env.CODEX_HOME = context.env?.CODEX_HOME ?? path.join(home, ".codex");
442
584
  env.PI_CODING_AGENT_DIR = context.env?.PI_CODING_AGENT_DIR ?? path.join(home, ".pi", "agent");
585
+ env.CLAUDE_CONFIG_DIR = context.env?.CLAUDE_CONFIG_DIR ?? path.join(home, ".claude");
443
586
  env.XDG_CONFIG_HOME = context.env?.XDG_CONFIG_HOME ?? path.join(home, ".config");
444
587
  return env;
445
588
  })(),
@@ -5,21 +5,13 @@ import { pathToFileURL } from "node:url";
5
5
  import { hostingApiHostMatches, vcsCliIdentity, vcsConfig } from "./vcs-config";
6
6
  export { hostingApiHostMatches } from "./vcs-config";
7
7
  import { resolveBranchPolicyFor } from "./branch";
8
+ import { buildBody, parseGhIssue, parseGhRepo } from "../forge/pr-body";
8
9
 
9
10
  // Port of scripts/pr-create.sh — build MR/PR body issue linking + create via glab/gh.
10
11
  // Hosted creation is enabled (decision ae03c569): every create path pre-binds
11
12
  // the approved source SHA through WORKIT_EXPECTED_* and the shared executor
12
13
  // post-verifies the provider PR head before reporting success. The residual
13
14
  // non-atomic source-SHA race is accepted.
14
- function parseGhRepo(remote: string): string | null {
15
- remote = (remote || "").trim().replace(/\/+$/, "");
16
- if (!remote) return null;
17
- if (remote.endsWith(".git")) remote = remote.slice(0, -4);
18
- if (remote.includes(":")) remote = remote.split(":").pop() ?? ""; // drop git@host part (scp-style URL)
19
- const parts = remote.split("/").filter(Boolean);
20
- return parts.length >= 2 ? parts.slice(-2).join("/") : null;
21
- }
22
-
23
15
  const remoteRepoPath = (
24
16
  remote: string,
25
17
  ): { host: string; path: string; protocol: string } | null => {
@@ -63,68 +55,6 @@ const cliRepository = (
63
55
  return host === "gitlab.com" ? parsed.path : `https://${host}/${parsed.path}`;
64
56
  };
65
57
 
66
- function parseGhIssue(value: string): string {
67
- const m = /issues\/(\d+)/.exec(value);
68
- return m ? m[1] : String(value).trim().replace(/^#/, "");
69
- }
70
-
71
- // RL-03/CA-25/AR-08: a branch-derived numeric issue id must be a bare number at
72
- // a segment or dash boundary — and never part of a date segment. Year-first
73
- // (feature/2024-01-15/x) and day-first (feature/15-01-2024/x) dates are both
74
- // skipped — a complete date anywhere in a segment (release-2024-01-15,
75
- // v2-2024-01-15-fix) — so no date digit ever closes an issue. Deliberate
76
- // numeric issue branches (feature/42-title, feature/2024-fix) keep linking.
77
- function deriveGhIssueFromBranch(branch: string): string {
78
- for (const segment of branch.split("/")) {
79
- if (/^\d{4}-\d/.test(segment)) continue; // year-first date-like segment (incl. year-month)
80
- if (/\d{4}-\d{1,2}-\d{1,2}/.test(segment)) continue; // complete year-first date anywhere
81
- if (/\d{1,2}-\d{1,2}-\d{4}/.test(segment)) continue; // complete day-first date anywhere
82
- const m = /(?:^|-)(\d+)(?:-|$)/.exec(segment);
83
- if (m) return m[1];
84
- }
85
- return "";
86
- }
87
-
88
- function buildBody(
89
- body: string,
90
- branch: string,
91
- linkIssues: boolean,
92
- baseUrl: string,
93
- ytIssue: string,
94
- ghLinkOnPr: boolean,
95
- ghIssue: string,
96
- ghRelation: string,
97
- ghRepo: string | null,
98
- ): string {
99
- let line: string | null = null;
100
- if (linkIssues) {
101
- let issue = ytIssue;
102
- if (!issue && branch) {
103
- // anchored prefix + \b boundary, 3+ digits so version-like tokens (POSTGRES-16, HTTP-3) never link
104
- const m = /(?:^|\/|-)([A-Z]{2,}-\d{3,})\b/.exec(branch);
105
- if (m) issue = m[1];
106
- }
107
- if (issue && baseUrl) line = `Related to: ${baseUrl.replace(/\/+$/, "")}/issue/${issue}`;
108
- } else if (ghLinkOnPr) {
109
- let issue = parseGhIssue(ghIssue);
110
- if (!issue && branch) {
111
- // pure-number issue id (feature/42-title -> 42); digits must be followed by a dash or end-of-string
112
- // so version tokens (release/1.2.3, backport/8.0.1, lodash-4.17.21, 2024.1) never link
113
- issue = deriveGhIssueFromBranch(branch);
114
- }
115
- if (issue) {
116
- if (ghRelation === "related") {
117
- line = `Related to #${issue}`;
118
- if (ghRepo) line += ` — https://github.com/${ghRepo}/issues/${issue}`;
119
- } else {
120
- line = `Closes #${issue}`;
121
- }
122
- }
123
- }
124
- if (line === null) return body;
125
- return body ? `${body}\n\n${line}` : line;
126
- }
127
-
128
58
  const truthy = (v: string | undefined): boolean =>
129
59
  ["1", "true", "yes"].includes(String(v ?? "").toLowerCase());
130
60
 
@@ -714,4 +644,5 @@ export function mergePr(
714
644
  };
715
645
  }
716
646
 
647
+ // Moved to forge/pr-body.ts (S11); re-exported for the managed action path until S16.
717
648
  export { buildBody, parseGhRepo, parseGhIssue };
package/src/core/setup.ts CHANGED
@@ -52,6 +52,8 @@ import { writeFileExclusive } from "./safe-write";
52
52
  import {
53
53
  isCodexWorkitInstalled,
54
54
  isPiWorkitInstalled,
55
+ isClaudeWorkitInstalled,
56
+ claudeConfigDir,
55
57
  planHostInstall,
56
58
  runHostInstall,
57
59
  type HostCommandRunner,
@@ -385,25 +387,27 @@ export function buildSetupPreview(
385
387
  mutations.push({ type: "install-adapter", platform, path: paths.cursorPluginDir });
386
388
  mutations.push({ type: "register-platform", platform, path: paths.cursorSettings });
387
389
  mutations.push({ type: "register-platform", platform, path: paths.cursorMcp });
388
- } else if (platform === "codex" || platform === "pi") {
390
+ } else if (platform === "codex" || platform === "pi" || platform === "claude-code") {
389
391
  const commands = planHostInstall(platform, { home: paths.home, cwd: paths.cwd, env });
390
392
  if (commands.length > 0) {
391
393
  mutations.push({
392
394
  type: "install-host",
393
395
  platform,
394
396
  path:
395
- platform === "codex"
396
- ? path.join(
397
- env.CODEX_HOME ?? path.join(paths.home, ".codex"),
398
- "plugins",
399
- "cache",
400
- "workflow-toolkit",
401
- "workit",
402
- )
403
- : path.join(
404
- env.PI_CODING_AGENT_DIR ?? path.join(paths.home, ".pi", "agent"),
405
- "settings.json",
406
- ),
397
+ platform === "claude-code"
398
+ ? claudeInstallRecord(paths.home, env)
399
+ : platform === "codex"
400
+ ? path.join(
401
+ env.CODEX_HOME ?? path.join(paths.home, ".codex"),
402
+ "plugins",
403
+ "cache",
404
+ "workflow-toolkit",
405
+ "workit",
406
+ )
407
+ : path.join(
408
+ env.PI_CODING_AGENT_DIR ?? path.join(paths.home, ".pi", "agent"),
409
+ "settings.json",
410
+ ),
407
411
  commands,
408
412
  });
409
413
  }
@@ -446,6 +450,10 @@ export function buildSetupPreview(
446
450
  // Apply (Task 14: WZ-09, WZ-10, WZ-13-WZ-15; CA-08, CA-13, CA-14, CA-31).
447
451
  // ---------------------------------------------------------------------------
448
452
 
453
+ /** The Claude Code plugin registry a Workit install is recorded in. */
454
+ const claudeInstallRecord = (home: string, env: NodeJS.ProcessEnv): string =>
455
+ path.join(claudeConfigDir(home, env), "plugins", "installed_plugins.json");
456
+
449
457
  export type Platform = "opencode" | "cursor";
450
458
 
451
459
  export type SetupResultStatus = "Installed" | "Configured" | "Skipped" | "Failed";
@@ -1250,7 +1258,9 @@ export function applySetupPreview(
1250
1258
  res.env.PI_CODING_AGENT_DIR ?? path.join(res.home, ".pi", "agent"),
1251
1259
  "settings.json",
1252
1260
  )
1253
- : "";
1261
+ : mutation.platform === "claude-code"
1262
+ ? claudeInstallRecord(res.home, res.env)
1263
+ : "";
1254
1264
  if (mutation.path !== expectedPath) {
1255
1265
  entries.push({
1256
1266
  platform: mutation.platform,
@@ -1292,7 +1302,9 @@ export function applySetupPreview(
1292
1302
  ? isCodexWorkitInstalled(res.home, res.env)
1293
1303
  : mutation.platform === "pi"
1294
1304
  ? isPiWorkitInstalled(res.home, res.env)
1295
- : true;
1305
+ : mutation.platform === "claude-code"
1306
+ ? isClaudeWorkitInstalled(res.home, res.env, null)
1307
+ : true;
1296
1308
  if (!verified) {
1297
1309
  entries.push({
1298
1310
  platform: mutation.platform,
@@ -16,5 +16,9 @@ export const SUPPORT_MATRIX = {
16
16
  // an installed CLI drifts ahead so a fresh install never silently outruns
17
17
  // the qualification pin.
18
18
  codex: { cli: "0.153.4", desktopPackage: "26.901.20858", bundledCodexCli: "0.153.0-alpha.5" },
19
+ // Claude Code is a qualification host too: CI runs `claude plugin validate
20
+ // --strict` with exactly this CLI, and the hook output schema fixture
21
+ // (test/fixtures/claude-code-schemas) was transcribed from this binary.
22
+ claudeCode: { cli: "2.1.288" },
19
23
  os: ["ubuntu-latest", "macos-latest", "windows-latest"],
20
24
  } as const;
@@ -10,12 +10,28 @@ import { existsSync, readFileSync, realpathSync, rmSync, writeFileSync } from "n
10
10
  import os from "node:os";
11
11
  import path from "node:path";
12
12
  import { isWorkitPlugin } from "./registration";
13
+ import {
14
+ claudeWorkitInstalls,
15
+ findHostExecutable,
16
+ runHostCommand,
17
+ type HostCommandRunner,
18
+ } from "./host-install";
13
19
 
14
- export type UninstallHost = "opencode" | "cursor" | "codex" | "pi";
20
+ export type UninstallHost = "opencode" | "cursor" | "codex" | "pi" | "claude-code";
15
21
 
16
22
  export type UninstallAction =
17
23
  | { kind: "edit-json-remove"; path: string; detail: string }
18
- | { kind: "remove-dir"; path: string; detail: string };
24
+ | { kind: "remove-dir"; path: string; detail: string }
25
+ /** A host-native uninstall (Claude Code owns its plugin cache and registry). */
26
+ | {
27
+ kind: "host-command";
28
+ path: string;
29
+ detail: string;
30
+ command: "claude";
31
+ args: string[];
32
+ /** The project a project/local-scope uninstall must run from; null for user scope. */
33
+ cwd: string | null;
34
+ };
19
35
 
20
36
  export type UninstallHostPlan = {
21
37
  host: UninstallHost;
@@ -49,6 +65,10 @@ export type UninstallPaths = {
49
65
  cursorSettings?: string;
50
66
  cursorMcp?: string;
51
67
  cursorPluginDir?: string;
68
+ /** Injectable host-command runner for host-native uninstall actions. */
69
+ runHostCommand?: HostCommandRunner;
70
+ /** Project directory for Claude Code project/local-scope installs (default: process cwd). */
71
+ cwd?: string;
52
72
  };
53
73
 
54
74
  type ResolvedUninstall = {
@@ -321,7 +341,32 @@ export function planUninstall(paths: UninstallPaths = {}): UninstallPlan {
321
341
  : [],
322
342
  };
323
343
 
324
- return { hosts: [opencode, cursor, codex, pi] };
344
+ // Claude Code: one native `claude plugin uninstall <id> --scope <scope>`
345
+ // per recorded Workit install that applies here (user scope, plus this
346
+ // project's project/local scope, run from that project). A --plugin-dir
347
+ // pin is never recorded.
348
+ const home = paths.home ?? paths.env?.HOME ?? os.homedir();
349
+ const claudeInstalls = claudeWorkitInstalls(
350
+ home,
351
+ paths.env ?? process.env,
352
+ paths.cwd ?? process.cwd(),
353
+ );
354
+ const claude: UninstallHostPlan = {
355
+ host: "claude-code",
356
+ installed: claudeInstalls.length > 0,
357
+ actions: claudeInstalls.map((install) => ({
358
+ kind: "host-command" as const,
359
+ path: install.installPath,
360
+ detail: `claude plugin uninstall ${install.id} --scope ${install.scope}${
361
+ install.projectPath ? ` (in ${install.projectPath})` : ""
362
+ }`,
363
+ command: "claude" as const,
364
+ args: ["plugin", "uninstall", install.id, "--scope", install.scope],
365
+ cwd: install.projectPath,
366
+ })),
367
+ };
368
+
369
+ return { hosts: [opencode, cursor, codex, pi, claude] };
325
370
  }
326
371
 
327
372
  // CA-14 traversal guard: rm -rf is permitted ONLY on the exact resolved
@@ -390,6 +435,60 @@ const applyEditJsonRemove = (
390
435
  return { status: "removed" };
391
436
  };
392
437
 
438
+ const CLAUDE_PLUGIN_ID = /^workit@[A-Za-z0-9._-]+$/;
439
+
440
+ /** Runs a reviewed `claude plugin uninstall workit@<marketplace>`; any other
441
+ * argv is refused, so a tampered plan cannot run an arbitrary command. */
442
+ const applyClaudeUninstall = (
443
+ action: Extract<UninstallAction, { kind: "host-command" }>,
444
+ paths: UninstallPaths,
445
+ ): { status: UninstallResultStatus; detail?: string } => {
446
+ const [verb, sub, id, flag, scope, ...rest] = action.args;
447
+ const projectScoped = scope === "project" || scope === "local";
448
+ if (
449
+ action.command !== "claude" ||
450
+ verb !== "plugin" ||
451
+ sub !== "uninstall" ||
452
+ !CLAUDE_PLUGIN_ID.test(id ?? "") ||
453
+ flag !== "--scope" ||
454
+ !(scope === "user" || projectScoped) ||
455
+ rest.length > 0 ||
456
+ // user scope runs anywhere; project/local only from their project.
457
+ (projectScoped ? typeof action.cwd !== "string" : action.cwd !== null)
458
+ )
459
+ return {
460
+ status: "failed",
461
+ detail: `refusing unreviewed host command: ${action.args.join(" ")}`,
462
+ };
463
+ const home = paths.home ?? paths.env?.HOME ?? os.homedir();
464
+ const env = paths.env ?? process.env;
465
+ const recorded = claudeWorkitInstalls(home, env, action.cwd).some(
466
+ (install) =>
467
+ install.id === id &&
468
+ install.scope === scope &&
469
+ (install.projectPath === null
470
+ ? action.cwd === null
471
+ : path.resolve(install.projectPath) === path.resolve(action.cwd ?? "")),
472
+ );
473
+ if (!recorded)
474
+ return { status: "skipped", detail: `${id} (${scope} scope) is no longer installed` };
475
+ const executable = findHostExecutable("claude", { home, env });
476
+ if (!executable && !paths.runHostCommand)
477
+ return { status: "failed", detail: "claude executable was not found on PATH" };
478
+ const step = {
479
+ command: executable ?? "claude",
480
+ args: action.args,
481
+ ...(action.cwd === null ? {} : { cwd: action.cwd }),
482
+ purpose: `Uninstall the Workit Claude Code plugin ${id} (${scope} scope)`,
483
+ };
484
+ const result = paths.runHostCommand
485
+ ? paths.runHostCommand(step)
486
+ : runHostCommand(step, { home, env });
487
+ return result.exitCode === 0
488
+ ? { status: "removed", detail: action.detail }
489
+ : { status: "failed", detail: result.stderr || `exit ${result.exitCode}` };
490
+ };
491
+
393
492
  /** Applies ONLY the reviewed plan actions with the given path options. Each
394
493
  * planned action yields exactly one result entry; malformed host JSON fails
395
494
  * its own action untouched while the remaining actions proceed (CA-13). */
@@ -400,7 +499,11 @@ export function applyUninstall(plan: UninstallPlan, paths: UninstallPaths = {}):
400
499
  for (const action of hostPlan.actions) {
401
500
  let status: UninstallResultStatus;
402
501
  let detail: string | undefined;
403
- if (action.kind === "remove-dir") {
502
+ if (action.kind === "host-command") {
503
+ const outcome = applyClaudeUninstall(action, paths);
504
+ status = outcome.status;
505
+ detail = outcome.detail;
506
+ } else if (action.kind === "remove-dir") {
404
507
  // Resolve before comparing so ".."/symlink tricks can never widen the rm.
405
508
  const resolved = path.resolve(action.path);
406
509
  const allowed =
package/src/forge/exec.ts CHANGED
@@ -179,3 +179,43 @@ export function apiText(
179
179
  if (run.status !== 0) return cliFailure(bin, apiHost, run, what, timeoutMs);
180
180
  return { ok: true, data: run.stdout };
181
181
  }
182
+
183
+ /**
184
+ * A forge write (create, merge, retarget). Like apiJson, plus: an HTTP 409 or
185
+ * a head-moved/SHA-mismatch message is `blocked` (`head_moved`), and 405/406/
186
+ * 422 refusals are `blocked` with the forge's own reason, so a refused
187
+ * mutation never reads as a transient failure.
188
+ */
189
+ export function apiWrite<T>(
190
+ runner: ForgeRunner,
191
+ bin: CliBin,
192
+ apiHost: string,
193
+ args: readonly string[],
194
+ what: string,
195
+ timeoutMs: number = FORGE_TIMEOUTS.api,
196
+ ): ForgeResult<T> {
197
+ const run = runner(bin, args, { timeoutMs });
198
+ if (run.status !== 0) {
199
+ const text = redactText(`${run.stderr}\n${run.stdout}`).trim();
200
+ const reason = (text.split(/\r?\n/u).find((value) => value.trim()) ?? "").slice(0, 200);
201
+ if (
202
+ !run.missing &&
203
+ !run.timedOut &&
204
+ /\b409\b|head branch was modified|sha does not match|sha.*mismatch/iu.test(text)
205
+ )
206
+ return failure(
207
+ "blocked",
208
+ `head_moved: ${what} refused because the head changed (${reason})`,
209
+ "workit pr status # re-check the new head, then verify it again",
210
+ );
211
+ if (!run.missing && !run.timedOut && /\b(405|406|422)\b/u.test(text))
212
+ return failure("blocked", `${what} refused by the forge: ${reason}`);
213
+ return cliFailure(bin, apiHost, run, what, timeoutMs);
214
+ }
215
+ if (!run.stdout.trim()) return { ok: true, data: {} as T };
216
+ try {
217
+ return { ok: true, data: JSON.parse(run.stdout) as T };
218
+ } catch {
219
+ return failure("failed", `${bin} api ${what} returned invalid JSON`);
220
+ }
221
+ }