neon 8.11.4 → 8.12.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -58,6 +58,13 @@ of them is Homebrew, the warning recommends `brew uninstall neonctl`, which
58
58
  leaves `neon` running the other install, such as npm. While duplicates remain, the update notice is held back;
59
59
  `NO_UPDATE_NOTIFIER=1` silences both.
60
60
 
61
+ Commands also warn once a day when Neon agent skills are behind their source, in this
62
+ directory's `skills-lock.json` or the user-level skills lock (see
63
+ [`neon skills status`](#install-neon-agent-skills-skills)). This warning runs without a
64
+ terminal too, so coding agents see it. It checks GitHub once a day in the background and
65
+ follows the same CI, machine-readable output, standalone binary, and
66
+ `NO_UPDATE_NOTIFIER=1` rules.
67
+
61
68
  ## Connect
62
69
 
63
70
  Run the following command to authenticate a connection to Neon:
@@ -973,8 +980,14 @@ $ neon skills --global
973
980
  $ neon skills update
974
981
  $ neon skills update -y
975
982
  $ neon skills update --global -y
983
+
984
+ # Check whether installed Neon skills are behind their source.
985
+ $ neon skills status
986
+ $ neon skills status --global
976
987
  ```
977
988
 
989
+ `neon skills status` compares each Neon skill in this directory's `skills-lock.json` (or, with `--global`, the user-level skills lock) with the skill folder on its source repository's default branch, and prints `up to date`, `outdated`, or `not found upstream` per skill. It reads GitHub anonymously; set `GITHUB_TOKEN` or `GH_TOKEN` if the GitHub API rate limit is reached. Skills installed through an agent plugin are not in either lock and are not listed.
990
+
978
991
  On a TTY the command asks which agents and which skills, then installs. Detected agents start selected from project-folder markers such as `.cursor`. Default skills start selected. `neon-postgres-agent-platforms` is offered and starts unselected.
979
992
 
980
993
  `-y` skips those questions and installs the default skills into detected agents: project-folder markers such as `.cursor`, else the agent driving the CLI. `--agent` / `-a` names coding agents and skips the agent picker. `--skill` / `-s` names specific skills and skips the skill picker; it does not select agents. `--global -y` uses installed apps, else the host CLI agent. Without a TTY, pass `-y`, or `--skill <name>` (add `--agent <name>` to name agents). If `-y` finds no agent, it exits: pass `--agent <name>`, run from a supported agent, or omit `-y` in a terminal to pick.
@@ -1565,7 +1578,7 @@ Global options are supported with any Neon CLI command.
1565
1578
 
1566
1579
  - <a id="analytics"></a>`--analytics`
1567
1580
 
1568
- Analytics are enabled by default to gather information about the CLI commands and options that are used by our customers. This data collection assists in offering support, and allows for a better understanding of typical usage patterns so that we can improve user experience. Neon does not collect user-defined data, such as project IDs or command payloads, except the question passed to `neon ask --prompt` and the query passed to `neon docs search`. The Neon search service records each `neon docs search` query with the CLI version and the detected coding agent; with `--no-analytics` it records nothing. When the CLI fails on an internal bug (a JavaScript `TypeError`, `ReferenceError`, `RangeError`, or `SyntaxError`), it also sends that error to Neon's Sentry: the error type, the message only when it is a standard JavaScript message that names nothing but code (any other message is replaced), the stack trace with file paths cut to the package (`neon/dist/...`), the CLI and Node.js versions, and whether it ran in CI or a coding agent. To opt-out of analytics data collection and error reports, specify `--no-analytics` or `--analytics false`.
1581
+ Analytics are enabled by default to gather information about the CLI commands and options that are used by our customers. This data collection assists in offering support, and allows for a better understanding of typical usage patterns so that we can improve user experience. Neon does not collect user-defined data, such as project IDs or command payloads, except the question passed to `neon ask --prompt` and the query passed to `neon docs search`. Each successful command also reports the Neon agent skills recorded in the user-level skills lock and in the current directory's `skills-lock.json`: the skill names, their lock hashes, and whether each is behind its source, read from local files without a network request. The Neon search service records each `neon docs search` query with the CLI version and the detected coding agent; with `--no-analytics` it records nothing. When the CLI fails on an internal bug (a JavaScript `TypeError`, `ReferenceError`, `RangeError`, or `SyntaxError`), it also sends that error to Neon's Sentry: the error type, the message only when it is a standard JavaScript message that names nothing but code (any other message is replaced), the stack trace with file paths cut to the package (`neon/dist/...`), the CLI and Node.js versions, and whether it ran in CI or a coding agent. To opt-out of analytics data collection and error reports, specify `--no-analytics` or `--analytics false`.
1569
1582
 
1570
1583
  - <a id="version"></a>`-v, --version`
1571
1584
 
@@ -10,9 +10,10 @@ import { noPassthrough } from "../utils/flags.js";
10
10
  import { AGENT_SKILLS_SKIP_MESSAGE } from "../init/copy.js";
11
11
  import { canPickAgentsInteractively, skippableAgentPicker } from "../utils/agent_picker.js";
12
12
  import { skillsHelpValues, skillsYesHelp } from "../skills/catalog.js";
13
+ import { lockPath, neonSkillsUpdateCommand, readLockedSkills, refreshSkillsCheckCache, scopeLabel, skillsCheckCachePath, skillsStatusRows } from "../skills/freshness.js";
13
14
  import { confirmSkillsUpdate } from "../skills/wizard.js";
14
15
  import { assertSkillsCanRun, resolveSkillsPlan } from "../skills/plan.js";
15
- import { assertSkillsNode, neonSkillsRetryCommand, neonSkillsUpdateRetryCommand, runSkillsCli, skillsAddArgs, skillsMetadata, skillsUpdateArgs, skillsUpdateDetail, skillsUpdateHadNothing } from "../skills/run.js";
16
+ import { assertSkillsNode, neonSkillsRetryCommand, runSkillsCli, skillsAddArgs, skillsMetadata, skillsUpdateArgs, skillsUpdateDetail, skillsUpdateHadNothing } from "../skills/run.js";
16
17
  //#region src/commands/skills.ts
17
18
  var skills_exports = /* @__PURE__ */ __exportAll({
18
19
  aliases: () => aliases,
@@ -24,7 +25,6 @@ var skills_exports = /* @__PURE__ */ __exportAll({
24
25
  reportSkillsInstall: () => reportSkillsInstall,
25
26
  skillsInstallError: () => skillsInstallError
26
27
  });
27
- const scopeLabel = (scope) => scope === "project" ? "this directory" : "user-level";
28
28
  const coerceAgents = (value) => {
29
29
  if (value === void 0) return [];
30
30
  const list = Array.isArray(value) ? value : [value];
@@ -60,7 +60,17 @@ const builder = (argv) => argv.usage("$0 skills [command] [options]").command("u
60
60
  const skills = args.skill;
61
61
  if (Array.isArray(skills) && skills.length > 0) throw new Error("neon skills update does not take --skill. It refreshes every installed skill in this directory (or --global).");
62
62
  return true;
63
- }), (args) => updateHandler(args)).options({
63
+ }), (args) => updateHandler(args)).command("status", "Check whether installed Neon skills match their source", (yargs) => yargs.usage("$0 skills status [options]").options({ global: {
64
+ type: "boolean",
65
+ default: false,
66
+ describe: "Check user-level skills (skills CLI -g). Default is this directory"
67
+ } }).example("$0 skills status", "Neon skills in this directory's skills-lock.json").example("$0 skills status --global", "User-level Neon skills").strict().hide("agent").hide("skill").hide("yes").check((args) => {
68
+ noPassthrough("skills status")(args);
69
+ const agents = args.agent;
70
+ const skills = args.skill;
71
+ if (Array.isArray(agents) && agents.length > 0 || Array.isArray(skills) && skills.length > 0) throw new Error("neon skills status does not take --agent or --skill. It checks every Neon skill in this directory (or --global).");
72
+ return true;
73
+ }), (args) => statusHandler(args)).options({
64
74
  yes: {
65
75
  alias: "y",
66
76
  type: "boolean",
@@ -249,7 +259,28 @@ const updateHandler = async (props) => {
249
259
  title: "Skills"
250
260
  });
251
261
  out.end();
252
- if (failure !== void 0) throw new Error(failure.includes("needs npx (Node.js)") || failure.includes("needs Node.js") ? failure : `${failure}\nRetry with: ${neonSkillsUpdateRetryCommand(scope === "global")}`);
262
+ if (failure !== void 0) throw new Error(failure.includes("needs npx (Node.js)") || failure.includes("needs Node.js") ? failure : `${failure}\nRetry with: ${neonSkillsUpdateCommand(scope)}`);
263
+ };
264
+ const statusHandler = async (props) => {
265
+ const scope = props.global === true ? "global" : "project";
266
+ const skills = readLockedSkills(lockPath(scope, process.cwd()), scope);
267
+ const folders = skills.length === 0 ? {} : await refreshSkillsCheckCache({
268
+ cachePath: skillsCheckCachePath(props.configDir),
269
+ skills
270
+ });
271
+ const rows = skillsStatusRows(skills, folders);
272
+ const out = writer(props);
273
+ out.write(rows, {
274
+ fields: [
275
+ "scope",
276
+ "skill",
277
+ "status"
278
+ ],
279
+ title: "Skills",
280
+ emptyMessage: scope === "project" ? "No Neon skills in this directory's skills-lock.json. For user-level skills, run: neon skills status --global" : "No user-level Neon skills in the skills CLI lock."
281
+ });
282
+ out.end();
283
+ if (rows.some((row) => row.status === "outdated")) log.info("Update them: %s", neonSkillsUpdateCommand(scope));
253
284
  };
254
285
  //#endregion
255
286
  export { handler as a, skillsInstallError as c, describe as i, skills_exports as l, builder as n, installSkills as o, command as r, reportSkillsInstall as s, aliases as t };
package/dist/analytics.js CHANGED
@@ -296,6 +296,8 @@ const trackEvent = (event, properties) => {
296
296
  });
297
297
  log.debug("Sent CLI event: %s", event);
298
298
  };
299
+ /** Whether this process sends telemetry: analytics is on and the client was created. */
300
+ const isAnalyticsActive = () => client !== void 0;
299
301
  /**
300
302
  * Tracks cli_command_success. The account fields come from the lookup, which may still be in
301
303
  * flight when the command finishes, so they are read when the event is sent.
@@ -354,4 +356,4 @@ const getAnalyticsEventProperties = (args) => ({
354
356
  githubEnvVars: getGithubEnvVars(process.env)
355
357
  });
356
358
  //#endregion
357
- export { analyticsIdentity, analyticsMiddleware, closeAnalytics, commandSuccessProperties, getAnalyticsEventProperties, getErrorAnalyticsEventProperties, initAnalyticsClientMiddleware, recordCommandSuccessExtras, recordScaffoldedTemplate, sendError, storedCredentialAttribution, takeCommandSuccessExtras, telemetryCredential, trackCommandSuccess, trackEvent, useAnalyticsClientForTests };
359
+ export { analyticsIdentity, analyticsMiddleware, closeAnalytics, commandSuccessProperties, getAnalyticsEventProperties, getErrorAnalyticsEventProperties, initAnalyticsClientMiddleware, isAnalyticsActive, recordCommandSuccessExtras, recordScaffoldedTemplate, sendError, storedCredentialAttribution, takeCommandSuccessExtras, telemetryCredential, trackCommandSuccess, trackEvent, useAnalyticsClientForTests };
@@ -8,7 +8,7 @@ import { u as projects_exports } from "../_chunks/projects-BwI2Uo0z.js";
8
8
  import { a as link_exports } from "../_chunks/link-BgtkOBws.js";
9
9
  import { o as mcp_exports } from "../_chunks/mcp-Bo-7TKc7.js";
10
10
  import { c as plugins_exports } from "../_chunks/plugins-DqD7BDO1.js";
11
- import { l as skills_exports } from "../_chunks/skills-DyiBXKS3.js";
11
+ import { l as skills_exports } from "../_chunks/skills-BP6pIm7O.js";
12
12
  import { t as bootstrap_exports } from "../_chunks/bootstrap-wPZWa1sT.js";
13
13
  import { r as branches_exports } from "../_chunks/branches-BNa1xX1L.js";
14
14
  import { n as bucket_exports } from "../_chunks/bucket-BCdryTkD.js";
@@ -1 +1 @@
1
- export { t as aliases, n as builder, r as command, i as describe, a as handler, o as installSkills, s as reportSkillsInstall, c as skillsInstallError } from "../_chunks/skills-DyiBXKS3.js";
1
+ export { t as aliases, n as builder, r as command, i as describe, a as handler, o as installSkills, s as reportSkillsInstall, c as skillsInstallError } from "../_chunks/skills-BP6pIm7O.js";
package/dist/index.js CHANGED
@@ -6,7 +6,7 @@ import { r as defaultDir } from "./_chunks/paths-DMq0Lt7a.js";
6
6
  import { ensureConfigDir } from "./config.js";
7
7
  import { currentContextFile, enrichFromContext } from "./context.js";
8
8
  import { NETWORK_ERROR_MESSAGE, isNetworkError, matchErrorCode } from "./errors.js";
9
- import { analyticsMiddleware, closeAnalytics, initAnalyticsClientMiddleware, sendError, takeCommandSuccessExtras, trackCommandSuccess } from "./analytics.js";
9
+ import { analyticsMiddleware, closeAnalytics, initAnalyticsClientMiddleware, isAnalyticsActive, recordCommandSuccessExtras, sendError, takeCommandSuccessExtras, trackCommandSuccess } from "./analytics.js";
10
10
  import { AuthRefreshError, defaultClientID } from "./auth.js";
11
11
  import { getCliName } from "./utils/cli_name.js";
12
12
  import { c as ensureAuth } from "./_chunks/auth-CIUtXByB.js";
@@ -15,7 +15,7 @@ import { UsageError, failOnUsageError, isBareParentCommand, showHelp, showUsageE
15
15
  import { lazyCommands, preloadCommand } from "./commands/manifest.js";
16
16
  import { rewriteUnknownAgentArg } from "./init/plan.js";
17
17
  import { recoverFromSSO } from "./sso_recovery.js";
18
- import { notifyIfUpdateAvailable } from "./update_notifier.js";
18
+ import { notifyIfUpdateAvailable, shouldRunSkillsNotifier } from "./update_notifier.js";
19
19
  import { fillInArgs, resolveApiKeyFromEnv } from "./utils/middlewares.js";
20
20
  import { collapseRepeatedFlags, declaredOptions } from "./utils/repeated_flags.js";
21
21
  import { stagingFeaturesEnabledForArgv } from "./utils/staging.js";
@@ -208,19 +208,38 @@ async function handleError(msg, err, canRetry) {
208
208
  await preloadCommand(builder, hideBin(process.argv));
209
209
  while (attempts < MAX_ATTEMPTS) try {
210
210
  const args = await builder.argv;
211
+ const configDir = typeof args.configDir === "string" ? args.configDir : defaultDir;
212
+ if (isAnalyticsActive()) {
213
+ const { readSkillsTelemetry } = await import("./skills/freshness.js");
214
+ const skills = readSkillsTelemetry({
215
+ configDir,
216
+ cwd: process.cwd()
217
+ });
218
+ if (skills !== void 0) recordCommandSuccessExtras({ skills });
219
+ }
211
220
  trackCommandSuccess(args);
212
221
  if (args._.length === 0 || args.help) {
213
222
  await showHelp(builder);
214
223
  process.exit(0);
215
224
  }
216
225
  await closeAnalytics();
217
- notifyIfUpdateAvailable({
226
+ const notifier = {
218
227
  commandPath: args._.map(String),
219
- configDir: typeof args.configDir === "string" ? args.configDir : defaultDir,
228
+ configDir,
220
229
  currentBranch: Boolean(args.currentBranch),
221
- currentVersion: pkg_default.version,
222
230
  output: typeof args.output === "string" ? args.output : "table"
231
+ };
232
+ notifyIfUpdateAvailable({
233
+ ...notifier,
234
+ currentVersion: pkg_default.version
223
235
  });
236
+ if (shouldRunSkillsNotifier(notifier)) {
237
+ const { notifyIfSkillsOutdated } = await import("./skills/freshness.js");
238
+ notifyIfSkillsOutdated({
239
+ configDir: notifier.configDir,
240
+ cwd: process.cwd()
241
+ });
242
+ }
224
243
  break;
225
244
  } catch (err) {
226
245
  takeCommandSuccessExtras();
@@ -7,7 +7,7 @@ import { detectAgent } from "./detect_host.js";
7
7
  import { AgentSelectionSkipped, canPickAgentsInteractively } from "../utils/agent_picker.js";
8
8
  import { a as mcpInstallError, s as reportMcpInstall } from "../_chunks/mcp-Bo-7TKc7.js";
9
9
  import { l as reportPluginsInstall, o as installPlugins, s as pluginsInstallError } from "../_chunks/plugins-DqD7BDO1.js";
10
- import { c as skillsInstallError, o as installSkills, s as reportSkillsInstall } from "../_chunks/skills-DyiBXKS3.js";
10
+ import { c as skillsInstallError, o as installSkills, s as reportSkillsInstall } from "../_chunks/skills-BP6pIm7O.js";
11
11
  import { runAuthenticatedMcp } from "./auth.js";
12
12
  import { raceSigint } from "./cancelled.js";
13
13
  import { pickAgentSetupInteractively } from "./wizard.js";
@@ -0,0 +1,17 @@
1
+ import { globalLockPath, projectLockPath, readLockedSkills, refreshSkillsCheckCache } from "./freshness.js";
2
+ import { pathToFileURL } from "node:url";
3
+ //#region src/skills/check_worker.ts
4
+ const run = async () => {
5
+ const [cachePath, cwd] = process.argv.slice(2);
6
+ if (cachePath === void 0 || cwd === void 0) return;
7
+ try {
8
+ await refreshSkillsCheckCache({
9
+ cachePath,
10
+ skills: [...readLockedSkills(globalLockPath(), "global"), ...readLockedSkills(projectLockPath(cwd), "project")]
11
+ });
12
+ } catch {}
13
+ };
14
+ const entry = process.argv[1];
15
+ if (entry !== void 0 && import.meta.url === pathToFileURL(entry).href) run();
16
+ //#endregion
17
+ export {};
@@ -0,0 +1,397 @@
1
+ import { log } from "../log.js";
2
+ import { writeJsonFile } from "../utils/json_file.js";
3
+ import { showNotice } from "../notices.js";
4
+ import { AGENT_SKILLS_SOURCE, PLATFORMS_SKILLS_SOURCE } from "./catalog.js";
5
+ import { closeSync, constants, existsSync, fstatSync, openSync, readFileSync, statSync } from "node:fs";
6
+ import { join } from "node:path";
7
+ import { createHash } from "node:crypto";
8
+ import { fileURLToPath } from "node:url";
9
+ import { homedir } from "node:os";
10
+ import { spawn } from "node:child_process";
11
+ //#region src/skills/freshness.ts
12
+ const DAY_MS = 864e5;
13
+ const CACHE_FILE = "skills-check.json";
14
+ const FETCH_TIMEOUT_MS = 1e4;
15
+ const NEON_SOURCES = /* @__PURE__ */ new Set([AGENT_SKILLS_SOURCE, PLATFORMS_SKILLS_SOURCE]);
16
+ const scopeLabel = (scope) => scope === "project" ? "this directory" : "user-level";
17
+ const neonSkillsUpdateCommand = (scope) => scope === "global" ? "neon skills update --global -y" : "neon skills update -y";
18
+ /** Where the skills CLI keeps its user-level lock. */
19
+ const globalLockPath = (env = process.env) => env.XDG_STATE_HOME ? join(env.XDG_STATE_HOME, "skills", ".skill-lock.json") : join(homedir(), ".agents", ".skill-lock.json");
20
+ const projectLockPath = (cwd) => join(cwd, "skills-lock.json");
21
+ const lockPath = (scope, cwd) => scope === "global" ? globalLockPath() : projectLockPath(cwd);
22
+ const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
23
+ /** The Agent Skills name rule. A lock is repository content, and names reach the terminal. */
24
+ const SKILL_NAME = /^[a-z0-9][a-z0-9-]{0,63}$/;
25
+ const skillFolder = (skillPath) => skillPath.replace(/\\/g, "/").replace(/\/?SKILL\.md$/i, "");
26
+ /**
27
+ * The global lock's `skillFolderHash` is usually a tree SHA, but the skills CLI stores a
28
+ * content SHA-256 there when it installed from a Git clone; it tells them apart by length.
29
+ */
30
+ const parseSkillsLock = (raw, scope) => {
31
+ const value = JSON.parse(raw);
32
+ if (!isRecord(value) || !isRecord(value.skills)) return [];
33
+ const field = scope === "global" ? "skillFolderHash" : "computedHash";
34
+ const skills = [];
35
+ for (const [skill, entry] of Object.entries(value.skills)) {
36
+ if (!isRecord(entry)) continue;
37
+ const { source, skillPath, ref } = entry;
38
+ const hash = entry[field];
39
+ if (!SKILL_NAME.test(skill) || typeof source !== "string" || !NEON_SOURCES.has(source) || typeof skillPath !== "string" || typeof hash !== "string" || hash === "") continue;
40
+ const folder = skillFolder(skillPath);
41
+ if (folder === "") continue;
42
+ const hashKind = scope === "global" && /^[0-9a-f]{40}$/i.test(hash) ? "tree" : "content";
43
+ skills.push({
44
+ scope,
45
+ skill,
46
+ source,
47
+ folder,
48
+ hash,
49
+ hashKind,
50
+ pinned: ref !== void 0
51
+ });
52
+ }
53
+ return skills;
54
+ };
55
+ const readAllLockedSkills = (path, scope) => {
56
+ if (!existsSync(path)) return [];
57
+ const raw = readLockFile(path);
58
+ try {
59
+ return parseSkillsLock(raw, scope);
60
+ } catch {
61
+ throw new Error(`${path} is not valid JSON.`);
62
+ }
63
+ };
64
+ /** The skills the update check covers: every Neon skill in the lock except pinned ones. */
65
+ const readLockedSkills = (path, scope) => readAllLockedSkills(path, scope).filter((skill) => !skill.pinned);
66
+ const MAX_LOCK_BYTES = 1048576;
67
+ /**
68
+ * A project lock is repository content: a FIFO would block the open and `/dev/zero` the read,
69
+ * on every command run in that directory.
70
+ */
71
+ const readLockFile = (path) => {
72
+ const fd = openSync(path, constants.O_RDONLY | (constants.O_NONBLOCK ?? 0));
73
+ try {
74
+ const stat = fstatSync(fd);
75
+ if (!stat.isFile()) throw new Error(`${path} is not a regular file.`);
76
+ if (stat.size > MAX_LOCK_BYTES) throw new Error(`${path} is larger than ${MAX_LOCK_BYTES} bytes.`);
77
+ return readFileSync(fd, "utf8");
78
+ } finally {
79
+ closeSync(fd);
80
+ }
81
+ };
82
+ const folderKey = ({ source, folder }) => `${source}:${folder}`;
83
+ /** `undefined` when the remote side has not been fetched yet. */
84
+ const skillStatus = (skill, remote) => {
85
+ if (remote?.missing === true) return "not found upstream";
86
+ const latest = skill.hashKind === "tree" ? remote?.tree : remote?.content;
87
+ if (latest === void 0) return void 0;
88
+ return latest === skill.hash ? "up to date" : "outdated";
89
+ };
90
+ const parseRemoteFolder = (value) => {
91
+ if (!isRecord(value)) return void 0;
92
+ const { tree, content, missing, pending } = value;
93
+ if (tree !== void 0 && typeof tree !== "string" || content !== void 0 && typeof content !== "string" || missing !== void 0 && missing !== true || pending !== void 0 && pending !== true) return;
94
+ return {
95
+ ...tree === void 0 ? {} : { tree },
96
+ ...content === void 0 ? {} : { content },
97
+ ...missing === void 0 ? {} : { missing },
98
+ ...pending === void 0 ? {} : { pending }
99
+ };
100
+ };
101
+ const parseSkillsCheckCache = (raw) => {
102
+ try {
103
+ const value = JSON.parse(raw);
104
+ if (!isRecord(value) || typeof value.checkedAt !== "number" || !Number.isFinite(value.checkedAt) || !isRecord(value.folders)) return;
105
+ const folders = {};
106
+ for (const [key, entry] of Object.entries(value.folders)) {
107
+ const folder = parseRemoteFolder(entry);
108
+ if (folder !== void 0) folders[key] = folder;
109
+ }
110
+ const { attemptedAt } = value;
111
+ return {
112
+ checkedAt: value.checkedAt,
113
+ ...typeof attemptedAt === "number" && Number.isFinite(attemptedAt) ? { attemptedAt } : {},
114
+ folders
115
+ };
116
+ } catch {
117
+ return;
118
+ }
119
+ };
120
+ const readSkillsCheckCache = (cachePath) => {
121
+ try {
122
+ return parseSkillsCheckCache(readFileSync(cachePath, "utf8"));
123
+ } catch {
124
+ return;
125
+ }
126
+ };
127
+ const skillsCheckCachePath = (configDir) => join(configDir, CACHE_FILE);
128
+ /**
129
+ * The digest the skills CLI writes to `computedHash`: every file's folder-relative path and
130
+ * then its bytes, in `localeCompare` order of path.
131
+ */
132
+ const skillContentHash = (files) => {
133
+ const hash = createHash("sha256");
134
+ for (const file of [...files].sort((a, b) => a.path.localeCompare(b.path))) {
135
+ hash.update(file.path);
136
+ hash.update(file.bytes);
137
+ }
138
+ return hash.digest("hex");
139
+ };
140
+ const gitBlobSha = (bytes) => createHash("sha1").update(`blob ${bytes.byteLength}\0`).update(bytes).digest("hex");
141
+ const isTreeEntry = (value) => isRecord(value) && typeof value.path === "string" && typeof value.mode === "string" && typeof value.type === "string" && typeof value.sha === "string";
142
+ const githubHeaders = (env) => {
143
+ const token = env.GITHUB_TOKEN || env.GH_TOKEN;
144
+ return {
145
+ Accept: "application/vnd.github+json",
146
+ "User-Agent": "neon-cli",
147
+ ...token ? { Authorization: `Bearer ${token}` } : {}
148
+ };
149
+ };
150
+ const fetchTree = async (source, env) => {
151
+ const response = await fetch(`https://api.github.com/repos/${source}/git/trees/HEAD?recursive=1`, {
152
+ headers: githubHeaders(env),
153
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS)
154
+ });
155
+ if (!response.ok) {
156
+ if ((response.status === 403 || response.status === 429) && response.headers.get("x-ratelimit-remaining") === "0") throw new Error(`GitHub API rate limit reached while checking ${source}. Set GITHUB_TOKEN and retry.`);
157
+ throw new Error(`GitHub returned ${response.status} for the file tree of ${source}.`);
158
+ }
159
+ const body = await response.json();
160
+ if (!isRecord(body) || !Array.isArray(body.tree)) throw new Error(`GitHub returned an unexpected file tree for ${source}.`);
161
+ if (body.truncated === true) throw new Error(`GitHub truncated the file tree of ${source}.`);
162
+ return body.tree.filter(isTreeEntry);
163
+ };
164
+ /** The files the skills CLI hashes: regular files, outside `.git` and `node_modules`. */
165
+ const folderFiles = (tree, folder) => tree.filter((entry) => {
166
+ if (entry.type !== "blob" || entry.mode === "120000") return false;
167
+ if (!entry.path.startsWith(`${folder}/`)) return false;
168
+ const dirs = entry.path.slice(folder.length + 1).split("/").slice(0, -1);
169
+ return !dirs.includes(".git") && !dirs.includes("node_modules");
170
+ });
171
+ const fetchContentHash = async (source, folder, tree) => {
172
+ const files = await Promise.all(folderFiles(tree, folder).map(async (entry) => {
173
+ const path = entry.path.split("/").map(encodeURIComponent).join("/");
174
+ const response = await fetch(`https://raw.githubusercontent.com/${source}/HEAD/${path}`, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
175
+ if (!response.ok) throw new Error(`GitHub returned ${response.status} for ${entry.path} in ${source}.`);
176
+ const bytes = new Uint8Array(await response.arrayBuffer());
177
+ if (gitBlobSha(bytes) !== entry.sha) throw new Error(`${source} changed during the check. Retry.`);
178
+ return {
179
+ path: entry.path.slice(folder.length + 1),
180
+ bytes
181
+ };
182
+ }));
183
+ return skillContentHash(files);
184
+ };
185
+ /**
186
+ * The folders to fetch: the given skills, plus every folder already in the cache so
187
+ * results for other directories stay current.
188
+ */
189
+ const folderRequests = (skills, previous) => {
190
+ const requests = /* @__PURE__ */ new Map();
191
+ const add = (request) => {
192
+ const key = folderKey(request);
193
+ const existing = requests.get(key);
194
+ requests.set(key, {
195
+ ...request,
196
+ content: request.content || existing?.content === true
197
+ });
198
+ };
199
+ for (const [key, remote] of Object.entries(previous)) {
200
+ const split = key.lastIndexOf(":");
201
+ const source = key.slice(0, split);
202
+ if (split <= 0 || !NEON_SOURCES.has(source)) continue;
203
+ add({
204
+ source,
205
+ folder: key.slice(split + 1),
206
+ content: remote.content !== void 0
207
+ });
208
+ }
209
+ for (const skill of skills) add({
210
+ source: skill.source,
211
+ folder: skill.folder,
212
+ content: skill.hashKind === "content"
213
+ });
214
+ return [...requests.values()];
215
+ };
216
+ /**
217
+ * Fetches the default-branch state of every requested skill folder and writes it to the
218
+ * cache. File contents are downloaded only for folders whose tree changed since the cache.
219
+ */
220
+ const refreshSkillsCheckCache = async ({ cachePath, skills, env = process.env, now = Date.now() }) => {
221
+ const previous = readSkillsCheckCache(cachePath)?.folders ?? {};
222
+ const requests = folderRequests(skills, previous);
223
+ const folders = {};
224
+ for (const source of new Set(requests.map((request) => request.source))) {
225
+ const tree = await fetchTree(source, env);
226
+ for (const request of requests) {
227
+ if (request.source !== source) continue;
228
+ const key = folderKey(request);
229
+ const node = tree.find((entry) => entry.type === "tree" && entry.path === request.folder);
230
+ if (node === void 0) {
231
+ folders[key] = { missing: true };
232
+ continue;
233
+ }
234
+ const prior = previous[key];
235
+ let content = prior?.tree === node.sha ? prior.content : void 0;
236
+ if (request.content && content === void 0) content = await fetchContentHash(source, request.folder, tree);
237
+ folders[key] = {
238
+ tree: node.sha,
239
+ ...content === void 0 ? {} : { content }
240
+ };
241
+ }
242
+ }
243
+ writeJsonFile(cachePath, {
244
+ checkedAt: now,
245
+ folders
246
+ }, "skills check cache");
247
+ return folders;
248
+ };
249
+ const skillsStatusRows = (skills, folders) => skills.map((skill) => {
250
+ const status = skillStatus(skill, folders[folderKey(skill)]);
251
+ if (status === void 0) throw new Error(`No result for ${skill.skill} from ${skill.source}. Retry the check.`);
252
+ return {
253
+ scope: scopeLabel(skill.scope),
254
+ skill: skill.skill,
255
+ status
256
+ };
257
+ });
258
+ const formatSkillsNotice = (outdated) => {
259
+ if (outdated.length === 0) return void 0;
260
+ const scopes = ["global", "project"].filter((scope) => outdated.some((skill) => skill.scope === scope));
261
+ return [
262
+ "Neon agent skills have updates:",
263
+ ...scopes.map((scope) => ` ${scopeLabel(scope)}: ${outdated.filter((skill) => skill.scope === scope).map((skill) => skill.skill).join(", ")}`),
264
+ "Update them:",
265
+ ...scopes.map((scope) => ` ${neonSkillsUpdateCommand(scope)}`)
266
+ ].join("\n");
267
+ };
268
+ /**
269
+ * `pending` folders are left alone until the next daily check. A lock written after the last
270
+ * check (an install or an update) holds back the warning and checks every skill again,
271
+ * because the cache can be older than what was just installed.
272
+ */
273
+ const planSkillsCheck = ({ skills, cache, lockModifiedAt, now }) => {
274
+ const remote = (skill) => cache?.folders[folderKey(skill)];
275
+ const settled = (skill) => remote(skill)?.pending !== true;
276
+ const lockChanged = cache !== void 0 && lockModifiedAt > cache.checkedAt;
277
+ const outdated = lockChanged ? [] : skills.filter((skill) => settled(skill) && skillStatus(skill, remote(skill)) === "outdated");
278
+ const toCheck = skills.filter((skill) => settled(skill) && (lockChanged || skillStatus(skill, remote(skill)) === void 0));
279
+ return {
280
+ outdated,
281
+ toCheck,
282
+ refresh: cache === void 0 || toCheck.length > 0 || now - (cache.attemptedAt ?? cache.checkedAt) >= DAY_MS
283
+ };
284
+ };
285
+ const spawnSkillsCheckWorker = (cachePath, cwd) => {
286
+ const workerPath = fileURLToPath(new URL("./check_worker.js", import.meta.url));
287
+ if (!existsSync(workerPath)) return;
288
+ try {
289
+ const child = spawn(process.execPath, [
290
+ workerPath,
291
+ cachePath,
292
+ cwd
293
+ ], {
294
+ detached: true,
295
+ stdio: "ignore",
296
+ windowsHide: true
297
+ });
298
+ child.on("error", (error) => {
299
+ log.debug("Skills check failed to start: %s", error.message);
300
+ });
301
+ child.unref();
302
+ } catch (error) {
303
+ log.debug("Could not start the skills check: %s", error instanceof Error ? error.message : String(error));
304
+ }
305
+ };
306
+ /** Every Neon skill in the lock, pinned ones included. */
307
+ const readLock = (path, scope) => ({
308
+ skills: readAllLockedSkills(path, scope),
309
+ modifiedAt: statSync(path, { throwIfNoEntry: false })?.mtimeMs ?? 0
310
+ });
311
+ const readLocks = (cwd) => ({
312
+ global: readLock(globalLockPath(), "global"),
313
+ project: readLock(projectLockPath(cwd), "project")
314
+ });
315
+ /**
316
+ * `outdated` is `null` when the cache cannot answer: no successful check yet, a check still
317
+ * pending for the folder, a lock written after the last check, a folder gone upstream, or a
318
+ * pinned skill.
319
+ */
320
+ const skillsTelemetry = (locks, cache) => {
321
+ const scope = ({ skills, modifiedAt }) => {
322
+ if (skills.length === 0) return { installed: false };
323
+ const known = cache !== void 0 && cache.checkedAt > 0 && modifiedAt <= cache.checkedAt;
324
+ return {
325
+ installed: true,
326
+ skills: Object.fromEntries(skills.map((skill) => {
327
+ const remote = cache?.folders[folderKey(skill)];
328
+ const status = known && !skill.pinned && remote?.pending !== true ? skillStatus(skill, remote) : void 0;
329
+ return [skill.skill, {
330
+ hash: skill.hash,
331
+ outdated: status === "outdated" ? true : status === "up to date" ? false : null
332
+ }];
333
+ }))
334
+ };
335
+ };
336
+ return {
337
+ checkedAt: cache !== void 0 && cache.checkedAt > 0 ? cache.checkedAt : null,
338
+ global: scope(locks.global),
339
+ project: scope(locks.project)
340
+ };
341
+ };
342
+ /**
343
+ * Reads only local files. `undefined` when a lock can't be read, which leaves the property
344
+ * off the event rather than reporting skills as not installed.
345
+ */
346
+ const readSkillsTelemetry = ({ configDir, cwd }) => {
347
+ try {
348
+ return skillsTelemetry(readLocks(cwd), readSkillsCheckCache(skillsCheckCachePath(configDir)));
349
+ } catch (error) {
350
+ log.debug("Leaving skills out of telemetry: %s", error instanceof Error ? error.message : String(error));
351
+ return;
352
+ }
353
+ };
354
+ /**
355
+ * Warns at most once a day when a Neon skill in the user-level lock or this directory's
356
+ * `skills-lock.json` is behind its source, from a cache a background worker refreshes daily.
357
+ */
358
+ const notifyIfSkillsOutdated = ({ configDir, cwd, now = Date.now() }) => {
359
+ let locks;
360
+ try {
361
+ locks = readLocks(cwd);
362
+ } catch (error) {
363
+ log.debug("Skipping the skills check: %s", error instanceof Error ? error.message : String(error));
364
+ return;
365
+ }
366
+ const skills = [...locks.global.skills, ...locks.project.skills].filter((skill) => !skill.pinned);
367
+ if (skills.length === 0) return;
368
+ const cachePath = skillsCheckCachePath(configDir);
369
+ const cache = readSkillsCheckCache(cachePath);
370
+ const plan = planSkillsCheck({
371
+ skills,
372
+ cache,
373
+ lockModifiedAt: Math.max(locks.global.modifiedAt, locks.project.modifiedAt),
374
+ now
375
+ });
376
+ const notice = formatSkillsNotice(plan.outdated);
377
+ if (notice !== void 0) showNotice({
378
+ configDir,
379
+ id: "skills",
380
+ intervalMs: DAY_MS,
381
+ message: notice,
382
+ now
383
+ });
384
+ if (!plan.refresh) return;
385
+ const folders = { ...cache?.folders };
386
+ for (const skill of plan.toCheck) folders[folderKey(skill)] = {
387
+ ...folders[folderKey(skill)],
388
+ pending: true
389
+ };
390
+ if (writeJsonFile(cachePath, {
391
+ checkedAt: cache?.checkedAt ?? 0,
392
+ attemptedAt: now,
393
+ folders
394
+ }, "skills check cache")) spawnSkillsCheckWorker(cachePath, cwd);
395
+ };
396
+ //#endregion
397
+ export { formatSkillsNotice, globalLockPath, lockPath, neonSkillsUpdateCommand, notifyIfSkillsOutdated, parseSkillsCheckCache, parseSkillsLock, planSkillsCheck, projectLockPath, readLockedSkills, readSkillsTelemetry, refreshSkillsCheckCache, scopeLabel, skillContentHash, skillStatus, skillsCheckCachePath, skillsStatusRows, skillsTelemetry };
@@ -59,7 +59,6 @@ const neonSkillsRetryCommand = (options) => {
59
59
  args.push("-y");
60
60
  return `neon ${args.map(quoteNpxArg).join(" ")}`;
61
61
  };
62
- const neonSkillsUpdateRetryCommand = (global) => global ? "neon skills update --global -y" : "neon skills update -y";
63
62
  const quoteNpxArg = (part) => /[\s"'\\*]/.test(part) ? `'${part.replace(/'/g, `'\\''`)}'` : part;
64
63
  const npxCommand = (args) => `npx ${args.map(quoteNpxArg).join(" ")}`;
65
64
  const ANSI = /\u001b\[[0-9;]*[A-Za-z]/g;
@@ -116,4 +115,4 @@ const runSkillsCli = async (options) => {
116
115
  const isCommandMissing = (error) => typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT";
117
116
  const isExecaFailure = (error) => typeof error === "object" && error !== null && "shortMessage" in error && typeof error.shortMessage === "string";
118
117
  //#endregion
119
- export { SKILLS_MIN_NODE, assertSkillsNode, neonSkillsRetryCommand, neonSkillsUpdateRetryCommand, nodeMeetsMinimum, npxCommand, quoteNpxArg, runSkillsCli, skillsAddArgs, skillsChildEnv, skillsMetadata, skillsUpdateArgs, skillsUpdateDetail, skillsUpdateHadNothing };
118
+ export { SKILLS_MIN_NODE, assertSkillsNode, neonSkillsRetryCommand, nodeMeetsMinimum, npxCommand, quoteNpxArg, runSkillsCli, skillsAddArgs, skillsChildEnv, skillsMetadata, skillsUpdateArgs, skillsUpdateDetail, skillsUpdateHadNothing };
@@ -91,6 +91,7 @@ const test = test$1.extend({
91
91
  ...options.cwd ? { cwd: options.cwd } : {},
92
92
  env: {
93
93
  PATH: `${join(process.cwd(), "mocks/bin")}:${process.env.PATH}`,
94
+ NO_UPDATE_NOTIFIER: "1",
94
95
  ...options.env ?? {}
95
96
  }
96
97
  });
@@ -145,6 +145,20 @@ const shouldRunUpdateNotifier = ({ commandPath, currentBranch, disabled, isCi: r
145
145
  commandPath,
146
146
  currentBranch
147
147
  });
148
+ /**
149
+ * The skills notice drops the terminal checks: coding agents run the CLI without a TTY,
150
+ * and they are the ones that act on it. `neon skills` itself reports skill state.
151
+ */
152
+ const shouldRunSkillsNotifier = ({ commandPath, currentBranch, output }) => commandPath[0] !== "skills" && commandPath[0] !== "skill" && shouldRunUpdateNotifier({
153
+ commandPath,
154
+ currentBranch,
155
+ disabled: "NO_UPDATE_NOTIFIER" in process.env,
156
+ isCi: isCi(),
157
+ isPackaged: isPackagedExecutable(),
158
+ output,
159
+ stderrIsTty: true,
160
+ stdoutIsTty: true
161
+ });
148
162
  const notifyIfUpdateAvailable = ({ commandPath, configDir, currentBranch, currentVersion, output }) => {
149
163
  if (!shouldRunUpdateNotifier({
150
164
  commandPath,
@@ -204,4 +218,4 @@ const recordLatestVersion = (cachePath, source, latestVersion) => {
204
218
  });
205
219
  };
206
220
  //#endregion
207
- export { cacheForUpdateSource, findCliInstalls, formatDuplicateInstallNotice, formatUpdateNotice, notifyIfUpdateAvailable, parseUpdateCheckCache, recordLatestVersion, shouldRefreshUpdateCheck, shouldRunUpdateNotifier, spawnUpdateWorkerProcess, writeUpdateCheckCache };
221
+ export { cacheForUpdateSource, findCliInstalls, formatDuplicateInstallNotice, formatUpdateNotice, notifyIfUpdateAvailable, parseUpdateCheckCache, recordLatestVersion, shouldRefreshUpdateCheck, shouldRunSkillsNotifier, shouldRunUpdateNotifier, spawnUpdateWorkerProcess, writeUpdateCheckCache };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "neon",
3
- "version": "8.11.4",
3
+ "version": "8.12.1",
4
4
  "description": "CLI tool for Neon, the cloud backend primitives built around Lakebase Postgres",
5
5
  "keywords": [
6
6
  "neon",
@@ -72,10 +72,10 @@
72
72
  "yaml": "^2.9.0",
73
73
  "yargs": "17.7.2",
74
74
  "yoctocolors": "^2.1.2",
75
- "@neon/config-runtime": "1.10.3",
76
75
  "@neon/config": "1.9.4",
77
- "@neon/sdk": "7.5.0",
78
- "@neon/realtime": "0.1.2"
76
+ "@neon/config-runtime": "1.10.3",
77
+ "@neon/realtime": "0.1.2",
78
+ "@neon/sdk": "7.5.0"
79
79
  },
80
80
  "optionalDependencies": {
81
81
  "@napi-rs/keyring": "1.3.0",
@@ -111,11 +111,11 @@
111
111
  "tsx": "4.22.3",
112
112
  "typescript": "^5.9.0",
113
113
  "vitest": "^3.0.9",
114
+ "@neon-internals/cli-core": "0.0.0",
114
115
  "@neon-internals/env-core": "0.0.35",
115
- "@neon/functions": "0.11.0",
116
- "@neon/env": "1.6.5",
117
116
  "@neon/e2e-harness": "0.0.0",
118
- "@neon-internals/cli-core": "0.0.0"
117
+ "@neon/env": "1.6.5",
118
+ "@neon/functions": "0.11.0"
119
119
  },
120
120
  "publishConfig": {
121
121
  "access": "public",