@brainervirus/workit-core 2.5.0 → 2.7.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/src/checks.ts ADDED
@@ -0,0 +1,480 @@
1
+ // Check runs for `workit check` (design §2.1 S9, §2.2; D5, D14): no shell
2
+ // (unless the caller passes one string with --shell), the child's output
3
+ // streams through while a bounded, redacted copy is kept for the log blob
4
+ // (`<store>/blobs/logs/<sha256>.log`) and the tail. Named-check config lives
5
+ // in check-config.ts.
6
+ //
7
+ // Plain TS over node built-ins: no zod, no task store.
8
+ import { spawn, spawnSync } from "node:child_process";
9
+ import { createHash } from "node:crypto";
10
+ import fs from "node:fs";
11
+ import os from "node:os";
12
+ import path from "node:path";
13
+ import { StringDecoder } from "node:string_decoder";
14
+ import { redactText } from "./forge/redact";
15
+
16
+ // ---------------------------------------------------------------------------
17
+ // bounded, redacted capture
18
+
19
+ /** Bytes of combined output kept for the log blob (the newest bytes win). */
20
+ export const MAX_LOG_BYTES = 2 * 1024 * 1024;
21
+ export const TAIL_LINES = 80;
22
+ const MAX_LINE_CHARS = 400;
23
+
24
+ /**
25
+ * Mask secrets and strip terminal escapes from captured output with the forge
26
+ * redaction (tokens, key=value and quoted secrets, URL credentials, private
27
+ * keys, signed URLs, credential-looking base64). The logger's redaction is not
28
+ * used: it rewrites Windows backslashes and home paths, which would alter the
29
+ * observed output.
30
+ */
31
+ export const redactLog = (text: string): string => redactText(text);
32
+
33
+ /** The last `lines` non-empty lines, each cut to a bounded width. */
34
+ export function tailLines(text: string, lines: number = TAIL_LINES): string[] {
35
+ const all = text.replace(/\r\n?/gu, "\n").split("\n");
36
+ while (all.length && !all.at(-1)?.trim()) all.pop();
37
+ return all
38
+ .slice(-lines)
39
+ .map((line) => (line.length > MAX_LINE_CHARS ? `${line.slice(0, MAX_LINE_CHARS)}…` : line));
40
+ }
41
+
42
+ /** Keeps the newest `max` bytes of already-redacted text. */
43
+ export class TailBuffer {
44
+ private chunks: string[] = [];
45
+ private size = 0;
46
+ total = 0;
47
+ constructor(private readonly max: number) {}
48
+ push(text: string): void {
49
+ const bytes = Buffer.byteLength(text);
50
+ this.total += bytes;
51
+ this.chunks.push(text);
52
+ this.size += bytes;
53
+ while (this.chunks.length > 1 && this.size - Buffer.byteLength(this.chunks[0]) >= this.max)
54
+ this.size -= Buffer.byteLength(this.chunks.shift()!);
55
+ }
56
+ text(): string {
57
+ const joined = Buffer.from(this.chunks.join(""));
58
+ if (joined.length <= this.max) return joined.toString("utf8");
59
+ const value = joined.subarray(joined.length - this.max).toString("utf8");
60
+ // The head was cut: drop the partial first line.
61
+ const newline = value.indexOf("\n");
62
+ return newline >= 0 ? value.slice(newline + 1) : value;
63
+ }
64
+ }
65
+
66
+ const PEM_BEGIN = /-----BEGIN [A-Z0-9 ]*PRIVATE KEY(?: BLOCK)?-----/u;
67
+ const PEM_END = /-----END [A-Z0-9 ]*PRIVATE KEY(?: BLOCK)?-----/u;
68
+ const BATCH_BYTES = 64 * 1024;
69
+ const MAX_PENDING = 64 * 1024;
70
+
71
+ /** Lines that can belong to a PEM body: base64 (or blank), or an RFC 1421 encapsulated header. */
72
+ const PEM_BODY = /^(?:[A-Za-z0-9+/=]*|(?:Proc-Type|DEK-Info|Comment): .*)\s*$/u;
73
+ /** Suppression bounds for a key whose END never appears (truncated output). */
74
+ const MAX_KEY_LINES = 4096;
75
+ const MAX_KEY_BYTES = 256 * 1024;
76
+
77
+ /**
78
+ * Redacts output as it streams, before anything is bounded: complete lines
79
+ * are redacted in batches, and a private key block is replaced as a whole
80
+ * while its BEGIN line is still in view, so trimming the log to its newest
81
+ * bytes can never cut a key loose from its header. Key state spans the merged
82
+ * stdout+stderr log: while a key is open, base64-looking lines from either
83
+ * stream are dropped. The key ends at its END line, at the first non-base64
84
+ * line from the stream that began it, or after MAX_KEY_LINES/MAX_KEY_BYTES, so
85
+ * a key without an END never swallows the rest of the log. Non-key lines from
86
+ * the other stream pass through. A line longer than 64 KB is treated as complete.
87
+ */
88
+ export class StreamRedactor {
89
+ private pending = new Map<string, string>();
90
+ private key: { stream: string; lines: number; bytes: number } | null = null;
91
+ private batch: string[] = [];
92
+ private batchBytes = 0;
93
+ constructor(private readonly sink: (text: string) => void) {}
94
+ write(stream: string, text: string): void {
95
+ let rest = (this.pending.get(stream) ?? "") + text;
96
+ for (let newline = rest.indexOf("\n"); newline >= 0; newline = rest.indexOf("\n")) {
97
+ this.line(stream, rest.slice(0, newline + 1));
98
+ rest = rest.slice(newline + 1);
99
+ }
100
+ if (rest.length > MAX_PENDING) {
101
+ this.line(stream, rest);
102
+ rest = "";
103
+ }
104
+ this.pending.set(stream, rest);
105
+ }
106
+ end(): void {
107
+ for (const [stream, rest] of this.pending) if (rest) this.line(stream, `${rest}\n`);
108
+ this.pending.clear();
109
+ this.flush();
110
+ }
111
+ private line(stream: string, line: string): void {
112
+ if (this.key) {
113
+ if (PEM_END.test(line)) {
114
+ this.key = null;
115
+ return;
116
+ }
117
+ const body = PEM_BODY.test(line);
118
+ if (body) {
119
+ this.key.lines += 1;
120
+ this.key.bytes += line.length;
121
+ if (this.key.lines > MAX_KEY_LINES || this.key.bytes > MAX_KEY_BYTES) this.key = null;
122
+ return;
123
+ }
124
+ if (stream === this.key.stream) this.key = null;
125
+ }
126
+ const begin = line.search(PEM_BEGIN);
127
+ if (begin >= 0) {
128
+ if (!PEM_END.test(line.slice(begin))) this.key = { stream, lines: 0, bytes: 0 };
129
+ this.push(`${line.slice(0, begin)}[REDACTED PRIVATE KEY]\n`);
130
+ return;
131
+ }
132
+ this.push(line);
133
+ }
134
+ private push(line: string): void {
135
+ this.batch.push(line);
136
+ this.batchBytes += line.length;
137
+ if (this.batchBytes >= BATCH_BYTES) this.flush();
138
+ }
139
+ private flush(): void {
140
+ if (!this.batch.length) return;
141
+ this.sink(redactLog(this.batch.join("")));
142
+ this.batch = [];
143
+ this.batchBytes = 0;
144
+ }
145
+ }
146
+
147
+ export type CheckRun = {
148
+ /** The child's exit code; 128+n when killed by signal n; 127 when it could not start. */
149
+ exitCode: number;
150
+ signal: string | null;
151
+ timedOut: boolean;
152
+ durationMs: number;
153
+ /** Redacted, bounded combined output. */
154
+ log: string;
155
+ /** Total bytes of redacted output (before bounding). */
156
+ outputBytes: number;
157
+ truncated: boolean;
158
+ /** Set when the command could not be started. */
159
+ spawnError: string | null;
160
+ };
161
+
162
+ export type RunOptions = {
163
+ cwd: string;
164
+ shell?: boolean;
165
+ timeoutMs?: number;
166
+ env?: NodeJS.ProcessEnv;
167
+ /** Receives the child's stdout as it arrives. */
168
+ onStdout?: (text: string) => void;
169
+ onStderr?: (text: string) => void;
170
+ /** For tests: the platform whose spawn rules apply (default: process.platform). */
171
+ platform?: NodeJS.Platform;
172
+ };
173
+
174
+ // cmd.exe metacharacters (the cross-spawn set).
175
+ const CMD_META = /([()\][%!^"`<>&|;, *?])/gu;
176
+
177
+ /** One argument for a `cmd /d /s /c "…"` line; `double` for node_modules/.bin shims, which re-parse. */
178
+ export function cmdArgument(arg: string, double = false): string {
179
+ let out = arg.replace(/(\\*)"/gu, '$1$1\\"').replace(/(\\*)$/u, "$1$1");
180
+ out = `"${out}"`.replace(CMD_META, "^$1");
181
+ return double ? out.replace(CMD_META, "^$1") : out;
182
+ }
183
+
184
+ export type SpawnPlan = {
185
+ file: string;
186
+ args: string[];
187
+ /** Pass the args to CreateProcess verbatim (cmd.exe lines carry their own quoting). */
188
+ verbatim: boolean;
189
+ shell: boolean;
190
+ };
191
+
192
+ /**
193
+ * How to start argv without a shell. On Windows, argv[0] is resolved through
194
+ * PATH and PATHEXT (CreateProcess finds only .exe/.com); a .cmd/.bat shim
195
+ * (npm, pnpm, yarn, node_modules/.bin) runs as `cmd.exe /d /s /c "<line>"`
196
+ * with every argument escaped, so the observed argv (and `configured`) stay
197
+ * the configured ones. Null `isFile` uses the real filesystem.
198
+ */
199
+ export function planSpawn(
200
+ argv: readonly string[],
201
+ options: {
202
+ platform: NodeJS.Platform;
203
+ env: NodeJS.ProcessEnv;
204
+ cwd: string;
205
+ shell?: boolean;
206
+ isFile?: (file: string) => boolean;
207
+ },
208
+ ): SpawnPlan {
209
+ if (options.shell) return { file: argv[0], args: [], verbatim: false, shell: true };
210
+ if (options.platform !== "win32")
211
+ return { file: argv[0], args: argv.slice(1), verbatim: false, shell: false };
212
+ const win = path.win32;
213
+ const isFile =
214
+ options.isFile ??
215
+ ((file: string) => {
216
+ try {
217
+ return fs.statSync(file).isFile();
218
+ } catch {
219
+ return false;
220
+ }
221
+ });
222
+ // Windows names are case-insensitive; an exact upper-case key wins over a duplicate.
223
+ const env = (name: string): string | undefined =>
224
+ options.env[name] ??
225
+ Object.entries(options.env).find(([key]) => key.toUpperCase() === name)?.[1];
226
+ const exts = (env("PATHEXT") ?? ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean);
227
+ const command = argv[0];
228
+ const hasDir = /[\\/]/u.test(command);
229
+ const dirs = hasDir ? [""] : (env("PATH") ?? "").split(";").filter(Boolean);
230
+ const candidates = (base: string) =>
231
+ win.extname(base) ? [base, ...exts.map((ext) => base + ext)] : exts.map((ext) => base + ext);
232
+ let resolved: string | null = null;
233
+ for (const dir of dirs) {
234
+ const base = hasDir ? win.resolve(options.cwd, command) : win.join(dir, command);
235
+ resolved = candidates(base).find(isFile) ?? null;
236
+ if (resolved) break;
237
+ }
238
+ if (!resolved) return { file: command, args: argv.slice(1), verbatim: false, shell: false };
239
+ if (!/\.(?:cmd|bat)$/iu.test(resolved))
240
+ return { file: resolved, args: argv.slice(1), verbatim: false, shell: false };
241
+ const double = /node_modules[\\/]\.bin[\\/][^\\/]+\.cmd$/iu.test(resolved);
242
+ const line = [
243
+ win.normalize(resolved).replace(CMD_META, "^$1"),
244
+ ...argv.slice(1).map((arg) => cmdArgument(arg, double)),
245
+ ].join(" ");
246
+ return {
247
+ file: env("COMSPEC") ?? "cmd.exe",
248
+ args: ["/d", "/s", "/c", `"${line}"`],
249
+ verbatim: true,
250
+ shell: false,
251
+ };
252
+ }
253
+
254
+ /** After the child exits, how long its pipes may stay open (held by leftovers) before they are cut. */
255
+ const DRAIN_MS = 250;
256
+ const FORWARDED_SIGNALS: NodeJS.Signals[] = ["SIGINT", "SIGTERM", "SIGHUP"];
257
+
258
+ /**
259
+ * Run one command, streaming its output to the callbacks while a redacted,
260
+ * bounded copy is captured. With `shell`, argv must be a single command
261
+ * string. On POSIX the child leads its own process group (stdin is not
262
+ * inherited), so a timeout, or the exit of the command, kills every process
263
+ * it started; Ctrl-C and SIGTERM sent to workit are forwarded to the group.
264
+ * On Windows the tree is killed with `taskkill /T /F`. The run settles on
265
+ * the child's exit plus a short drain, so a leftover holding the pipes open
266
+ * cannot hang it.
267
+ */
268
+ export function runCheckCommand(argv: readonly string[], options: RunOptions): Promise<CheckRun> {
269
+ const started = Date.now();
270
+ const platform = options.platform ?? process.platform;
271
+ const windows = process.platform === "win32";
272
+ const buffer = new TailBuffer(MAX_LOG_BYTES);
273
+ const redactor = new StreamRedactor((text) => buffer.push(text));
274
+ const env = options.env ?? process.env;
275
+ const plan = planSpawn(argv, { platform, env, cwd: options.cwd, shell: options.shell });
276
+ return new Promise((resolve) => {
277
+ let settled = false;
278
+ let timedOut = false;
279
+ let drain: NodeJS.Timeout | null = null;
280
+ let child: ReturnType<typeof spawn>;
281
+ let timer: NodeJS.Timeout | null = null;
282
+ const killTree = () => {
283
+ if (!child?.pid) return;
284
+ if (windows)
285
+ spawnSync("taskkill", ["/pid", String(child.pid), "/T", "/F"], {
286
+ stdio: "ignore",
287
+ windowsHide: true,
288
+ });
289
+ else
290
+ try {
291
+ process.kill(-child.pid, "SIGKILL");
292
+ } catch {
293
+ // The group is already gone.
294
+ }
295
+ };
296
+ const forwarders = new Map<NodeJS.Signals, () => void>();
297
+ const finish = (code: number | null, signal: NodeJS.Signals | null, spawnError?: string) => {
298
+ if (settled) return;
299
+ settled = true;
300
+ if (timer) clearTimeout(timer);
301
+ if (drain) clearTimeout(drain);
302
+ for (const [name, handler] of forwarders) process.off(name, handler);
303
+ child?.stdout?.destroy();
304
+ child?.stderr?.destroy();
305
+ if (spawnError) redactor.write("workit", `workit: cannot start ${argv[0]}: ${spawnError}\n`);
306
+ redactor.end();
307
+ resolve({
308
+ exitCode: spawnError
309
+ ? 127
310
+ : code !== null
311
+ ? code
312
+ : 128 + (signal ? (os.constants.signals[signal] ?? 1) : 1),
313
+ signal: signal ?? null,
314
+ timedOut,
315
+ durationMs: Date.now() - started,
316
+ log: buffer.text(),
317
+ outputBytes: buffer.total,
318
+ truncated: buffer.total > MAX_LOG_BYTES,
319
+ spawnError: spawnError ?? null,
320
+ });
321
+ };
322
+ const spawnOptions = {
323
+ cwd: options.cwd,
324
+ env,
325
+ stdio: ["ignore", "pipe", "pipe"] as ["ignore", "pipe", "pipe"],
326
+ windowsHide: true,
327
+ detached: !windows,
328
+ windowsVerbatimArguments: plan.verbatim,
329
+ shell: plan.shell,
330
+ };
331
+ try {
332
+ child = spawn(plan.file, plan.args, spawnOptions);
333
+ } catch (error) {
334
+ finish(null, null, (error as NodeJS.ErrnoException).code ?? (error as Error).message);
335
+ return;
336
+ }
337
+ if (!windows)
338
+ for (const name of FORWARDED_SIGNALS) {
339
+ const handler = () => {
340
+ if (child.pid)
341
+ try {
342
+ process.kill(-child.pid, name);
343
+ } catch {
344
+ // Already gone.
345
+ }
346
+ };
347
+ forwarders.set(name, handler);
348
+ process.on(name, handler);
349
+ }
350
+ if (options.timeoutMs && options.timeoutMs > 0)
351
+ timer = setTimeout(() => {
352
+ timedOut = true;
353
+ killTree();
354
+ }, options.timeoutMs);
355
+ const forward = (
356
+ name: string,
357
+ stream: NodeJS.ReadableStream | null,
358
+ sink?: (text: string) => void,
359
+ ) => {
360
+ if (!stream) return;
361
+ const decoder = new StringDecoder("utf8");
362
+ stream.on("data", (chunk: Buffer) => {
363
+ const text = decoder.write(chunk);
364
+ redactor.write(name, text);
365
+ if (sink) sink(text);
366
+ });
367
+ stream.on("end", () => {
368
+ const rest = decoder.end();
369
+ if (rest) {
370
+ redactor.write(name, rest);
371
+ if (sink) sink(rest);
372
+ }
373
+ });
374
+ stream.on("error", () => {
375
+ // A destroyed pipe after the drain: the captured output stands.
376
+ });
377
+ };
378
+ forward("stdout", child.stdout, options.onStdout);
379
+ forward("stderr", child.stderr, options.onStderr);
380
+ child.on("error", (error: NodeJS.ErrnoException) =>
381
+ finish(null, null, error.code ?? error.message),
382
+ );
383
+ child.on("exit", (code, signal) => {
384
+ // Leftovers of the command die with it; then give the pipes a moment.
385
+ if (!windows) killTree();
386
+ drain = setTimeout(() => finish(code, signal), DRAIN_MS);
387
+ });
388
+ child.on("close", (code, signal) => finish(code, signal));
389
+ });
390
+ }
391
+
392
+ /** Content address of a stored log: `sha256:<hex>`. */
393
+ export const logDigest = (log: string): string =>
394
+ `sha256:${createHash("sha256").update(log).digest("hex")}`;
395
+
396
+ /**
397
+ * Store a redacted log under `<storeRoot>/blobs/logs/<hex>.log` (content
398
+ * addressed, so concurrent writers of the same log agree). Returns the path
399
+ * relative to the store root, or null when the write fails.
400
+ */
401
+ export function storeLog(storeRoot: string, log: string): { digest: string; ref: string } | null {
402
+ const digest = logDigest(log);
403
+ const ref = `blobs/logs/${digest.slice("sha256:".length)}.log`;
404
+ const file = path.join(storeRoot, ref);
405
+ try {
406
+ if (!fs.existsSync(file)) {
407
+ fs.mkdirSync(path.dirname(file), { recursive: true });
408
+ const temp = `${file}.${process.pid}.${Date.now()}.tmp`;
409
+ fs.writeFileSync(temp, log, { mode: 0o600 });
410
+ fs.renameSync(temp, file);
411
+ }
412
+ return { digest, ref };
413
+ } catch {
414
+ return null;
415
+ }
416
+ }
417
+
418
+ export type LogPruneReport = { removed: number; removedBytes: number; kept: number };
419
+
420
+ /** Check-log retention for `workit gc`: newest 200, at most 30 days old, at most 256 MB. */
421
+ export const LOG_RETENTION = {
422
+ maxCount: 200,
423
+ maxAgeMs: 30 * 86_400_000,
424
+ maxBytes: 256 * 1024 * 1024,
425
+ };
426
+
427
+ /**
428
+ * Prune `<storeRoot>/blobs/logs`: keep the newest logs within the count, age
429
+ * and size caps, and drop leftover temp files. Evidence keeps its logDigest
430
+ * when its blob is pruned. `dryRun` only reports.
431
+ */
432
+ export function pruneCheckLogs(
433
+ storeRoot: string,
434
+ options: { dryRun?: boolean; now?: number; retention?: Partial<typeof LOG_RETENTION> } = {},
435
+ ): LogPruneReport {
436
+ const limits = { ...LOG_RETENTION, ...options.retention };
437
+ const now = options.now ?? Date.now();
438
+ const dir = path.join(storeRoot, "blobs", "logs");
439
+ const report: LogPruneReport = { removed: 0, removedBytes: 0, kept: 0 };
440
+ let names: string[];
441
+ try {
442
+ names = fs.readdirSync(dir);
443
+ } catch {
444
+ return report;
445
+ }
446
+ const files = names
447
+ .flatMap((name) => {
448
+ try {
449
+ const stat = fs.statSync(path.join(dir, name));
450
+ return stat.isFile() ? [{ name, size: stat.size, mtimeMs: stat.mtimeMs }] : [];
451
+ } catch {
452
+ return [];
453
+ }
454
+ })
455
+ .toSorted((a, b) => b.mtimeMs - a.mtimeMs);
456
+ let keptBytes = 0;
457
+ for (const file of files) {
458
+ const temp = file.name.endsWith(".tmp");
459
+ const keep =
460
+ !temp &&
461
+ report.kept < limits.maxCount &&
462
+ now - file.mtimeMs <= limits.maxAgeMs &&
463
+ keptBytes + file.size <= limits.maxBytes;
464
+ if (keep) {
465
+ report.kept += 1;
466
+ keptBytes += file.size;
467
+ continue;
468
+ }
469
+ if (temp && now - file.mtimeMs < 3_600_000) continue;
470
+ if (!options.dryRun)
471
+ try {
472
+ fs.rmSync(path.join(dir, file.name), { force: true });
473
+ } catch {
474
+ continue;
475
+ }
476
+ report.removed += 1;
477
+ report.removedBytes += file.size;
478
+ }
479
+ return report;
480
+ }
@@ -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(host, { home, env }) !== null || installedHostApp(host, home, env);
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
- : (configuredByHost.get(host) ?? false),
68
+ : host === "claude-code"
69
+ ? isClaudeWorkitInstalled(home, env)
70
+ : (configuredByHost.get(host) ?? false),
65
71
  };
66
72
  }
67
73
  return found;
@@ -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,