From 92bb0ed6e6ce6701c936e92e73d56bd8165a6592 Mon Sep 17 00:00:00 2001 From: anbeltra Date: Fri, 17 Apr 2026 09:53:44 -0600 Subject: [PATCH 1/3] Announce PromptKit version when bootstrap loads Add an instruction to bootstrap.md step 1 so the composition engine emits a one-line 'PromptKit v loaded.' banner after reading manifest.yaml. The version is read from the top-level 'version:' field; a 'version unknown' fallback is used if the field is missing or unreadable, and no version is ever fabricated. This applies to both manual loads and the 'promptkit interactive' (npx) flow, since the CLI stages the same bootstrap.md and manifest.yaml into a temp dir before instructing the LLM to read bootstrap.md. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- bootstrap.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/bootstrap.md b/bootstrap.md index cbe3e0a..fbe8123 100644 --- a/bootstrap.md +++ b/bootstrap.md @@ -28,6 +28,13 @@ You are the **composition engine** for PromptKit. Your job is to: ## How to Begin 1. **Read the manifest** at `manifest.yaml` to discover all available components. + Immediately after reading it, **announce the PromptKit version** to the + user by reading the top-level `version:` field from `manifest.yaml` and + emitting a one-line banner such as `PromptKit v loaded.` before + any other output. If the `version:` field is missing or unreadable, say + `PromptKit (version unknown) loaded.` instead — do not fabricate a version + number. Do the same (re-announce the current version) any time you + re-read `bootstrap.md` or `manifest.yaml` later in the session. 2. **Ask the user** what they want to accomplish. Examples: - "I need to write a requirements doc for a new authentication system." - "I need to investigate a memory leak in our C codebase." From 5ababc959f5f993792da284a87abf9aee49b3fea Mon Sep 17 00:00:00 2001 From: anbeltra Date: Fri, 17 Apr 2026 10:11:46 -0600 Subject: [PATCH 2/3] Notify users when a newer PromptKit CLI version is available Adds a best-effort daily update check to 'promptkit interactive'. The CLI queries https://registry.npmjs.org//latest (built-in https, ~1500ms timeout), caches the result in ~/.promptkit/update-check.json for 24h, and prints a boxed banner before spawning the LLM when a newer version exists. No new runtime dependencies. Suppressed by NO_UPDATE_NOTIFIER=1, CI, non-TTY stdout, --no-update-check, and for non-interactive subcommands (list/search/show/--version). Network, cache, and parse failures are silently swallowed and never surface to the user. Adds cli/tests/update-check.test.js with unit coverage for parseVersion, isNewer, formatBanner, and suppressionReason (no network I/O). Updates cli/tests/cli.test.js harness to copy the new lib/update-check.js into the temp CLI root. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cli/bin/cli.js | 18 +++- cli/lib/update-check.js | 173 +++++++++++++++++++++++++++++++++ cli/package.json | 2 +- cli/tests/cli.test.js | 6 ++ cli/tests/update-check.test.js | 150 ++++++++++++++++++++++++++++ 5 files changed, 347 insertions(+), 2 deletions(-) create mode 100644 cli/lib/update-check.js create mode 100644 cli/tests/update-check.test.js diff --git a/cli/bin/cli.js b/cli/bin/cli.js index 9e64730..816b07a 100644 --- a/cli/bin/cli.js +++ b/cli/bin/cli.js @@ -6,6 +6,7 @@ const { Command } = require("commander"); const path = require("path"); const fs = require("fs"); const { launchInteractive } = require("../lib/launch"); +const { checkForUpdate, formatBanner } = require("../lib/update-check"); const { loadManifest, allComponents, @@ -61,8 +62,23 @@ program "--dry-run", "Print the spawn command and args without launching the LLM CLI" ) - .action((opts) => { + .option( + "--no-update-check", + "Skip checking the npm registry for a newer PromptKit version" + ) + .action(async (opts) => { ensureContent(); + if (opts.updateCheck !== false) { + try { + const result = await checkForUpdate(pkg.name, pkg.version); + if (result && result.isUpdate) { + console.log(formatBanner(pkg.name, pkg.version, result.latest)); + console.log(); + } + } catch { + // Update checks are strictly best-effort; never fail the CLI over them. + } + } launchInteractive(contentDir, opts.cli || null, { dryRun: !!opts.dryRun }); }); diff --git a/cli/lib/update-check.js b/cli/lib/update-check.js new file mode 100644 index 0000000..e63722e --- /dev/null +++ b/cli/lib/update-check.js @@ -0,0 +1,173 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) PromptKit Contributors + +// cli/lib/update-check.js — best-effort npm registry update check for the +// PromptKit CLI. All network and filesystem operations are wrapped so that +// any failure (timeout, DNS, bad JSON, unwritable cache dir, etc.) is +// swallowed — an update check must never block or break the CLI. + +const fs = require("fs"); +const os = require("os"); +const path = require("path"); +const https = require("https"); + +const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours +const FETCH_TIMEOUT_MS = 1500; +const REGISTRY_BASE = "https://registry.npmjs.org"; + +function cachePath() { + return path.join(os.homedir(), ".promptkit", "update-check.json"); +} + +function readCache() { + try { + return JSON.parse(fs.readFileSync(cachePath(), "utf8")); + } catch { + return null; + } +} + +function writeCache(data) { + try { + const file = cachePath(); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, JSON.stringify(data)); + } catch { + // Best-effort only; cache failures must never surface. + } +} + +// Parse a version string into [major, minor, patch]. Strips an optional +// leading 'v' and ignores any prerelease/build suffix after the patch number. +// Returns null for unparseable input. +function parseVersion(v) { + const match = /^v?(\d+)\.(\d+)\.(\d+)/.exec(String(v || "")); + if (!match) return null; + return [Number(match[1]), Number(match[2]), Number(match[3])]; +} + +function isNewer(candidate, current) { + const a = parseVersion(candidate); + const b = parseVersion(current); + if (!a || !b) return false; + for (let i = 0; i < 3; i++) { + if (a[i] > b[i]) return true; + if (a[i] < b[i]) return false; + } + return false; +} + +function formatBanner(pkgName, current, latest) { + const line1 = `Update available: ${current} -> ${latest}`; + const line2 = `Run: npm i -g ${pkgName}`; + const inner = Math.max(line1.length, line2.length); + const bar = "-".repeat(inner + 2); + return ( + `+${bar}+\n` + + `| ${line1.padEnd(inner)} |\n` + + `| ${line2.padEnd(inner)} |\n` + + `+${bar}+` + ); +} + +function fetchLatest(pkgName) { + return new Promise((resolve) => { + const url = `${REGISTRY_BASE}/${pkgName}/latest`; + let settled = false; + const done = (value) => { + if (settled) return; + settled = true; + resolve(value); + }; + try { + const req = https.get( + url, + { timeout: FETCH_TIMEOUT_MS, headers: { Accept: "application/json" } }, + (res) => { + if (res.statusCode !== 200) { + res.resume(); + return done(null); + } + let body = ""; + res.setEncoding("utf8"); + res.on("data", (chunk) => { + body += chunk; + // Hard cap to avoid unbounded memory on a misbehaving registry. + if (body.length > 64 * 1024) { + req.destroy(); + done(null); + } + }); + res.on("end", () => { + try { + const json = JSON.parse(body); + done(typeof json.version === "string" ? json.version : null); + } catch { + done(null); + } + }); + res.on("error", () => done(null)); + } + ); + req.on("timeout", () => { + req.destroy(); + done(null); + }); + req.on("error", () => done(null)); + } catch { + done(null); + } + }); +} + +// Decide whether update checking should be performed in this invocation. +// Returns a short string describing the suppression reason, or null if the +// check should proceed. +function suppressionReason({ force = false, ttyOverride } = {}) { + if (force) return null; + if (process.env.NO_UPDATE_NOTIFIER === "1") return "NO_UPDATE_NOTIFIER"; + if (process.env.CI) return "CI"; + const isTty = ttyOverride !== undefined ? ttyOverride : !!process.stdout.isTTY; + if (!isTty) return "non-tty"; + return null; +} + +async function checkForUpdate( + pkgName, + currentVersion, + { force = false, now = Date.now() } = {} +) { + if (suppressionReason({ force })) return null; + + const cache = readCache(); + let latest = null; + + if ( + !force && + cache && + cache.pkg === pkgName && + typeof cache.latest === "string" && + typeof cache.checkedAt === "number" && + now - cache.checkedAt < CACHE_TTL_MS + ) { + latest = cache.latest; + } else { + latest = await fetchLatest(pkgName); + if (latest) { + writeCache({ pkg: pkgName, latest, checkedAt: now }); + } + } + + if (!latest) return null; + return { latest, isUpdate: isNewer(latest, currentVersion) }; +} + +module.exports = { + checkForUpdate, + formatBanner, + isNewer, + parseVersion, + suppressionReason, + // Exported for tests that need to bypass the real paths. + _internals: { cachePath, readCache, writeCache, fetchLatest }, +}; diff --git a/cli/package.json b/cli/package.json index 0ddfe91..2e54c00 100644 --- a/cli/package.json +++ b/cli/package.json @@ -20,7 +20,7 @@ "prepublishOnly": "node scripts/copy-content.js", "prepare": "node scripts/copy-content.js", "pretest": "node scripts/copy-content.js", - "test": "node --test --test-concurrency=1 tests/cli.test.js tests/list.test.js tests/search-show.test.js tests/launch.test.js tests/copy-content.test.js" + "test": "node --test --test-concurrency=1 tests/cli.test.js tests/list.test.js tests/search-show.test.js tests/launch.test.js tests/copy-content.test.js tests/update-check.test.js" }, "dependencies": { "commander": "^12.0.0", diff --git a/cli/tests/cli.test.js b/cli/tests/cli.test.js index 29003bc..1d29654 100644 --- a/cli/tests/cli.test.js +++ b/cli/tests/cli.test.js @@ -71,6 +71,12 @@ function makeTempContent(removeFiles) { fs.copyFileSync(manifestJs, path.join(tmpLib, "manifest.js")); } + // Copy lib/update-check.js (required by bin/cli.js) + const updateCheckJs = path.resolve(__dirname, "..", "lib", "update-check.js"); + if (fs.existsSync(updateCheckJs)) { + fs.copyFileSync(updateCheckJs, path.join(tmpLib, "update-check.js")); + } + // Copy node_modules (symlink for speed) const srcModules = path.resolve(__dirname, "..", "node_modules"); const destModules = path.join(tmpCli, "node_modules"); diff --git a/cli/tests/update-check.test.js b/cli/tests/update-check.test.js new file mode 100644 index 0000000..b474b83 --- /dev/null +++ b/cli/tests/update-check.test.js @@ -0,0 +1,150 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) PromptKit Contributors + +// cli/tests/update-check.test.js — unit tests for the update-check module. +// Exercises pure functions (version parsing, comparison, banner formatting) +// and the suppression-reason logic. Does not hit the network. + +const { describe, it } = require("node:test"); +const assert = require("node:assert"); + +const { + parseVersion, + isNewer, + formatBanner, + suppressionReason, + checkForUpdate, +} = require("../lib/update-check"); + +describe("parseVersion", () => { + it("parses plain semver", () => { + assert.deepStrictEqual(parseVersion("1.2.3"), [1, 2, 3]); + }); + + it("strips leading 'v'", () => { + assert.deepStrictEqual(parseVersion("v0.6.1"), [0, 6, 1]); + }); + + it("ignores prerelease and build metadata after patch", () => { + assert.deepStrictEqual(parseVersion("1.2.3-rc.1"), [1, 2, 3]); + assert.deepStrictEqual(parseVersion("1.2.3+build.5"), [1, 2, 3]); + }); + + it("returns null for garbage input", () => { + assert.strictEqual(parseVersion(""), null); + assert.strictEqual(parseVersion("not-a-version"), null); + assert.strictEqual(parseVersion(undefined), null); + assert.strictEqual(parseVersion(null), null); + assert.strictEqual(parseVersion("1.2"), null); + }); +}); + +describe("isNewer", () => { + it("detects major, minor, and patch bumps", () => { + assert.strictEqual(isNewer("1.0.0", "0.9.9"), true); + assert.strictEqual(isNewer("0.7.0", "0.6.9"), true); + assert.strictEqual(isNewer("0.6.2", "0.6.1"), true); + }); + + it("returns false for equal versions", () => { + assert.strictEqual(isNewer("0.6.1", "0.6.1"), false); + }); + + it("returns false when candidate is older", () => { + assert.strictEqual(isNewer("0.5.0", "0.6.0"), false); + assert.strictEqual(isNewer("0.6.0", "0.6.1"), false); + assert.strictEqual(isNewer("0.9.9", "1.0.0"), false); + }); + + it("returns false on unparseable input", () => { + assert.strictEqual(isNewer("bogus", "0.6.1"), false); + assert.strictEqual(isNewer("0.6.1", "bogus"), false); + }); +}); + +describe("formatBanner", () => { + it("produces a boxed banner containing both versions and the package", () => { + const banner = formatBanner("@alan-jowett/promptkit", "0.6.1", "0.7.0"); + assert.match(banner, /Update available: 0\.6\.1 -> 0\.7\.0/); + assert.match(banner, /npm i -g @alan-jowett\/promptkit/); + // Four lines: top bar, two content lines, bottom bar. + assert.strictEqual(banner.split("\n").length, 4); + }); + + it("pads content lines to the same width", () => { + const banner = formatBanner("pkg", "1.0.0", "2.0.0"); + const lines = banner.split("\n"); + assert.strictEqual(lines[0].length, lines[3].length); + assert.strictEqual(lines[1].length, lines[2].length); + assert.strictEqual(lines[0].length, lines[1].length); + }); +}); + +describe("suppressionReason", () => { + function withEnv(overrides, fn) { + const saved = {}; + for (const key of Object.keys(overrides)) { + saved[key] = process.env[key]; + if (overrides[key] === undefined) delete process.env[key]; + else process.env[key] = overrides[key]; + } + try { + return fn(); + } finally { + for (const key of Object.keys(saved)) { + if (saved[key] === undefined) delete process.env[key]; + else process.env[key] = saved[key]; + } + } + } + + it("returns 'NO_UPDATE_NOTIFIER' when the env var is '1'", () => { + withEnv({ NO_UPDATE_NOTIFIER: "1", CI: undefined }, () => { + assert.strictEqual( + suppressionReason({ ttyOverride: true }), + "NO_UPDATE_NOTIFIER" + ); + }); + }); + + it("returns 'CI' when CI env var is set", () => { + withEnv({ NO_UPDATE_NOTIFIER: undefined, CI: "true" }, () => { + assert.strictEqual(suppressionReason({ ttyOverride: true }), "CI"); + }); + }); + + it("returns 'non-tty' when stdout is not a TTY", () => { + withEnv({ NO_UPDATE_NOTIFIER: undefined, CI: undefined }, () => { + assert.strictEqual(suppressionReason({ ttyOverride: false }), "non-tty"); + }); + }); + + it("returns null when checks should proceed", () => { + withEnv({ NO_UPDATE_NOTIFIER: undefined, CI: undefined }, () => { + assert.strictEqual(suppressionReason({ ttyOverride: true }), null); + }); + }); + + it("force: true bypasses all suppressions", () => { + withEnv({ NO_UPDATE_NOTIFIER: "1", CI: "true" }, () => { + assert.strictEqual( + suppressionReason({ force: true, ttyOverride: false }), + null + ); + }); + }); +}); + +describe("checkForUpdate (suppression paths)", () => { + it("returns null immediately when suppressed, without network I/O", async () => { + const saved = process.env.NO_UPDATE_NOTIFIER; + process.env.NO_UPDATE_NOTIFIER = "1"; + try { + const result = await checkForUpdate("@alan-jowett/promptkit", "0.6.1"); + assert.strictEqual(result, null); + } finally { + if (saved === undefined) delete process.env.NO_UPDATE_NOTIFIER; + else process.env.NO_UPDATE_NOTIFIER = saved; + } + }); +}); From 4376f5f0009a3d2db9e9495c8e2d789ff59f0805 Mon Sep 17 00:00:00 2001 From: Andrew Beltrano <2082148+abeltrano@users.noreply.github.com> Date: Fri, 17 Apr 2026 10:31:45 -0600 Subject: [PATCH 3/3] Apply suggestions from code review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- bootstrap.md | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/bootstrap.md b/bootstrap.md index fbe8123..ae4c93d 100644 --- a/bootstrap.md +++ b/bootstrap.md @@ -31,10 +31,13 @@ You are the **composition engine** for PromptKit. Your job is to: Immediately after reading it, **announce the PromptKit version** to the user by reading the top-level `version:` field from `manifest.yaml` and emitting a one-line banner such as `PromptKit v loaded.` before - any other output. If the `version:` field is missing or unreadable, say - `PromptKit (version unknown) loaded.` instead — do not fabricate a version - number. Do the same (re-announce the current version) any time you - re-read `bootstrap.md` or `manifest.yaml` later in the session. + any other output. Use the **parsed YAML scalar value** for `version`, trim + surrounding whitespace, and do **not** preserve any YAML quoting characters + from the source text. If the parsed `version:` value is missing, unreadable, + empty, or whitespace-only, say `PromptKit (version unknown) loaded.` + instead — do not fabricate a version number or emit `PromptKit v loaded.` + Do the same (re-announce the current version) any time you re-read + `bootstrap.md` or `manifest.yaml` later in the session. 2. **Ask the user** what they want to accomplish. Examples: - "I need to write a requirements doc for a new authentication system." - "I need to investigate a memory leak in our C codebase."