diff --git a/docs/conventions/prerequisites/README.md b/docs/conventions/prerequisites/README.md index ec00d3a9c6..deac68f6b1 100644 --- a/docs/conventions/prerequisites/README.md +++ b/docs/conventions/prerequisites/README.md @@ -74,7 +74,7 @@ at its entry point. `optional` means the scope continues with a reduced result a | `kind` | `detect` | Resolves when | | --- | --- | --- | -| `cli`, `runtime` | `any` (names, first match wins), optional `local_bin` (paths tried upward from the working directory, up to eight levels), optional `version` (`args`, a `pattern` whose first group is the version, `min`) | A name is on `PATH` or a `local_bin` path is executable, and the version is at least `min`. A runtime lists its ladder in `any`, such as `["python3", "python", "py"]`. | +| `cli`, `runtime` | `any` (names, first match wins), optional `local_bin` (paths tried upward from the working directory, up to eight levels), optional `version` (`args`, a `pattern` whose first group is the version, `min`), optional `reject_store_alias` (`true` skips Windows `WindowsApps` directories, so a Microsoft Store App Execution Alias never counts) | A name is on `PATH` or a `local_bin` path is executable, and the version is at least `min`. A runtime lists its ladder in `any`, such as `["python3", "python", "py"]`. On Windows a Store App Execution Alias counts as found, except the Python install stub, unless the entry sets `reject_store_alias`. | | `system-lib` | `probe`: `args` (the first item is the binary) and `pattern` | The probe output matches the pattern, for example `ffmpeg -encoders` listing `libx264`. | | `python-pkg` | `any` (interpreters) and `import` (a module name) | The first interpreter found imports the module. | | `node-pkg` | `module` and `paths` (relative to the plugin root, or starting with `${CLAUDE_PLUGIN_ROOT}` or `${CLAUDE_PLUGIN_DATA}`) | `//package.json` exists under one of the paths. | diff --git a/docs/conventions/prerequisites/prerequisites.schema.json b/docs/conventions/prerequisites/prerequisites.schema.json index 4682e59abf..6589c2aae1 100644 --- a/docs/conventions/prerequisites/prerequisites.schema.json +++ b/docs/conventions/prerequisites/prerequisites.schema.json @@ -65,6 +65,10 @@ "properties": { "any": { "$ref": "#/$defs/names" }, "local_bin": { "$ref": "#/$defs/relativePaths" }, + "reject_store_alias": { + "type": "boolean", + "description": "On Windows, skip PATH directories named WindowsApps, so a Microsoft Store App Execution Alias never counts as found. Default false." + }, "version": { "type": "object", "additionalProperties": false, diff --git a/lib/prerequisites.mjs b/lib/prerequisites.mjs index e19bd0a9b4..68660d9799 100755 --- a/lib/prerequisites.mjs +++ b/lib/prerequisites.mjs @@ -26,8 +26,10 @@ import { accessSync, constants, existsSync, + lstatSync, mkdirSync, readFileSync, + readlinkSync, realpathSync, statSync, writeFileSync, @@ -40,8 +42,8 @@ export const KINDS = ["cli", "runtime", "system-lib", "python-pkg", "node-pkg", export const NEEDS = ["required", "optional"]; const ENTRY_KEYS = ["id", "kind", "need", "for", "detect", "degrade", "install", "check"]; const DETECT_KEYS = { - cli: { required: ["any"], optional: ["local_bin", "version"] }, - runtime: { required: ["any"], optional: ["local_bin", "version"] }, + cli: { required: ["any"], optional: ["local_bin", "version", "reject_store_alias"] }, + runtime: { required: ["any"], optional: ["local_bin", "version", "reject_store_alias"] }, "system-lib": { required: ["probe"], optional: [] }, "python-pkg": { required: ["any", "import"], optional: [] }, "node-pkg": { required: ["module", "paths"], optional: [] }, @@ -98,6 +100,9 @@ function detectErrors(kind, detect, where) { } if ("any" in detect && !isTextList(detect.any)) errors.push(`${where}.any must be a non-empty list of names`); if ("local_bin" in detect) errors.push(...relativePathErrors(detect.local_bin, `${where}.local_bin`)); + if ("reject_store_alias" in detect && typeof detect.reject_store_alias !== "boolean") { + errors.push(`${where}.reject_store_alias must be true or false`); + } if ("version" in detect) { const v = detect.version; const vw = `${where}.version`; @@ -241,36 +246,66 @@ export function compareVersions(a, b) { return 0; } -function isExecutable(file, platform) { +const FS = { statSync, lstatSync, readlinkSync, accessSync }; + +// The target of App Installer's Python "install me" stub, which opens the Store +// instead of running Python, and the alias names it takes. +const PYTHON_STUB_TARGET = "appinstallerpythonredirector.exe"; +const PYTHON_STUB_NAMES = new Set(["python", "python3"]); + +// A Windows App Execution Alias, how a Store or winget app reaches PATH, is a +// reparse point in a WindowsApps directory that stat cannot open, yet it runs. +// It counts as found unless it is the Python stub, known by its target or, when +// the target cannot be read, by its name. +const isWindowsApps = (dir) => path.basename(dir).toLowerCase() === "windowsapps"; + +function isAppAlias(file, fs) { + if (!isWindowsApps(path.dirname(file))) return false; try { - if (!statSync(file).isFile()) return false; - if (platform !== "win32") accessSync(file, constants.X_OK); - return true; + if (!fs.lstatSync(file).isSymbolicLink()) return false; } catch { return false; } + let target; + try { + target = fs.readlinkSync(file); + } catch { + return !PYTHON_STUB_NAMES.has(path.basename(file).toLowerCase().replace(/\.exe$/, "")); + } + return path.win32.basename(target).toLowerCase() !== PYTHON_STUB_TARGET; +} + +function isExecutable(file, platform, fs = FS) { + try { + if (!fs.statSync(file).isFile()) return false; + if (platform !== "win32") fs.accessSync(file, constants.X_OK); + return true; + } catch { + return platform === "win32" && isAppAlias(file, fs); + } } -// which(name) -> absolute path of the first PATH match, or null. -export function which(name, env = process.env, platform = process.platform) { +// which(name) -> absolute path of the first PATH match, or null. fs is injectable for tests. +// rejectStoreAlias skips WindowsApps directories on win32, so no App Execution Alias matches. +export function which(name, env = process.env, platform = process.platform, fs = FS, rejectStoreAlias = false) { const pathVar = env.PATH ?? env.Path ?? ""; const exts = platform === "win32" ? ["", ...(env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean)] : [""]; for (const dir of pathVar.split(path.delimiter)) { - if (!dir) continue; + if (!dir || (rejectStoreAlias && platform === "win32" && isWindowsApps(dir))) continue; for (const ext of exts) { const candidate = path.join(dir, name + ext); - if (isExecutable(candidate, platform)) return candidate; + if (isExecutable(candidate, platform, fs)) return candidate; } } return null; } -function findLocalBin(rels, cwd, platform) { +function findLocalBin(rels, cwd, platform, fs) { for (const rel of rels ?? []) { let dir = path.resolve(cwd); for (let i = 0; i < LOCAL_BIN_DEPTH; i++) { const candidate = path.join(dir, rel); - if (isExecutable(candidate, platform)) return candidate; + if (isExecutable(candidate, platform, fs)) return candidate; const parent = path.dirname(dir); if (parent === dir) break; dir = parent; @@ -284,9 +319,9 @@ function run(file, args) { return { ok: !r.error && r.status === 0, out: `${r.stdout ?? ""}${r.stderr ?? ""}` }; } -function firstOnPath(names, ctx) { +function firstOnPath(names, ctx, rejectStoreAlias = false) { for (const name of names) { - const found = which(name, ctx.env, ctx.platform); + const found = which(name, ctx.env, ctx.platform, ctx.fs, rejectStoreAlias); if (found) return found; } return null; @@ -312,7 +347,7 @@ export function detectEntry(entry, ctx) { switch (entry.kind) { case "cli": case "runtime": { - const found = firstOnPath(d.any, ctx) ?? findLocalBin(d.local_bin, ctx.cwd, ctx.platform); + const found = firstOnPath(d.any, ctx, d.reject_store_alias) ?? findLocalBin(d.local_bin, ctx.cwd, ctx.platform, ctx.fs); if (!found) { const where = d.local_bin ? `on PATH or as ${d.local_bin.join(", ")}` : "on PATH"; return { status: "missing", detail: `${d.any.join(" or ")} was not found ${where}` }; @@ -329,7 +364,7 @@ export function detectEntry(entry, ctx) { } case "system-lib": { const [bin, ...args] = d.probe.args; - const found = which(bin, ctx.env, ctx.platform); + const found = which(bin, ctx.env, ctx.platform, ctx.fs); if (!found) return { status: "missing", detail: `${bin} was not found on PATH to probe` }; if (new RegExp(d.probe.pattern).test(run(found, args).out)) return { status: "present", detail: found }; return { status: "missing", detail: `${bin} ${args.join(" ")} did not report ${d.probe.pattern}` }; @@ -564,6 +599,7 @@ export function main(argv, ctx = {}, out = {}) { env: ctx.env ?? process.env, platform: ctx.platform ?? process.platform, cwd: ctx.cwd ?? process.cwd(), + fs: ctx.fs ?? FS, stdin: ctx.stdin ?? (() => (process.stdin.isTTY ? "" : readFileSync(0, "utf8"))), }; const io = { stdout: out.stdout ?? ((s) => console.log(s)), stderr: out.stderr ?? ((s) => console.error(s)) }; diff --git a/lib/prerequisites.test.mjs b/lib/prerequisites.test.mjs index 2caa0473d0..ebeb7d2e42 100644 --- a/lib/prerequisites.test.mjs +++ b/lib/prerequisites.test.mjs @@ -15,7 +15,7 @@ import path from "node:path"; import process from "node:process"; import { after, describe, test } from "node:test"; import { fileURLToPath } from "node:url"; -import { compareVersions, KINDS, loadManifest, main, NEEDS, report, validateManifest } from "./prerequisites.mjs"; +import { compareVersions, KINDS, loadManifest, main, NEEDS, report, validateManifest, which } from "./prerequisites.mjs"; const here = path.dirname(fileURLToPath(import.meta.url)); const checker = path.join(here, "prerequisites.mjs"); @@ -145,6 +145,16 @@ describe("validateManifest", () => { /unknown key "name"/, ], ["a cli without any", { requires: [entry({ detect: { local_bin: ["bin/jq"] } })] }, /any is required/], + [ + "a reject_store_alias that is not a boolean", + { requires: [entry({ detect: { any: ["jq"], reject_store_alias: "yes" } })] }, + /reject_store_alias must be true or false/, + ], + [ + "a reject_store_alias on a kind that does not search PATH", + { requires: [entry({ kind: "env", detect: { name: "X", reject_store_alias: true } })] }, + /unknown key "reject_store_alias"/, + ], [ "an absolute local_bin", { requires: [entry({ detect: { any: ["jq"], local_bin: ["/usr/bin/jq"] } })] }, @@ -524,6 +534,127 @@ describe("probe option gate", { skip: !posix && "the stub tools are POSIX shell }); }); +// A Windows App Execution Alias cannot be created in a test, so these feed the +// checker an injected fs. files maps a path to "file" (a plain executable) or to +// { target } (an alias reparse point: stat fails with EACCES, lstat reports a +// link, readlink returns target, or fails when target is null). +function fakeFs(files) { + const fail = (code) => Object.assign(new Error(code), { code }); + return { + statSync(p) { + if (!(p in files)) throw fail("ENOENT"); + if (files[p] !== "file") throw fail("EACCES"); + return { isFile: () => true }; + }, + lstatSync(p) { + if (!(p in files)) throw fail("ENOENT"); + return { isSymbolicLink: () => files[p] !== "file" }; + }, + readlinkSync(p) { + if (!files[p]?.target) throw fail("EINVAL"); + return files[p].target; + }, + accessSync() {}, + }; +} + +describe("Windows App Execution Aliases", () => { + const apps = path.join(work, "Local", "Microsoft", "WindowsApps"); + const programs = path.join(work, "Programs", "Python"); + const pkg = "C:\\Program Files\\WindowsApps"; + const pwshAlias = { target: `${pkg}\\Microsoft.PowerShell_7.6.6.0_x64__8wekyb3d8bbwe\\pwsh.exe` }; + const wingetAlias = { target: `${pkg}\\Microsoft.DesktopAppInstaller_1.29.380.0_x64__8wekyb3d8bbwe\\winget.exe` }; + const pythonStub = { + target: `${pkg}\\Microsoft.DesktopAppInstaller_1.17.106910_x64__8wekyb3d8bbwe\\AppInstallerPythonRedirector.exe`, + }; + const winCtx = (files, PATH = apps) => ({ platform: "win32", env: { PATH, PATHEXT: ".EXE" }, fs: fakeFs(files) }); + const python = entry({ id: "python", kind: "runtime", detect: { any: ["python3", "python"] } }); + + test("a Store pwsh alias is found, and the probe stays quiet about it", () => { + const files = { [path.join(apps, "pwsh.EXE")]: pwshAlias }; + const pwsh = entry({ id: "pwsh", for: ["hook:powershell-format.sh"], detect: { any: ["pwsh"] } }); + const r = runMain(["probe", plugin([pwsh])], { + ...winCtx(files), + stdin: () => JSON.stringify({ session_id: "s1", hook_event_name: "SessionStart" }), + }); + assert.equal(r.code, 0); + assert.equal(r.out, ""); + assert.equal(which("pwsh", winCtx(files).env, "win32", fakeFs(files)), path.join(apps, "pwsh.EXE")); + }); + + test("an alias to something other than the Python stub is found, winget included", () => { + const files = { [path.join(apps, "winget.EXE")]: wingetAlias }; + const r = runMain(["check", plugin([entry({ id: "winget", detect: { any: ["winget"] } })])], winCtx(files)); + assert.equal(r.code, 0); + assert.match(r.out, /^PASS {2}winget \(cli, required\)/m); + }); + + test("the Python install stub stays missing", () => { + const files = { [path.join(apps, "python3.EXE")]: pythonStub, [path.join(apps, "python.EXE")]: pythonStub }; + const r = runMain(["check", plugin([python])], winCtx(files)); + assert.equal(r.code, 1); + assert.match(r.out, /^FAIL {2}python \(runtime, required\): python3 or python was not found on PATH/m); + }); + + test("a stub python3 ahead of a real python resolves to the real python", () => { + const real = path.join(programs, "python.EXE"); + const files = { [path.join(apps, "python3.EXE")]: pythonStub, [real]: "file" }; + const r = runMain(["check", plugin([python])], winCtx(files, [apps, programs].join(path.delimiter))); + assert.equal(r.code, 0); + assert.ok(r.out.includes(`PASS python (runtime, required): ${real}`), r.out); + }); + + test("an unreadable alias target falls back to rejecting the python and python3 names only", () => { + const files = { [path.join(apps, "python3.EXE")]: { target: null }, [path.join(apps, "pwsh.EXE")]: { target: null } }; + const ctx = winCtx(files); + assert.equal(which("python3", ctx.env, "win32", ctx.fs), null); + assert.equal(which("pwsh", ctx.env, "win32", ctx.fs), path.join(apps, "pwsh.EXE")); + }); + + test("a link outside a WindowsApps directory is not an alias", () => { + const elsewhere = path.join(work, "bin-links"); + const files = { [path.join(elsewhere, "pwsh.EXE")]: pwshAlias }; + const ctx = winCtx(files, elsewhere); + assert.equal(which("pwsh", ctx.env, "win32", ctx.fs), null); + }); + + test("only win32 reads an alias", () => { + const files = { [path.join(apps, "pwsh")]: pwshAlias }; + assert.equal(which("pwsh", { PATH: apps }, "linux", fakeFs(files)), null); + }); + + const storePython = { + target: `${pkg}\\PythonSoftwareFoundation.Python.3.13_3.13.2032.0_x64__qbz5n2kfra8p0\\python.exe`, + }; + const strictPython = entry({ + id: "python", + kind: "runtime", + detect: { any: ["python3", "python"], reject_store_alias: true }, + }); + + test("without reject_store_alias, a Store Python alias is found", () => { + const alias = path.join(apps, "python.EXE"); + const r = runMain(["check", plugin([python])], winCtx({ [alias]: storePython })); + assert.equal(r.code, 0); + assert.ok(r.out.includes(`PASS python (runtime, required): ${alias}`), r.out); + }); + + test("with reject_store_alias, a Store Python alias is skipped for a later real python", () => { + const real = path.join(programs, "python.EXE"); + const files = { [path.join(apps, "python3.EXE")]: storePython, [path.join(apps, "python.EXE")]: storePython, [real]: "file" }; + const r = runMain(["check", plugin([strictPython])], winCtx(files, [apps, programs].join(path.delimiter))); + assert.equal(r.code, 0); + assert.ok(r.out.includes(`PASS python (runtime, required): ${real}`), r.out); + }); + + test("with reject_store_alias and only a Store Python alias, python is missing", () => { + const files = { [path.join(apps, "python3.EXE")]: storePython, [path.join(apps, "python.EXE")]: storePython }; + const r = runMain(["check", plugin([strictPython])], winCtx(files)); + assert.equal(r.code, 1); + assert.match(r.out, /^FAIL {2}python \(runtime, required\): python3 or python was not found on PATH/m); + }); +}); + describe("report with no plugin roots", () => { test("prints an empty table and exits 0", () => { const lines = []; diff --git a/plugins/actionlint/.claude-plugin/plugin.json b/plugins/actionlint/.claude-plugin/plugin.json index 299710c0ae..00a29a8c84 100644 --- a/plugins/actionlint/.claude-plugin/plugin.json +++ b/plugins/actionlint/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "actionlint", - "version": "0.12.2", + "version": "0.12.3", "description": "Lint GitHub Actions workflow files on edit via actionlint, surfacing findings as advisory context.", "author": { "name": "Melodic Software", diff --git a/plugins/actionlint/CHANGELOG.md b/plugins/actionlint/CHANGELOG.md index 9e19e1bbc5..2cecd69efb 100644 --- a/plugins/actionlint/CHANGELOG.md +++ b/plugins/actionlint/CHANGELOG.md @@ -3,6 +3,14 @@ All notable changes to the `actionlint` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.12.3] - 2026-10-03 + +### Changed + +- **Shared `prerequisites.mjs` synced ([#6084](https://github.com/melodic-software/claude-code-plugins/issues/6084)); no change to this plugin's lib.** + The prerequisite check now counts a Windows App Execution Alias (a Store or winget install on PATH) as found, + except App Installer's Python install stub. A `cli` or `runtime` entry can set `reject_store_alias` to skip aliases instead; no entry in this plugin does. + ## [0.12.2] - 2026-10-03 ### Changed diff --git a/plugins/actionlint/lib/prerequisites.mjs b/plugins/actionlint/lib/prerequisites.mjs index 57f65a79b5..1e11abf2cc 100755 --- a/plugins/actionlint/lib/prerequisites.mjs +++ b/plugins/actionlint/lib/prerequisites.mjs @@ -28,8 +28,10 @@ import { accessSync, constants, existsSync, + lstatSync, mkdirSync, readFileSync, + readlinkSync, realpathSync, statSync, writeFileSync, @@ -42,8 +44,8 @@ export const KINDS = ["cli", "runtime", "system-lib", "python-pkg", "node-pkg", export const NEEDS = ["required", "optional"]; const ENTRY_KEYS = ["id", "kind", "need", "for", "detect", "degrade", "install", "check"]; const DETECT_KEYS = { - cli: { required: ["any"], optional: ["local_bin", "version"] }, - runtime: { required: ["any"], optional: ["local_bin", "version"] }, + cli: { required: ["any"], optional: ["local_bin", "version", "reject_store_alias"] }, + runtime: { required: ["any"], optional: ["local_bin", "version", "reject_store_alias"] }, "system-lib": { required: ["probe"], optional: [] }, "python-pkg": { required: ["any", "import"], optional: [] }, "node-pkg": { required: ["module", "paths"], optional: [] }, @@ -100,6 +102,9 @@ function detectErrors(kind, detect, where) { } if ("any" in detect && !isTextList(detect.any)) errors.push(`${where}.any must be a non-empty list of names`); if ("local_bin" in detect) errors.push(...relativePathErrors(detect.local_bin, `${where}.local_bin`)); + if ("reject_store_alias" in detect && typeof detect.reject_store_alias !== "boolean") { + errors.push(`${where}.reject_store_alias must be true or false`); + } if ("version" in detect) { const v = detect.version; const vw = `${where}.version`; @@ -243,36 +248,66 @@ export function compareVersions(a, b) { return 0; } -function isExecutable(file, platform) { +const FS = { statSync, lstatSync, readlinkSync, accessSync }; + +// The target of App Installer's Python "install me" stub, which opens the Store +// instead of running Python, and the alias names it takes. +const PYTHON_STUB_TARGET = "appinstallerpythonredirector.exe"; +const PYTHON_STUB_NAMES = new Set(["python", "python3"]); + +// A Windows App Execution Alias, how a Store or winget app reaches PATH, is a +// reparse point in a WindowsApps directory that stat cannot open, yet it runs. +// It counts as found unless it is the Python stub, known by its target or, when +// the target cannot be read, by its name. +const isWindowsApps = (dir) => path.basename(dir).toLowerCase() === "windowsapps"; + +function isAppAlias(file, fs) { + if (!isWindowsApps(path.dirname(file))) return false; try { - if (!statSync(file).isFile()) return false; - if (platform !== "win32") accessSync(file, constants.X_OK); - return true; + if (!fs.lstatSync(file).isSymbolicLink()) return false; } catch { return false; } + let target; + try { + target = fs.readlinkSync(file); + } catch { + return !PYTHON_STUB_NAMES.has(path.basename(file).toLowerCase().replace(/\.exe$/, "")); + } + return path.win32.basename(target).toLowerCase() !== PYTHON_STUB_TARGET; +} + +function isExecutable(file, platform, fs = FS) { + try { + if (!fs.statSync(file).isFile()) return false; + if (platform !== "win32") fs.accessSync(file, constants.X_OK); + return true; + } catch { + return platform === "win32" && isAppAlias(file, fs); + } } -// which(name) -> absolute path of the first PATH match, or null. -export function which(name, env = process.env, platform = process.platform) { +// which(name) -> absolute path of the first PATH match, or null. fs is injectable for tests. +// rejectStoreAlias skips WindowsApps directories on win32, so no App Execution Alias matches. +export function which(name, env = process.env, platform = process.platform, fs = FS, rejectStoreAlias = false) { const pathVar = env.PATH ?? env.Path ?? ""; const exts = platform === "win32" ? ["", ...(env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean)] : [""]; for (const dir of pathVar.split(path.delimiter)) { - if (!dir) continue; + if (!dir || (rejectStoreAlias && platform === "win32" && isWindowsApps(dir))) continue; for (const ext of exts) { const candidate = path.join(dir, name + ext); - if (isExecutable(candidate, platform)) return candidate; + if (isExecutable(candidate, platform, fs)) return candidate; } } return null; } -function findLocalBin(rels, cwd, platform) { +function findLocalBin(rels, cwd, platform, fs) { for (const rel of rels ?? []) { let dir = path.resolve(cwd); for (let i = 0; i < LOCAL_BIN_DEPTH; i++) { const candidate = path.join(dir, rel); - if (isExecutable(candidate, platform)) return candidate; + if (isExecutable(candidate, platform, fs)) return candidate; const parent = path.dirname(dir); if (parent === dir) break; dir = parent; @@ -286,9 +321,9 @@ function run(file, args) { return { ok: !r.error && r.status === 0, out: `${r.stdout ?? ""}${r.stderr ?? ""}` }; } -function firstOnPath(names, ctx) { +function firstOnPath(names, ctx, rejectStoreAlias = false) { for (const name of names) { - const found = which(name, ctx.env, ctx.platform); + const found = which(name, ctx.env, ctx.platform, ctx.fs, rejectStoreAlias); if (found) return found; } return null; @@ -314,7 +349,7 @@ export function detectEntry(entry, ctx) { switch (entry.kind) { case "cli": case "runtime": { - const found = firstOnPath(d.any, ctx) ?? findLocalBin(d.local_bin, ctx.cwd, ctx.platform); + const found = firstOnPath(d.any, ctx, d.reject_store_alias) ?? findLocalBin(d.local_bin, ctx.cwd, ctx.platform, ctx.fs); if (!found) { const where = d.local_bin ? `on PATH or as ${d.local_bin.join(", ")}` : "on PATH"; return { status: "missing", detail: `${d.any.join(" or ")} was not found ${where}` }; @@ -331,7 +366,7 @@ export function detectEntry(entry, ctx) { } case "system-lib": { const [bin, ...args] = d.probe.args; - const found = which(bin, ctx.env, ctx.platform); + const found = which(bin, ctx.env, ctx.platform, ctx.fs); if (!found) return { status: "missing", detail: `${bin} was not found on PATH to probe` }; if (new RegExp(d.probe.pattern).test(run(found, args).out)) return { status: "present", detail: found }; return { status: "missing", detail: `${bin} ${args.join(" ")} did not report ${d.probe.pattern}` }; @@ -566,6 +601,7 @@ export function main(argv, ctx = {}, out = {}) { env: ctx.env ?? process.env, platform: ctx.platform ?? process.platform, cwd: ctx.cwd ?? process.cwd(), + fs: ctx.fs ?? FS, stdin: ctx.stdin ?? (() => (process.stdin.isTTY ? "" : readFileSync(0, "utf8"))), }; const io = { stdout: out.stdout ?? ((s) => console.log(s)), stderr: out.stderr ?? ((s) => console.error(s)) }; diff --git a/plugins/ai-briefing/.claude-plugin/plugin.json b/plugins/ai-briefing/.claude-plugin/plugin.json index 6c7d97bac3..e949c9f201 100644 --- a/plugins/ai-briefing/.claude-plugin/plugin.json +++ b/plugins/ai-briefing/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "ai-briefing", - "version": "0.8.2", + "version": "0.8.3", "description": "Build source-backed AI-industry briefings from official vendor publications, configured RSS/Atom feeds, GitHub releases, reputable secondary reporting, and user-supplied URLs. Deduplicate, rank, and present results as markdown or optional HTML/PPTX decks, with repository-owned profile, audience, and brand configuration. Automated X/Twitter collection is disabled; Playwright is used only for deterministic local rendering.", "author": { "name": "Melodic Software", diff --git a/plugins/ai-briefing/CHANGELOG.md b/plugins/ai-briefing/CHANGELOG.md index f3ffca5084..47043d56c4 100644 --- a/plugins/ai-briefing/CHANGELOG.md +++ b/plugins/ai-briefing/CHANGELOG.md @@ -3,6 +3,14 @@ All notable changes to the `ai-briefing` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.8.3] - 2026-10-03 + +### Changed + +- **Shared `prerequisites.mjs` synced ([#6084](https://github.com/melodic-software/claude-code-plugins/issues/6084)); no change to this plugin's lib.** + The prerequisite check now counts a Windows App Execution Alias (a Store or winget install on PATH) as found, + except App Installer's Python install stub. A `cli` or `runtime` entry can set `reject_store_alias` to skip aliases instead; no entry in this plugin does. + ## [0.8.2] - 2026-10-03 ### Fixed diff --git a/plugins/ai-briefing/lib/prerequisites.mjs b/plugins/ai-briefing/lib/prerequisites.mjs index 57f65a79b5..1e11abf2cc 100755 --- a/plugins/ai-briefing/lib/prerequisites.mjs +++ b/plugins/ai-briefing/lib/prerequisites.mjs @@ -28,8 +28,10 @@ import { accessSync, constants, existsSync, + lstatSync, mkdirSync, readFileSync, + readlinkSync, realpathSync, statSync, writeFileSync, @@ -42,8 +44,8 @@ export const KINDS = ["cli", "runtime", "system-lib", "python-pkg", "node-pkg", export const NEEDS = ["required", "optional"]; const ENTRY_KEYS = ["id", "kind", "need", "for", "detect", "degrade", "install", "check"]; const DETECT_KEYS = { - cli: { required: ["any"], optional: ["local_bin", "version"] }, - runtime: { required: ["any"], optional: ["local_bin", "version"] }, + cli: { required: ["any"], optional: ["local_bin", "version", "reject_store_alias"] }, + runtime: { required: ["any"], optional: ["local_bin", "version", "reject_store_alias"] }, "system-lib": { required: ["probe"], optional: [] }, "python-pkg": { required: ["any", "import"], optional: [] }, "node-pkg": { required: ["module", "paths"], optional: [] }, @@ -100,6 +102,9 @@ function detectErrors(kind, detect, where) { } if ("any" in detect && !isTextList(detect.any)) errors.push(`${where}.any must be a non-empty list of names`); if ("local_bin" in detect) errors.push(...relativePathErrors(detect.local_bin, `${where}.local_bin`)); + if ("reject_store_alias" in detect && typeof detect.reject_store_alias !== "boolean") { + errors.push(`${where}.reject_store_alias must be true or false`); + } if ("version" in detect) { const v = detect.version; const vw = `${where}.version`; @@ -243,36 +248,66 @@ export function compareVersions(a, b) { return 0; } -function isExecutable(file, platform) { +const FS = { statSync, lstatSync, readlinkSync, accessSync }; + +// The target of App Installer's Python "install me" stub, which opens the Store +// instead of running Python, and the alias names it takes. +const PYTHON_STUB_TARGET = "appinstallerpythonredirector.exe"; +const PYTHON_STUB_NAMES = new Set(["python", "python3"]); + +// A Windows App Execution Alias, how a Store or winget app reaches PATH, is a +// reparse point in a WindowsApps directory that stat cannot open, yet it runs. +// It counts as found unless it is the Python stub, known by its target or, when +// the target cannot be read, by its name. +const isWindowsApps = (dir) => path.basename(dir).toLowerCase() === "windowsapps"; + +function isAppAlias(file, fs) { + if (!isWindowsApps(path.dirname(file))) return false; try { - if (!statSync(file).isFile()) return false; - if (platform !== "win32") accessSync(file, constants.X_OK); - return true; + if (!fs.lstatSync(file).isSymbolicLink()) return false; } catch { return false; } + let target; + try { + target = fs.readlinkSync(file); + } catch { + return !PYTHON_STUB_NAMES.has(path.basename(file).toLowerCase().replace(/\.exe$/, "")); + } + return path.win32.basename(target).toLowerCase() !== PYTHON_STUB_TARGET; +} + +function isExecutable(file, platform, fs = FS) { + try { + if (!fs.statSync(file).isFile()) return false; + if (platform !== "win32") fs.accessSync(file, constants.X_OK); + return true; + } catch { + return platform === "win32" && isAppAlias(file, fs); + } } -// which(name) -> absolute path of the first PATH match, or null. -export function which(name, env = process.env, platform = process.platform) { +// which(name) -> absolute path of the first PATH match, or null. fs is injectable for tests. +// rejectStoreAlias skips WindowsApps directories on win32, so no App Execution Alias matches. +export function which(name, env = process.env, platform = process.platform, fs = FS, rejectStoreAlias = false) { const pathVar = env.PATH ?? env.Path ?? ""; const exts = platform === "win32" ? ["", ...(env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean)] : [""]; for (const dir of pathVar.split(path.delimiter)) { - if (!dir) continue; + if (!dir || (rejectStoreAlias && platform === "win32" && isWindowsApps(dir))) continue; for (const ext of exts) { const candidate = path.join(dir, name + ext); - if (isExecutable(candidate, platform)) return candidate; + if (isExecutable(candidate, platform, fs)) return candidate; } } return null; } -function findLocalBin(rels, cwd, platform) { +function findLocalBin(rels, cwd, platform, fs) { for (const rel of rels ?? []) { let dir = path.resolve(cwd); for (let i = 0; i < LOCAL_BIN_DEPTH; i++) { const candidate = path.join(dir, rel); - if (isExecutable(candidate, platform)) return candidate; + if (isExecutable(candidate, platform, fs)) return candidate; const parent = path.dirname(dir); if (parent === dir) break; dir = parent; @@ -286,9 +321,9 @@ function run(file, args) { return { ok: !r.error && r.status === 0, out: `${r.stdout ?? ""}${r.stderr ?? ""}` }; } -function firstOnPath(names, ctx) { +function firstOnPath(names, ctx, rejectStoreAlias = false) { for (const name of names) { - const found = which(name, ctx.env, ctx.platform); + const found = which(name, ctx.env, ctx.platform, ctx.fs, rejectStoreAlias); if (found) return found; } return null; @@ -314,7 +349,7 @@ export function detectEntry(entry, ctx) { switch (entry.kind) { case "cli": case "runtime": { - const found = firstOnPath(d.any, ctx) ?? findLocalBin(d.local_bin, ctx.cwd, ctx.platform); + const found = firstOnPath(d.any, ctx, d.reject_store_alias) ?? findLocalBin(d.local_bin, ctx.cwd, ctx.platform, ctx.fs); if (!found) { const where = d.local_bin ? `on PATH or as ${d.local_bin.join(", ")}` : "on PATH"; return { status: "missing", detail: `${d.any.join(" or ")} was not found ${where}` }; @@ -331,7 +366,7 @@ export function detectEntry(entry, ctx) { } case "system-lib": { const [bin, ...args] = d.probe.args; - const found = which(bin, ctx.env, ctx.platform); + const found = which(bin, ctx.env, ctx.platform, ctx.fs); if (!found) return { status: "missing", detail: `${bin} was not found on PATH to probe` }; if (new RegExp(d.probe.pattern).test(run(found, args).out)) return { status: "present", detail: found }; return { status: "missing", detail: `${bin} ${args.join(" ")} did not report ${d.probe.pattern}` }; @@ -566,6 +601,7 @@ export function main(argv, ctx = {}, out = {}) { env: ctx.env ?? process.env, platform: ctx.platform ?? process.platform, cwd: ctx.cwd ?? process.cwd(), + fs: ctx.fs ?? FS, stdin: ctx.stdin ?? (() => (process.stdin.isTTY ? "" : readFileSync(0, "utf8"))), }; const io = { stdout: out.stdout ?? ((s) => console.log(s)), stderr: out.stderr ?? ((s) => console.error(s)) }; diff --git a/plugins/ai-slop/.claude-plugin/plugin.json b/plugins/ai-slop/.claude-plugin/plugin.json index 0f700cb99b..3759427213 100644 --- a/plugins/ai-slop/.claude-plugin/plugin.json +++ b/plugins/ai-slop/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "ai-slop", - "version": "0.13.2", + "version": "0.13.3", "description": "Detects and removes AI-writing tells (slop) in tracked markdown prose: em dashes, emoji formatting, AI vocabulary, negative parallelisms, chatbot phrases, filler, stacked hedging, citation artifacts, and model-era phrases, from a catalog based on Wikipedia's Signs of AI writing plus an evidence-graded model-vocabulary inventory. Read-only audit by default (deterministic detector plus judgment rubric); an explicit fix action rewrites findings behind a semantic-diff guard.", "author": { "name": "Melodic Software", diff --git a/plugins/ai-slop/CHANGELOG.md b/plugins/ai-slop/CHANGELOG.md index fec829f9b4..c5cbae151f 100644 --- a/plugins/ai-slop/CHANGELOG.md +++ b/plugins/ai-slop/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## [0.13.3] - 2026-10-03 + +### Changed + +- **Shared `prerequisites.mjs` synced ([#6084](https://github.com/melodic-software/claude-code-plugins/issues/6084)); no change to this plugin's lib.** + The prerequisite check now counts a Windows App Execution Alias (a Store or winget install on PATH) as found, + except App Installer's Python install stub. A `cli` or `runtime` entry can set `reject_store_alias` to skip aliases instead; no entry in this plugin does. + ## [0.13.2] - 2026-10-03 ### Changed diff --git a/plugins/ai-slop/lib/prerequisites.mjs b/plugins/ai-slop/lib/prerequisites.mjs index 57f65a79b5..1e11abf2cc 100755 --- a/plugins/ai-slop/lib/prerequisites.mjs +++ b/plugins/ai-slop/lib/prerequisites.mjs @@ -28,8 +28,10 @@ import { accessSync, constants, existsSync, + lstatSync, mkdirSync, readFileSync, + readlinkSync, realpathSync, statSync, writeFileSync, @@ -42,8 +44,8 @@ export const KINDS = ["cli", "runtime", "system-lib", "python-pkg", "node-pkg", export const NEEDS = ["required", "optional"]; const ENTRY_KEYS = ["id", "kind", "need", "for", "detect", "degrade", "install", "check"]; const DETECT_KEYS = { - cli: { required: ["any"], optional: ["local_bin", "version"] }, - runtime: { required: ["any"], optional: ["local_bin", "version"] }, + cli: { required: ["any"], optional: ["local_bin", "version", "reject_store_alias"] }, + runtime: { required: ["any"], optional: ["local_bin", "version", "reject_store_alias"] }, "system-lib": { required: ["probe"], optional: [] }, "python-pkg": { required: ["any", "import"], optional: [] }, "node-pkg": { required: ["module", "paths"], optional: [] }, @@ -100,6 +102,9 @@ function detectErrors(kind, detect, where) { } if ("any" in detect && !isTextList(detect.any)) errors.push(`${where}.any must be a non-empty list of names`); if ("local_bin" in detect) errors.push(...relativePathErrors(detect.local_bin, `${where}.local_bin`)); + if ("reject_store_alias" in detect && typeof detect.reject_store_alias !== "boolean") { + errors.push(`${where}.reject_store_alias must be true or false`); + } if ("version" in detect) { const v = detect.version; const vw = `${where}.version`; @@ -243,36 +248,66 @@ export function compareVersions(a, b) { return 0; } -function isExecutable(file, platform) { +const FS = { statSync, lstatSync, readlinkSync, accessSync }; + +// The target of App Installer's Python "install me" stub, which opens the Store +// instead of running Python, and the alias names it takes. +const PYTHON_STUB_TARGET = "appinstallerpythonredirector.exe"; +const PYTHON_STUB_NAMES = new Set(["python", "python3"]); + +// A Windows App Execution Alias, how a Store or winget app reaches PATH, is a +// reparse point in a WindowsApps directory that stat cannot open, yet it runs. +// It counts as found unless it is the Python stub, known by its target or, when +// the target cannot be read, by its name. +const isWindowsApps = (dir) => path.basename(dir).toLowerCase() === "windowsapps"; + +function isAppAlias(file, fs) { + if (!isWindowsApps(path.dirname(file))) return false; try { - if (!statSync(file).isFile()) return false; - if (platform !== "win32") accessSync(file, constants.X_OK); - return true; + if (!fs.lstatSync(file).isSymbolicLink()) return false; } catch { return false; } + let target; + try { + target = fs.readlinkSync(file); + } catch { + return !PYTHON_STUB_NAMES.has(path.basename(file).toLowerCase().replace(/\.exe$/, "")); + } + return path.win32.basename(target).toLowerCase() !== PYTHON_STUB_TARGET; +} + +function isExecutable(file, platform, fs = FS) { + try { + if (!fs.statSync(file).isFile()) return false; + if (platform !== "win32") fs.accessSync(file, constants.X_OK); + return true; + } catch { + return platform === "win32" && isAppAlias(file, fs); + } } -// which(name) -> absolute path of the first PATH match, or null. -export function which(name, env = process.env, platform = process.platform) { +// which(name) -> absolute path of the first PATH match, or null. fs is injectable for tests. +// rejectStoreAlias skips WindowsApps directories on win32, so no App Execution Alias matches. +export function which(name, env = process.env, platform = process.platform, fs = FS, rejectStoreAlias = false) { const pathVar = env.PATH ?? env.Path ?? ""; const exts = platform === "win32" ? ["", ...(env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean)] : [""]; for (const dir of pathVar.split(path.delimiter)) { - if (!dir) continue; + if (!dir || (rejectStoreAlias && platform === "win32" && isWindowsApps(dir))) continue; for (const ext of exts) { const candidate = path.join(dir, name + ext); - if (isExecutable(candidate, platform)) return candidate; + if (isExecutable(candidate, platform, fs)) return candidate; } } return null; } -function findLocalBin(rels, cwd, platform) { +function findLocalBin(rels, cwd, platform, fs) { for (const rel of rels ?? []) { let dir = path.resolve(cwd); for (let i = 0; i < LOCAL_BIN_DEPTH; i++) { const candidate = path.join(dir, rel); - if (isExecutable(candidate, platform)) return candidate; + if (isExecutable(candidate, platform, fs)) return candidate; const parent = path.dirname(dir); if (parent === dir) break; dir = parent; @@ -286,9 +321,9 @@ function run(file, args) { return { ok: !r.error && r.status === 0, out: `${r.stdout ?? ""}${r.stderr ?? ""}` }; } -function firstOnPath(names, ctx) { +function firstOnPath(names, ctx, rejectStoreAlias = false) { for (const name of names) { - const found = which(name, ctx.env, ctx.platform); + const found = which(name, ctx.env, ctx.platform, ctx.fs, rejectStoreAlias); if (found) return found; } return null; @@ -314,7 +349,7 @@ export function detectEntry(entry, ctx) { switch (entry.kind) { case "cli": case "runtime": { - const found = firstOnPath(d.any, ctx) ?? findLocalBin(d.local_bin, ctx.cwd, ctx.platform); + const found = firstOnPath(d.any, ctx, d.reject_store_alias) ?? findLocalBin(d.local_bin, ctx.cwd, ctx.platform, ctx.fs); if (!found) { const where = d.local_bin ? `on PATH or as ${d.local_bin.join(", ")}` : "on PATH"; return { status: "missing", detail: `${d.any.join(" or ")} was not found ${where}` }; @@ -331,7 +366,7 @@ export function detectEntry(entry, ctx) { } case "system-lib": { const [bin, ...args] = d.probe.args; - const found = which(bin, ctx.env, ctx.platform); + const found = which(bin, ctx.env, ctx.platform, ctx.fs); if (!found) return { status: "missing", detail: `${bin} was not found on PATH to probe` }; if (new RegExp(d.probe.pattern).test(run(found, args).out)) return { status: "present", detail: found }; return { status: "missing", detail: `${bin} ${args.join(" ")} did not report ${d.probe.pattern}` }; @@ -566,6 +601,7 @@ export function main(argv, ctx = {}, out = {}) { env: ctx.env ?? process.env, platform: ctx.platform ?? process.platform, cwd: ctx.cwd ?? process.cwd(), + fs: ctx.fs ?? FS, stdin: ctx.stdin ?? (() => (process.stdin.isTTY ? "" : readFileSync(0, "utf8"))), }; const io = { stdout: out.stdout ?? ((s) => console.log(s)), stderr: out.stderr ?? ((s) => console.error(s)) }; diff --git a/plugins/animation/.claude-plugin/plugin.json b/plugins/animation/.claude-plugin/plugin.json index 8fd92b4a6b..b0321de444 100644 --- a/plugins/animation/.claude-plugin/plugin.json +++ b/plugins/animation/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "animation", - "version": "0.4.2", + "version": "0.4.3", "description": "Hand-drawn-style 2D animation as code, rendered in headless Chromium by an ink brush engine. rotoscope copies a reference clip drawing by drawing: trace to vector paths, render, measure against the source (XOR, SSIM), and fit per-shot brush overrides. learn-style measures a clip into a style pack (ships woodcut-ink) and checks films against it. produce turns a brief into approved boards, a shot list, frames, and a delivered file. setup checks ffmpeg, Node, Chromium, and Python.", "author": { "name": "Melodic Software", diff --git a/plugins/animation/CHANGELOG.md b/plugins/animation/CHANGELOG.md index 0ec53458cf..52216c62ac 100644 --- a/plugins/animation/CHANGELOG.md +++ b/plugins/animation/CHANGELOG.md @@ -3,6 +3,14 @@ All notable changes to the `animation` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.4.3] - 2026-10-03 + +### Changed + +- **Shared `prerequisites.mjs` synced ([#6084](https://github.com/melodic-software/claude-code-plugins/issues/6084)); no change to this plugin's lib.** + The prerequisite check now counts a Windows App Execution Alias (a Store or winget install on PATH) as found, + except App Installer's Python install stub. A `cli` or `runtime` entry can set `reject_store_alias` to skip aliases instead; no entry in this plugin does. + ## [0.4.2] - 2026-10-03 ### Changed diff --git a/plugins/animation/lib/prerequisites.mjs b/plugins/animation/lib/prerequisites.mjs index 57f65a79b5..1e11abf2cc 100755 --- a/plugins/animation/lib/prerequisites.mjs +++ b/plugins/animation/lib/prerequisites.mjs @@ -28,8 +28,10 @@ import { accessSync, constants, existsSync, + lstatSync, mkdirSync, readFileSync, + readlinkSync, realpathSync, statSync, writeFileSync, @@ -42,8 +44,8 @@ export const KINDS = ["cli", "runtime", "system-lib", "python-pkg", "node-pkg", export const NEEDS = ["required", "optional"]; const ENTRY_KEYS = ["id", "kind", "need", "for", "detect", "degrade", "install", "check"]; const DETECT_KEYS = { - cli: { required: ["any"], optional: ["local_bin", "version"] }, - runtime: { required: ["any"], optional: ["local_bin", "version"] }, + cli: { required: ["any"], optional: ["local_bin", "version", "reject_store_alias"] }, + runtime: { required: ["any"], optional: ["local_bin", "version", "reject_store_alias"] }, "system-lib": { required: ["probe"], optional: [] }, "python-pkg": { required: ["any", "import"], optional: [] }, "node-pkg": { required: ["module", "paths"], optional: [] }, @@ -100,6 +102,9 @@ function detectErrors(kind, detect, where) { } if ("any" in detect && !isTextList(detect.any)) errors.push(`${where}.any must be a non-empty list of names`); if ("local_bin" in detect) errors.push(...relativePathErrors(detect.local_bin, `${where}.local_bin`)); + if ("reject_store_alias" in detect && typeof detect.reject_store_alias !== "boolean") { + errors.push(`${where}.reject_store_alias must be true or false`); + } if ("version" in detect) { const v = detect.version; const vw = `${where}.version`; @@ -243,36 +248,66 @@ export function compareVersions(a, b) { return 0; } -function isExecutable(file, platform) { +const FS = { statSync, lstatSync, readlinkSync, accessSync }; + +// The target of App Installer's Python "install me" stub, which opens the Store +// instead of running Python, and the alias names it takes. +const PYTHON_STUB_TARGET = "appinstallerpythonredirector.exe"; +const PYTHON_STUB_NAMES = new Set(["python", "python3"]); + +// A Windows App Execution Alias, how a Store or winget app reaches PATH, is a +// reparse point in a WindowsApps directory that stat cannot open, yet it runs. +// It counts as found unless it is the Python stub, known by its target or, when +// the target cannot be read, by its name. +const isWindowsApps = (dir) => path.basename(dir).toLowerCase() === "windowsapps"; + +function isAppAlias(file, fs) { + if (!isWindowsApps(path.dirname(file))) return false; try { - if (!statSync(file).isFile()) return false; - if (platform !== "win32") accessSync(file, constants.X_OK); - return true; + if (!fs.lstatSync(file).isSymbolicLink()) return false; } catch { return false; } + let target; + try { + target = fs.readlinkSync(file); + } catch { + return !PYTHON_STUB_NAMES.has(path.basename(file).toLowerCase().replace(/\.exe$/, "")); + } + return path.win32.basename(target).toLowerCase() !== PYTHON_STUB_TARGET; +} + +function isExecutable(file, platform, fs = FS) { + try { + if (!fs.statSync(file).isFile()) return false; + if (platform !== "win32") fs.accessSync(file, constants.X_OK); + return true; + } catch { + return platform === "win32" && isAppAlias(file, fs); + } } -// which(name) -> absolute path of the first PATH match, or null. -export function which(name, env = process.env, platform = process.platform) { +// which(name) -> absolute path of the first PATH match, or null. fs is injectable for tests. +// rejectStoreAlias skips WindowsApps directories on win32, so no App Execution Alias matches. +export function which(name, env = process.env, platform = process.platform, fs = FS, rejectStoreAlias = false) { const pathVar = env.PATH ?? env.Path ?? ""; const exts = platform === "win32" ? ["", ...(env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean)] : [""]; for (const dir of pathVar.split(path.delimiter)) { - if (!dir) continue; + if (!dir || (rejectStoreAlias && platform === "win32" && isWindowsApps(dir))) continue; for (const ext of exts) { const candidate = path.join(dir, name + ext); - if (isExecutable(candidate, platform)) return candidate; + if (isExecutable(candidate, platform, fs)) return candidate; } } return null; } -function findLocalBin(rels, cwd, platform) { +function findLocalBin(rels, cwd, platform, fs) { for (const rel of rels ?? []) { let dir = path.resolve(cwd); for (let i = 0; i < LOCAL_BIN_DEPTH; i++) { const candidate = path.join(dir, rel); - if (isExecutable(candidate, platform)) return candidate; + if (isExecutable(candidate, platform, fs)) return candidate; const parent = path.dirname(dir); if (parent === dir) break; dir = parent; @@ -286,9 +321,9 @@ function run(file, args) { return { ok: !r.error && r.status === 0, out: `${r.stdout ?? ""}${r.stderr ?? ""}` }; } -function firstOnPath(names, ctx) { +function firstOnPath(names, ctx, rejectStoreAlias = false) { for (const name of names) { - const found = which(name, ctx.env, ctx.platform); + const found = which(name, ctx.env, ctx.platform, ctx.fs, rejectStoreAlias); if (found) return found; } return null; @@ -314,7 +349,7 @@ export function detectEntry(entry, ctx) { switch (entry.kind) { case "cli": case "runtime": { - const found = firstOnPath(d.any, ctx) ?? findLocalBin(d.local_bin, ctx.cwd, ctx.platform); + const found = firstOnPath(d.any, ctx, d.reject_store_alias) ?? findLocalBin(d.local_bin, ctx.cwd, ctx.platform, ctx.fs); if (!found) { const where = d.local_bin ? `on PATH or as ${d.local_bin.join(", ")}` : "on PATH"; return { status: "missing", detail: `${d.any.join(" or ")} was not found ${where}` }; @@ -331,7 +366,7 @@ export function detectEntry(entry, ctx) { } case "system-lib": { const [bin, ...args] = d.probe.args; - const found = which(bin, ctx.env, ctx.platform); + const found = which(bin, ctx.env, ctx.platform, ctx.fs); if (!found) return { status: "missing", detail: `${bin} was not found on PATH to probe` }; if (new RegExp(d.probe.pattern).test(run(found, args).out)) return { status: "present", detail: found }; return { status: "missing", detail: `${bin} ${args.join(" ")} did not report ${d.probe.pattern}` }; @@ -566,6 +601,7 @@ export function main(argv, ctx = {}, out = {}) { env: ctx.env ?? process.env, platform: ctx.platform ?? process.platform, cwd: ctx.cwd ?? process.cwd(), + fs: ctx.fs ?? FS, stdin: ctx.stdin ?? (() => (process.stdin.isTTY ? "" : readFileSync(0, "utf8"))), }; const io = { stdout: out.stdout ?? ((s) => console.log(s)), stderr: out.stderr ?? ((s) => console.error(s)) }; diff --git a/plugins/architecture/.claude-plugin/plugin.json b/plugins/architecture/.claude-plugin/plugin.json index a0216a0db3..935ec883f4 100644 --- a/plugins/architecture/.claude-plugin/plugin.json +++ b/plugins/architecture/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "architecture", - "version": "0.21.1", + "version": "0.21.2", "description": "improve uses Ousterhout's deep-module lens to find shallow modules, seam leaks, and locality gaps, reports them as HTML, and interviews the chosen one. map-landscape charts a C4 system landscape and portfolio table with drift checks. map-dependencies, map-components, map-containers, map-context, map-flow, map-events, map-data, and map-deployment draw dependency, C4, sequence, event, data, and deployment views from committed files. record-decision writes an ADR in the repo's convention.", "author": { "name": "Melodic Software", diff --git a/plugins/architecture/CHANGELOG.md b/plugins/architecture/CHANGELOG.md index 9d52f90cd4..afa60105b1 100644 --- a/plugins/architecture/CHANGELOG.md +++ b/plugins/architecture/CHANGELOG.md @@ -3,6 +3,14 @@ All notable changes to the `architecture` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.21.2] - 2026-10-03 + +### Changed + +- **Shared `prerequisites.mjs` synced ([#6084](https://github.com/melodic-software/claude-code-plugins/issues/6084)); no change to this plugin's lib.** + The prerequisite check now counts a Windows App Execution Alias (a Store or winget install on PATH) as found, + except App Installer's Python install stub. A `cli` or `runtime` entry can set `reject_store_alias` to skip aliases instead; no entry in this plugin does. + ## [0.21.1] - 2026-10-03 ### Changed diff --git a/plugins/architecture/lib/prerequisites.mjs b/plugins/architecture/lib/prerequisites.mjs index 57f65a79b5..1e11abf2cc 100755 --- a/plugins/architecture/lib/prerequisites.mjs +++ b/plugins/architecture/lib/prerequisites.mjs @@ -28,8 +28,10 @@ import { accessSync, constants, existsSync, + lstatSync, mkdirSync, readFileSync, + readlinkSync, realpathSync, statSync, writeFileSync, @@ -42,8 +44,8 @@ export const KINDS = ["cli", "runtime", "system-lib", "python-pkg", "node-pkg", export const NEEDS = ["required", "optional"]; const ENTRY_KEYS = ["id", "kind", "need", "for", "detect", "degrade", "install", "check"]; const DETECT_KEYS = { - cli: { required: ["any"], optional: ["local_bin", "version"] }, - runtime: { required: ["any"], optional: ["local_bin", "version"] }, + cli: { required: ["any"], optional: ["local_bin", "version", "reject_store_alias"] }, + runtime: { required: ["any"], optional: ["local_bin", "version", "reject_store_alias"] }, "system-lib": { required: ["probe"], optional: [] }, "python-pkg": { required: ["any", "import"], optional: [] }, "node-pkg": { required: ["module", "paths"], optional: [] }, @@ -100,6 +102,9 @@ function detectErrors(kind, detect, where) { } if ("any" in detect && !isTextList(detect.any)) errors.push(`${where}.any must be a non-empty list of names`); if ("local_bin" in detect) errors.push(...relativePathErrors(detect.local_bin, `${where}.local_bin`)); + if ("reject_store_alias" in detect && typeof detect.reject_store_alias !== "boolean") { + errors.push(`${where}.reject_store_alias must be true or false`); + } if ("version" in detect) { const v = detect.version; const vw = `${where}.version`; @@ -243,36 +248,66 @@ export function compareVersions(a, b) { return 0; } -function isExecutable(file, platform) { +const FS = { statSync, lstatSync, readlinkSync, accessSync }; + +// The target of App Installer's Python "install me" stub, which opens the Store +// instead of running Python, and the alias names it takes. +const PYTHON_STUB_TARGET = "appinstallerpythonredirector.exe"; +const PYTHON_STUB_NAMES = new Set(["python", "python3"]); + +// A Windows App Execution Alias, how a Store or winget app reaches PATH, is a +// reparse point in a WindowsApps directory that stat cannot open, yet it runs. +// It counts as found unless it is the Python stub, known by its target or, when +// the target cannot be read, by its name. +const isWindowsApps = (dir) => path.basename(dir).toLowerCase() === "windowsapps"; + +function isAppAlias(file, fs) { + if (!isWindowsApps(path.dirname(file))) return false; try { - if (!statSync(file).isFile()) return false; - if (platform !== "win32") accessSync(file, constants.X_OK); - return true; + if (!fs.lstatSync(file).isSymbolicLink()) return false; } catch { return false; } + let target; + try { + target = fs.readlinkSync(file); + } catch { + return !PYTHON_STUB_NAMES.has(path.basename(file).toLowerCase().replace(/\.exe$/, "")); + } + return path.win32.basename(target).toLowerCase() !== PYTHON_STUB_TARGET; +} + +function isExecutable(file, platform, fs = FS) { + try { + if (!fs.statSync(file).isFile()) return false; + if (platform !== "win32") fs.accessSync(file, constants.X_OK); + return true; + } catch { + return platform === "win32" && isAppAlias(file, fs); + } } -// which(name) -> absolute path of the first PATH match, or null. -export function which(name, env = process.env, platform = process.platform) { +// which(name) -> absolute path of the first PATH match, or null. fs is injectable for tests. +// rejectStoreAlias skips WindowsApps directories on win32, so no App Execution Alias matches. +export function which(name, env = process.env, platform = process.platform, fs = FS, rejectStoreAlias = false) { const pathVar = env.PATH ?? env.Path ?? ""; const exts = platform === "win32" ? ["", ...(env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean)] : [""]; for (const dir of pathVar.split(path.delimiter)) { - if (!dir) continue; + if (!dir || (rejectStoreAlias && platform === "win32" && isWindowsApps(dir))) continue; for (const ext of exts) { const candidate = path.join(dir, name + ext); - if (isExecutable(candidate, platform)) return candidate; + if (isExecutable(candidate, platform, fs)) return candidate; } } return null; } -function findLocalBin(rels, cwd, platform) { +function findLocalBin(rels, cwd, platform, fs) { for (const rel of rels ?? []) { let dir = path.resolve(cwd); for (let i = 0; i < LOCAL_BIN_DEPTH; i++) { const candidate = path.join(dir, rel); - if (isExecutable(candidate, platform)) return candidate; + if (isExecutable(candidate, platform, fs)) return candidate; const parent = path.dirname(dir); if (parent === dir) break; dir = parent; @@ -286,9 +321,9 @@ function run(file, args) { return { ok: !r.error && r.status === 0, out: `${r.stdout ?? ""}${r.stderr ?? ""}` }; } -function firstOnPath(names, ctx) { +function firstOnPath(names, ctx, rejectStoreAlias = false) { for (const name of names) { - const found = which(name, ctx.env, ctx.platform); + const found = which(name, ctx.env, ctx.platform, ctx.fs, rejectStoreAlias); if (found) return found; } return null; @@ -314,7 +349,7 @@ export function detectEntry(entry, ctx) { switch (entry.kind) { case "cli": case "runtime": { - const found = firstOnPath(d.any, ctx) ?? findLocalBin(d.local_bin, ctx.cwd, ctx.platform); + const found = firstOnPath(d.any, ctx, d.reject_store_alias) ?? findLocalBin(d.local_bin, ctx.cwd, ctx.platform, ctx.fs); if (!found) { const where = d.local_bin ? `on PATH or as ${d.local_bin.join(", ")}` : "on PATH"; return { status: "missing", detail: `${d.any.join(" or ")} was not found ${where}` }; @@ -331,7 +366,7 @@ export function detectEntry(entry, ctx) { } case "system-lib": { const [bin, ...args] = d.probe.args; - const found = which(bin, ctx.env, ctx.platform); + const found = which(bin, ctx.env, ctx.platform, ctx.fs); if (!found) return { status: "missing", detail: `${bin} was not found on PATH to probe` }; if (new RegExp(d.probe.pattern).test(run(found, args).out)) return { status: "present", detail: found }; return { status: "missing", detail: `${bin} ${args.join(" ")} did not report ${d.probe.pattern}` }; @@ -566,6 +601,7 @@ export function main(argv, ctx = {}, out = {}) { env: ctx.env ?? process.env, platform: ctx.platform ?? process.platform, cwd: ctx.cwd ?? process.cwd(), + fs: ctx.fs ?? FS, stdin: ctx.stdin ?? (() => (process.stdin.isTTY ? "" : readFileSync(0, "utf8"))), }; const io = { stdout: out.stdout ?? ((s) => console.log(s)), stderr: out.stderr ?? ((s) => console.error(s)) }; diff --git a/plugins/attribution/.claude-plugin/plugin.json b/plugins/attribution/.claude-plugin/plugin.json index 3a90c23470..168b0993ae 100644 --- a/plugins/attribution/.claude-plugin/plugin.json +++ b/plugins/attribution/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "attribution", - "version": "0.10.2", + "version": "0.10.3", "description": "Finds prose in tracked markdown that restates content an external source owns (vendor docs, blogs, articles) without attribution, confirms the source, and converts the copy into a pointer, a citation, or a dated stamped record. Documentation attribution, not software supply chain. Scripts do only mechanical work; judgment is LLM work. Read-only audit by default; explicit fix and sweep actions apply changes behind a semantic-diff guard and live pointer checks.", "author": { "name": "Melodic Software", diff --git a/plugins/attribution/CHANGELOG.md b/plugins/attribution/CHANGELOG.md index 4028cb0a9f..6d690e5382 100644 --- a/plugins/attribution/CHANGELOG.md +++ b/plugins/attribution/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## [0.10.3] - 2026-10-03 + +### Changed + +- **Shared `prerequisites.mjs` synced ([#6084](https://github.com/melodic-software/claude-code-plugins/issues/6084)); no change to this plugin's lib.** + The prerequisite check now counts a Windows App Execution Alias (a Store or winget install on PATH) as found, + except App Installer's Python install stub. A `cli` or `runtime` entry can set `reject_store_alias` to skip aliases instead; no entry in this plugin does. + ## [0.10.2] - 2026-10-03 ### Changed diff --git a/plugins/attribution/lib/prerequisites.mjs b/plugins/attribution/lib/prerequisites.mjs index 57f65a79b5..1e11abf2cc 100755 --- a/plugins/attribution/lib/prerequisites.mjs +++ b/plugins/attribution/lib/prerequisites.mjs @@ -28,8 +28,10 @@ import { accessSync, constants, existsSync, + lstatSync, mkdirSync, readFileSync, + readlinkSync, realpathSync, statSync, writeFileSync, @@ -42,8 +44,8 @@ export const KINDS = ["cli", "runtime", "system-lib", "python-pkg", "node-pkg", export const NEEDS = ["required", "optional"]; const ENTRY_KEYS = ["id", "kind", "need", "for", "detect", "degrade", "install", "check"]; const DETECT_KEYS = { - cli: { required: ["any"], optional: ["local_bin", "version"] }, - runtime: { required: ["any"], optional: ["local_bin", "version"] }, + cli: { required: ["any"], optional: ["local_bin", "version", "reject_store_alias"] }, + runtime: { required: ["any"], optional: ["local_bin", "version", "reject_store_alias"] }, "system-lib": { required: ["probe"], optional: [] }, "python-pkg": { required: ["any", "import"], optional: [] }, "node-pkg": { required: ["module", "paths"], optional: [] }, @@ -100,6 +102,9 @@ function detectErrors(kind, detect, where) { } if ("any" in detect && !isTextList(detect.any)) errors.push(`${where}.any must be a non-empty list of names`); if ("local_bin" in detect) errors.push(...relativePathErrors(detect.local_bin, `${where}.local_bin`)); + if ("reject_store_alias" in detect && typeof detect.reject_store_alias !== "boolean") { + errors.push(`${where}.reject_store_alias must be true or false`); + } if ("version" in detect) { const v = detect.version; const vw = `${where}.version`; @@ -243,36 +248,66 @@ export function compareVersions(a, b) { return 0; } -function isExecutable(file, platform) { +const FS = { statSync, lstatSync, readlinkSync, accessSync }; + +// The target of App Installer's Python "install me" stub, which opens the Store +// instead of running Python, and the alias names it takes. +const PYTHON_STUB_TARGET = "appinstallerpythonredirector.exe"; +const PYTHON_STUB_NAMES = new Set(["python", "python3"]); + +// A Windows App Execution Alias, how a Store or winget app reaches PATH, is a +// reparse point in a WindowsApps directory that stat cannot open, yet it runs. +// It counts as found unless it is the Python stub, known by its target or, when +// the target cannot be read, by its name. +const isWindowsApps = (dir) => path.basename(dir).toLowerCase() === "windowsapps"; + +function isAppAlias(file, fs) { + if (!isWindowsApps(path.dirname(file))) return false; try { - if (!statSync(file).isFile()) return false; - if (platform !== "win32") accessSync(file, constants.X_OK); - return true; + if (!fs.lstatSync(file).isSymbolicLink()) return false; } catch { return false; } + let target; + try { + target = fs.readlinkSync(file); + } catch { + return !PYTHON_STUB_NAMES.has(path.basename(file).toLowerCase().replace(/\.exe$/, "")); + } + return path.win32.basename(target).toLowerCase() !== PYTHON_STUB_TARGET; +} + +function isExecutable(file, platform, fs = FS) { + try { + if (!fs.statSync(file).isFile()) return false; + if (platform !== "win32") fs.accessSync(file, constants.X_OK); + return true; + } catch { + return platform === "win32" && isAppAlias(file, fs); + } } -// which(name) -> absolute path of the first PATH match, or null. -export function which(name, env = process.env, platform = process.platform) { +// which(name) -> absolute path of the first PATH match, or null. fs is injectable for tests. +// rejectStoreAlias skips WindowsApps directories on win32, so no App Execution Alias matches. +export function which(name, env = process.env, platform = process.platform, fs = FS, rejectStoreAlias = false) { const pathVar = env.PATH ?? env.Path ?? ""; const exts = platform === "win32" ? ["", ...(env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean)] : [""]; for (const dir of pathVar.split(path.delimiter)) { - if (!dir) continue; + if (!dir || (rejectStoreAlias && platform === "win32" && isWindowsApps(dir))) continue; for (const ext of exts) { const candidate = path.join(dir, name + ext); - if (isExecutable(candidate, platform)) return candidate; + if (isExecutable(candidate, platform, fs)) return candidate; } } return null; } -function findLocalBin(rels, cwd, platform) { +function findLocalBin(rels, cwd, platform, fs) { for (const rel of rels ?? []) { let dir = path.resolve(cwd); for (let i = 0; i < LOCAL_BIN_DEPTH; i++) { const candidate = path.join(dir, rel); - if (isExecutable(candidate, platform)) return candidate; + if (isExecutable(candidate, platform, fs)) return candidate; const parent = path.dirname(dir); if (parent === dir) break; dir = parent; @@ -286,9 +321,9 @@ function run(file, args) { return { ok: !r.error && r.status === 0, out: `${r.stdout ?? ""}${r.stderr ?? ""}` }; } -function firstOnPath(names, ctx) { +function firstOnPath(names, ctx, rejectStoreAlias = false) { for (const name of names) { - const found = which(name, ctx.env, ctx.platform); + const found = which(name, ctx.env, ctx.platform, ctx.fs, rejectStoreAlias); if (found) return found; } return null; @@ -314,7 +349,7 @@ export function detectEntry(entry, ctx) { switch (entry.kind) { case "cli": case "runtime": { - const found = firstOnPath(d.any, ctx) ?? findLocalBin(d.local_bin, ctx.cwd, ctx.platform); + const found = firstOnPath(d.any, ctx, d.reject_store_alias) ?? findLocalBin(d.local_bin, ctx.cwd, ctx.platform, ctx.fs); if (!found) { const where = d.local_bin ? `on PATH or as ${d.local_bin.join(", ")}` : "on PATH"; return { status: "missing", detail: `${d.any.join(" or ")} was not found ${where}` }; @@ -331,7 +366,7 @@ export function detectEntry(entry, ctx) { } case "system-lib": { const [bin, ...args] = d.probe.args; - const found = which(bin, ctx.env, ctx.platform); + const found = which(bin, ctx.env, ctx.platform, ctx.fs); if (!found) return { status: "missing", detail: `${bin} was not found on PATH to probe` }; if (new RegExp(d.probe.pattern).test(run(found, args).out)) return { status: "present", detail: found }; return { status: "missing", detail: `${bin} ${args.join(" ")} did not report ${d.probe.pattern}` }; @@ -566,6 +601,7 @@ export function main(argv, ctx = {}, out = {}) { env: ctx.env ?? process.env, platform: ctx.platform ?? process.platform, cwd: ctx.cwd ?? process.cwd(), + fs: ctx.fs ?? FS, stdin: ctx.stdin ?? (() => (process.stdin.isTTY ? "" : readFileSync(0, "utf8"))), }; const io = { stdout: out.stdout ?? ((s) => console.log(s)), stderr: out.stderr ?? ((s) => console.error(s)) }; diff --git a/plugins/autonomy/.claude-plugin/plugin.json b/plugins/autonomy/.claude-plugin/plugin.json index 011a5cc7b3..e26bd41365 100644 --- a/plugins/autonomy/.claude-plugin/plugin.json +++ b/plugins/autonomy/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "autonomy", - "version": "0.26.2", + "version": "0.26.3", "description": "Contracts for governed autonomous agent operation: role topology, binding seam, telemetry, return accounting, trigger dispatch, a per-work-class guardrail matrix, a standing-routine catalog, and a runner charter. A guided setup skill writes an org's binding, wires OTLP telemetry (file default), human-attested return capture, and signal adapters behind one governed dispatch entrypoint, binds the guardrail matrix to isolation substrates after a live probe, and schedules routines.", "author": { "name": "Melodic Software", diff --git a/plugins/autonomy/CHANGELOG.md b/plugins/autonomy/CHANGELOG.md index c7378ac922..9b1f266b93 100644 --- a/plugins/autonomy/CHANGELOG.md +++ b/plugins/autonomy/CHANGELOG.md @@ -3,6 +3,14 @@ All notable changes to the `autonomy` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.26.3] - 2026-10-03 + +### Changed + +- **Shared `prerequisites.mjs` synced (#6084); no change to this plugin's lib.** + The prerequisite check now counts a Windows App Execution Alias (a Store or winget install on PATH) as found, + except App Installer's Python install stub. A `cli` or `runtime` entry can set `reject_store_alias` to skip aliases instead; no entry in this plugin does. + ## [0.26.2] - 2026-10-03 ### Changed diff --git a/plugins/autonomy/lib/prerequisites.mjs b/plugins/autonomy/lib/prerequisites.mjs index 57f65a79b5..1e11abf2cc 100755 --- a/plugins/autonomy/lib/prerequisites.mjs +++ b/plugins/autonomy/lib/prerequisites.mjs @@ -28,8 +28,10 @@ import { accessSync, constants, existsSync, + lstatSync, mkdirSync, readFileSync, + readlinkSync, realpathSync, statSync, writeFileSync, @@ -42,8 +44,8 @@ export const KINDS = ["cli", "runtime", "system-lib", "python-pkg", "node-pkg", export const NEEDS = ["required", "optional"]; const ENTRY_KEYS = ["id", "kind", "need", "for", "detect", "degrade", "install", "check"]; const DETECT_KEYS = { - cli: { required: ["any"], optional: ["local_bin", "version"] }, - runtime: { required: ["any"], optional: ["local_bin", "version"] }, + cli: { required: ["any"], optional: ["local_bin", "version", "reject_store_alias"] }, + runtime: { required: ["any"], optional: ["local_bin", "version", "reject_store_alias"] }, "system-lib": { required: ["probe"], optional: [] }, "python-pkg": { required: ["any", "import"], optional: [] }, "node-pkg": { required: ["module", "paths"], optional: [] }, @@ -100,6 +102,9 @@ function detectErrors(kind, detect, where) { } if ("any" in detect && !isTextList(detect.any)) errors.push(`${where}.any must be a non-empty list of names`); if ("local_bin" in detect) errors.push(...relativePathErrors(detect.local_bin, `${where}.local_bin`)); + if ("reject_store_alias" in detect && typeof detect.reject_store_alias !== "boolean") { + errors.push(`${where}.reject_store_alias must be true or false`); + } if ("version" in detect) { const v = detect.version; const vw = `${where}.version`; @@ -243,36 +248,66 @@ export function compareVersions(a, b) { return 0; } -function isExecutable(file, platform) { +const FS = { statSync, lstatSync, readlinkSync, accessSync }; + +// The target of App Installer's Python "install me" stub, which opens the Store +// instead of running Python, and the alias names it takes. +const PYTHON_STUB_TARGET = "appinstallerpythonredirector.exe"; +const PYTHON_STUB_NAMES = new Set(["python", "python3"]); + +// A Windows App Execution Alias, how a Store or winget app reaches PATH, is a +// reparse point in a WindowsApps directory that stat cannot open, yet it runs. +// It counts as found unless it is the Python stub, known by its target or, when +// the target cannot be read, by its name. +const isWindowsApps = (dir) => path.basename(dir).toLowerCase() === "windowsapps"; + +function isAppAlias(file, fs) { + if (!isWindowsApps(path.dirname(file))) return false; try { - if (!statSync(file).isFile()) return false; - if (platform !== "win32") accessSync(file, constants.X_OK); - return true; + if (!fs.lstatSync(file).isSymbolicLink()) return false; } catch { return false; } + let target; + try { + target = fs.readlinkSync(file); + } catch { + return !PYTHON_STUB_NAMES.has(path.basename(file).toLowerCase().replace(/\.exe$/, "")); + } + return path.win32.basename(target).toLowerCase() !== PYTHON_STUB_TARGET; +} + +function isExecutable(file, platform, fs = FS) { + try { + if (!fs.statSync(file).isFile()) return false; + if (platform !== "win32") fs.accessSync(file, constants.X_OK); + return true; + } catch { + return platform === "win32" && isAppAlias(file, fs); + } } -// which(name) -> absolute path of the first PATH match, or null. -export function which(name, env = process.env, platform = process.platform) { +// which(name) -> absolute path of the first PATH match, or null. fs is injectable for tests. +// rejectStoreAlias skips WindowsApps directories on win32, so no App Execution Alias matches. +export function which(name, env = process.env, platform = process.platform, fs = FS, rejectStoreAlias = false) { const pathVar = env.PATH ?? env.Path ?? ""; const exts = platform === "win32" ? ["", ...(env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean)] : [""]; for (const dir of pathVar.split(path.delimiter)) { - if (!dir) continue; + if (!dir || (rejectStoreAlias && platform === "win32" && isWindowsApps(dir))) continue; for (const ext of exts) { const candidate = path.join(dir, name + ext); - if (isExecutable(candidate, platform)) return candidate; + if (isExecutable(candidate, platform, fs)) return candidate; } } return null; } -function findLocalBin(rels, cwd, platform) { +function findLocalBin(rels, cwd, platform, fs) { for (const rel of rels ?? []) { let dir = path.resolve(cwd); for (let i = 0; i < LOCAL_BIN_DEPTH; i++) { const candidate = path.join(dir, rel); - if (isExecutable(candidate, platform)) return candidate; + if (isExecutable(candidate, platform, fs)) return candidate; const parent = path.dirname(dir); if (parent === dir) break; dir = parent; @@ -286,9 +321,9 @@ function run(file, args) { return { ok: !r.error && r.status === 0, out: `${r.stdout ?? ""}${r.stderr ?? ""}` }; } -function firstOnPath(names, ctx) { +function firstOnPath(names, ctx, rejectStoreAlias = false) { for (const name of names) { - const found = which(name, ctx.env, ctx.platform); + const found = which(name, ctx.env, ctx.platform, ctx.fs, rejectStoreAlias); if (found) return found; } return null; @@ -314,7 +349,7 @@ export function detectEntry(entry, ctx) { switch (entry.kind) { case "cli": case "runtime": { - const found = firstOnPath(d.any, ctx) ?? findLocalBin(d.local_bin, ctx.cwd, ctx.platform); + const found = firstOnPath(d.any, ctx, d.reject_store_alias) ?? findLocalBin(d.local_bin, ctx.cwd, ctx.platform, ctx.fs); if (!found) { const where = d.local_bin ? `on PATH or as ${d.local_bin.join(", ")}` : "on PATH"; return { status: "missing", detail: `${d.any.join(" or ")} was not found ${where}` }; @@ -331,7 +366,7 @@ export function detectEntry(entry, ctx) { } case "system-lib": { const [bin, ...args] = d.probe.args; - const found = which(bin, ctx.env, ctx.platform); + const found = which(bin, ctx.env, ctx.platform, ctx.fs); if (!found) return { status: "missing", detail: `${bin} was not found on PATH to probe` }; if (new RegExp(d.probe.pattern).test(run(found, args).out)) return { status: "present", detail: found }; return { status: "missing", detail: `${bin} ${args.join(" ")} did not report ${d.probe.pattern}` }; @@ -566,6 +601,7 @@ export function main(argv, ctx = {}, out = {}) { env: ctx.env ?? process.env, platform: ctx.platform ?? process.platform, cwd: ctx.cwd ?? process.cwd(), + fs: ctx.fs ?? FS, stdin: ctx.stdin ?? (() => (process.stdin.isTTY ? "" : readFileSync(0, "utf8"))), }; const io = { stdout: out.stdout ?? ((s) => console.log(s)), stderr: out.stderr ?? ((s) => console.error(s)) }; diff --git a/plugins/bash-format/.claude-plugin/plugin.json b/plugins/bash-format/.claude-plugin/plugin.json index 280bfb4c35..6e1f26b738 100644 --- a/plugins/bash-format/.claude-plugin/plugin.json +++ b/plugins/bash-format/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "bash-format", - "version": "0.10.1", + "version": "0.10.2", "description": "Auto-format and lint shell scripts on edit via shfmt + ShellCheck, using the consuming repo's own .editorconfig and .shellcheckrc.", "author": { "name": "Melodic Software", diff --git a/plugins/bash-format/CHANGELOG.md b/plugins/bash-format/CHANGELOG.md index 1b6dc19736..b8930278d6 100644 --- a/plugins/bash-format/CHANGELOG.md +++ b/plugins/bash-format/CHANGELOG.md @@ -3,6 +3,14 @@ All notable changes to the `bash-format` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.10.2] - 2026-10-03 + +### Changed + +- **Shared `prerequisites.mjs` synced ([#6084](https://github.com/melodic-software/claude-code-plugins/issues/6084)); no change to this plugin's lib.** + The prerequisite check now counts a Windows App Execution Alias (a Store or winget install on PATH) as found, + except App Installer's Python install stub. A `cli` or `runtime` entry can set `reject_store_alias` to skip aliases instead; no entry in this plugin does. + ## [0.10.1] - 2026-10-03 ### Changed diff --git a/plugins/bash-format/lib/prerequisites.mjs b/plugins/bash-format/lib/prerequisites.mjs index 57f65a79b5..1e11abf2cc 100755 --- a/plugins/bash-format/lib/prerequisites.mjs +++ b/plugins/bash-format/lib/prerequisites.mjs @@ -28,8 +28,10 @@ import { accessSync, constants, existsSync, + lstatSync, mkdirSync, readFileSync, + readlinkSync, realpathSync, statSync, writeFileSync, @@ -42,8 +44,8 @@ export const KINDS = ["cli", "runtime", "system-lib", "python-pkg", "node-pkg", export const NEEDS = ["required", "optional"]; const ENTRY_KEYS = ["id", "kind", "need", "for", "detect", "degrade", "install", "check"]; const DETECT_KEYS = { - cli: { required: ["any"], optional: ["local_bin", "version"] }, - runtime: { required: ["any"], optional: ["local_bin", "version"] }, + cli: { required: ["any"], optional: ["local_bin", "version", "reject_store_alias"] }, + runtime: { required: ["any"], optional: ["local_bin", "version", "reject_store_alias"] }, "system-lib": { required: ["probe"], optional: [] }, "python-pkg": { required: ["any", "import"], optional: [] }, "node-pkg": { required: ["module", "paths"], optional: [] }, @@ -100,6 +102,9 @@ function detectErrors(kind, detect, where) { } if ("any" in detect && !isTextList(detect.any)) errors.push(`${where}.any must be a non-empty list of names`); if ("local_bin" in detect) errors.push(...relativePathErrors(detect.local_bin, `${where}.local_bin`)); + if ("reject_store_alias" in detect && typeof detect.reject_store_alias !== "boolean") { + errors.push(`${where}.reject_store_alias must be true or false`); + } if ("version" in detect) { const v = detect.version; const vw = `${where}.version`; @@ -243,36 +248,66 @@ export function compareVersions(a, b) { return 0; } -function isExecutable(file, platform) { +const FS = { statSync, lstatSync, readlinkSync, accessSync }; + +// The target of App Installer's Python "install me" stub, which opens the Store +// instead of running Python, and the alias names it takes. +const PYTHON_STUB_TARGET = "appinstallerpythonredirector.exe"; +const PYTHON_STUB_NAMES = new Set(["python", "python3"]); + +// A Windows App Execution Alias, how a Store or winget app reaches PATH, is a +// reparse point in a WindowsApps directory that stat cannot open, yet it runs. +// It counts as found unless it is the Python stub, known by its target or, when +// the target cannot be read, by its name. +const isWindowsApps = (dir) => path.basename(dir).toLowerCase() === "windowsapps"; + +function isAppAlias(file, fs) { + if (!isWindowsApps(path.dirname(file))) return false; try { - if (!statSync(file).isFile()) return false; - if (platform !== "win32") accessSync(file, constants.X_OK); - return true; + if (!fs.lstatSync(file).isSymbolicLink()) return false; } catch { return false; } + let target; + try { + target = fs.readlinkSync(file); + } catch { + return !PYTHON_STUB_NAMES.has(path.basename(file).toLowerCase().replace(/\.exe$/, "")); + } + return path.win32.basename(target).toLowerCase() !== PYTHON_STUB_TARGET; +} + +function isExecutable(file, platform, fs = FS) { + try { + if (!fs.statSync(file).isFile()) return false; + if (platform !== "win32") fs.accessSync(file, constants.X_OK); + return true; + } catch { + return platform === "win32" && isAppAlias(file, fs); + } } -// which(name) -> absolute path of the first PATH match, or null. -export function which(name, env = process.env, platform = process.platform) { +// which(name) -> absolute path of the first PATH match, or null. fs is injectable for tests. +// rejectStoreAlias skips WindowsApps directories on win32, so no App Execution Alias matches. +export function which(name, env = process.env, platform = process.platform, fs = FS, rejectStoreAlias = false) { const pathVar = env.PATH ?? env.Path ?? ""; const exts = platform === "win32" ? ["", ...(env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean)] : [""]; for (const dir of pathVar.split(path.delimiter)) { - if (!dir) continue; + if (!dir || (rejectStoreAlias && platform === "win32" && isWindowsApps(dir))) continue; for (const ext of exts) { const candidate = path.join(dir, name + ext); - if (isExecutable(candidate, platform)) return candidate; + if (isExecutable(candidate, platform, fs)) return candidate; } } return null; } -function findLocalBin(rels, cwd, platform) { +function findLocalBin(rels, cwd, platform, fs) { for (const rel of rels ?? []) { let dir = path.resolve(cwd); for (let i = 0; i < LOCAL_BIN_DEPTH; i++) { const candidate = path.join(dir, rel); - if (isExecutable(candidate, platform)) return candidate; + if (isExecutable(candidate, platform, fs)) return candidate; const parent = path.dirname(dir); if (parent === dir) break; dir = parent; @@ -286,9 +321,9 @@ function run(file, args) { return { ok: !r.error && r.status === 0, out: `${r.stdout ?? ""}${r.stderr ?? ""}` }; } -function firstOnPath(names, ctx) { +function firstOnPath(names, ctx, rejectStoreAlias = false) { for (const name of names) { - const found = which(name, ctx.env, ctx.platform); + const found = which(name, ctx.env, ctx.platform, ctx.fs, rejectStoreAlias); if (found) return found; } return null; @@ -314,7 +349,7 @@ export function detectEntry(entry, ctx) { switch (entry.kind) { case "cli": case "runtime": { - const found = firstOnPath(d.any, ctx) ?? findLocalBin(d.local_bin, ctx.cwd, ctx.platform); + const found = firstOnPath(d.any, ctx, d.reject_store_alias) ?? findLocalBin(d.local_bin, ctx.cwd, ctx.platform, ctx.fs); if (!found) { const where = d.local_bin ? `on PATH or as ${d.local_bin.join(", ")}` : "on PATH"; return { status: "missing", detail: `${d.any.join(" or ")} was not found ${where}` }; @@ -331,7 +366,7 @@ export function detectEntry(entry, ctx) { } case "system-lib": { const [bin, ...args] = d.probe.args; - const found = which(bin, ctx.env, ctx.platform); + const found = which(bin, ctx.env, ctx.platform, ctx.fs); if (!found) return { status: "missing", detail: `${bin} was not found on PATH to probe` }; if (new RegExp(d.probe.pattern).test(run(found, args).out)) return { status: "present", detail: found }; return { status: "missing", detail: `${bin} ${args.join(" ")} did not report ${d.probe.pattern}` }; @@ -566,6 +601,7 @@ export function main(argv, ctx = {}, out = {}) { env: ctx.env ?? process.env, platform: ctx.platform ?? process.platform, cwd: ctx.cwd ?? process.cwd(), + fs: ctx.fs ?? FS, stdin: ctx.stdin ?? (() => (process.stdin.isTTY ? "" : readFileSync(0, "utf8"))), }; const io = { stdout: out.stdout ?? ((s) => console.log(s)), stderr: out.stderr ?? ((s) => console.error(s)) }; diff --git a/plugins/biome-format/.claude-plugin/plugin.json b/plugins/biome-format/.claude-plugin/plugin.json index 06c00cddc1..b7ad957fe2 100644 --- a/plugins/biome-format/.claude-plugin/plugin.json +++ b/plugins/biome-format/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "biome-format", - "version": "0.8.1", + "version": "0.8.2", "description": "Auto-format and lint JS/TS/JSX/JSON on edit via Biome, only when a biome.json governs the repo, using the consuming repo's own Biome config.", "author": { "name": "Melodic Software", diff --git a/plugins/biome-format/CHANGELOG.md b/plugins/biome-format/CHANGELOG.md index 9290c0bd81..b78b11e07e 100644 --- a/plugins/biome-format/CHANGELOG.md +++ b/plugins/biome-format/CHANGELOG.md @@ -3,6 +3,14 @@ All notable changes to the `biome-format` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.8.2] - 2026-10-03 + +### Changed + +- **Shared `prerequisites.mjs` synced ([#6084](https://github.com/melodic-software/claude-code-plugins/issues/6084)); no change to this plugin's lib.** + The prerequisite check now counts a Windows App Execution Alias (a Store or winget install on PATH) as found, + except App Installer's Python install stub. A `cli` or `runtime` entry can set `reject_store_alias` to skip aliases instead; no entry in this plugin does. + ## [0.8.1] - 2026-10-03 ### Changed diff --git a/plugins/biome-format/lib/prerequisites.mjs b/plugins/biome-format/lib/prerequisites.mjs index 57f65a79b5..1e11abf2cc 100755 --- a/plugins/biome-format/lib/prerequisites.mjs +++ b/plugins/biome-format/lib/prerequisites.mjs @@ -28,8 +28,10 @@ import { accessSync, constants, existsSync, + lstatSync, mkdirSync, readFileSync, + readlinkSync, realpathSync, statSync, writeFileSync, @@ -42,8 +44,8 @@ export const KINDS = ["cli", "runtime", "system-lib", "python-pkg", "node-pkg", export const NEEDS = ["required", "optional"]; const ENTRY_KEYS = ["id", "kind", "need", "for", "detect", "degrade", "install", "check"]; const DETECT_KEYS = { - cli: { required: ["any"], optional: ["local_bin", "version"] }, - runtime: { required: ["any"], optional: ["local_bin", "version"] }, + cli: { required: ["any"], optional: ["local_bin", "version", "reject_store_alias"] }, + runtime: { required: ["any"], optional: ["local_bin", "version", "reject_store_alias"] }, "system-lib": { required: ["probe"], optional: [] }, "python-pkg": { required: ["any", "import"], optional: [] }, "node-pkg": { required: ["module", "paths"], optional: [] }, @@ -100,6 +102,9 @@ function detectErrors(kind, detect, where) { } if ("any" in detect && !isTextList(detect.any)) errors.push(`${where}.any must be a non-empty list of names`); if ("local_bin" in detect) errors.push(...relativePathErrors(detect.local_bin, `${where}.local_bin`)); + if ("reject_store_alias" in detect && typeof detect.reject_store_alias !== "boolean") { + errors.push(`${where}.reject_store_alias must be true or false`); + } if ("version" in detect) { const v = detect.version; const vw = `${where}.version`; @@ -243,36 +248,66 @@ export function compareVersions(a, b) { return 0; } -function isExecutable(file, platform) { +const FS = { statSync, lstatSync, readlinkSync, accessSync }; + +// The target of App Installer's Python "install me" stub, which opens the Store +// instead of running Python, and the alias names it takes. +const PYTHON_STUB_TARGET = "appinstallerpythonredirector.exe"; +const PYTHON_STUB_NAMES = new Set(["python", "python3"]); + +// A Windows App Execution Alias, how a Store or winget app reaches PATH, is a +// reparse point in a WindowsApps directory that stat cannot open, yet it runs. +// It counts as found unless it is the Python stub, known by its target or, when +// the target cannot be read, by its name. +const isWindowsApps = (dir) => path.basename(dir).toLowerCase() === "windowsapps"; + +function isAppAlias(file, fs) { + if (!isWindowsApps(path.dirname(file))) return false; try { - if (!statSync(file).isFile()) return false; - if (platform !== "win32") accessSync(file, constants.X_OK); - return true; + if (!fs.lstatSync(file).isSymbolicLink()) return false; } catch { return false; } + let target; + try { + target = fs.readlinkSync(file); + } catch { + return !PYTHON_STUB_NAMES.has(path.basename(file).toLowerCase().replace(/\.exe$/, "")); + } + return path.win32.basename(target).toLowerCase() !== PYTHON_STUB_TARGET; +} + +function isExecutable(file, platform, fs = FS) { + try { + if (!fs.statSync(file).isFile()) return false; + if (platform !== "win32") fs.accessSync(file, constants.X_OK); + return true; + } catch { + return platform === "win32" && isAppAlias(file, fs); + } } -// which(name) -> absolute path of the first PATH match, or null. -export function which(name, env = process.env, platform = process.platform) { +// which(name) -> absolute path of the first PATH match, or null. fs is injectable for tests. +// rejectStoreAlias skips WindowsApps directories on win32, so no App Execution Alias matches. +export function which(name, env = process.env, platform = process.platform, fs = FS, rejectStoreAlias = false) { const pathVar = env.PATH ?? env.Path ?? ""; const exts = platform === "win32" ? ["", ...(env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean)] : [""]; for (const dir of pathVar.split(path.delimiter)) { - if (!dir) continue; + if (!dir || (rejectStoreAlias && platform === "win32" && isWindowsApps(dir))) continue; for (const ext of exts) { const candidate = path.join(dir, name + ext); - if (isExecutable(candidate, platform)) return candidate; + if (isExecutable(candidate, platform, fs)) return candidate; } } return null; } -function findLocalBin(rels, cwd, platform) { +function findLocalBin(rels, cwd, platform, fs) { for (const rel of rels ?? []) { let dir = path.resolve(cwd); for (let i = 0; i < LOCAL_BIN_DEPTH; i++) { const candidate = path.join(dir, rel); - if (isExecutable(candidate, platform)) return candidate; + if (isExecutable(candidate, platform, fs)) return candidate; const parent = path.dirname(dir); if (parent === dir) break; dir = parent; @@ -286,9 +321,9 @@ function run(file, args) { return { ok: !r.error && r.status === 0, out: `${r.stdout ?? ""}${r.stderr ?? ""}` }; } -function firstOnPath(names, ctx) { +function firstOnPath(names, ctx, rejectStoreAlias = false) { for (const name of names) { - const found = which(name, ctx.env, ctx.platform); + const found = which(name, ctx.env, ctx.platform, ctx.fs, rejectStoreAlias); if (found) return found; } return null; @@ -314,7 +349,7 @@ export function detectEntry(entry, ctx) { switch (entry.kind) { case "cli": case "runtime": { - const found = firstOnPath(d.any, ctx) ?? findLocalBin(d.local_bin, ctx.cwd, ctx.platform); + const found = firstOnPath(d.any, ctx, d.reject_store_alias) ?? findLocalBin(d.local_bin, ctx.cwd, ctx.platform, ctx.fs); if (!found) { const where = d.local_bin ? `on PATH or as ${d.local_bin.join(", ")}` : "on PATH"; return { status: "missing", detail: `${d.any.join(" or ")} was not found ${where}` }; @@ -331,7 +366,7 @@ export function detectEntry(entry, ctx) { } case "system-lib": { const [bin, ...args] = d.probe.args; - const found = which(bin, ctx.env, ctx.platform); + const found = which(bin, ctx.env, ctx.platform, ctx.fs); if (!found) return { status: "missing", detail: `${bin} was not found on PATH to probe` }; if (new RegExp(d.probe.pattern).test(run(found, args).out)) return { status: "present", detail: found }; return { status: "missing", detail: `${bin} ${args.join(" ")} did not report ${d.probe.pattern}` }; @@ -566,6 +601,7 @@ export function main(argv, ctx = {}, out = {}) { env: ctx.env ?? process.env, platform: ctx.platform ?? process.platform, cwd: ctx.cwd ?? process.cwd(), + fs: ctx.fs ?? FS, stdin: ctx.stdin ?? (() => (process.stdin.isTTY ? "" : readFileSync(0, "utf8"))), }; const io = { stdout: out.stdout ?? ((s) => console.log(s)), stderr: out.stderr ?? ((s) => console.error(s)) }; diff --git a/plugins/code-metrics/.claude-plugin/plugin.json b/plugins/code-metrics/.claude-plugin/plugin.json index 6f311aede6..11bf6aa52c 100644 --- a/plugins/code-metrics/.claude-plugin/plugin.json +++ b/plugins/code-metrics/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "code-metrics", - "version": "0.5.4", + "version": "0.5.5", "description": "Read-only code measures for a change, with cited references and no verdict: lines per file (audit-size), cyclomatic, cognitive, and Halstead complexity (audit-complexity), duplication (audit-duplication), per-function coverage and CRAP from existing lcov, Cobertura, coverage.py, or Go artifacts (audit-coverage), TypeScript and Python type debt (audit-type-debt), metric literacy (principles), and setup. Uses only collectors already installed; never installs or runs tests.", "author": { "name": "Melodic Software", diff --git a/plugins/code-metrics/CHANGELOG.md b/plugins/code-metrics/CHANGELOG.md index 18fe71e3bf..6476ab9351 100644 --- a/plugins/code-metrics/CHANGELOG.md +++ b/plugins/code-metrics/CHANGELOG.md @@ -3,6 +3,14 @@ All notable changes to the `code-metrics` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.5.5] - 2026-10-03 + +### Changed + +- **Shared `prerequisites.mjs` synced ([#6084](https://github.com/melodic-software/claude-code-plugins/issues/6084)); no change to this plugin's lib.** + The prerequisite check now counts a Windows App Execution Alias (a Store or winget install on PATH) as found, + except App Installer's Python install stub. A `cli` or `runtime` entry can set `reject_store_alias` to skip aliases instead; no entry in this plugin does. + ## [0.5.4] - 2026-10-03 ### Changed diff --git a/plugins/code-metrics/lib/prerequisites.mjs b/plugins/code-metrics/lib/prerequisites.mjs index 57f65a79b5..1e11abf2cc 100755 --- a/plugins/code-metrics/lib/prerequisites.mjs +++ b/plugins/code-metrics/lib/prerequisites.mjs @@ -28,8 +28,10 @@ import { accessSync, constants, existsSync, + lstatSync, mkdirSync, readFileSync, + readlinkSync, realpathSync, statSync, writeFileSync, @@ -42,8 +44,8 @@ export const KINDS = ["cli", "runtime", "system-lib", "python-pkg", "node-pkg", export const NEEDS = ["required", "optional"]; const ENTRY_KEYS = ["id", "kind", "need", "for", "detect", "degrade", "install", "check"]; const DETECT_KEYS = { - cli: { required: ["any"], optional: ["local_bin", "version"] }, - runtime: { required: ["any"], optional: ["local_bin", "version"] }, + cli: { required: ["any"], optional: ["local_bin", "version", "reject_store_alias"] }, + runtime: { required: ["any"], optional: ["local_bin", "version", "reject_store_alias"] }, "system-lib": { required: ["probe"], optional: [] }, "python-pkg": { required: ["any", "import"], optional: [] }, "node-pkg": { required: ["module", "paths"], optional: [] }, @@ -100,6 +102,9 @@ function detectErrors(kind, detect, where) { } if ("any" in detect && !isTextList(detect.any)) errors.push(`${where}.any must be a non-empty list of names`); if ("local_bin" in detect) errors.push(...relativePathErrors(detect.local_bin, `${where}.local_bin`)); + if ("reject_store_alias" in detect && typeof detect.reject_store_alias !== "boolean") { + errors.push(`${where}.reject_store_alias must be true or false`); + } if ("version" in detect) { const v = detect.version; const vw = `${where}.version`; @@ -243,36 +248,66 @@ export function compareVersions(a, b) { return 0; } -function isExecutable(file, platform) { +const FS = { statSync, lstatSync, readlinkSync, accessSync }; + +// The target of App Installer's Python "install me" stub, which opens the Store +// instead of running Python, and the alias names it takes. +const PYTHON_STUB_TARGET = "appinstallerpythonredirector.exe"; +const PYTHON_STUB_NAMES = new Set(["python", "python3"]); + +// A Windows App Execution Alias, how a Store or winget app reaches PATH, is a +// reparse point in a WindowsApps directory that stat cannot open, yet it runs. +// It counts as found unless it is the Python stub, known by its target or, when +// the target cannot be read, by its name. +const isWindowsApps = (dir) => path.basename(dir).toLowerCase() === "windowsapps"; + +function isAppAlias(file, fs) { + if (!isWindowsApps(path.dirname(file))) return false; try { - if (!statSync(file).isFile()) return false; - if (platform !== "win32") accessSync(file, constants.X_OK); - return true; + if (!fs.lstatSync(file).isSymbolicLink()) return false; } catch { return false; } + let target; + try { + target = fs.readlinkSync(file); + } catch { + return !PYTHON_STUB_NAMES.has(path.basename(file).toLowerCase().replace(/\.exe$/, "")); + } + return path.win32.basename(target).toLowerCase() !== PYTHON_STUB_TARGET; +} + +function isExecutable(file, platform, fs = FS) { + try { + if (!fs.statSync(file).isFile()) return false; + if (platform !== "win32") fs.accessSync(file, constants.X_OK); + return true; + } catch { + return platform === "win32" && isAppAlias(file, fs); + } } -// which(name) -> absolute path of the first PATH match, or null. -export function which(name, env = process.env, platform = process.platform) { +// which(name) -> absolute path of the first PATH match, or null. fs is injectable for tests. +// rejectStoreAlias skips WindowsApps directories on win32, so no App Execution Alias matches. +export function which(name, env = process.env, platform = process.platform, fs = FS, rejectStoreAlias = false) { const pathVar = env.PATH ?? env.Path ?? ""; const exts = platform === "win32" ? ["", ...(env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean)] : [""]; for (const dir of pathVar.split(path.delimiter)) { - if (!dir) continue; + if (!dir || (rejectStoreAlias && platform === "win32" && isWindowsApps(dir))) continue; for (const ext of exts) { const candidate = path.join(dir, name + ext); - if (isExecutable(candidate, platform)) return candidate; + if (isExecutable(candidate, platform, fs)) return candidate; } } return null; } -function findLocalBin(rels, cwd, platform) { +function findLocalBin(rels, cwd, platform, fs) { for (const rel of rels ?? []) { let dir = path.resolve(cwd); for (let i = 0; i < LOCAL_BIN_DEPTH; i++) { const candidate = path.join(dir, rel); - if (isExecutable(candidate, platform)) return candidate; + if (isExecutable(candidate, platform, fs)) return candidate; const parent = path.dirname(dir); if (parent === dir) break; dir = parent; @@ -286,9 +321,9 @@ function run(file, args) { return { ok: !r.error && r.status === 0, out: `${r.stdout ?? ""}${r.stderr ?? ""}` }; } -function firstOnPath(names, ctx) { +function firstOnPath(names, ctx, rejectStoreAlias = false) { for (const name of names) { - const found = which(name, ctx.env, ctx.platform); + const found = which(name, ctx.env, ctx.platform, ctx.fs, rejectStoreAlias); if (found) return found; } return null; @@ -314,7 +349,7 @@ export function detectEntry(entry, ctx) { switch (entry.kind) { case "cli": case "runtime": { - const found = firstOnPath(d.any, ctx) ?? findLocalBin(d.local_bin, ctx.cwd, ctx.platform); + const found = firstOnPath(d.any, ctx, d.reject_store_alias) ?? findLocalBin(d.local_bin, ctx.cwd, ctx.platform, ctx.fs); if (!found) { const where = d.local_bin ? `on PATH or as ${d.local_bin.join(", ")}` : "on PATH"; return { status: "missing", detail: `${d.any.join(" or ")} was not found ${where}` }; @@ -331,7 +366,7 @@ export function detectEntry(entry, ctx) { } case "system-lib": { const [bin, ...args] = d.probe.args; - const found = which(bin, ctx.env, ctx.platform); + const found = which(bin, ctx.env, ctx.platform, ctx.fs); if (!found) return { status: "missing", detail: `${bin} was not found on PATH to probe` }; if (new RegExp(d.probe.pattern).test(run(found, args).out)) return { status: "present", detail: found }; return { status: "missing", detail: `${bin} ${args.join(" ")} did not report ${d.probe.pattern}` }; @@ -566,6 +601,7 @@ export function main(argv, ctx = {}, out = {}) { env: ctx.env ?? process.env, platform: ctx.platform ?? process.platform, cwd: ctx.cwd ?? process.cwd(), + fs: ctx.fs ?? FS, stdin: ctx.stdin ?? (() => (process.stdin.isTTY ? "" : readFileSync(0, "utf8"))), }; const io = { stdout: out.stdout ?? ((s) => console.log(s)), stderr: out.stderr ?? ((s) => console.error(s)) }; diff --git a/plugins/code-tidying/.claude-plugin/plugin.json b/plugins/code-tidying/.claude-plugin/plugin.json index 0d9bbb9acd..6f2a02f745 100644 --- a/plugins/code-tidying/.claude-plugin/plugin.json +++ b/plugins/code-tidying/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "code-tidying", - "version": "0.28.3", + "version": "0.28.4", "description": "Code tidying and comment hygiene. tidy hunts a rotated lane for Beck-style tidyings and ships one PR. batch-simplify sweeps a branch, time window, or repo in dependency-ordered waves. dissolve-comments deletes zero-information comments and moves the rest into names (safe, aggressive, strip modes). audit-comment-residue flags history, plan, conversational, and ticket residue. audit-dead-code finds dead code with knip, vulture, gopls, and grep. setup scaffolds tidy lanes.", "author": { "name": "Melodic Software", diff --git a/plugins/code-tidying/CHANGELOG.md b/plugins/code-tidying/CHANGELOG.md index e83672a6e0..cc04391042 100644 --- a/plugins/code-tidying/CHANGELOG.md +++ b/plugins/code-tidying/CHANGELOG.md @@ -3,6 +3,14 @@ All notable changes to the `code-tidying` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.28.4] - 2026-10-03 + +### Changed + +- **Shared `prerequisites.mjs` synced ([#6084](https://github.com/melodic-software/claude-code-plugins/issues/6084)); no change to this plugin's lib.** + The prerequisite check now counts a Windows App Execution Alias (a Store or winget install on PATH) as found, + except App Installer's Python install stub. A `cli` or `runtime` entry can set `reject_store_alias` to skip aliases instead; no entry in this plugin does. + ## [0.28.3] - 2026-10-03 ### Changed diff --git a/plugins/code-tidying/lib/prerequisites.mjs b/plugins/code-tidying/lib/prerequisites.mjs index 57f65a79b5..1e11abf2cc 100755 --- a/plugins/code-tidying/lib/prerequisites.mjs +++ b/plugins/code-tidying/lib/prerequisites.mjs @@ -28,8 +28,10 @@ import { accessSync, constants, existsSync, + lstatSync, mkdirSync, readFileSync, + readlinkSync, realpathSync, statSync, writeFileSync, @@ -42,8 +44,8 @@ export const KINDS = ["cli", "runtime", "system-lib", "python-pkg", "node-pkg", export const NEEDS = ["required", "optional"]; const ENTRY_KEYS = ["id", "kind", "need", "for", "detect", "degrade", "install", "check"]; const DETECT_KEYS = { - cli: { required: ["any"], optional: ["local_bin", "version"] }, - runtime: { required: ["any"], optional: ["local_bin", "version"] }, + cli: { required: ["any"], optional: ["local_bin", "version", "reject_store_alias"] }, + runtime: { required: ["any"], optional: ["local_bin", "version", "reject_store_alias"] }, "system-lib": { required: ["probe"], optional: [] }, "python-pkg": { required: ["any", "import"], optional: [] }, "node-pkg": { required: ["module", "paths"], optional: [] }, @@ -100,6 +102,9 @@ function detectErrors(kind, detect, where) { } if ("any" in detect && !isTextList(detect.any)) errors.push(`${where}.any must be a non-empty list of names`); if ("local_bin" in detect) errors.push(...relativePathErrors(detect.local_bin, `${where}.local_bin`)); + if ("reject_store_alias" in detect && typeof detect.reject_store_alias !== "boolean") { + errors.push(`${where}.reject_store_alias must be true or false`); + } if ("version" in detect) { const v = detect.version; const vw = `${where}.version`; @@ -243,36 +248,66 @@ export function compareVersions(a, b) { return 0; } -function isExecutable(file, platform) { +const FS = { statSync, lstatSync, readlinkSync, accessSync }; + +// The target of App Installer's Python "install me" stub, which opens the Store +// instead of running Python, and the alias names it takes. +const PYTHON_STUB_TARGET = "appinstallerpythonredirector.exe"; +const PYTHON_STUB_NAMES = new Set(["python", "python3"]); + +// A Windows App Execution Alias, how a Store or winget app reaches PATH, is a +// reparse point in a WindowsApps directory that stat cannot open, yet it runs. +// It counts as found unless it is the Python stub, known by its target or, when +// the target cannot be read, by its name. +const isWindowsApps = (dir) => path.basename(dir).toLowerCase() === "windowsapps"; + +function isAppAlias(file, fs) { + if (!isWindowsApps(path.dirname(file))) return false; try { - if (!statSync(file).isFile()) return false; - if (platform !== "win32") accessSync(file, constants.X_OK); - return true; + if (!fs.lstatSync(file).isSymbolicLink()) return false; } catch { return false; } + let target; + try { + target = fs.readlinkSync(file); + } catch { + return !PYTHON_STUB_NAMES.has(path.basename(file).toLowerCase().replace(/\.exe$/, "")); + } + return path.win32.basename(target).toLowerCase() !== PYTHON_STUB_TARGET; +} + +function isExecutable(file, platform, fs = FS) { + try { + if (!fs.statSync(file).isFile()) return false; + if (platform !== "win32") fs.accessSync(file, constants.X_OK); + return true; + } catch { + return platform === "win32" && isAppAlias(file, fs); + } } -// which(name) -> absolute path of the first PATH match, or null. -export function which(name, env = process.env, platform = process.platform) { +// which(name) -> absolute path of the first PATH match, or null. fs is injectable for tests. +// rejectStoreAlias skips WindowsApps directories on win32, so no App Execution Alias matches. +export function which(name, env = process.env, platform = process.platform, fs = FS, rejectStoreAlias = false) { const pathVar = env.PATH ?? env.Path ?? ""; const exts = platform === "win32" ? ["", ...(env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean)] : [""]; for (const dir of pathVar.split(path.delimiter)) { - if (!dir) continue; + if (!dir || (rejectStoreAlias && platform === "win32" && isWindowsApps(dir))) continue; for (const ext of exts) { const candidate = path.join(dir, name + ext); - if (isExecutable(candidate, platform)) return candidate; + if (isExecutable(candidate, platform, fs)) return candidate; } } return null; } -function findLocalBin(rels, cwd, platform) { +function findLocalBin(rels, cwd, platform, fs) { for (const rel of rels ?? []) { let dir = path.resolve(cwd); for (let i = 0; i < LOCAL_BIN_DEPTH; i++) { const candidate = path.join(dir, rel); - if (isExecutable(candidate, platform)) return candidate; + if (isExecutable(candidate, platform, fs)) return candidate; const parent = path.dirname(dir); if (parent === dir) break; dir = parent; @@ -286,9 +321,9 @@ function run(file, args) { return { ok: !r.error && r.status === 0, out: `${r.stdout ?? ""}${r.stderr ?? ""}` }; } -function firstOnPath(names, ctx) { +function firstOnPath(names, ctx, rejectStoreAlias = false) { for (const name of names) { - const found = which(name, ctx.env, ctx.platform); + const found = which(name, ctx.env, ctx.platform, ctx.fs, rejectStoreAlias); if (found) return found; } return null; @@ -314,7 +349,7 @@ export function detectEntry(entry, ctx) { switch (entry.kind) { case "cli": case "runtime": { - const found = firstOnPath(d.any, ctx) ?? findLocalBin(d.local_bin, ctx.cwd, ctx.platform); + const found = firstOnPath(d.any, ctx, d.reject_store_alias) ?? findLocalBin(d.local_bin, ctx.cwd, ctx.platform, ctx.fs); if (!found) { const where = d.local_bin ? `on PATH or as ${d.local_bin.join(", ")}` : "on PATH"; return { status: "missing", detail: `${d.any.join(" or ")} was not found ${where}` }; @@ -331,7 +366,7 @@ export function detectEntry(entry, ctx) { } case "system-lib": { const [bin, ...args] = d.probe.args; - const found = which(bin, ctx.env, ctx.platform); + const found = which(bin, ctx.env, ctx.platform, ctx.fs); if (!found) return { status: "missing", detail: `${bin} was not found on PATH to probe` }; if (new RegExp(d.probe.pattern).test(run(found, args).out)) return { status: "present", detail: found }; return { status: "missing", detail: `${bin} ${args.join(" ")} did not report ${d.probe.pattern}` }; @@ -566,6 +601,7 @@ export function main(argv, ctx = {}, out = {}) { env: ctx.env ?? process.env, platform: ctx.platform ?? process.platform, cwd: ctx.cwd ?? process.cwd(), + fs: ctx.fs ?? FS, stdin: ctx.stdin ?? (() => (process.stdin.isTTY ? "" : readFileSync(0, "utf8"))), }; const io = { stdout: out.stdout ?? ((s) => console.log(s)), stderr: out.stderr ?? ((s) => console.error(s)) }; diff --git a/plugins/context-budget/.claude-plugin/plugin.json b/plugins/context-budget/.claude-plugin/plugin.json index 124fd2b829..885aa1af3a 100644 --- a/plugins/context-budget/.claude-plugin/plugin.json +++ b/plugins/context-budget/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "context-budget", - "version": "0.9.1", + "version": "0.9.2", "description": "Measures a Claude Code session's fixed startup context payload per item at a pinned binary version, including per-tool attribution of the built-in tool pools that /context reports only as totals, derived by A/B deny differencing under comparability rules. Falls back from an SDK meter to a headless /context parser, then to an error rather than a wrong number. Keeps a per-project before/after ledger per lever. Report-only by default; fix applies one approved project-scope trim.", "author": { "name": "Melodic Software", diff --git a/plugins/context-budget/CHANGELOG.md b/plugins/context-budget/CHANGELOG.md index b158091e2d..1c011e5239 100644 --- a/plugins/context-budget/CHANGELOG.md +++ b/plugins/context-budget/CHANGELOG.md @@ -7,6 +7,14 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). Versions 0.6.38 and 0.6.40 were reserved by parallel changes and never published. +## [0.9.2] - 2026-10-03 + +### Changed + +- **Shared `prerequisites.mjs` synced ([#6084](https://github.com/melodic-software/claude-code-plugins/issues/6084)); no change to this plugin's lib.** + The prerequisite check now counts a Windows App Execution Alias (a Store or winget install on PATH) as found, + except App Installer's Python install stub. A `cli` or `runtime` entry can set `reject_store_alias` to skip aliases instead; no entry in this plugin does. + ## [0.9.1] - 2026-10-03 ### Fixed diff --git a/plugins/context-budget/lib/prerequisites.mjs b/plugins/context-budget/lib/prerequisites.mjs index 57f65a79b5..1e11abf2cc 100755 --- a/plugins/context-budget/lib/prerequisites.mjs +++ b/plugins/context-budget/lib/prerequisites.mjs @@ -28,8 +28,10 @@ import { accessSync, constants, existsSync, + lstatSync, mkdirSync, readFileSync, + readlinkSync, realpathSync, statSync, writeFileSync, @@ -42,8 +44,8 @@ export const KINDS = ["cli", "runtime", "system-lib", "python-pkg", "node-pkg", export const NEEDS = ["required", "optional"]; const ENTRY_KEYS = ["id", "kind", "need", "for", "detect", "degrade", "install", "check"]; const DETECT_KEYS = { - cli: { required: ["any"], optional: ["local_bin", "version"] }, - runtime: { required: ["any"], optional: ["local_bin", "version"] }, + cli: { required: ["any"], optional: ["local_bin", "version", "reject_store_alias"] }, + runtime: { required: ["any"], optional: ["local_bin", "version", "reject_store_alias"] }, "system-lib": { required: ["probe"], optional: [] }, "python-pkg": { required: ["any", "import"], optional: [] }, "node-pkg": { required: ["module", "paths"], optional: [] }, @@ -100,6 +102,9 @@ function detectErrors(kind, detect, where) { } if ("any" in detect && !isTextList(detect.any)) errors.push(`${where}.any must be a non-empty list of names`); if ("local_bin" in detect) errors.push(...relativePathErrors(detect.local_bin, `${where}.local_bin`)); + if ("reject_store_alias" in detect && typeof detect.reject_store_alias !== "boolean") { + errors.push(`${where}.reject_store_alias must be true or false`); + } if ("version" in detect) { const v = detect.version; const vw = `${where}.version`; @@ -243,36 +248,66 @@ export function compareVersions(a, b) { return 0; } -function isExecutable(file, platform) { +const FS = { statSync, lstatSync, readlinkSync, accessSync }; + +// The target of App Installer's Python "install me" stub, which opens the Store +// instead of running Python, and the alias names it takes. +const PYTHON_STUB_TARGET = "appinstallerpythonredirector.exe"; +const PYTHON_STUB_NAMES = new Set(["python", "python3"]); + +// A Windows App Execution Alias, how a Store or winget app reaches PATH, is a +// reparse point in a WindowsApps directory that stat cannot open, yet it runs. +// It counts as found unless it is the Python stub, known by its target or, when +// the target cannot be read, by its name. +const isWindowsApps = (dir) => path.basename(dir).toLowerCase() === "windowsapps"; + +function isAppAlias(file, fs) { + if (!isWindowsApps(path.dirname(file))) return false; try { - if (!statSync(file).isFile()) return false; - if (platform !== "win32") accessSync(file, constants.X_OK); - return true; + if (!fs.lstatSync(file).isSymbolicLink()) return false; } catch { return false; } + let target; + try { + target = fs.readlinkSync(file); + } catch { + return !PYTHON_STUB_NAMES.has(path.basename(file).toLowerCase().replace(/\.exe$/, "")); + } + return path.win32.basename(target).toLowerCase() !== PYTHON_STUB_TARGET; +} + +function isExecutable(file, platform, fs = FS) { + try { + if (!fs.statSync(file).isFile()) return false; + if (platform !== "win32") fs.accessSync(file, constants.X_OK); + return true; + } catch { + return platform === "win32" && isAppAlias(file, fs); + } } -// which(name) -> absolute path of the first PATH match, or null. -export function which(name, env = process.env, platform = process.platform) { +// which(name) -> absolute path of the first PATH match, or null. fs is injectable for tests. +// rejectStoreAlias skips WindowsApps directories on win32, so no App Execution Alias matches. +export function which(name, env = process.env, platform = process.platform, fs = FS, rejectStoreAlias = false) { const pathVar = env.PATH ?? env.Path ?? ""; const exts = platform === "win32" ? ["", ...(env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean)] : [""]; for (const dir of pathVar.split(path.delimiter)) { - if (!dir) continue; + if (!dir || (rejectStoreAlias && platform === "win32" && isWindowsApps(dir))) continue; for (const ext of exts) { const candidate = path.join(dir, name + ext); - if (isExecutable(candidate, platform)) return candidate; + if (isExecutable(candidate, platform, fs)) return candidate; } } return null; } -function findLocalBin(rels, cwd, platform) { +function findLocalBin(rels, cwd, platform, fs) { for (const rel of rels ?? []) { let dir = path.resolve(cwd); for (let i = 0; i < LOCAL_BIN_DEPTH; i++) { const candidate = path.join(dir, rel); - if (isExecutable(candidate, platform)) return candidate; + if (isExecutable(candidate, platform, fs)) return candidate; const parent = path.dirname(dir); if (parent === dir) break; dir = parent; @@ -286,9 +321,9 @@ function run(file, args) { return { ok: !r.error && r.status === 0, out: `${r.stdout ?? ""}${r.stderr ?? ""}` }; } -function firstOnPath(names, ctx) { +function firstOnPath(names, ctx, rejectStoreAlias = false) { for (const name of names) { - const found = which(name, ctx.env, ctx.platform); + const found = which(name, ctx.env, ctx.platform, ctx.fs, rejectStoreAlias); if (found) return found; } return null; @@ -314,7 +349,7 @@ export function detectEntry(entry, ctx) { switch (entry.kind) { case "cli": case "runtime": { - const found = firstOnPath(d.any, ctx) ?? findLocalBin(d.local_bin, ctx.cwd, ctx.platform); + const found = firstOnPath(d.any, ctx, d.reject_store_alias) ?? findLocalBin(d.local_bin, ctx.cwd, ctx.platform, ctx.fs); if (!found) { const where = d.local_bin ? `on PATH or as ${d.local_bin.join(", ")}` : "on PATH"; return { status: "missing", detail: `${d.any.join(" or ")} was not found ${where}` }; @@ -331,7 +366,7 @@ export function detectEntry(entry, ctx) { } case "system-lib": { const [bin, ...args] = d.probe.args; - const found = which(bin, ctx.env, ctx.platform); + const found = which(bin, ctx.env, ctx.platform, ctx.fs); if (!found) return { status: "missing", detail: `${bin} was not found on PATH to probe` }; if (new RegExp(d.probe.pattern).test(run(found, args).out)) return { status: "present", detail: found }; return { status: "missing", detail: `${bin} ${args.join(" ")} did not report ${d.probe.pattern}` }; @@ -566,6 +601,7 @@ export function main(argv, ctx = {}, out = {}) { env: ctx.env ?? process.env, platform: ctx.platform ?? process.platform, cwd: ctx.cwd ?? process.cwd(), + fs: ctx.fs ?? FS, stdin: ctx.stdin ?? (() => (process.stdin.isTTY ? "" : readFileSync(0, "utf8"))), }; const io = { stdout: out.stdout ?? ((s) => console.log(s)), stderr: out.stderr ?? ((s) => console.error(s)) }; diff --git a/plugins/context-guard/.claude-plugin/plugin.json b/plugins/context-guard/.claude-plugin/plugin.json index 1dddae663d..677a40284e 100644 --- a/plugins/context-guard/.claude-plugin/plugin.json +++ b/plugins/context-guard/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "context-guard", - "version": "0.10.2", + "version": "0.10.3", "description": "Per-session context-window observability. A statusline wrapper writes each session's context_window fields to a snapshot file; a resolver classifies usage into smart, acceptable, and dumb zones from zones.json. Hooks report each move into a worse zone once: the continuation menu to the operator, and only the zone to the model, noting a zone is not a decay signal. Optional blocking mode gates new mutating work in the dumb zone, except handoffs. A PostCompact hook leaves a marker.", "author": { "name": "Melodic Software", diff --git a/plugins/context-guard/CHANGELOG.md b/plugins/context-guard/CHANGELOG.md index 69d0d6162d..4a07d16293 100644 --- a/plugins/context-guard/CHANGELOG.md +++ b/plugins/context-guard/CHANGELOG.md @@ -5,6 +5,14 @@ All notable changes to the `context-guard` plugin. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.10.3] - 2026-10-03 + +### Changed + +- **Shared `prerequisites.mjs` synced ([#6084](https://github.com/melodic-software/claude-code-plugins/issues/6084)); no change to this plugin's lib.** + The prerequisite check now counts a Windows App Execution Alias (a Store or winget install on PATH) as found, + except App Installer's Python install stub. A `cli` or `runtime` entry can set `reject_store_alias` to skip aliases instead; no entry in this plugin does. + ## [0.10.2] - 2026-10-03 ### Changed diff --git a/plugins/context-guard/lib/prerequisites.mjs b/plugins/context-guard/lib/prerequisites.mjs index 57f65a79b5..1e11abf2cc 100755 --- a/plugins/context-guard/lib/prerequisites.mjs +++ b/plugins/context-guard/lib/prerequisites.mjs @@ -28,8 +28,10 @@ import { accessSync, constants, existsSync, + lstatSync, mkdirSync, readFileSync, + readlinkSync, realpathSync, statSync, writeFileSync, @@ -42,8 +44,8 @@ export const KINDS = ["cli", "runtime", "system-lib", "python-pkg", "node-pkg", export const NEEDS = ["required", "optional"]; const ENTRY_KEYS = ["id", "kind", "need", "for", "detect", "degrade", "install", "check"]; const DETECT_KEYS = { - cli: { required: ["any"], optional: ["local_bin", "version"] }, - runtime: { required: ["any"], optional: ["local_bin", "version"] }, + cli: { required: ["any"], optional: ["local_bin", "version", "reject_store_alias"] }, + runtime: { required: ["any"], optional: ["local_bin", "version", "reject_store_alias"] }, "system-lib": { required: ["probe"], optional: [] }, "python-pkg": { required: ["any", "import"], optional: [] }, "node-pkg": { required: ["module", "paths"], optional: [] }, @@ -100,6 +102,9 @@ function detectErrors(kind, detect, where) { } if ("any" in detect && !isTextList(detect.any)) errors.push(`${where}.any must be a non-empty list of names`); if ("local_bin" in detect) errors.push(...relativePathErrors(detect.local_bin, `${where}.local_bin`)); + if ("reject_store_alias" in detect && typeof detect.reject_store_alias !== "boolean") { + errors.push(`${where}.reject_store_alias must be true or false`); + } if ("version" in detect) { const v = detect.version; const vw = `${where}.version`; @@ -243,36 +248,66 @@ export function compareVersions(a, b) { return 0; } -function isExecutable(file, platform) { +const FS = { statSync, lstatSync, readlinkSync, accessSync }; + +// The target of App Installer's Python "install me" stub, which opens the Store +// instead of running Python, and the alias names it takes. +const PYTHON_STUB_TARGET = "appinstallerpythonredirector.exe"; +const PYTHON_STUB_NAMES = new Set(["python", "python3"]); + +// A Windows App Execution Alias, how a Store or winget app reaches PATH, is a +// reparse point in a WindowsApps directory that stat cannot open, yet it runs. +// It counts as found unless it is the Python stub, known by its target or, when +// the target cannot be read, by its name. +const isWindowsApps = (dir) => path.basename(dir).toLowerCase() === "windowsapps"; + +function isAppAlias(file, fs) { + if (!isWindowsApps(path.dirname(file))) return false; try { - if (!statSync(file).isFile()) return false; - if (platform !== "win32") accessSync(file, constants.X_OK); - return true; + if (!fs.lstatSync(file).isSymbolicLink()) return false; } catch { return false; } + let target; + try { + target = fs.readlinkSync(file); + } catch { + return !PYTHON_STUB_NAMES.has(path.basename(file).toLowerCase().replace(/\.exe$/, "")); + } + return path.win32.basename(target).toLowerCase() !== PYTHON_STUB_TARGET; +} + +function isExecutable(file, platform, fs = FS) { + try { + if (!fs.statSync(file).isFile()) return false; + if (platform !== "win32") fs.accessSync(file, constants.X_OK); + return true; + } catch { + return platform === "win32" && isAppAlias(file, fs); + } } -// which(name) -> absolute path of the first PATH match, or null. -export function which(name, env = process.env, platform = process.platform) { +// which(name) -> absolute path of the first PATH match, or null. fs is injectable for tests. +// rejectStoreAlias skips WindowsApps directories on win32, so no App Execution Alias matches. +export function which(name, env = process.env, platform = process.platform, fs = FS, rejectStoreAlias = false) { const pathVar = env.PATH ?? env.Path ?? ""; const exts = platform === "win32" ? ["", ...(env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean)] : [""]; for (const dir of pathVar.split(path.delimiter)) { - if (!dir) continue; + if (!dir || (rejectStoreAlias && platform === "win32" && isWindowsApps(dir))) continue; for (const ext of exts) { const candidate = path.join(dir, name + ext); - if (isExecutable(candidate, platform)) return candidate; + if (isExecutable(candidate, platform, fs)) return candidate; } } return null; } -function findLocalBin(rels, cwd, platform) { +function findLocalBin(rels, cwd, platform, fs) { for (const rel of rels ?? []) { let dir = path.resolve(cwd); for (let i = 0; i < LOCAL_BIN_DEPTH; i++) { const candidate = path.join(dir, rel); - if (isExecutable(candidate, platform)) return candidate; + if (isExecutable(candidate, platform, fs)) return candidate; const parent = path.dirname(dir); if (parent === dir) break; dir = parent; @@ -286,9 +321,9 @@ function run(file, args) { return { ok: !r.error && r.status === 0, out: `${r.stdout ?? ""}${r.stderr ?? ""}` }; } -function firstOnPath(names, ctx) { +function firstOnPath(names, ctx, rejectStoreAlias = false) { for (const name of names) { - const found = which(name, ctx.env, ctx.platform); + const found = which(name, ctx.env, ctx.platform, ctx.fs, rejectStoreAlias); if (found) return found; } return null; @@ -314,7 +349,7 @@ export function detectEntry(entry, ctx) { switch (entry.kind) { case "cli": case "runtime": { - const found = firstOnPath(d.any, ctx) ?? findLocalBin(d.local_bin, ctx.cwd, ctx.platform); + const found = firstOnPath(d.any, ctx, d.reject_store_alias) ?? findLocalBin(d.local_bin, ctx.cwd, ctx.platform, ctx.fs); if (!found) { const where = d.local_bin ? `on PATH or as ${d.local_bin.join(", ")}` : "on PATH"; return { status: "missing", detail: `${d.any.join(" or ")} was not found ${where}` }; @@ -331,7 +366,7 @@ export function detectEntry(entry, ctx) { } case "system-lib": { const [bin, ...args] = d.probe.args; - const found = which(bin, ctx.env, ctx.platform); + const found = which(bin, ctx.env, ctx.platform, ctx.fs); if (!found) return { status: "missing", detail: `${bin} was not found on PATH to probe` }; if (new RegExp(d.probe.pattern).test(run(found, args).out)) return { status: "present", detail: found }; return { status: "missing", detail: `${bin} ${args.join(" ")} did not report ${d.probe.pattern}` }; @@ -566,6 +601,7 @@ export function main(argv, ctx = {}, out = {}) { env: ctx.env ?? process.env, platform: ctx.platform ?? process.platform, cwd: ctx.cwd ?? process.cwd(), + fs: ctx.fs ?? FS, stdin: ctx.stdin ?? (() => (process.stdin.isTTY ? "" : readFileSync(0, "utf8"))), }; const io = { stdout: out.stdout ?? ((s) => console.log(s)), stderr: out.stderr ?? ((s) => console.error(s)) }; diff --git a/plugins/context7/.claude-plugin/plugin.json b/plugins/context7/.claude-plugin/plugin.json index 5ee6481cd7..d1fc7c31fa 100644 --- a/plugins/context7/.claude-plugin/plugin.json +++ b/plugins/context7/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "context7", - "version": "0.6.6", + "version": "0.6.7", "description": "Looks up current library documentation, API references, and code examples via Context7 (ctx7 CLI or the Context7 MCP server) with a two-step resolve-then-query workflow: a lookup skill (default lookup plus an upstream drift-check action) and a setup skill for CLI install, auth, and MCP configuration.", "author": { "name": "Melodic Software", diff --git a/plugins/context7/CHANGELOG.md b/plugins/context7/CHANGELOG.md index 765fd4d49d..8a969a1137 100644 --- a/plugins/context7/CHANGELOG.md +++ b/plugins/context7/CHANGELOG.md @@ -3,6 +3,14 @@ All notable changes to the `context7` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.6.7] - 2026-10-03 + +### Changed + +- **Shared `prerequisites.mjs` synced ([#6084](https://github.com/melodic-software/claude-code-plugins/issues/6084)); no change to this plugin's lib.** + The prerequisite check now counts a Windows App Execution Alias (a Store or winget install on PATH) as found, + except App Installer's Python install stub. A `cli` or `runtime` entry can set `reject_store_alias` to skip aliases instead; no entry in this plugin does. + ## [0.6.6] - 2026-10-03 ### Changed diff --git a/plugins/context7/lib/prerequisites.mjs b/plugins/context7/lib/prerequisites.mjs index 57f65a79b5..1e11abf2cc 100755 --- a/plugins/context7/lib/prerequisites.mjs +++ b/plugins/context7/lib/prerequisites.mjs @@ -28,8 +28,10 @@ import { accessSync, constants, existsSync, + lstatSync, mkdirSync, readFileSync, + readlinkSync, realpathSync, statSync, writeFileSync, @@ -42,8 +44,8 @@ export const KINDS = ["cli", "runtime", "system-lib", "python-pkg", "node-pkg", export const NEEDS = ["required", "optional"]; const ENTRY_KEYS = ["id", "kind", "need", "for", "detect", "degrade", "install", "check"]; const DETECT_KEYS = { - cli: { required: ["any"], optional: ["local_bin", "version"] }, - runtime: { required: ["any"], optional: ["local_bin", "version"] }, + cli: { required: ["any"], optional: ["local_bin", "version", "reject_store_alias"] }, + runtime: { required: ["any"], optional: ["local_bin", "version", "reject_store_alias"] }, "system-lib": { required: ["probe"], optional: [] }, "python-pkg": { required: ["any", "import"], optional: [] }, "node-pkg": { required: ["module", "paths"], optional: [] }, @@ -100,6 +102,9 @@ function detectErrors(kind, detect, where) { } if ("any" in detect && !isTextList(detect.any)) errors.push(`${where}.any must be a non-empty list of names`); if ("local_bin" in detect) errors.push(...relativePathErrors(detect.local_bin, `${where}.local_bin`)); + if ("reject_store_alias" in detect && typeof detect.reject_store_alias !== "boolean") { + errors.push(`${where}.reject_store_alias must be true or false`); + } if ("version" in detect) { const v = detect.version; const vw = `${where}.version`; @@ -243,36 +248,66 @@ export function compareVersions(a, b) { return 0; } -function isExecutable(file, platform) { +const FS = { statSync, lstatSync, readlinkSync, accessSync }; + +// The target of App Installer's Python "install me" stub, which opens the Store +// instead of running Python, and the alias names it takes. +const PYTHON_STUB_TARGET = "appinstallerpythonredirector.exe"; +const PYTHON_STUB_NAMES = new Set(["python", "python3"]); + +// A Windows App Execution Alias, how a Store or winget app reaches PATH, is a +// reparse point in a WindowsApps directory that stat cannot open, yet it runs. +// It counts as found unless it is the Python stub, known by its target or, when +// the target cannot be read, by its name. +const isWindowsApps = (dir) => path.basename(dir).toLowerCase() === "windowsapps"; + +function isAppAlias(file, fs) { + if (!isWindowsApps(path.dirname(file))) return false; try { - if (!statSync(file).isFile()) return false; - if (platform !== "win32") accessSync(file, constants.X_OK); - return true; + if (!fs.lstatSync(file).isSymbolicLink()) return false; } catch { return false; } + let target; + try { + target = fs.readlinkSync(file); + } catch { + return !PYTHON_STUB_NAMES.has(path.basename(file).toLowerCase().replace(/\.exe$/, "")); + } + return path.win32.basename(target).toLowerCase() !== PYTHON_STUB_TARGET; +} + +function isExecutable(file, platform, fs = FS) { + try { + if (!fs.statSync(file).isFile()) return false; + if (platform !== "win32") fs.accessSync(file, constants.X_OK); + return true; + } catch { + return platform === "win32" && isAppAlias(file, fs); + } } -// which(name) -> absolute path of the first PATH match, or null. -export function which(name, env = process.env, platform = process.platform) { +// which(name) -> absolute path of the first PATH match, or null. fs is injectable for tests. +// rejectStoreAlias skips WindowsApps directories on win32, so no App Execution Alias matches. +export function which(name, env = process.env, platform = process.platform, fs = FS, rejectStoreAlias = false) { const pathVar = env.PATH ?? env.Path ?? ""; const exts = platform === "win32" ? ["", ...(env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean)] : [""]; for (const dir of pathVar.split(path.delimiter)) { - if (!dir) continue; + if (!dir || (rejectStoreAlias && platform === "win32" && isWindowsApps(dir))) continue; for (const ext of exts) { const candidate = path.join(dir, name + ext); - if (isExecutable(candidate, platform)) return candidate; + if (isExecutable(candidate, platform, fs)) return candidate; } } return null; } -function findLocalBin(rels, cwd, platform) { +function findLocalBin(rels, cwd, platform, fs) { for (const rel of rels ?? []) { let dir = path.resolve(cwd); for (let i = 0; i < LOCAL_BIN_DEPTH; i++) { const candidate = path.join(dir, rel); - if (isExecutable(candidate, platform)) return candidate; + if (isExecutable(candidate, platform, fs)) return candidate; const parent = path.dirname(dir); if (parent === dir) break; dir = parent; @@ -286,9 +321,9 @@ function run(file, args) { return { ok: !r.error && r.status === 0, out: `${r.stdout ?? ""}${r.stderr ?? ""}` }; } -function firstOnPath(names, ctx) { +function firstOnPath(names, ctx, rejectStoreAlias = false) { for (const name of names) { - const found = which(name, ctx.env, ctx.platform); + const found = which(name, ctx.env, ctx.platform, ctx.fs, rejectStoreAlias); if (found) return found; } return null; @@ -314,7 +349,7 @@ export function detectEntry(entry, ctx) { switch (entry.kind) { case "cli": case "runtime": { - const found = firstOnPath(d.any, ctx) ?? findLocalBin(d.local_bin, ctx.cwd, ctx.platform); + const found = firstOnPath(d.any, ctx, d.reject_store_alias) ?? findLocalBin(d.local_bin, ctx.cwd, ctx.platform, ctx.fs); if (!found) { const where = d.local_bin ? `on PATH or as ${d.local_bin.join(", ")}` : "on PATH"; return { status: "missing", detail: `${d.any.join(" or ")} was not found ${where}` }; @@ -331,7 +366,7 @@ export function detectEntry(entry, ctx) { } case "system-lib": { const [bin, ...args] = d.probe.args; - const found = which(bin, ctx.env, ctx.platform); + const found = which(bin, ctx.env, ctx.platform, ctx.fs); if (!found) return { status: "missing", detail: `${bin} was not found on PATH to probe` }; if (new RegExp(d.probe.pattern).test(run(found, args).out)) return { status: "present", detail: found }; return { status: "missing", detail: `${bin} ${args.join(" ")} did not report ${d.probe.pattern}` }; @@ -566,6 +601,7 @@ export function main(argv, ctx = {}, out = {}) { env: ctx.env ?? process.env, platform: ctx.platform ?? process.platform, cwd: ctx.cwd ?? process.cwd(), + fs: ctx.fs ?? FS, stdin: ctx.stdin ?? (() => (process.stdin.isTTY ? "" : readFileSync(0, "utf8"))), }; const io = { stdout: out.stdout ?? ((s) => console.log(s)), stderr: out.stderr ?? ((s) => console.error(s)) }; diff --git a/plugins/desktop-notification/.claude-plugin/plugin.json b/plugins/desktop-notification/.claude-plugin/plugin.json index 0eb091b827..02c908e352 100644 --- a/plugins/desktop-notification/.claude-plugin/plugin.json +++ b/plugins/desktop-notification/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "desktop-notification", - "version": "0.8.1", + "version": "0.8.2", "description": "Alert you when Claude Code needs input: an audible terminal bell, an OSC 9 terminal notification, and an OS-native toast (macOS/Linux) on permission and idle prompts.", "author": { "name": "Melodic Software", diff --git a/plugins/desktop-notification/CHANGELOG.md b/plugins/desktop-notification/CHANGELOG.md index 3a978f0fcb..ecb0a91657 100644 --- a/plugins/desktop-notification/CHANGELOG.md +++ b/plugins/desktop-notification/CHANGELOG.md @@ -3,6 +3,14 @@ All notable changes to the `desktop-notification` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.8.2] - 2026-10-03 + +### Changed + +- **Shared `prerequisites.mjs` synced ([#6084](https://github.com/melodic-software/claude-code-plugins/issues/6084)); no change to this plugin's lib.** + The prerequisite check now counts a Windows App Execution Alias (a Store or winget install on PATH) as found, + except App Installer's Python install stub. A `cli` or `runtime` entry can set `reject_store_alias` to skip aliases instead; no entry in this plugin does. + ## [0.8.1] - 2026-10-03 ### Changed diff --git a/plugins/desktop-notification/lib/prerequisites.mjs b/plugins/desktop-notification/lib/prerequisites.mjs index 57f65a79b5..1e11abf2cc 100755 --- a/plugins/desktop-notification/lib/prerequisites.mjs +++ b/plugins/desktop-notification/lib/prerequisites.mjs @@ -28,8 +28,10 @@ import { accessSync, constants, existsSync, + lstatSync, mkdirSync, readFileSync, + readlinkSync, realpathSync, statSync, writeFileSync, @@ -42,8 +44,8 @@ export const KINDS = ["cli", "runtime", "system-lib", "python-pkg", "node-pkg", export const NEEDS = ["required", "optional"]; const ENTRY_KEYS = ["id", "kind", "need", "for", "detect", "degrade", "install", "check"]; const DETECT_KEYS = { - cli: { required: ["any"], optional: ["local_bin", "version"] }, - runtime: { required: ["any"], optional: ["local_bin", "version"] }, + cli: { required: ["any"], optional: ["local_bin", "version", "reject_store_alias"] }, + runtime: { required: ["any"], optional: ["local_bin", "version", "reject_store_alias"] }, "system-lib": { required: ["probe"], optional: [] }, "python-pkg": { required: ["any", "import"], optional: [] }, "node-pkg": { required: ["module", "paths"], optional: [] }, @@ -100,6 +102,9 @@ function detectErrors(kind, detect, where) { } if ("any" in detect && !isTextList(detect.any)) errors.push(`${where}.any must be a non-empty list of names`); if ("local_bin" in detect) errors.push(...relativePathErrors(detect.local_bin, `${where}.local_bin`)); + if ("reject_store_alias" in detect && typeof detect.reject_store_alias !== "boolean") { + errors.push(`${where}.reject_store_alias must be true or false`); + } if ("version" in detect) { const v = detect.version; const vw = `${where}.version`; @@ -243,36 +248,66 @@ export function compareVersions(a, b) { return 0; } -function isExecutable(file, platform) { +const FS = { statSync, lstatSync, readlinkSync, accessSync }; + +// The target of App Installer's Python "install me" stub, which opens the Store +// instead of running Python, and the alias names it takes. +const PYTHON_STUB_TARGET = "appinstallerpythonredirector.exe"; +const PYTHON_STUB_NAMES = new Set(["python", "python3"]); + +// A Windows App Execution Alias, how a Store or winget app reaches PATH, is a +// reparse point in a WindowsApps directory that stat cannot open, yet it runs. +// It counts as found unless it is the Python stub, known by its target or, when +// the target cannot be read, by its name. +const isWindowsApps = (dir) => path.basename(dir).toLowerCase() === "windowsapps"; + +function isAppAlias(file, fs) { + if (!isWindowsApps(path.dirname(file))) return false; try { - if (!statSync(file).isFile()) return false; - if (platform !== "win32") accessSync(file, constants.X_OK); - return true; + if (!fs.lstatSync(file).isSymbolicLink()) return false; } catch { return false; } + let target; + try { + target = fs.readlinkSync(file); + } catch { + return !PYTHON_STUB_NAMES.has(path.basename(file).toLowerCase().replace(/\.exe$/, "")); + } + return path.win32.basename(target).toLowerCase() !== PYTHON_STUB_TARGET; +} + +function isExecutable(file, platform, fs = FS) { + try { + if (!fs.statSync(file).isFile()) return false; + if (platform !== "win32") fs.accessSync(file, constants.X_OK); + return true; + } catch { + return platform === "win32" && isAppAlias(file, fs); + } } -// which(name) -> absolute path of the first PATH match, or null. -export function which(name, env = process.env, platform = process.platform) { +// which(name) -> absolute path of the first PATH match, or null. fs is injectable for tests. +// rejectStoreAlias skips WindowsApps directories on win32, so no App Execution Alias matches. +export function which(name, env = process.env, platform = process.platform, fs = FS, rejectStoreAlias = false) { const pathVar = env.PATH ?? env.Path ?? ""; const exts = platform === "win32" ? ["", ...(env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean)] : [""]; for (const dir of pathVar.split(path.delimiter)) { - if (!dir) continue; + if (!dir || (rejectStoreAlias && platform === "win32" && isWindowsApps(dir))) continue; for (const ext of exts) { const candidate = path.join(dir, name + ext); - if (isExecutable(candidate, platform)) return candidate; + if (isExecutable(candidate, platform, fs)) return candidate; } } return null; } -function findLocalBin(rels, cwd, platform) { +function findLocalBin(rels, cwd, platform, fs) { for (const rel of rels ?? []) { let dir = path.resolve(cwd); for (let i = 0; i < LOCAL_BIN_DEPTH; i++) { const candidate = path.join(dir, rel); - if (isExecutable(candidate, platform)) return candidate; + if (isExecutable(candidate, platform, fs)) return candidate; const parent = path.dirname(dir); if (parent === dir) break; dir = parent; @@ -286,9 +321,9 @@ function run(file, args) { return { ok: !r.error && r.status === 0, out: `${r.stdout ?? ""}${r.stderr ?? ""}` }; } -function firstOnPath(names, ctx) { +function firstOnPath(names, ctx, rejectStoreAlias = false) { for (const name of names) { - const found = which(name, ctx.env, ctx.platform); + const found = which(name, ctx.env, ctx.platform, ctx.fs, rejectStoreAlias); if (found) return found; } return null; @@ -314,7 +349,7 @@ export function detectEntry(entry, ctx) { switch (entry.kind) { case "cli": case "runtime": { - const found = firstOnPath(d.any, ctx) ?? findLocalBin(d.local_bin, ctx.cwd, ctx.platform); + const found = firstOnPath(d.any, ctx, d.reject_store_alias) ?? findLocalBin(d.local_bin, ctx.cwd, ctx.platform, ctx.fs); if (!found) { const where = d.local_bin ? `on PATH or as ${d.local_bin.join(", ")}` : "on PATH"; return { status: "missing", detail: `${d.any.join(" or ")} was not found ${where}` }; @@ -331,7 +366,7 @@ export function detectEntry(entry, ctx) { } case "system-lib": { const [bin, ...args] = d.probe.args; - const found = which(bin, ctx.env, ctx.platform); + const found = which(bin, ctx.env, ctx.platform, ctx.fs); if (!found) return { status: "missing", detail: `${bin} was not found on PATH to probe` }; if (new RegExp(d.probe.pattern).test(run(found, args).out)) return { status: "present", detail: found }; return { status: "missing", detail: `${bin} ${args.join(" ")} did not report ${d.probe.pattern}` }; @@ -566,6 +601,7 @@ export function main(argv, ctx = {}, out = {}) { env: ctx.env ?? process.env, platform: ctx.platform ?? process.platform, cwd: ctx.cwd ?? process.cwd(), + fs: ctx.fs ?? FS, stdin: ctx.stdin ?? (() => (process.stdin.isTTY ? "" : readFileSync(0, "utf8"))), }; const io = { stdout: out.stdout ?? ((s) => console.log(s)), stderr: out.stderr ?? ((s) => console.error(s)) }; diff --git a/plugins/disk-hygiene/.claude-plugin/plugin.json b/plugins/disk-hygiene/.claude-plugin/plugin.json index 334404acfd..663d6f585d 100644 --- a/plugins/disk-hygiene/.claude-plugin/plugin.json +++ b/plugins/disk-hygiene/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "disk-hygiene", - "version": "0.43.1", + "version": "0.43.2", "description": "Context-aware disk hygiene for arbitrary directory trees: inventories orphaned and temporary artifacts, classifies evidence into review tiers, and offers exact-path cleanup only after a fresh safety preview and explicit per-tier approval. The target is read-only by default; OS-managed paths, links and mount points, VCS-tracked content without the complete checkout evidence bundle, changed entries, and live-handle uncertainty fail closed.", "author": { "name": "Melodic Software", diff --git a/plugins/disk-hygiene/CHANGELOG.md b/plugins/disk-hygiene/CHANGELOG.md index d38a7c1c8d..3231f4ae15 100644 --- a/plugins/disk-hygiene/CHANGELOG.md +++ b/plugins/disk-hygiene/CHANGELOG.md @@ -3,6 +3,14 @@ All notable changes to the `disk-hygiene` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.43.2] - 2026-10-03 + +### Changed + +- **Shared `prerequisites.mjs` synced ([#6084](https://github.com/melodic-software/claude-code-plugins/issues/6084)); no change to this plugin's lib.** + The prerequisite check now counts a Windows App Execution Alias (a Store or winget install on PATH) as found, + except App Installer's Python install stub. A `cli` or `runtime` entry can set `reject_store_alias` to skip aliases instead; no entry in this plugin does. + ## [0.43.1] - 2026-10-03 ### Changed diff --git a/plugins/disk-hygiene/lib/prerequisites.mjs b/plugins/disk-hygiene/lib/prerequisites.mjs index 57f65a79b5..1e11abf2cc 100755 --- a/plugins/disk-hygiene/lib/prerequisites.mjs +++ b/plugins/disk-hygiene/lib/prerequisites.mjs @@ -28,8 +28,10 @@ import { accessSync, constants, existsSync, + lstatSync, mkdirSync, readFileSync, + readlinkSync, realpathSync, statSync, writeFileSync, @@ -42,8 +44,8 @@ export const KINDS = ["cli", "runtime", "system-lib", "python-pkg", "node-pkg", export const NEEDS = ["required", "optional"]; const ENTRY_KEYS = ["id", "kind", "need", "for", "detect", "degrade", "install", "check"]; const DETECT_KEYS = { - cli: { required: ["any"], optional: ["local_bin", "version"] }, - runtime: { required: ["any"], optional: ["local_bin", "version"] }, + cli: { required: ["any"], optional: ["local_bin", "version", "reject_store_alias"] }, + runtime: { required: ["any"], optional: ["local_bin", "version", "reject_store_alias"] }, "system-lib": { required: ["probe"], optional: [] }, "python-pkg": { required: ["any", "import"], optional: [] }, "node-pkg": { required: ["module", "paths"], optional: [] }, @@ -100,6 +102,9 @@ function detectErrors(kind, detect, where) { } if ("any" in detect && !isTextList(detect.any)) errors.push(`${where}.any must be a non-empty list of names`); if ("local_bin" in detect) errors.push(...relativePathErrors(detect.local_bin, `${where}.local_bin`)); + if ("reject_store_alias" in detect && typeof detect.reject_store_alias !== "boolean") { + errors.push(`${where}.reject_store_alias must be true or false`); + } if ("version" in detect) { const v = detect.version; const vw = `${where}.version`; @@ -243,36 +248,66 @@ export function compareVersions(a, b) { return 0; } -function isExecutable(file, platform) { +const FS = { statSync, lstatSync, readlinkSync, accessSync }; + +// The target of App Installer's Python "install me" stub, which opens the Store +// instead of running Python, and the alias names it takes. +const PYTHON_STUB_TARGET = "appinstallerpythonredirector.exe"; +const PYTHON_STUB_NAMES = new Set(["python", "python3"]); + +// A Windows App Execution Alias, how a Store or winget app reaches PATH, is a +// reparse point in a WindowsApps directory that stat cannot open, yet it runs. +// It counts as found unless it is the Python stub, known by its target or, when +// the target cannot be read, by its name. +const isWindowsApps = (dir) => path.basename(dir).toLowerCase() === "windowsapps"; + +function isAppAlias(file, fs) { + if (!isWindowsApps(path.dirname(file))) return false; try { - if (!statSync(file).isFile()) return false; - if (platform !== "win32") accessSync(file, constants.X_OK); - return true; + if (!fs.lstatSync(file).isSymbolicLink()) return false; } catch { return false; } + let target; + try { + target = fs.readlinkSync(file); + } catch { + return !PYTHON_STUB_NAMES.has(path.basename(file).toLowerCase().replace(/\.exe$/, "")); + } + return path.win32.basename(target).toLowerCase() !== PYTHON_STUB_TARGET; +} + +function isExecutable(file, platform, fs = FS) { + try { + if (!fs.statSync(file).isFile()) return false; + if (platform !== "win32") fs.accessSync(file, constants.X_OK); + return true; + } catch { + return platform === "win32" && isAppAlias(file, fs); + } } -// which(name) -> absolute path of the first PATH match, or null. -export function which(name, env = process.env, platform = process.platform) { +// which(name) -> absolute path of the first PATH match, or null. fs is injectable for tests. +// rejectStoreAlias skips WindowsApps directories on win32, so no App Execution Alias matches. +export function which(name, env = process.env, platform = process.platform, fs = FS, rejectStoreAlias = false) { const pathVar = env.PATH ?? env.Path ?? ""; const exts = platform === "win32" ? ["", ...(env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean)] : [""]; for (const dir of pathVar.split(path.delimiter)) { - if (!dir) continue; + if (!dir || (rejectStoreAlias && platform === "win32" && isWindowsApps(dir))) continue; for (const ext of exts) { const candidate = path.join(dir, name + ext); - if (isExecutable(candidate, platform)) return candidate; + if (isExecutable(candidate, platform, fs)) return candidate; } } return null; } -function findLocalBin(rels, cwd, platform) { +function findLocalBin(rels, cwd, platform, fs) { for (const rel of rels ?? []) { let dir = path.resolve(cwd); for (let i = 0; i < LOCAL_BIN_DEPTH; i++) { const candidate = path.join(dir, rel); - if (isExecutable(candidate, platform)) return candidate; + if (isExecutable(candidate, platform, fs)) return candidate; const parent = path.dirname(dir); if (parent === dir) break; dir = parent; @@ -286,9 +321,9 @@ function run(file, args) { return { ok: !r.error && r.status === 0, out: `${r.stdout ?? ""}${r.stderr ?? ""}` }; } -function firstOnPath(names, ctx) { +function firstOnPath(names, ctx, rejectStoreAlias = false) { for (const name of names) { - const found = which(name, ctx.env, ctx.platform); + const found = which(name, ctx.env, ctx.platform, ctx.fs, rejectStoreAlias); if (found) return found; } return null; @@ -314,7 +349,7 @@ export function detectEntry(entry, ctx) { switch (entry.kind) { case "cli": case "runtime": { - const found = firstOnPath(d.any, ctx) ?? findLocalBin(d.local_bin, ctx.cwd, ctx.platform); + const found = firstOnPath(d.any, ctx, d.reject_store_alias) ?? findLocalBin(d.local_bin, ctx.cwd, ctx.platform, ctx.fs); if (!found) { const where = d.local_bin ? `on PATH or as ${d.local_bin.join(", ")}` : "on PATH"; return { status: "missing", detail: `${d.any.join(" or ")} was not found ${where}` }; @@ -331,7 +366,7 @@ export function detectEntry(entry, ctx) { } case "system-lib": { const [bin, ...args] = d.probe.args; - const found = which(bin, ctx.env, ctx.platform); + const found = which(bin, ctx.env, ctx.platform, ctx.fs); if (!found) return { status: "missing", detail: `${bin} was not found on PATH to probe` }; if (new RegExp(d.probe.pattern).test(run(found, args).out)) return { status: "present", detail: found }; return { status: "missing", detail: `${bin} ${args.join(" ")} did not report ${d.probe.pattern}` }; @@ -566,6 +601,7 @@ export function main(argv, ctx = {}, out = {}) { env: ctx.env ?? process.env, platform: ctx.platform ?? process.platform, cwd: ctx.cwd ?? process.cwd(), + fs: ctx.fs ?? FS, stdin: ctx.stdin ?? (() => (process.stdin.isTTY ? "" : readFileSync(0, "utf8"))), }; const io = { stdout: out.stdout ?? ((s) => console.log(s)), stderr: out.stderr ?? ((s) => console.error(s)) }; diff --git a/plugins/docs-hygiene/.claude-plugin/plugin.json b/plugins/docs-hygiene/.claude-plugin/plugin.json index 2ccd97a882..c168e84ca7 100644 --- a/plugins/docs-hygiene/.claude-plugin/plugin.json +++ b/plugins/docs-hygiene/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "docs-hygiene", - "version": "0.26.3", + "version": "0.26.4", "description": "Documentation-hygiene toolkit: compress (trim markdown with a semantic-diff check), audit-noise (classify markdown noise), extract-ssot (deduplicate into a single source of truth), audit-encapsulation (citations into skill-private files), rename-references (stale references after renames), audit-derivability (does a doc earn its existence), audit-progressive-disclosure (load tiers), write-for-agents and write-for-humans (authoring). Setup checks markdownlint-cli2.", "author": { "name": "Melodic Software", diff --git a/plugins/docs-hygiene/CHANGELOG.md b/plugins/docs-hygiene/CHANGELOG.md index 718bc64a86..cb4ae80d6f 100644 --- a/plugins/docs-hygiene/CHANGELOG.md +++ b/plugins/docs-hygiene/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog: docs-hygiene plugin +## [0.26.4] - 2026-10-03 + +### Changed + +- **Shared `prerequisites.mjs` synced ([#6084](https://github.com/melodic-software/claude-code-plugins/issues/6084)); no change to this plugin's lib.** + The prerequisite check now counts a Windows App Execution Alias (a Store or winget install on PATH) as found, + except App Installer's Python install stub. A `cli` or `runtime` entry can set `reject_store_alias` to skip aliases instead; no entry in this plugin does. + ## [0.26.3] - 2026-10-03 ### Changed diff --git a/plugins/docs-hygiene/lib/prerequisites.mjs b/plugins/docs-hygiene/lib/prerequisites.mjs index 57f65a79b5..1e11abf2cc 100755 --- a/plugins/docs-hygiene/lib/prerequisites.mjs +++ b/plugins/docs-hygiene/lib/prerequisites.mjs @@ -28,8 +28,10 @@ import { accessSync, constants, existsSync, + lstatSync, mkdirSync, readFileSync, + readlinkSync, realpathSync, statSync, writeFileSync, @@ -42,8 +44,8 @@ export const KINDS = ["cli", "runtime", "system-lib", "python-pkg", "node-pkg", export const NEEDS = ["required", "optional"]; const ENTRY_KEYS = ["id", "kind", "need", "for", "detect", "degrade", "install", "check"]; const DETECT_KEYS = { - cli: { required: ["any"], optional: ["local_bin", "version"] }, - runtime: { required: ["any"], optional: ["local_bin", "version"] }, + cli: { required: ["any"], optional: ["local_bin", "version", "reject_store_alias"] }, + runtime: { required: ["any"], optional: ["local_bin", "version", "reject_store_alias"] }, "system-lib": { required: ["probe"], optional: [] }, "python-pkg": { required: ["any", "import"], optional: [] }, "node-pkg": { required: ["module", "paths"], optional: [] }, @@ -100,6 +102,9 @@ function detectErrors(kind, detect, where) { } if ("any" in detect && !isTextList(detect.any)) errors.push(`${where}.any must be a non-empty list of names`); if ("local_bin" in detect) errors.push(...relativePathErrors(detect.local_bin, `${where}.local_bin`)); + if ("reject_store_alias" in detect && typeof detect.reject_store_alias !== "boolean") { + errors.push(`${where}.reject_store_alias must be true or false`); + } if ("version" in detect) { const v = detect.version; const vw = `${where}.version`; @@ -243,36 +248,66 @@ export function compareVersions(a, b) { return 0; } -function isExecutable(file, platform) { +const FS = { statSync, lstatSync, readlinkSync, accessSync }; + +// The target of App Installer's Python "install me" stub, which opens the Store +// instead of running Python, and the alias names it takes. +const PYTHON_STUB_TARGET = "appinstallerpythonredirector.exe"; +const PYTHON_STUB_NAMES = new Set(["python", "python3"]); + +// A Windows App Execution Alias, how a Store or winget app reaches PATH, is a +// reparse point in a WindowsApps directory that stat cannot open, yet it runs. +// It counts as found unless it is the Python stub, known by its target or, when +// the target cannot be read, by its name. +const isWindowsApps = (dir) => path.basename(dir).toLowerCase() === "windowsapps"; + +function isAppAlias(file, fs) { + if (!isWindowsApps(path.dirname(file))) return false; try { - if (!statSync(file).isFile()) return false; - if (platform !== "win32") accessSync(file, constants.X_OK); - return true; + if (!fs.lstatSync(file).isSymbolicLink()) return false; } catch { return false; } + let target; + try { + target = fs.readlinkSync(file); + } catch { + return !PYTHON_STUB_NAMES.has(path.basename(file).toLowerCase().replace(/\.exe$/, "")); + } + return path.win32.basename(target).toLowerCase() !== PYTHON_STUB_TARGET; +} + +function isExecutable(file, platform, fs = FS) { + try { + if (!fs.statSync(file).isFile()) return false; + if (platform !== "win32") fs.accessSync(file, constants.X_OK); + return true; + } catch { + return platform === "win32" && isAppAlias(file, fs); + } } -// which(name) -> absolute path of the first PATH match, or null. -export function which(name, env = process.env, platform = process.platform) { +// which(name) -> absolute path of the first PATH match, or null. fs is injectable for tests. +// rejectStoreAlias skips WindowsApps directories on win32, so no App Execution Alias matches. +export function which(name, env = process.env, platform = process.platform, fs = FS, rejectStoreAlias = false) { const pathVar = env.PATH ?? env.Path ?? ""; const exts = platform === "win32" ? ["", ...(env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean)] : [""]; for (const dir of pathVar.split(path.delimiter)) { - if (!dir) continue; + if (!dir || (rejectStoreAlias && platform === "win32" && isWindowsApps(dir))) continue; for (const ext of exts) { const candidate = path.join(dir, name + ext); - if (isExecutable(candidate, platform)) return candidate; + if (isExecutable(candidate, platform, fs)) return candidate; } } return null; } -function findLocalBin(rels, cwd, platform) { +function findLocalBin(rels, cwd, platform, fs) { for (const rel of rels ?? []) { let dir = path.resolve(cwd); for (let i = 0; i < LOCAL_BIN_DEPTH; i++) { const candidate = path.join(dir, rel); - if (isExecutable(candidate, platform)) return candidate; + if (isExecutable(candidate, platform, fs)) return candidate; const parent = path.dirname(dir); if (parent === dir) break; dir = parent; @@ -286,9 +321,9 @@ function run(file, args) { return { ok: !r.error && r.status === 0, out: `${r.stdout ?? ""}${r.stderr ?? ""}` }; } -function firstOnPath(names, ctx) { +function firstOnPath(names, ctx, rejectStoreAlias = false) { for (const name of names) { - const found = which(name, ctx.env, ctx.platform); + const found = which(name, ctx.env, ctx.platform, ctx.fs, rejectStoreAlias); if (found) return found; } return null; @@ -314,7 +349,7 @@ export function detectEntry(entry, ctx) { switch (entry.kind) { case "cli": case "runtime": { - const found = firstOnPath(d.any, ctx) ?? findLocalBin(d.local_bin, ctx.cwd, ctx.platform); + const found = firstOnPath(d.any, ctx, d.reject_store_alias) ?? findLocalBin(d.local_bin, ctx.cwd, ctx.platform, ctx.fs); if (!found) { const where = d.local_bin ? `on PATH or as ${d.local_bin.join(", ")}` : "on PATH"; return { status: "missing", detail: `${d.any.join(" or ")} was not found ${where}` }; @@ -331,7 +366,7 @@ export function detectEntry(entry, ctx) { } case "system-lib": { const [bin, ...args] = d.probe.args; - const found = which(bin, ctx.env, ctx.platform); + const found = which(bin, ctx.env, ctx.platform, ctx.fs); if (!found) return { status: "missing", detail: `${bin} was not found on PATH to probe` }; if (new RegExp(d.probe.pattern).test(run(found, args).out)) return { status: "present", detail: found }; return { status: "missing", detail: `${bin} ${args.join(" ")} did not report ${d.probe.pattern}` }; @@ -566,6 +601,7 @@ export function main(argv, ctx = {}, out = {}) { env: ctx.env ?? process.env, platform: ctx.platform ?? process.platform, cwd: ctx.cwd ?? process.cwd(), + fs: ctx.fs ?? FS, stdin: ctx.stdin ?? (() => (process.stdin.isTTY ? "" : readFileSync(0, "utf8"))), }; const io = { stdout: out.stdout ?? ((s) => console.log(s)), stderr: out.stderr ?? ((s) => console.error(s)) }; diff --git a/plugins/docs-naming/.claude-plugin/plugin.json b/plugins/docs-naming/.claude-plugin/plugin.json index b88294afe5..97af81d9f3 100644 --- a/plugins/docs-naming/.claude-plugin/plugin.json +++ b/plugins/docs-naming/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "docs-naming", - "version": "0.2.4", + "version": "0.2.5", "description": "File-name toolkit that plans, applies, and enforces a casing rule across a tracked tree: setup (the one configuration surface), audit-file-names (read-only inventory plus the reference sweep, writing the rename plan), realign-file-names (the executor, one human acceptance per file), and generate-file-name-gate (emits the standalone check that keeps the tree from drifting back).", "author": { "name": "Melodic Software", diff --git a/plugins/docs-naming/CHANGELOG.md b/plugins/docs-naming/CHANGELOG.md index 228ea016b8..2f679fb2c4 100644 --- a/plugins/docs-naming/CHANGELOG.md +++ b/plugins/docs-naming/CHANGELOG.md @@ -3,6 +3,14 @@ All notable changes to the `docs-naming` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.2.5] - 2026-10-03 + +### Changed + +- **Shared `prerequisites.mjs` synced ([#6084](https://github.com/melodic-software/claude-code-plugins/issues/6084)); no change to this plugin's lib.** + The prerequisite check now counts a Windows App Execution Alias (a Store or winget install on PATH) as found, + except App Installer's Python install stub. A `cli` or `runtime` entry can set `reject_store_alias` to skip aliases instead; no entry in this plugin does. + ## [0.2.4] - 2026-10-03 ### Changed diff --git a/plugins/docs-naming/lib/prerequisites.mjs b/plugins/docs-naming/lib/prerequisites.mjs index 57f65a79b5..1e11abf2cc 100755 --- a/plugins/docs-naming/lib/prerequisites.mjs +++ b/plugins/docs-naming/lib/prerequisites.mjs @@ -28,8 +28,10 @@ import { accessSync, constants, existsSync, + lstatSync, mkdirSync, readFileSync, + readlinkSync, realpathSync, statSync, writeFileSync, @@ -42,8 +44,8 @@ export const KINDS = ["cli", "runtime", "system-lib", "python-pkg", "node-pkg", export const NEEDS = ["required", "optional"]; const ENTRY_KEYS = ["id", "kind", "need", "for", "detect", "degrade", "install", "check"]; const DETECT_KEYS = { - cli: { required: ["any"], optional: ["local_bin", "version"] }, - runtime: { required: ["any"], optional: ["local_bin", "version"] }, + cli: { required: ["any"], optional: ["local_bin", "version", "reject_store_alias"] }, + runtime: { required: ["any"], optional: ["local_bin", "version", "reject_store_alias"] }, "system-lib": { required: ["probe"], optional: [] }, "python-pkg": { required: ["any", "import"], optional: [] }, "node-pkg": { required: ["module", "paths"], optional: [] }, @@ -100,6 +102,9 @@ function detectErrors(kind, detect, where) { } if ("any" in detect && !isTextList(detect.any)) errors.push(`${where}.any must be a non-empty list of names`); if ("local_bin" in detect) errors.push(...relativePathErrors(detect.local_bin, `${where}.local_bin`)); + if ("reject_store_alias" in detect && typeof detect.reject_store_alias !== "boolean") { + errors.push(`${where}.reject_store_alias must be true or false`); + } if ("version" in detect) { const v = detect.version; const vw = `${where}.version`; @@ -243,36 +248,66 @@ export function compareVersions(a, b) { return 0; } -function isExecutable(file, platform) { +const FS = { statSync, lstatSync, readlinkSync, accessSync }; + +// The target of App Installer's Python "install me" stub, which opens the Store +// instead of running Python, and the alias names it takes. +const PYTHON_STUB_TARGET = "appinstallerpythonredirector.exe"; +const PYTHON_STUB_NAMES = new Set(["python", "python3"]); + +// A Windows App Execution Alias, how a Store or winget app reaches PATH, is a +// reparse point in a WindowsApps directory that stat cannot open, yet it runs. +// It counts as found unless it is the Python stub, known by its target or, when +// the target cannot be read, by its name. +const isWindowsApps = (dir) => path.basename(dir).toLowerCase() === "windowsapps"; + +function isAppAlias(file, fs) { + if (!isWindowsApps(path.dirname(file))) return false; try { - if (!statSync(file).isFile()) return false; - if (platform !== "win32") accessSync(file, constants.X_OK); - return true; + if (!fs.lstatSync(file).isSymbolicLink()) return false; } catch { return false; } + let target; + try { + target = fs.readlinkSync(file); + } catch { + return !PYTHON_STUB_NAMES.has(path.basename(file).toLowerCase().replace(/\.exe$/, "")); + } + return path.win32.basename(target).toLowerCase() !== PYTHON_STUB_TARGET; +} + +function isExecutable(file, platform, fs = FS) { + try { + if (!fs.statSync(file).isFile()) return false; + if (platform !== "win32") fs.accessSync(file, constants.X_OK); + return true; + } catch { + return platform === "win32" && isAppAlias(file, fs); + } } -// which(name) -> absolute path of the first PATH match, or null. -export function which(name, env = process.env, platform = process.platform) { +// which(name) -> absolute path of the first PATH match, or null. fs is injectable for tests. +// rejectStoreAlias skips WindowsApps directories on win32, so no App Execution Alias matches. +export function which(name, env = process.env, platform = process.platform, fs = FS, rejectStoreAlias = false) { const pathVar = env.PATH ?? env.Path ?? ""; const exts = platform === "win32" ? ["", ...(env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean)] : [""]; for (const dir of pathVar.split(path.delimiter)) { - if (!dir) continue; + if (!dir || (rejectStoreAlias && platform === "win32" && isWindowsApps(dir))) continue; for (const ext of exts) { const candidate = path.join(dir, name + ext); - if (isExecutable(candidate, platform)) return candidate; + if (isExecutable(candidate, platform, fs)) return candidate; } } return null; } -function findLocalBin(rels, cwd, platform) { +function findLocalBin(rels, cwd, platform, fs) { for (const rel of rels ?? []) { let dir = path.resolve(cwd); for (let i = 0; i < LOCAL_BIN_DEPTH; i++) { const candidate = path.join(dir, rel); - if (isExecutable(candidate, platform)) return candidate; + if (isExecutable(candidate, platform, fs)) return candidate; const parent = path.dirname(dir); if (parent === dir) break; dir = parent; @@ -286,9 +321,9 @@ function run(file, args) { return { ok: !r.error && r.status === 0, out: `${r.stdout ?? ""}${r.stderr ?? ""}` }; } -function firstOnPath(names, ctx) { +function firstOnPath(names, ctx, rejectStoreAlias = false) { for (const name of names) { - const found = which(name, ctx.env, ctx.platform); + const found = which(name, ctx.env, ctx.platform, ctx.fs, rejectStoreAlias); if (found) return found; } return null; @@ -314,7 +349,7 @@ export function detectEntry(entry, ctx) { switch (entry.kind) { case "cli": case "runtime": { - const found = firstOnPath(d.any, ctx) ?? findLocalBin(d.local_bin, ctx.cwd, ctx.platform); + const found = firstOnPath(d.any, ctx, d.reject_store_alias) ?? findLocalBin(d.local_bin, ctx.cwd, ctx.platform, ctx.fs); if (!found) { const where = d.local_bin ? `on PATH or as ${d.local_bin.join(", ")}` : "on PATH"; return { status: "missing", detail: `${d.any.join(" or ")} was not found ${where}` }; @@ -331,7 +366,7 @@ export function detectEntry(entry, ctx) { } case "system-lib": { const [bin, ...args] = d.probe.args; - const found = which(bin, ctx.env, ctx.platform); + const found = which(bin, ctx.env, ctx.platform, ctx.fs); if (!found) return { status: "missing", detail: `${bin} was not found on PATH to probe` }; if (new RegExp(d.probe.pattern).test(run(found, args).out)) return { status: "present", detail: found }; return { status: "missing", detail: `${bin} ${args.join(" ")} did not report ${d.probe.pattern}` }; @@ -566,6 +601,7 @@ export function main(argv, ctx = {}, out = {}) { env: ctx.env ?? process.env, platform: ctx.platform ?? process.platform, cwd: ctx.cwd ?? process.cwd(), + fs: ctx.fs ?? FS, stdin: ctx.stdin ?? (() => (process.stdin.isTTY ? "" : readFileSync(0, "utf8"))), }; const io = { stdout: out.stdout ?? ((s) => console.log(s)), stderr: out.stderr ?? ((s) => console.error(s)) }; diff --git a/plugins/dometrain/.claude-plugin/plugin.json b/plugins/dometrain/.claude-plugin/plugin.json index 1fed092f33..c9a8915821 100644 --- a/plugins/dometrain/.claude-plugin/plugin.json +++ b/plugins/dometrain/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "dometrain", - "version": "0.6.1", + "version": "0.6.2", "description": "Dometrain course-content grounding skills for a Dometrain MCP server: search lessons, pull curated lesson documents with on-screen code, and cite timestamped deep links. Requires an active Dometrain Pro subscription. Ships no server: install dometrain-mcp for the bundled one, or configure your own user-scope dometrain server. Includes a setup check and a grounding usage skill kept in sync with Dometrain's own official Claude Code plugin.", "author": { "name": "Melodic Software", diff --git a/plugins/dometrain/CHANGELOG.md b/plugins/dometrain/CHANGELOG.md index 72f3c64b4f..ac705dfa90 100644 --- a/plugins/dometrain/CHANGELOG.md +++ b/plugins/dometrain/CHANGELOG.md @@ -3,6 +3,14 @@ All notable changes to the `dometrain` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.6.2] - 2026-10-03 + +### Changed + +- **Shared `prerequisites.mjs` synced ([#6084](https://github.com/melodic-software/claude-code-plugins/issues/6084)); no change to this plugin's lib.** + The prerequisite check now counts a Windows App Execution Alias (a Store or winget install on PATH) as found, + except App Installer's Python install stub. A `cli` or `runtime` entry can set `reject_store_alias` to skip aliases instead; no entry in this plugin does. + ## [0.6.1] - 2026-10-03 ### Changed diff --git a/plugins/dometrain/lib/prerequisites.mjs b/plugins/dometrain/lib/prerequisites.mjs index 57f65a79b5..1e11abf2cc 100755 --- a/plugins/dometrain/lib/prerequisites.mjs +++ b/plugins/dometrain/lib/prerequisites.mjs @@ -28,8 +28,10 @@ import { accessSync, constants, existsSync, + lstatSync, mkdirSync, readFileSync, + readlinkSync, realpathSync, statSync, writeFileSync, @@ -42,8 +44,8 @@ export const KINDS = ["cli", "runtime", "system-lib", "python-pkg", "node-pkg", export const NEEDS = ["required", "optional"]; const ENTRY_KEYS = ["id", "kind", "need", "for", "detect", "degrade", "install", "check"]; const DETECT_KEYS = { - cli: { required: ["any"], optional: ["local_bin", "version"] }, - runtime: { required: ["any"], optional: ["local_bin", "version"] }, + cli: { required: ["any"], optional: ["local_bin", "version", "reject_store_alias"] }, + runtime: { required: ["any"], optional: ["local_bin", "version", "reject_store_alias"] }, "system-lib": { required: ["probe"], optional: [] }, "python-pkg": { required: ["any", "import"], optional: [] }, "node-pkg": { required: ["module", "paths"], optional: [] }, @@ -100,6 +102,9 @@ function detectErrors(kind, detect, where) { } if ("any" in detect && !isTextList(detect.any)) errors.push(`${where}.any must be a non-empty list of names`); if ("local_bin" in detect) errors.push(...relativePathErrors(detect.local_bin, `${where}.local_bin`)); + if ("reject_store_alias" in detect && typeof detect.reject_store_alias !== "boolean") { + errors.push(`${where}.reject_store_alias must be true or false`); + } if ("version" in detect) { const v = detect.version; const vw = `${where}.version`; @@ -243,36 +248,66 @@ export function compareVersions(a, b) { return 0; } -function isExecutable(file, platform) { +const FS = { statSync, lstatSync, readlinkSync, accessSync }; + +// The target of App Installer's Python "install me" stub, which opens the Store +// instead of running Python, and the alias names it takes. +const PYTHON_STUB_TARGET = "appinstallerpythonredirector.exe"; +const PYTHON_STUB_NAMES = new Set(["python", "python3"]); + +// A Windows App Execution Alias, how a Store or winget app reaches PATH, is a +// reparse point in a WindowsApps directory that stat cannot open, yet it runs. +// It counts as found unless it is the Python stub, known by its target or, when +// the target cannot be read, by its name. +const isWindowsApps = (dir) => path.basename(dir).toLowerCase() === "windowsapps"; + +function isAppAlias(file, fs) { + if (!isWindowsApps(path.dirname(file))) return false; try { - if (!statSync(file).isFile()) return false; - if (platform !== "win32") accessSync(file, constants.X_OK); - return true; + if (!fs.lstatSync(file).isSymbolicLink()) return false; } catch { return false; } + let target; + try { + target = fs.readlinkSync(file); + } catch { + return !PYTHON_STUB_NAMES.has(path.basename(file).toLowerCase().replace(/\.exe$/, "")); + } + return path.win32.basename(target).toLowerCase() !== PYTHON_STUB_TARGET; +} + +function isExecutable(file, platform, fs = FS) { + try { + if (!fs.statSync(file).isFile()) return false; + if (platform !== "win32") fs.accessSync(file, constants.X_OK); + return true; + } catch { + return platform === "win32" && isAppAlias(file, fs); + } } -// which(name) -> absolute path of the first PATH match, or null. -export function which(name, env = process.env, platform = process.platform) { +// which(name) -> absolute path of the first PATH match, or null. fs is injectable for tests. +// rejectStoreAlias skips WindowsApps directories on win32, so no App Execution Alias matches. +export function which(name, env = process.env, platform = process.platform, fs = FS, rejectStoreAlias = false) { const pathVar = env.PATH ?? env.Path ?? ""; const exts = platform === "win32" ? ["", ...(env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean)] : [""]; for (const dir of pathVar.split(path.delimiter)) { - if (!dir) continue; + if (!dir || (rejectStoreAlias && platform === "win32" && isWindowsApps(dir))) continue; for (const ext of exts) { const candidate = path.join(dir, name + ext); - if (isExecutable(candidate, platform)) return candidate; + if (isExecutable(candidate, platform, fs)) return candidate; } } return null; } -function findLocalBin(rels, cwd, platform) { +function findLocalBin(rels, cwd, platform, fs) { for (const rel of rels ?? []) { let dir = path.resolve(cwd); for (let i = 0; i < LOCAL_BIN_DEPTH; i++) { const candidate = path.join(dir, rel); - if (isExecutable(candidate, platform)) return candidate; + if (isExecutable(candidate, platform, fs)) return candidate; const parent = path.dirname(dir); if (parent === dir) break; dir = parent; @@ -286,9 +321,9 @@ function run(file, args) { return { ok: !r.error && r.status === 0, out: `${r.stdout ?? ""}${r.stderr ?? ""}` }; } -function firstOnPath(names, ctx) { +function firstOnPath(names, ctx, rejectStoreAlias = false) { for (const name of names) { - const found = which(name, ctx.env, ctx.platform); + const found = which(name, ctx.env, ctx.platform, ctx.fs, rejectStoreAlias); if (found) return found; } return null; @@ -314,7 +349,7 @@ export function detectEntry(entry, ctx) { switch (entry.kind) { case "cli": case "runtime": { - const found = firstOnPath(d.any, ctx) ?? findLocalBin(d.local_bin, ctx.cwd, ctx.platform); + const found = firstOnPath(d.any, ctx, d.reject_store_alias) ?? findLocalBin(d.local_bin, ctx.cwd, ctx.platform, ctx.fs); if (!found) { const where = d.local_bin ? `on PATH or as ${d.local_bin.join(", ")}` : "on PATH"; return { status: "missing", detail: `${d.any.join(" or ")} was not found ${where}` }; @@ -331,7 +366,7 @@ export function detectEntry(entry, ctx) { } case "system-lib": { const [bin, ...args] = d.probe.args; - const found = which(bin, ctx.env, ctx.platform); + const found = which(bin, ctx.env, ctx.platform, ctx.fs); if (!found) return { status: "missing", detail: `${bin} was not found on PATH to probe` }; if (new RegExp(d.probe.pattern).test(run(found, args).out)) return { status: "present", detail: found }; return { status: "missing", detail: `${bin} ${args.join(" ")} did not report ${d.probe.pattern}` }; @@ -566,6 +601,7 @@ export function main(argv, ctx = {}, out = {}) { env: ctx.env ?? process.env, platform: ctx.platform ?? process.platform, cwd: ctx.cwd ?? process.cwd(), + fs: ctx.fs ?? FS, stdin: ctx.stdin ?? (() => (process.stdin.isTTY ? "" : readFileSync(0, "utf8"))), }; const io = { stdout: out.stdout ?? ((s) => console.log(s)), stderr: out.stderr ?? ((s) => console.error(s)) }; diff --git a/plugins/eol-normalizer/.claude-plugin/plugin.json b/plugins/eol-normalizer/.claude-plugin/plugin.json index 5f89c64619..530a156230 100644 --- a/plugins/eol-normalizer/.claude-plugin/plugin.json +++ b/plugins/eol-normalizer/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "eol-normalizer", - "version": "0.9.1", + "version": "0.9.2", "description": "Normalize a written file's working-tree line endings to its .gitattributes eol value on edit: symmetric CRLF/LF driven by git check-attr, advisory and never blocking.", "author": { "name": "Melodic Software", diff --git a/plugins/eol-normalizer/CHANGELOG.md b/plugins/eol-normalizer/CHANGELOG.md index ffc60e53da..f2acc17218 100644 --- a/plugins/eol-normalizer/CHANGELOG.md +++ b/plugins/eol-normalizer/CHANGELOG.md @@ -3,6 +3,14 @@ All notable changes to the `eol-normalizer` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.9.2] - 2026-10-03 + +### Changed + +- **Shared `prerequisites.mjs` synced ([#6084](https://github.com/melodic-software/claude-code-plugins/issues/6084)); no change to this plugin's lib.** + The prerequisite check now counts a Windows App Execution Alias (a Store or winget install on PATH) as found, + except App Installer's Python install stub. A `cli` or `runtime` entry can set `reject_store_alias` to skip aliases instead; no entry in this plugin does. + ## [0.9.1] - 2026-10-03 ### Changed diff --git a/plugins/eol-normalizer/lib/prerequisites.mjs b/plugins/eol-normalizer/lib/prerequisites.mjs index 57f65a79b5..1e11abf2cc 100755 --- a/plugins/eol-normalizer/lib/prerequisites.mjs +++ b/plugins/eol-normalizer/lib/prerequisites.mjs @@ -28,8 +28,10 @@ import { accessSync, constants, existsSync, + lstatSync, mkdirSync, readFileSync, + readlinkSync, realpathSync, statSync, writeFileSync, @@ -42,8 +44,8 @@ export const KINDS = ["cli", "runtime", "system-lib", "python-pkg", "node-pkg", export const NEEDS = ["required", "optional"]; const ENTRY_KEYS = ["id", "kind", "need", "for", "detect", "degrade", "install", "check"]; const DETECT_KEYS = { - cli: { required: ["any"], optional: ["local_bin", "version"] }, - runtime: { required: ["any"], optional: ["local_bin", "version"] }, + cli: { required: ["any"], optional: ["local_bin", "version", "reject_store_alias"] }, + runtime: { required: ["any"], optional: ["local_bin", "version", "reject_store_alias"] }, "system-lib": { required: ["probe"], optional: [] }, "python-pkg": { required: ["any", "import"], optional: [] }, "node-pkg": { required: ["module", "paths"], optional: [] }, @@ -100,6 +102,9 @@ function detectErrors(kind, detect, where) { } if ("any" in detect && !isTextList(detect.any)) errors.push(`${where}.any must be a non-empty list of names`); if ("local_bin" in detect) errors.push(...relativePathErrors(detect.local_bin, `${where}.local_bin`)); + if ("reject_store_alias" in detect && typeof detect.reject_store_alias !== "boolean") { + errors.push(`${where}.reject_store_alias must be true or false`); + } if ("version" in detect) { const v = detect.version; const vw = `${where}.version`; @@ -243,36 +248,66 @@ export function compareVersions(a, b) { return 0; } -function isExecutable(file, platform) { +const FS = { statSync, lstatSync, readlinkSync, accessSync }; + +// The target of App Installer's Python "install me" stub, which opens the Store +// instead of running Python, and the alias names it takes. +const PYTHON_STUB_TARGET = "appinstallerpythonredirector.exe"; +const PYTHON_STUB_NAMES = new Set(["python", "python3"]); + +// A Windows App Execution Alias, how a Store or winget app reaches PATH, is a +// reparse point in a WindowsApps directory that stat cannot open, yet it runs. +// It counts as found unless it is the Python stub, known by its target or, when +// the target cannot be read, by its name. +const isWindowsApps = (dir) => path.basename(dir).toLowerCase() === "windowsapps"; + +function isAppAlias(file, fs) { + if (!isWindowsApps(path.dirname(file))) return false; try { - if (!statSync(file).isFile()) return false; - if (platform !== "win32") accessSync(file, constants.X_OK); - return true; + if (!fs.lstatSync(file).isSymbolicLink()) return false; } catch { return false; } + let target; + try { + target = fs.readlinkSync(file); + } catch { + return !PYTHON_STUB_NAMES.has(path.basename(file).toLowerCase().replace(/\.exe$/, "")); + } + return path.win32.basename(target).toLowerCase() !== PYTHON_STUB_TARGET; +} + +function isExecutable(file, platform, fs = FS) { + try { + if (!fs.statSync(file).isFile()) return false; + if (platform !== "win32") fs.accessSync(file, constants.X_OK); + return true; + } catch { + return platform === "win32" && isAppAlias(file, fs); + } } -// which(name) -> absolute path of the first PATH match, or null. -export function which(name, env = process.env, platform = process.platform) { +// which(name) -> absolute path of the first PATH match, or null. fs is injectable for tests. +// rejectStoreAlias skips WindowsApps directories on win32, so no App Execution Alias matches. +export function which(name, env = process.env, platform = process.platform, fs = FS, rejectStoreAlias = false) { const pathVar = env.PATH ?? env.Path ?? ""; const exts = platform === "win32" ? ["", ...(env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean)] : [""]; for (const dir of pathVar.split(path.delimiter)) { - if (!dir) continue; + if (!dir || (rejectStoreAlias && platform === "win32" && isWindowsApps(dir))) continue; for (const ext of exts) { const candidate = path.join(dir, name + ext); - if (isExecutable(candidate, platform)) return candidate; + if (isExecutable(candidate, platform, fs)) return candidate; } } return null; } -function findLocalBin(rels, cwd, platform) { +function findLocalBin(rels, cwd, platform, fs) { for (const rel of rels ?? []) { let dir = path.resolve(cwd); for (let i = 0; i < LOCAL_BIN_DEPTH; i++) { const candidate = path.join(dir, rel); - if (isExecutable(candidate, platform)) return candidate; + if (isExecutable(candidate, platform, fs)) return candidate; const parent = path.dirname(dir); if (parent === dir) break; dir = parent; @@ -286,9 +321,9 @@ function run(file, args) { return { ok: !r.error && r.status === 0, out: `${r.stdout ?? ""}${r.stderr ?? ""}` }; } -function firstOnPath(names, ctx) { +function firstOnPath(names, ctx, rejectStoreAlias = false) { for (const name of names) { - const found = which(name, ctx.env, ctx.platform); + const found = which(name, ctx.env, ctx.platform, ctx.fs, rejectStoreAlias); if (found) return found; } return null; @@ -314,7 +349,7 @@ export function detectEntry(entry, ctx) { switch (entry.kind) { case "cli": case "runtime": { - const found = firstOnPath(d.any, ctx) ?? findLocalBin(d.local_bin, ctx.cwd, ctx.platform); + const found = firstOnPath(d.any, ctx, d.reject_store_alias) ?? findLocalBin(d.local_bin, ctx.cwd, ctx.platform, ctx.fs); if (!found) { const where = d.local_bin ? `on PATH or as ${d.local_bin.join(", ")}` : "on PATH"; return { status: "missing", detail: `${d.any.join(" or ")} was not found ${where}` }; @@ -331,7 +366,7 @@ export function detectEntry(entry, ctx) { } case "system-lib": { const [bin, ...args] = d.probe.args; - const found = which(bin, ctx.env, ctx.platform); + const found = which(bin, ctx.env, ctx.platform, ctx.fs); if (!found) return { status: "missing", detail: `${bin} was not found on PATH to probe` }; if (new RegExp(d.probe.pattern).test(run(found, args).out)) return { status: "present", detail: found }; return { status: "missing", detail: `${bin} ${args.join(" ")} did not report ${d.probe.pattern}` }; @@ -566,6 +601,7 @@ export function main(argv, ctx = {}, out = {}) { env: ctx.env ?? process.env, platform: ctx.platform ?? process.platform, cwd: ctx.cwd ?? process.cwd(), + fs: ctx.fs ?? FS, stdin: ctx.stdin ?? (() => (process.stdin.isTTY ? "" : readFileSync(0, "utf8"))), }; const io = { stdout: out.stdout ?? ((s) => console.log(s)), stderr: out.stderr ?? ((s) => console.error(s)) }; diff --git a/plugins/evals/.claude-plugin/plugin.json b/plugins/evals/.claude-plugin/plugin.json index 81c38a16d4..92a30cace4 100644 --- a/plugins/evals/.claude-plugin/plugin.json +++ b/plugins/evals/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "evals", - "version": "0.5.2", + "version": "0.5.3", "description": "LLM evaluation methodology and eval-suite design, based on Anthropic's evaluation guidance. /evals:methodology answers questions on success criteria, eval design, and grading. /evals:design interviews for measurable success criteria and scaffolds a graded eval suite for an LLM app or Claude Code skill. /evals:plugin-eval preflights the CLI and target, prices a suite before running, and reads the with-versus-without delta. /evals:validate checks case files with no model call.", "author": { "name": "Melodic Software", diff --git a/plugins/evals/CHANGELOG.md b/plugins/evals/CHANGELOG.md index 9e05e2ef20..5d75414d40 100644 --- a/plugins/evals/CHANGELOG.md +++ b/plugins/evals/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog: evals +## [0.5.3] - 2026-10-03 + +### Changed + +- **Shared `prerequisites.mjs` synced ([#6084](https://github.com/melodic-software/claude-code-plugins/issues/6084)); no change to this plugin's lib.** + The prerequisite check now counts a Windows App Execution Alias (a Store or winget install on PATH) as found, + except App Installer's Python install stub. A `cli` or `runtime` entry can set `reject_store_alias` to skip aliases instead; no entry in this plugin does. + ## [0.5.2] - 2026-10-03 ### Changed diff --git a/plugins/evals/lib/prerequisites.mjs b/plugins/evals/lib/prerequisites.mjs index 57f65a79b5..1e11abf2cc 100755 --- a/plugins/evals/lib/prerequisites.mjs +++ b/plugins/evals/lib/prerequisites.mjs @@ -28,8 +28,10 @@ import { accessSync, constants, existsSync, + lstatSync, mkdirSync, readFileSync, + readlinkSync, realpathSync, statSync, writeFileSync, @@ -42,8 +44,8 @@ export const KINDS = ["cli", "runtime", "system-lib", "python-pkg", "node-pkg", export const NEEDS = ["required", "optional"]; const ENTRY_KEYS = ["id", "kind", "need", "for", "detect", "degrade", "install", "check"]; const DETECT_KEYS = { - cli: { required: ["any"], optional: ["local_bin", "version"] }, - runtime: { required: ["any"], optional: ["local_bin", "version"] }, + cli: { required: ["any"], optional: ["local_bin", "version", "reject_store_alias"] }, + runtime: { required: ["any"], optional: ["local_bin", "version", "reject_store_alias"] }, "system-lib": { required: ["probe"], optional: [] }, "python-pkg": { required: ["any", "import"], optional: [] }, "node-pkg": { required: ["module", "paths"], optional: [] }, @@ -100,6 +102,9 @@ function detectErrors(kind, detect, where) { } if ("any" in detect && !isTextList(detect.any)) errors.push(`${where}.any must be a non-empty list of names`); if ("local_bin" in detect) errors.push(...relativePathErrors(detect.local_bin, `${where}.local_bin`)); + if ("reject_store_alias" in detect && typeof detect.reject_store_alias !== "boolean") { + errors.push(`${where}.reject_store_alias must be true or false`); + } if ("version" in detect) { const v = detect.version; const vw = `${where}.version`; @@ -243,36 +248,66 @@ export function compareVersions(a, b) { return 0; } -function isExecutable(file, platform) { +const FS = { statSync, lstatSync, readlinkSync, accessSync }; + +// The target of App Installer's Python "install me" stub, which opens the Store +// instead of running Python, and the alias names it takes. +const PYTHON_STUB_TARGET = "appinstallerpythonredirector.exe"; +const PYTHON_STUB_NAMES = new Set(["python", "python3"]); + +// A Windows App Execution Alias, how a Store or winget app reaches PATH, is a +// reparse point in a WindowsApps directory that stat cannot open, yet it runs. +// It counts as found unless it is the Python stub, known by its target or, when +// the target cannot be read, by its name. +const isWindowsApps = (dir) => path.basename(dir).toLowerCase() === "windowsapps"; + +function isAppAlias(file, fs) { + if (!isWindowsApps(path.dirname(file))) return false; try { - if (!statSync(file).isFile()) return false; - if (platform !== "win32") accessSync(file, constants.X_OK); - return true; + if (!fs.lstatSync(file).isSymbolicLink()) return false; } catch { return false; } + let target; + try { + target = fs.readlinkSync(file); + } catch { + return !PYTHON_STUB_NAMES.has(path.basename(file).toLowerCase().replace(/\.exe$/, "")); + } + return path.win32.basename(target).toLowerCase() !== PYTHON_STUB_TARGET; +} + +function isExecutable(file, platform, fs = FS) { + try { + if (!fs.statSync(file).isFile()) return false; + if (platform !== "win32") fs.accessSync(file, constants.X_OK); + return true; + } catch { + return platform === "win32" && isAppAlias(file, fs); + } } -// which(name) -> absolute path of the first PATH match, or null. -export function which(name, env = process.env, platform = process.platform) { +// which(name) -> absolute path of the first PATH match, or null. fs is injectable for tests. +// rejectStoreAlias skips WindowsApps directories on win32, so no App Execution Alias matches. +export function which(name, env = process.env, platform = process.platform, fs = FS, rejectStoreAlias = false) { const pathVar = env.PATH ?? env.Path ?? ""; const exts = platform === "win32" ? ["", ...(env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean)] : [""]; for (const dir of pathVar.split(path.delimiter)) { - if (!dir) continue; + if (!dir || (rejectStoreAlias && platform === "win32" && isWindowsApps(dir))) continue; for (const ext of exts) { const candidate = path.join(dir, name + ext); - if (isExecutable(candidate, platform)) return candidate; + if (isExecutable(candidate, platform, fs)) return candidate; } } return null; } -function findLocalBin(rels, cwd, platform) { +function findLocalBin(rels, cwd, platform, fs) { for (const rel of rels ?? []) { let dir = path.resolve(cwd); for (let i = 0; i < LOCAL_BIN_DEPTH; i++) { const candidate = path.join(dir, rel); - if (isExecutable(candidate, platform)) return candidate; + if (isExecutable(candidate, platform, fs)) return candidate; const parent = path.dirname(dir); if (parent === dir) break; dir = parent; @@ -286,9 +321,9 @@ function run(file, args) { return { ok: !r.error && r.status === 0, out: `${r.stdout ?? ""}${r.stderr ?? ""}` }; } -function firstOnPath(names, ctx) { +function firstOnPath(names, ctx, rejectStoreAlias = false) { for (const name of names) { - const found = which(name, ctx.env, ctx.platform); + const found = which(name, ctx.env, ctx.platform, ctx.fs, rejectStoreAlias); if (found) return found; } return null; @@ -314,7 +349,7 @@ export function detectEntry(entry, ctx) { switch (entry.kind) { case "cli": case "runtime": { - const found = firstOnPath(d.any, ctx) ?? findLocalBin(d.local_bin, ctx.cwd, ctx.platform); + const found = firstOnPath(d.any, ctx, d.reject_store_alias) ?? findLocalBin(d.local_bin, ctx.cwd, ctx.platform, ctx.fs); if (!found) { const where = d.local_bin ? `on PATH or as ${d.local_bin.join(", ")}` : "on PATH"; return { status: "missing", detail: `${d.any.join(" or ")} was not found ${where}` }; @@ -331,7 +366,7 @@ export function detectEntry(entry, ctx) { } case "system-lib": { const [bin, ...args] = d.probe.args; - const found = which(bin, ctx.env, ctx.platform); + const found = which(bin, ctx.env, ctx.platform, ctx.fs); if (!found) return { status: "missing", detail: `${bin} was not found on PATH to probe` }; if (new RegExp(d.probe.pattern).test(run(found, args).out)) return { status: "present", detail: found }; return { status: "missing", detail: `${bin} ${args.join(" ")} did not report ${d.probe.pattern}` }; @@ -566,6 +601,7 @@ export function main(argv, ctx = {}, out = {}) { env: ctx.env ?? process.env, platform: ctx.platform ?? process.platform, cwd: ctx.cwd ?? process.cwd(), + fs: ctx.fs ?? FS, stdin: ctx.stdin ?? (() => (process.stdin.isTTY ? "" : readFileSync(0, "utf8"))), }; const io = { stdout: out.stdout ?? ((s) => console.log(s)), stderr: out.stderr ?? ((s) => console.error(s)) }; diff --git a/plugins/explainer-video/.claude-plugin/plugin.json b/plugins/explainer-video/.claude-plugin/plugin.json index d2ac9890d4..f914d2cea9 100644 --- a/plugins/explainer-video/.claude-plugin/plugin.json +++ b/plugins/explainer-video/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "explainer-video", - "version": "0.2.0", + "version": "0.2.1", "description": "Short explainer videos made with ManimCE, narrated when the speech plugin is installed and silent otherwise. The produce skill plans the beats, writes one scene script whose scene changes follow the narration's word timings, renders at low quality first, muxes narration and captions with ffmpeg, and checks every render with ffprobe and a frame read-back. A SessionStart hook installs the hash-locked Python packages; the check skill reports readiness.", "author": { "name": "Melodic Software", diff --git a/plugins/explainer-video/CHANGELOG.md b/plugins/explainer-video/CHANGELOG.md index 6d52afc259..ece260fcca 100644 --- a/plugins/explainer-video/CHANGELOG.md +++ b/plugins/explainer-video/CHANGELOG.md @@ -3,6 +3,14 @@ All notable changes to the `explainer-video` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.2.1] - 2026-10-03 + +### Changed + +- **Shared `prerequisites.mjs` synced ([#6084](https://github.com/melodic-software/claude-code-plugins/issues/6084)); no change to this plugin's lib.** + The prerequisite check now counts a Windows App Execution Alias (a Store or winget install on PATH) as found, + except App Installer's Python install stub. A `cli` or `runtime` entry can set `reject_store_alias` to skip aliases instead; no entry in this plugin does. + ## [0.2.0] - 2026-10-03 ### Added diff --git a/plugins/explainer-video/lib/prerequisites.mjs b/plugins/explainer-video/lib/prerequisites.mjs index 57f65a79b5..1e11abf2cc 100755 --- a/plugins/explainer-video/lib/prerequisites.mjs +++ b/plugins/explainer-video/lib/prerequisites.mjs @@ -28,8 +28,10 @@ import { accessSync, constants, existsSync, + lstatSync, mkdirSync, readFileSync, + readlinkSync, realpathSync, statSync, writeFileSync, @@ -42,8 +44,8 @@ export const KINDS = ["cli", "runtime", "system-lib", "python-pkg", "node-pkg", export const NEEDS = ["required", "optional"]; const ENTRY_KEYS = ["id", "kind", "need", "for", "detect", "degrade", "install", "check"]; const DETECT_KEYS = { - cli: { required: ["any"], optional: ["local_bin", "version"] }, - runtime: { required: ["any"], optional: ["local_bin", "version"] }, + cli: { required: ["any"], optional: ["local_bin", "version", "reject_store_alias"] }, + runtime: { required: ["any"], optional: ["local_bin", "version", "reject_store_alias"] }, "system-lib": { required: ["probe"], optional: [] }, "python-pkg": { required: ["any", "import"], optional: [] }, "node-pkg": { required: ["module", "paths"], optional: [] }, @@ -100,6 +102,9 @@ function detectErrors(kind, detect, where) { } if ("any" in detect && !isTextList(detect.any)) errors.push(`${where}.any must be a non-empty list of names`); if ("local_bin" in detect) errors.push(...relativePathErrors(detect.local_bin, `${where}.local_bin`)); + if ("reject_store_alias" in detect && typeof detect.reject_store_alias !== "boolean") { + errors.push(`${where}.reject_store_alias must be true or false`); + } if ("version" in detect) { const v = detect.version; const vw = `${where}.version`; @@ -243,36 +248,66 @@ export function compareVersions(a, b) { return 0; } -function isExecutable(file, platform) { +const FS = { statSync, lstatSync, readlinkSync, accessSync }; + +// The target of App Installer's Python "install me" stub, which opens the Store +// instead of running Python, and the alias names it takes. +const PYTHON_STUB_TARGET = "appinstallerpythonredirector.exe"; +const PYTHON_STUB_NAMES = new Set(["python", "python3"]); + +// A Windows App Execution Alias, how a Store or winget app reaches PATH, is a +// reparse point in a WindowsApps directory that stat cannot open, yet it runs. +// It counts as found unless it is the Python stub, known by its target or, when +// the target cannot be read, by its name. +const isWindowsApps = (dir) => path.basename(dir).toLowerCase() === "windowsapps"; + +function isAppAlias(file, fs) { + if (!isWindowsApps(path.dirname(file))) return false; try { - if (!statSync(file).isFile()) return false; - if (platform !== "win32") accessSync(file, constants.X_OK); - return true; + if (!fs.lstatSync(file).isSymbolicLink()) return false; } catch { return false; } + let target; + try { + target = fs.readlinkSync(file); + } catch { + return !PYTHON_STUB_NAMES.has(path.basename(file).toLowerCase().replace(/\.exe$/, "")); + } + return path.win32.basename(target).toLowerCase() !== PYTHON_STUB_TARGET; +} + +function isExecutable(file, platform, fs = FS) { + try { + if (!fs.statSync(file).isFile()) return false; + if (platform !== "win32") fs.accessSync(file, constants.X_OK); + return true; + } catch { + return platform === "win32" && isAppAlias(file, fs); + } } -// which(name) -> absolute path of the first PATH match, or null. -export function which(name, env = process.env, platform = process.platform) { +// which(name) -> absolute path of the first PATH match, or null. fs is injectable for tests. +// rejectStoreAlias skips WindowsApps directories on win32, so no App Execution Alias matches. +export function which(name, env = process.env, platform = process.platform, fs = FS, rejectStoreAlias = false) { const pathVar = env.PATH ?? env.Path ?? ""; const exts = platform === "win32" ? ["", ...(env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean)] : [""]; for (const dir of pathVar.split(path.delimiter)) { - if (!dir) continue; + if (!dir || (rejectStoreAlias && platform === "win32" && isWindowsApps(dir))) continue; for (const ext of exts) { const candidate = path.join(dir, name + ext); - if (isExecutable(candidate, platform)) return candidate; + if (isExecutable(candidate, platform, fs)) return candidate; } } return null; } -function findLocalBin(rels, cwd, platform) { +function findLocalBin(rels, cwd, platform, fs) { for (const rel of rels ?? []) { let dir = path.resolve(cwd); for (let i = 0; i < LOCAL_BIN_DEPTH; i++) { const candidate = path.join(dir, rel); - if (isExecutable(candidate, platform)) return candidate; + if (isExecutable(candidate, platform, fs)) return candidate; const parent = path.dirname(dir); if (parent === dir) break; dir = parent; @@ -286,9 +321,9 @@ function run(file, args) { return { ok: !r.error && r.status === 0, out: `${r.stdout ?? ""}${r.stderr ?? ""}` }; } -function firstOnPath(names, ctx) { +function firstOnPath(names, ctx, rejectStoreAlias = false) { for (const name of names) { - const found = which(name, ctx.env, ctx.platform); + const found = which(name, ctx.env, ctx.platform, ctx.fs, rejectStoreAlias); if (found) return found; } return null; @@ -314,7 +349,7 @@ export function detectEntry(entry, ctx) { switch (entry.kind) { case "cli": case "runtime": { - const found = firstOnPath(d.any, ctx) ?? findLocalBin(d.local_bin, ctx.cwd, ctx.platform); + const found = firstOnPath(d.any, ctx, d.reject_store_alias) ?? findLocalBin(d.local_bin, ctx.cwd, ctx.platform, ctx.fs); if (!found) { const where = d.local_bin ? `on PATH or as ${d.local_bin.join(", ")}` : "on PATH"; return { status: "missing", detail: `${d.any.join(" or ")} was not found ${where}` }; @@ -331,7 +366,7 @@ export function detectEntry(entry, ctx) { } case "system-lib": { const [bin, ...args] = d.probe.args; - const found = which(bin, ctx.env, ctx.platform); + const found = which(bin, ctx.env, ctx.platform, ctx.fs); if (!found) return { status: "missing", detail: `${bin} was not found on PATH to probe` }; if (new RegExp(d.probe.pattern).test(run(found, args).out)) return { status: "present", detail: found }; return { status: "missing", detail: `${bin} ${args.join(" ")} did not report ${d.probe.pattern}` }; @@ -566,6 +601,7 @@ export function main(argv, ctx = {}, out = {}) { env: ctx.env ?? process.env, platform: ctx.platform ?? process.platform, cwd: ctx.cwd ?? process.cwd(), + fs: ctx.fs ?? FS, stdin: ctx.stdin ?? (() => (process.stdin.isTTY ? "" : readFileSync(0, "utf8"))), }; const io = { stdout: out.stdout ?? ((s) => console.log(s)), stderr: out.stderr ?? ((s) => console.error(s)) }; diff --git a/plugins/firecrawl/.claude-plugin/plugin.json b/plugins/firecrawl/.claude-plugin/plugin.json index 551d8cd1e2..2ec8764307 100644 --- a/plugins/firecrawl/.claude-plugin/plugin.json +++ b/plugins/firecrawl/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "firecrawl", - "version": "0.6.1", + "version": "0.6.2", "description": "Web scraping, search, crawling, and file parsing through the firecrawl-cli binary with a write-to-disk-then-Read pattern that keeps large results out of context: a user-facing wrapper skill, a lazy-install setup skill, and a separate gated maintainer update skill tracking the upstream CLI and skill source.", "author": { "name": "Melodic Software", diff --git a/plugins/firecrawl/CHANGELOG.md b/plugins/firecrawl/CHANGELOG.md index 69b22c662a..fee5d909f2 100644 --- a/plugins/firecrawl/CHANGELOG.md +++ b/plugins/firecrawl/CHANGELOG.md @@ -3,6 +3,14 @@ All notable changes to the `firecrawl` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.6.2] - 2026-10-03 + +### Changed + +- **Shared `prerequisites.mjs` synced ([#6084](https://github.com/melodic-software/claude-code-plugins/issues/6084)); no change to this plugin's lib.** + The prerequisite check now counts a Windows App Execution Alias (a Store or winget install on PATH) as found, + except App Installer's Python install stub. A `cli` or `runtime` entry can set `reject_store_alias` to skip aliases instead; no entry in this plugin does. + ## [0.6.1] - 2026-10-03 ### Changed diff --git a/plugins/firecrawl/lib/prerequisites.mjs b/plugins/firecrawl/lib/prerequisites.mjs index 57f65a79b5..1e11abf2cc 100755 --- a/plugins/firecrawl/lib/prerequisites.mjs +++ b/plugins/firecrawl/lib/prerequisites.mjs @@ -28,8 +28,10 @@ import { accessSync, constants, existsSync, + lstatSync, mkdirSync, readFileSync, + readlinkSync, realpathSync, statSync, writeFileSync, @@ -42,8 +44,8 @@ export const KINDS = ["cli", "runtime", "system-lib", "python-pkg", "node-pkg", export const NEEDS = ["required", "optional"]; const ENTRY_KEYS = ["id", "kind", "need", "for", "detect", "degrade", "install", "check"]; const DETECT_KEYS = { - cli: { required: ["any"], optional: ["local_bin", "version"] }, - runtime: { required: ["any"], optional: ["local_bin", "version"] }, + cli: { required: ["any"], optional: ["local_bin", "version", "reject_store_alias"] }, + runtime: { required: ["any"], optional: ["local_bin", "version", "reject_store_alias"] }, "system-lib": { required: ["probe"], optional: [] }, "python-pkg": { required: ["any", "import"], optional: [] }, "node-pkg": { required: ["module", "paths"], optional: [] }, @@ -100,6 +102,9 @@ function detectErrors(kind, detect, where) { } if ("any" in detect && !isTextList(detect.any)) errors.push(`${where}.any must be a non-empty list of names`); if ("local_bin" in detect) errors.push(...relativePathErrors(detect.local_bin, `${where}.local_bin`)); + if ("reject_store_alias" in detect && typeof detect.reject_store_alias !== "boolean") { + errors.push(`${where}.reject_store_alias must be true or false`); + } if ("version" in detect) { const v = detect.version; const vw = `${where}.version`; @@ -243,36 +248,66 @@ export function compareVersions(a, b) { return 0; } -function isExecutable(file, platform) { +const FS = { statSync, lstatSync, readlinkSync, accessSync }; + +// The target of App Installer's Python "install me" stub, which opens the Store +// instead of running Python, and the alias names it takes. +const PYTHON_STUB_TARGET = "appinstallerpythonredirector.exe"; +const PYTHON_STUB_NAMES = new Set(["python", "python3"]); + +// A Windows App Execution Alias, how a Store or winget app reaches PATH, is a +// reparse point in a WindowsApps directory that stat cannot open, yet it runs. +// It counts as found unless it is the Python stub, known by its target or, when +// the target cannot be read, by its name. +const isWindowsApps = (dir) => path.basename(dir).toLowerCase() === "windowsapps"; + +function isAppAlias(file, fs) { + if (!isWindowsApps(path.dirname(file))) return false; try { - if (!statSync(file).isFile()) return false; - if (platform !== "win32") accessSync(file, constants.X_OK); - return true; + if (!fs.lstatSync(file).isSymbolicLink()) return false; } catch { return false; } + let target; + try { + target = fs.readlinkSync(file); + } catch { + return !PYTHON_STUB_NAMES.has(path.basename(file).toLowerCase().replace(/\.exe$/, "")); + } + return path.win32.basename(target).toLowerCase() !== PYTHON_STUB_TARGET; +} + +function isExecutable(file, platform, fs = FS) { + try { + if (!fs.statSync(file).isFile()) return false; + if (platform !== "win32") fs.accessSync(file, constants.X_OK); + return true; + } catch { + return platform === "win32" && isAppAlias(file, fs); + } } -// which(name) -> absolute path of the first PATH match, or null. -export function which(name, env = process.env, platform = process.platform) { +// which(name) -> absolute path of the first PATH match, or null. fs is injectable for tests. +// rejectStoreAlias skips WindowsApps directories on win32, so no App Execution Alias matches. +export function which(name, env = process.env, platform = process.platform, fs = FS, rejectStoreAlias = false) { const pathVar = env.PATH ?? env.Path ?? ""; const exts = platform === "win32" ? ["", ...(env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean)] : [""]; for (const dir of pathVar.split(path.delimiter)) { - if (!dir) continue; + if (!dir || (rejectStoreAlias && platform === "win32" && isWindowsApps(dir))) continue; for (const ext of exts) { const candidate = path.join(dir, name + ext); - if (isExecutable(candidate, platform)) return candidate; + if (isExecutable(candidate, platform, fs)) return candidate; } } return null; } -function findLocalBin(rels, cwd, platform) { +function findLocalBin(rels, cwd, platform, fs) { for (const rel of rels ?? []) { let dir = path.resolve(cwd); for (let i = 0; i < LOCAL_BIN_DEPTH; i++) { const candidate = path.join(dir, rel); - if (isExecutable(candidate, platform)) return candidate; + if (isExecutable(candidate, platform, fs)) return candidate; const parent = path.dirname(dir); if (parent === dir) break; dir = parent; @@ -286,9 +321,9 @@ function run(file, args) { return { ok: !r.error && r.status === 0, out: `${r.stdout ?? ""}${r.stderr ?? ""}` }; } -function firstOnPath(names, ctx) { +function firstOnPath(names, ctx, rejectStoreAlias = false) { for (const name of names) { - const found = which(name, ctx.env, ctx.platform); + const found = which(name, ctx.env, ctx.platform, ctx.fs, rejectStoreAlias); if (found) return found; } return null; @@ -314,7 +349,7 @@ export function detectEntry(entry, ctx) { switch (entry.kind) { case "cli": case "runtime": { - const found = firstOnPath(d.any, ctx) ?? findLocalBin(d.local_bin, ctx.cwd, ctx.platform); + const found = firstOnPath(d.any, ctx, d.reject_store_alias) ?? findLocalBin(d.local_bin, ctx.cwd, ctx.platform, ctx.fs); if (!found) { const where = d.local_bin ? `on PATH or as ${d.local_bin.join(", ")}` : "on PATH"; return { status: "missing", detail: `${d.any.join(" or ")} was not found ${where}` }; @@ -331,7 +366,7 @@ export function detectEntry(entry, ctx) { } case "system-lib": { const [bin, ...args] = d.probe.args; - const found = which(bin, ctx.env, ctx.platform); + const found = which(bin, ctx.env, ctx.platform, ctx.fs); if (!found) return { status: "missing", detail: `${bin} was not found on PATH to probe` }; if (new RegExp(d.probe.pattern).test(run(found, args).out)) return { status: "present", detail: found }; return { status: "missing", detail: `${bin} ${args.join(" ")} did not report ${d.probe.pattern}` }; @@ -566,6 +601,7 @@ export function main(argv, ctx = {}, out = {}) { env: ctx.env ?? process.env, platform: ctx.platform ?? process.platform, cwd: ctx.cwd ?? process.cwd(), + fs: ctx.fs ?? FS, stdin: ctx.stdin ?? (() => (process.stdin.isTTY ? "" : readFileSync(0, "utf8"))), }; const io = { stdout: out.stdout ?? ((s) => console.log(s)), stderr: out.stderr ?? ((s) => console.error(s)) }; diff --git a/plugins/gaming/.claude-plugin/plugin.json b/plugins/gaming/.claude-plugin/plugin.json index 108c42f61a..3ac78841b8 100644 --- a/plugins/gaming/.claude-plugin/plugin.json +++ b/plugins/gaming/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "gaming", - "version": "0.10.1", + "version": "0.10.2", "description": "Apply, track, tune, and remove the community DLSS 5 Neural Rendering mod (OptiScaler forks) in Windows PC games from Steam, Epic, EA app, Battle.net, GOG, Ubisoft Connect, and Xbox (gaming:dlss5). Anti-cheat checks refuse unless you type an at-your-own-risk acknowledgement. Snapshots allow byte-exact removal; a ledger and upstream watch track fork and driver releases. Ships no NVIDIA binary: the DLL comes from a path, an installed DLSS 5 title, or a configured source.", "author": { "name": "Melodic Software", diff --git a/plugins/gaming/CHANGELOG.md b/plugins/gaming/CHANGELOG.md index dda7a5b42c..856a4cf720 100644 --- a/plugins/gaming/CHANGELOG.md +++ b/plugins/gaming/CHANGELOG.md @@ -3,6 +3,14 @@ All notable changes to the `gaming` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.10.2] - 2026-10-03 + +### Changed + +- **Shared `prerequisites.mjs` synced ([#6084](https://github.com/melodic-software/claude-code-plugins/issues/6084)); no change to this plugin's lib.** + The prerequisite check now counts a Windows App Execution Alias (a Store or winget install on PATH) as found, + except App Installer's Python install stub. A `cli` or `runtime` entry can set `reject_store_alias` to skip aliases instead; no entry in this plugin does. + ## [0.10.1] - 2026-10-03 ### Changed diff --git a/plugins/gaming/lib/prerequisites.mjs b/plugins/gaming/lib/prerequisites.mjs index 57f65a79b5..1e11abf2cc 100755 --- a/plugins/gaming/lib/prerequisites.mjs +++ b/plugins/gaming/lib/prerequisites.mjs @@ -28,8 +28,10 @@ import { accessSync, constants, existsSync, + lstatSync, mkdirSync, readFileSync, + readlinkSync, realpathSync, statSync, writeFileSync, @@ -42,8 +44,8 @@ export const KINDS = ["cli", "runtime", "system-lib", "python-pkg", "node-pkg", export const NEEDS = ["required", "optional"]; const ENTRY_KEYS = ["id", "kind", "need", "for", "detect", "degrade", "install", "check"]; const DETECT_KEYS = { - cli: { required: ["any"], optional: ["local_bin", "version"] }, - runtime: { required: ["any"], optional: ["local_bin", "version"] }, + cli: { required: ["any"], optional: ["local_bin", "version", "reject_store_alias"] }, + runtime: { required: ["any"], optional: ["local_bin", "version", "reject_store_alias"] }, "system-lib": { required: ["probe"], optional: [] }, "python-pkg": { required: ["any", "import"], optional: [] }, "node-pkg": { required: ["module", "paths"], optional: [] }, @@ -100,6 +102,9 @@ function detectErrors(kind, detect, where) { } if ("any" in detect && !isTextList(detect.any)) errors.push(`${where}.any must be a non-empty list of names`); if ("local_bin" in detect) errors.push(...relativePathErrors(detect.local_bin, `${where}.local_bin`)); + if ("reject_store_alias" in detect && typeof detect.reject_store_alias !== "boolean") { + errors.push(`${where}.reject_store_alias must be true or false`); + } if ("version" in detect) { const v = detect.version; const vw = `${where}.version`; @@ -243,36 +248,66 @@ export function compareVersions(a, b) { return 0; } -function isExecutable(file, platform) { +const FS = { statSync, lstatSync, readlinkSync, accessSync }; + +// The target of App Installer's Python "install me" stub, which opens the Store +// instead of running Python, and the alias names it takes. +const PYTHON_STUB_TARGET = "appinstallerpythonredirector.exe"; +const PYTHON_STUB_NAMES = new Set(["python", "python3"]); + +// A Windows App Execution Alias, how a Store or winget app reaches PATH, is a +// reparse point in a WindowsApps directory that stat cannot open, yet it runs. +// It counts as found unless it is the Python stub, known by its target or, when +// the target cannot be read, by its name. +const isWindowsApps = (dir) => path.basename(dir).toLowerCase() === "windowsapps"; + +function isAppAlias(file, fs) { + if (!isWindowsApps(path.dirname(file))) return false; try { - if (!statSync(file).isFile()) return false; - if (platform !== "win32") accessSync(file, constants.X_OK); - return true; + if (!fs.lstatSync(file).isSymbolicLink()) return false; } catch { return false; } + let target; + try { + target = fs.readlinkSync(file); + } catch { + return !PYTHON_STUB_NAMES.has(path.basename(file).toLowerCase().replace(/\.exe$/, "")); + } + return path.win32.basename(target).toLowerCase() !== PYTHON_STUB_TARGET; +} + +function isExecutable(file, platform, fs = FS) { + try { + if (!fs.statSync(file).isFile()) return false; + if (platform !== "win32") fs.accessSync(file, constants.X_OK); + return true; + } catch { + return platform === "win32" && isAppAlias(file, fs); + } } -// which(name) -> absolute path of the first PATH match, or null. -export function which(name, env = process.env, platform = process.platform) { +// which(name) -> absolute path of the first PATH match, or null. fs is injectable for tests. +// rejectStoreAlias skips WindowsApps directories on win32, so no App Execution Alias matches. +export function which(name, env = process.env, platform = process.platform, fs = FS, rejectStoreAlias = false) { const pathVar = env.PATH ?? env.Path ?? ""; const exts = platform === "win32" ? ["", ...(env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean)] : [""]; for (const dir of pathVar.split(path.delimiter)) { - if (!dir) continue; + if (!dir || (rejectStoreAlias && platform === "win32" && isWindowsApps(dir))) continue; for (const ext of exts) { const candidate = path.join(dir, name + ext); - if (isExecutable(candidate, platform)) return candidate; + if (isExecutable(candidate, platform, fs)) return candidate; } } return null; } -function findLocalBin(rels, cwd, platform) { +function findLocalBin(rels, cwd, platform, fs) { for (const rel of rels ?? []) { let dir = path.resolve(cwd); for (let i = 0; i < LOCAL_BIN_DEPTH; i++) { const candidate = path.join(dir, rel); - if (isExecutable(candidate, platform)) return candidate; + if (isExecutable(candidate, platform, fs)) return candidate; const parent = path.dirname(dir); if (parent === dir) break; dir = parent; @@ -286,9 +321,9 @@ function run(file, args) { return { ok: !r.error && r.status === 0, out: `${r.stdout ?? ""}${r.stderr ?? ""}` }; } -function firstOnPath(names, ctx) { +function firstOnPath(names, ctx, rejectStoreAlias = false) { for (const name of names) { - const found = which(name, ctx.env, ctx.platform); + const found = which(name, ctx.env, ctx.platform, ctx.fs, rejectStoreAlias); if (found) return found; } return null; @@ -314,7 +349,7 @@ export function detectEntry(entry, ctx) { switch (entry.kind) { case "cli": case "runtime": { - const found = firstOnPath(d.any, ctx) ?? findLocalBin(d.local_bin, ctx.cwd, ctx.platform); + const found = firstOnPath(d.any, ctx, d.reject_store_alias) ?? findLocalBin(d.local_bin, ctx.cwd, ctx.platform, ctx.fs); if (!found) { const where = d.local_bin ? `on PATH or as ${d.local_bin.join(", ")}` : "on PATH"; return { status: "missing", detail: `${d.any.join(" or ")} was not found ${where}` }; @@ -331,7 +366,7 @@ export function detectEntry(entry, ctx) { } case "system-lib": { const [bin, ...args] = d.probe.args; - const found = which(bin, ctx.env, ctx.platform); + const found = which(bin, ctx.env, ctx.platform, ctx.fs); if (!found) return { status: "missing", detail: `${bin} was not found on PATH to probe` }; if (new RegExp(d.probe.pattern).test(run(found, args).out)) return { status: "present", detail: found }; return { status: "missing", detail: `${bin} ${args.join(" ")} did not report ${d.probe.pattern}` }; @@ -566,6 +601,7 @@ export function main(argv, ctx = {}, out = {}) { env: ctx.env ?? process.env, platform: ctx.platform ?? process.platform, cwd: ctx.cwd ?? process.cwd(), + fs: ctx.fs ?? FS, stdin: ctx.stdin ?? (() => (process.stdin.isTTY ? "" : readFileSync(0, "utf8"))), }; const io = { stdout: out.stdout ?? ((s) => console.log(s)), stderr: out.stderr ?? ((s) => console.error(s)) }; diff --git a/plugins/go-format/.claude-plugin/plugin.json b/plugins/go-format/.claude-plugin/plugin.json index 4a6c7af3cb..0d7bdd0679 100644 --- a/plugins/go-format/.claude-plugin/plugin.json +++ b/plugins/go-format/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "go-format", - "version": "0.5.1", + "version": "0.5.2", "description": "Auto-fix Go formatting and import management on edit via goimports. Runs unconditionally (no consumer-config gate), skipping generated files.", "author": { "name": "Melodic Software", diff --git a/plugins/go-format/CHANGELOG.md b/plugins/go-format/CHANGELOG.md index a42591f4fe..d75991c4bb 100644 --- a/plugins/go-format/CHANGELOG.md +++ b/plugins/go-format/CHANGELOG.md @@ -3,6 +3,14 @@ All notable changes to the `go-format` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.5.2] - 2026-10-03 + +### Changed + +- **Shared `prerequisites.mjs` synced ([#6084](https://github.com/melodic-software/claude-code-plugins/issues/6084)); no change to this plugin's lib.** + The prerequisite check now counts a Windows App Execution Alias (a Store or winget install on PATH) as found, + except App Installer's Python install stub. A `cli` or `runtime` entry can set `reject_store_alias` to skip aliases instead; no entry in this plugin does. + ## [0.5.1] - 2026-10-03 ### Changed diff --git a/plugins/go-format/lib/prerequisites.mjs b/plugins/go-format/lib/prerequisites.mjs index 57f65a79b5..1e11abf2cc 100755 --- a/plugins/go-format/lib/prerequisites.mjs +++ b/plugins/go-format/lib/prerequisites.mjs @@ -28,8 +28,10 @@ import { accessSync, constants, existsSync, + lstatSync, mkdirSync, readFileSync, + readlinkSync, realpathSync, statSync, writeFileSync, @@ -42,8 +44,8 @@ export const KINDS = ["cli", "runtime", "system-lib", "python-pkg", "node-pkg", export const NEEDS = ["required", "optional"]; const ENTRY_KEYS = ["id", "kind", "need", "for", "detect", "degrade", "install", "check"]; const DETECT_KEYS = { - cli: { required: ["any"], optional: ["local_bin", "version"] }, - runtime: { required: ["any"], optional: ["local_bin", "version"] }, + cli: { required: ["any"], optional: ["local_bin", "version", "reject_store_alias"] }, + runtime: { required: ["any"], optional: ["local_bin", "version", "reject_store_alias"] }, "system-lib": { required: ["probe"], optional: [] }, "python-pkg": { required: ["any", "import"], optional: [] }, "node-pkg": { required: ["module", "paths"], optional: [] }, @@ -100,6 +102,9 @@ function detectErrors(kind, detect, where) { } if ("any" in detect && !isTextList(detect.any)) errors.push(`${where}.any must be a non-empty list of names`); if ("local_bin" in detect) errors.push(...relativePathErrors(detect.local_bin, `${where}.local_bin`)); + if ("reject_store_alias" in detect && typeof detect.reject_store_alias !== "boolean") { + errors.push(`${where}.reject_store_alias must be true or false`); + } if ("version" in detect) { const v = detect.version; const vw = `${where}.version`; @@ -243,36 +248,66 @@ export function compareVersions(a, b) { return 0; } -function isExecutable(file, platform) { +const FS = { statSync, lstatSync, readlinkSync, accessSync }; + +// The target of App Installer's Python "install me" stub, which opens the Store +// instead of running Python, and the alias names it takes. +const PYTHON_STUB_TARGET = "appinstallerpythonredirector.exe"; +const PYTHON_STUB_NAMES = new Set(["python", "python3"]); + +// A Windows App Execution Alias, how a Store or winget app reaches PATH, is a +// reparse point in a WindowsApps directory that stat cannot open, yet it runs. +// It counts as found unless it is the Python stub, known by its target or, when +// the target cannot be read, by its name. +const isWindowsApps = (dir) => path.basename(dir).toLowerCase() === "windowsapps"; + +function isAppAlias(file, fs) { + if (!isWindowsApps(path.dirname(file))) return false; try { - if (!statSync(file).isFile()) return false; - if (platform !== "win32") accessSync(file, constants.X_OK); - return true; + if (!fs.lstatSync(file).isSymbolicLink()) return false; } catch { return false; } + let target; + try { + target = fs.readlinkSync(file); + } catch { + return !PYTHON_STUB_NAMES.has(path.basename(file).toLowerCase().replace(/\.exe$/, "")); + } + return path.win32.basename(target).toLowerCase() !== PYTHON_STUB_TARGET; +} + +function isExecutable(file, platform, fs = FS) { + try { + if (!fs.statSync(file).isFile()) return false; + if (platform !== "win32") fs.accessSync(file, constants.X_OK); + return true; + } catch { + return platform === "win32" && isAppAlias(file, fs); + } } -// which(name) -> absolute path of the first PATH match, or null. -export function which(name, env = process.env, platform = process.platform) { +// which(name) -> absolute path of the first PATH match, or null. fs is injectable for tests. +// rejectStoreAlias skips WindowsApps directories on win32, so no App Execution Alias matches. +export function which(name, env = process.env, platform = process.platform, fs = FS, rejectStoreAlias = false) { const pathVar = env.PATH ?? env.Path ?? ""; const exts = platform === "win32" ? ["", ...(env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean)] : [""]; for (const dir of pathVar.split(path.delimiter)) { - if (!dir) continue; + if (!dir || (rejectStoreAlias && platform === "win32" && isWindowsApps(dir))) continue; for (const ext of exts) { const candidate = path.join(dir, name + ext); - if (isExecutable(candidate, platform)) return candidate; + if (isExecutable(candidate, platform, fs)) return candidate; } } return null; } -function findLocalBin(rels, cwd, platform) { +function findLocalBin(rels, cwd, platform, fs) { for (const rel of rels ?? []) { let dir = path.resolve(cwd); for (let i = 0; i < LOCAL_BIN_DEPTH; i++) { const candidate = path.join(dir, rel); - if (isExecutable(candidate, platform)) return candidate; + if (isExecutable(candidate, platform, fs)) return candidate; const parent = path.dirname(dir); if (parent === dir) break; dir = parent; @@ -286,9 +321,9 @@ function run(file, args) { return { ok: !r.error && r.status === 0, out: `${r.stdout ?? ""}${r.stderr ?? ""}` }; } -function firstOnPath(names, ctx) { +function firstOnPath(names, ctx, rejectStoreAlias = false) { for (const name of names) { - const found = which(name, ctx.env, ctx.platform); + const found = which(name, ctx.env, ctx.platform, ctx.fs, rejectStoreAlias); if (found) return found; } return null; @@ -314,7 +349,7 @@ export function detectEntry(entry, ctx) { switch (entry.kind) { case "cli": case "runtime": { - const found = firstOnPath(d.any, ctx) ?? findLocalBin(d.local_bin, ctx.cwd, ctx.platform); + const found = firstOnPath(d.any, ctx, d.reject_store_alias) ?? findLocalBin(d.local_bin, ctx.cwd, ctx.platform, ctx.fs); if (!found) { const where = d.local_bin ? `on PATH or as ${d.local_bin.join(", ")}` : "on PATH"; return { status: "missing", detail: `${d.any.join(" or ")} was not found ${where}` }; @@ -331,7 +366,7 @@ export function detectEntry(entry, ctx) { } case "system-lib": { const [bin, ...args] = d.probe.args; - const found = which(bin, ctx.env, ctx.platform); + const found = which(bin, ctx.env, ctx.platform, ctx.fs); if (!found) return { status: "missing", detail: `${bin} was not found on PATH to probe` }; if (new RegExp(d.probe.pattern).test(run(found, args).out)) return { status: "present", detail: found }; return { status: "missing", detail: `${bin} ${args.join(" ")} did not report ${d.probe.pattern}` }; @@ -566,6 +601,7 @@ export function main(argv, ctx = {}, out = {}) { env: ctx.env ?? process.env, platform: ctx.platform ?? process.platform, cwd: ctx.cwd ?? process.cwd(), + fs: ctx.fs ?? FS, stdin: ctx.stdin ?? (() => (process.stdin.isTTY ? "" : readFileSync(0, "utf8"))), }; const io = { stdout: out.stdout ?? ((s) => console.log(s)), stderr: out.stderr ?? ((s) => console.error(s)) }; diff --git a/plugins/guardrails/.claude-plugin/plugin.json b/plugins/guardrails/.claude-plugin/plugin.json index 3f29a5cb2f..2ef36d3280 100644 --- a/plugins/guardrails/.claude-plugin/plugin.json +++ b/plugins/guardrails/.claude-plugin/plugin.json @@ -170,5 +170,5 @@ "min": 1 } }, - "version": "0.47.2" + "version": "0.47.3" } diff --git a/plugins/guardrails/CHANGELOG.md b/plugins/guardrails/CHANGELOG.md index 346172357d..cf5c939c95 100644 --- a/plugins/guardrails/CHANGELOG.md +++ b/plugins/guardrails/CHANGELOG.md @@ -3,6 +3,14 @@ All notable changes to the `guardrails` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.47.3] - 2026-10-03 + +### Changed + +- **Shared `prerequisites.mjs` synced ([#6084](https://github.com/melodic-software/claude-code-plugins/issues/6084)); no change to this plugin's lib.** + The prerequisite check now counts a Windows App Execution Alias (a Store or winget install on PATH) as found, + except App Installer's Python install stub. A `cli` or `runtime` entry can set `reject_store_alias` to skip aliases instead; no entry in this plugin does. + ## [0.47.2] - 2026-10-03 ### Changed diff --git a/plugins/guardrails/lib/prerequisites.mjs b/plugins/guardrails/lib/prerequisites.mjs index 57f65a79b5..1e11abf2cc 100755 --- a/plugins/guardrails/lib/prerequisites.mjs +++ b/plugins/guardrails/lib/prerequisites.mjs @@ -28,8 +28,10 @@ import { accessSync, constants, existsSync, + lstatSync, mkdirSync, readFileSync, + readlinkSync, realpathSync, statSync, writeFileSync, @@ -42,8 +44,8 @@ export const KINDS = ["cli", "runtime", "system-lib", "python-pkg", "node-pkg", export const NEEDS = ["required", "optional"]; const ENTRY_KEYS = ["id", "kind", "need", "for", "detect", "degrade", "install", "check"]; const DETECT_KEYS = { - cli: { required: ["any"], optional: ["local_bin", "version"] }, - runtime: { required: ["any"], optional: ["local_bin", "version"] }, + cli: { required: ["any"], optional: ["local_bin", "version", "reject_store_alias"] }, + runtime: { required: ["any"], optional: ["local_bin", "version", "reject_store_alias"] }, "system-lib": { required: ["probe"], optional: [] }, "python-pkg": { required: ["any", "import"], optional: [] }, "node-pkg": { required: ["module", "paths"], optional: [] }, @@ -100,6 +102,9 @@ function detectErrors(kind, detect, where) { } if ("any" in detect && !isTextList(detect.any)) errors.push(`${where}.any must be a non-empty list of names`); if ("local_bin" in detect) errors.push(...relativePathErrors(detect.local_bin, `${where}.local_bin`)); + if ("reject_store_alias" in detect && typeof detect.reject_store_alias !== "boolean") { + errors.push(`${where}.reject_store_alias must be true or false`); + } if ("version" in detect) { const v = detect.version; const vw = `${where}.version`; @@ -243,36 +248,66 @@ export function compareVersions(a, b) { return 0; } -function isExecutable(file, platform) { +const FS = { statSync, lstatSync, readlinkSync, accessSync }; + +// The target of App Installer's Python "install me" stub, which opens the Store +// instead of running Python, and the alias names it takes. +const PYTHON_STUB_TARGET = "appinstallerpythonredirector.exe"; +const PYTHON_STUB_NAMES = new Set(["python", "python3"]); + +// A Windows App Execution Alias, how a Store or winget app reaches PATH, is a +// reparse point in a WindowsApps directory that stat cannot open, yet it runs. +// It counts as found unless it is the Python stub, known by its target or, when +// the target cannot be read, by its name. +const isWindowsApps = (dir) => path.basename(dir).toLowerCase() === "windowsapps"; + +function isAppAlias(file, fs) { + if (!isWindowsApps(path.dirname(file))) return false; try { - if (!statSync(file).isFile()) return false; - if (platform !== "win32") accessSync(file, constants.X_OK); - return true; + if (!fs.lstatSync(file).isSymbolicLink()) return false; } catch { return false; } + let target; + try { + target = fs.readlinkSync(file); + } catch { + return !PYTHON_STUB_NAMES.has(path.basename(file).toLowerCase().replace(/\.exe$/, "")); + } + return path.win32.basename(target).toLowerCase() !== PYTHON_STUB_TARGET; +} + +function isExecutable(file, platform, fs = FS) { + try { + if (!fs.statSync(file).isFile()) return false; + if (platform !== "win32") fs.accessSync(file, constants.X_OK); + return true; + } catch { + return platform === "win32" && isAppAlias(file, fs); + } } -// which(name) -> absolute path of the first PATH match, or null. -export function which(name, env = process.env, platform = process.platform) { +// which(name) -> absolute path of the first PATH match, or null. fs is injectable for tests. +// rejectStoreAlias skips WindowsApps directories on win32, so no App Execution Alias matches. +export function which(name, env = process.env, platform = process.platform, fs = FS, rejectStoreAlias = false) { const pathVar = env.PATH ?? env.Path ?? ""; const exts = platform === "win32" ? ["", ...(env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean)] : [""]; for (const dir of pathVar.split(path.delimiter)) { - if (!dir) continue; + if (!dir || (rejectStoreAlias && platform === "win32" && isWindowsApps(dir))) continue; for (const ext of exts) { const candidate = path.join(dir, name + ext); - if (isExecutable(candidate, platform)) return candidate; + if (isExecutable(candidate, platform, fs)) return candidate; } } return null; } -function findLocalBin(rels, cwd, platform) { +function findLocalBin(rels, cwd, platform, fs) { for (const rel of rels ?? []) { let dir = path.resolve(cwd); for (let i = 0; i < LOCAL_BIN_DEPTH; i++) { const candidate = path.join(dir, rel); - if (isExecutable(candidate, platform)) return candidate; + if (isExecutable(candidate, platform, fs)) return candidate; const parent = path.dirname(dir); if (parent === dir) break; dir = parent; @@ -286,9 +321,9 @@ function run(file, args) { return { ok: !r.error && r.status === 0, out: `${r.stdout ?? ""}${r.stderr ?? ""}` }; } -function firstOnPath(names, ctx) { +function firstOnPath(names, ctx, rejectStoreAlias = false) { for (const name of names) { - const found = which(name, ctx.env, ctx.platform); + const found = which(name, ctx.env, ctx.platform, ctx.fs, rejectStoreAlias); if (found) return found; } return null; @@ -314,7 +349,7 @@ export function detectEntry(entry, ctx) { switch (entry.kind) { case "cli": case "runtime": { - const found = firstOnPath(d.any, ctx) ?? findLocalBin(d.local_bin, ctx.cwd, ctx.platform); + const found = firstOnPath(d.any, ctx, d.reject_store_alias) ?? findLocalBin(d.local_bin, ctx.cwd, ctx.platform, ctx.fs); if (!found) { const where = d.local_bin ? `on PATH or as ${d.local_bin.join(", ")}` : "on PATH"; return { status: "missing", detail: `${d.any.join(" or ")} was not found ${where}` }; @@ -331,7 +366,7 @@ export function detectEntry(entry, ctx) { } case "system-lib": { const [bin, ...args] = d.probe.args; - const found = which(bin, ctx.env, ctx.platform); + const found = which(bin, ctx.env, ctx.platform, ctx.fs); if (!found) return { status: "missing", detail: `${bin} was not found on PATH to probe` }; if (new RegExp(d.probe.pattern).test(run(found, args).out)) return { status: "present", detail: found }; return { status: "missing", detail: `${bin} ${args.join(" ")} did not report ${d.probe.pattern}` }; @@ -566,6 +601,7 @@ export function main(argv, ctx = {}, out = {}) { env: ctx.env ?? process.env, platform: ctx.platform ?? process.platform, cwd: ctx.cwd ?? process.cwd(), + fs: ctx.fs ?? FS, stdin: ctx.stdin ?? (() => (process.stdin.isTTY ? "" : readFileSync(0, "utf8"))), }; const io = { stdout: out.stdout ?? ((s) => console.log(s)), stderr: out.stderr ?? ((s) => console.error(s)) }; diff --git a/plugins/harness-config/.claude-plugin/plugin.json b/plugins/harness-config/.claude-plugin/plugin.json index b62a379bae..fc684df22d 100644 --- a/plugins/harness-config/.claude-plugin/plugin.json +++ b/plugins/harness-config/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "harness-config", - "version": "1.7.4", + "version": "1.7.5", "description": "Configuration health for a repo's Claude Code: audit (settings, .mcp.json, hooks, plugins, permission drift), audit-automation-gaps, audit-permission-grants, audit-permission-state (effective rules with provenance), draft-auto-mode-rules (prints an autoMode block), audit-instructions (instructions the model no longer needs, conflicts), audit-prompting-postures, audit-pass (one ordered, resumable pass), unhobble (strip instructions, re-add what evidence earns), and setup.", "author": { "name": "Melodic Software", diff --git a/plugins/harness-config/CHANGELOG.md b/plugins/harness-config/CHANGELOG.md index 6e04c2be12..2ef780cf40 100644 --- a/plugins/harness-config/CHANGELOG.md +++ b/plugins/harness-config/CHANGELOG.md @@ -5,6 +5,14 @@ All notable changes to the `harness-config` plugin are documented here. Format f Versions 0.51.8 to 0.51.9 and 0.51.11 to 0.51.14 were reserved by parallel branches and never released. +## [1.7.5] - 2026-10-03 + +### Changed + +- **Shared `prerequisites.mjs` synced ([#6084](https://github.com/melodic-software/claude-code-plugins/issues/6084)); no change to this plugin's lib.** + The prerequisite check now counts a Windows App Execution Alias (a Store or winget install on PATH) as found, + except App Installer's Python install stub. A `cli` or `runtime` entry can set `reject_store_alias` to skip aliases instead; no entry in this plugin does. + ## [1.7.4] - 2026-10-03 ### Changed diff --git a/plugins/harness-config/lib/prerequisites.mjs b/plugins/harness-config/lib/prerequisites.mjs index 57f65a79b5..1e11abf2cc 100755 --- a/plugins/harness-config/lib/prerequisites.mjs +++ b/plugins/harness-config/lib/prerequisites.mjs @@ -28,8 +28,10 @@ import { accessSync, constants, existsSync, + lstatSync, mkdirSync, readFileSync, + readlinkSync, realpathSync, statSync, writeFileSync, @@ -42,8 +44,8 @@ export const KINDS = ["cli", "runtime", "system-lib", "python-pkg", "node-pkg", export const NEEDS = ["required", "optional"]; const ENTRY_KEYS = ["id", "kind", "need", "for", "detect", "degrade", "install", "check"]; const DETECT_KEYS = { - cli: { required: ["any"], optional: ["local_bin", "version"] }, - runtime: { required: ["any"], optional: ["local_bin", "version"] }, + cli: { required: ["any"], optional: ["local_bin", "version", "reject_store_alias"] }, + runtime: { required: ["any"], optional: ["local_bin", "version", "reject_store_alias"] }, "system-lib": { required: ["probe"], optional: [] }, "python-pkg": { required: ["any", "import"], optional: [] }, "node-pkg": { required: ["module", "paths"], optional: [] }, @@ -100,6 +102,9 @@ function detectErrors(kind, detect, where) { } if ("any" in detect && !isTextList(detect.any)) errors.push(`${where}.any must be a non-empty list of names`); if ("local_bin" in detect) errors.push(...relativePathErrors(detect.local_bin, `${where}.local_bin`)); + if ("reject_store_alias" in detect && typeof detect.reject_store_alias !== "boolean") { + errors.push(`${where}.reject_store_alias must be true or false`); + } if ("version" in detect) { const v = detect.version; const vw = `${where}.version`; @@ -243,36 +248,66 @@ export function compareVersions(a, b) { return 0; } -function isExecutable(file, platform) { +const FS = { statSync, lstatSync, readlinkSync, accessSync }; + +// The target of App Installer's Python "install me" stub, which opens the Store +// instead of running Python, and the alias names it takes. +const PYTHON_STUB_TARGET = "appinstallerpythonredirector.exe"; +const PYTHON_STUB_NAMES = new Set(["python", "python3"]); + +// A Windows App Execution Alias, how a Store or winget app reaches PATH, is a +// reparse point in a WindowsApps directory that stat cannot open, yet it runs. +// It counts as found unless it is the Python stub, known by its target or, when +// the target cannot be read, by its name. +const isWindowsApps = (dir) => path.basename(dir).toLowerCase() === "windowsapps"; + +function isAppAlias(file, fs) { + if (!isWindowsApps(path.dirname(file))) return false; try { - if (!statSync(file).isFile()) return false; - if (platform !== "win32") accessSync(file, constants.X_OK); - return true; + if (!fs.lstatSync(file).isSymbolicLink()) return false; } catch { return false; } + let target; + try { + target = fs.readlinkSync(file); + } catch { + return !PYTHON_STUB_NAMES.has(path.basename(file).toLowerCase().replace(/\.exe$/, "")); + } + return path.win32.basename(target).toLowerCase() !== PYTHON_STUB_TARGET; +} + +function isExecutable(file, platform, fs = FS) { + try { + if (!fs.statSync(file).isFile()) return false; + if (platform !== "win32") fs.accessSync(file, constants.X_OK); + return true; + } catch { + return platform === "win32" && isAppAlias(file, fs); + } } -// which(name) -> absolute path of the first PATH match, or null. -export function which(name, env = process.env, platform = process.platform) { +// which(name) -> absolute path of the first PATH match, or null. fs is injectable for tests. +// rejectStoreAlias skips WindowsApps directories on win32, so no App Execution Alias matches. +export function which(name, env = process.env, platform = process.platform, fs = FS, rejectStoreAlias = false) { const pathVar = env.PATH ?? env.Path ?? ""; const exts = platform === "win32" ? ["", ...(env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean)] : [""]; for (const dir of pathVar.split(path.delimiter)) { - if (!dir) continue; + if (!dir || (rejectStoreAlias && platform === "win32" && isWindowsApps(dir))) continue; for (const ext of exts) { const candidate = path.join(dir, name + ext); - if (isExecutable(candidate, platform)) return candidate; + if (isExecutable(candidate, platform, fs)) return candidate; } } return null; } -function findLocalBin(rels, cwd, platform) { +function findLocalBin(rels, cwd, platform, fs) { for (const rel of rels ?? []) { let dir = path.resolve(cwd); for (let i = 0; i < LOCAL_BIN_DEPTH; i++) { const candidate = path.join(dir, rel); - if (isExecutable(candidate, platform)) return candidate; + if (isExecutable(candidate, platform, fs)) return candidate; const parent = path.dirname(dir); if (parent === dir) break; dir = parent; @@ -286,9 +321,9 @@ function run(file, args) { return { ok: !r.error && r.status === 0, out: `${r.stdout ?? ""}${r.stderr ?? ""}` }; } -function firstOnPath(names, ctx) { +function firstOnPath(names, ctx, rejectStoreAlias = false) { for (const name of names) { - const found = which(name, ctx.env, ctx.platform); + const found = which(name, ctx.env, ctx.platform, ctx.fs, rejectStoreAlias); if (found) return found; } return null; @@ -314,7 +349,7 @@ export function detectEntry(entry, ctx) { switch (entry.kind) { case "cli": case "runtime": { - const found = firstOnPath(d.any, ctx) ?? findLocalBin(d.local_bin, ctx.cwd, ctx.platform); + const found = firstOnPath(d.any, ctx, d.reject_store_alias) ?? findLocalBin(d.local_bin, ctx.cwd, ctx.platform, ctx.fs); if (!found) { const where = d.local_bin ? `on PATH or as ${d.local_bin.join(", ")}` : "on PATH"; return { status: "missing", detail: `${d.any.join(" or ")} was not found ${where}` }; @@ -331,7 +366,7 @@ export function detectEntry(entry, ctx) { } case "system-lib": { const [bin, ...args] = d.probe.args; - const found = which(bin, ctx.env, ctx.platform); + const found = which(bin, ctx.env, ctx.platform, ctx.fs); if (!found) return { status: "missing", detail: `${bin} was not found on PATH to probe` }; if (new RegExp(d.probe.pattern).test(run(found, args).out)) return { status: "present", detail: found }; return { status: "missing", detail: `${bin} ${args.join(" ")} did not report ${d.probe.pattern}` }; @@ -566,6 +601,7 @@ export function main(argv, ctx = {}, out = {}) { env: ctx.env ?? process.env, platform: ctx.platform ?? process.platform, cwd: ctx.cwd ?? process.cwd(), + fs: ctx.fs ?? FS, stdin: ctx.stdin ?? (() => (process.stdin.isTTY ? "" : readFileSync(0, "utf8"))), }; const io = { stdout: out.stdout ?? ((s) => console.log(s)), stderr: out.stderr ?? ((s) => console.error(s)) }; diff --git a/plugins/harness-ops/.claude-plugin/plugin.json b/plugins/harness-ops/.claude-plugin/plugin.json index a60bdf1e82..5698384d08 100644 --- a/plugins/harness-ops/.claude-plugin/plugin.json +++ b/plugins/harness-ops/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "harness-ops", - "version": "3.6.4", + "version": "3.6.5", "description": "Claude Code operations: audit-skill-visibility (skills the listing budget hides), inventory (all commands, skills, agents, tools, plugins), audit-install-state (~/.claude), audit-performance (slowness), audit-native-overlap (skills duplicating built-ins), observability (telemetry), known-issues (Claude bugs, status), changelog, prerequisites, check, machine-profile, plugins (update the fleet), morning-brief, lanes (background loop sessions), setup. Plus opt-in hook event logs.", "author": { "name": "Melodic Software", diff --git a/plugins/harness-ops/CHANGELOG.md b/plugins/harness-ops/CHANGELOG.md index 385c8beaa7..433e48cb73 100644 --- a/plugins/harness-ops/CHANGELOG.md +++ b/plugins/harness-ops/CHANGELOG.md @@ -3,6 +3,14 @@ All notable changes to the `harness-ops` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [3.6.5] - 2026-10-03 + +### Changed + +- **Shared `prerequisites.mjs` synced ([#6084](https://github.com/melodic-software/claude-code-plugins/issues/6084)); no change to this plugin's lib.** + The prerequisite check now counts a Windows App Execution Alias (a Store or winget install on PATH) as found, + except App Installer's Python install stub. A `cli` or `runtime` entry can set `reject_store_alias` to skip aliases instead; no entry in this plugin does. + ## [3.6.4] - 2026-10-03 ### Changed diff --git a/plugins/harness-ops/lib/prerequisites.mjs b/plugins/harness-ops/lib/prerequisites.mjs index 57f65a79b5..1e11abf2cc 100755 --- a/plugins/harness-ops/lib/prerequisites.mjs +++ b/plugins/harness-ops/lib/prerequisites.mjs @@ -28,8 +28,10 @@ import { accessSync, constants, existsSync, + lstatSync, mkdirSync, readFileSync, + readlinkSync, realpathSync, statSync, writeFileSync, @@ -42,8 +44,8 @@ export const KINDS = ["cli", "runtime", "system-lib", "python-pkg", "node-pkg", export const NEEDS = ["required", "optional"]; const ENTRY_KEYS = ["id", "kind", "need", "for", "detect", "degrade", "install", "check"]; const DETECT_KEYS = { - cli: { required: ["any"], optional: ["local_bin", "version"] }, - runtime: { required: ["any"], optional: ["local_bin", "version"] }, + cli: { required: ["any"], optional: ["local_bin", "version", "reject_store_alias"] }, + runtime: { required: ["any"], optional: ["local_bin", "version", "reject_store_alias"] }, "system-lib": { required: ["probe"], optional: [] }, "python-pkg": { required: ["any", "import"], optional: [] }, "node-pkg": { required: ["module", "paths"], optional: [] }, @@ -100,6 +102,9 @@ function detectErrors(kind, detect, where) { } if ("any" in detect && !isTextList(detect.any)) errors.push(`${where}.any must be a non-empty list of names`); if ("local_bin" in detect) errors.push(...relativePathErrors(detect.local_bin, `${where}.local_bin`)); + if ("reject_store_alias" in detect && typeof detect.reject_store_alias !== "boolean") { + errors.push(`${where}.reject_store_alias must be true or false`); + } if ("version" in detect) { const v = detect.version; const vw = `${where}.version`; @@ -243,36 +248,66 @@ export function compareVersions(a, b) { return 0; } -function isExecutable(file, platform) { +const FS = { statSync, lstatSync, readlinkSync, accessSync }; + +// The target of App Installer's Python "install me" stub, which opens the Store +// instead of running Python, and the alias names it takes. +const PYTHON_STUB_TARGET = "appinstallerpythonredirector.exe"; +const PYTHON_STUB_NAMES = new Set(["python", "python3"]); + +// A Windows App Execution Alias, how a Store or winget app reaches PATH, is a +// reparse point in a WindowsApps directory that stat cannot open, yet it runs. +// It counts as found unless it is the Python stub, known by its target or, when +// the target cannot be read, by its name. +const isWindowsApps = (dir) => path.basename(dir).toLowerCase() === "windowsapps"; + +function isAppAlias(file, fs) { + if (!isWindowsApps(path.dirname(file))) return false; try { - if (!statSync(file).isFile()) return false; - if (platform !== "win32") accessSync(file, constants.X_OK); - return true; + if (!fs.lstatSync(file).isSymbolicLink()) return false; } catch { return false; } + let target; + try { + target = fs.readlinkSync(file); + } catch { + return !PYTHON_STUB_NAMES.has(path.basename(file).toLowerCase().replace(/\.exe$/, "")); + } + return path.win32.basename(target).toLowerCase() !== PYTHON_STUB_TARGET; +} + +function isExecutable(file, platform, fs = FS) { + try { + if (!fs.statSync(file).isFile()) return false; + if (platform !== "win32") fs.accessSync(file, constants.X_OK); + return true; + } catch { + return platform === "win32" && isAppAlias(file, fs); + } } -// which(name) -> absolute path of the first PATH match, or null. -export function which(name, env = process.env, platform = process.platform) { +// which(name) -> absolute path of the first PATH match, or null. fs is injectable for tests. +// rejectStoreAlias skips WindowsApps directories on win32, so no App Execution Alias matches. +export function which(name, env = process.env, platform = process.platform, fs = FS, rejectStoreAlias = false) { const pathVar = env.PATH ?? env.Path ?? ""; const exts = platform === "win32" ? ["", ...(env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean)] : [""]; for (const dir of pathVar.split(path.delimiter)) { - if (!dir) continue; + if (!dir || (rejectStoreAlias && platform === "win32" && isWindowsApps(dir))) continue; for (const ext of exts) { const candidate = path.join(dir, name + ext); - if (isExecutable(candidate, platform)) return candidate; + if (isExecutable(candidate, platform, fs)) return candidate; } } return null; } -function findLocalBin(rels, cwd, platform) { +function findLocalBin(rels, cwd, platform, fs) { for (const rel of rels ?? []) { let dir = path.resolve(cwd); for (let i = 0; i < LOCAL_BIN_DEPTH; i++) { const candidate = path.join(dir, rel); - if (isExecutable(candidate, platform)) return candidate; + if (isExecutable(candidate, platform, fs)) return candidate; const parent = path.dirname(dir); if (parent === dir) break; dir = parent; @@ -286,9 +321,9 @@ function run(file, args) { return { ok: !r.error && r.status === 0, out: `${r.stdout ?? ""}${r.stderr ?? ""}` }; } -function firstOnPath(names, ctx) { +function firstOnPath(names, ctx, rejectStoreAlias = false) { for (const name of names) { - const found = which(name, ctx.env, ctx.platform); + const found = which(name, ctx.env, ctx.platform, ctx.fs, rejectStoreAlias); if (found) return found; } return null; @@ -314,7 +349,7 @@ export function detectEntry(entry, ctx) { switch (entry.kind) { case "cli": case "runtime": { - const found = firstOnPath(d.any, ctx) ?? findLocalBin(d.local_bin, ctx.cwd, ctx.platform); + const found = firstOnPath(d.any, ctx, d.reject_store_alias) ?? findLocalBin(d.local_bin, ctx.cwd, ctx.platform, ctx.fs); if (!found) { const where = d.local_bin ? `on PATH or as ${d.local_bin.join(", ")}` : "on PATH"; return { status: "missing", detail: `${d.any.join(" or ")} was not found ${where}` }; @@ -331,7 +366,7 @@ export function detectEntry(entry, ctx) { } case "system-lib": { const [bin, ...args] = d.probe.args; - const found = which(bin, ctx.env, ctx.platform); + const found = which(bin, ctx.env, ctx.platform, ctx.fs); if (!found) return { status: "missing", detail: `${bin} was not found on PATH to probe` }; if (new RegExp(d.probe.pattern).test(run(found, args).out)) return { status: "present", detail: found }; return { status: "missing", detail: `${bin} ${args.join(" ")} did not report ${d.probe.pattern}` }; @@ -566,6 +601,7 @@ export function main(argv, ctx = {}, out = {}) { env: ctx.env ?? process.env, platform: ctx.platform ?? process.platform, cwd: ctx.cwd ?? process.cwd(), + fs: ctx.fs ?? FS, stdin: ctx.stdin ?? (() => (process.stdin.isTTY ? "" : readFileSync(0, "utf8"))), }; const io = { stdout: out.stdout ?? ((s) => console.log(s)), stderr: out.stderr ?? ((s) => console.error(s)) }; diff --git a/plugins/instruction-placement/.claude-plugin/plugin.json b/plugins/instruction-placement/.claude-plugin/plugin.json index 8b4fb03e60..00d3c05999 100644 --- a/plugins/instruction-placement/.claude-plugin/plugin.json +++ b/plugins/instruction-placement/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "instruction-placement", - "version": "0.21.1", + "version": "0.21.2", "description": "Routes agent instructions to the surface that loads them at the right time. audit finds conventions narrower than the always-loaded CLAUDE.md or AGENTS.md holding them, or stranded in docs Claude never loads, and proposes a destination with a validated paths glob; safety-class content is never demoted. realign applies moves item by item and regenerates an index. check verifies globs and index. migrate moves content to AGENTS.md with a CLAUDE.md shim. setup checks the index is readable.", "author": { "name": "Melodic Software", diff --git a/plugins/instruction-placement/CHANGELOG.md b/plugins/instruction-placement/CHANGELOG.md index 96b43b5d8d..15016cb043 100644 --- a/plugins/instruction-placement/CHANGELOG.md +++ b/plugins/instruction-placement/CHANGELOG.md @@ -3,6 +3,14 @@ All notable changes to the `instruction-placement` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.21.2] - 2026-10-03 + +### Changed + +- **Shared `prerequisites.mjs` synced ([#6084](https://github.com/melodic-software/claude-code-plugins/issues/6084)); no change to this plugin's lib.** + The prerequisite check now counts a Windows App Execution Alias (a Store or winget install on PATH) as found, + except App Installer's Python install stub. A `cli` or `runtime` entry can set `reject_store_alias` to skip aliases instead; no entry in this plugin does. + ## [0.21.1] - 2026-10-03 ### Changed diff --git a/plugins/instruction-placement/lib/prerequisites.mjs b/plugins/instruction-placement/lib/prerequisites.mjs index 57f65a79b5..1e11abf2cc 100755 --- a/plugins/instruction-placement/lib/prerequisites.mjs +++ b/plugins/instruction-placement/lib/prerequisites.mjs @@ -28,8 +28,10 @@ import { accessSync, constants, existsSync, + lstatSync, mkdirSync, readFileSync, + readlinkSync, realpathSync, statSync, writeFileSync, @@ -42,8 +44,8 @@ export const KINDS = ["cli", "runtime", "system-lib", "python-pkg", "node-pkg", export const NEEDS = ["required", "optional"]; const ENTRY_KEYS = ["id", "kind", "need", "for", "detect", "degrade", "install", "check"]; const DETECT_KEYS = { - cli: { required: ["any"], optional: ["local_bin", "version"] }, - runtime: { required: ["any"], optional: ["local_bin", "version"] }, + cli: { required: ["any"], optional: ["local_bin", "version", "reject_store_alias"] }, + runtime: { required: ["any"], optional: ["local_bin", "version", "reject_store_alias"] }, "system-lib": { required: ["probe"], optional: [] }, "python-pkg": { required: ["any", "import"], optional: [] }, "node-pkg": { required: ["module", "paths"], optional: [] }, @@ -100,6 +102,9 @@ function detectErrors(kind, detect, where) { } if ("any" in detect && !isTextList(detect.any)) errors.push(`${where}.any must be a non-empty list of names`); if ("local_bin" in detect) errors.push(...relativePathErrors(detect.local_bin, `${where}.local_bin`)); + if ("reject_store_alias" in detect && typeof detect.reject_store_alias !== "boolean") { + errors.push(`${where}.reject_store_alias must be true or false`); + } if ("version" in detect) { const v = detect.version; const vw = `${where}.version`; @@ -243,36 +248,66 @@ export function compareVersions(a, b) { return 0; } -function isExecutable(file, platform) { +const FS = { statSync, lstatSync, readlinkSync, accessSync }; + +// The target of App Installer's Python "install me" stub, which opens the Store +// instead of running Python, and the alias names it takes. +const PYTHON_STUB_TARGET = "appinstallerpythonredirector.exe"; +const PYTHON_STUB_NAMES = new Set(["python", "python3"]); + +// A Windows App Execution Alias, how a Store or winget app reaches PATH, is a +// reparse point in a WindowsApps directory that stat cannot open, yet it runs. +// It counts as found unless it is the Python stub, known by its target or, when +// the target cannot be read, by its name. +const isWindowsApps = (dir) => path.basename(dir).toLowerCase() === "windowsapps"; + +function isAppAlias(file, fs) { + if (!isWindowsApps(path.dirname(file))) return false; try { - if (!statSync(file).isFile()) return false; - if (platform !== "win32") accessSync(file, constants.X_OK); - return true; + if (!fs.lstatSync(file).isSymbolicLink()) return false; } catch { return false; } + let target; + try { + target = fs.readlinkSync(file); + } catch { + return !PYTHON_STUB_NAMES.has(path.basename(file).toLowerCase().replace(/\.exe$/, "")); + } + return path.win32.basename(target).toLowerCase() !== PYTHON_STUB_TARGET; +} + +function isExecutable(file, platform, fs = FS) { + try { + if (!fs.statSync(file).isFile()) return false; + if (platform !== "win32") fs.accessSync(file, constants.X_OK); + return true; + } catch { + return platform === "win32" && isAppAlias(file, fs); + } } -// which(name) -> absolute path of the first PATH match, or null. -export function which(name, env = process.env, platform = process.platform) { +// which(name) -> absolute path of the first PATH match, or null. fs is injectable for tests. +// rejectStoreAlias skips WindowsApps directories on win32, so no App Execution Alias matches. +export function which(name, env = process.env, platform = process.platform, fs = FS, rejectStoreAlias = false) { const pathVar = env.PATH ?? env.Path ?? ""; const exts = platform === "win32" ? ["", ...(env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean)] : [""]; for (const dir of pathVar.split(path.delimiter)) { - if (!dir) continue; + if (!dir || (rejectStoreAlias && platform === "win32" && isWindowsApps(dir))) continue; for (const ext of exts) { const candidate = path.join(dir, name + ext); - if (isExecutable(candidate, platform)) return candidate; + if (isExecutable(candidate, platform, fs)) return candidate; } } return null; } -function findLocalBin(rels, cwd, platform) { +function findLocalBin(rels, cwd, platform, fs) { for (const rel of rels ?? []) { let dir = path.resolve(cwd); for (let i = 0; i < LOCAL_BIN_DEPTH; i++) { const candidate = path.join(dir, rel); - if (isExecutable(candidate, platform)) return candidate; + if (isExecutable(candidate, platform, fs)) return candidate; const parent = path.dirname(dir); if (parent === dir) break; dir = parent; @@ -286,9 +321,9 @@ function run(file, args) { return { ok: !r.error && r.status === 0, out: `${r.stdout ?? ""}${r.stderr ?? ""}` }; } -function firstOnPath(names, ctx) { +function firstOnPath(names, ctx, rejectStoreAlias = false) { for (const name of names) { - const found = which(name, ctx.env, ctx.platform); + const found = which(name, ctx.env, ctx.platform, ctx.fs, rejectStoreAlias); if (found) return found; } return null; @@ -314,7 +349,7 @@ export function detectEntry(entry, ctx) { switch (entry.kind) { case "cli": case "runtime": { - const found = firstOnPath(d.any, ctx) ?? findLocalBin(d.local_bin, ctx.cwd, ctx.platform); + const found = firstOnPath(d.any, ctx, d.reject_store_alias) ?? findLocalBin(d.local_bin, ctx.cwd, ctx.platform, ctx.fs); if (!found) { const where = d.local_bin ? `on PATH or as ${d.local_bin.join(", ")}` : "on PATH"; return { status: "missing", detail: `${d.any.join(" or ")} was not found ${where}` }; @@ -331,7 +366,7 @@ export function detectEntry(entry, ctx) { } case "system-lib": { const [bin, ...args] = d.probe.args; - const found = which(bin, ctx.env, ctx.platform); + const found = which(bin, ctx.env, ctx.platform, ctx.fs); if (!found) return { status: "missing", detail: `${bin} was not found on PATH to probe` }; if (new RegExp(d.probe.pattern).test(run(found, args).out)) return { status: "present", detail: found }; return { status: "missing", detail: `${bin} ${args.join(" ")} did not report ${d.probe.pattern}` }; @@ -566,6 +601,7 @@ export function main(argv, ctx = {}, out = {}) { env: ctx.env ?? process.env, platform: ctx.platform ?? process.platform, cwd: ctx.cwd ?? process.cwd(), + fs: ctx.fs ?? FS, stdin: ctx.stdin ?? (() => (process.stdin.isTTY ? "" : readFileSync(0, "utf8"))), }; const io = { stdout: out.stdout ?? ((s) => console.log(s)), stderr: out.stderr ?? ((s) => console.error(s)) }; diff --git a/plugins/kindle-dedrm/.claude-plugin/plugin.json b/plugins/kindle-dedrm/.claude-plugin/plugin.json index 24c7ee41a7..0c1af09f7c 100644 --- a/plugins/kindle-dedrm/.claude-plugin/plugin.json +++ b/plugins/kindle-dedrm/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "kindle-dedrm", - "version": "0.8.1", + "version": "0.8.2", "description": "Manage the Kindle for PC 2.8.0 + Calibre DeDRM workflow for personal-use ebook DRM removal on books the user owns (Windows only). Action router with setup, sync, update, cleanup, and status, each state mutation paired with a documented compensating reversal.", "author": { "name": "Melodic Software", diff --git a/plugins/kindle-dedrm/CHANGELOG.md b/plugins/kindle-dedrm/CHANGELOG.md index f306be799c..388daf7835 100644 --- a/plugins/kindle-dedrm/CHANGELOG.md +++ b/plugins/kindle-dedrm/CHANGELOG.md @@ -3,6 +3,17 @@ All notable changes to the `kindle-dedrm` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.8.2] - 2026-10-03 + +### Changed + +- **Shared `prerequisites.mjs` synced ([#6084](https://github.com/melodic-software/claude-code-plugins/issues/6084)).** + The prerequisite check now counts a Windows App Execution Alias (a Store or winget install on PATH) as found, + except App Installer's Python install stub. +- **The `python` prerequisite sets `reject_store_alias`, so a Microsoft Store Python alias no longer passes it + ([#6084](https://github.com/melodic-software/claude-code-plugins/issues/6084)).** Keyfinder refuses WindowsApps + stubs and Store sandbox installs, so the check now skips them too and finds a python.org or uv Python later on PATH. + ## [0.8.1] - 2026-10-03 ### Changed diff --git a/plugins/kindle-dedrm/lib/prerequisites.mjs b/plugins/kindle-dedrm/lib/prerequisites.mjs index 57f65a79b5..1e11abf2cc 100755 --- a/plugins/kindle-dedrm/lib/prerequisites.mjs +++ b/plugins/kindle-dedrm/lib/prerequisites.mjs @@ -28,8 +28,10 @@ import { accessSync, constants, existsSync, + lstatSync, mkdirSync, readFileSync, + readlinkSync, realpathSync, statSync, writeFileSync, @@ -42,8 +44,8 @@ export const KINDS = ["cli", "runtime", "system-lib", "python-pkg", "node-pkg", export const NEEDS = ["required", "optional"]; const ENTRY_KEYS = ["id", "kind", "need", "for", "detect", "degrade", "install", "check"]; const DETECT_KEYS = { - cli: { required: ["any"], optional: ["local_bin", "version"] }, - runtime: { required: ["any"], optional: ["local_bin", "version"] }, + cli: { required: ["any"], optional: ["local_bin", "version", "reject_store_alias"] }, + runtime: { required: ["any"], optional: ["local_bin", "version", "reject_store_alias"] }, "system-lib": { required: ["probe"], optional: [] }, "python-pkg": { required: ["any", "import"], optional: [] }, "node-pkg": { required: ["module", "paths"], optional: [] }, @@ -100,6 +102,9 @@ function detectErrors(kind, detect, where) { } if ("any" in detect && !isTextList(detect.any)) errors.push(`${where}.any must be a non-empty list of names`); if ("local_bin" in detect) errors.push(...relativePathErrors(detect.local_bin, `${where}.local_bin`)); + if ("reject_store_alias" in detect && typeof detect.reject_store_alias !== "boolean") { + errors.push(`${where}.reject_store_alias must be true or false`); + } if ("version" in detect) { const v = detect.version; const vw = `${where}.version`; @@ -243,36 +248,66 @@ export function compareVersions(a, b) { return 0; } -function isExecutable(file, platform) { +const FS = { statSync, lstatSync, readlinkSync, accessSync }; + +// The target of App Installer's Python "install me" stub, which opens the Store +// instead of running Python, and the alias names it takes. +const PYTHON_STUB_TARGET = "appinstallerpythonredirector.exe"; +const PYTHON_STUB_NAMES = new Set(["python", "python3"]); + +// A Windows App Execution Alias, how a Store or winget app reaches PATH, is a +// reparse point in a WindowsApps directory that stat cannot open, yet it runs. +// It counts as found unless it is the Python stub, known by its target or, when +// the target cannot be read, by its name. +const isWindowsApps = (dir) => path.basename(dir).toLowerCase() === "windowsapps"; + +function isAppAlias(file, fs) { + if (!isWindowsApps(path.dirname(file))) return false; try { - if (!statSync(file).isFile()) return false; - if (platform !== "win32") accessSync(file, constants.X_OK); - return true; + if (!fs.lstatSync(file).isSymbolicLink()) return false; } catch { return false; } + let target; + try { + target = fs.readlinkSync(file); + } catch { + return !PYTHON_STUB_NAMES.has(path.basename(file).toLowerCase().replace(/\.exe$/, "")); + } + return path.win32.basename(target).toLowerCase() !== PYTHON_STUB_TARGET; +} + +function isExecutable(file, platform, fs = FS) { + try { + if (!fs.statSync(file).isFile()) return false; + if (platform !== "win32") fs.accessSync(file, constants.X_OK); + return true; + } catch { + return platform === "win32" && isAppAlias(file, fs); + } } -// which(name) -> absolute path of the first PATH match, or null. -export function which(name, env = process.env, platform = process.platform) { +// which(name) -> absolute path of the first PATH match, or null. fs is injectable for tests. +// rejectStoreAlias skips WindowsApps directories on win32, so no App Execution Alias matches. +export function which(name, env = process.env, platform = process.platform, fs = FS, rejectStoreAlias = false) { const pathVar = env.PATH ?? env.Path ?? ""; const exts = platform === "win32" ? ["", ...(env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean)] : [""]; for (const dir of pathVar.split(path.delimiter)) { - if (!dir) continue; + if (!dir || (rejectStoreAlias && platform === "win32" && isWindowsApps(dir))) continue; for (const ext of exts) { const candidate = path.join(dir, name + ext); - if (isExecutable(candidate, platform)) return candidate; + if (isExecutable(candidate, platform, fs)) return candidate; } } return null; } -function findLocalBin(rels, cwd, platform) { +function findLocalBin(rels, cwd, platform, fs) { for (const rel of rels ?? []) { let dir = path.resolve(cwd); for (let i = 0; i < LOCAL_BIN_DEPTH; i++) { const candidate = path.join(dir, rel); - if (isExecutable(candidate, platform)) return candidate; + if (isExecutable(candidate, platform, fs)) return candidate; const parent = path.dirname(dir); if (parent === dir) break; dir = parent; @@ -286,9 +321,9 @@ function run(file, args) { return { ok: !r.error && r.status === 0, out: `${r.stdout ?? ""}${r.stderr ?? ""}` }; } -function firstOnPath(names, ctx) { +function firstOnPath(names, ctx, rejectStoreAlias = false) { for (const name of names) { - const found = which(name, ctx.env, ctx.platform); + const found = which(name, ctx.env, ctx.platform, ctx.fs, rejectStoreAlias); if (found) return found; } return null; @@ -314,7 +349,7 @@ export function detectEntry(entry, ctx) { switch (entry.kind) { case "cli": case "runtime": { - const found = firstOnPath(d.any, ctx) ?? findLocalBin(d.local_bin, ctx.cwd, ctx.platform); + const found = firstOnPath(d.any, ctx, d.reject_store_alias) ?? findLocalBin(d.local_bin, ctx.cwd, ctx.platform, ctx.fs); if (!found) { const where = d.local_bin ? `on PATH or as ${d.local_bin.join(", ")}` : "on PATH"; return { status: "missing", detail: `${d.any.join(" or ")} was not found ${where}` }; @@ -331,7 +366,7 @@ export function detectEntry(entry, ctx) { } case "system-lib": { const [bin, ...args] = d.probe.args; - const found = which(bin, ctx.env, ctx.platform); + const found = which(bin, ctx.env, ctx.platform, ctx.fs); if (!found) return { status: "missing", detail: `${bin} was not found on PATH to probe` }; if (new RegExp(d.probe.pattern).test(run(found, args).out)) return { status: "present", detail: found }; return { status: "missing", detail: `${bin} ${args.join(" ")} did not report ${d.probe.pattern}` }; @@ -566,6 +601,7 @@ export function main(argv, ctx = {}, out = {}) { env: ctx.env ?? process.env, platform: ctx.platform ?? process.platform, cwd: ctx.cwd ?? process.cwd(), + fs: ctx.fs ?? FS, stdin: ctx.stdin ?? (() => (process.stdin.isTTY ? "" : readFileSync(0, "utf8"))), }; const io = { stdout: out.stdout ?? ((s) => console.log(s)), stderr: out.stderr ?? ((s) => console.error(s)) }; diff --git a/plugins/kindle-dedrm/prerequisites.json b/plugins/kindle-dedrm/prerequisites.json index 96e34ca606..c59d529efa 100644 --- a/plugins/kindle-dedrm/prerequisites.json +++ b/plugins/kindle-dedrm/prerequisites.json @@ -34,6 +34,7 @@ "python3", "python" ], + "reject_store_alias": true, "version": { "args": [ "--version" diff --git a/plugins/knowledge/.claude-plugin/plugin.json b/plugins/knowledge/.claude-plugin/plugin.json index 08690ba53b..43926f49eb 100644 --- a/plugins/knowledge/.claude-plugin/plugin.json +++ b/plugins/knowledge/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "knowledge", - "version": "0.19.3", + "version": "0.19.4", "description": "Ingests external knowledge into synthesized artifacts: book-distill (PDF or EPUB into author-attributed reference files), video-digest (one YouTube or X video: transcript, links, repo applicability), course-digest (Dometrain and Teachable courses into recommendations), docpage-digest (one documentation page into a verified knowledge slice), and map-corpus (a multi-resource corpus into a classified link map and an approved docpage-digest queue). setup sets where artifacts land.", "author": { "name": "Melodic Software", diff --git a/plugins/knowledge/CHANGELOG.md b/plugins/knowledge/CHANGELOG.md index d6e5e44834..ff5b92e230 100644 --- a/plugins/knowledge/CHANGELOG.md +++ b/plugins/knowledge/CHANGELOG.md @@ -4,6 +4,14 @@ All notable changes to the `knowledge` plugin are recorded here. The `version` i `.claude-plugin/plugin.json` is the delivery vehicle. A consumer receives a change only after that version increases. +## [0.19.4] - 2026-10-03 + +### Changed + +- **Shared `prerequisites.mjs` synced ([#6084](https://github.com/melodic-software/claude-code-plugins/issues/6084)); no change to this plugin's lib.** + The prerequisite check now counts a Windows App Execution Alias (a Store or winget install on PATH) as found, + except App Installer's Python install stub. A `cli` or `runtime` entry can set `reject_store_alias` to skip aliases instead; no entry in this plugin does. + ## [0.19.3] - 2026-10-03 ### Changed diff --git a/plugins/knowledge/lib/prerequisites.mjs b/plugins/knowledge/lib/prerequisites.mjs index 57f65a79b5..1e11abf2cc 100755 --- a/plugins/knowledge/lib/prerequisites.mjs +++ b/plugins/knowledge/lib/prerequisites.mjs @@ -28,8 +28,10 @@ import { accessSync, constants, existsSync, + lstatSync, mkdirSync, readFileSync, + readlinkSync, realpathSync, statSync, writeFileSync, @@ -42,8 +44,8 @@ export const KINDS = ["cli", "runtime", "system-lib", "python-pkg", "node-pkg", export const NEEDS = ["required", "optional"]; const ENTRY_KEYS = ["id", "kind", "need", "for", "detect", "degrade", "install", "check"]; const DETECT_KEYS = { - cli: { required: ["any"], optional: ["local_bin", "version"] }, - runtime: { required: ["any"], optional: ["local_bin", "version"] }, + cli: { required: ["any"], optional: ["local_bin", "version", "reject_store_alias"] }, + runtime: { required: ["any"], optional: ["local_bin", "version", "reject_store_alias"] }, "system-lib": { required: ["probe"], optional: [] }, "python-pkg": { required: ["any", "import"], optional: [] }, "node-pkg": { required: ["module", "paths"], optional: [] }, @@ -100,6 +102,9 @@ function detectErrors(kind, detect, where) { } if ("any" in detect && !isTextList(detect.any)) errors.push(`${where}.any must be a non-empty list of names`); if ("local_bin" in detect) errors.push(...relativePathErrors(detect.local_bin, `${where}.local_bin`)); + if ("reject_store_alias" in detect && typeof detect.reject_store_alias !== "boolean") { + errors.push(`${where}.reject_store_alias must be true or false`); + } if ("version" in detect) { const v = detect.version; const vw = `${where}.version`; @@ -243,36 +248,66 @@ export function compareVersions(a, b) { return 0; } -function isExecutable(file, platform) { +const FS = { statSync, lstatSync, readlinkSync, accessSync }; + +// The target of App Installer's Python "install me" stub, which opens the Store +// instead of running Python, and the alias names it takes. +const PYTHON_STUB_TARGET = "appinstallerpythonredirector.exe"; +const PYTHON_STUB_NAMES = new Set(["python", "python3"]); + +// A Windows App Execution Alias, how a Store or winget app reaches PATH, is a +// reparse point in a WindowsApps directory that stat cannot open, yet it runs. +// It counts as found unless it is the Python stub, known by its target or, when +// the target cannot be read, by its name. +const isWindowsApps = (dir) => path.basename(dir).toLowerCase() === "windowsapps"; + +function isAppAlias(file, fs) { + if (!isWindowsApps(path.dirname(file))) return false; try { - if (!statSync(file).isFile()) return false; - if (platform !== "win32") accessSync(file, constants.X_OK); - return true; + if (!fs.lstatSync(file).isSymbolicLink()) return false; } catch { return false; } + let target; + try { + target = fs.readlinkSync(file); + } catch { + return !PYTHON_STUB_NAMES.has(path.basename(file).toLowerCase().replace(/\.exe$/, "")); + } + return path.win32.basename(target).toLowerCase() !== PYTHON_STUB_TARGET; +} + +function isExecutable(file, platform, fs = FS) { + try { + if (!fs.statSync(file).isFile()) return false; + if (platform !== "win32") fs.accessSync(file, constants.X_OK); + return true; + } catch { + return platform === "win32" && isAppAlias(file, fs); + } } -// which(name) -> absolute path of the first PATH match, or null. -export function which(name, env = process.env, platform = process.platform) { +// which(name) -> absolute path of the first PATH match, or null. fs is injectable for tests. +// rejectStoreAlias skips WindowsApps directories on win32, so no App Execution Alias matches. +export function which(name, env = process.env, platform = process.platform, fs = FS, rejectStoreAlias = false) { const pathVar = env.PATH ?? env.Path ?? ""; const exts = platform === "win32" ? ["", ...(env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean)] : [""]; for (const dir of pathVar.split(path.delimiter)) { - if (!dir) continue; + if (!dir || (rejectStoreAlias && platform === "win32" && isWindowsApps(dir))) continue; for (const ext of exts) { const candidate = path.join(dir, name + ext); - if (isExecutable(candidate, platform)) return candidate; + if (isExecutable(candidate, platform, fs)) return candidate; } } return null; } -function findLocalBin(rels, cwd, platform) { +function findLocalBin(rels, cwd, platform, fs) { for (const rel of rels ?? []) { let dir = path.resolve(cwd); for (let i = 0; i < LOCAL_BIN_DEPTH; i++) { const candidate = path.join(dir, rel); - if (isExecutable(candidate, platform)) return candidate; + if (isExecutable(candidate, platform, fs)) return candidate; const parent = path.dirname(dir); if (parent === dir) break; dir = parent; @@ -286,9 +321,9 @@ function run(file, args) { return { ok: !r.error && r.status === 0, out: `${r.stdout ?? ""}${r.stderr ?? ""}` }; } -function firstOnPath(names, ctx) { +function firstOnPath(names, ctx, rejectStoreAlias = false) { for (const name of names) { - const found = which(name, ctx.env, ctx.platform); + const found = which(name, ctx.env, ctx.platform, ctx.fs, rejectStoreAlias); if (found) return found; } return null; @@ -314,7 +349,7 @@ export function detectEntry(entry, ctx) { switch (entry.kind) { case "cli": case "runtime": { - const found = firstOnPath(d.any, ctx) ?? findLocalBin(d.local_bin, ctx.cwd, ctx.platform); + const found = firstOnPath(d.any, ctx, d.reject_store_alias) ?? findLocalBin(d.local_bin, ctx.cwd, ctx.platform, ctx.fs); if (!found) { const where = d.local_bin ? `on PATH or as ${d.local_bin.join(", ")}` : "on PATH"; return { status: "missing", detail: `${d.any.join(" or ")} was not found ${where}` }; @@ -331,7 +366,7 @@ export function detectEntry(entry, ctx) { } case "system-lib": { const [bin, ...args] = d.probe.args; - const found = which(bin, ctx.env, ctx.platform); + const found = which(bin, ctx.env, ctx.platform, ctx.fs); if (!found) return { status: "missing", detail: `${bin} was not found on PATH to probe` }; if (new RegExp(d.probe.pattern).test(run(found, args).out)) return { status: "present", detail: found }; return { status: "missing", detail: `${bin} ${args.join(" ")} did not report ${d.probe.pattern}` }; @@ -566,6 +601,7 @@ export function main(argv, ctx = {}, out = {}) { env: ctx.env ?? process.env, platform: ctx.platform ?? process.platform, cwd: ctx.cwd ?? process.cwd(), + fs: ctx.fs ?? FS, stdin: ctx.stdin ?? (() => (process.stdin.isTTY ? "" : readFileSync(0, "utf8"))), }; const io = { stdout: out.stdout ?? ((s) => console.log(s)), stderr: out.stderr ?? ((s) => console.error(s)) }; diff --git a/plugins/machine-health/.claude-plugin/plugin.json b/plugins/machine-health/.claude-plugin/plugin.json index 9ac9ef30a7..e4c18b4ca6 100644 --- a/plugins/machine-health/.claude-plugin/plugin.json +++ b/plugins/machine-health/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "machine-health", - "version": "0.15.1", + "version": "0.15.2", "description": "Workstation health audit: OS-specific checks (disk, OS updates, security posture, CISA KEV correlation) run from a versioned catalog with trend-aware severity, approval-gated remediations, and dated markdown reports. Windows fully implemented; macOS/Linux scaffolded (report UNKNOWN and stop). Machine state persists in the plugin data directory; the report directory and check catalog are configurable.", "author": { "name": "Melodic Software", diff --git a/plugins/machine-health/CHANGELOG.md b/plugins/machine-health/CHANGELOG.md index 250c08020a..997ce43837 100644 --- a/plugins/machine-health/CHANGELOG.md +++ b/plugins/machine-health/CHANGELOG.md @@ -3,6 +3,14 @@ All notable changes to the `machine-health` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.15.2] - 2026-10-03 + +### Changed + +- **Shared `prerequisites.mjs` synced ([#6084](https://github.com/melodic-software/claude-code-plugins/issues/6084)); no change to this plugin's lib.** + The prerequisite check now counts a Windows App Execution Alias (a Store or winget install on PATH) as found, + except App Installer's Python install stub. A `cli` or `runtime` entry can set `reject_store_alias` to skip aliases instead; no entry in this plugin does. + ## [0.15.1] - 2026-10-03 ### Changed diff --git a/plugins/machine-health/lib/prerequisites.mjs b/plugins/machine-health/lib/prerequisites.mjs index 57f65a79b5..1e11abf2cc 100755 --- a/plugins/machine-health/lib/prerequisites.mjs +++ b/plugins/machine-health/lib/prerequisites.mjs @@ -28,8 +28,10 @@ import { accessSync, constants, existsSync, + lstatSync, mkdirSync, readFileSync, + readlinkSync, realpathSync, statSync, writeFileSync, @@ -42,8 +44,8 @@ export const KINDS = ["cli", "runtime", "system-lib", "python-pkg", "node-pkg", export const NEEDS = ["required", "optional"]; const ENTRY_KEYS = ["id", "kind", "need", "for", "detect", "degrade", "install", "check"]; const DETECT_KEYS = { - cli: { required: ["any"], optional: ["local_bin", "version"] }, - runtime: { required: ["any"], optional: ["local_bin", "version"] }, + cli: { required: ["any"], optional: ["local_bin", "version", "reject_store_alias"] }, + runtime: { required: ["any"], optional: ["local_bin", "version", "reject_store_alias"] }, "system-lib": { required: ["probe"], optional: [] }, "python-pkg": { required: ["any", "import"], optional: [] }, "node-pkg": { required: ["module", "paths"], optional: [] }, @@ -100,6 +102,9 @@ function detectErrors(kind, detect, where) { } if ("any" in detect && !isTextList(detect.any)) errors.push(`${where}.any must be a non-empty list of names`); if ("local_bin" in detect) errors.push(...relativePathErrors(detect.local_bin, `${where}.local_bin`)); + if ("reject_store_alias" in detect && typeof detect.reject_store_alias !== "boolean") { + errors.push(`${where}.reject_store_alias must be true or false`); + } if ("version" in detect) { const v = detect.version; const vw = `${where}.version`; @@ -243,36 +248,66 @@ export function compareVersions(a, b) { return 0; } -function isExecutable(file, platform) { +const FS = { statSync, lstatSync, readlinkSync, accessSync }; + +// The target of App Installer's Python "install me" stub, which opens the Store +// instead of running Python, and the alias names it takes. +const PYTHON_STUB_TARGET = "appinstallerpythonredirector.exe"; +const PYTHON_STUB_NAMES = new Set(["python", "python3"]); + +// A Windows App Execution Alias, how a Store or winget app reaches PATH, is a +// reparse point in a WindowsApps directory that stat cannot open, yet it runs. +// It counts as found unless it is the Python stub, known by its target or, when +// the target cannot be read, by its name. +const isWindowsApps = (dir) => path.basename(dir).toLowerCase() === "windowsapps"; + +function isAppAlias(file, fs) { + if (!isWindowsApps(path.dirname(file))) return false; try { - if (!statSync(file).isFile()) return false; - if (platform !== "win32") accessSync(file, constants.X_OK); - return true; + if (!fs.lstatSync(file).isSymbolicLink()) return false; } catch { return false; } + let target; + try { + target = fs.readlinkSync(file); + } catch { + return !PYTHON_STUB_NAMES.has(path.basename(file).toLowerCase().replace(/\.exe$/, "")); + } + return path.win32.basename(target).toLowerCase() !== PYTHON_STUB_TARGET; +} + +function isExecutable(file, platform, fs = FS) { + try { + if (!fs.statSync(file).isFile()) return false; + if (platform !== "win32") fs.accessSync(file, constants.X_OK); + return true; + } catch { + return platform === "win32" && isAppAlias(file, fs); + } } -// which(name) -> absolute path of the first PATH match, or null. -export function which(name, env = process.env, platform = process.platform) { +// which(name) -> absolute path of the first PATH match, or null. fs is injectable for tests. +// rejectStoreAlias skips WindowsApps directories on win32, so no App Execution Alias matches. +export function which(name, env = process.env, platform = process.platform, fs = FS, rejectStoreAlias = false) { const pathVar = env.PATH ?? env.Path ?? ""; const exts = platform === "win32" ? ["", ...(env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean)] : [""]; for (const dir of pathVar.split(path.delimiter)) { - if (!dir) continue; + if (!dir || (rejectStoreAlias && platform === "win32" && isWindowsApps(dir))) continue; for (const ext of exts) { const candidate = path.join(dir, name + ext); - if (isExecutable(candidate, platform)) return candidate; + if (isExecutable(candidate, platform, fs)) return candidate; } } return null; } -function findLocalBin(rels, cwd, platform) { +function findLocalBin(rels, cwd, platform, fs) { for (const rel of rels ?? []) { let dir = path.resolve(cwd); for (let i = 0; i < LOCAL_BIN_DEPTH; i++) { const candidate = path.join(dir, rel); - if (isExecutable(candidate, platform)) return candidate; + if (isExecutable(candidate, platform, fs)) return candidate; const parent = path.dirname(dir); if (parent === dir) break; dir = parent; @@ -286,9 +321,9 @@ function run(file, args) { return { ok: !r.error && r.status === 0, out: `${r.stdout ?? ""}${r.stderr ?? ""}` }; } -function firstOnPath(names, ctx) { +function firstOnPath(names, ctx, rejectStoreAlias = false) { for (const name of names) { - const found = which(name, ctx.env, ctx.platform); + const found = which(name, ctx.env, ctx.platform, ctx.fs, rejectStoreAlias); if (found) return found; } return null; @@ -314,7 +349,7 @@ export function detectEntry(entry, ctx) { switch (entry.kind) { case "cli": case "runtime": { - const found = firstOnPath(d.any, ctx) ?? findLocalBin(d.local_bin, ctx.cwd, ctx.platform); + const found = firstOnPath(d.any, ctx, d.reject_store_alias) ?? findLocalBin(d.local_bin, ctx.cwd, ctx.platform, ctx.fs); if (!found) { const where = d.local_bin ? `on PATH or as ${d.local_bin.join(", ")}` : "on PATH"; return { status: "missing", detail: `${d.any.join(" or ")} was not found ${where}` }; @@ -331,7 +366,7 @@ export function detectEntry(entry, ctx) { } case "system-lib": { const [bin, ...args] = d.probe.args; - const found = which(bin, ctx.env, ctx.platform); + const found = which(bin, ctx.env, ctx.platform, ctx.fs); if (!found) return { status: "missing", detail: `${bin} was not found on PATH to probe` }; if (new RegExp(d.probe.pattern).test(run(found, args).out)) return { status: "present", detail: found }; return { status: "missing", detail: `${bin} ${args.join(" ")} did not report ${d.probe.pattern}` }; @@ -566,6 +601,7 @@ export function main(argv, ctx = {}, out = {}) { env: ctx.env ?? process.env, platform: ctx.platform ?? process.platform, cwd: ctx.cwd ?? process.cwd(), + fs: ctx.fs ?? FS, stdin: ctx.stdin ?? (() => (process.stdin.isTTY ? "" : readFileSync(0, "utf8"))), }; const io = { stdout: out.stdout ?? ((s) => console.log(s)), stderr: out.stderr ?? ((s) => console.error(s)) }; diff --git a/plugins/markdown-format/.claude-plugin/plugin.json b/plugins/markdown-format/.claude-plugin/plugin.json index 9134ac6df1..b1c0181300 100644 --- a/plugins/markdown-format/.claude-plugin/plugin.json +++ b/plugins/markdown-format/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "markdown-format", - "version": "0.12.1", + "version": "0.12.2", "description": "Auto-format and lint Markdown on edit via markdownlint-cli2, only in repos that carry their own markdownlint config.", "author": { "name": "Melodic Software", diff --git a/plugins/markdown-format/CHANGELOG.md b/plugins/markdown-format/CHANGELOG.md index 14fdcafbad..7e9c795cc9 100644 --- a/plugins/markdown-format/CHANGELOG.md +++ b/plugins/markdown-format/CHANGELOG.md @@ -3,6 +3,14 @@ All notable changes to the `markdown-format` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.12.2] - 2026-10-03 + +### Changed + +- **Shared `prerequisites.mjs` synced ([#6084](https://github.com/melodic-software/claude-code-plugins/issues/6084)); no change to this plugin's lib.** + The prerequisite check now counts a Windows App Execution Alias (a Store or winget install on PATH) as found, + except App Installer's Python install stub. A `cli` or `runtime` entry can set `reject_store_alias` to skip aliases instead; no entry in this plugin does. + ## [0.12.1] - 2026-10-03 ### Changed diff --git a/plugins/markdown-format/lib/prerequisites.mjs b/plugins/markdown-format/lib/prerequisites.mjs index 57f65a79b5..1e11abf2cc 100755 --- a/plugins/markdown-format/lib/prerequisites.mjs +++ b/plugins/markdown-format/lib/prerequisites.mjs @@ -28,8 +28,10 @@ import { accessSync, constants, existsSync, + lstatSync, mkdirSync, readFileSync, + readlinkSync, realpathSync, statSync, writeFileSync, @@ -42,8 +44,8 @@ export const KINDS = ["cli", "runtime", "system-lib", "python-pkg", "node-pkg", export const NEEDS = ["required", "optional"]; const ENTRY_KEYS = ["id", "kind", "need", "for", "detect", "degrade", "install", "check"]; const DETECT_KEYS = { - cli: { required: ["any"], optional: ["local_bin", "version"] }, - runtime: { required: ["any"], optional: ["local_bin", "version"] }, + cli: { required: ["any"], optional: ["local_bin", "version", "reject_store_alias"] }, + runtime: { required: ["any"], optional: ["local_bin", "version", "reject_store_alias"] }, "system-lib": { required: ["probe"], optional: [] }, "python-pkg": { required: ["any", "import"], optional: [] }, "node-pkg": { required: ["module", "paths"], optional: [] }, @@ -100,6 +102,9 @@ function detectErrors(kind, detect, where) { } if ("any" in detect && !isTextList(detect.any)) errors.push(`${where}.any must be a non-empty list of names`); if ("local_bin" in detect) errors.push(...relativePathErrors(detect.local_bin, `${where}.local_bin`)); + if ("reject_store_alias" in detect && typeof detect.reject_store_alias !== "boolean") { + errors.push(`${where}.reject_store_alias must be true or false`); + } if ("version" in detect) { const v = detect.version; const vw = `${where}.version`; @@ -243,36 +248,66 @@ export function compareVersions(a, b) { return 0; } -function isExecutable(file, platform) { +const FS = { statSync, lstatSync, readlinkSync, accessSync }; + +// The target of App Installer's Python "install me" stub, which opens the Store +// instead of running Python, and the alias names it takes. +const PYTHON_STUB_TARGET = "appinstallerpythonredirector.exe"; +const PYTHON_STUB_NAMES = new Set(["python", "python3"]); + +// A Windows App Execution Alias, how a Store or winget app reaches PATH, is a +// reparse point in a WindowsApps directory that stat cannot open, yet it runs. +// It counts as found unless it is the Python stub, known by its target or, when +// the target cannot be read, by its name. +const isWindowsApps = (dir) => path.basename(dir).toLowerCase() === "windowsapps"; + +function isAppAlias(file, fs) { + if (!isWindowsApps(path.dirname(file))) return false; try { - if (!statSync(file).isFile()) return false; - if (platform !== "win32") accessSync(file, constants.X_OK); - return true; + if (!fs.lstatSync(file).isSymbolicLink()) return false; } catch { return false; } + let target; + try { + target = fs.readlinkSync(file); + } catch { + return !PYTHON_STUB_NAMES.has(path.basename(file).toLowerCase().replace(/\.exe$/, "")); + } + return path.win32.basename(target).toLowerCase() !== PYTHON_STUB_TARGET; +} + +function isExecutable(file, platform, fs = FS) { + try { + if (!fs.statSync(file).isFile()) return false; + if (platform !== "win32") fs.accessSync(file, constants.X_OK); + return true; + } catch { + return platform === "win32" && isAppAlias(file, fs); + } } -// which(name) -> absolute path of the first PATH match, or null. -export function which(name, env = process.env, platform = process.platform) { +// which(name) -> absolute path of the first PATH match, or null. fs is injectable for tests. +// rejectStoreAlias skips WindowsApps directories on win32, so no App Execution Alias matches. +export function which(name, env = process.env, platform = process.platform, fs = FS, rejectStoreAlias = false) { const pathVar = env.PATH ?? env.Path ?? ""; const exts = platform === "win32" ? ["", ...(env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean)] : [""]; for (const dir of pathVar.split(path.delimiter)) { - if (!dir) continue; + if (!dir || (rejectStoreAlias && platform === "win32" && isWindowsApps(dir))) continue; for (const ext of exts) { const candidate = path.join(dir, name + ext); - if (isExecutable(candidate, platform)) return candidate; + if (isExecutable(candidate, platform, fs)) return candidate; } } return null; } -function findLocalBin(rels, cwd, platform) { +function findLocalBin(rels, cwd, platform, fs) { for (const rel of rels ?? []) { let dir = path.resolve(cwd); for (let i = 0; i < LOCAL_BIN_DEPTH; i++) { const candidate = path.join(dir, rel); - if (isExecutable(candidate, platform)) return candidate; + if (isExecutable(candidate, platform, fs)) return candidate; const parent = path.dirname(dir); if (parent === dir) break; dir = parent; @@ -286,9 +321,9 @@ function run(file, args) { return { ok: !r.error && r.status === 0, out: `${r.stdout ?? ""}${r.stderr ?? ""}` }; } -function firstOnPath(names, ctx) { +function firstOnPath(names, ctx, rejectStoreAlias = false) { for (const name of names) { - const found = which(name, ctx.env, ctx.platform); + const found = which(name, ctx.env, ctx.platform, ctx.fs, rejectStoreAlias); if (found) return found; } return null; @@ -314,7 +349,7 @@ export function detectEntry(entry, ctx) { switch (entry.kind) { case "cli": case "runtime": { - const found = firstOnPath(d.any, ctx) ?? findLocalBin(d.local_bin, ctx.cwd, ctx.platform); + const found = firstOnPath(d.any, ctx, d.reject_store_alias) ?? findLocalBin(d.local_bin, ctx.cwd, ctx.platform, ctx.fs); if (!found) { const where = d.local_bin ? `on PATH or as ${d.local_bin.join(", ")}` : "on PATH"; return { status: "missing", detail: `${d.any.join(" or ")} was not found ${where}` }; @@ -331,7 +366,7 @@ export function detectEntry(entry, ctx) { } case "system-lib": { const [bin, ...args] = d.probe.args; - const found = which(bin, ctx.env, ctx.platform); + const found = which(bin, ctx.env, ctx.platform, ctx.fs); if (!found) return { status: "missing", detail: `${bin} was not found on PATH to probe` }; if (new RegExp(d.probe.pattern).test(run(found, args).out)) return { status: "present", detail: found }; return { status: "missing", detail: `${bin} ${args.join(" ")} did not report ${d.probe.pattern}` }; @@ -566,6 +601,7 @@ export function main(argv, ctx = {}, out = {}) { env: ctx.env ?? process.env, platform: ctx.platform ?? process.platform, cwd: ctx.cwd ?? process.cwd(), + fs: ctx.fs ?? FS, stdin: ctx.stdin ?? (() => (process.stdin.isTTY ? "" : readFileSync(0, "utf8"))), }; const io = { stdout: out.stdout ?? ((s) => console.log(s)), stderr: out.stderr ?? ((s) => console.error(s)) }; diff --git a/plugins/mcp-tools/.claude-plugin/plugin.json b/plugins/mcp-tools/.claude-plugin/plugin.json index ada28760e8..26e622b097 100644 --- a/plugins/mcp-tools/.claude-plugin/plugin.json +++ b/plugins/mcp-tools/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "mcp-tools", - "version": "0.6.1", + "version": "0.6.2", "description": "Two MCP audits. audit scores the tool definitions of a server you build against MCP-specification, Anthropic tool-design, and Claude-Code client criteria in a per-tool PASS/WARN/FAIL scorecard (Python, TypeScript, .NET). audit-posture inventories the MCP servers your Claude Code configuration runs and flags supply-chain risks such as floating package versions, without running any server.", "author": { "name": "Melodic Software", diff --git a/plugins/mcp-tools/CHANGELOG.md b/plugins/mcp-tools/CHANGELOG.md index 574d9630cb..035b5d4b5a 100644 --- a/plugins/mcp-tools/CHANGELOG.md +++ b/plugins/mcp-tools/CHANGELOG.md @@ -3,6 +3,14 @@ All notable changes to the `mcp-tools` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.6.2] - 2026-10-03 + +### Changed + +- **Shared `prerequisites.mjs` synced ([#6084](https://github.com/melodic-software/claude-code-plugins/issues/6084)); no change to this plugin's lib.** + The prerequisite check now counts a Windows App Execution Alias (a Store or winget install on PATH) as found, + except App Installer's Python install stub. A `cli` or `runtime` entry can set `reject_store_alias` to skip aliases instead; no entry in this plugin does. + ## [0.6.1] - 2026-10-03 ### Changed diff --git a/plugins/mcp-tools/lib/prerequisites.mjs b/plugins/mcp-tools/lib/prerequisites.mjs index 57f65a79b5..1e11abf2cc 100755 --- a/plugins/mcp-tools/lib/prerequisites.mjs +++ b/plugins/mcp-tools/lib/prerequisites.mjs @@ -28,8 +28,10 @@ import { accessSync, constants, existsSync, + lstatSync, mkdirSync, readFileSync, + readlinkSync, realpathSync, statSync, writeFileSync, @@ -42,8 +44,8 @@ export const KINDS = ["cli", "runtime", "system-lib", "python-pkg", "node-pkg", export const NEEDS = ["required", "optional"]; const ENTRY_KEYS = ["id", "kind", "need", "for", "detect", "degrade", "install", "check"]; const DETECT_KEYS = { - cli: { required: ["any"], optional: ["local_bin", "version"] }, - runtime: { required: ["any"], optional: ["local_bin", "version"] }, + cli: { required: ["any"], optional: ["local_bin", "version", "reject_store_alias"] }, + runtime: { required: ["any"], optional: ["local_bin", "version", "reject_store_alias"] }, "system-lib": { required: ["probe"], optional: [] }, "python-pkg": { required: ["any", "import"], optional: [] }, "node-pkg": { required: ["module", "paths"], optional: [] }, @@ -100,6 +102,9 @@ function detectErrors(kind, detect, where) { } if ("any" in detect && !isTextList(detect.any)) errors.push(`${where}.any must be a non-empty list of names`); if ("local_bin" in detect) errors.push(...relativePathErrors(detect.local_bin, `${where}.local_bin`)); + if ("reject_store_alias" in detect && typeof detect.reject_store_alias !== "boolean") { + errors.push(`${where}.reject_store_alias must be true or false`); + } if ("version" in detect) { const v = detect.version; const vw = `${where}.version`; @@ -243,36 +248,66 @@ export function compareVersions(a, b) { return 0; } -function isExecutable(file, platform) { +const FS = { statSync, lstatSync, readlinkSync, accessSync }; + +// The target of App Installer's Python "install me" stub, which opens the Store +// instead of running Python, and the alias names it takes. +const PYTHON_STUB_TARGET = "appinstallerpythonredirector.exe"; +const PYTHON_STUB_NAMES = new Set(["python", "python3"]); + +// A Windows App Execution Alias, how a Store or winget app reaches PATH, is a +// reparse point in a WindowsApps directory that stat cannot open, yet it runs. +// It counts as found unless it is the Python stub, known by its target or, when +// the target cannot be read, by its name. +const isWindowsApps = (dir) => path.basename(dir).toLowerCase() === "windowsapps"; + +function isAppAlias(file, fs) { + if (!isWindowsApps(path.dirname(file))) return false; try { - if (!statSync(file).isFile()) return false; - if (platform !== "win32") accessSync(file, constants.X_OK); - return true; + if (!fs.lstatSync(file).isSymbolicLink()) return false; } catch { return false; } + let target; + try { + target = fs.readlinkSync(file); + } catch { + return !PYTHON_STUB_NAMES.has(path.basename(file).toLowerCase().replace(/\.exe$/, "")); + } + return path.win32.basename(target).toLowerCase() !== PYTHON_STUB_TARGET; +} + +function isExecutable(file, platform, fs = FS) { + try { + if (!fs.statSync(file).isFile()) return false; + if (platform !== "win32") fs.accessSync(file, constants.X_OK); + return true; + } catch { + return platform === "win32" && isAppAlias(file, fs); + } } -// which(name) -> absolute path of the first PATH match, or null. -export function which(name, env = process.env, platform = process.platform) { +// which(name) -> absolute path of the first PATH match, or null. fs is injectable for tests. +// rejectStoreAlias skips WindowsApps directories on win32, so no App Execution Alias matches. +export function which(name, env = process.env, platform = process.platform, fs = FS, rejectStoreAlias = false) { const pathVar = env.PATH ?? env.Path ?? ""; const exts = platform === "win32" ? ["", ...(env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean)] : [""]; for (const dir of pathVar.split(path.delimiter)) { - if (!dir) continue; + if (!dir || (rejectStoreAlias && platform === "win32" && isWindowsApps(dir))) continue; for (const ext of exts) { const candidate = path.join(dir, name + ext); - if (isExecutable(candidate, platform)) return candidate; + if (isExecutable(candidate, platform, fs)) return candidate; } } return null; } -function findLocalBin(rels, cwd, platform) { +function findLocalBin(rels, cwd, platform, fs) { for (const rel of rels ?? []) { let dir = path.resolve(cwd); for (let i = 0; i < LOCAL_BIN_DEPTH; i++) { const candidate = path.join(dir, rel); - if (isExecutable(candidate, platform)) return candidate; + if (isExecutable(candidate, platform, fs)) return candidate; const parent = path.dirname(dir); if (parent === dir) break; dir = parent; @@ -286,9 +321,9 @@ function run(file, args) { return { ok: !r.error && r.status === 0, out: `${r.stdout ?? ""}${r.stderr ?? ""}` }; } -function firstOnPath(names, ctx) { +function firstOnPath(names, ctx, rejectStoreAlias = false) { for (const name of names) { - const found = which(name, ctx.env, ctx.platform); + const found = which(name, ctx.env, ctx.platform, ctx.fs, rejectStoreAlias); if (found) return found; } return null; @@ -314,7 +349,7 @@ export function detectEntry(entry, ctx) { switch (entry.kind) { case "cli": case "runtime": { - const found = firstOnPath(d.any, ctx) ?? findLocalBin(d.local_bin, ctx.cwd, ctx.platform); + const found = firstOnPath(d.any, ctx, d.reject_store_alias) ?? findLocalBin(d.local_bin, ctx.cwd, ctx.platform, ctx.fs); if (!found) { const where = d.local_bin ? `on PATH or as ${d.local_bin.join(", ")}` : "on PATH"; return { status: "missing", detail: `${d.any.join(" or ")} was not found ${where}` }; @@ -331,7 +366,7 @@ export function detectEntry(entry, ctx) { } case "system-lib": { const [bin, ...args] = d.probe.args; - const found = which(bin, ctx.env, ctx.platform); + const found = which(bin, ctx.env, ctx.platform, ctx.fs); if (!found) return { status: "missing", detail: `${bin} was not found on PATH to probe` }; if (new RegExp(d.probe.pattern).test(run(found, args).out)) return { status: "present", detail: found }; return { status: "missing", detail: `${bin} ${args.join(" ")} did not report ${d.probe.pattern}` }; @@ -566,6 +601,7 @@ export function main(argv, ctx = {}, out = {}) { env: ctx.env ?? process.env, platform: ctx.platform ?? process.platform, cwd: ctx.cwd ?? process.cwd(), + fs: ctx.fs ?? FS, stdin: ctx.stdin ?? (() => (process.stdin.isTTY ? "" : readFileSync(0, "utf8"))), }; const io = { stdout: out.stdout ?? ((s) => console.log(s)), stderr: out.stderr ?? ((s) => console.error(s)) }; diff --git a/plugins/miro/.claude-plugin/plugin.json b/plugins/miro/.claude-plugin/plugin.json index 9eda60fc97..15404f72b8 100644 --- a/plugins/miro/.claude-plugin/plugin.json +++ b/plugins/miro/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "miro", - "version": "0.6.2", + "version": "0.6.3", "description": "Miro board management over the Model Context Protocol: create and manage boards, sticky notes, shapes, frames, connectors, and tags for EventStorming, brainstorming, and diagramming. Ships a local stdio MCP server that installs its pinned npm dependencies on first launch (needs Node.js 24+ and npm); installs disabled, so opt in. The server starts without a Miro API token; until one is set, each tool call explains how to set it.", "author": { "name": "Melodic Software", diff --git a/plugins/miro/CHANGELOG.md b/plugins/miro/CHANGELOG.md index f8d572a100..3fe7cd7792 100644 --- a/plugins/miro/CHANGELOG.md +++ b/plugins/miro/CHANGELOG.md @@ -3,6 +3,14 @@ All notable changes to the `miro` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.6.3] - 2026-10-03 + +### Changed + +- **Shared `prerequisites.mjs` synced ([#6084](https://github.com/melodic-software/claude-code-plugins/issues/6084)); no change to this plugin's lib.** + The prerequisite check now counts a Windows App Execution Alias (a Store or winget install on PATH) as found, + except App Installer's Python install stub. A `cli` or `runtime` entry can set `reject_store_alias` to skip aliases instead; no entry in this plugin does. + ## [0.6.2] - 2026-10-03 ### Changed diff --git a/plugins/miro/lib/prerequisites.mjs b/plugins/miro/lib/prerequisites.mjs index 57f65a79b5..1e11abf2cc 100755 --- a/plugins/miro/lib/prerequisites.mjs +++ b/plugins/miro/lib/prerequisites.mjs @@ -28,8 +28,10 @@ import { accessSync, constants, existsSync, + lstatSync, mkdirSync, readFileSync, + readlinkSync, realpathSync, statSync, writeFileSync, @@ -42,8 +44,8 @@ export const KINDS = ["cli", "runtime", "system-lib", "python-pkg", "node-pkg", export const NEEDS = ["required", "optional"]; const ENTRY_KEYS = ["id", "kind", "need", "for", "detect", "degrade", "install", "check"]; const DETECT_KEYS = { - cli: { required: ["any"], optional: ["local_bin", "version"] }, - runtime: { required: ["any"], optional: ["local_bin", "version"] }, + cli: { required: ["any"], optional: ["local_bin", "version", "reject_store_alias"] }, + runtime: { required: ["any"], optional: ["local_bin", "version", "reject_store_alias"] }, "system-lib": { required: ["probe"], optional: [] }, "python-pkg": { required: ["any", "import"], optional: [] }, "node-pkg": { required: ["module", "paths"], optional: [] }, @@ -100,6 +102,9 @@ function detectErrors(kind, detect, where) { } if ("any" in detect && !isTextList(detect.any)) errors.push(`${where}.any must be a non-empty list of names`); if ("local_bin" in detect) errors.push(...relativePathErrors(detect.local_bin, `${where}.local_bin`)); + if ("reject_store_alias" in detect && typeof detect.reject_store_alias !== "boolean") { + errors.push(`${where}.reject_store_alias must be true or false`); + } if ("version" in detect) { const v = detect.version; const vw = `${where}.version`; @@ -243,36 +248,66 @@ export function compareVersions(a, b) { return 0; } -function isExecutable(file, platform) { +const FS = { statSync, lstatSync, readlinkSync, accessSync }; + +// The target of App Installer's Python "install me" stub, which opens the Store +// instead of running Python, and the alias names it takes. +const PYTHON_STUB_TARGET = "appinstallerpythonredirector.exe"; +const PYTHON_STUB_NAMES = new Set(["python", "python3"]); + +// A Windows App Execution Alias, how a Store or winget app reaches PATH, is a +// reparse point in a WindowsApps directory that stat cannot open, yet it runs. +// It counts as found unless it is the Python stub, known by its target or, when +// the target cannot be read, by its name. +const isWindowsApps = (dir) => path.basename(dir).toLowerCase() === "windowsapps"; + +function isAppAlias(file, fs) { + if (!isWindowsApps(path.dirname(file))) return false; try { - if (!statSync(file).isFile()) return false; - if (platform !== "win32") accessSync(file, constants.X_OK); - return true; + if (!fs.lstatSync(file).isSymbolicLink()) return false; } catch { return false; } + let target; + try { + target = fs.readlinkSync(file); + } catch { + return !PYTHON_STUB_NAMES.has(path.basename(file).toLowerCase().replace(/\.exe$/, "")); + } + return path.win32.basename(target).toLowerCase() !== PYTHON_STUB_TARGET; +} + +function isExecutable(file, platform, fs = FS) { + try { + if (!fs.statSync(file).isFile()) return false; + if (platform !== "win32") fs.accessSync(file, constants.X_OK); + return true; + } catch { + return platform === "win32" && isAppAlias(file, fs); + } } -// which(name) -> absolute path of the first PATH match, or null. -export function which(name, env = process.env, platform = process.platform) { +// which(name) -> absolute path of the first PATH match, or null. fs is injectable for tests. +// rejectStoreAlias skips WindowsApps directories on win32, so no App Execution Alias matches. +export function which(name, env = process.env, platform = process.platform, fs = FS, rejectStoreAlias = false) { const pathVar = env.PATH ?? env.Path ?? ""; const exts = platform === "win32" ? ["", ...(env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean)] : [""]; for (const dir of pathVar.split(path.delimiter)) { - if (!dir) continue; + if (!dir || (rejectStoreAlias && platform === "win32" && isWindowsApps(dir))) continue; for (const ext of exts) { const candidate = path.join(dir, name + ext); - if (isExecutable(candidate, platform)) return candidate; + if (isExecutable(candidate, platform, fs)) return candidate; } } return null; } -function findLocalBin(rels, cwd, platform) { +function findLocalBin(rels, cwd, platform, fs) { for (const rel of rels ?? []) { let dir = path.resolve(cwd); for (let i = 0; i < LOCAL_BIN_DEPTH; i++) { const candidate = path.join(dir, rel); - if (isExecutable(candidate, platform)) return candidate; + if (isExecutable(candidate, platform, fs)) return candidate; const parent = path.dirname(dir); if (parent === dir) break; dir = parent; @@ -286,9 +321,9 @@ function run(file, args) { return { ok: !r.error && r.status === 0, out: `${r.stdout ?? ""}${r.stderr ?? ""}` }; } -function firstOnPath(names, ctx) { +function firstOnPath(names, ctx, rejectStoreAlias = false) { for (const name of names) { - const found = which(name, ctx.env, ctx.platform); + const found = which(name, ctx.env, ctx.platform, ctx.fs, rejectStoreAlias); if (found) return found; } return null; @@ -314,7 +349,7 @@ export function detectEntry(entry, ctx) { switch (entry.kind) { case "cli": case "runtime": { - const found = firstOnPath(d.any, ctx) ?? findLocalBin(d.local_bin, ctx.cwd, ctx.platform); + const found = firstOnPath(d.any, ctx, d.reject_store_alias) ?? findLocalBin(d.local_bin, ctx.cwd, ctx.platform, ctx.fs); if (!found) { const where = d.local_bin ? `on PATH or as ${d.local_bin.join(", ")}` : "on PATH"; return { status: "missing", detail: `${d.any.join(" or ")} was not found ${where}` }; @@ -331,7 +366,7 @@ export function detectEntry(entry, ctx) { } case "system-lib": { const [bin, ...args] = d.probe.args; - const found = which(bin, ctx.env, ctx.platform); + const found = which(bin, ctx.env, ctx.platform, ctx.fs); if (!found) return { status: "missing", detail: `${bin} was not found on PATH to probe` }; if (new RegExp(d.probe.pattern).test(run(found, args).out)) return { status: "present", detail: found }; return { status: "missing", detail: `${bin} ${args.join(" ")} did not report ${d.probe.pattern}` }; @@ -566,6 +601,7 @@ export function main(argv, ctx = {}, out = {}) { env: ctx.env ?? process.env, platform: ctx.platform ?? process.platform, cwd: ctx.cwd ?? process.cwd(), + fs: ctx.fs ?? FS, stdin: ctx.stdin ?? (() => (process.stdin.isTTY ? "" : readFileSync(0, "utf8"))), }; const io = { stdout: out.stdout ?? ((s) => console.log(s)), stderr: out.stderr ?? ((s) => console.error(s)) }; diff --git a/plugins/multi-agent/.claude-plugin/plugin.json b/plugins/multi-agent/.claude-plugin/plugin.json index e01f3ada13..5b2f4e4e46 100644 --- a/plugins/multi-agent/.claude-plugin/plugin.json +++ b/plugins/multi-agent/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "multi-agent", - "version": "0.4.1", + "version": "0.4.2", "description": "Workflow and model-routing guidance for multi-agent work: assess (workflow, subagent or single context), route (the role map a workflow script reads from args, keeping fan-out stages off a frontier model), audit-defaults (rechecks bundled defaults against upstream, or sweeps the repo's model and workflow guidance, via a read-only drift-audit workflow), check (whether node resolves and the fetch gate is registered), and setup (writes a user, team or local layer after a preview and a yes).", "author": { "name": "Melodic Software", diff --git a/plugins/multi-agent/CHANGELOG.md b/plugins/multi-agent/CHANGELOG.md index c6fa4b64f2..6810eddbb8 100644 --- a/plugins/multi-agent/CHANGELOG.md +++ b/plugins/multi-agent/CHANGELOG.md @@ -3,6 +3,14 @@ All notable changes to the `multi-agent` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.4.2] - 2026-10-03 + +### Changed + +- **Shared `prerequisites.mjs` synced ([#6084](https://github.com/melodic-software/claude-code-plugins/issues/6084)); no change to this plugin's lib.** + The prerequisite check now counts a Windows App Execution Alias (a Store or winget install on PATH) as found, + except App Installer's Python install stub. A `cli` or `runtime` entry can set `reject_store_alias` to skip aliases instead; no entry in this plugin does. + ## [0.4.1] - 2026-10-03 ### Changed diff --git a/plugins/multi-agent/lib/prerequisites.mjs b/plugins/multi-agent/lib/prerequisites.mjs index 57f65a79b5..1e11abf2cc 100755 --- a/plugins/multi-agent/lib/prerequisites.mjs +++ b/plugins/multi-agent/lib/prerequisites.mjs @@ -28,8 +28,10 @@ import { accessSync, constants, existsSync, + lstatSync, mkdirSync, readFileSync, + readlinkSync, realpathSync, statSync, writeFileSync, @@ -42,8 +44,8 @@ export const KINDS = ["cli", "runtime", "system-lib", "python-pkg", "node-pkg", export const NEEDS = ["required", "optional"]; const ENTRY_KEYS = ["id", "kind", "need", "for", "detect", "degrade", "install", "check"]; const DETECT_KEYS = { - cli: { required: ["any"], optional: ["local_bin", "version"] }, - runtime: { required: ["any"], optional: ["local_bin", "version"] }, + cli: { required: ["any"], optional: ["local_bin", "version", "reject_store_alias"] }, + runtime: { required: ["any"], optional: ["local_bin", "version", "reject_store_alias"] }, "system-lib": { required: ["probe"], optional: [] }, "python-pkg": { required: ["any", "import"], optional: [] }, "node-pkg": { required: ["module", "paths"], optional: [] }, @@ -100,6 +102,9 @@ function detectErrors(kind, detect, where) { } if ("any" in detect && !isTextList(detect.any)) errors.push(`${where}.any must be a non-empty list of names`); if ("local_bin" in detect) errors.push(...relativePathErrors(detect.local_bin, `${where}.local_bin`)); + if ("reject_store_alias" in detect && typeof detect.reject_store_alias !== "boolean") { + errors.push(`${where}.reject_store_alias must be true or false`); + } if ("version" in detect) { const v = detect.version; const vw = `${where}.version`; @@ -243,36 +248,66 @@ export function compareVersions(a, b) { return 0; } -function isExecutable(file, platform) { +const FS = { statSync, lstatSync, readlinkSync, accessSync }; + +// The target of App Installer's Python "install me" stub, which opens the Store +// instead of running Python, and the alias names it takes. +const PYTHON_STUB_TARGET = "appinstallerpythonredirector.exe"; +const PYTHON_STUB_NAMES = new Set(["python", "python3"]); + +// A Windows App Execution Alias, how a Store or winget app reaches PATH, is a +// reparse point in a WindowsApps directory that stat cannot open, yet it runs. +// It counts as found unless it is the Python stub, known by its target or, when +// the target cannot be read, by its name. +const isWindowsApps = (dir) => path.basename(dir).toLowerCase() === "windowsapps"; + +function isAppAlias(file, fs) { + if (!isWindowsApps(path.dirname(file))) return false; try { - if (!statSync(file).isFile()) return false; - if (platform !== "win32") accessSync(file, constants.X_OK); - return true; + if (!fs.lstatSync(file).isSymbolicLink()) return false; } catch { return false; } + let target; + try { + target = fs.readlinkSync(file); + } catch { + return !PYTHON_STUB_NAMES.has(path.basename(file).toLowerCase().replace(/\.exe$/, "")); + } + return path.win32.basename(target).toLowerCase() !== PYTHON_STUB_TARGET; +} + +function isExecutable(file, platform, fs = FS) { + try { + if (!fs.statSync(file).isFile()) return false; + if (platform !== "win32") fs.accessSync(file, constants.X_OK); + return true; + } catch { + return platform === "win32" && isAppAlias(file, fs); + } } -// which(name) -> absolute path of the first PATH match, or null. -export function which(name, env = process.env, platform = process.platform) { +// which(name) -> absolute path of the first PATH match, or null. fs is injectable for tests. +// rejectStoreAlias skips WindowsApps directories on win32, so no App Execution Alias matches. +export function which(name, env = process.env, platform = process.platform, fs = FS, rejectStoreAlias = false) { const pathVar = env.PATH ?? env.Path ?? ""; const exts = platform === "win32" ? ["", ...(env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean)] : [""]; for (const dir of pathVar.split(path.delimiter)) { - if (!dir) continue; + if (!dir || (rejectStoreAlias && platform === "win32" && isWindowsApps(dir))) continue; for (const ext of exts) { const candidate = path.join(dir, name + ext); - if (isExecutable(candidate, platform)) return candidate; + if (isExecutable(candidate, platform, fs)) return candidate; } } return null; } -function findLocalBin(rels, cwd, platform) { +function findLocalBin(rels, cwd, platform, fs) { for (const rel of rels ?? []) { let dir = path.resolve(cwd); for (let i = 0; i < LOCAL_BIN_DEPTH; i++) { const candidate = path.join(dir, rel); - if (isExecutable(candidate, platform)) return candidate; + if (isExecutable(candidate, platform, fs)) return candidate; const parent = path.dirname(dir); if (parent === dir) break; dir = parent; @@ -286,9 +321,9 @@ function run(file, args) { return { ok: !r.error && r.status === 0, out: `${r.stdout ?? ""}${r.stderr ?? ""}` }; } -function firstOnPath(names, ctx) { +function firstOnPath(names, ctx, rejectStoreAlias = false) { for (const name of names) { - const found = which(name, ctx.env, ctx.platform); + const found = which(name, ctx.env, ctx.platform, ctx.fs, rejectStoreAlias); if (found) return found; } return null; @@ -314,7 +349,7 @@ export function detectEntry(entry, ctx) { switch (entry.kind) { case "cli": case "runtime": { - const found = firstOnPath(d.any, ctx) ?? findLocalBin(d.local_bin, ctx.cwd, ctx.platform); + const found = firstOnPath(d.any, ctx, d.reject_store_alias) ?? findLocalBin(d.local_bin, ctx.cwd, ctx.platform, ctx.fs); if (!found) { const where = d.local_bin ? `on PATH or as ${d.local_bin.join(", ")}` : "on PATH"; return { status: "missing", detail: `${d.any.join(" or ")} was not found ${where}` }; @@ -331,7 +366,7 @@ export function detectEntry(entry, ctx) { } case "system-lib": { const [bin, ...args] = d.probe.args; - const found = which(bin, ctx.env, ctx.platform); + const found = which(bin, ctx.env, ctx.platform, ctx.fs); if (!found) return { status: "missing", detail: `${bin} was not found on PATH to probe` }; if (new RegExp(d.probe.pattern).test(run(found, args).out)) return { status: "present", detail: found }; return { status: "missing", detail: `${bin} ${args.join(" ")} did not report ${d.probe.pattern}` }; @@ -566,6 +601,7 @@ export function main(argv, ctx = {}, out = {}) { env: ctx.env ?? process.env, platform: ctx.platform ?? process.platform, cwd: ctx.cwd ?? process.cwd(), + fs: ctx.fs ?? FS, stdin: ctx.stdin ?? (() => (process.stdin.isTTY ? "" : readFileSync(0, "utf8"))), }; const io = { stdout: out.stdout ?? ((s) => console.log(s)), stderr: out.stderr ?? ((s) => console.error(s)) }; diff --git a/plugins/pixel-art/.claude-plugin/plugin.json b/plugins/pixel-art/.claude-plugin/plugin.json index d2073fe01c..3e83485506 100644 --- a/plugins/pixel-art/.claude-plugin/plugin.json +++ b/plugins/pixel-art/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "pixel-art", - "version": "0.6.2", + "version": "0.6.3", "description": "Creates pixel art with no external tools: sprites (sprite), animation sprite sheets for RPG Maker MZ, Godot, PICO-8, or plain strips with Aseprite-style frame data and GIF previews (animate), tilesets and parallax (tileset), UI skins and bitmap fonts (ui), effect sheets (vfx), and animated scenes as one HTML file (scene). Specs are palette-locked; a humanoid kit and a Python stdlib renderer write PNG, GIF, and frame data with a render-review loop. Optional Aseprite adapter.", "author": { "name": "Melodic Software", diff --git a/plugins/pixel-art/CHANGELOG.md b/plugins/pixel-art/CHANGELOG.md index 67b09a361c..c9a5a2dfa7 100644 --- a/plugins/pixel-art/CHANGELOG.md +++ b/plugins/pixel-art/CHANGELOG.md @@ -3,6 +3,14 @@ All notable changes to the `pixel-art` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.6.3] - 2026-10-03 + +### Changed + +- **Shared `prerequisites.mjs` synced ([#6084](https://github.com/melodic-software/claude-code-plugins/issues/6084)); no change to this plugin's lib.** + The prerequisite check now counts a Windows App Execution Alias (a Store or winget install on PATH) as found, + except App Installer's Python install stub. A `cli` or `runtime` entry can set `reject_store_alias` to skip aliases instead; no entry in this plugin does. + ## [0.6.2] - 2026-10-03 ### Changed diff --git a/plugins/pixel-art/lib/prerequisites.mjs b/plugins/pixel-art/lib/prerequisites.mjs index 57f65a79b5..1e11abf2cc 100755 --- a/plugins/pixel-art/lib/prerequisites.mjs +++ b/plugins/pixel-art/lib/prerequisites.mjs @@ -28,8 +28,10 @@ import { accessSync, constants, existsSync, + lstatSync, mkdirSync, readFileSync, + readlinkSync, realpathSync, statSync, writeFileSync, @@ -42,8 +44,8 @@ export const KINDS = ["cli", "runtime", "system-lib", "python-pkg", "node-pkg", export const NEEDS = ["required", "optional"]; const ENTRY_KEYS = ["id", "kind", "need", "for", "detect", "degrade", "install", "check"]; const DETECT_KEYS = { - cli: { required: ["any"], optional: ["local_bin", "version"] }, - runtime: { required: ["any"], optional: ["local_bin", "version"] }, + cli: { required: ["any"], optional: ["local_bin", "version", "reject_store_alias"] }, + runtime: { required: ["any"], optional: ["local_bin", "version", "reject_store_alias"] }, "system-lib": { required: ["probe"], optional: [] }, "python-pkg": { required: ["any", "import"], optional: [] }, "node-pkg": { required: ["module", "paths"], optional: [] }, @@ -100,6 +102,9 @@ function detectErrors(kind, detect, where) { } if ("any" in detect && !isTextList(detect.any)) errors.push(`${where}.any must be a non-empty list of names`); if ("local_bin" in detect) errors.push(...relativePathErrors(detect.local_bin, `${where}.local_bin`)); + if ("reject_store_alias" in detect && typeof detect.reject_store_alias !== "boolean") { + errors.push(`${where}.reject_store_alias must be true or false`); + } if ("version" in detect) { const v = detect.version; const vw = `${where}.version`; @@ -243,36 +248,66 @@ export function compareVersions(a, b) { return 0; } -function isExecutable(file, platform) { +const FS = { statSync, lstatSync, readlinkSync, accessSync }; + +// The target of App Installer's Python "install me" stub, which opens the Store +// instead of running Python, and the alias names it takes. +const PYTHON_STUB_TARGET = "appinstallerpythonredirector.exe"; +const PYTHON_STUB_NAMES = new Set(["python", "python3"]); + +// A Windows App Execution Alias, how a Store or winget app reaches PATH, is a +// reparse point in a WindowsApps directory that stat cannot open, yet it runs. +// It counts as found unless it is the Python stub, known by its target or, when +// the target cannot be read, by its name. +const isWindowsApps = (dir) => path.basename(dir).toLowerCase() === "windowsapps"; + +function isAppAlias(file, fs) { + if (!isWindowsApps(path.dirname(file))) return false; try { - if (!statSync(file).isFile()) return false; - if (platform !== "win32") accessSync(file, constants.X_OK); - return true; + if (!fs.lstatSync(file).isSymbolicLink()) return false; } catch { return false; } + let target; + try { + target = fs.readlinkSync(file); + } catch { + return !PYTHON_STUB_NAMES.has(path.basename(file).toLowerCase().replace(/\.exe$/, "")); + } + return path.win32.basename(target).toLowerCase() !== PYTHON_STUB_TARGET; +} + +function isExecutable(file, platform, fs = FS) { + try { + if (!fs.statSync(file).isFile()) return false; + if (platform !== "win32") fs.accessSync(file, constants.X_OK); + return true; + } catch { + return platform === "win32" && isAppAlias(file, fs); + } } -// which(name) -> absolute path of the first PATH match, or null. -export function which(name, env = process.env, platform = process.platform) { +// which(name) -> absolute path of the first PATH match, or null. fs is injectable for tests. +// rejectStoreAlias skips WindowsApps directories on win32, so no App Execution Alias matches. +export function which(name, env = process.env, platform = process.platform, fs = FS, rejectStoreAlias = false) { const pathVar = env.PATH ?? env.Path ?? ""; const exts = platform === "win32" ? ["", ...(env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean)] : [""]; for (const dir of pathVar.split(path.delimiter)) { - if (!dir) continue; + if (!dir || (rejectStoreAlias && platform === "win32" && isWindowsApps(dir))) continue; for (const ext of exts) { const candidate = path.join(dir, name + ext); - if (isExecutable(candidate, platform)) return candidate; + if (isExecutable(candidate, platform, fs)) return candidate; } } return null; } -function findLocalBin(rels, cwd, platform) { +function findLocalBin(rels, cwd, platform, fs) { for (const rel of rels ?? []) { let dir = path.resolve(cwd); for (let i = 0; i < LOCAL_BIN_DEPTH; i++) { const candidate = path.join(dir, rel); - if (isExecutable(candidate, platform)) return candidate; + if (isExecutable(candidate, platform, fs)) return candidate; const parent = path.dirname(dir); if (parent === dir) break; dir = parent; @@ -286,9 +321,9 @@ function run(file, args) { return { ok: !r.error && r.status === 0, out: `${r.stdout ?? ""}${r.stderr ?? ""}` }; } -function firstOnPath(names, ctx) { +function firstOnPath(names, ctx, rejectStoreAlias = false) { for (const name of names) { - const found = which(name, ctx.env, ctx.platform); + const found = which(name, ctx.env, ctx.platform, ctx.fs, rejectStoreAlias); if (found) return found; } return null; @@ -314,7 +349,7 @@ export function detectEntry(entry, ctx) { switch (entry.kind) { case "cli": case "runtime": { - const found = firstOnPath(d.any, ctx) ?? findLocalBin(d.local_bin, ctx.cwd, ctx.platform); + const found = firstOnPath(d.any, ctx, d.reject_store_alias) ?? findLocalBin(d.local_bin, ctx.cwd, ctx.platform, ctx.fs); if (!found) { const where = d.local_bin ? `on PATH or as ${d.local_bin.join(", ")}` : "on PATH"; return { status: "missing", detail: `${d.any.join(" or ")} was not found ${where}` }; @@ -331,7 +366,7 @@ export function detectEntry(entry, ctx) { } case "system-lib": { const [bin, ...args] = d.probe.args; - const found = which(bin, ctx.env, ctx.platform); + const found = which(bin, ctx.env, ctx.platform, ctx.fs); if (!found) return { status: "missing", detail: `${bin} was not found on PATH to probe` }; if (new RegExp(d.probe.pattern).test(run(found, args).out)) return { status: "present", detail: found }; return { status: "missing", detail: `${bin} ${args.join(" ")} did not report ${d.probe.pattern}` }; @@ -566,6 +601,7 @@ export function main(argv, ctx = {}, out = {}) { env: ctx.env ?? process.env, platform: ctx.platform ?? process.platform, cwd: ctx.cwd ?? process.cwd(), + fs: ctx.fs ?? FS, stdin: ctx.stdin ?? (() => (process.stdin.isTTY ? "" : readFileSync(0, "utf8"))), }; const io = { stdout: out.stdout ?? ((s) => console.log(s)), stderr: out.stderr ?? ((s) => console.error(s)) }; diff --git a/plugins/planning/.claude-plugin/plugin.json b/plugins/planning/.claude-plugin/plugin.json index 151e9fa4bc..1e46288fc3 100644 --- a/plugins/planning/.claude-plugin/plugin.json +++ b/plugins/planning/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "planning", - "version": "0.67.0", + "version": "0.67.1", "userConfig": { "surface": { "type": "string", diff --git a/plugins/planning/CHANGELOG.md b/plugins/planning/CHANGELOG.md index fe002b4c16..931dceb50c 100644 --- a/plugins/planning/CHANGELOG.md +++ b/plugins/planning/CHANGELOG.md @@ -3,6 +3,14 @@ All notable changes to the `planning` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.67.1] - 2026-10-03 + +### Changed + +- **Shared `prerequisites.mjs` synced ([#6084](https://github.com/melodic-software/claude-code-plugins/issues/6084)); no change to this plugin's lib.** + The prerequisite check now counts a Windows App Execution Alias (a Store or winget install on PATH) as found, + except App Installer's Python install stub. A `cli` or `runtime` entry can set `reject_store_alias` to skip aliases instead; no entry in this plugin does. + ## [0.67.0] - 2026-10-03 ### Added diff --git a/plugins/planning/lib/prerequisites.mjs b/plugins/planning/lib/prerequisites.mjs index 57f65a79b5..1e11abf2cc 100755 --- a/plugins/planning/lib/prerequisites.mjs +++ b/plugins/planning/lib/prerequisites.mjs @@ -28,8 +28,10 @@ import { accessSync, constants, existsSync, + lstatSync, mkdirSync, readFileSync, + readlinkSync, realpathSync, statSync, writeFileSync, @@ -42,8 +44,8 @@ export const KINDS = ["cli", "runtime", "system-lib", "python-pkg", "node-pkg", export const NEEDS = ["required", "optional"]; const ENTRY_KEYS = ["id", "kind", "need", "for", "detect", "degrade", "install", "check"]; const DETECT_KEYS = { - cli: { required: ["any"], optional: ["local_bin", "version"] }, - runtime: { required: ["any"], optional: ["local_bin", "version"] }, + cli: { required: ["any"], optional: ["local_bin", "version", "reject_store_alias"] }, + runtime: { required: ["any"], optional: ["local_bin", "version", "reject_store_alias"] }, "system-lib": { required: ["probe"], optional: [] }, "python-pkg": { required: ["any", "import"], optional: [] }, "node-pkg": { required: ["module", "paths"], optional: [] }, @@ -100,6 +102,9 @@ function detectErrors(kind, detect, where) { } if ("any" in detect && !isTextList(detect.any)) errors.push(`${where}.any must be a non-empty list of names`); if ("local_bin" in detect) errors.push(...relativePathErrors(detect.local_bin, `${where}.local_bin`)); + if ("reject_store_alias" in detect && typeof detect.reject_store_alias !== "boolean") { + errors.push(`${where}.reject_store_alias must be true or false`); + } if ("version" in detect) { const v = detect.version; const vw = `${where}.version`; @@ -243,36 +248,66 @@ export function compareVersions(a, b) { return 0; } -function isExecutable(file, platform) { +const FS = { statSync, lstatSync, readlinkSync, accessSync }; + +// The target of App Installer's Python "install me" stub, which opens the Store +// instead of running Python, and the alias names it takes. +const PYTHON_STUB_TARGET = "appinstallerpythonredirector.exe"; +const PYTHON_STUB_NAMES = new Set(["python", "python3"]); + +// A Windows App Execution Alias, how a Store or winget app reaches PATH, is a +// reparse point in a WindowsApps directory that stat cannot open, yet it runs. +// It counts as found unless it is the Python stub, known by its target or, when +// the target cannot be read, by its name. +const isWindowsApps = (dir) => path.basename(dir).toLowerCase() === "windowsapps"; + +function isAppAlias(file, fs) { + if (!isWindowsApps(path.dirname(file))) return false; try { - if (!statSync(file).isFile()) return false; - if (platform !== "win32") accessSync(file, constants.X_OK); - return true; + if (!fs.lstatSync(file).isSymbolicLink()) return false; } catch { return false; } + let target; + try { + target = fs.readlinkSync(file); + } catch { + return !PYTHON_STUB_NAMES.has(path.basename(file).toLowerCase().replace(/\.exe$/, "")); + } + return path.win32.basename(target).toLowerCase() !== PYTHON_STUB_TARGET; +} + +function isExecutable(file, platform, fs = FS) { + try { + if (!fs.statSync(file).isFile()) return false; + if (platform !== "win32") fs.accessSync(file, constants.X_OK); + return true; + } catch { + return platform === "win32" && isAppAlias(file, fs); + } } -// which(name) -> absolute path of the first PATH match, or null. -export function which(name, env = process.env, platform = process.platform) { +// which(name) -> absolute path of the first PATH match, or null. fs is injectable for tests. +// rejectStoreAlias skips WindowsApps directories on win32, so no App Execution Alias matches. +export function which(name, env = process.env, platform = process.platform, fs = FS, rejectStoreAlias = false) { const pathVar = env.PATH ?? env.Path ?? ""; const exts = platform === "win32" ? ["", ...(env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean)] : [""]; for (const dir of pathVar.split(path.delimiter)) { - if (!dir) continue; + if (!dir || (rejectStoreAlias && platform === "win32" && isWindowsApps(dir))) continue; for (const ext of exts) { const candidate = path.join(dir, name + ext); - if (isExecutable(candidate, platform)) return candidate; + if (isExecutable(candidate, platform, fs)) return candidate; } } return null; } -function findLocalBin(rels, cwd, platform) { +function findLocalBin(rels, cwd, platform, fs) { for (const rel of rels ?? []) { let dir = path.resolve(cwd); for (let i = 0; i < LOCAL_BIN_DEPTH; i++) { const candidate = path.join(dir, rel); - if (isExecutable(candidate, platform)) return candidate; + if (isExecutable(candidate, platform, fs)) return candidate; const parent = path.dirname(dir); if (parent === dir) break; dir = parent; @@ -286,9 +321,9 @@ function run(file, args) { return { ok: !r.error && r.status === 0, out: `${r.stdout ?? ""}${r.stderr ?? ""}` }; } -function firstOnPath(names, ctx) { +function firstOnPath(names, ctx, rejectStoreAlias = false) { for (const name of names) { - const found = which(name, ctx.env, ctx.platform); + const found = which(name, ctx.env, ctx.platform, ctx.fs, rejectStoreAlias); if (found) return found; } return null; @@ -314,7 +349,7 @@ export function detectEntry(entry, ctx) { switch (entry.kind) { case "cli": case "runtime": { - const found = firstOnPath(d.any, ctx) ?? findLocalBin(d.local_bin, ctx.cwd, ctx.platform); + const found = firstOnPath(d.any, ctx, d.reject_store_alias) ?? findLocalBin(d.local_bin, ctx.cwd, ctx.platform, ctx.fs); if (!found) { const where = d.local_bin ? `on PATH or as ${d.local_bin.join(", ")}` : "on PATH"; return { status: "missing", detail: `${d.any.join(" or ")} was not found ${where}` }; @@ -331,7 +366,7 @@ export function detectEntry(entry, ctx) { } case "system-lib": { const [bin, ...args] = d.probe.args; - const found = which(bin, ctx.env, ctx.platform); + const found = which(bin, ctx.env, ctx.platform, ctx.fs); if (!found) return { status: "missing", detail: `${bin} was not found on PATH to probe` }; if (new RegExp(d.probe.pattern).test(run(found, args).out)) return { status: "present", detail: found }; return { status: "missing", detail: `${bin} ${args.join(" ")} did not report ${d.probe.pattern}` }; @@ -566,6 +601,7 @@ export function main(argv, ctx = {}, out = {}) { env: ctx.env ?? process.env, platform: ctx.platform ?? process.platform, cwd: ctx.cwd ?? process.cwd(), + fs: ctx.fs ?? FS, stdin: ctx.stdin ?? (() => (process.stdin.isTTY ? "" : readFileSync(0, "utf8"))), }; const io = { stdout: out.stdout ?? ((s) => console.log(s)), stderr: out.stderr ?? ((s) => console.error(s)) }; diff --git a/plugins/playbooks/.claude-plugin/plugin.json b/plugins/playbooks/.claude-plugin/plugin.json index 6d73ce74a0..050bcaa87e 100644 --- a/plugins/playbooks/.claude-plugin/plugin.json +++ b/plugins/playbooks/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "playbooks", - "version": "0.19.2", + "version": "0.19.3", "description": "Doctrine and knowledge playbooks as on-demand skills: boris (Boris Cherny's Claude Code workflow tips, howborisusesclaudecode.com), skill-authoring (Anthropic's internal skill-authoring playbook), fable-5 (Claude Fable 5's operating doctrine, self-authored), and repo-sweep (runs a catalog of hygiene skills through a repository one commit per step). boris and skill-authoring vendor a verbatim upstream baseline; /playbooks:update drift-checks and syncs them (maintainers).", "author": { "name": "Melodic Software", diff --git a/plugins/playbooks/CHANGELOG.md b/plugins/playbooks/CHANGELOG.md index 8616c0f45c..dabab02883 100644 --- a/plugins/playbooks/CHANGELOG.md +++ b/plugins/playbooks/CHANGELOG.md @@ -4,6 +4,14 @@ All notable changes to the `playbooks` plugin are recorded here. The `version` i `.claude-plugin/plugin.json` is the delivery vehicle. A consumer receives a change only after that version increases. +## [0.19.3] - 2026-10-03 + +### Changed + +- **Shared `prerequisites.mjs` synced ([#6084](https://github.com/melodic-software/claude-code-plugins/issues/6084)); no change to this plugin's lib.** + The prerequisite check now counts a Windows App Execution Alias (a Store or winget install on PATH) as found, + except App Installer's Python install stub. A `cli` or `runtime` entry can set `reject_store_alias` to skip aliases instead; no entry in this plugin does. + ## [0.19.2] - 2026-10-03 ### Changed diff --git a/plugins/playbooks/lib/prerequisites.mjs b/plugins/playbooks/lib/prerequisites.mjs index 57f65a79b5..1e11abf2cc 100755 --- a/plugins/playbooks/lib/prerequisites.mjs +++ b/plugins/playbooks/lib/prerequisites.mjs @@ -28,8 +28,10 @@ import { accessSync, constants, existsSync, + lstatSync, mkdirSync, readFileSync, + readlinkSync, realpathSync, statSync, writeFileSync, @@ -42,8 +44,8 @@ export const KINDS = ["cli", "runtime", "system-lib", "python-pkg", "node-pkg", export const NEEDS = ["required", "optional"]; const ENTRY_KEYS = ["id", "kind", "need", "for", "detect", "degrade", "install", "check"]; const DETECT_KEYS = { - cli: { required: ["any"], optional: ["local_bin", "version"] }, - runtime: { required: ["any"], optional: ["local_bin", "version"] }, + cli: { required: ["any"], optional: ["local_bin", "version", "reject_store_alias"] }, + runtime: { required: ["any"], optional: ["local_bin", "version", "reject_store_alias"] }, "system-lib": { required: ["probe"], optional: [] }, "python-pkg": { required: ["any", "import"], optional: [] }, "node-pkg": { required: ["module", "paths"], optional: [] }, @@ -100,6 +102,9 @@ function detectErrors(kind, detect, where) { } if ("any" in detect && !isTextList(detect.any)) errors.push(`${where}.any must be a non-empty list of names`); if ("local_bin" in detect) errors.push(...relativePathErrors(detect.local_bin, `${where}.local_bin`)); + if ("reject_store_alias" in detect && typeof detect.reject_store_alias !== "boolean") { + errors.push(`${where}.reject_store_alias must be true or false`); + } if ("version" in detect) { const v = detect.version; const vw = `${where}.version`; @@ -243,36 +248,66 @@ export function compareVersions(a, b) { return 0; } -function isExecutable(file, platform) { +const FS = { statSync, lstatSync, readlinkSync, accessSync }; + +// The target of App Installer's Python "install me" stub, which opens the Store +// instead of running Python, and the alias names it takes. +const PYTHON_STUB_TARGET = "appinstallerpythonredirector.exe"; +const PYTHON_STUB_NAMES = new Set(["python", "python3"]); + +// A Windows App Execution Alias, how a Store or winget app reaches PATH, is a +// reparse point in a WindowsApps directory that stat cannot open, yet it runs. +// It counts as found unless it is the Python stub, known by its target or, when +// the target cannot be read, by its name. +const isWindowsApps = (dir) => path.basename(dir).toLowerCase() === "windowsapps"; + +function isAppAlias(file, fs) { + if (!isWindowsApps(path.dirname(file))) return false; try { - if (!statSync(file).isFile()) return false; - if (platform !== "win32") accessSync(file, constants.X_OK); - return true; + if (!fs.lstatSync(file).isSymbolicLink()) return false; } catch { return false; } + let target; + try { + target = fs.readlinkSync(file); + } catch { + return !PYTHON_STUB_NAMES.has(path.basename(file).toLowerCase().replace(/\.exe$/, "")); + } + return path.win32.basename(target).toLowerCase() !== PYTHON_STUB_TARGET; +} + +function isExecutable(file, platform, fs = FS) { + try { + if (!fs.statSync(file).isFile()) return false; + if (platform !== "win32") fs.accessSync(file, constants.X_OK); + return true; + } catch { + return platform === "win32" && isAppAlias(file, fs); + } } -// which(name) -> absolute path of the first PATH match, or null. -export function which(name, env = process.env, platform = process.platform) { +// which(name) -> absolute path of the first PATH match, or null. fs is injectable for tests. +// rejectStoreAlias skips WindowsApps directories on win32, so no App Execution Alias matches. +export function which(name, env = process.env, platform = process.platform, fs = FS, rejectStoreAlias = false) { const pathVar = env.PATH ?? env.Path ?? ""; const exts = platform === "win32" ? ["", ...(env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean)] : [""]; for (const dir of pathVar.split(path.delimiter)) { - if (!dir) continue; + if (!dir || (rejectStoreAlias && platform === "win32" && isWindowsApps(dir))) continue; for (const ext of exts) { const candidate = path.join(dir, name + ext); - if (isExecutable(candidate, platform)) return candidate; + if (isExecutable(candidate, platform, fs)) return candidate; } } return null; } -function findLocalBin(rels, cwd, platform) { +function findLocalBin(rels, cwd, platform, fs) { for (const rel of rels ?? []) { let dir = path.resolve(cwd); for (let i = 0; i < LOCAL_BIN_DEPTH; i++) { const candidate = path.join(dir, rel); - if (isExecutable(candidate, platform)) return candidate; + if (isExecutable(candidate, platform, fs)) return candidate; const parent = path.dirname(dir); if (parent === dir) break; dir = parent; @@ -286,9 +321,9 @@ function run(file, args) { return { ok: !r.error && r.status === 0, out: `${r.stdout ?? ""}${r.stderr ?? ""}` }; } -function firstOnPath(names, ctx) { +function firstOnPath(names, ctx, rejectStoreAlias = false) { for (const name of names) { - const found = which(name, ctx.env, ctx.platform); + const found = which(name, ctx.env, ctx.platform, ctx.fs, rejectStoreAlias); if (found) return found; } return null; @@ -314,7 +349,7 @@ export function detectEntry(entry, ctx) { switch (entry.kind) { case "cli": case "runtime": { - const found = firstOnPath(d.any, ctx) ?? findLocalBin(d.local_bin, ctx.cwd, ctx.platform); + const found = firstOnPath(d.any, ctx, d.reject_store_alias) ?? findLocalBin(d.local_bin, ctx.cwd, ctx.platform, ctx.fs); if (!found) { const where = d.local_bin ? `on PATH or as ${d.local_bin.join(", ")}` : "on PATH"; return { status: "missing", detail: `${d.any.join(" or ")} was not found ${where}` }; @@ -331,7 +366,7 @@ export function detectEntry(entry, ctx) { } case "system-lib": { const [bin, ...args] = d.probe.args; - const found = which(bin, ctx.env, ctx.platform); + const found = which(bin, ctx.env, ctx.platform, ctx.fs); if (!found) return { status: "missing", detail: `${bin} was not found on PATH to probe` }; if (new RegExp(d.probe.pattern).test(run(found, args).out)) return { status: "present", detail: found }; return { status: "missing", detail: `${bin} ${args.join(" ")} did not report ${d.probe.pattern}` }; @@ -566,6 +601,7 @@ export function main(argv, ctx = {}, out = {}) { env: ctx.env ?? process.env, platform: ctx.platform ?? process.platform, cwd: ctx.cwd ?? process.cwd(), + fs: ctx.fs ?? FS, stdin: ctx.stdin ?? (() => (process.stdin.isTTY ? "" : readFileSync(0, "utf8"))), }; const io = { stdout: out.stdout ?? ((s) => console.log(s)), stderr: out.stderr ?? ((s) => console.error(s)) }; diff --git a/plugins/playwright/.claude-plugin/plugin.json b/plugins/playwright/.claude-plugin/plugin.json index 717abbdf19..d618ee6357 100644 --- a/plugins/playwright/.claude-plugin/plugin.json +++ b/plugins/playwright/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "playwright", - "version": "0.8.7", + "version": "0.8.8", "description": "Live E2E browser automation via Microsoft's @playwright/cli: named sessions, accessibility-ref snapshots, click/fill by ref, screenshots, console and network capture, mocking, tracing, video, and auth state, with artifacts written to disk so only paths enter context, plus a vendored upstream baseline and maintainer drift-check update flow.", "author": { "name": "Melodic Software", diff --git a/plugins/playwright/CHANGELOG.md b/plugins/playwright/CHANGELOG.md index 9192320bc5..38e72afbf1 100644 --- a/plugins/playwright/CHANGELOG.md +++ b/plugins/playwright/CHANGELOG.md @@ -3,6 +3,14 @@ All notable changes to the `playwright` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.8.8] - 2026-10-03 + +### Changed + +- **Shared `prerequisites.mjs` synced ([#6084](https://github.com/melodic-software/claude-code-plugins/issues/6084)); no change to this plugin's lib.** + The prerequisite check now counts a Windows App Execution Alias (a Store or winget install on PATH) as found, + except App Installer's Python install stub. A `cli` or `runtime` entry can set `reject_store_alias` to skip aliases instead; no entry in this plugin does. + ## [0.8.7] - 2026-10-03 ### Changed diff --git a/plugins/playwright/lib/prerequisites.mjs b/plugins/playwright/lib/prerequisites.mjs index 57f65a79b5..1e11abf2cc 100755 --- a/plugins/playwright/lib/prerequisites.mjs +++ b/plugins/playwright/lib/prerequisites.mjs @@ -28,8 +28,10 @@ import { accessSync, constants, existsSync, + lstatSync, mkdirSync, readFileSync, + readlinkSync, realpathSync, statSync, writeFileSync, @@ -42,8 +44,8 @@ export const KINDS = ["cli", "runtime", "system-lib", "python-pkg", "node-pkg", export const NEEDS = ["required", "optional"]; const ENTRY_KEYS = ["id", "kind", "need", "for", "detect", "degrade", "install", "check"]; const DETECT_KEYS = { - cli: { required: ["any"], optional: ["local_bin", "version"] }, - runtime: { required: ["any"], optional: ["local_bin", "version"] }, + cli: { required: ["any"], optional: ["local_bin", "version", "reject_store_alias"] }, + runtime: { required: ["any"], optional: ["local_bin", "version", "reject_store_alias"] }, "system-lib": { required: ["probe"], optional: [] }, "python-pkg": { required: ["any", "import"], optional: [] }, "node-pkg": { required: ["module", "paths"], optional: [] }, @@ -100,6 +102,9 @@ function detectErrors(kind, detect, where) { } if ("any" in detect && !isTextList(detect.any)) errors.push(`${where}.any must be a non-empty list of names`); if ("local_bin" in detect) errors.push(...relativePathErrors(detect.local_bin, `${where}.local_bin`)); + if ("reject_store_alias" in detect && typeof detect.reject_store_alias !== "boolean") { + errors.push(`${where}.reject_store_alias must be true or false`); + } if ("version" in detect) { const v = detect.version; const vw = `${where}.version`; @@ -243,36 +248,66 @@ export function compareVersions(a, b) { return 0; } -function isExecutable(file, platform) { +const FS = { statSync, lstatSync, readlinkSync, accessSync }; + +// The target of App Installer's Python "install me" stub, which opens the Store +// instead of running Python, and the alias names it takes. +const PYTHON_STUB_TARGET = "appinstallerpythonredirector.exe"; +const PYTHON_STUB_NAMES = new Set(["python", "python3"]); + +// A Windows App Execution Alias, how a Store or winget app reaches PATH, is a +// reparse point in a WindowsApps directory that stat cannot open, yet it runs. +// It counts as found unless it is the Python stub, known by its target or, when +// the target cannot be read, by its name. +const isWindowsApps = (dir) => path.basename(dir).toLowerCase() === "windowsapps"; + +function isAppAlias(file, fs) { + if (!isWindowsApps(path.dirname(file))) return false; try { - if (!statSync(file).isFile()) return false; - if (platform !== "win32") accessSync(file, constants.X_OK); - return true; + if (!fs.lstatSync(file).isSymbolicLink()) return false; } catch { return false; } + let target; + try { + target = fs.readlinkSync(file); + } catch { + return !PYTHON_STUB_NAMES.has(path.basename(file).toLowerCase().replace(/\.exe$/, "")); + } + return path.win32.basename(target).toLowerCase() !== PYTHON_STUB_TARGET; +} + +function isExecutable(file, platform, fs = FS) { + try { + if (!fs.statSync(file).isFile()) return false; + if (platform !== "win32") fs.accessSync(file, constants.X_OK); + return true; + } catch { + return platform === "win32" && isAppAlias(file, fs); + } } -// which(name) -> absolute path of the first PATH match, or null. -export function which(name, env = process.env, platform = process.platform) { +// which(name) -> absolute path of the first PATH match, or null. fs is injectable for tests. +// rejectStoreAlias skips WindowsApps directories on win32, so no App Execution Alias matches. +export function which(name, env = process.env, platform = process.platform, fs = FS, rejectStoreAlias = false) { const pathVar = env.PATH ?? env.Path ?? ""; const exts = platform === "win32" ? ["", ...(env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean)] : [""]; for (const dir of pathVar.split(path.delimiter)) { - if (!dir) continue; + if (!dir || (rejectStoreAlias && platform === "win32" && isWindowsApps(dir))) continue; for (const ext of exts) { const candidate = path.join(dir, name + ext); - if (isExecutable(candidate, platform)) return candidate; + if (isExecutable(candidate, platform, fs)) return candidate; } } return null; } -function findLocalBin(rels, cwd, platform) { +function findLocalBin(rels, cwd, platform, fs) { for (const rel of rels ?? []) { let dir = path.resolve(cwd); for (let i = 0; i < LOCAL_BIN_DEPTH; i++) { const candidate = path.join(dir, rel); - if (isExecutable(candidate, platform)) return candidate; + if (isExecutable(candidate, platform, fs)) return candidate; const parent = path.dirname(dir); if (parent === dir) break; dir = parent; @@ -286,9 +321,9 @@ function run(file, args) { return { ok: !r.error && r.status === 0, out: `${r.stdout ?? ""}${r.stderr ?? ""}` }; } -function firstOnPath(names, ctx) { +function firstOnPath(names, ctx, rejectStoreAlias = false) { for (const name of names) { - const found = which(name, ctx.env, ctx.platform); + const found = which(name, ctx.env, ctx.platform, ctx.fs, rejectStoreAlias); if (found) return found; } return null; @@ -314,7 +349,7 @@ export function detectEntry(entry, ctx) { switch (entry.kind) { case "cli": case "runtime": { - const found = firstOnPath(d.any, ctx) ?? findLocalBin(d.local_bin, ctx.cwd, ctx.platform); + const found = firstOnPath(d.any, ctx, d.reject_store_alias) ?? findLocalBin(d.local_bin, ctx.cwd, ctx.platform, ctx.fs); if (!found) { const where = d.local_bin ? `on PATH or as ${d.local_bin.join(", ")}` : "on PATH"; return { status: "missing", detail: `${d.any.join(" or ")} was not found ${where}` }; @@ -331,7 +366,7 @@ export function detectEntry(entry, ctx) { } case "system-lib": { const [bin, ...args] = d.probe.args; - const found = which(bin, ctx.env, ctx.platform); + const found = which(bin, ctx.env, ctx.platform, ctx.fs); if (!found) return { status: "missing", detail: `${bin} was not found on PATH to probe` }; if (new RegExp(d.probe.pattern).test(run(found, args).out)) return { status: "present", detail: found }; return { status: "missing", detail: `${bin} ${args.join(" ")} did not report ${d.probe.pattern}` }; @@ -566,6 +601,7 @@ export function main(argv, ctx = {}, out = {}) { env: ctx.env ?? process.env, platform: ctx.platform ?? process.platform, cwd: ctx.cwd ?? process.cwd(), + fs: ctx.fs ?? FS, stdin: ctx.stdin ?? (() => (process.stdin.isTTY ? "" : readFileSync(0, "utf8"))), }; const io = { stdout: out.stdout ?? ((s) => console.log(s)), stderr: out.stderr ?? ((s) => console.error(s)) }; diff --git a/plugins/plugin-quality/.claude-plugin/plugin.json b/plugins/plugin-quality/.claude-plugin/plugin.json index 826fd4735c..aeb8048a34 100644 --- a/plugins/plugin-quality/.claude-plugin/plugin.json +++ b/plugins/plugin-quality/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "plugin-quality", - "version": "0.13.4", + "version": "0.13.5", "description": "Post-use behavioral audit (plugin-quality:audit) of any Claude Code plugin skill, agent, hook, command, or config you have used. Six steps: capture evidence; map behavior in a fresh subagent that records errors, improvements, and quality-of-life items against standards; a blindspot pass; an interactive contract lock; review with a research gate on each finding's claims and fixes; and a confirmed work item for the maintainers. Uses context-guard snapshots when present.", "author": { "name": "Melodic Software", diff --git a/plugins/plugin-quality/CHANGELOG.md b/plugins/plugin-quality/CHANGELOG.md index c49093d22c..e52bb9d094 100644 --- a/plugins/plugin-quality/CHANGELOG.md +++ b/plugins/plugin-quality/CHANGELOG.md @@ -5,6 +5,14 @@ All notable changes to the `plugin-quality` plugin. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.13.5] - 2026-10-03 + +### Changed + +- **Shared `prerequisites.mjs` synced ([#6084](https://github.com/melodic-software/claude-code-plugins/issues/6084)); no change to this plugin's lib.** + The prerequisite check now counts a Windows App Execution Alias (a Store or winget install on PATH) as found, + except App Installer's Python install stub. A `cli` or `runtime` entry can set `reject_store_alias` to skip aliases instead; no entry in this plugin does. + ## [0.13.4] - 2026-10-03 ### Fixed diff --git a/plugins/plugin-quality/lib/prerequisites.mjs b/plugins/plugin-quality/lib/prerequisites.mjs index 57f65a79b5..1e11abf2cc 100755 --- a/plugins/plugin-quality/lib/prerequisites.mjs +++ b/plugins/plugin-quality/lib/prerequisites.mjs @@ -28,8 +28,10 @@ import { accessSync, constants, existsSync, + lstatSync, mkdirSync, readFileSync, + readlinkSync, realpathSync, statSync, writeFileSync, @@ -42,8 +44,8 @@ export const KINDS = ["cli", "runtime", "system-lib", "python-pkg", "node-pkg", export const NEEDS = ["required", "optional"]; const ENTRY_KEYS = ["id", "kind", "need", "for", "detect", "degrade", "install", "check"]; const DETECT_KEYS = { - cli: { required: ["any"], optional: ["local_bin", "version"] }, - runtime: { required: ["any"], optional: ["local_bin", "version"] }, + cli: { required: ["any"], optional: ["local_bin", "version", "reject_store_alias"] }, + runtime: { required: ["any"], optional: ["local_bin", "version", "reject_store_alias"] }, "system-lib": { required: ["probe"], optional: [] }, "python-pkg": { required: ["any", "import"], optional: [] }, "node-pkg": { required: ["module", "paths"], optional: [] }, @@ -100,6 +102,9 @@ function detectErrors(kind, detect, where) { } if ("any" in detect && !isTextList(detect.any)) errors.push(`${where}.any must be a non-empty list of names`); if ("local_bin" in detect) errors.push(...relativePathErrors(detect.local_bin, `${where}.local_bin`)); + if ("reject_store_alias" in detect && typeof detect.reject_store_alias !== "boolean") { + errors.push(`${where}.reject_store_alias must be true or false`); + } if ("version" in detect) { const v = detect.version; const vw = `${where}.version`; @@ -243,36 +248,66 @@ export function compareVersions(a, b) { return 0; } -function isExecutable(file, platform) { +const FS = { statSync, lstatSync, readlinkSync, accessSync }; + +// The target of App Installer's Python "install me" stub, which opens the Store +// instead of running Python, and the alias names it takes. +const PYTHON_STUB_TARGET = "appinstallerpythonredirector.exe"; +const PYTHON_STUB_NAMES = new Set(["python", "python3"]); + +// A Windows App Execution Alias, how a Store or winget app reaches PATH, is a +// reparse point in a WindowsApps directory that stat cannot open, yet it runs. +// It counts as found unless it is the Python stub, known by its target or, when +// the target cannot be read, by its name. +const isWindowsApps = (dir) => path.basename(dir).toLowerCase() === "windowsapps"; + +function isAppAlias(file, fs) { + if (!isWindowsApps(path.dirname(file))) return false; try { - if (!statSync(file).isFile()) return false; - if (platform !== "win32") accessSync(file, constants.X_OK); - return true; + if (!fs.lstatSync(file).isSymbolicLink()) return false; } catch { return false; } + let target; + try { + target = fs.readlinkSync(file); + } catch { + return !PYTHON_STUB_NAMES.has(path.basename(file).toLowerCase().replace(/\.exe$/, "")); + } + return path.win32.basename(target).toLowerCase() !== PYTHON_STUB_TARGET; +} + +function isExecutable(file, platform, fs = FS) { + try { + if (!fs.statSync(file).isFile()) return false; + if (platform !== "win32") fs.accessSync(file, constants.X_OK); + return true; + } catch { + return platform === "win32" && isAppAlias(file, fs); + } } -// which(name) -> absolute path of the first PATH match, or null. -export function which(name, env = process.env, platform = process.platform) { +// which(name) -> absolute path of the first PATH match, or null. fs is injectable for tests. +// rejectStoreAlias skips WindowsApps directories on win32, so no App Execution Alias matches. +export function which(name, env = process.env, platform = process.platform, fs = FS, rejectStoreAlias = false) { const pathVar = env.PATH ?? env.Path ?? ""; const exts = platform === "win32" ? ["", ...(env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean)] : [""]; for (const dir of pathVar.split(path.delimiter)) { - if (!dir) continue; + if (!dir || (rejectStoreAlias && platform === "win32" && isWindowsApps(dir))) continue; for (const ext of exts) { const candidate = path.join(dir, name + ext); - if (isExecutable(candidate, platform)) return candidate; + if (isExecutable(candidate, platform, fs)) return candidate; } } return null; } -function findLocalBin(rels, cwd, platform) { +function findLocalBin(rels, cwd, platform, fs) { for (const rel of rels ?? []) { let dir = path.resolve(cwd); for (let i = 0; i < LOCAL_BIN_DEPTH; i++) { const candidate = path.join(dir, rel); - if (isExecutable(candidate, platform)) return candidate; + if (isExecutable(candidate, platform, fs)) return candidate; const parent = path.dirname(dir); if (parent === dir) break; dir = parent; @@ -286,9 +321,9 @@ function run(file, args) { return { ok: !r.error && r.status === 0, out: `${r.stdout ?? ""}${r.stderr ?? ""}` }; } -function firstOnPath(names, ctx) { +function firstOnPath(names, ctx, rejectStoreAlias = false) { for (const name of names) { - const found = which(name, ctx.env, ctx.platform); + const found = which(name, ctx.env, ctx.platform, ctx.fs, rejectStoreAlias); if (found) return found; } return null; @@ -314,7 +349,7 @@ export function detectEntry(entry, ctx) { switch (entry.kind) { case "cli": case "runtime": { - const found = firstOnPath(d.any, ctx) ?? findLocalBin(d.local_bin, ctx.cwd, ctx.platform); + const found = firstOnPath(d.any, ctx, d.reject_store_alias) ?? findLocalBin(d.local_bin, ctx.cwd, ctx.platform, ctx.fs); if (!found) { const where = d.local_bin ? `on PATH or as ${d.local_bin.join(", ")}` : "on PATH"; return { status: "missing", detail: `${d.any.join(" or ")} was not found ${where}` }; @@ -331,7 +366,7 @@ export function detectEntry(entry, ctx) { } case "system-lib": { const [bin, ...args] = d.probe.args; - const found = which(bin, ctx.env, ctx.platform); + const found = which(bin, ctx.env, ctx.platform, ctx.fs); if (!found) return { status: "missing", detail: `${bin} was not found on PATH to probe` }; if (new RegExp(d.probe.pattern).test(run(found, args).out)) return { status: "present", detail: found }; return { status: "missing", detail: `${bin} ${args.join(" ")} did not report ${d.probe.pattern}` }; @@ -566,6 +601,7 @@ export function main(argv, ctx = {}, out = {}) { env: ctx.env ?? process.env, platform: ctx.platform ?? process.platform, cwd: ctx.cwd ?? process.cwd(), + fs: ctx.fs ?? FS, stdin: ctx.stdin ?? (() => (process.stdin.isTTY ? "" : readFileSync(0, "utf8"))), }; const io = { stdout: out.stdout ?? ((s) => console.log(s)), stderr: out.stderr ?? ((s) => console.error(s)) }; diff --git a/plugins/powershell-format/.claude-plugin/plugin.json b/plugins/powershell-format/.claude-plugin/plugin.json index f5dd4ecb5a..f4c6ad90ac 100644 --- a/plugins/powershell-format/.claude-plugin/plugin.json +++ b/plugins/powershell-format/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "powershell-format", - "version": "0.10.1", + "version": "0.10.2", "description": "Auto-format and lint PowerShell on edit via PSScriptAnalyzer, only when a PSScriptAnalyzerSettings.psd1 governs the repo, using the consuming repo's own analyzer settings.", "author": { "name": "Melodic Software", diff --git a/plugins/powershell-format/CHANGELOG.md b/plugins/powershell-format/CHANGELOG.md index 9bd9268da1..f3e1b7fa1b 100644 --- a/plugins/powershell-format/CHANGELOG.md +++ b/plugins/powershell-format/CHANGELOG.md @@ -3,6 +3,15 @@ All notable changes to the `powershell-format` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.10.2] - 2026-10-03 + +### Fixed + +- **No more false "pwsh was not found on PATH" notice for a Microsoft Store pwsh ([#6084](https://github.com/melodic-software/claude-code-plugins/issues/6084)).** The SessionStart + prerequisite check now counts a Windows App Execution Alias, such as the Store build's + `%LOCALAPPDATA%\Microsoft\WindowsApps\pwsh.exe`, as found. App Installer's Python install stub still reports + missing. Shared `prerequisites.mjs` synced; it also accepts a per-entry `reject_store_alias`, which no entry here sets. + ## [0.10.1] - 2026-10-03 ### Changed diff --git a/plugins/powershell-format/lib/prerequisites.mjs b/plugins/powershell-format/lib/prerequisites.mjs index 57f65a79b5..1e11abf2cc 100755 --- a/plugins/powershell-format/lib/prerequisites.mjs +++ b/plugins/powershell-format/lib/prerequisites.mjs @@ -28,8 +28,10 @@ import { accessSync, constants, existsSync, + lstatSync, mkdirSync, readFileSync, + readlinkSync, realpathSync, statSync, writeFileSync, @@ -42,8 +44,8 @@ export const KINDS = ["cli", "runtime", "system-lib", "python-pkg", "node-pkg", export const NEEDS = ["required", "optional"]; const ENTRY_KEYS = ["id", "kind", "need", "for", "detect", "degrade", "install", "check"]; const DETECT_KEYS = { - cli: { required: ["any"], optional: ["local_bin", "version"] }, - runtime: { required: ["any"], optional: ["local_bin", "version"] }, + cli: { required: ["any"], optional: ["local_bin", "version", "reject_store_alias"] }, + runtime: { required: ["any"], optional: ["local_bin", "version", "reject_store_alias"] }, "system-lib": { required: ["probe"], optional: [] }, "python-pkg": { required: ["any", "import"], optional: [] }, "node-pkg": { required: ["module", "paths"], optional: [] }, @@ -100,6 +102,9 @@ function detectErrors(kind, detect, where) { } if ("any" in detect && !isTextList(detect.any)) errors.push(`${where}.any must be a non-empty list of names`); if ("local_bin" in detect) errors.push(...relativePathErrors(detect.local_bin, `${where}.local_bin`)); + if ("reject_store_alias" in detect && typeof detect.reject_store_alias !== "boolean") { + errors.push(`${where}.reject_store_alias must be true or false`); + } if ("version" in detect) { const v = detect.version; const vw = `${where}.version`; @@ -243,36 +248,66 @@ export function compareVersions(a, b) { return 0; } -function isExecutable(file, platform) { +const FS = { statSync, lstatSync, readlinkSync, accessSync }; + +// The target of App Installer's Python "install me" stub, which opens the Store +// instead of running Python, and the alias names it takes. +const PYTHON_STUB_TARGET = "appinstallerpythonredirector.exe"; +const PYTHON_STUB_NAMES = new Set(["python", "python3"]); + +// A Windows App Execution Alias, how a Store or winget app reaches PATH, is a +// reparse point in a WindowsApps directory that stat cannot open, yet it runs. +// It counts as found unless it is the Python stub, known by its target or, when +// the target cannot be read, by its name. +const isWindowsApps = (dir) => path.basename(dir).toLowerCase() === "windowsapps"; + +function isAppAlias(file, fs) { + if (!isWindowsApps(path.dirname(file))) return false; try { - if (!statSync(file).isFile()) return false; - if (platform !== "win32") accessSync(file, constants.X_OK); - return true; + if (!fs.lstatSync(file).isSymbolicLink()) return false; } catch { return false; } + let target; + try { + target = fs.readlinkSync(file); + } catch { + return !PYTHON_STUB_NAMES.has(path.basename(file).toLowerCase().replace(/\.exe$/, "")); + } + return path.win32.basename(target).toLowerCase() !== PYTHON_STUB_TARGET; +} + +function isExecutable(file, platform, fs = FS) { + try { + if (!fs.statSync(file).isFile()) return false; + if (platform !== "win32") fs.accessSync(file, constants.X_OK); + return true; + } catch { + return platform === "win32" && isAppAlias(file, fs); + } } -// which(name) -> absolute path of the first PATH match, or null. -export function which(name, env = process.env, platform = process.platform) { +// which(name) -> absolute path of the first PATH match, or null. fs is injectable for tests. +// rejectStoreAlias skips WindowsApps directories on win32, so no App Execution Alias matches. +export function which(name, env = process.env, platform = process.platform, fs = FS, rejectStoreAlias = false) { const pathVar = env.PATH ?? env.Path ?? ""; const exts = platform === "win32" ? ["", ...(env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean)] : [""]; for (const dir of pathVar.split(path.delimiter)) { - if (!dir) continue; + if (!dir || (rejectStoreAlias && platform === "win32" && isWindowsApps(dir))) continue; for (const ext of exts) { const candidate = path.join(dir, name + ext); - if (isExecutable(candidate, platform)) return candidate; + if (isExecutable(candidate, platform, fs)) return candidate; } } return null; } -function findLocalBin(rels, cwd, platform) { +function findLocalBin(rels, cwd, platform, fs) { for (const rel of rels ?? []) { let dir = path.resolve(cwd); for (let i = 0; i < LOCAL_BIN_DEPTH; i++) { const candidate = path.join(dir, rel); - if (isExecutable(candidate, platform)) return candidate; + if (isExecutable(candidate, platform, fs)) return candidate; const parent = path.dirname(dir); if (parent === dir) break; dir = parent; @@ -286,9 +321,9 @@ function run(file, args) { return { ok: !r.error && r.status === 0, out: `${r.stdout ?? ""}${r.stderr ?? ""}` }; } -function firstOnPath(names, ctx) { +function firstOnPath(names, ctx, rejectStoreAlias = false) { for (const name of names) { - const found = which(name, ctx.env, ctx.platform); + const found = which(name, ctx.env, ctx.platform, ctx.fs, rejectStoreAlias); if (found) return found; } return null; @@ -314,7 +349,7 @@ export function detectEntry(entry, ctx) { switch (entry.kind) { case "cli": case "runtime": { - const found = firstOnPath(d.any, ctx) ?? findLocalBin(d.local_bin, ctx.cwd, ctx.platform); + const found = firstOnPath(d.any, ctx, d.reject_store_alias) ?? findLocalBin(d.local_bin, ctx.cwd, ctx.platform, ctx.fs); if (!found) { const where = d.local_bin ? `on PATH or as ${d.local_bin.join(", ")}` : "on PATH"; return { status: "missing", detail: `${d.any.join(" or ")} was not found ${where}` }; @@ -331,7 +366,7 @@ export function detectEntry(entry, ctx) { } case "system-lib": { const [bin, ...args] = d.probe.args; - const found = which(bin, ctx.env, ctx.platform); + const found = which(bin, ctx.env, ctx.platform, ctx.fs); if (!found) return { status: "missing", detail: `${bin} was not found on PATH to probe` }; if (new RegExp(d.probe.pattern).test(run(found, args).out)) return { status: "present", detail: found }; return { status: "missing", detail: `${bin} ${args.join(" ")} did not report ${d.probe.pattern}` }; @@ -566,6 +601,7 @@ export function main(argv, ctx = {}, out = {}) { env: ctx.env ?? process.env, platform: ctx.platform ?? process.platform, cwd: ctx.cwd ?? process.cwd(), + fs: ctx.fs ?? FS, stdin: ctx.stdin ?? (() => (process.stdin.isTTY ? "" : readFileSync(0, "utf8"))), }; const io = { stdout: out.stdout ?? ((s) => console.log(s)), stderr: out.stderr ?? ((s) => console.error(s)) }; diff --git a/plugins/rate-limit-guard/.claude-plugin/plugin.json b/plugins/rate-limit-guard/.claude-plugin/plugin.json index dbadc30957..01575d6872 100644 --- a/plugins/rate-limit-guard/.claude-plugin/plugin.json +++ b/plugins/rate-limit-guard/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "rate-limit-guard", - "version": "0.11.1", + "version": "0.11.2", "description": "Shared rate-limit guard for loop lanes: a statusline wrapper tees the subscription rate-limit windows to a fixed machine-scope file, a StopFailure hook records rate-limit stops reactively, and a reader contract fixes how consuming sessions pause and resume.", "author": { "name": "Melodic Software", diff --git a/plugins/rate-limit-guard/CHANGELOG.md b/plugins/rate-limit-guard/CHANGELOG.md index 232f9eae7a..9c792366e9 100644 --- a/plugins/rate-limit-guard/CHANGELOG.md +++ b/plugins/rate-limit-guard/CHANGELOG.md @@ -3,6 +3,14 @@ All notable changes to the `rate-limit-guard` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.11.2] - 2026-10-03 + +### Changed + +- **Shared `prerequisites.mjs` synced ([#6084](https://github.com/melodic-software/claude-code-plugins/issues/6084)); no change to this plugin's lib.** + The prerequisite check now counts a Windows App Execution Alias (a Store or winget install on PATH) as found, + except App Installer's Python install stub. A `cli` or `runtime` entry can set `reject_store_alias` to skip aliases instead; no entry in this plugin does. + ## [0.11.1] - 2026-10-03 ### Changed diff --git a/plugins/rate-limit-guard/lib/prerequisites.mjs b/plugins/rate-limit-guard/lib/prerequisites.mjs index 57f65a79b5..1e11abf2cc 100755 --- a/plugins/rate-limit-guard/lib/prerequisites.mjs +++ b/plugins/rate-limit-guard/lib/prerequisites.mjs @@ -28,8 +28,10 @@ import { accessSync, constants, existsSync, + lstatSync, mkdirSync, readFileSync, + readlinkSync, realpathSync, statSync, writeFileSync, @@ -42,8 +44,8 @@ export const KINDS = ["cli", "runtime", "system-lib", "python-pkg", "node-pkg", export const NEEDS = ["required", "optional"]; const ENTRY_KEYS = ["id", "kind", "need", "for", "detect", "degrade", "install", "check"]; const DETECT_KEYS = { - cli: { required: ["any"], optional: ["local_bin", "version"] }, - runtime: { required: ["any"], optional: ["local_bin", "version"] }, + cli: { required: ["any"], optional: ["local_bin", "version", "reject_store_alias"] }, + runtime: { required: ["any"], optional: ["local_bin", "version", "reject_store_alias"] }, "system-lib": { required: ["probe"], optional: [] }, "python-pkg": { required: ["any", "import"], optional: [] }, "node-pkg": { required: ["module", "paths"], optional: [] }, @@ -100,6 +102,9 @@ function detectErrors(kind, detect, where) { } if ("any" in detect && !isTextList(detect.any)) errors.push(`${where}.any must be a non-empty list of names`); if ("local_bin" in detect) errors.push(...relativePathErrors(detect.local_bin, `${where}.local_bin`)); + if ("reject_store_alias" in detect && typeof detect.reject_store_alias !== "boolean") { + errors.push(`${where}.reject_store_alias must be true or false`); + } if ("version" in detect) { const v = detect.version; const vw = `${where}.version`; @@ -243,36 +248,66 @@ export function compareVersions(a, b) { return 0; } -function isExecutable(file, platform) { +const FS = { statSync, lstatSync, readlinkSync, accessSync }; + +// The target of App Installer's Python "install me" stub, which opens the Store +// instead of running Python, and the alias names it takes. +const PYTHON_STUB_TARGET = "appinstallerpythonredirector.exe"; +const PYTHON_STUB_NAMES = new Set(["python", "python3"]); + +// A Windows App Execution Alias, how a Store or winget app reaches PATH, is a +// reparse point in a WindowsApps directory that stat cannot open, yet it runs. +// It counts as found unless it is the Python stub, known by its target or, when +// the target cannot be read, by its name. +const isWindowsApps = (dir) => path.basename(dir).toLowerCase() === "windowsapps"; + +function isAppAlias(file, fs) { + if (!isWindowsApps(path.dirname(file))) return false; try { - if (!statSync(file).isFile()) return false; - if (platform !== "win32") accessSync(file, constants.X_OK); - return true; + if (!fs.lstatSync(file).isSymbolicLink()) return false; } catch { return false; } + let target; + try { + target = fs.readlinkSync(file); + } catch { + return !PYTHON_STUB_NAMES.has(path.basename(file).toLowerCase().replace(/\.exe$/, "")); + } + return path.win32.basename(target).toLowerCase() !== PYTHON_STUB_TARGET; +} + +function isExecutable(file, platform, fs = FS) { + try { + if (!fs.statSync(file).isFile()) return false; + if (platform !== "win32") fs.accessSync(file, constants.X_OK); + return true; + } catch { + return platform === "win32" && isAppAlias(file, fs); + } } -// which(name) -> absolute path of the first PATH match, or null. -export function which(name, env = process.env, platform = process.platform) { +// which(name) -> absolute path of the first PATH match, or null. fs is injectable for tests. +// rejectStoreAlias skips WindowsApps directories on win32, so no App Execution Alias matches. +export function which(name, env = process.env, platform = process.platform, fs = FS, rejectStoreAlias = false) { const pathVar = env.PATH ?? env.Path ?? ""; const exts = platform === "win32" ? ["", ...(env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean)] : [""]; for (const dir of pathVar.split(path.delimiter)) { - if (!dir) continue; + if (!dir || (rejectStoreAlias && platform === "win32" && isWindowsApps(dir))) continue; for (const ext of exts) { const candidate = path.join(dir, name + ext); - if (isExecutable(candidate, platform)) return candidate; + if (isExecutable(candidate, platform, fs)) return candidate; } } return null; } -function findLocalBin(rels, cwd, platform) { +function findLocalBin(rels, cwd, platform, fs) { for (const rel of rels ?? []) { let dir = path.resolve(cwd); for (let i = 0; i < LOCAL_BIN_DEPTH; i++) { const candidate = path.join(dir, rel); - if (isExecutable(candidate, platform)) return candidate; + if (isExecutable(candidate, platform, fs)) return candidate; const parent = path.dirname(dir); if (parent === dir) break; dir = parent; @@ -286,9 +321,9 @@ function run(file, args) { return { ok: !r.error && r.status === 0, out: `${r.stdout ?? ""}${r.stderr ?? ""}` }; } -function firstOnPath(names, ctx) { +function firstOnPath(names, ctx, rejectStoreAlias = false) { for (const name of names) { - const found = which(name, ctx.env, ctx.platform); + const found = which(name, ctx.env, ctx.platform, ctx.fs, rejectStoreAlias); if (found) return found; } return null; @@ -314,7 +349,7 @@ export function detectEntry(entry, ctx) { switch (entry.kind) { case "cli": case "runtime": { - const found = firstOnPath(d.any, ctx) ?? findLocalBin(d.local_bin, ctx.cwd, ctx.platform); + const found = firstOnPath(d.any, ctx, d.reject_store_alias) ?? findLocalBin(d.local_bin, ctx.cwd, ctx.platform, ctx.fs); if (!found) { const where = d.local_bin ? `on PATH or as ${d.local_bin.join(", ")}` : "on PATH"; return { status: "missing", detail: `${d.any.join(" or ")} was not found ${where}` }; @@ -331,7 +366,7 @@ export function detectEntry(entry, ctx) { } case "system-lib": { const [bin, ...args] = d.probe.args; - const found = which(bin, ctx.env, ctx.platform); + const found = which(bin, ctx.env, ctx.platform, ctx.fs); if (!found) return { status: "missing", detail: `${bin} was not found on PATH to probe` }; if (new RegExp(d.probe.pattern).test(run(found, args).out)) return { status: "present", detail: found }; return { status: "missing", detail: `${bin} ${args.join(" ")} did not report ${d.probe.pattern}` }; @@ -566,6 +601,7 @@ export function main(argv, ctx = {}, out = {}) { env: ctx.env ?? process.env, platform: ctx.platform ?? process.platform, cwd: ctx.cwd ?? process.cwd(), + fs: ctx.fs ?? FS, stdin: ctx.stdin ?? (() => (process.stdin.isTTY ? "" : readFileSync(0, "utf8"))), }; const io = { stdout: out.stdout ?? ((s) => console.log(s)), stderr: out.stderr ?? ((s) => console.error(s)) }; diff --git a/plugins/repo-fleet-hygiene/.claude-plugin/plugin.json b/plugins/repo-fleet-hygiene/.claude-plugin/plugin.json index f6911e3fd3..fb1cc586cc 100644 --- a/plugins/repo-fleet-hygiene/.claude-plugin/plugin.json +++ b/plugins/repo-fleet-hygiene/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "repo-fleet-hygiene", - "version": "0.28.2", + "version": "0.28.3", "description": "Cross-repository Git/GitHub fleet discovery, evidence rollup, a gated apply verb, and a sync verb that fast-forwards canonical checkouts onto the remote default branch. Audit stays read-only. apply and sync mutate only with --apply plus interactive confirmation or --yes.", "author": { "name": "Melodic Software", diff --git a/plugins/repo-fleet-hygiene/CHANGELOG.md b/plugins/repo-fleet-hygiene/CHANGELOG.md index 371b171300..f17d2d045e 100644 --- a/plugins/repo-fleet-hygiene/CHANGELOG.md +++ b/plugins/repo-fleet-hygiene/CHANGELOG.md @@ -3,6 +3,14 @@ All notable changes to `repo-fleet-hygiene` are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.28.3] - 2026-10-03 + +### Changed + +- **Shared `prerequisites.mjs` synced ([#6084](https://github.com/melodic-software/claude-code-plugins/issues/6084)); no change to this plugin's lib.** + The prerequisite check now counts a Windows App Execution Alias (a Store or winget install on PATH) as found, + except App Installer's Python install stub. A `cli` or `runtime` entry can set `reject_store_alias` to skip aliases instead; no entry in this plugin does. + ## [0.28.2] - 2026-10-03 ### Changed diff --git a/plugins/repo-fleet-hygiene/lib/prerequisites.mjs b/plugins/repo-fleet-hygiene/lib/prerequisites.mjs index 57f65a79b5..1e11abf2cc 100755 --- a/plugins/repo-fleet-hygiene/lib/prerequisites.mjs +++ b/plugins/repo-fleet-hygiene/lib/prerequisites.mjs @@ -28,8 +28,10 @@ import { accessSync, constants, existsSync, + lstatSync, mkdirSync, readFileSync, + readlinkSync, realpathSync, statSync, writeFileSync, @@ -42,8 +44,8 @@ export const KINDS = ["cli", "runtime", "system-lib", "python-pkg", "node-pkg", export const NEEDS = ["required", "optional"]; const ENTRY_KEYS = ["id", "kind", "need", "for", "detect", "degrade", "install", "check"]; const DETECT_KEYS = { - cli: { required: ["any"], optional: ["local_bin", "version"] }, - runtime: { required: ["any"], optional: ["local_bin", "version"] }, + cli: { required: ["any"], optional: ["local_bin", "version", "reject_store_alias"] }, + runtime: { required: ["any"], optional: ["local_bin", "version", "reject_store_alias"] }, "system-lib": { required: ["probe"], optional: [] }, "python-pkg": { required: ["any", "import"], optional: [] }, "node-pkg": { required: ["module", "paths"], optional: [] }, @@ -100,6 +102,9 @@ function detectErrors(kind, detect, where) { } if ("any" in detect && !isTextList(detect.any)) errors.push(`${where}.any must be a non-empty list of names`); if ("local_bin" in detect) errors.push(...relativePathErrors(detect.local_bin, `${where}.local_bin`)); + if ("reject_store_alias" in detect && typeof detect.reject_store_alias !== "boolean") { + errors.push(`${where}.reject_store_alias must be true or false`); + } if ("version" in detect) { const v = detect.version; const vw = `${where}.version`; @@ -243,36 +248,66 @@ export function compareVersions(a, b) { return 0; } -function isExecutable(file, platform) { +const FS = { statSync, lstatSync, readlinkSync, accessSync }; + +// The target of App Installer's Python "install me" stub, which opens the Store +// instead of running Python, and the alias names it takes. +const PYTHON_STUB_TARGET = "appinstallerpythonredirector.exe"; +const PYTHON_STUB_NAMES = new Set(["python", "python3"]); + +// A Windows App Execution Alias, how a Store or winget app reaches PATH, is a +// reparse point in a WindowsApps directory that stat cannot open, yet it runs. +// It counts as found unless it is the Python stub, known by its target or, when +// the target cannot be read, by its name. +const isWindowsApps = (dir) => path.basename(dir).toLowerCase() === "windowsapps"; + +function isAppAlias(file, fs) { + if (!isWindowsApps(path.dirname(file))) return false; try { - if (!statSync(file).isFile()) return false; - if (platform !== "win32") accessSync(file, constants.X_OK); - return true; + if (!fs.lstatSync(file).isSymbolicLink()) return false; } catch { return false; } + let target; + try { + target = fs.readlinkSync(file); + } catch { + return !PYTHON_STUB_NAMES.has(path.basename(file).toLowerCase().replace(/\.exe$/, "")); + } + return path.win32.basename(target).toLowerCase() !== PYTHON_STUB_TARGET; +} + +function isExecutable(file, platform, fs = FS) { + try { + if (!fs.statSync(file).isFile()) return false; + if (platform !== "win32") fs.accessSync(file, constants.X_OK); + return true; + } catch { + return platform === "win32" && isAppAlias(file, fs); + } } -// which(name) -> absolute path of the first PATH match, or null. -export function which(name, env = process.env, platform = process.platform) { +// which(name) -> absolute path of the first PATH match, or null. fs is injectable for tests. +// rejectStoreAlias skips WindowsApps directories on win32, so no App Execution Alias matches. +export function which(name, env = process.env, platform = process.platform, fs = FS, rejectStoreAlias = false) { const pathVar = env.PATH ?? env.Path ?? ""; const exts = platform === "win32" ? ["", ...(env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean)] : [""]; for (const dir of pathVar.split(path.delimiter)) { - if (!dir) continue; + if (!dir || (rejectStoreAlias && platform === "win32" && isWindowsApps(dir))) continue; for (const ext of exts) { const candidate = path.join(dir, name + ext); - if (isExecutable(candidate, platform)) return candidate; + if (isExecutable(candidate, platform, fs)) return candidate; } } return null; } -function findLocalBin(rels, cwd, platform) { +function findLocalBin(rels, cwd, platform, fs) { for (const rel of rels ?? []) { let dir = path.resolve(cwd); for (let i = 0; i < LOCAL_BIN_DEPTH; i++) { const candidate = path.join(dir, rel); - if (isExecutable(candidate, platform)) return candidate; + if (isExecutable(candidate, platform, fs)) return candidate; const parent = path.dirname(dir); if (parent === dir) break; dir = parent; @@ -286,9 +321,9 @@ function run(file, args) { return { ok: !r.error && r.status === 0, out: `${r.stdout ?? ""}${r.stderr ?? ""}` }; } -function firstOnPath(names, ctx) { +function firstOnPath(names, ctx, rejectStoreAlias = false) { for (const name of names) { - const found = which(name, ctx.env, ctx.platform); + const found = which(name, ctx.env, ctx.platform, ctx.fs, rejectStoreAlias); if (found) return found; } return null; @@ -314,7 +349,7 @@ export function detectEntry(entry, ctx) { switch (entry.kind) { case "cli": case "runtime": { - const found = firstOnPath(d.any, ctx) ?? findLocalBin(d.local_bin, ctx.cwd, ctx.platform); + const found = firstOnPath(d.any, ctx, d.reject_store_alias) ?? findLocalBin(d.local_bin, ctx.cwd, ctx.platform, ctx.fs); if (!found) { const where = d.local_bin ? `on PATH or as ${d.local_bin.join(", ")}` : "on PATH"; return { status: "missing", detail: `${d.any.join(" or ")} was not found ${where}` }; @@ -331,7 +366,7 @@ export function detectEntry(entry, ctx) { } case "system-lib": { const [bin, ...args] = d.probe.args; - const found = which(bin, ctx.env, ctx.platform); + const found = which(bin, ctx.env, ctx.platform, ctx.fs); if (!found) return { status: "missing", detail: `${bin} was not found on PATH to probe` }; if (new RegExp(d.probe.pattern).test(run(found, args).out)) return { status: "present", detail: found }; return { status: "missing", detail: `${bin} ${args.join(" ")} did not report ${d.probe.pattern}` }; @@ -566,6 +601,7 @@ export function main(argv, ctx = {}, out = {}) { env: ctx.env ?? process.env, platform: ctx.platform ?? process.platform, cwd: ctx.cwd ?? process.cwd(), + fs: ctx.fs ?? FS, stdin: ctx.stdin ?? (() => (process.stdin.isTTY ? "" : readFileSync(0, "utf8"))), }; const io = { stdout: out.stdout ?? ((s) => console.log(s)), stderr: out.stderr ?? ((s) => console.error(s)) }; diff --git a/plugins/repo-hygiene/.claude-plugin/plugin.json b/plugins/repo-hygiene/.claude-plugin/plugin.json index e8e9c4f1d8..072fe20f9a 100644 --- a/plugins/repo-hygiene/.claude-plugin/plugin.json +++ b/plugins/repo-hygiene/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "repo-hygiene", - "version": "0.19.4", + "version": "0.19.5", "description": "Repo hygiene action-router: /repo-hygiene:clean sweeps reclaimable caches, build artifacts, and stale git metadata, and can realign the working tree to a fresh-pull state, dry-run-first, with destructive tiers gated behind explicit confirmation and a session-scoped destructive-command guard. Ecosystem targets are detected at runtime; secrets, runtime dependencies, and skill data are preserved by default.", "author": { "name": "Melodic Software", diff --git a/plugins/repo-hygiene/CHANGELOG.md b/plugins/repo-hygiene/CHANGELOG.md index b69181ae1b..83515a065b 100644 --- a/plugins/repo-hygiene/CHANGELOG.md +++ b/plugins/repo-hygiene/CHANGELOG.md @@ -3,6 +3,14 @@ All notable changes to the `repo-hygiene` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.19.5] - 2026-10-03 + +### Changed + +- **Shared `prerequisites.mjs` synced ([#6084](https://github.com/melodic-software/claude-code-plugins/issues/6084)); no change to this plugin's lib.** + The prerequisite check now counts a Windows App Execution Alias (a Store or winget install on PATH) as found, + except App Installer's Python install stub. A `cli` or `runtime` entry can set `reject_store_alias` to skip aliases instead; no entry in this plugin does. + ## [0.19.4] - 2026-10-03 ### Changed diff --git a/plugins/repo-hygiene/lib/prerequisites.mjs b/plugins/repo-hygiene/lib/prerequisites.mjs index 57f65a79b5..1e11abf2cc 100755 --- a/plugins/repo-hygiene/lib/prerequisites.mjs +++ b/plugins/repo-hygiene/lib/prerequisites.mjs @@ -28,8 +28,10 @@ import { accessSync, constants, existsSync, + lstatSync, mkdirSync, readFileSync, + readlinkSync, realpathSync, statSync, writeFileSync, @@ -42,8 +44,8 @@ export const KINDS = ["cli", "runtime", "system-lib", "python-pkg", "node-pkg", export const NEEDS = ["required", "optional"]; const ENTRY_KEYS = ["id", "kind", "need", "for", "detect", "degrade", "install", "check"]; const DETECT_KEYS = { - cli: { required: ["any"], optional: ["local_bin", "version"] }, - runtime: { required: ["any"], optional: ["local_bin", "version"] }, + cli: { required: ["any"], optional: ["local_bin", "version", "reject_store_alias"] }, + runtime: { required: ["any"], optional: ["local_bin", "version", "reject_store_alias"] }, "system-lib": { required: ["probe"], optional: [] }, "python-pkg": { required: ["any", "import"], optional: [] }, "node-pkg": { required: ["module", "paths"], optional: [] }, @@ -100,6 +102,9 @@ function detectErrors(kind, detect, where) { } if ("any" in detect && !isTextList(detect.any)) errors.push(`${where}.any must be a non-empty list of names`); if ("local_bin" in detect) errors.push(...relativePathErrors(detect.local_bin, `${where}.local_bin`)); + if ("reject_store_alias" in detect && typeof detect.reject_store_alias !== "boolean") { + errors.push(`${where}.reject_store_alias must be true or false`); + } if ("version" in detect) { const v = detect.version; const vw = `${where}.version`; @@ -243,36 +248,66 @@ export function compareVersions(a, b) { return 0; } -function isExecutable(file, platform) { +const FS = { statSync, lstatSync, readlinkSync, accessSync }; + +// The target of App Installer's Python "install me" stub, which opens the Store +// instead of running Python, and the alias names it takes. +const PYTHON_STUB_TARGET = "appinstallerpythonredirector.exe"; +const PYTHON_STUB_NAMES = new Set(["python", "python3"]); + +// A Windows App Execution Alias, how a Store or winget app reaches PATH, is a +// reparse point in a WindowsApps directory that stat cannot open, yet it runs. +// It counts as found unless it is the Python stub, known by its target or, when +// the target cannot be read, by its name. +const isWindowsApps = (dir) => path.basename(dir).toLowerCase() === "windowsapps"; + +function isAppAlias(file, fs) { + if (!isWindowsApps(path.dirname(file))) return false; try { - if (!statSync(file).isFile()) return false; - if (platform !== "win32") accessSync(file, constants.X_OK); - return true; + if (!fs.lstatSync(file).isSymbolicLink()) return false; } catch { return false; } + let target; + try { + target = fs.readlinkSync(file); + } catch { + return !PYTHON_STUB_NAMES.has(path.basename(file).toLowerCase().replace(/\.exe$/, "")); + } + return path.win32.basename(target).toLowerCase() !== PYTHON_STUB_TARGET; +} + +function isExecutable(file, platform, fs = FS) { + try { + if (!fs.statSync(file).isFile()) return false; + if (platform !== "win32") fs.accessSync(file, constants.X_OK); + return true; + } catch { + return platform === "win32" && isAppAlias(file, fs); + } } -// which(name) -> absolute path of the first PATH match, or null. -export function which(name, env = process.env, platform = process.platform) { +// which(name) -> absolute path of the first PATH match, or null. fs is injectable for tests. +// rejectStoreAlias skips WindowsApps directories on win32, so no App Execution Alias matches. +export function which(name, env = process.env, platform = process.platform, fs = FS, rejectStoreAlias = false) { const pathVar = env.PATH ?? env.Path ?? ""; const exts = platform === "win32" ? ["", ...(env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean)] : [""]; for (const dir of pathVar.split(path.delimiter)) { - if (!dir) continue; + if (!dir || (rejectStoreAlias && platform === "win32" && isWindowsApps(dir))) continue; for (const ext of exts) { const candidate = path.join(dir, name + ext); - if (isExecutable(candidate, platform)) return candidate; + if (isExecutable(candidate, platform, fs)) return candidate; } } return null; } -function findLocalBin(rels, cwd, platform) { +function findLocalBin(rels, cwd, platform, fs) { for (const rel of rels ?? []) { let dir = path.resolve(cwd); for (let i = 0; i < LOCAL_BIN_DEPTH; i++) { const candidate = path.join(dir, rel); - if (isExecutable(candidate, platform)) return candidate; + if (isExecutable(candidate, platform, fs)) return candidate; const parent = path.dirname(dir); if (parent === dir) break; dir = parent; @@ -286,9 +321,9 @@ function run(file, args) { return { ok: !r.error && r.status === 0, out: `${r.stdout ?? ""}${r.stderr ?? ""}` }; } -function firstOnPath(names, ctx) { +function firstOnPath(names, ctx, rejectStoreAlias = false) { for (const name of names) { - const found = which(name, ctx.env, ctx.platform); + const found = which(name, ctx.env, ctx.platform, ctx.fs, rejectStoreAlias); if (found) return found; } return null; @@ -314,7 +349,7 @@ export function detectEntry(entry, ctx) { switch (entry.kind) { case "cli": case "runtime": { - const found = firstOnPath(d.any, ctx) ?? findLocalBin(d.local_bin, ctx.cwd, ctx.platform); + const found = firstOnPath(d.any, ctx, d.reject_store_alias) ?? findLocalBin(d.local_bin, ctx.cwd, ctx.platform, ctx.fs); if (!found) { const where = d.local_bin ? `on PATH or as ${d.local_bin.join(", ")}` : "on PATH"; return { status: "missing", detail: `${d.any.join(" or ")} was not found ${where}` }; @@ -331,7 +366,7 @@ export function detectEntry(entry, ctx) { } case "system-lib": { const [bin, ...args] = d.probe.args; - const found = which(bin, ctx.env, ctx.platform); + const found = which(bin, ctx.env, ctx.platform, ctx.fs); if (!found) return { status: "missing", detail: `${bin} was not found on PATH to probe` }; if (new RegExp(d.probe.pattern).test(run(found, args).out)) return { status: "present", detail: found }; return { status: "missing", detail: `${bin} ${args.join(" ")} did not report ${d.probe.pattern}` }; @@ -566,6 +601,7 @@ export function main(argv, ctx = {}, out = {}) { env: ctx.env ?? process.env, platform: ctx.platform ?? process.platform, cwd: ctx.cwd ?? process.cwd(), + fs: ctx.fs ?? FS, stdin: ctx.stdin ?? (() => (process.stdin.isTTY ? "" : readFileSync(0, "utf8"))), }; const io = { stdout: out.stdout ?? ((s) => console.log(s)), stderr: out.stderr ?? ((s) => console.error(s)) }; diff --git a/plugins/retro-audio/.claude-plugin/plugin.json b/plugins/retro-audio/.claude-plugin/plugin.json index b856e75bd3..c44d1b38f9 100644 --- a/plugins/retro-audio/.claude-plugin/plugin.json +++ b/plugins/retro-audio/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "retro-audio", - "version": "0.2.2", + "version": "0.2.3", "description": "Renders retro sound effects and short chiptune loops to WAV with the Python standard library only: an sfxr-style parameter model and an MML subset with Game Boy and NES pulse duties and channel limits for Game Boy, NES, and PICO-8 (four channels, one noise part). Another plugin can play the WAV; this one does not read that plugin's files.", "author": { "name": "Melodic Software", diff --git a/plugins/retro-audio/CHANGELOG.md b/plugins/retro-audio/CHANGELOG.md index 6c9006cf48..ea50ca9b11 100644 --- a/plugins/retro-audio/CHANGELOG.md +++ b/plugins/retro-audio/CHANGELOG.md @@ -3,6 +3,14 @@ All notable changes to the `retro-audio` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.2.3] - 2026-10-03 + +### Changed + +- **Shared `prerequisites.mjs` synced ([#6084](https://github.com/melodic-software/claude-code-plugins/issues/6084)); no change to this plugin's lib.** + The prerequisite check now counts a Windows App Execution Alias (a Store or winget install on PATH) as found, + except App Installer's Python install stub. A `cli` or `runtime` entry can set `reject_store_alias` to skip aliases instead; no entry in this plugin does. + ## [0.2.2] - 2026-10-03 ### Changed diff --git a/plugins/retro-audio/lib/prerequisites.mjs b/plugins/retro-audio/lib/prerequisites.mjs index 57f65a79b5..1e11abf2cc 100755 --- a/plugins/retro-audio/lib/prerequisites.mjs +++ b/plugins/retro-audio/lib/prerequisites.mjs @@ -28,8 +28,10 @@ import { accessSync, constants, existsSync, + lstatSync, mkdirSync, readFileSync, + readlinkSync, realpathSync, statSync, writeFileSync, @@ -42,8 +44,8 @@ export const KINDS = ["cli", "runtime", "system-lib", "python-pkg", "node-pkg", export const NEEDS = ["required", "optional"]; const ENTRY_KEYS = ["id", "kind", "need", "for", "detect", "degrade", "install", "check"]; const DETECT_KEYS = { - cli: { required: ["any"], optional: ["local_bin", "version"] }, - runtime: { required: ["any"], optional: ["local_bin", "version"] }, + cli: { required: ["any"], optional: ["local_bin", "version", "reject_store_alias"] }, + runtime: { required: ["any"], optional: ["local_bin", "version", "reject_store_alias"] }, "system-lib": { required: ["probe"], optional: [] }, "python-pkg": { required: ["any", "import"], optional: [] }, "node-pkg": { required: ["module", "paths"], optional: [] }, @@ -100,6 +102,9 @@ function detectErrors(kind, detect, where) { } if ("any" in detect && !isTextList(detect.any)) errors.push(`${where}.any must be a non-empty list of names`); if ("local_bin" in detect) errors.push(...relativePathErrors(detect.local_bin, `${where}.local_bin`)); + if ("reject_store_alias" in detect && typeof detect.reject_store_alias !== "boolean") { + errors.push(`${where}.reject_store_alias must be true or false`); + } if ("version" in detect) { const v = detect.version; const vw = `${where}.version`; @@ -243,36 +248,66 @@ export function compareVersions(a, b) { return 0; } -function isExecutable(file, platform) { +const FS = { statSync, lstatSync, readlinkSync, accessSync }; + +// The target of App Installer's Python "install me" stub, which opens the Store +// instead of running Python, and the alias names it takes. +const PYTHON_STUB_TARGET = "appinstallerpythonredirector.exe"; +const PYTHON_STUB_NAMES = new Set(["python", "python3"]); + +// A Windows App Execution Alias, how a Store or winget app reaches PATH, is a +// reparse point in a WindowsApps directory that stat cannot open, yet it runs. +// It counts as found unless it is the Python stub, known by its target or, when +// the target cannot be read, by its name. +const isWindowsApps = (dir) => path.basename(dir).toLowerCase() === "windowsapps"; + +function isAppAlias(file, fs) { + if (!isWindowsApps(path.dirname(file))) return false; try { - if (!statSync(file).isFile()) return false; - if (platform !== "win32") accessSync(file, constants.X_OK); - return true; + if (!fs.lstatSync(file).isSymbolicLink()) return false; } catch { return false; } + let target; + try { + target = fs.readlinkSync(file); + } catch { + return !PYTHON_STUB_NAMES.has(path.basename(file).toLowerCase().replace(/\.exe$/, "")); + } + return path.win32.basename(target).toLowerCase() !== PYTHON_STUB_TARGET; +} + +function isExecutable(file, platform, fs = FS) { + try { + if (!fs.statSync(file).isFile()) return false; + if (platform !== "win32") fs.accessSync(file, constants.X_OK); + return true; + } catch { + return platform === "win32" && isAppAlias(file, fs); + } } -// which(name) -> absolute path of the first PATH match, or null. -export function which(name, env = process.env, platform = process.platform) { +// which(name) -> absolute path of the first PATH match, or null. fs is injectable for tests. +// rejectStoreAlias skips WindowsApps directories on win32, so no App Execution Alias matches. +export function which(name, env = process.env, platform = process.platform, fs = FS, rejectStoreAlias = false) { const pathVar = env.PATH ?? env.Path ?? ""; const exts = platform === "win32" ? ["", ...(env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean)] : [""]; for (const dir of pathVar.split(path.delimiter)) { - if (!dir) continue; + if (!dir || (rejectStoreAlias && platform === "win32" && isWindowsApps(dir))) continue; for (const ext of exts) { const candidate = path.join(dir, name + ext); - if (isExecutable(candidate, platform)) return candidate; + if (isExecutable(candidate, platform, fs)) return candidate; } } return null; } -function findLocalBin(rels, cwd, platform) { +function findLocalBin(rels, cwd, platform, fs) { for (const rel of rels ?? []) { let dir = path.resolve(cwd); for (let i = 0; i < LOCAL_BIN_DEPTH; i++) { const candidate = path.join(dir, rel); - if (isExecutable(candidate, platform)) return candidate; + if (isExecutable(candidate, platform, fs)) return candidate; const parent = path.dirname(dir); if (parent === dir) break; dir = parent; @@ -286,9 +321,9 @@ function run(file, args) { return { ok: !r.error && r.status === 0, out: `${r.stdout ?? ""}${r.stderr ?? ""}` }; } -function firstOnPath(names, ctx) { +function firstOnPath(names, ctx, rejectStoreAlias = false) { for (const name of names) { - const found = which(name, ctx.env, ctx.platform); + const found = which(name, ctx.env, ctx.platform, ctx.fs, rejectStoreAlias); if (found) return found; } return null; @@ -314,7 +349,7 @@ export function detectEntry(entry, ctx) { switch (entry.kind) { case "cli": case "runtime": { - const found = firstOnPath(d.any, ctx) ?? findLocalBin(d.local_bin, ctx.cwd, ctx.platform); + const found = firstOnPath(d.any, ctx, d.reject_store_alias) ?? findLocalBin(d.local_bin, ctx.cwd, ctx.platform, ctx.fs); if (!found) { const where = d.local_bin ? `on PATH or as ${d.local_bin.join(", ")}` : "on PATH"; return { status: "missing", detail: `${d.any.join(" or ")} was not found ${where}` }; @@ -331,7 +366,7 @@ export function detectEntry(entry, ctx) { } case "system-lib": { const [bin, ...args] = d.probe.args; - const found = which(bin, ctx.env, ctx.platform); + const found = which(bin, ctx.env, ctx.platform, ctx.fs); if (!found) return { status: "missing", detail: `${bin} was not found on PATH to probe` }; if (new RegExp(d.probe.pattern).test(run(found, args).out)) return { status: "present", detail: found }; return { status: "missing", detail: `${bin} ${args.join(" ")} did not report ${d.probe.pattern}` }; @@ -566,6 +601,7 @@ export function main(argv, ctx = {}, out = {}) { env: ctx.env ?? process.env, platform: ctx.platform ?? process.platform, cwd: ctx.cwd ?? process.cwd(), + fs: ctx.fs ?? FS, stdin: ctx.stdin ?? (() => (process.stdin.isTTY ? "" : readFileSync(0, "utf8"))), }; const io = { stdout: out.stdout ?? ((s) => console.log(s)), stderr: out.stderr ?? ((s) => console.error(s)) }; diff --git a/plugins/review/.claude-plugin/plugin.json b/plugins/review/.claude-plugin/plugin.json index 3f992eeb33..72e2032381 100644 --- a/plugins/review/.claude-plugin/plugin.json +++ b/plugins/review/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "review", - "version": "0.40.1", + "version": "0.40.2", "description": "Code-review toolkit: six reviewer agents, read-only over the reviewed code (code, security, architecture, doc drift, build/test/lint, CI-log audit), plus orchestration skills for the quality gate, fan-out, and enforceability audit (/review:audit-enforceability), a pull-request change digest with an interactive view (/review:explain-change), a fan-out sweep workflow (/review:fanout-sweep), and CI lane commands (/review:code-review, /review:security-review) for org reusable workflows.", "author": { "name": "Melodic Software", diff --git a/plugins/review/CHANGELOG.md b/plugins/review/CHANGELOG.md index c2704e5d0a..5e7122b9e2 100644 --- a/plugins/review/CHANGELOG.md +++ b/plugins/review/CHANGELOG.md @@ -3,6 +3,14 @@ All notable changes to the `review` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.40.2] - 2026-10-03 + +### Changed + +- **Shared `prerequisites.mjs` synced ([#6084](https://github.com/melodic-software/claude-code-plugins/issues/6084)); no change to this plugin's lib.** + The prerequisite check now counts a Windows App Execution Alias (a Store or winget install on PATH) as found, + except App Installer's Python install stub. A `cli` or `runtime` entry can set `reject_store_alias` to skip aliases instead; no entry in this plugin does. + ## [0.40.1] - 2026-10-03 ### Changed diff --git a/plugins/review/lib/prerequisites.mjs b/plugins/review/lib/prerequisites.mjs index 57f65a79b5..1e11abf2cc 100755 --- a/plugins/review/lib/prerequisites.mjs +++ b/plugins/review/lib/prerequisites.mjs @@ -28,8 +28,10 @@ import { accessSync, constants, existsSync, + lstatSync, mkdirSync, readFileSync, + readlinkSync, realpathSync, statSync, writeFileSync, @@ -42,8 +44,8 @@ export const KINDS = ["cli", "runtime", "system-lib", "python-pkg", "node-pkg", export const NEEDS = ["required", "optional"]; const ENTRY_KEYS = ["id", "kind", "need", "for", "detect", "degrade", "install", "check"]; const DETECT_KEYS = { - cli: { required: ["any"], optional: ["local_bin", "version"] }, - runtime: { required: ["any"], optional: ["local_bin", "version"] }, + cli: { required: ["any"], optional: ["local_bin", "version", "reject_store_alias"] }, + runtime: { required: ["any"], optional: ["local_bin", "version", "reject_store_alias"] }, "system-lib": { required: ["probe"], optional: [] }, "python-pkg": { required: ["any", "import"], optional: [] }, "node-pkg": { required: ["module", "paths"], optional: [] }, @@ -100,6 +102,9 @@ function detectErrors(kind, detect, where) { } if ("any" in detect && !isTextList(detect.any)) errors.push(`${where}.any must be a non-empty list of names`); if ("local_bin" in detect) errors.push(...relativePathErrors(detect.local_bin, `${where}.local_bin`)); + if ("reject_store_alias" in detect && typeof detect.reject_store_alias !== "boolean") { + errors.push(`${where}.reject_store_alias must be true or false`); + } if ("version" in detect) { const v = detect.version; const vw = `${where}.version`; @@ -243,36 +248,66 @@ export function compareVersions(a, b) { return 0; } -function isExecutable(file, platform) { +const FS = { statSync, lstatSync, readlinkSync, accessSync }; + +// The target of App Installer's Python "install me" stub, which opens the Store +// instead of running Python, and the alias names it takes. +const PYTHON_STUB_TARGET = "appinstallerpythonredirector.exe"; +const PYTHON_STUB_NAMES = new Set(["python", "python3"]); + +// A Windows App Execution Alias, how a Store or winget app reaches PATH, is a +// reparse point in a WindowsApps directory that stat cannot open, yet it runs. +// It counts as found unless it is the Python stub, known by its target or, when +// the target cannot be read, by its name. +const isWindowsApps = (dir) => path.basename(dir).toLowerCase() === "windowsapps"; + +function isAppAlias(file, fs) { + if (!isWindowsApps(path.dirname(file))) return false; try { - if (!statSync(file).isFile()) return false; - if (platform !== "win32") accessSync(file, constants.X_OK); - return true; + if (!fs.lstatSync(file).isSymbolicLink()) return false; } catch { return false; } + let target; + try { + target = fs.readlinkSync(file); + } catch { + return !PYTHON_STUB_NAMES.has(path.basename(file).toLowerCase().replace(/\.exe$/, "")); + } + return path.win32.basename(target).toLowerCase() !== PYTHON_STUB_TARGET; +} + +function isExecutable(file, platform, fs = FS) { + try { + if (!fs.statSync(file).isFile()) return false; + if (platform !== "win32") fs.accessSync(file, constants.X_OK); + return true; + } catch { + return platform === "win32" && isAppAlias(file, fs); + } } -// which(name) -> absolute path of the first PATH match, or null. -export function which(name, env = process.env, platform = process.platform) { +// which(name) -> absolute path of the first PATH match, or null. fs is injectable for tests. +// rejectStoreAlias skips WindowsApps directories on win32, so no App Execution Alias matches. +export function which(name, env = process.env, platform = process.platform, fs = FS, rejectStoreAlias = false) { const pathVar = env.PATH ?? env.Path ?? ""; const exts = platform === "win32" ? ["", ...(env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean)] : [""]; for (const dir of pathVar.split(path.delimiter)) { - if (!dir) continue; + if (!dir || (rejectStoreAlias && platform === "win32" && isWindowsApps(dir))) continue; for (const ext of exts) { const candidate = path.join(dir, name + ext); - if (isExecutable(candidate, platform)) return candidate; + if (isExecutable(candidate, platform, fs)) return candidate; } } return null; } -function findLocalBin(rels, cwd, platform) { +function findLocalBin(rels, cwd, platform, fs) { for (const rel of rels ?? []) { let dir = path.resolve(cwd); for (let i = 0; i < LOCAL_BIN_DEPTH; i++) { const candidate = path.join(dir, rel); - if (isExecutable(candidate, platform)) return candidate; + if (isExecutable(candidate, platform, fs)) return candidate; const parent = path.dirname(dir); if (parent === dir) break; dir = parent; @@ -286,9 +321,9 @@ function run(file, args) { return { ok: !r.error && r.status === 0, out: `${r.stdout ?? ""}${r.stderr ?? ""}` }; } -function firstOnPath(names, ctx) { +function firstOnPath(names, ctx, rejectStoreAlias = false) { for (const name of names) { - const found = which(name, ctx.env, ctx.platform); + const found = which(name, ctx.env, ctx.platform, ctx.fs, rejectStoreAlias); if (found) return found; } return null; @@ -314,7 +349,7 @@ export function detectEntry(entry, ctx) { switch (entry.kind) { case "cli": case "runtime": { - const found = firstOnPath(d.any, ctx) ?? findLocalBin(d.local_bin, ctx.cwd, ctx.platform); + const found = firstOnPath(d.any, ctx, d.reject_store_alias) ?? findLocalBin(d.local_bin, ctx.cwd, ctx.platform, ctx.fs); if (!found) { const where = d.local_bin ? `on PATH or as ${d.local_bin.join(", ")}` : "on PATH"; return { status: "missing", detail: `${d.any.join(" or ")} was not found ${where}` }; @@ -331,7 +366,7 @@ export function detectEntry(entry, ctx) { } case "system-lib": { const [bin, ...args] = d.probe.args; - const found = which(bin, ctx.env, ctx.platform); + const found = which(bin, ctx.env, ctx.platform, ctx.fs); if (!found) return { status: "missing", detail: `${bin} was not found on PATH to probe` }; if (new RegExp(d.probe.pattern).test(run(found, args).out)) return { status: "present", detail: found }; return { status: "missing", detail: `${bin} ${args.join(" ")} did not report ${d.probe.pattern}` }; @@ -566,6 +601,7 @@ export function main(argv, ctx = {}, out = {}) { env: ctx.env ?? process.env, platform: ctx.platform ?? process.platform, cwd: ctx.cwd ?? process.cwd(), + fs: ctx.fs ?? FS, stdin: ctx.stdin ?? (() => (process.stdin.isTTY ? "" : readFileSync(0, "utf8"))), }; const io = { stdout: out.stdout ?? ((s) => console.log(s)), stderr: out.stderr ?? ((s) => console.error(s)) }; diff --git a/plugins/ruff-format/.claude-plugin/plugin.json b/plugins/ruff-format/.claude-plugin/plugin.json index df7d905a1a..50ee9fc6cc 100644 --- a/plugins/ruff-format/.claude-plugin/plugin.json +++ b/plugins/ruff-format/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "ruff-format", - "version": "0.9.1", + "version": "0.9.2", "description": "Auto-format and lint Python on edit via Ruff, only when a Ruff config governs the repo, using the consuming repo's own Ruff config.", "author": { "name": "Melodic Software", diff --git a/plugins/ruff-format/CHANGELOG.md b/plugins/ruff-format/CHANGELOG.md index 8c50f6e3a9..fdf22d999b 100644 --- a/plugins/ruff-format/CHANGELOG.md +++ b/plugins/ruff-format/CHANGELOG.md @@ -3,6 +3,14 @@ All notable changes to the `ruff-format` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.9.2] - 2026-10-03 + +### Changed + +- **Shared `prerequisites.mjs` synced ([#6084](https://github.com/melodic-software/claude-code-plugins/issues/6084)); no change to this plugin's lib.** + The prerequisite check now counts a Windows App Execution Alias (a Store or winget install on PATH) as found, + except App Installer's Python install stub. A `cli` or `runtime` entry can set `reject_store_alias` to skip aliases instead; no entry in this plugin does. + ## [0.9.1] - 2026-10-03 ### Changed diff --git a/plugins/ruff-format/lib/prerequisites.mjs b/plugins/ruff-format/lib/prerequisites.mjs index 57f65a79b5..1e11abf2cc 100755 --- a/plugins/ruff-format/lib/prerequisites.mjs +++ b/plugins/ruff-format/lib/prerequisites.mjs @@ -28,8 +28,10 @@ import { accessSync, constants, existsSync, + lstatSync, mkdirSync, readFileSync, + readlinkSync, realpathSync, statSync, writeFileSync, @@ -42,8 +44,8 @@ export const KINDS = ["cli", "runtime", "system-lib", "python-pkg", "node-pkg", export const NEEDS = ["required", "optional"]; const ENTRY_KEYS = ["id", "kind", "need", "for", "detect", "degrade", "install", "check"]; const DETECT_KEYS = { - cli: { required: ["any"], optional: ["local_bin", "version"] }, - runtime: { required: ["any"], optional: ["local_bin", "version"] }, + cli: { required: ["any"], optional: ["local_bin", "version", "reject_store_alias"] }, + runtime: { required: ["any"], optional: ["local_bin", "version", "reject_store_alias"] }, "system-lib": { required: ["probe"], optional: [] }, "python-pkg": { required: ["any", "import"], optional: [] }, "node-pkg": { required: ["module", "paths"], optional: [] }, @@ -100,6 +102,9 @@ function detectErrors(kind, detect, where) { } if ("any" in detect && !isTextList(detect.any)) errors.push(`${where}.any must be a non-empty list of names`); if ("local_bin" in detect) errors.push(...relativePathErrors(detect.local_bin, `${where}.local_bin`)); + if ("reject_store_alias" in detect && typeof detect.reject_store_alias !== "boolean") { + errors.push(`${where}.reject_store_alias must be true or false`); + } if ("version" in detect) { const v = detect.version; const vw = `${where}.version`; @@ -243,36 +248,66 @@ export function compareVersions(a, b) { return 0; } -function isExecutable(file, platform) { +const FS = { statSync, lstatSync, readlinkSync, accessSync }; + +// The target of App Installer's Python "install me" stub, which opens the Store +// instead of running Python, and the alias names it takes. +const PYTHON_STUB_TARGET = "appinstallerpythonredirector.exe"; +const PYTHON_STUB_NAMES = new Set(["python", "python3"]); + +// A Windows App Execution Alias, how a Store or winget app reaches PATH, is a +// reparse point in a WindowsApps directory that stat cannot open, yet it runs. +// It counts as found unless it is the Python stub, known by its target or, when +// the target cannot be read, by its name. +const isWindowsApps = (dir) => path.basename(dir).toLowerCase() === "windowsapps"; + +function isAppAlias(file, fs) { + if (!isWindowsApps(path.dirname(file))) return false; try { - if (!statSync(file).isFile()) return false; - if (platform !== "win32") accessSync(file, constants.X_OK); - return true; + if (!fs.lstatSync(file).isSymbolicLink()) return false; } catch { return false; } + let target; + try { + target = fs.readlinkSync(file); + } catch { + return !PYTHON_STUB_NAMES.has(path.basename(file).toLowerCase().replace(/\.exe$/, "")); + } + return path.win32.basename(target).toLowerCase() !== PYTHON_STUB_TARGET; +} + +function isExecutable(file, platform, fs = FS) { + try { + if (!fs.statSync(file).isFile()) return false; + if (platform !== "win32") fs.accessSync(file, constants.X_OK); + return true; + } catch { + return platform === "win32" && isAppAlias(file, fs); + } } -// which(name) -> absolute path of the first PATH match, or null. -export function which(name, env = process.env, platform = process.platform) { +// which(name) -> absolute path of the first PATH match, or null. fs is injectable for tests. +// rejectStoreAlias skips WindowsApps directories on win32, so no App Execution Alias matches. +export function which(name, env = process.env, platform = process.platform, fs = FS, rejectStoreAlias = false) { const pathVar = env.PATH ?? env.Path ?? ""; const exts = platform === "win32" ? ["", ...(env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean)] : [""]; for (const dir of pathVar.split(path.delimiter)) { - if (!dir) continue; + if (!dir || (rejectStoreAlias && platform === "win32" && isWindowsApps(dir))) continue; for (const ext of exts) { const candidate = path.join(dir, name + ext); - if (isExecutable(candidate, platform)) return candidate; + if (isExecutable(candidate, platform, fs)) return candidate; } } return null; } -function findLocalBin(rels, cwd, platform) { +function findLocalBin(rels, cwd, platform, fs) { for (const rel of rels ?? []) { let dir = path.resolve(cwd); for (let i = 0; i < LOCAL_BIN_DEPTH; i++) { const candidate = path.join(dir, rel); - if (isExecutable(candidate, platform)) return candidate; + if (isExecutable(candidate, platform, fs)) return candidate; const parent = path.dirname(dir); if (parent === dir) break; dir = parent; @@ -286,9 +321,9 @@ function run(file, args) { return { ok: !r.error && r.status === 0, out: `${r.stdout ?? ""}${r.stderr ?? ""}` }; } -function firstOnPath(names, ctx) { +function firstOnPath(names, ctx, rejectStoreAlias = false) { for (const name of names) { - const found = which(name, ctx.env, ctx.platform); + const found = which(name, ctx.env, ctx.platform, ctx.fs, rejectStoreAlias); if (found) return found; } return null; @@ -314,7 +349,7 @@ export function detectEntry(entry, ctx) { switch (entry.kind) { case "cli": case "runtime": { - const found = firstOnPath(d.any, ctx) ?? findLocalBin(d.local_bin, ctx.cwd, ctx.platform); + const found = firstOnPath(d.any, ctx, d.reject_store_alias) ?? findLocalBin(d.local_bin, ctx.cwd, ctx.platform, ctx.fs); if (!found) { const where = d.local_bin ? `on PATH or as ${d.local_bin.join(", ")}` : "on PATH"; return { status: "missing", detail: `${d.any.join(" or ")} was not found ${where}` }; @@ -331,7 +366,7 @@ export function detectEntry(entry, ctx) { } case "system-lib": { const [bin, ...args] = d.probe.args; - const found = which(bin, ctx.env, ctx.platform); + const found = which(bin, ctx.env, ctx.platform, ctx.fs); if (!found) return { status: "missing", detail: `${bin} was not found on PATH to probe` }; if (new RegExp(d.probe.pattern).test(run(found, args).out)) return { status: "present", detail: found }; return { status: "missing", detail: `${bin} ${args.join(" ")} did not report ${d.probe.pattern}` }; @@ -566,6 +601,7 @@ export function main(argv, ctx = {}, out = {}) { env: ctx.env ?? process.env, platform: ctx.platform ?? process.platform, cwd: ctx.cwd ?? process.cwd(), + fs: ctx.fs ?? FS, stdin: ctx.stdin ?? (() => (process.stdin.isTTY ? "" : readFileSync(0, "utf8"))), }; const io = { stdout: out.stdout ?? ((s) => console.log(s)), stderr: out.stderr ?? ((s) => console.error(s)) }; diff --git a/plugins/session-flow/.claude-plugin/plugin.json b/plugins/session-flow/.claude-plugin/plugin.json index 590ed8096b..1524d4fd1e 100644 --- a/plugins/session-flow/.claude-plugin/plugin.json +++ b/plugins/session-flow/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "session-flow", - "version": "0.48.2", + "version": "0.48.3", "description": "Session lifecycle: workflow (next stage), handoff (save-point, resume prompt), continue-in-background, keep-going (resume after interruption or stall), find-handoff (recover a lost handoff), clean-stop (durable stopping point), retro and running-retro (retrospectives), audit-sessions, orient (where the session stands), orchestrate (delegation imperatives), reanchor (verify assumptions), reconcile (retire finished work), show-options (ranked skill menu), tidy-work (.work tiers), check, setup.", "author": { "name": "Melodic Software", diff --git a/plugins/session-flow/CHANGELOG.md b/plugins/session-flow/CHANGELOG.md index 66e555d6d8..5883ece4f0 100644 --- a/plugins/session-flow/CHANGELOG.md +++ b/plugins/session-flow/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog: session-flow plugin +## [0.48.3] - 2026-10-03 + +### Changed + +- **Shared `prerequisites.mjs` synced ([#6084](https://github.com/melodic-software/claude-code-plugins/issues/6084)); no change to this plugin's lib.** + The prerequisite check now counts a Windows App Execution Alias (a Store or winget install on PATH) as found, + except App Installer's Python install stub. A `cli` or `runtime` entry can set `reject_store_alias` to skip aliases instead; no entry in this plugin does. + ## [0.48.2] - 2026-10-03 ### Changed diff --git a/plugins/session-flow/lib/prerequisites.mjs b/plugins/session-flow/lib/prerequisites.mjs index 57f65a79b5..1e11abf2cc 100755 --- a/plugins/session-flow/lib/prerequisites.mjs +++ b/plugins/session-flow/lib/prerequisites.mjs @@ -28,8 +28,10 @@ import { accessSync, constants, existsSync, + lstatSync, mkdirSync, readFileSync, + readlinkSync, realpathSync, statSync, writeFileSync, @@ -42,8 +44,8 @@ export const KINDS = ["cli", "runtime", "system-lib", "python-pkg", "node-pkg", export const NEEDS = ["required", "optional"]; const ENTRY_KEYS = ["id", "kind", "need", "for", "detect", "degrade", "install", "check"]; const DETECT_KEYS = { - cli: { required: ["any"], optional: ["local_bin", "version"] }, - runtime: { required: ["any"], optional: ["local_bin", "version"] }, + cli: { required: ["any"], optional: ["local_bin", "version", "reject_store_alias"] }, + runtime: { required: ["any"], optional: ["local_bin", "version", "reject_store_alias"] }, "system-lib": { required: ["probe"], optional: [] }, "python-pkg": { required: ["any", "import"], optional: [] }, "node-pkg": { required: ["module", "paths"], optional: [] }, @@ -100,6 +102,9 @@ function detectErrors(kind, detect, where) { } if ("any" in detect && !isTextList(detect.any)) errors.push(`${where}.any must be a non-empty list of names`); if ("local_bin" in detect) errors.push(...relativePathErrors(detect.local_bin, `${where}.local_bin`)); + if ("reject_store_alias" in detect && typeof detect.reject_store_alias !== "boolean") { + errors.push(`${where}.reject_store_alias must be true or false`); + } if ("version" in detect) { const v = detect.version; const vw = `${where}.version`; @@ -243,36 +248,66 @@ export function compareVersions(a, b) { return 0; } -function isExecutable(file, platform) { +const FS = { statSync, lstatSync, readlinkSync, accessSync }; + +// The target of App Installer's Python "install me" stub, which opens the Store +// instead of running Python, and the alias names it takes. +const PYTHON_STUB_TARGET = "appinstallerpythonredirector.exe"; +const PYTHON_STUB_NAMES = new Set(["python", "python3"]); + +// A Windows App Execution Alias, how a Store or winget app reaches PATH, is a +// reparse point in a WindowsApps directory that stat cannot open, yet it runs. +// It counts as found unless it is the Python stub, known by its target or, when +// the target cannot be read, by its name. +const isWindowsApps = (dir) => path.basename(dir).toLowerCase() === "windowsapps"; + +function isAppAlias(file, fs) { + if (!isWindowsApps(path.dirname(file))) return false; try { - if (!statSync(file).isFile()) return false; - if (platform !== "win32") accessSync(file, constants.X_OK); - return true; + if (!fs.lstatSync(file).isSymbolicLink()) return false; } catch { return false; } + let target; + try { + target = fs.readlinkSync(file); + } catch { + return !PYTHON_STUB_NAMES.has(path.basename(file).toLowerCase().replace(/\.exe$/, "")); + } + return path.win32.basename(target).toLowerCase() !== PYTHON_STUB_TARGET; +} + +function isExecutable(file, platform, fs = FS) { + try { + if (!fs.statSync(file).isFile()) return false; + if (platform !== "win32") fs.accessSync(file, constants.X_OK); + return true; + } catch { + return platform === "win32" && isAppAlias(file, fs); + } } -// which(name) -> absolute path of the first PATH match, or null. -export function which(name, env = process.env, platform = process.platform) { +// which(name) -> absolute path of the first PATH match, or null. fs is injectable for tests. +// rejectStoreAlias skips WindowsApps directories on win32, so no App Execution Alias matches. +export function which(name, env = process.env, platform = process.platform, fs = FS, rejectStoreAlias = false) { const pathVar = env.PATH ?? env.Path ?? ""; const exts = platform === "win32" ? ["", ...(env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean)] : [""]; for (const dir of pathVar.split(path.delimiter)) { - if (!dir) continue; + if (!dir || (rejectStoreAlias && platform === "win32" && isWindowsApps(dir))) continue; for (const ext of exts) { const candidate = path.join(dir, name + ext); - if (isExecutable(candidate, platform)) return candidate; + if (isExecutable(candidate, platform, fs)) return candidate; } } return null; } -function findLocalBin(rels, cwd, platform) { +function findLocalBin(rels, cwd, platform, fs) { for (const rel of rels ?? []) { let dir = path.resolve(cwd); for (let i = 0; i < LOCAL_BIN_DEPTH; i++) { const candidate = path.join(dir, rel); - if (isExecutable(candidate, platform)) return candidate; + if (isExecutable(candidate, platform, fs)) return candidate; const parent = path.dirname(dir); if (parent === dir) break; dir = parent; @@ -286,9 +321,9 @@ function run(file, args) { return { ok: !r.error && r.status === 0, out: `${r.stdout ?? ""}${r.stderr ?? ""}` }; } -function firstOnPath(names, ctx) { +function firstOnPath(names, ctx, rejectStoreAlias = false) { for (const name of names) { - const found = which(name, ctx.env, ctx.platform); + const found = which(name, ctx.env, ctx.platform, ctx.fs, rejectStoreAlias); if (found) return found; } return null; @@ -314,7 +349,7 @@ export function detectEntry(entry, ctx) { switch (entry.kind) { case "cli": case "runtime": { - const found = firstOnPath(d.any, ctx) ?? findLocalBin(d.local_bin, ctx.cwd, ctx.platform); + const found = firstOnPath(d.any, ctx, d.reject_store_alias) ?? findLocalBin(d.local_bin, ctx.cwd, ctx.platform, ctx.fs); if (!found) { const where = d.local_bin ? `on PATH or as ${d.local_bin.join(", ")}` : "on PATH"; return { status: "missing", detail: `${d.any.join(" or ")} was not found ${where}` }; @@ -331,7 +366,7 @@ export function detectEntry(entry, ctx) { } case "system-lib": { const [bin, ...args] = d.probe.args; - const found = which(bin, ctx.env, ctx.platform); + const found = which(bin, ctx.env, ctx.platform, ctx.fs); if (!found) return { status: "missing", detail: `${bin} was not found on PATH to probe` }; if (new RegExp(d.probe.pattern).test(run(found, args).out)) return { status: "present", detail: found }; return { status: "missing", detail: `${bin} ${args.join(" ")} did not report ${d.probe.pattern}` }; @@ -566,6 +601,7 @@ export function main(argv, ctx = {}, out = {}) { env: ctx.env ?? process.env, platform: ctx.platform ?? process.platform, cwd: ctx.cwd ?? process.cwd(), + fs: ctx.fs ?? FS, stdin: ctx.stdin ?? (() => (process.stdin.isTTY ? "" : readFileSync(0, "utf8"))), }; const io = { stdout: out.stdout ?? ((s) => console.log(s)), stderr: out.stderr ?? ((s) => console.error(s)) }; diff --git a/plugins/skill-quality/.claude-plugin/plugin.json b/plugins/skill-quality/.claude-plugin/plugin.json index 3d5ada2196..035913bd73 100644 --- a/plugins/skill-quality/.claude-plugin/plugin.json +++ b/plugins/skill-quality/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "skill-quality", - "version": "0.28.0", + "version": "0.28.1", "description": "Skill-authoring QA tooling (skill-quality:check): a static contract checker running twenty-six deterministic checks over a Claude Code skill (frontmatter, invocation mode, description, listing cap, trigger keywords, line caps, broken refs, markdownlint, gotchas, evals), a shared skill-listing budget reporter, an evals.json schema with an eval-quality lint, and a measure-invocation harness that scores description auto-invocation probes on train and validation splits.", "author": { "name": "Melodic Software", diff --git a/plugins/skill-quality/CHANGELOG.md b/plugins/skill-quality/CHANGELOG.md index 61bba01eed..f7f8f6453d 100644 --- a/plugins/skill-quality/CHANGELOG.md +++ b/plugins/skill-quality/CHANGELOG.md @@ -3,6 +3,14 @@ All notable changes to the `skill-quality` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.28.1] - 2026-10-03 + +### Changed + +- **Shared `prerequisites.mjs` synced ([#6084](https://github.com/melodic-software/claude-code-plugins/issues/6084)); no change to this plugin's lib.** + The prerequisite check now counts a Windows App Execution Alias (a Store or winget install on PATH) as found, + except App Installer's Python install stub. A `cli` or `runtime` entry can set `reject_store_alias` to skip aliases instead; no entry in this plugin does. + ## [0.28.0] - 2026-10-03 ### Added diff --git a/plugins/skill-quality/lib/prerequisites.mjs b/plugins/skill-quality/lib/prerequisites.mjs index 57f65a79b5..1e11abf2cc 100755 --- a/plugins/skill-quality/lib/prerequisites.mjs +++ b/plugins/skill-quality/lib/prerequisites.mjs @@ -28,8 +28,10 @@ import { accessSync, constants, existsSync, + lstatSync, mkdirSync, readFileSync, + readlinkSync, realpathSync, statSync, writeFileSync, @@ -42,8 +44,8 @@ export const KINDS = ["cli", "runtime", "system-lib", "python-pkg", "node-pkg", export const NEEDS = ["required", "optional"]; const ENTRY_KEYS = ["id", "kind", "need", "for", "detect", "degrade", "install", "check"]; const DETECT_KEYS = { - cli: { required: ["any"], optional: ["local_bin", "version"] }, - runtime: { required: ["any"], optional: ["local_bin", "version"] }, + cli: { required: ["any"], optional: ["local_bin", "version", "reject_store_alias"] }, + runtime: { required: ["any"], optional: ["local_bin", "version", "reject_store_alias"] }, "system-lib": { required: ["probe"], optional: [] }, "python-pkg": { required: ["any", "import"], optional: [] }, "node-pkg": { required: ["module", "paths"], optional: [] }, @@ -100,6 +102,9 @@ function detectErrors(kind, detect, where) { } if ("any" in detect && !isTextList(detect.any)) errors.push(`${where}.any must be a non-empty list of names`); if ("local_bin" in detect) errors.push(...relativePathErrors(detect.local_bin, `${where}.local_bin`)); + if ("reject_store_alias" in detect && typeof detect.reject_store_alias !== "boolean") { + errors.push(`${where}.reject_store_alias must be true or false`); + } if ("version" in detect) { const v = detect.version; const vw = `${where}.version`; @@ -243,36 +248,66 @@ export function compareVersions(a, b) { return 0; } -function isExecutable(file, platform) { +const FS = { statSync, lstatSync, readlinkSync, accessSync }; + +// The target of App Installer's Python "install me" stub, which opens the Store +// instead of running Python, and the alias names it takes. +const PYTHON_STUB_TARGET = "appinstallerpythonredirector.exe"; +const PYTHON_STUB_NAMES = new Set(["python", "python3"]); + +// A Windows App Execution Alias, how a Store or winget app reaches PATH, is a +// reparse point in a WindowsApps directory that stat cannot open, yet it runs. +// It counts as found unless it is the Python stub, known by its target or, when +// the target cannot be read, by its name. +const isWindowsApps = (dir) => path.basename(dir).toLowerCase() === "windowsapps"; + +function isAppAlias(file, fs) { + if (!isWindowsApps(path.dirname(file))) return false; try { - if (!statSync(file).isFile()) return false; - if (platform !== "win32") accessSync(file, constants.X_OK); - return true; + if (!fs.lstatSync(file).isSymbolicLink()) return false; } catch { return false; } + let target; + try { + target = fs.readlinkSync(file); + } catch { + return !PYTHON_STUB_NAMES.has(path.basename(file).toLowerCase().replace(/\.exe$/, "")); + } + return path.win32.basename(target).toLowerCase() !== PYTHON_STUB_TARGET; +} + +function isExecutable(file, platform, fs = FS) { + try { + if (!fs.statSync(file).isFile()) return false; + if (platform !== "win32") fs.accessSync(file, constants.X_OK); + return true; + } catch { + return platform === "win32" && isAppAlias(file, fs); + } } -// which(name) -> absolute path of the first PATH match, or null. -export function which(name, env = process.env, platform = process.platform) { +// which(name) -> absolute path of the first PATH match, or null. fs is injectable for tests. +// rejectStoreAlias skips WindowsApps directories on win32, so no App Execution Alias matches. +export function which(name, env = process.env, platform = process.platform, fs = FS, rejectStoreAlias = false) { const pathVar = env.PATH ?? env.Path ?? ""; const exts = platform === "win32" ? ["", ...(env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean)] : [""]; for (const dir of pathVar.split(path.delimiter)) { - if (!dir) continue; + if (!dir || (rejectStoreAlias && platform === "win32" && isWindowsApps(dir))) continue; for (const ext of exts) { const candidate = path.join(dir, name + ext); - if (isExecutable(candidate, platform)) return candidate; + if (isExecutable(candidate, platform, fs)) return candidate; } } return null; } -function findLocalBin(rels, cwd, platform) { +function findLocalBin(rels, cwd, platform, fs) { for (const rel of rels ?? []) { let dir = path.resolve(cwd); for (let i = 0; i < LOCAL_BIN_DEPTH; i++) { const candidate = path.join(dir, rel); - if (isExecutable(candidate, platform)) return candidate; + if (isExecutable(candidate, platform, fs)) return candidate; const parent = path.dirname(dir); if (parent === dir) break; dir = parent; @@ -286,9 +321,9 @@ function run(file, args) { return { ok: !r.error && r.status === 0, out: `${r.stdout ?? ""}${r.stderr ?? ""}` }; } -function firstOnPath(names, ctx) { +function firstOnPath(names, ctx, rejectStoreAlias = false) { for (const name of names) { - const found = which(name, ctx.env, ctx.platform); + const found = which(name, ctx.env, ctx.platform, ctx.fs, rejectStoreAlias); if (found) return found; } return null; @@ -314,7 +349,7 @@ export function detectEntry(entry, ctx) { switch (entry.kind) { case "cli": case "runtime": { - const found = firstOnPath(d.any, ctx) ?? findLocalBin(d.local_bin, ctx.cwd, ctx.platform); + const found = firstOnPath(d.any, ctx, d.reject_store_alias) ?? findLocalBin(d.local_bin, ctx.cwd, ctx.platform, ctx.fs); if (!found) { const where = d.local_bin ? `on PATH or as ${d.local_bin.join(", ")}` : "on PATH"; return { status: "missing", detail: `${d.any.join(" or ")} was not found ${where}` }; @@ -331,7 +366,7 @@ export function detectEntry(entry, ctx) { } case "system-lib": { const [bin, ...args] = d.probe.args; - const found = which(bin, ctx.env, ctx.platform); + const found = which(bin, ctx.env, ctx.platform, ctx.fs); if (!found) return { status: "missing", detail: `${bin} was not found on PATH to probe` }; if (new RegExp(d.probe.pattern).test(run(found, args).out)) return { status: "present", detail: found }; return { status: "missing", detail: `${bin} ${args.join(" ")} did not report ${d.probe.pattern}` }; @@ -566,6 +601,7 @@ export function main(argv, ctx = {}, out = {}) { env: ctx.env ?? process.env, platform: ctx.platform ?? process.platform, cwd: ctx.cwd ?? process.cwd(), + fs: ctx.fs ?? FS, stdin: ctx.stdin ?? (() => (process.stdin.isTTY ? "" : readFileSync(0, "utf8"))), }; const io = { stdout: out.stdout ?? ((s) => console.log(s)), stderr: out.stderr ?? ((s) => console.error(s)) }; diff --git a/plugins/songwriting/.claude-plugin/plugin.json b/plugins/songwriting/.claude-plugin/plugin.json index 5026b9100d..d974b45ba5 100644 --- a/plugins/songwriting/.claude-plugin/plugin.json +++ b/plugins/songwriting/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "songwriting", - "version": "1.6.2", + "version": "1.6.3", "description": "Songwriting craft companion: nine concern-scoped lyric-craft skills (workflow router, rhyme, object-writing, metaphor, meter-prosody, song-form, co-write, diagnose, practice) applying Pat Pattison's methods, with an object-writing agent that performs the sensory exercise itself and per-skill emission boundaries that route generation to the skill that owns it, plus Suno v5.5 prompt engineering (style prompts, tagged lyrics, genre templates, troubleshooting).", "author": { "name": "Melodic Software", diff --git a/plugins/songwriting/CHANGELOG.md b/plugins/songwriting/CHANGELOG.md index fc9cea6ea5..c02eedc452 100644 --- a/plugins/songwriting/CHANGELOG.md +++ b/plugins/songwriting/CHANGELOG.md @@ -3,6 +3,14 @@ All notable changes to the `songwriting` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [1.6.3] - 2026-10-03 + +### Changed + +- **Shared `prerequisites.mjs` synced ([#6084](https://github.com/melodic-software/claude-code-plugins/issues/6084)); no change to this plugin's lib.** + The prerequisite check now counts a Windows App Execution Alias (a Store or winget install on PATH) as found, + except App Installer's Python install stub. A `cli` or `runtime` entry can set `reject_store_alias` to skip aliases instead; no entry in this plugin does. + ## [1.6.2] - 2026-10-03 ### Changed diff --git a/plugins/songwriting/lib/prerequisites.mjs b/plugins/songwriting/lib/prerequisites.mjs index 57f65a79b5..1e11abf2cc 100755 --- a/plugins/songwriting/lib/prerequisites.mjs +++ b/plugins/songwriting/lib/prerequisites.mjs @@ -28,8 +28,10 @@ import { accessSync, constants, existsSync, + lstatSync, mkdirSync, readFileSync, + readlinkSync, realpathSync, statSync, writeFileSync, @@ -42,8 +44,8 @@ export const KINDS = ["cli", "runtime", "system-lib", "python-pkg", "node-pkg", export const NEEDS = ["required", "optional"]; const ENTRY_KEYS = ["id", "kind", "need", "for", "detect", "degrade", "install", "check"]; const DETECT_KEYS = { - cli: { required: ["any"], optional: ["local_bin", "version"] }, - runtime: { required: ["any"], optional: ["local_bin", "version"] }, + cli: { required: ["any"], optional: ["local_bin", "version", "reject_store_alias"] }, + runtime: { required: ["any"], optional: ["local_bin", "version", "reject_store_alias"] }, "system-lib": { required: ["probe"], optional: [] }, "python-pkg": { required: ["any", "import"], optional: [] }, "node-pkg": { required: ["module", "paths"], optional: [] }, @@ -100,6 +102,9 @@ function detectErrors(kind, detect, where) { } if ("any" in detect && !isTextList(detect.any)) errors.push(`${where}.any must be a non-empty list of names`); if ("local_bin" in detect) errors.push(...relativePathErrors(detect.local_bin, `${where}.local_bin`)); + if ("reject_store_alias" in detect && typeof detect.reject_store_alias !== "boolean") { + errors.push(`${where}.reject_store_alias must be true or false`); + } if ("version" in detect) { const v = detect.version; const vw = `${where}.version`; @@ -243,36 +248,66 @@ export function compareVersions(a, b) { return 0; } -function isExecutable(file, platform) { +const FS = { statSync, lstatSync, readlinkSync, accessSync }; + +// The target of App Installer's Python "install me" stub, which opens the Store +// instead of running Python, and the alias names it takes. +const PYTHON_STUB_TARGET = "appinstallerpythonredirector.exe"; +const PYTHON_STUB_NAMES = new Set(["python", "python3"]); + +// A Windows App Execution Alias, how a Store or winget app reaches PATH, is a +// reparse point in a WindowsApps directory that stat cannot open, yet it runs. +// It counts as found unless it is the Python stub, known by its target or, when +// the target cannot be read, by its name. +const isWindowsApps = (dir) => path.basename(dir).toLowerCase() === "windowsapps"; + +function isAppAlias(file, fs) { + if (!isWindowsApps(path.dirname(file))) return false; try { - if (!statSync(file).isFile()) return false; - if (platform !== "win32") accessSync(file, constants.X_OK); - return true; + if (!fs.lstatSync(file).isSymbolicLink()) return false; } catch { return false; } + let target; + try { + target = fs.readlinkSync(file); + } catch { + return !PYTHON_STUB_NAMES.has(path.basename(file).toLowerCase().replace(/\.exe$/, "")); + } + return path.win32.basename(target).toLowerCase() !== PYTHON_STUB_TARGET; +} + +function isExecutable(file, platform, fs = FS) { + try { + if (!fs.statSync(file).isFile()) return false; + if (platform !== "win32") fs.accessSync(file, constants.X_OK); + return true; + } catch { + return platform === "win32" && isAppAlias(file, fs); + } } -// which(name) -> absolute path of the first PATH match, or null. -export function which(name, env = process.env, platform = process.platform) { +// which(name) -> absolute path of the first PATH match, or null. fs is injectable for tests. +// rejectStoreAlias skips WindowsApps directories on win32, so no App Execution Alias matches. +export function which(name, env = process.env, platform = process.platform, fs = FS, rejectStoreAlias = false) { const pathVar = env.PATH ?? env.Path ?? ""; const exts = platform === "win32" ? ["", ...(env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean)] : [""]; for (const dir of pathVar.split(path.delimiter)) { - if (!dir) continue; + if (!dir || (rejectStoreAlias && platform === "win32" && isWindowsApps(dir))) continue; for (const ext of exts) { const candidate = path.join(dir, name + ext); - if (isExecutable(candidate, platform)) return candidate; + if (isExecutable(candidate, platform, fs)) return candidate; } } return null; } -function findLocalBin(rels, cwd, platform) { +function findLocalBin(rels, cwd, platform, fs) { for (const rel of rels ?? []) { let dir = path.resolve(cwd); for (let i = 0; i < LOCAL_BIN_DEPTH; i++) { const candidate = path.join(dir, rel); - if (isExecutable(candidate, platform)) return candidate; + if (isExecutable(candidate, platform, fs)) return candidate; const parent = path.dirname(dir); if (parent === dir) break; dir = parent; @@ -286,9 +321,9 @@ function run(file, args) { return { ok: !r.error && r.status === 0, out: `${r.stdout ?? ""}${r.stderr ?? ""}` }; } -function firstOnPath(names, ctx) { +function firstOnPath(names, ctx, rejectStoreAlias = false) { for (const name of names) { - const found = which(name, ctx.env, ctx.platform); + const found = which(name, ctx.env, ctx.platform, ctx.fs, rejectStoreAlias); if (found) return found; } return null; @@ -314,7 +349,7 @@ export function detectEntry(entry, ctx) { switch (entry.kind) { case "cli": case "runtime": { - const found = firstOnPath(d.any, ctx) ?? findLocalBin(d.local_bin, ctx.cwd, ctx.platform); + const found = firstOnPath(d.any, ctx, d.reject_store_alias) ?? findLocalBin(d.local_bin, ctx.cwd, ctx.platform, ctx.fs); if (!found) { const where = d.local_bin ? `on PATH or as ${d.local_bin.join(", ")}` : "on PATH"; return { status: "missing", detail: `${d.any.join(" or ")} was not found ${where}` }; @@ -331,7 +366,7 @@ export function detectEntry(entry, ctx) { } case "system-lib": { const [bin, ...args] = d.probe.args; - const found = which(bin, ctx.env, ctx.platform); + const found = which(bin, ctx.env, ctx.platform, ctx.fs); if (!found) return { status: "missing", detail: `${bin} was not found on PATH to probe` }; if (new RegExp(d.probe.pattern).test(run(found, args).out)) return { status: "present", detail: found }; return { status: "missing", detail: `${bin} ${args.join(" ")} did not report ${d.probe.pattern}` }; @@ -566,6 +601,7 @@ export function main(argv, ctx = {}, out = {}) { env: ctx.env ?? process.env, platform: ctx.platform ?? process.platform, cwd: ctx.cwd ?? process.cwd(), + fs: ctx.fs ?? FS, stdin: ctx.stdin ?? (() => (process.stdin.isTTY ? "" : readFileSync(0, "utf8"))), }; const io = { stdout: out.stdout ?? ((s) => console.log(s)), stderr: out.stderr ?? ((s) => console.error(s)) }; diff --git a/plugins/source-control/.claude-plugin/plugin.json b/plugins/source-control/.claude-plugin/plugin.json index 5907e31727..b7391187e6 100644 --- a/plugins/source-control/.claude-plugin/plugin.json +++ b/plugins/source-control/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "source-control", - "version": "0.79.3", + "version": "0.79.4", "description": "Git and GitHub delivery: /commit (convention-checked subject, Co-authored-by trailer, surgical staging), /pull-request (prep, create, CI monitoring, review triage, merge, CI logs), /babysit-prs (safe-by-default PR fleet loop, opt-in worker and autopilot tiers), /babysit-loop (merge lane; merge is human until the repo adopts it), /worktree, /resolve-conflicts (intent-first), /check, and /setup (layered source-control.md convention config; Conventional Commits by default).", "author": { "name": "Melodic Software", diff --git a/plugins/source-control/CHANGELOG.md b/plugins/source-control/CHANGELOG.md index 1b13c0c139..ff0218b089 100644 --- a/plugins/source-control/CHANGELOG.md +++ b/plugins/source-control/CHANGELOG.md @@ -3,6 +3,14 @@ All notable changes to the `source-control` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.79.4] - 2026-10-03 + +### Changed + +- **Shared `prerequisites.mjs` synced ([#6084](https://github.com/melodic-software/claude-code-plugins/issues/6084)); no change to this plugin's lib.** + The prerequisite check now counts a Windows App Execution Alias (a Store or winget install on PATH) as found, + except App Installer's Python install stub. A `cli` or `runtime` entry can set `reject_store_alias` to skip aliases instead; no entry in this plugin does. + ## [0.79.3] - 2026-10-03 ### Changed diff --git a/plugins/source-control/lib/prerequisites.mjs b/plugins/source-control/lib/prerequisites.mjs index 57f65a79b5..1e11abf2cc 100755 --- a/plugins/source-control/lib/prerequisites.mjs +++ b/plugins/source-control/lib/prerequisites.mjs @@ -28,8 +28,10 @@ import { accessSync, constants, existsSync, + lstatSync, mkdirSync, readFileSync, + readlinkSync, realpathSync, statSync, writeFileSync, @@ -42,8 +44,8 @@ export const KINDS = ["cli", "runtime", "system-lib", "python-pkg", "node-pkg", export const NEEDS = ["required", "optional"]; const ENTRY_KEYS = ["id", "kind", "need", "for", "detect", "degrade", "install", "check"]; const DETECT_KEYS = { - cli: { required: ["any"], optional: ["local_bin", "version"] }, - runtime: { required: ["any"], optional: ["local_bin", "version"] }, + cli: { required: ["any"], optional: ["local_bin", "version", "reject_store_alias"] }, + runtime: { required: ["any"], optional: ["local_bin", "version", "reject_store_alias"] }, "system-lib": { required: ["probe"], optional: [] }, "python-pkg": { required: ["any", "import"], optional: [] }, "node-pkg": { required: ["module", "paths"], optional: [] }, @@ -100,6 +102,9 @@ function detectErrors(kind, detect, where) { } if ("any" in detect && !isTextList(detect.any)) errors.push(`${where}.any must be a non-empty list of names`); if ("local_bin" in detect) errors.push(...relativePathErrors(detect.local_bin, `${where}.local_bin`)); + if ("reject_store_alias" in detect && typeof detect.reject_store_alias !== "boolean") { + errors.push(`${where}.reject_store_alias must be true or false`); + } if ("version" in detect) { const v = detect.version; const vw = `${where}.version`; @@ -243,36 +248,66 @@ export function compareVersions(a, b) { return 0; } -function isExecutable(file, platform) { +const FS = { statSync, lstatSync, readlinkSync, accessSync }; + +// The target of App Installer's Python "install me" stub, which opens the Store +// instead of running Python, and the alias names it takes. +const PYTHON_STUB_TARGET = "appinstallerpythonredirector.exe"; +const PYTHON_STUB_NAMES = new Set(["python", "python3"]); + +// A Windows App Execution Alias, how a Store or winget app reaches PATH, is a +// reparse point in a WindowsApps directory that stat cannot open, yet it runs. +// It counts as found unless it is the Python stub, known by its target or, when +// the target cannot be read, by its name. +const isWindowsApps = (dir) => path.basename(dir).toLowerCase() === "windowsapps"; + +function isAppAlias(file, fs) { + if (!isWindowsApps(path.dirname(file))) return false; try { - if (!statSync(file).isFile()) return false; - if (platform !== "win32") accessSync(file, constants.X_OK); - return true; + if (!fs.lstatSync(file).isSymbolicLink()) return false; } catch { return false; } + let target; + try { + target = fs.readlinkSync(file); + } catch { + return !PYTHON_STUB_NAMES.has(path.basename(file).toLowerCase().replace(/\.exe$/, "")); + } + return path.win32.basename(target).toLowerCase() !== PYTHON_STUB_TARGET; +} + +function isExecutable(file, platform, fs = FS) { + try { + if (!fs.statSync(file).isFile()) return false; + if (platform !== "win32") fs.accessSync(file, constants.X_OK); + return true; + } catch { + return platform === "win32" && isAppAlias(file, fs); + } } -// which(name) -> absolute path of the first PATH match, or null. -export function which(name, env = process.env, platform = process.platform) { +// which(name) -> absolute path of the first PATH match, or null. fs is injectable for tests. +// rejectStoreAlias skips WindowsApps directories on win32, so no App Execution Alias matches. +export function which(name, env = process.env, platform = process.platform, fs = FS, rejectStoreAlias = false) { const pathVar = env.PATH ?? env.Path ?? ""; const exts = platform === "win32" ? ["", ...(env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean)] : [""]; for (const dir of pathVar.split(path.delimiter)) { - if (!dir) continue; + if (!dir || (rejectStoreAlias && platform === "win32" && isWindowsApps(dir))) continue; for (const ext of exts) { const candidate = path.join(dir, name + ext); - if (isExecutable(candidate, platform)) return candidate; + if (isExecutable(candidate, platform, fs)) return candidate; } } return null; } -function findLocalBin(rels, cwd, platform) { +function findLocalBin(rels, cwd, platform, fs) { for (const rel of rels ?? []) { let dir = path.resolve(cwd); for (let i = 0; i < LOCAL_BIN_DEPTH; i++) { const candidate = path.join(dir, rel); - if (isExecutable(candidate, platform)) return candidate; + if (isExecutable(candidate, platform, fs)) return candidate; const parent = path.dirname(dir); if (parent === dir) break; dir = parent; @@ -286,9 +321,9 @@ function run(file, args) { return { ok: !r.error && r.status === 0, out: `${r.stdout ?? ""}${r.stderr ?? ""}` }; } -function firstOnPath(names, ctx) { +function firstOnPath(names, ctx, rejectStoreAlias = false) { for (const name of names) { - const found = which(name, ctx.env, ctx.platform); + const found = which(name, ctx.env, ctx.platform, ctx.fs, rejectStoreAlias); if (found) return found; } return null; @@ -314,7 +349,7 @@ export function detectEntry(entry, ctx) { switch (entry.kind) { case "cli": case "runtime": { - const found = firstOnPath(d.any, ctx) ?? findLocalBin(d.local_bin, ctx.cwd, ctx.platform); + const found = firstOnPath(d.any, ctx, d.reject_store_alias) ?? findLocalBin(d.local_bin, ctx.cwd, ctx.platform, ctx.fs); if (!found) { const where = d.local_bin ? `on PATH or as ${d.local_bin.join(", ")}` : "on PATH"; return { status: "missing", detail: `${d.any.join(" or ")} was not found ${where}` }; @@ -331,7 +366,7 @@ export function detectEntry(entry, ctx) { } case "system-lib": { const [bin, ...args] = d.probe.args; - const found = which(bin, ctx.env, ctx.platform); + const found = which(bin, ctx.env, ctx.platform, ctx.fs); if (!found) return { status: "missing", detail: `${bin} was not found on PATH to probe` }; if (new RegExp(d.probe.pattern).test(run(found, args).out)) return { status: "present", detail: found }; return { status: "missing", detail: `${bin} ${args.join(" ")} did not report ${d.probe.pattern}` }; @@ -566,6 +601,7 @@ export function main(argv, ctx = {}, out = {}) { env: ctx.env ?? process.env, platform: ctx.platform ?? process.platform, cwd: ctx.cwd ?? process.cwd(), + fs: ctx.fs ?? FS, stdin: ctx.stdin ?? (() => (process.stdin.isTTY ? "" : readFileSync(0, "utf8"))), }; const io = { stdout: out.stdout ?? ((s) => console.log(s)), stderr: out.stderr ?? ((s) => console.error(s)) }; diff --git a/plugins/speech/.claude-plugin/plugin.json b/plugins/speech/.claude-plugin/plugin.json index 16470089e1..3cffd96255 100644 --- a/plugins/speech/.claude-plugin/plugin.json +++ b/plugins/speech/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "speech", - "version": "0.2.1", + "version": "0.2.2", "description": "Text-to-speech narration. The narrate skill turns a script into narration.wav plus words.json, a start and end time for every word. The default kokoro backend runs Kokoro-82M locally with onnxruntime. The optional elevenlabs backend sends the text to the third-party ElevenLabs API (ELEVENLABS_API_KEY) after showing a cost estimate; an organization can forbid it. You install espeak-ng (GPL-3.0). A hook installs the Python packages; setup downloads the models; check reports missing prerequisites.", "author": { "name": "Melodic Software", diff --git a/plugins/speech/CHANGELOG.md b/plugins/speech/CHANGELOG.md index c6c1f37cfa..4510a82a7e 100644 --- a/plugins/speech/CHANGELOG.md +++ b/plugins/speech/CHANGELOG.md @@ -3,6 +3,14 @@ All notable changes to the `speech` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.2.2] - 2026-10-03 + +### Changed + +- **Shared `prerequisites.mjs` synced ([#6084](https://github.com/melodic-software/claude-code-plugins/issues/6084)); no change to this plugin's lib.** + The prerequisite check now counts a Windows App Execution Alias (a Store or winget install on PATH) as found, + except App Installer's Python install stub. A `cli` or `runtime` entry can set `reject_store_alias` to skip aliases instead; no entry in this plugin does. + ## [0.2.1] - 2026-10-03 ### Changed diff --git a/plugins/speech/lib/prerequisites.mjs b/plugins/speech/lib/prerequisites.mjs index 57f65a79b5..1e11abf2cc 100755 --- a/plugins/speech/lib/prerequisites.mjs +++ b/plugins/speech/lib/prerequisites.mjs @@ -28,8 +28,10 @@ import { accessSync, constants, existsSync, + lstatSync, mkdirSync, readFileSync, + readlinkSync, realpathSync, statSync, writeFileSync, @@ -42,8 +44,8 @@ export const KINDS = ["cli", "runtime", "system-lib", "python-pkg", "node-pkg", export const NEEDS = ["required", "optional"]; const ENTRY_KEYS = ["id", "kind", "need", "for", "detect", "degrade", "install", "check"]; const DETECT_KEYS = { - cli: { required: ["any"], optional: ["local_bin", "version"] }, - runtime: { required: ["any"], optional: ["local_bin", "version"] }, + cli: { required: ["any"], optional: ["local_bin", "version", "reject_store_alias"] }, + runtime: { required: ["any"], optional: ["local_bin", "version", "reject_store_alias"] }, "system-lib": { required: ["probe"], optional: [] }, "python-pkg": { required: ["any", "import"], optional: [] }, "node-pkg": { required: ["module", "paths"], optional: [] }, @@ -100,6 +102,9 @@ function detectErrors(kind, detect, where) { } if ("any" in detect && !isTextList(detect.any)) errors.push(`${where}.any must be a non-empty list of names`); if ("local_bin" in detect) errors.push(...relativePathErrors(detect.local_bin, `${where}.local_bin`)); + if ("reject_store_alias" in detect && typeof detect.reject_store_alias !== "boolean") { + errors.push(`${where}.reject_store_alias must be true or false`); + } if ("version" in detect) { const v = detect.version; const vw = `${where}.version`; @@ -243,36 +248,66 @@ export function compareVersions(a, b) { return 0; } -function isExecutable(file, platform) { +const FS = { statSync, lstatSync, readlinkSync, accessSync }; + +// The target of App Installer's Python "install me" stub, which opens the Store +// instead of running Python, and the alias names it takes. +const PYTHON_STUB_TARGET = "appinstallerpythonredirector.exe"; +const PYTHON_STUB_NAMES = new Set(["python", "python3"]); + +// A Windows App Execution Alias, how a Store or winget app reaches PATH, is a +// reparse point in a WindowsApps directory that stat cannot open, yet it runs. +// It counts as found unless it is the Python stub, known by its target or, when +// the target cannot be read, by its name. +const isWindowsApps = (dir) => path.basename(dir).toLowerCase() === "windowsapps"; + +function isAppAlias(file, fs) { + if (!isWindowsApps(path.dirname(file))) return false; try { - if (!statSync(file).isFile()) return false; - if (platform !== "win32") accessSync(file, constants.X_OK); - return true; + if (!fs.lstatSync(file).isSymbolicLink()) return false; } catch { return false; } + let target; + try { + target = fs.readlinkSync(file); + } catch { + return !PYTHON_STUB_NAMES.has(path.basename(file).toLowerCase().replace(/\.exe$/, "")); + } + return path.win32.basename(target).toLowerCase() !== PYTHON_STUB_TARGET; +} + +function isExecutable(file, platform, fs = FS) { + try { + if (!fs.statSync(file).isFile()) return false; + if (platform !== "win32") fs.accessSync(file, constants.X_OK); + return true; + } catch { + return platform === "win32" && isAppAlias(file, fs); + } } -// which(name) -> absolute path of the first PATH match, or null. -export function which(name, env = process.env, platform = process.platform) { +// which(name) -> absolute path of the first PATH match, or null. fs is injectable for tests. +// rejectStoreAlias skips WindowsApps directories on win32, so no App Execution Alias matches. +export function which(name, env = process.env, platform = process.platform, fs = FS, rejectStoreAlias = false) { const pathVar = env.PATH ?? env.Path ?? ""; const exts = platform === "win32" ? ["", ...(env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean)] : [""]; for (const dir of pathVar.split(path.delimiter)) { - if (!dir) continue; + if (!dir || (rejectStoreAlias && platform === "win32" && isWindowsApps(dir))) continue; for (const ext of exts) { const candidate = path.join(dir, name + ext); - if (isExecutable(candidate, platform)) return candidate; + if (isExecutable(candidate, platform, fs)) return candidate; } } return null; } -function findLocalBin(rels, cwd, platform) { +function findLocalBin(rels, cwd, platform, fs) { for (const rel of rels ?? []) { let dir = path.resolve(cwd); for (let i = 0; i < LOCAL_BIN_DEPTH; i++) { const candidate = path.join(dir, rel); - if (isExecutable(candidate, platform)) return candidate; + if (isExecutable(candidate, platform, fs)) return candidate; const parent = path.dirname(dir); if (parent === dir) break; dir = parent; @@ -286,9 +321,9 @@ function run(file, args) { return { ok: !r.error && r.status === 0, out: `${r.stdout ?? ""}${r.stderr ?? ""}` }; } -function firstOnPath(names, ctx) { +function firstOnPath(names, ctx, rejectStoreAlias = false) { for (const name of names) { - const found = which(name, ctx.env, ctx.platform); + const found = which(name, ctx.env, ctx.platform, ctx.fs, rejectStoreAlias); if (found) return found; } return null; @@ -314,7 +349,7 @@ export function detectEntry(entry, ctx) { switch (entry.kind) { case "cli": case "runtime": { - const found = firstOnPath(d.any, ctx) ?? findLocalBin(d.local_bin, ctx.cwd, ctx.platform); + const found = firstOnPath(d.any, ctx, d.reject_store_alias) ?? findLocalBin(d.local_bin, ctx.cwd, ctx.platform, ctx.fs); if (!found) { const where = d.local_bin ? `on PATH or as ${d.local_bin.join(", ")}` : "on PATH"; return { status: "missing", detail: `${d.any.join(" or ")} was not found ${where}` }; @@ -331,7 +366,7 @@ export function detectEntry(entry, ctx) { } case "system-lib": { const [bin, ...args] = d.probe.args; - const found = which(bin, ctx.env, ctx.platform); + const found = which(bin, ctx.env, ctx.platform, ctx.fs); if (!found) return { status: "missing", detail: `${bin} was not found on PATH to probe` }; if (new RegExp(d.probe.pattern).test(run(found, args).out)) return { status: "present", detail: found }; return { status: "missing", detail: `${bin} ${args.join(" ")} did not report ${d.probe.pattern}` }; @@ -566,6 +601,7 @@ export function main(argv, ctx = {}, out = {}) { env: ctx.env ?? process.env, platform: ctx.platform ?? process.platform, cwd: ctx.cwd ?? process.cwd(), + fs: ctx.fs ?? FS, stdin: ctx.stdin ?? (() => (process.stdin.isTTY ? "" : readFileSync(0, "utf8"))), }; const io = { stdout: out.stdout ?? ((s) => console.log(s)), stderr: out.stderr ?? ((s) => console.error(s)) }; diff --git a/plugins/testing/.claude-plugin/plugin.json b/plugins/testing/.claude-plugin/plugin.json index 86533e2bc1..d8c5ba90cb 100644 --- a/plugins/testing/.claude-plugin/plugin.json +++ b/plugins/testing/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "testing", - "version": "0.22.2", + "version": "0.22.3", "description": "Test-stage discipline: /testing:plan (coverage gaps, test plans), /testing:write (TDD authoring), /testing:run-e2e (live E2E and smoke checks), /testing:diagnose (failing-test root cause, with a fix-until-green workflow across files), /testing:audit (can't-fail test audit with a fail-closed gate), /testing:cleanup (rewrite, quarantine, or delete low-value tests behind a mutation gate), /testing:setup, and opt-in hooks that scan test files Claude writes and flag edits that weaken tests.", "author": { "name": "Melodic Software", diff --git a/plugins/testing/CHANGELOG.md b/plugins/testing/CHANGELOG.md index 03bf1e8364..c47b5d455d 100644 --- a/plugins/testing/CHANGELOG.md +++ b/plugins/testing/CHANGELOG.md @@ -3,6 +3,14 @@ All notable changes to the `testing` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.22.3] - 2026-10-03 + +### Changed + +- **Shared `prerequisites.mjs` synced ([#6084](https://github.com/melodic-software/claude-code-plugins/issues/6084)); no change to this plugin's lib.** + The prerequisite check now counts a Windows App Execution Alias (a Store or winget install on PATH) as found, + except App Installer's Python install stub. A `cli` or `runtime` entry can set `reject_store_alias` to skip aliases instead; no entry in this plugin does. + ## [0.22.2] - 2026-10-03 ### Changed diff --git a/plugins/testing/lib/prerequisites.mjs b/plugins/testing/lib/prerequisites.mjs index 57f65a79b5..1e11abf2cc 100755 --- a/plugins/testing/lib/prerequisites.mjs +++ b/plugins/testing/lib/prerequisites.mjs @@ -28,8 +28,10 @@ import { accessSync, constants, existsSync, + lstatSync, mkdirSync, readFileSync, + readlinkSync, realpathSync, statSync, writeFileSync, @@ -42,8 +44,8 @@ export const KINDS = ["cli", "runtime", "system-lib", "python-pkg", "node-pkg", export const NEEDS = ["required", "optional"]; const ENTRY_KEYS = ["id", "kind", "need", "for", "detect", "degrade", "install", "check"]; const DETECT_KEYS = { - cli: { required: ["any"], optional: ["local_bin", "version"] }, - runtime: { required: ["any"], optional: ["local_bin", "version"] }, + cli: { required: ["any"], optional: ["local_bin", "version", "reject_store_alias"] }, + runtime: { required: ["any"], optional: ["local_bin", "version", "reject_store_alias"] }, "system-lib": { required: ["probe"], optional: [] }, "python-pkg": { required: ["any", "import"], optional: [] }, "node-pkg": { required: ["module", "paths"], optional: [] }, @@ -100,6 +102,9 @@ function detectErrors(kind, detect, where) { } if ("any" in detect && !isTextList(detect.any)) errors.push(`${where}.any must be a non-empty list of names`); if ("local_bin" in detect) errors.push(...relativePathErrors(detect.local_bin, `${where}.local_bin`)); + if ("reject_store_alias" in detect && typeof detect.reject_store_alias !== "boolean") { + errors.push(`${where}.reject_store_alias must be true or false`); + } if ("version" in detect) { const v = detect.version; const vw = `${where}.version`; @@ -243,36 +248,66 @@ export function compareVersions(a, b) { return 0; } -function isExecutable(file, platform) { +const FS = { statSync, lstatSync, readlinkSync, accessSync }; + +// The target of App Installer's Python "install me" stub, which opens the Store +// instead of running Python, and the alias names it takes. +const PYTHON_STUB_TARGET = "appinstallerpythonredirector.exe"; +const PYTHON_STUB_NAMES = new Set(["python", "python3"]); + +// A Windows App Execution Alias, how a Store or winget app reaches PATH, is a +// reparse point in a WindowsApps directory that stat cannot open, yet it runs. +// It counts as found unless it is the Python stub, known by its target or, when +// the target cannot be read, by its name. +const isWindowsApps = (dir) => path.basename(dir).toLowerCase() === "windowsapps"; + +function isAppAlias(file, fs) { + if (!isWindowsApps(path.dirname(file))) return false; try { - if (!statSync(file).isFile()) return false; - if (platform !== "win32") accessSync(file, constants.X_OK); - return true; + if (!fs.lstatSync(file).isSymbolicLink()) return false; } catch { return false; } + let target; + try { + target = fs.readlinkSync(file); + } catch { + return !PYTHON_STUB_NAMES.has(path.basename(file).toLowerCase().replace(/\.exe$/, "")); + } + return path.win32.basename(target).toLowerCase() !== PYTHON_STUB_TARGET; +} + +function isExecutable(file, platform, fs = FS) { + try { + if (!fs.statSync(file).isFile()) return false; + if (platform !== "win32") fs.accessSync(file, constants.X_OK); + return true; + } catch { + return platform === "win32" && isAppAlias(file, fs); + } } -// which(name) -> absolute path of the first PATH match, or null. -export function which(name, env = process.env, platform = process.platform) { +// which(name) -> absolute path of the first PATH match, or null. fs is injectable for tests. +// rejectStoreAlias skips WindowsApps directories on win32, so no App Execution Alias matches. +export function which(name, env = process.env, platform = process.platform, fs = FS, rejectStoreAlias = false) { const pathVar = env.PATH ?? env.Path ?? ""; const exts = platform === "win32" ? ["", ...(env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean)] : [""]; for (const dir of pathVar.split(path.delimiter)) { - if (!dir) continue; + if (!dir || (rejectStoreAlias && platform === "win32" && isWindowsApps(dir))) continue; for (const ext of exts) { const candidate = path.join(dir, name + ext); - if (isExecutable(candidate, platform)) return candidate; + if (isExecutable(candidate, platform, fs)) return candidate; } } return null; } -function findLocalBin(rels, cwd, platform) { +function findLocalBin(rels, cwd, platform, fs) { for (const rel of rels ?? []) { let dir = path.resolve(cwd); for (let i = 0; i < LOCAL_BIN_DEPTH; i++) { const candidate = path.join(dir, rel); - if (isExecutable(candidate, platform)) return candidate; + if (isExecutable(candidate, platform, fs)) return candidate; const parent = path.dirname(dir); if (parent === dir) break; dir = parent; @@ -286,9 +321,9 @@ function run(file, args) { return { ok: !r.error && r.status === 0, out: `${r.stdout ?? ""}${r.stderr ?? ""}` }; } -function firstOnPath(names, ctx) { +function firstOnPath(names, ctx, rejectStoreAlias = false) { for (const name of names) { - const found = which(name, ctx.env, ctx.platform); + const found = which(name, ctx.env, ctx.platform, ctx.fs, rejectStoreAlias); if (found) return found; } return null; @@ -314,7 +349,7 @@ export function detectEntry(entry, ctx) { switch (entry.kind) { case "cli": case "runtime": { - const found = firstOnPath(d.any, ctx) ?? findLocalBin(d.local_bin, ctx.cwd, ctx.platform); + const found = firstOnPath(d.any, ctx, d.reject_store_alias) ?? findLocalBin(d.local_bin, ctx.cwd, ctx.platform, ctx.fs); if (!found) { const where = d.local_bin ? `on PATH or as ${d.local_bin.join(", ")}` : "on PATH"; return { status: "missing", detail: `${d.any.join(" or ")} was not found ${where}` }; @@ -331,7 +366,7 @@ export function detectEntry(entry, ctx) { } case "system-lib": { const [bin, ...args] = d.probe.args; - const found = which(bin, ctx.env, ctx.platform); + const found = which(bin, ctx.env, ctx.platform, ctx.fs); if (!found) return { status: "missing", detail: `${bin} was not found on PATH to probe` }; if (new RegExp(d.probe.pattern).test(run(found, args).out)) return { status: "present", detail: found }; return { status: "missing", detail: `${bin} ${args.join(" ")} did not report ${d.probe.pattern}` }; @@ -566,6 +601,7 @@ export function main(argv, ctx = {}, out = {}) { env: ctx.env ?? process.env, platform: ctx.platform ?? process.platform, cwd: ctx.cwd ?? process.cwd(), + fs: ctx.fs ?? FS, stdin: ctx.stdin ?? (() => (process.stdin.isTTY ? "" : readFileSync(0, "utf8"))), }; const io = { stdout: out.stdout ?? ((s) => console.log(s)), stderr: out.stderr ?? ((s) => console.error(s)) }; diff --git a/plugins/toolchain/.claude-plugin/plugin.json b/plugins/toolchain/.claude-plugin/plugin.json index c9a235934d..51dd062307 100644 --- a/plugins/toolchain/.claude-plugin/plugin.json +++ b/plugins/toolchain/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "toolchain", - "version": "0.15.0", + "version": "0.15.1", "description": "Repo-agnostic polyglot verification toolchain: build + test + lint for changed files across .NET, Python, TypeScript, Bash, PowerShell, Markdown, Go, YAML, and cross-cutting surfaces (`/toolchain:check`, `/toolchain:lint` with format-only `--fix` and gated `--code-fix`), plus a re-runnable `/toolchain:setup` with check (report the configured ecosystems and their command surface) and apply (interview, infer, and write the tracked per-ecosystem command config those skills resolve first).", "author": { "name": "Melodic Software", diff --git a/plugins/toolchain/CHANGELOG.md b/plugins/toolchain/CHANGELOG.md index 7cf9c84834..5582380d61 100644 --- a/plugins/toolchain/CHANGELOG.md +++ b/plugins/toolchain/CHANGELOG.md @@ -3,6 +3,14 @@ All notable changes to the `toolchain` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.15.1] - 2026-10-03 + +### Changed + +- **Shared `prerequisites.mjs` synced ([#6084](https://github.com/melodic-software/claude-code-plugins/issues/6084)); no change to this plugin's lib.** + The prerequisite check now counts a Windows App Execution Alias (a Store or winget install on PATH) as found, + except App Installer's Python install stub. A `cli` or `runtime` entry can set `reject_store_alias` to skip aliases instead; no entry in this plugin does. + ## [0.15.0] - 2026-10-03 ### Added diff --git a/plugins/toolchain/lib/prerequisites.mjs b/plugins/toolchain/lib/prerequisites.mjs index 57f65a79b5..1e11abf2cc 100755 --- a/plugins/toolchain/lib/prerequisites.mjs +++ b/plugins/toolchain/lib/prerequisites.mjs @@ -28,8 +28,10 @@ import { accessSync, constants, existsSync, + lstatSync, mkdirSync, readFileSync, + readlinkSync, realpathSync, statSync, writeFileSync, @@ -42,8 +44,8 @@ export const KINDS = ["cli", "runtime", "system-lib", "python-pkg", "node-pkg", export const NEEDS = ["required", "optional"]; const ENTRY_KEYS = ["id", "kind", "need", "for", "detect", "degrade", "install", "check"]; const DETECT_KEYS = { - cli: { required: ["any"], optional: ["local_bin", "version"] }, - runtime: { required: ["any"], optional: ["local_bin", "version"] }, + cli: { required: ["any"], optional: ["local_bin", "version", "reject_store_alias"] }, + runtime: { required: ["any"], optional: ["local_bin", "version", "reject_store_alias"] }, "system-lib": { required: ["probe"], optional: [] }, "python-pkg": { required: ["any", "import"], optional: [] }, "node-pkg": { required: ["module", "paths"], optional: [] }, @@ -100,6 +102,9 @@ function detectErrors(kind, detect, where) { } if ("any" in detect && !isTextList(detect.any)) errors.push(`${where}.any must be a non-empty list of names`); if ("local_bin" in detect) errors.push(...relativePathErrors(detect.local_bin, `${where}.local_bin`)); + if ("reject_store_alias" in detect && typeof detect.reject_store_alias !== "boolean") { + errors.push(`${where}.reject_store_alias must be true or false`); + } if ("version" in detect) { const v = detect.version; const vw = `${where}.version`; @@ -243,36 +248,66 @@ export function compareVersions(a, b) { return 0; } -function isExecutable(file, platform) { +const FS = { statSync, lstatSync, readlinkSync, accessSync }; + +// The target of App Installer's Python "install me" stub, which opens the Store +// instead of running Python, and the alias names it takes. +const PYTHON_STUB_TARGET = "appinstallerpythonredirector.exe"; +const PYTHON_STUB_NAMES = new Set(["python", "python3"]); + +// A Windows App Execution Alias, how a Store or winget app reaches PATH, is a +// reparse point in a WindowsApps directory that stat cannot open, yet it runs. +// It counts as found unless it is the Python stub, known by its target or, when +// the target cannot be read, by its name. +const isWindowsApps = (dir) => path.basename(dir).toLowerCase() === "windowsapps"; + +function isAppAlias(file, fs) { + if (!isWindowsApps(path.dirname(file))) return false; try { - if (!statSync(file).isFile()) return false; - if (platform !== "win32") accessSync(file, constants.X_OK); - return true; + if (!fs.lstatSync(file).isSymbolicLink()) return false; } catch { return false; } + let target; + try { + target = fs.readlinkSync(file); + } catch { + return !PYTHON_STUB_NAMES.has(path.basename(file).toLowerCase().replace(/\.exe$/, "")); + } + return path.win32.basename(target).toLowerCase() !== PYTHON_STUB_TARGET; +} + +function isExecutable(file, platform, fs = FS) { + try { + if (!fs.statSync(file).isFile()) return false; + if (platform !== "win32") fs.accessSync(file, constants.X_OK); + return true; + } catch { + return platform === "win32" && isAppAlias(file, fs); + } } -// which(name) -> absolute path of the first PATH match, or null. -export function which(name, env = process.env, platform = process.platform) { +// which(name) -> absolute path of the first PATH match, or null. fs is injectable for tests. +// rejectStoreAlias skips WindowsApps directories on win32, so no App Execution Alias matches. +export function which(name, env = process.env, platform = process.platform, fs = FS, rejectStoreAlias = false) { const pathVar = env.PATH ?? env.Path ?? ""; const exts = platform === "win32" ? ["", ...(env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean)] : [""]; for (const dir of pathVar.split(path.delimiter)) { - if (!dir) continue; + if (!dir || (rejectStoreAlias && platform === "win32" && isWindowsApps(dir))) continue; for (const ext of exts) { const candidate = path.join(dir, name + ext); - if (isExecutable(candidate, platform)) return candidate; + if (isExecutable(candidate, platform, fs)) return candidate; } } return null; } -function findLocalBin(rels, cwd, platform) { +function findLocalBin(rels, cwd, platform, fs) { for (const rel of rels ?? []) { let dir = path.resolve(cwd); for (let i = 0; i < LOCAL_BIN_DEPTH; i++) { const candidate = path.join(dir, rel); - if (isExecutable(candidate, platform)) return candidate; + if (isExecutable(candidate, platform, fs)) return candidate; const parent = path.dirname(dir); if (parent === dir) break; dir = parent; @@ -286,9 +321,9 @@ function run(file, args) { return { ok: !r.error && r.status === 0, out: `${r.stdout ?? ""}${r.stderr ?? ""}` }; } -function firstOnPath(names, ctx) { +function firstOnPath(names, ctx, rejectStoreAlias = false) { for (const name of names) { - const found = which(name, ctx.env, ctx.platform); + const found = which(name, ctx.env, ctx.platform, ctx.fs, rejectStoreAlias); if (found) return found; } return null; @@ -314,7 +349,7 @@ export function detectEntry(entry, ctx) { switch (entry.kind) { case "cli": case "runtime": { - const found = firstOnPath(d.any, ctx) ?? findLocalBin(d.local_bin, ctx.cwd, ctx.platform); + const found = firstOnPath(d.any, ctx, d.reject_store_alias) ?? findLocalBin(d.local_bin, ctx.cwd, ctx.platform, ctx.fs); if (!found) { const where = d.local_bin ? `on PATH or as ${d.local_bin.join(", ")}` : "on PATH"; return { status: "missing", detail: `${d.any.join(" or ")} was not found ${where}` }; @@ -331,7 +366,7 @@ export function detectEntry(entry, ctx) { } case "system-lib": { const [bin, ...args] = d.probe.args; - const found = which(bin, ctx.env, ctx.platform); + const found = which(bin, ctx.env, ctx.platform, ctx.fs); if (!found) return { status: "missing", detail: `${bin} was not found on PATH to probe` }; if (new RegExp(d.probe.pattern).test(run(found, args).out)) return { status: "present", detail: found }; return { status: "missing", detail: `${bin} ${args.join(" ")} did not report ${d.probe.pattern}` }; @@ -566,6 +601,7 @@ export function main(argv, ctx = {}, out = {}) { env: ctx.env ?? process.env, platform: ctx.platform ?? process.platform, cwd: ctx.cwd ?? process.cwd(), + fs: ctx.fs ?? FS, stdin: ctx.stdin ?? (() => (process.stdin.isTTY ? "" : readFileSync(0, "utf8"))), }; const io = { stdout: out.stdout ?? ((s) => console.log(s)), stderr: out.stderr ?? ((s) => console.error(s)) }; diff --git a/plugins/typos-format/.claude-plugin/plugin.json b/plugins/typos-format/.claude-plugin/plugin.json index 7c668bb022..d0e9481897 100644 --- a/plugins/typos-format/.claude-plugin/plugin.json +++ b/plugins/typos-format/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "typos-format", - "version": "0.9.1", + "version": "0.9.2", "description": "Spell-check on edit via typos-cli, unconditionally. Report-only by default, honoring the consuming repo's own typos configuration when one is present.", "author": { "name": "Melodic Software", diff --git a/plugins/typos-format/CHANGELOG.md b/plugins/typos-format/CHANGELOG.md index daa670fc7f..7d9155fa3b 100644 --- a/plugins/typos-format/CHANGELOG.md +++ b/plugins/typos-format/CHANGELOG.md @@ -3,6 +3,14 @@ All notable changes to the `typos-format` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.9.2] - 2026-10-03 + +### Changed + +- **Shared `prerequisites.mjs` synced ([#6084](https://github.com/melodic-software/claude-code-plugins/issues/6084)); no change to this plugin's lib.** + The prerequisite check now counts a Windows App Execution Alias (a Store or winget install on PATH) as found, + except App Installer's Python install stub. A `cli` or `runtime` entry can set `reject_store_alias` to skip aliases instead; no entry in this plugin does. + ## [0.9.1] - 2026-10-03 ### Changed diff --git a/plugins/typos-format/lib/prerequisites.mjs b/plugins/typos-format/lib/prerequisites.mjs index 57f65a79b5..1e11abf2cc 100755 --- a/plugins/typos-format/lib/prerequisites.mjs +++ b/plugins/typos-format/lib/prerequisites.mjs @@ -28,8 +28,10 @@ import { accessSync, constants, existsSync, + lstatSync, mkdirSync, readFileSync, + readlinkSync, realpathSync, statSync, writeFileSync, @@ -42,8 +44,8 @@ export const KINDS = ["cli", "runtime", "system-lib", "python-pkg", "node-pkg", export const NEEDS = ["required", "optional"]; const ENTRY_KEYS = ["id", "kind", "need", "for", "detect", "degrade", "install", "check"]; const DETECT_KEYS = { - cli: { required: ["any"], optional: ["local_bin", "version"] }, - runtime: { required: ["any"], optional: ["local_bin", "version"] }, + cli: { required: ["any"], optional: ["local_bin", "version", "reject_store_alias"] }, + runtime: { required: ["any"], optional: ["local_bin", "version", "reject_store_alias"] }, "system-lib": { required: ["probe"], optional: [] }, "python-pkg": { required: ["any", "import"], optional: [] }, "node-pkg": { required: ["module", "paths"], optional: [] }, @@ -100,6 +102,9 @@ function detectErrors(kind, detect, where) { } if ("any" in detect && !isTextList(detect.any)) errors.push(`${where}.any must be a non-empty list of names`); if ("local_bin" in detect) errors.push(...relativePathErrors(detect.local_bin, `${where}.local_bin`)); + if ("reject_store_alias" in detect && typeof detect.reject_store_alias !== "boolean") { + errors.push(`${where}.reject_store_alias must be true or false`); + } if ("version" in detect) { const v = detect.version; const vw = `${where}.version`; @@ -243,36 +248,66 @@ export function compareVersions(a, b) { return 0; } -function isExecutable(file, platform) { +const FS = { statSync, lstatSync, readlinkSync, accessSync }; + +// The target of App Installer's Python "install me" stub, which opens the Store +// instead of running Python, and the alias names it takes. +const PYTHON_STUB_TARGET = "appinstallerpythonredirector.exe"; +const PYTHON_STUB_NAMES = new Set(["python", "python3"]); + +// A Windows App Execution Alias, how a Store or winget app reaches PATH, is a +// reparse point in a WindowsApps directory that stat cannot open, yet it runs. +// It counts as found unless it is the Python stub, known by its target or, when +// the target cannot be read, by its name. +const isWindowsApps = (dir) => path.basename(dir).toLowerCase() === "windowsapps"; + +function isAppAlias(file, fs) { + if (!isWindowsApps(path.dirname(file))) return false; try { - if (!statSync(file).isFile()) return false; - if (platform !== "win32") accessSync(file, constants.X_OK); - return true; + if (!fs.lstatSync(file).isSymbolicLink()) return false; } catch { return false; } + let target; + try { + target = fs.readlinkSync(file); + } catch { + return !PYTHON_STUB_NAMES.has(path.basename(file).toLowerCase().replace(/\.exe$/, "")); + } + return path.win32.basename(target).toLowerCase() !== PYTHON_STUB_TARGET; +} + +function isExecutable(file, platform, fs = FS) { + try { + if (!fs.statSync(file).isFile()) return false; + if (platform !== "win32") fs.accessSync(file, constants.X_OK); + return true; + } catch { + return platform === "win32" && isAppAlias(file, fs); + } } -// which(name) -> absolute path of the first PATH match, or null. -export function which(name, env = process.env, platform = process.platform) { +// which(name) -> absolute path of the first PATH match, or null. fs is injectable for tests. +// rejectStoreAlias skips WindowsApps directories on win32, so no App Execution Alias matches. +export function which(name, env = process.env, platform = process.platform, fs = FS, rejectStoreAlias = false) { const pathVar = env.PATH ?? env.Path ?? ""; const exts = platform === "win32" ? ["", ...(env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean)] : [""]; for (const dir of pathVar.split(path.delimiter)) { - if (!dir) continue; + if (!dir || (rejectStoreAlias && platform === "win32" && isWindowsApps(dir))) continue; for (const ext of exts) { const candidate = path.join(dir, name + ext); - if (isExecutable(candidate, platform)) return candidate; + if (isExecutable(candidate, platform, fs)) return candidate; } } return null; } -function findLocalBin(rels, cwd, platform) { +function findLocalBin(rels, cwd, platform, fs) { for (const rel of rels ?? []) { let dir = path.resolve(cwd); for (let i = 0; i < LOCAL_BIN_DEPTH; i++) { const candidate = path.join(dir, rel); - if (isExecutable(candidate, platform)) return candidate; + if (isExecutable(candidate, platform, fs)) return candidate; const parent = path.dirname(dir); if (parent === dir) break; dir = parent; @@ -286,9 +321,9 @@ function run(file, args) { return { ok: !r.error && r.status === 0, out: `${r.stdout ?? ""}${r.stderr ?? ""}` }; } -function firstOnPath(names, ctx) { +function firstOnPath(names, ctx, rejectStoreAlias = false) { for (const name of names) { - const found = which(name, ctx.env, ctx.platform); + const found = which(name, ctx.env, ctx.platform, ctx.fs, rejectStoreAlias); if (found) return found; } return null; @@ -314,7 +349,7 @@ export function detectEntry(entry, ctx) { switch (entry.kind) { case "cli": case "runtime": { - const found = firstOnPath(d.any, ctx) ?? findLocalBin(d.local_bin, ctx.cwd, ctx.platform); + const found = firstOnPath(d.any, ctx, d.reject_store_alias) ?? findLocalBin(d.local_bin, ctx.cwd, ctx.platform, ctx.fs); if (!found) { const where = d.local_bin ? `on PATH or as ${d.local_bin.join(", ")}` : "on PATH"; return { status: "missing", detail: `${d.any.join(" or ")} was not found ${where}` }; @@ -331,7 +366,7 @@ export function detectEntry(entry, ctx) { } case "system-lib": { const [bin, ...args] = d.probe.args; - const found = which(bin, ctx.env, ctx.platform); + const found = which(bin, ctx.env, ctx.platform, ctx.fs); if (!found) return { status: "missing", detail: `${bin} was not found on PATH to probe` }; if (new RegExp(d.probe.pattern).test(run(found, args).out)) return { status: "present", detail: found }; return { status: "missing", detail: `${bin} ${args.join(" ")} did not report ${d.probe.pattern}` }; @@ -566,6 +601,7 @@ export function main(argv, ctx = {}, out = {}) { env: ctx.env ?? process.env, platform: ctx.platform ?? process.platform, cwd: ctx.cwd ?? process.cwd(), + fs: ctx.fs ?? FS, stdin: ctx.stdin ?? (() => (process.stdin.isTTY ? "" : readFileSync(0, "utf8"))), }; const io = { stdout: out.stdout ?? ((s) => console.log(s)), stderr: out.stderr ?? ((s) => console.error(s)) }; diff --git a/plugins/visualization/.claude-plugin/plugin.json b/plugins/visualization/.claude-plugin/plugin.json index dcabeed34d..bee8ccc210 100644 --- a/plugins/visualization/.claude-plugin/plugin.json +++ b/plugins/visualization/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "visualization", - "version": "0.11.0", + "version": "0.11.1", "description": "Visualization skills. visualize picks the form (mermaid diagram, markdown table, SVG/CSS chart, ASCII/Unicode art, or a rendered page) and the medium (terminal, local HTML file, or published Artifact) for what is in the conversation, asking only when the target is ambiguous. present builds a slide deck from the account's claude.ai Slides Artifact type behind a publish gate, with a markdown outline as the record. Chart craft and artifact design route to those capabilities when installed.", "author": { "name": "Melodic Software", diff --git a/plugins/visualization/CHANGELOG.md b/plugins/visualization/CHANGELOG.md index 9002aa6553..6768c64f45 100644 --- a/plugins/visualization/CHANGELOG.md +++ b/plugins/visualization/CHANGELOG.md @@ -3,6 +3,14 @@ All notable changes to the `visualization` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.11.1] - 2026-10-03 + +### Changed + +- **Shared `prerequisites.mjs` synced ([#6084](https://github.com/melodic-software/claude-code-plugins/issues/6084)); no change to this plugin's lib.** + The prerequisite check now counts a Windows App Execution Alias (a Store or winget install on PATH) as found, + except App Installer's Python install stub. A `cli` or `runtime` entry can set `reject_store_alias` to skip aliases instead; no entry in this plugin does. + ## [0.11.0] - 2026-10-03 ### Added diff --git a/plugins/visualization/lib/prerequisites.mjs b/plugins/visualization/lib/prerequisites.mjs index 57f65a79b5..1e11abf2cc 100755 --- a/plugins/visualization/lib/prerequisites.mjs +++ b/plugins/visualization/lib/prerequisites.mjs @@ -28,8 +28,10 @@ import { accessSync, constants, existsSync, + lstatSync, mkdirSync, readFileSync, + readlinkSync, realpathSync, statSync, writeFileSync, @@ -42,8 +44,8 @@ export const KINDS = ["cli", "runtime", "system-lib", "python-pkg", "node-pkg", export const NEEDS = ["required", "optional"]; const ENTRY_KEYS = ["id", "kind", "need", "for", "detect", "degrade", "install", "check"]; const DETECT_KEYS = { - cli: { required: ["any"], optional: ["local_bin", "version"] }, - runtime: { required: ["any"], optional: ["local_bin", "version"] }, + cli: { required: ["any"], optional: ["local_bin", "version", "reject_store_alias"] }, + runtime: { required: ["any"], optional: ["local_bin", "version", "reject_store_alias"] }, "system-lib": { required: ["probe"], optional: [] }, "python-pkg": { required: ["any", "import"], optional: [] }, "node-pkg": { required: ["module", "paths"], optional: [] }, @@ -100,6 +102,9 @@ function detectErrors(kind, detect, where) { } if ("any" in detect && !isTextList(detect.any)) errors.push(`${where}.any must be a non-empty list of names`); if ("local_bin" in detect) errors.push(...relativePathErrors(detect.local_bin, `${where}.local_bin`)); + if ("reject_store_alias" in detect && typeof detect.reject_store_alias !== "boolean") { + errors.push(`${where}.reject_store_alias must be true or false`); + } if ("version" in detect) { const v = detect.version; const vw = `${where}.version`; @@ -243,36 +248,66 @@ export function compareVersions(a, b) { return 0; } -function isExecutable(file, platform) { +const FS = { statSync, lstatSync, readlinkSync, accessSync }; + +// The target of App Installer's Python "install me" stub, which opens the Store +// instead of running Python, and the alias names it takes. +const PYTHON_STUB_TARGET = "appinstallerpythonredirector.exe"; +const PYTHON_STUB_NAMES = new Set(["python", "python3"]); + +// A Windows App Execution Alias, how a Store or winget app reaches PATH, is a +// reparse point in a WindowsApps directory that stat cannot open, yet it runs. +// It counts as found unless it is the Python stub, known by its target or, when +// the target cannot be read, by its name. +const isWindowsApps = (dir) => path.basename(dir).toLowerCase() === "windowsapps"; + +function isAppAlias(file, fs) { + if (!isWindowsApps(path.dirname(file))) return false; try { - if (!statSync(file).isFile()) return false; - if (platform !== "win32") accessSync(file, constants.X_OK); - return true; + if (!fs.lstatSync(file).isSymbolicLink()) return false; } catch { return false; } + let target; + try { + target = fs.readlinkSync(file); + } catch { + return !PYTHON_STUB_NAMES.has(path.basename(file).toLowerCase().replace(/\.exe$/, "")); + } + return path.win32.basename(target).toLowerCase() !== PYTHON_STUB_TARGET; +} + +function isExecutable(file, platform, fs = FS) { + try { + if (!fs.statSync(file).isFile()) return false; + if (platform !== "win32") fs.accessSync(file, constants.X_OK); + return true; + } catch { + return platform === "win32" && isAppAlias(file, fs); + } } -// which(name) -> absolute path of the first PATH match, or null. -export function which(name, env = process.env, platform = process.platform) { +// which(name) -> absolute path of the first PATH match, or null. fs is injectable for tests. +// rejectStoreAlias skips WindowsApps directories on win32, so no App Execution Alias matches. +export function which(name, env = process.env, platform = process.platform, fs = FS, rejectStoreAlias = false) { const pathVar = env.PATH ?? env.Path ?? ""; const exts = platform === "win32" ? ["", ...(env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean)] : [""]; for (const dir of pathVar.split(path.delimiter)) { - if (!dir) continue; + if (!dir || (rejectStoreAlias && platform === "win32" && isWindowsApps(dir))) continue; for (const ext of exts) { const candidate = path.join(dir, name + ext); - if (isExecutable(candidate, platform)) return candidate; + if (isExecutable(candidate, platform, fs)) return candidate; } } return null; } -function findLocalBin(rels, cwd, platform) { +function findLocalBin(rels, cwd, platform, fs) { for (const rel of rels ?? []) { let dir = path.resolve(cwd); for (let i = 0; i < LOCAL_BIN_DEPTH; i++) { const candidate = path.join(dir, rel); - if (isExecutable(candidate, platform)) return candidate; + if (isExecutable(candidate, platform, fs)) return candidate; const parent = path.dirname(dir); if (parent === dir) break; dir = parent; @@ -286,9 +321,9 @@ function run(file, args) { return { ok: !r.error && r.status === 0, out: `${r.stdout ?? ""}${r.stderr ?? ""}` }; } -function firstOnPath(names, ctx) { +function firstOnPath(names, ctx, rejectStoreAlias = false) { for (const name of names) { - const found = which(name, ctx.env, ctx.platform); + const found = which(name, ctx.env, ctx.platform, ctx.fs, rejectStoreAlias); if (found) return found; } return null; @@ -314,7 +349,7 @@ export function detectEntry(entry, ctx) { switch (entry.kind) { case "cli": case "runtime": { - const found = firstOnPath(d.any, ctx) ?? findLocalBin(d.local_bin, ctx.cwd, ctx.platform); + const found = firstOnPath(d.any, ctx, d.reject_store_alias) ?? findLocalBin(d.local_bin, ctx.cwd, ctx.platform, ctx.fs); if (!found) { const where = d.local_bin ? `on PATH or as ${d.local_bin.join(", ")}` : "on PATH"; return { status: "missing", detail: `${d.any.join(" or ")} was not found ${where}` }; @@ -331,7 +366,7 @@ export function detectEntry(entry, ctx) { } case "system-lib": { const [bin, ...args] = d.probe.args; - const found = which(bin, ctx.env, ctx.platform); + const found = which(bin, ctx.env, ctx.platform, ctx.fs); if (!found) return { status: "missing", detail: `${bin} was not found on PATH to probe` }; if (new RegExp(d.probe.pattern).test(run(found, args).out)) return { status: "present", detail: found }; return { status: "missing", detail: `${bin} ${args.join(" ")} did not report ${d.probe.pattern}` }; @@ -566,6 +601,7 @@ export function main(argv, ctx = {}, out = {}) { env: ctx.env ?? process.env, platform: ctx.platform ?? process.platform, cwd: ctx.cwd ?? process.cwd(), + fs: ctx.fs ?? FS, stdin: ctx.stdin ?? (() => (process.stdin.isTTY ? "" : readFileSync(0, "utf8"))), }; const io = { stdout: out.stdout ?? ((s) => console.log(s)), stderr: out.stderr ?? ((s) => console.error(s)) }; diff --git a/plugins/wizard/.claude-plugin/plugin.json b/plugins/wizard/.claude-plugin/plugin.json index 0e2b15c05a..40b90fd58b 100644 --- a/plugins/wizard/.claude-plugin/plugin.json +++ b/plugins/wizard/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "wizard", - "version": "0.6.1", + "version": "0.6.2", "description": "Scripts that walk a human through manual steps an agent cannot do: provisioning infrastructure or credentials, CI secrets, third-party dashboards, one-off migrations. /wizard:generate scopes stages from the repo (.env key names only), builds an interactive bash wizard on a hardened library (TTY-only prompts, hidden secret entry, 0600 .env writes), and shows the stages for approval first; the human runs it. /wizard:unattended writes a PowerShell script the human launches once.", "author": { "name": "Melodic Software", diff --git a/plugins/wizard/CHANGELOG.md b/plugins/wizard/CHANGELOG.md index 80f8e177a3..c08f8e843d 100644 --- a/plugins/wizard/CHANGELOG.md +++ b/plugins/wizard/CHANGELOG.md @@ -3,6 +3,14 @@ All notable changes to the `wizard` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.6.2] - 2026-10-03 + +### Changed + +- **Shared `prerequisites.mjs` synced ([#6084](https://github.com/melodic-software/claude-code-plugins/issues/6084)); no change to this plugin's lib.** + The prerequisite check now counts a Windows App Execution Alias (a Store or winget install on PATH) as found, + except App Installer's Python install stub. A `cli` or `runtime` entry can set `reject_store_alias` to skip aliases instead; no entry in this plugin does. + ## [0.6.1] - 2026-10-03 ### Changed diff --git a/plugins/wizard/lib/prerequisites.mjs b/plugins/wizard/lib/prerequisites.mjs index 57f65a79b5..1e11abf2cc 100755 --- a/plugins/wizard/lib/prerequisites.mjs +++ b/plugins/wizard/lib/prerequisites.mjs @@ -28,8 +28,10 @@ import { accessSync, constants, existsSync, + lstatSync, mkdirSync, readFileSync, + readlinkSync, realpathSync, statSync, writeFileSync, @@ -42,8 +44,8 @@ export const KINDS = ["cli", "runtime", "system-lib", "python-pkg", "node-pkg", export const NEEDS = ["required", "optional"]; const ENTRY_KEYS = ["id", "kind", "need", "for", "detect", "degrade", "install", "check"]; const DETECT_KEYS = { - cli: { required: ["any"], optional: ["local_bin", "version"] }, - runtime: { required: ["any"], optional: ["local_bin", "version"] }, + cli: { required: ["any"], optional: ["local_bin", "version", "reject_store_alias"] }, + runtime: { required: ["any"], optional: ["local_bin", "version", "reject_store_alias"] }, "system-lib": { required: ["probe"], optional: [] }, "python-pkg": { required: ["any", "import"], optional: [] }, "node-pkg": { required: ["module", "paths"], optional: [] }, @@ -100,6 +102,9 @@ function detectErrors(kind, detect, where) { } if ("any" in detect && !isTextList(detect.any)) errors.push(`${where}.any must be a non-empty list of names`); if ("local_bin" in detect) errors.push(...relativePathErrors(detect.local_bin, `${where}.local_bin`)); + if ("reject_store_alias" in detect && typeof detect.reject_store_alias !== "boolean") { + errors.push(`${where}.reject_store_alias must be true or false`); + } if ("version" in detect) { const v = detect.version; const vw = `${where}.version`; @@ -243,36 +248,66 @@ export function compareVersions(a, b) { return 0; } -function isExecutable(file, platform) { +const FS = { statSync, lstatSync, readlinkSync, accessSync }; + +// The target of App Installer's Python "install me" stub, which opens the Store +// instead of running Python, and the alias names it takes. +const PYTHON_STUB_TARGET = "appinstallerpythonredirector.exe"; +const PYTHON_STUB_NAMES = new Set(["python", "python3"]); + +// A Windows App Execution Alias, how a Store or winget app reaches PATH, is a +// reparse point in a WindowsApps directory that stat cannot open, yet it runs. +// It counts as found unless it is the Python stub, known by its target or, when +// the target cannot be read, by its name. +const isWindowsApps = (dir) => path.basename(dir).toLowerCase() === "windowsapps"; + +function isAppAlias(file, fs) { + if (!isWindowsApps(path.dirname(file))) return false; try { - if (!statSync(file).isFile()) return false; - if (platform !== "win32") accessSync(file, constants.X_OK); - return true; + if (!fs.lstatSync(file).isSymbolicLink()) return false; } catch { return false; } + let target; + try { + target = fs.readlinkSync(file); + } catch { + return !PYTHON_STUB_NAMES.has(path.basename(file).toLowerCase().replace(/\.exe$/, "")); + } + return path.win32.basename(target).toLowerCase() !== PYTHON_STUB_TARGET; +} + +function isExecutable(file, platform, fs = FS) { + try { + if (!fs.statSync(file).isFile()) return false; + if (platform !== "win32") fs.accessSync(file, constants.X_OK); + return true; + } catch { + return platform === "win32" && isAppAlias(file, fs); + } } -// which(name) -> absolute path of the first PATH match, or null. -export function which(name, env = process.env, platform = process.platform) { +// which(name) -> absolute path of the first PATH match, or null. fs is injectable for tests. +// rejectStoreAlias skips WindowsApps directories on win32, so no App Execution Alias matches. +export function which(name, env = process.env, platform = process.platform, fs = FS, rejectStoreAlias = false) { const pathVar = env.PATH ?? env.Path ?? ""; const exts = platform === "win32" ? ["", ...(env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean)] : [""]; for (const dir of pathVar.split(path.delimiter)) { - if (!dir) continue; + if (!dir || (rejectStoreAlias && platform === "win32" && isWindowsApps(dir))) continue; for (const ext of exts) { const candidate = path.join(dir, name + ext); - if (isExecutable(candidate, platform)) return candidate; + if (isExecutable(candidate, platform, fs)) return candidate; } } return null; } -function findLocalBin(rels, cwd, platform) { +function findLocalBin(rels, cwd, platform, fs) { for (const rel of rels ?? []) { let dir = path.resolve(cwd); for (let i = 0; i < LOCAL_BIN_DEPTH; i++) { const candidate = path.join(dir, rel); - if (isExecutable(candidate, platform)) return candidate; + if (isExecutable(candidate, platform, fs)) return candidate; const parent = path.dirname(dir); if (parent === dir) break; dir = parent; @@ -286,9 +321,9 @@ function run(file, args) { return { ok: !r.error && r.status === 0, out: `${r.stdout ?? ""}${r.stderr ?? ""}` }; } -function firstOnPath(names, ctx) { +function firstOnPath(names, ctx, rejectStoreAlias = false) { for (const name of names) { - const found = which(name, ctx.env, ctx.platform); + const found = which(name, ctx.env, ctx.platform, ctx.fs, rejectStoreAlias); if (found) return found; } return null; @@ -314,7 +349,7 @@ export function detectEntry(entry, ctx) { switch (entry.kind) { case "cli": case "runtime": { - const found = firstOnPath(d.any, ctx) ?? findLocalBin(d.local_bin, ctx.cwd, ctx.platform); + const found = firstOnPath(d.any, ctx, d.reject_store_alias) ?? findLocalBin(d.local_bin, ctx.cwd, ctx.platform, ctx.fs); if (!found) { const where = d.local_bin ? `on PATH or as ${d.local_bin.join(", ")}` : "on PATH"; return { status: "missing", detail: `${d.any.join(" or ")} was not found ${where}` }; @@ -331,7 +366,7 @@ export function detectEntry(entry, ctx) { } case "system-lib": { const [bin, ...args] = d.probe.args; - const found = which(bin, ctx.env, ctx.platform); + const found = which(bin, ctx.env, ctx.platform, ctx.fs); if (!found) return { status: "missing", detail: `${bin} was not found on PATH to probe` }; if (new RegExp(d.probe.pattern).test(run(found, args).out)) return { status: "present", detail: found }; return { status: "missing", detail: `${bin} ${args.join(" ")} did not report ${d.probe.pattern}` }; @@ -566,6 +601,7 @@ export function main(argv, ctx = {}, out = {}) { env: ctx.env ?? process.env, platform: ctx.platform ?? process.platform, cwd: ctx.cwd ?? process.cwd(), + fs: ctx.fs ?? FS, stdin: ctx.stdin ?? (() => (process.stdin.isTTY ? "" : readFileSync(0, "utf8"))), }; const io = { stdout: out.stdout ?? ((s) => console.log(s)), stderr: out.stderr ?? ((s) => console.error(s)) }; diff --git a/plugins/work-items/.claude-plugin/plugin.json b/plugins/work-items/.claude-plugin/plugin.json index 2d206d86a7..b27a1e26c5 100644 --- a/plugins/work-items/.claude-plugin/plugin.json +++ b/plugins/work-items/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "work-items", - "version": "0.48.0", + "version": "0.48.1", "description": "Work items through a provider-neutral tracker seam (github, local-markdown, jira, gitea, linear adapters): track (dashboard, creation, race-safe claims, recurring-schedule checks, stale-lease audits), scan-todos, decompose (plans into vertical-slice items), ship (route a spec container), triage (raw intake and unsolicited PRs), work, work-loop (autonomous PR-only drain), attend-queue (escalations), onboard-adapter (new tracker adapter), and setup (binds the provider).", "author": { "name": "Melodic Software", diff --git a/plugins/work-items/CHANGELOG.md b/plugins/work-items/CHANGELOG.md index ba9e822e83..09b3dbcc49 100644 --- a/plugins/work-items/CHANGELOG.md +++ b/plugins/work-items/CHANGELOG.md @@ -3,6 +3,14 @@ All notable changes to the `work-items` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.48.1] - 2026-10-03 + +### Changed + +- **Shared `prerequisites.mjs` synced ([#6084](https://github.com/melodic-software/claude-code-plugins/issues/6084)); no change to this plugin's lib.** + The prerequisite check now counts a Windows App Execution Alias (a Store or winget install on PATH) as found, + except App Installer's Python install stub. A `cli` or `runtime` entry can set `reject_store_alias` to skip aliases instead; no entry in this plugin does. + ## [0.48.0] - 2026-10-03 ### Added diff --git a/plugins/work-items/lib/prerequisites.mjs b/plugins/work-items/lib/prerequisites.mjs index 57f65a79b5..1e11abf2cc 100755 --- a/plugins/work-items/lib/prerequisites.mjs +++ b/plugins/work-items/lib/prerequisites.mjs @@ -28,8 +28,10 @@ import { accessSync, constants, existsSync, + lstatSync, mkdirSync, readFileSync, + readlinkSync, realpathSync, statSync, writeFileSync, @@ -42,8 +44,8 @@ export const KINDS = ["cli", "runtime", "system-lib", "python-pkg", "node-pkg", export const NEEDS = ["required", "optional"]; const ENTRY_KEYS = ["id", "kind", "need", "for", "detect", "degrade", "install", "check"]; const DETECT_KEYS = { - cli: { required: ["any"], optional: ["local_bin", "version"] }, - runtime: { required: ["any"], optional: ["local_bin", "version"] }, + cli: { required: ["any"], optional: ["local_bin", "version", "reject_store_alias"] }, + runtime: { required: ["any"], optional: ["local_bin", "version", "reject_store_alias"] }, "system-lib": { required: ["probe"], optional: [] }, "python-pkg": { required: ["any", "import"], optional: [] }, "node-pkg": { required: ["module", "paths"], optional: [] }, @@ -100,6 +102,9 @@ function detectErrors(kind, detect, where) { } if ("any" in detect && !isTextList(detect.any)) errors.push(`${where}.any must be a non-empty list of names`); if ("local_bin" in detect) errors.push(...relativePathErrors(detect.local_bin, `${where}.local_bin`)); + if ("reject_store_alias" in detect && typeof detect.reject_store_alias !== "boolean") { + errors.push(`${where}.reject_store_alias must be true or false`); + } if ("version" in detect) { const v = detect.version; const vw = `${where}.version`; @@ -243,36 +248,66 @@ export function compareVersions(a, b) { return 0; } -function isExecutable(file, platform) { +const FS = { statSync, lstatSync, readlinkSync, accessSync }; + +// The target of App Installer's Python "install me" stub, which opens the Store +// instead of running Python, and the alias names it takes. +const PYTHON_STUB_TARGET = "appinstallerpythonredirector.exe"; +const PYTHON_STUB_NAMES = new Set(["python", "python3"]); + +// A Windows App Execution Alias, how a Store or winget app reaches PATH, is a +// reparse point in a WindowsApps directory that stat cannot open, yet it runs. +// It counts as found unless it is the Python stub, known by its target or, when +// the target cannot be read, by its name. +const isWindowsApps = (dir) => path.basename(dir).toLowerCase() === "windowsapps"; + +function isAppAlias(file, fs) { + if (!isWindowsApps(path.dirname(file))) return false; try { - if (!statSync(file).isFile()) return false; - if (platform !== "win32") accessSync(file, constants.X_OK); - return true; + if (!fs.lstatSync(file).isSymbolicLink()) return false; } catch { return false; } + let target; + try { + target = fs.readlinkSync(file); + } catch { + return !PYTHON_STUB_NAMES.has(path.basename(file).toLowerCase().replace(/\.exe$/, "")); + } + return path.win32.basename(target).toLowerCase() !== PYTHON_STUB_TARGET; +} + +function isExecutable(file, platform, fs = FS) { + try { + if (!fs.statSync(file).isFile()) return false; + if (platform !== "win32") fs.accessSync(file, constants.X_OK); + return true; + } catch { + return platform === "win32" && isAppAlias(file, fs); + } } -// which(name) -> absolute path of the first PATH match, or null. -export function which(name, env = process.env, platform = process.platform) { +// which(name) -> absolute path of the first PATH match, or null. fs is injectable for tests. +// rejectStoreAlias skips WindowsApps directories on win32, so no App Execution Alias matches. +export function which(name, env = process.env, platform = process.platform, fs = FS, rejectStoreAlias = false) { const pathVar = env.PATH ?? env.Path ?? ""; const exts = platform === "win32" ? ["", ...(env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean)] : [""]; for (const dir of pathVar.split(path.delimiter)) { - if (!dir) continue; + if (!dir || (rejectStoreAlias && platform === "win32" && isWindowsApps(dir))) continue; for (const ext of exts) { const candidate = path.join(dir, name + ext); - if (isExecutable(candidate, platform)) return candidate; + if (isExecutable(candidate, platform, fs)) return candidate; } } return null; } -function findLocalBin(rels, cwd, platform) { +function findLocalBin(rels, cwd, platform, fs) { for (const rel of rels ?? []) { let dir = path.resolve(cwd); for (let i = 0; i < LOCAL_BIN_DEPTH; i++) { const candidate = path.join(dir, rel); - if (isExecutable(candidate, platform)) return candidate; + if (isExecutable(candidate, platform, fs)) return candidate; const parent = path.dirname(dir); if (parent === dir) break; dir = parent; @@ -286,9 +321,9 @@ function run(file, args) { return { ok: !r.error && r.status === 0, out: `${r.stdout ?? ""}${r.stderr ?? ""}` }; } -function firstOnPath(names, ctx) { +function firstOnPath(names, ctx, rejectStoreAlias = false) { for (const name of names) { - const found = which(name, ctx.env, ctx.platform); + const found = which(name, ctx.env, ctx.platform, ctx.fs, rejectStoreAlias); if (found) return found; } return null; @@ -314,7 +349,7 @@ export function detectEntry(entry, ctx) { switch (entry.kind) { case "cli": case "runtime": { - const found = firstOnPath(d.any, ctx) ?? findLocalBin(d.local_bin, ctx.cwd, ctx.platform); + const found = firstOnPath(d.any, ctx, d.reject_store_alias) ?? findLocalBin(d.local_bin, ctx.cwd, ctx.platform, ctx.fs); if (!found) { const where = d.local_bin ? `on PATH or as ${d.local_bin.join(", ")}` : "on PATH"; return { status: "missing", detail: `${d.any.join(" or ")} was not found ${where}` }; @@ -331,7 +366,7 @@ export function detectEntry(entry, ctx) { } case "system-lib": { const [bin, ...args] = d.probe.args; - const found = which(bin, ctx.env, ctx.platform); + const found = which(bin, ctx.env, ctx.platform, ctx.fs); if (!found) return { status: "missing", detail: `${bin} was not found on PATH to probe` }; if (new RegExp(d.probe.pattern).test(run(found, args).out)) return { status: "present", detail: found }; return { status: "missing", detail: `${bin} ${args.join(" ")} did not report ${d.probe.pattern}` }; @@ -566,6 +601,7 @@ export function main(argv, ctx = {}, out = {}) { env: ctx.env ?? process.env, platform: ctx.platform ?? process.platform, cwd: ctx.cwd ?? process.cwd(), + fs: ctx.fs ?? FS, stdin: ctx.stdin ?? (() => (process.stdin.isTTY ? "" : readFileSync(0, "utf8"))), }; const io = { stdout: out.stdout ?? ((s) => console.log(s)), stderr: out.stderr ?? ((s) => console.error(s)) }; diff --git a/plugins/x/.claude-plugin/plugin.json b/plugins/x/.claude-plugin/plugin.json index ed253419ed..d73b042ad6 100644 --- a/plugins/x/.claude-plugin/plugin.json +++ b/plugins/x/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "x", - "version": "0.3.1", + "version": "0.3.2", "description": "Read X (formerly Twitter) posts, note tweets, and X Articles as Markdown without an X API key, via a documented fallback ladder over third-party converters: xtomd.com for single posts and articles, Thread Reader App for unrolled reply chains. A pasted X link becomes readable content instead of a login wall.", "author": { "name": "Melodic Software", diff --git a/plugins/x/CHANGELOG.md b/plugins/x/CHANGELOG.md index 622fed5c56..d86d11d563 100644 --- a/plugins/x/CHANGELOG.md +++ b/plugins/x/CHANGELOG.md @@ -5,6 +5,14 @@ All notable changes to the `x` plugin. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.3.2] - 2026-10-03 + +### Changed + +- **Shared `prerequisites.mjs` synced ([#6084](https://github.com/melodic-software/claude-code-plugins/issues/6084)); no change to this plugin's lib.** + The prerequisite check now counts a Windows App Execution Alias (a Store or winget install on PATH) as found, + except App Installer's Python install stub. A `cli` or `runtime` entry can set `reject_store_alias` to skip aliases instead; no entry in this plugin does. + ## [0.3.1] - 2026-10-03 ### Changed diff --git a/plugins/x/lib/prerequisites.mjs b/plugins/x/lib/prerequisites.mjs index 57f65a79b5..1e11abf2cc 100755 --- a/plugins/x/lib/prerequisites.mjs +++ b/plugins/x/lib/prerequisites.mjs @@ -28,8 +28,10 @@ import { accessSync, constants, existsSync, + lstatSync, mkdirSync, readFileSync, + readlinkSync, realpathSync, statSync, writeFileSync, @@ -42,8 +44,8 @@ export const KINDS = ["cli", "runtime", "system-lib", "python-pkg", "node-pkg", export const NEEDS = ["required", "optional"]; const ENTRY_KEYS = ["id", "kind", "need", "for", "detect", "degrade", "install", "check"]; const DETECT_KEYS = { - cli: { required: ["any"], optional: ["local_bin", "version"] }, - runtime: { required: ["any"], optional: ["local_bin", "version"] }, + cli: { required: ["any"], optional: ["local_bin", "version", "reject_store_alias"] }, + runtime: { required: ["any"], optional: ["local_bin", "version", "reject_store_alias"] }, "system-lib": { required: ["probe"], optional: [] }, "python-pkg": { required: ["any", "import"], optional: [] }, "node-pkg": { required: ["module", "paths"], optional: [] }, @@ -100,6 +102,9 @@ function detectErrors(kind, detect, where) { } if ("any" in detect && !isTextList(detect.any)) errors.push(`${where}.any must be a non-empty list of names`); if ("local_bin" in detect) errors.push(...relativePathErrors(detect.local_bin, `${where}.local_bin`)); + if ("reject_store_alias" in detect && typeof detect.reject_store_alias !== "boolean") { + errors.push(`${where}.reject_store_alias must be true or false`); + } if ("version" in detect) { const v = detect.version; const vw = `${where}.version`; @@ -243,36 +248,66 @@ export function compareVersions(a, b) { return 0; } -function isExecutable(file, platform) { +const FS = { statSync, lstatSync, readlinkSync, accessSync }; + +// The target of App Installer's Python "install me" stub, which opens the Store +// instead of running Python, and the alias names it takes. +const PYTHON_STUB_TARGET = "appinstallerpythonredirector.exe"; +const PYTHON_STUB_NAMES = new Set(["python", "python3"]); + +// A Windows App Execution Alias, how a Store or winget app reaches PATH, is a +// reparse point in a WindowsApps directory that stat cannot open, yet it runs. +// It counts as found unless it is the Python stub, known by its target or, when +// the target cannot be read, by its name. +const isWindowsApps = (dir) => path.basename(dir).toLowerCase() === "windowsapps"; + +function isAppAlias(file, fs) { + if (!isWindowsApps(path.dirname(file))) return false; try { - if (!statSync(file).isFile()) return false; - if (platform !== "win32") accessSync(file, constants.X_OK); - return true; + if (!fs.lstatSync(file).isSymbolicLink()) return false; } catch { return false; } + let target; + try { + target = fs.readlinkSync(file); + } catch { + return !PYTHON_STUB_NAMES.has(path.basename(file).toLowerCase().replace(/\.exe$/, "")); + } + return path.win32.basename(target).toLowerCase() !== PYTHON_STUB_TARGET; +} + +function isExecutable(file, platform, fs = FS) { + try { + if (!fs.statSync(file).isFile()) return false; + if (platform !== "win32") fs.accessSync(file, constants.X_OK); + return true; + } catch { + return platform === "win32" && isAppAlias(file, fs); + } } -// which(name) -> absolute path of the first PATH match, or null. -export function which(name, env = process.env, platform = process.platform) { +// which(name) -> absolute path of the first PATH match, or null. fs is injectable for tests. +// rejectStoreAlias skips WindowsApps directories on win32, so no App Execution Alias matches. +export function which(name, env = process.env, platform = process.platform, fs = FS, rejectStoreAlias = false) { const pathVar = env.PATH ?? env.Path ?? ""; const exts = platform === "win32" ? ["", ...(env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean)] : [""]; for (const dir of pathVar.split(path.delimiter)) { - if (!dir) continue; + if (!dir || (rejectStoreAlias && platform === "win32" && isWindowsApps(dir))) continue; for (const ext of exts) { const candidate = path.join(dir, name + ext); - if (isExecutable(candidate, platform)) return candidate; + if (isExecutable(candidate, platform, fs)) return candidate; } } return null; } -function findLocalBin(rels, cwd, platform) { +function findLocalBin(rels, cwd, platform, fs) { for (const rel of rels ?? []) { let dir = path.resolve(cwd); for (let i = 0; i < LOCAL_BIN_DEPTH; i++) { const candidate = path.join(dir, rel); - if (isExecutable(candidate, platform)) return candidate; + if (isExecutable(candidate, platform, fs)) return candidate; const parent = path.dirname(dir); if (parent === dir) break; dir = parent; @@ -286,9 +321,9 @@ function run(file, args) { return { ok: !r.error && r.status === 0, out: `${r.stdout ?? ""}${r.stderr ?? ""}` }; } -function firstOnPath(names, ctx) { +function firstOnPath(names, ctx, rejectStoreAlias = false) { for (const name of names) { - const found = which(name, ctx.env, ctx.platform); + const found = which(name, ctx.env, ctx.platform, ctx.fs, rejectStoreAlias); if (found) return found; } return null; @@ -314,7 +349,7 @@ export function detectEntry(entry, ctx) { switch (entry.kind) { case "cli": case "runtime": { - const found = firstOnPath(d.any, ctx) ?? findLocalBin(d.local_bin, ctx.cwd, ctx.platform); + const found = firstOnPath(d.any, ctx, d.reject_store_alias) ?? findLocalBin(d.local_bin, ctx.cwd, ctx.platform, ctx.fs); if (!found) { const where = d.local_bin ? `on PATH or as ${d.local_bin.join(", ")}` : "on PATH"; return { status: "missing", detail: `${d.any.join(" or ")} was not found ${where}` }; @@ -331,7 +366,7 @@ export function detectEntry(entry, ctx) { } case "system-lib": { const [bin, ...args] = d.probe.args; - const found = which(bin, ctx.env, ctx.platform); + const found = which(bin, ctx.env, ctx.platform, ctx.fs); if (!found) return { status: "missing", detail: `${bin} was not found on PATH to probe` }; if (new RegExp(d.probe.pattern).test(run(found, args).out)) return { status: "present", detail: found }; return { status: "missing", detail: `${bin} ${args.join(" ")} did not report ${d.probe.pattern}` }; @@ -566,6 +601,7 @@ export function main(argv, ctx = {}, out = {}) { env: ctx.env ?? process.env, platform: ctx.platform ?? process.platform, cwd: ctx.cwd ?? process.cwd(), + fs: ctx.fs ?? FS, stdin: ctx.stdin ?? (() => (process.stdin.isTTY ? "" : readFileSync(0, "utf8"))), }; const io = { stdout: out.stdout ?? ((s) => console.log(s)), stderr: out.stderr ?? ((s) => console.error(s)) };