@brainervirus/workit-core 2.5.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/core/detect-hosts.ts +9 -3
- package/src/core/doctor.ts +61 -0
- package/src/core/host-install.ts +144 -1
- package/src/core/setup.ts +27 -15
- package/src/core/support-matrix.ts +4 -0
- package/src/core/uninstall.ts +107 -4
- package/src/hooks/handle.ts +15 -4
package/package.json
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
// them; merge-backs and the release's own manifest sync never do.
|
|
7
7
|
import { execFileSync } from "node:child_process";
|
|
8
8
|
import { resolve } from "node:path";
|
|
9
|
+
import BUNDLED_DEPS_JSON from "./bundled-deps.json" with { type: "json" };
|
|
9
10
|
|
|
10
11
|
export const RELEASE_PACKAGES = [
|
|
11
12
|
"workit-core",
|
|
@@ -15,8 +16,90 @@ export const RELEASE_PACKAGES = [
|
|
|
15
16
|
"workit-cursor",
|
|
16
17
|
"workit-codex",
|
|
17
18
|
"workit-pi",
|
|
19
|
+
"workit-claude-code",
|
|
18
20
|
] as const;
|
|
19
21
|
|
|
22
|
+
type ReleasePackage = (typeof RELEASE_PACKAGES)[number];
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Sources a package's published dist/ inlines from OUTSIDE its own directory.
|
|
26
|
+
* Every adapter build is a no-external `bun build`, so whatever it imports
|
|
27
|
+
* from another workspace package is copied into its bundle: a runtime
|
|
28
|
+
* dependency on that package does not reach the shipped code. A change in
|
|
29
|
+
* these sources must therefore republish the bundling package too. Verified
|
|
30
|
+
* against the build entries' metafiles by
|
|
31
|
+
* test/workit-core/bundled-sources.test.ts.
|
|
32
|
+
*/
|
|
33
|
+
const CORE = "packages/workit-core/"; // sources, skills, templates and package.json
|
|
34
|
+
// Cursor and Codex inline the MCP transport and its declared dependencies.
|
|
35
|
+
const MCP = ["packages/workit-mcp/src/", "packages/workit-mcp/package.json"];
|
|
36
|
+
// The bundled CLI inlines its sources, package.json (`workit --version`) and
|
|
37
|
+
// its third-party dependencies.
|
|
38
|
+
const CLI = ["packages/workit-cli/src/", "packages/workit-cli/package.json"];
|
|
39
|
+
export const BUNDLED_SOURCES: Partial<Record<ReleasePackage, readonly string[]>> = {
|
|
40
|
+
"workit-mcp": [CORE],
|
|
41
|
+
"workit-cli": [CORE],
|
|
42
|
+
"workit-opencode": [CORE],
|
|
43
|
+
"workit-cursor": [CORE, ...MCP],
|
|
44
|
+
"workit-codex": [CORE, ...MCP],
|
|
45
|
+
"workit-pi": [CORE],
|
|
46
|
+
"workit-claude-code": [CORE, ...CLI],
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Third-party packages each dist/ inlines, as bun.lock `packages` keys
|
|
51
|
+
* (`zod`, or `@opencode-ai/plugin/zod` for a nested copy). A lockfile change
|
|
52
|
+
* republishes a package only when one of these resolves to a different
|
|
53
|
+
* version, so dev-tooling bumps never release. Kept in sync with the bundle
|
|
54
|
+
* metafiles by test/workit-core/bundled-sources.test.ts.
|
|
55
|
+
*/
|
|
56
|
+
export const BUNDLED_DEPS = BUNDLED_DEPS_JSON as Partial<Record<ReleasePackage, string[]>>;
|
|
57
|
+
|
|
58
|
+
const LOCKFILE = "bun.lock";
|
|
59
|
+
|
|
60
|
+
/** `packages` key → resolved `name@version` from a bun.lock text (JSONC). */
|
|
61
|
+
export const lockResolutions = (text: string): Map<string, string> => {
|
|
62
|
+
// bun.lock is JSON with trailing commas.
|
|
63
|
+
const parsed = JSON.parse(text.replace(/,(\s*[}\]])/g, "$1")) as {
|
|
64
|
+
packages?: Record<string, unknown>;
|
|
65
|
+
};
|
|
66
|
+
const resolved = new Map<string, string>();
|
|
67
|
+
for (const [key, entry] of Object.entries(parsed.packages ?? {}))
|
|
68
|
+
if (Array.isArray(entry) && typeof entry[0] === "string") resolved.set(key, entry[0]);
|
|
69
|
+
return resolved;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const lockAt = (root: string, rev: string): Map<string, string> | null => {
|
|
73
|
+
try {
|
|
74
|
+
return lockResolutions(
|
|
75
|
+
execFileSync("git", ["show", `${rev}:${LOCKFILE}`], {
|
|
76
|
+
cwd: root,
|
|
77
|
+
encoding: "utf8",
|
|
78
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
79
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
80
|
+
}),
|
|
81
|
+
);
|
|
82
|
+
} catch {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
/** Packages whose inlined third-party resolutions differ between two revisions. */
|
|
88
|
+
export const lockChangedPackages = (root: string, from: string, to: string): ReleasePackage[] => {
|
|
89
|
+
const before = lockAt(root, from);
|
|
90
|
+
const after = lockAt(root, to);
|
|
91
|
+
if (before === null && after === null) return [];
|
|
92
|
+
return RELEASE_PACKAGES.filter((pkg) =>
|
|
93
|
+
(BUNDLED_DEPS[pkg] ?? []).some((key) => before?.get(key) !== after?.get(key)),
|
|
94
|
+
);
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
/** Every repository path whose change alters `pkg`'s published payload. */
|
|
98
|
+
export const payloadPaths = (pkg: ReleasePackage): string[] => [
|
|
99
|
+
`packages/${pkg}/`,
|
|
100
|
+
...(BUNDLED_SOURCES[pkg] ?? []),
|
|
101
|
+
];
|
|
102
|
+
|
|
20
103
|
/** The release pipeline's own version-sync commit: never a release trigger. */
|
|
21
104
|
const RELEASE_SYNC = /^chore\(release\): sync manifests\b/;
|
|
22
105
|
|
|
@@ -56,11 +139,15 @@ const subjectLevel = (commit: string): Level | null => {
|
|
|
56
139
|
// combined diff drops files identical to either parent — e.g. hotfix-branch
|
|
57
140
|
// back-merges), and -z returns raw NUL-delimited paths so spaces/non-ASCII
|
|
58
141
|
// are never C-quoted. NUL is fine in captured output, never in argv.
|
|
59
|
-
const commitsSince = (
|
|
142
|
+
const commitsSince = (
|
|
143
|
+
root: string,
|
|
144
|
+
from: string,
|
|
145
|
+
): { hash: string; message: string; files: string[] }[] => {
|
|
60
146
|
const hashes = g(root, ["log", "--reverse", "--format=%H", `${from}..HEAD`])
|
|
61
147
|
.split("\n")
|
|
62
148
|
.filter(Boolean);
|
|
63
149
|
return hashes.map((h) => ({
|
|
150
|
+
hash: h,
|
|
64
151
|
message: g(root, ["show", "-s", "--format=%B", h]),
|
|
65
152
|
files: g(root, ["diff-tree", "--no-commit-id", "--name-only", "-r", "-m", "--root", "-z", h])
|
|
66
153
|
.split("\0")
|
|
@@ -80,20 +167,24 @@ export function analyzeReleaseScope(root = process.cwd()): {
|
|
|
80
167
|
const levels: Level[] = [];
|
|
81
168
|
const pkgs = new Set<string>();
|
|
82
169
|
let payloadOnly = false;
|
|
83
|
-
for (const { message, files } of commits) {
|
|
170
|
+
for (const { hash, message, files } of commits) {
|
|
84
171
|
const subject = (message.split("\n")[0] ?? "").trim();
|
|
85
172
|
if (RELEASE_SYNC.test(subject)) continue;
|
|
86
173
|
const touched = files.filter((f) =>
|
|
87
|
-
RELEASE_PACKAGES.some((p) => f.startsWith(
|
|
174
|
+
RELEASE_PACKAGES.some((p) => payloadPaths(p).some((prefix) => f.startsWith(prefix))),
|
|
88
175
|
);
|
|
89
|
-
|
|
176
|
+
// A lockfile edit counts only for packages whose inlined deps moved.
|
|
177
|
+
const relocked = files.includes(LOCKFILE) ? lockChangedPackages(root, `${hash}^`, hash) : [];
|
|
178
|
+
if (touched.length === 0 && relocked.length === 0) continue;
|
|
90
179
|
const lvl = subjectLevel(message);
|
|
91
180
|
if (lvl) levels.push(lvl);
|
|
92
181
|
else if (!subject.startsWith("Merge ")) payloadOnly = true;
|
|
93
|
-
for (const
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
182
|
+
for (const pkg of RELEASE_PACKAGES)
|
|
183
|
+
if (
|
|
184
|
+
relocked.includes(pkg) ||
|
|
185
|
+
touched.some((f) => payloadPaths(pkg).some((prefix) => f.startsWith(prefix)))
|
|
186
|
+
)
|
|
187
|
+
pkgs.add(pkg);
|
|
97
188
|
}
|
|
98
189
|
if (levels.length === 0) return { level: payloadOnly ? "patch" : null, productPkgs: [...pkgs] };
|
|
99
190
|
const level = levels.reduce<Level>(
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
{
|
|
2
|
+
"workit-mcp": [
|
|
3
|
+
"@modelcontextprotocol/sdk",
|
|
4
|
+
"@openclaw/fs-safe",
|
|
5
|
+
"ajv",
|
|
6
|
+
"ajv-formats",
|
|
7
|
+
"fast-deep-equal",
|
|
8
|
+
"fast-uri",
|
|
9
|
+
"json-schema-traverse",
|
|
10
|
+
"zod",
|
|
11
|
+
"zod-to-json-schema"
|
|
12
|
+
],
|
|
13
|
+
"workit-cli": [
|
|
14
|
+
"@alcalzone/ansi-tokenize",
|
|
15
|
+
"@inkjs/ui",
|
|
16
|
+
"@openclaw/fs-safe",
|
|
17
|
+
"ansi-escapes",
|
|
18
|
+
"ansi-regex",
|
|
19
|
+
"ansi-styles",
|
|
20
|
+
"auto-bind",
|
|
21
|
+
"chalk",
|
|
22
|
+
"cli-boxes",
|
|
23
|
+
"cli-cursor",
|
|
24
|
+
"cli-truncate",
|
|
25
|
+
"code-excerpt",
|
|
26
|
+
"convert-to-spaces",
|
|
27
|
+
"deepmerge",
|
|
28
|
+
"environment",
|
|
29
|
+
"es-toolkit",
|
|
30
|
+
"escape-string-regexp",
|
|
31
|
+
"figures",
|
|
32
|
+
"get-east-asian-width",
|
|
33
|
+
"indent-string",
|
|
34
|
+
"ink",
|
|
35
|
+
"is-fullwidth-code-point",
|
|
36
|
+
"is-in-ci",
|
|
37
|
+
"is-unicode-supported",
|
|
38
|
+
"patch-console",
|
|
39
|
+
"react",
|
|
40
|
+
"react-devtools-core",
|
|
41
|
+
"react-reconciler",
|
|
42
|
+
"restore-cursor",
|
|
43
|
+
"restore-cursor/onetime",
|
|
44
|
+
"restore-cursor/onetime/mimic-fn",
|
|
45
|
+
"scheduler",
|
|
46
|
+
"signal-exit",
|
|
47
|
+
"slice-ansi",
|
|
48
|
+
"stack-utils",
|
|
49
|
+
"string-width",
|
|
50
|
+
"strip-ansi",
|
|
51
|
+
"terminal-size",
|
|
52
|
+
"widest-line",
|
|
53
|
+
"wrap-ansi",
|
|
54
|
+
"ws",
|
|
55
|
+
"yoga-layout",
|
|
56
|
+
"zod"
|
|
57
|
+
],
|
|
58
|
+
"workit-opencode": [
|
|
59
|
+
"@openclaw/fs-safe",
|
|
60
|
+
"@opencode-ai/plugin",
|
|
61
|
+
"@opencode-ai/plugin/zod",
|
|
62
|
+
"@opencode/plugin",
|
|
63
|
+
"zod"
|
|
64
|
+
],
|
|
65
|
+
"workit-cursor": [
|
|
66
|
+
"@modelcontextprotocol/sdk",
|
|
67
|
+
"@openclaw/fs-safe",
|
|
68
|
+
"ajv",
|
|
69
|
+
"ajv-formats",
|
|
70
|
+
"fast-deep-equal",
|
|
71
|
+
"fast-uri",
|
|
72
|
+
"json-schema-traverse",
|
|
73
|
+
"zod",
|
|
74
|
+
"zod-to-json-schema"
|
|
75
|
+
],
|
|
76
|
+
"workit-codex": [
|
|
77
|
+
"@modelcontextprotocol/sdk",
|
|
78
|
+
"@openclaw/fs-safe",
|
|
79
|
+
"ajv",
|
|
80
|
+
"ajv-formats",
|
|
81
|
+
"fast-deep-equal",
|
|
82
|
+
"fast-uri",
|
|
83
|
+
"json-schema-traverse",
|
|
84
|
+
"zod",
|
|
85
|
+
"zod-to-json-schema"
|
|
86
|
+
],
|
|
87
|
+
"workit-pi": ["@openclaw/fs-safe", "zod"],
|
|
88
|
+
"workit-claude-code": [
|
|
89
|
+
"@alcalzone/ansi-tokenize",
|
|
90
|
+
"@inkjs/ui",
|
|
91
|
+
"@openclaw/fs-safe",
|
|
92
|
+
"ansi-escapes",
|
|
93
|
+
"ansi-regex",
|
|
94
|
+
"ansi-styles",
|
|
95
|
+
"auto-bind",
|
|
96
|
+
"chalk",
|
|
97
|
+
"cli-boxes",
|
|
98
|
+
"cli-cursor",
|
|
99
|
+
"cli-truncate",
|
|
100
|
+
"code-excerpt",
|
|
101
|
+
"convert-to-spaces",
|
|
102
|
+
"deepmerge",
|
|
103
|
+
"environment",
|
|
104
|
+
"es-toolkit",
|
|
105
|
+
"escape-string-regexp",
|
|
106
|
+
"figures",
|
|
107
|
+
"get-east-asian-width",
|
|
108
|
+
"indent-string",
|
|
109
|
+
"ink",
|
|
110
|
+
"is-fullwidth-code-point",
|
|
111
|
+
"is-in-ci",
|
|
112
|
+
"is-unicode-supported",
|
|
113
|
+
"patch-console",
|
|
114
|
+
"react",
|
|
115
|
+
"react-devtools-core",
|
|
116
|
+
"react-reconciler",
|
|
117
|
+
"restore-cursor",
|
|
118
|
+
"restore-cursor/onetime",
|
|
119
|
+
"restore-cursor/onetime/mimic-fn",
|
|
120
|
+
"scheduler",
|
|
121
|
+
"signal-exit",
|
|
122
|
+
"slice-ansi",
|
|
123
|
+
"stack-utils",
|
|
124
|
+
"string-width",
|
|
125
|
+
"strip-ansi",
|
|
126
|
+
"terminal-size",
|
|
127
|
+
"widest-line",
|
|
128
|
+
"wrap-ansi",
|
|
129
|
+
"ws",
|
|
130
|
+
"yoga-layout",
|
|
131
|
+
"zod"
|
|
132
|
+
]
|
|
133
|
+
}
|
|
@@ -4,7 +4,12 @@
|
|
|
4
4
|
// package so release logs answer "what shipped?" without leaving the terminal.
|
|
5
5
|
import { execFileSync } from "node:child_process";
|
|
6
6
|
import { resolve } from "node:path";
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
latestTag,
|
|
9
|
+
lockChangedPackages,
|
|
10
|
+
payloadPaths,
|
|
11
|
+
RELEASE_PACKAGES,
|
|
12
|
+
} from "./analyze-release-scope";
|
|
8
13
|
|
|
9
14
|
const git = (root: string, args: string[]): string =>
|
|
10
15
|
execFileSync("git", args, { cwd: root, encoding: "utf8" }).trim();
|
|
@@ -12,8 +17,13 @@ const git = (root: string, args: string[]): string =>
|
|
|
12
17
|
export function changedPackages(root: string, fromTag: string): string[] {
|
|
13
18
|
// Committed state only: <tag>..HEAD, never the working tree — unreviewed
|
|
14
19
|
// local edits must not decide what ships.
|
|
20
|
+
// Plus packages whose inlined third-party deps resolve differently in the
|
|
21
|
+
// lockfile (BUNDLED_DEPS); a dev-tooling-only lockfile bump changes none.
|
|
22
|
+
const relocked = new Set<string>(lockChangedPackages(root, fromTag, "HEAD"));
|
|
15
23
|
return RELEASE_PACKAGES.filter((pkg) => {
|
|
16
|
-
|
|
24
|
+
if (relocked.has(pkg)) return true;
|
|
25
|
+
// Own directory plus any sources bundled into its dist/ (BUNDLED_SOURCES).
|
|
26
|
+
const out = git(root, ["diff", "--name-only", `${fromTag}..HEAD`, "--", ...payloadPaths(pkg)]);
|
|
17
27
|
return out !== "";
|
|
18
28
|
});
|
|
19
29
|
}
|
|
@@ -38,20 +48,15 @@ export function publishChanged(opts: {
|
|
|
38
48
|
execFileSync(cmd, args, { cwd: o.cwd, encoding: "utf8", stdio: "inherit" }));
|
|
39
49
|
const tag =
|
|
40
50
|
opts.fromTag !== undefined ? (opts.fromTag === "" ? null : opts.fromTag) : latestTag(root);
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
for (const pkg of RELEASE_PACKAGES) {
|
|
45
|
-
const cwd = resolve(root, "packages", pkg);
|
|
46
|
-
if (!dryRun) run("npm", ["publish", "--access", "public"], { cwd });
|
|
47
|
-
published.push(pkg);
|
|
48
|
-
console.log(`published ${pkg} @ ${cwd}`);
|
|
49
|
-
}
|
|
50
|
-
return { published, skipped: [], tag: null };
|
|
51
|
-
}
|
|
52
|
-
const changed = new Set(changedPackages(root, tag));
|
|
51
|
+
// First-ever release: everything ships.
|
|
52
|
+
const changed =
|
|
53
|
+
tag === null ? new Set<string>(RELEASE_PACKAGES) : new Set(changedPackages(root, tag));
|
|
53
54
|
const published: string[] = [];
|
|
54
55
|
const skipped: string[] = [];
|
|
56
|
+
const failed: Array<{ pkg: string; error: string }> = [];
|
|
57
|
+
// Every changed package is attempted even after a failure: one package that
|
|
58
|
+
// cannot publish (e.g. a first publish of a new npm name the token does not
|
|
59
|
+
// cover) must not leave the others unreleased. Failures throw at the end.
|
|
55
60
|
for (const pkg of RELEASE_PACKAGES) {
|
|
56
61
|
if (!changed.has(pkg)) {
|
|
57
62
|
skipped.push(pkg);
|
|
@@ -62,12 +67,19 @@ export function publishChanged(opts: {
|
|
|
62
67
|
try {
|
|
63
68
|
if (!dryRun) run("npm", ["publish", "--access", "public"], { cwd });
|
|
64
69
|
} catch (e) {
|
|
65
|
-
|
|
66
|
-
|
|
70
|
+
const error = e instanceof Error ? e.message : String(e);
|
|
71
|
+
failed.push({ pkg, error });
|
|
72
|
+
console.log(`publish failed ${pkg}: ${error}`);
|
|
73
|
+
continue;
|
|
67
74
|
}
|
|
68
75
|
published.push(pkg);
|
|
69
76
|
console.log(`published ${pkg} @ ${cwd}`);
|
|
70
77
|
}
|
|
78
|
+
if (failed.length > 0) {
|
|
79
|
+
const summary = `publish failed for ${failed.length} package(s): ${failed.map((f) => f.pkg).join(", ")}; published: ${published.join(", ") || "none"}. A first publish of a new @brainervirus package needs an npm token allowed to create packages in the scope.`;
|
|
80
|
+
console.log(summary);
|
|
81
|
+
throw new Error(summary);
|
|
82
|
+
}
|
|
71
83
|
return { published, skipped, tag };
|
|
72
84
|
}
|
|
73
85
|
|
|
@@ -25,14 +25,19 @@ for (const pkg of [
|
|
|
25
25
|
"workit-cursor",
|
|
26
26
|
"workit-codex",
|
|
27
27
|
"workit-pi",
|
|
28
|
+
"workit-claude-code",
|
|
28
29
|
]) {
|
|
29
30
|
const file = resolve(root, `packages/${pkg}/package.json`);
|
|
30
31
|
const data = JSON.parse(readFileSync(file, "utf8"));
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
32
|
+
// devDependencies too: the Claude Code plugin bundles core and the CLI into
|
|
33
|
+
// dist/, so they are build-time only, but a published manifest must still
|
|
34
|
+
// never carry workspace:*.
|
|
35
|
+
const groups = [data.dependencies, data.devDependencies].filter(Boolean);
|
|
36
|
+
if (groups.length === 0) continue;
|
|
37
|
+
for (const deps of groups)
|
|
38
|
+
for (const name of Object.keys(deps)) {
|
|
39
|
+
if (name.startsWith("@brainervirus/")) deps[name] = `^${core.version}`;
|
|
40
|
+
}
|
|
36
41
|
writeFileSync(file, `${JSON.stringify(data, null, 2)}\n`);
|
|
37
42
|
}
|
|
38
43
|
// The root marketplace index carries no release version; only the package
|
|
@@ -40,6 +45,7 @@ for (const pkg of [
|
|
|
40
45
|
for (const file of [
|
|
41
46
|
resolve(root, "packages/workit-cursor/.cursor-plugin/plugin.json"),
|
|
42
47
|
resolve(root, "packages/workit-codex/.codex-plugin/plugin.json"),
|
|
48
|
+
resolve(root, "packages/workit-claude-code/.claude-plugin/plugin.json"),
|
|
43
49
|
]) {
|
|
44
50
|
const data = JSON.parse(readFileSync(file, "utf8"));
|
|
45
51
|
data.version = core.version;
|
|
@@ -9,7 +9,7 @@ import { execFileSync } from "node:child_process";
|
|
|
9
9
|
import { readFileSync, writeFileSync } from "node:fs";
|
|
10
10
|
import { resolve } from "node:path";
|
|
11
11
|
|
|
12
|
-
const SYNC_MANIFEST_PATHS = [
|
|
12
|
+
export const SYNC_MANIFEST_PATHS = [
|
|
13
13
|
"package.json",
|
|
14
14
|
"packages/workit-core/package.json",
|
|
15
15
|
"packages/workit-mcp/package.json",
|
|
@@ -18,9 +18,13 @@ const SYNC_MANIFEST_PATHS = [
|
|
|
18
18
|
"packages/workit-cursor/package.json",
|
|
19
19
|
"packages/workit-codex/package.json",
|
|
20
20
|
"packages/workit-pi/package.json",
|
|
21
|
+
"packages/workit-claude-code/package.json",
|
|
21
22
|
// Kept in lockstep with packages/workit-core/package.json by contract test.
|
|
22
23
|
"packages/workit-cursor/.cursor-plugin/plugin.json",
|
|
23
24
|
"packages/workit-codex/.codex-plugin/plugin.json",
|
|
25
|
+
// Claude Code reads the plugin version from here (the marketplace entry
|
|
26
|
+
// carries none), so it must track the published npm version.
|
|
27
|
+
"packages/workit-claude-code/.claude-plugin/plugin.json",
|
|
24
28
|
];
|
|
25
29
|
|
|
26
30
|
export type ManifestSyncResult = { version: string; changed: string[] };
|
package/src/core/detect-hosts.ts
CHANGED
|
@@ -9,6 +9,7 @@ import { planUninstall, type UninstallPaths } from "./uninstall";
|
|
|
9
9
|
import {
|
|
10
10
|
findHostExecutable,
|
|
11
11
|
installedHostApp,
|
|
12
|
+
isClaudeWorkitInstalled,
|
|
12
13
|
isCodexWorkitInstalled,
|
|
13
14
|
isPiWorkitInstalled,
|
|
14
15
|
type HostId,
|
|
@@ -24,7 +25,7 @@ export type HostDetection = {
|
|
|
24
25
|
|
|
25
26
|
export type DetectHostsOptions = UninstallPaths;
|
|
26
27
|
|
|
27
|
-
const HOSTS: HostId[] = ["opencode", "cursor", "codex", "pi"];
|
|
28
|
+
const HOSTS: HostId[] = ["opencode", "cursor", "codex", "pi", "claude-code"];
|
|
28
29
|
|
|
29
30
|
/** Hosts the setup wizard configures through their native install paths. */
|
|
30
31
|
export const WIZARD_HOSTS: HostId[] = [...HOSTS];
|
|
@@ -40,6 +41,7 @@ export function emptyDetection(): Record<HostId, HostDetection> {
|
|
|
40
41
|
cursor: { detected: false, configured: false },
|
|
41
42
|
codex: { detected: false, configured: false },
|
|
42
43
|
pi: { detected: false, configured: false },
|
|
44
|
+
"claude-code": { detected: false, configured: false },
|
|
43
45
|
};
|
|
44
46
|
}
|
|
45
47
|
|
|
@@ -52,8 +54,10 @@ export function detectHosts(options: DetectHostsOptions = {}): Record<HostId, Ho
|
|
|
52
54
|
const configuredByHost = new Map(plan.hosts.map((h) => [h.host, h.installed]));
|
|
53
55
|
const found = emptyDetection();
|
|
54
56
|
for (const host of HOSTS) {
|
|
57
|
+
// Claude Code ships as the `claude` CLI (the desktop app embeds it).
|
|
58
|
+
const executable = host === "claude-code" ? "claude" : host;
|
|
55
59
|
const detected =
|
|
56
|
-
findHostExecutable(
|
|
60
|
+
findHostExecutable(executable, { home, env }) !== null || installedHostApp(host, home, env);
|
|
57
61
|
found[host] = {
|
|
58
62
|
detected,
|
|
59
63
|
configured:
|
|
@@ -61,7 +65,9 @@ export function detectHosts(options: DetectHostsOptions = {}): Record<HostId, Ho
|
|
|
61
65
|
? isCodexWorkitInstalled(home, env) || (configuredByHost.get(host) ?? false)
|
|
62
66
|
: host === "pi"
|
|
63
67
|
? isPiWorkitInstalled(home, env) || (configuredByHost.get(host) ?? false)
|
|
64
|
-
:
|
|
68
|
+
: host === "claude-code"
|
|
69
|
+
? isClaudeWorkitInstalled(home, env)
|
|
70
|
+
: (configuredByHost.get(host) ?? false),
|
|
65
71
|
};
|
|
66
72
|
}
|
|
67
73
|
return found;
|
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,
|
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/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/hooks/handle.ts
CHANGED
|
@@ -12,6 +12,20 @@ const NONE: HookDecision = { kind: "none" };
|
|
|
12
12
|
/** Sessions already offered unfinished tasks in this process. */
|
|
13
13
|
const offered = new Set<string>();
|
|
14
14
|
|
|
15
|
+
/** The Workit plugin's worktree-isolated implementer is the one Claude Code
|
|
16
|
+
* subagent that writes. Claude namespaces plugin agents, so only the exact
|
|
17
|
+
* `workit:implementer` is ours: a bare or other-plugin `implementer` is not. */
|
|
18
|
+
const CLAUDE_WORKTREE_IMPLEMENTER = "workit:implementer";
|
|
19
|
+
|
|
20
|
+
const subagentStartText = (
|
|
21
|
+
host: HookInput["host"],
|
|
22
|
+
descriptor: HostDescriptor,
|
|
23
|
+
event: Extract<HookInput["event"], { kind: "subagent.start" }>,
|
|
24
|
+
): string =>
|
|
25
|
+
host === "claude_code" && event.agentType === CLAUDE_WORKTREE_IMPLEMENTER
|
|
26
|
+
? `Workit observed ${descriptor.label} subagent ${event.agentId} (${event.agentType}) working in its own git worktree: it may edit and commit there, within its brief's scope. Before the first commit, switch to a policy-compliant branch (\`git switch -c <type>/<slug>\`, e.g. feature/<slug>); branch policy hooks still deny protected or non-compliant branches. Never push, open a PR, or merge unless the brief asks for it.`
|
|
27
|
+
: `Workit observed ${descriptor.label} subagent ${event.agentId} (${event.agentType}) as read-only/agent-guided; writer delegation is unavailable.`;
|
|
28
|
+
|
|
15
29
|
export function handleHook(input: HookInput, deps: HookDeps): HookDecision {
|
|
16
30
|
const { descriptor } = deps;
|
|
17
31
|
const event = input.event;
|
|
@@ -36,10 +50,7 @@ export function handleHook(input: HookInput, deps: HookDeps): HookDecision {
|
|
|
36
50
|
case "shell.pre":
|
|
37
51
|
return usable(descriptor.shellPolicy.deny) ? shellPolicy(input.cwd, event.command) : NONE;
|
|
38
52
|
case "subagent.start":
|
|
39
|
-
return {
|
|
40
|
-
kind: "context",
|
|
41
|
-
text: `Workit observed ${descriptor.label} subagent ${event.agentId} (${event.agentType}) as read-only/agent-guided; writer delegation is unavailable.`,
|
|
42
|
-
};
|
|
53
|
+
return { kind: "context", text: subagentStartText(input.host, descriptor, event) };
|
|
43
54
|
case "compact.pre":
|
|
44
55
|
return {
|
|
45
56
|
kind: "notice",
|