@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.
- package/package.json +1 -1
- package/scripts/analyze-release-scope.ts +99 -8
- package/scripts/bundled-deps.json +133 -0
- package/scripts/publish-changed-packages.ts +28 -16
- package/scripts/rewrite-workspace-deps.ts +11 -5
- package/scripts/sync-release-manifests.ts +5 -1
- package/src/autonomy.ts +135 -0
- package/src/core/detect-hosts.ts +9 -3
- package/src/core/doctor.ts +61 -0
- package/src/core/external-action-effects.ts +4 -9
- package/src/core/host-install.ts +144 -1
- package/src/core/pr-create.ts +2 -71
- package/src/core/setup.ts +27 -15
- package/src/core/support-matrix.ts +4 -0
- package/src/core/uninstall.ts +107 -4
- package/src/forge/exec.ts +40 -0
- package/src/forge/github.ts +84 -1
- package/src/forge/gitlab.ts +102 -1
- package/src/forge/pr-body.ts +100 -0
- package/src/forge/pr-ops.ts +450 -0
- package/src/forge/report.ts +1 -1
- package/src/forge/types.ts +30 -0
- package/src/forge/verify.ts +398 -0
- package/src/git/ops.ts +626 -0
- package/src/git/rev.ts +24 -7
- package/src/hooks/handle.ts +15 -4
- package/src/ledger.ts +17 -1
package/src/core/doctor.ts
CHANGED
|
@@ -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 =
|
|
2132
|
-
|
|
2133
|
-
|
|
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
|
})
|
package/src/core/host-install.ts
CHANGED
|
@@ -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
|
})(),
|
package/src/core/pr-create.ts
CHANGED
|
@@ -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 === "
|
|
396
|
-
?
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
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
|
-
:
|
|
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;
|
package/src/core/uninstall.ts
CHANGED
|
@@ -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
|
-
|
|
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 === "
|
|
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
|
+
}
|