Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions bootstrap.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,16 @@ 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<version> loaded.` before
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."
Expand Down
18 changes: 17 additions & 1 deletion cli/bin/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 });
});

Expand Down
197 changes: 197 additions & 0 deletions cli/lib/update-check.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
// 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;
let hardTimer = null;
const done = (value) => {
if (settled) return;
settled = true;
if (hardTimer) clearTimeout(hardTimer);
resolve(value);
};
try {
const req = https.get(
url,
{ timeout: FETCH_TIMEOUT_MS, headers: { Accept: "application/json" } },
(res) => {
Comment thread
abeltrano marked this conversation as resolved.
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);
const version =
typeof json.version === "string" ? json.version : null;
// Only return parseable semver so we never cache or surface
// a malformed value (e.g., missing patch, garbage string).
done(version && parseVersion(version) ? version : null);
} catch {
done(null);
}
});
res.on("error", () => done(null));
}
);
// The { timeout } option above is only a socket-inactivity timeout —
// a server that trickles bytes can keep the request alive well past
// FETCH_TIMEOUT_MS. Add an overall hard deadline so interactive
// startup is never delayed longer than intended.
hardTimer = setTimeout(() => {
req.destroy();
done(null);
}, FETCH_TIMEOUT_MS);
if (typeof hardTimer.unref === "function") hardTimer.unref();
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;

// Dispatch through module.exports._internals so tests can stub these
// without depending on the real filesystem or network.
const { readCache, writeCache, fetchLatest } = module.exports._internals;

const cache = readCache();
let latest = null;

if (
!force &&
cache &&
cache.pkg === pkgName &&
typeof cache.latest === "string" &&
parseVersion(cache.latest) &&
typeof cache.checkedAt === "number" &&
now - cache.checkedAt < CACHE_TTL_MS
) {
latest = cache.latest;
} else {
latest = await fetchLatest(pkgName);
// fetchLatest already filters to parseable semver, but guard again so
// a future change to that contract can't poison the cache.
if (latest && parseVersion(latest)) {
writeCache({ pkg: pkgName, latest, checkedAt: now });
} else {
latest = null;
}
Comment thread
abeltrano marked this conversation as resolved.
}
Comment thread
abeltrano marked this conversation as resolved.

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 },
};
2 changes: 1 addition & 1 deletion cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
6 changes: 6 additions & 0 deletions cli/tests/cli.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
Loading
Loading