claude-code-modes 0.2.10 → 0.2.11

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-modes",
3
- "version": "0.2.10",
3
+ "version": "0.2.11",
4
4
  "description": "Behaviorally-tuned system prompts for Claude Code",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/build-info.ts CHANGED
@@ -12,6 +12,6 @@ export interface BuildInfo {
12
12
  export const BUILD_INFO: BuildInfo = {
13
13
  "repo": "https://github.com/nklisch/claude-code-modes",
14
14
  "branch": null,
15
- "commit": "0938ea3",
15
+ "commit": "1078494",
16
16
  "dirty": false
17
17
  };
package/src/cli.ts CHANGED
@@ -11,6 +11,12 @@ import { runInspectCommand } from "./inspect.js";
11
11
  import { runUpdateCommand } from "./update.js";
12
12
  import { printUsage } from "./usage.js";
13
13
  import { formatVersion } from "./version.js";
14
+ import {
15
+ shouldRunCheck,
16
+ startVersionCheck,
17
+ awaitAndNag,
18
+ type VersionCheckHandle,
19
+ } from "./version-check.js";
14
20
 
15
21
  async function main(): Promise<void> {
16
22
  const argv = process.argv.slice(2);
@@ -39,6 +45,17 @@ async function main(): Promise<void> {
39
45
  process.exit(0);
40
46
  }
41
47
 
48
+ // Fire version check early so the fetch overlaps with arg parsing / config /
49
+ // prompt assembly. Skipped when stderr is not a TTY (piped/CI), on the
50
+ // update subcommand, on --version, and when CLAUDE_MODE_NO_UPDATE_CHECK=1.
51
+ const versionCheck: VersionCheckHandle | null = shouldRunCheck(
52
+ argv,
53
+ process.env,
54
+ process.stderr.isTTY === true,
55
+ )
56
+ ? startVersionCheck()
57
+ : null;
58
+
42
59
  // Prompts directory — embedded prompts are primary; disk is fallback
43
60
  const promptsDir = join(import.meta.dir, "..", "prompts");
44
61
 
@@ -112,8 +129,10 @@ async function main(): Promise<void> {
112
129
  promptsDir,
113
130
  });
114
131
 
115
- // --print: output the prompt itself (for debugging)
132
+ // --print: output the prompt itself (for debugging); abort the check so
133
+ // a background fetch doesn't keep the process alive after stdout is written.
116
134
  if (parsed.modifiers.print) {
135
+ versionCheck?.abort();
117
136
  process.stdout.write(prompt);
118
137
  process.exit(0);
119
138
  }
@@ -135,6 +154,10 @@ async function main(): Promise<void> {
135
154
  // Add passthrough args
136
155
  claudeArgs.push(...parsed.passthroughArgs);
137
156
 
157
+ // Await the version check result and print a nag if an update is available.
158
+ // Total latency is capped at 1 s by awaitAndNag's internal timeout.
159
+ await awaitAndNag(versionCheck);
160
+
138
161
  // Spawn claude directly — gives it full TTY ownership
139
162
  const proc = Bun.spawn(["claude", ...claudeArgs], {
140
163
  stdio: ["inherit", "inherit", "inherit"],
@@ -0,0 +1,277 @@
1
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { homedir } from "node:os";
4
+ import { VERSION } from "./version.js";
5
+ import {
6
+ defaultTransport,
7
+ fetchLatestRelease,
8
+ compareSemver,
9
+ type UpdateTransport,
10
+ } from "./update.js";
11
+
12
+ // ----------------------------------------------------------------------
13
+ // Constants
14
+ // ----------------------------------------------------------------------
15
+
16
+ /** How long a cached "latest version" entry is considered fresh. */
17
+ const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
18
+
19
+ /** Hard ceiling on time spent waiting for the fetch before launching claude. */
20
+ const FETCH_RACE_TIMEOUT_MS = 1000;
21
+
22
+ /** Pause after printing the nag, so the user can read it before claude takes the TTY. */
23
+ const NAG_PAUSE_MS = 1500;
24
+
25
+ /** Env-var name that disables the check entirely. */
26
+ const OPT_OUT_ENV = "CLAUDE_MODE_NO_UPDATE_CHECK";
27
+
28
+ /** Subcommand names that should skip the check. */
29
+ const SKIPPED_SUBCOMMANDS = new Set(["update"]);
30
+
31
+ // ----------------------------------------------------------------------
32
+ // Public types
33
+ // ----------------------------------------------------------------------
34
+
35
+ export interface VersionCheckCache {
36
+ /** Unix epoch milliseconds when this entry was written. */
37
+ checkedAt: number;
38
+ /** "0.2.11" — without a leading "v". Comparable to VERSION. */
39
+ latestVersion: string;
40
+ }
41
+
42
+ /**
43
+ * The handle returned by startVersionCheck. Consumers race this against a
44
+ * timeout right before launching claude, then call awaitAndNag on the result.
45
+ */
46
+ export interface VersionCheckHandle {
47
+ /**
48
+ * Resolves with the latest known version (from cache or freshly fetched),
49
+ * or null if no version could be determined within the budget. NEVER rejects.
50
+ */
51
+ result: Promise<string | null>;
52
+ /** Aborts the in-flight fetch, if any. Safe to call multiple times. */
53
+ abort(): void;
54
+ }
55
+
56
+ // ----------------------------------------------------------------------
57
+ // Pure decision: should we run the check at all?
58
+ // ----------------------------------------------------------------------
59
+
60
+ /**
61
+ * Pure decision function — given argv (process.argv.slice(2)), env, and the
62
+ * stderr-isTTY flag, decide whether to run the version check.
63
+ *
64
+ * Skips when:
65
+ * - CLAUDE_MODE_NO_UPDATE_CHECK is set to a truthy value
66
+ * - argv[0] === "update"
67
+ * - argv contains "--version" before any "--"
68
+ * - stderr is not a TTY (output is being captured)
69
+ */
70
+ export function shouldRunCheck(
71
+ argv: readonly string[],
72
+ env: NodeJS.ProcessEnv,
73
+ stderrIsTty: boolean,
74
+ ): boolean {
75
+ if (!stderrIsTty) return false;
76
+
77
+ const optOut = env[OPT_OUT_ENV];
78
+ if (optOut === "1" || optOut === "true") return false;
79
+
80
+ if (argv.length > 0 && SKIPPED_SUBCOMMANDS.has(argv[0])) return false;
81
+
82
+ // --version must stand alone (cli.ts enforces this elsewhere); the check
83
+ // here is to skip even when `--version` appears as the only own-arg.
84
+ const dashDashIdx = argv.indexOf("--");
85
+ const ownArgs = dashDashIdx >= 0 ? argv.slice(0, dashDashIdx) : argv;
86
+ if (ownArgs.includes("--version")) return false;
87
+
88
+ return true;
89
+ }
90
+
91
+ // ----------------------------------------------------------------------
92
+ // Cache I/O — pure-ish (touches disk; tests inject path)
93
+ // ----------------------------------------------------------------------
94
+
95
+ /**
96
+ * Returns the absolute path to the cache file. Honors XDG_CACHE_HOME on Linux
97
+ * and macOS; falls back to ~/.cache/claude-mode/version-check.json.
98
+ */
99
+ export function getCachePath(env: NodeJS.ProcessEnv = process.env): string {
100
+ const xdg = env.XDG_CACHE_HOME;
101
+ const baseDir = xdg && xdg.length > 0 ? xdg : join(homedir(), ".cache");
102
+ return join(baseDir, "claude-mode", "version-check.json");
103
+ }
104
+
105
+ /**
106
+ * Reads the cache file. Returns null if missing, unreadable, or malformed —
107
+ * never throws. The caller treats null as "no cache" and proceeds.
108
+ */
109
+ export function readCache(path: string): VersionCheckCache | null {
110
+ try {
111
+ const raw = readFileSync(path, "utf8");
112
+ const parsed = JSON.parse(raw) as Partial<VersionCheckCache>;
113
+ if (
114
+ typeof parsed.checkedAt !== "number" ||
115
+ typeof parsed.latestVersion !== "string" ||
116
+ parsed.latestVersion.length === 0
117
+ ) {
118
+ return null;
119
+ }
120
+ return { checkedAt: parsed.checkedAt, latestVersion: parsed.latestVersion };
121
+ } catch {
122
+ return null;
123
+ }
124
+ }
125
+
126
+ /**
127
+ * Writes the cache file. Creates the parent directory if missing. Swallows
128
+ * I/O errors (the check is a courtesy — never a blocker).
129
+ */
130
+ export function writeCache(path: string, cache: VersionCheckCache): void {
131
+ try {
132
+ mkdirSync(dirname(path), { recursive: true });
133
+ writeFileSync(path, JSON.stringify(cache));
134
+ } catch {
135
+ // best-effort
136
+ }
137
+ }
138
+
139
+ /** Pure: is this cache entry younger than CACHE_TTL_MS? */
140
+ export function isCacheFresh(
141
+ cache: VersionCheckCache,
142
+ now: number = Date.now(),
143
+ ): boolean {
144
+ return now - cache.checkedAt < CACHE_TTL_MS;
145
+ }
146
+
147
+ // ----------------------------------------------------------------------
148
+ // Orchestrator: fire the check, return a handle
149
+ // ----------------------------------------------------------------------
150
+
151
+ export interface StartVersionCheckOptions {
152
+ transport?: UpdateTransport;
153
+ cachePath?: string;
154
+ now?: () => number;
155
+ /** Where the in-flight notice is written. Defaults to process.stderr. */
156
+ stderr?: NodeJS.WritableStream;
157
+ }
158
+
159
+ /**
160
+ * Fires the version check in the background. Returns immediately with a
161
+ * handle whose `result` promise resolves to the latest version (string) or
162
+ * null. NEVER throws synchronously; the result promise NEVER rejects.
163
+ *
164
+ * If the cache is fresh, resolves immediately with the cached value (no
165
+ * network request, no stderr noise).
166
+ *
167
+ * If the cache is stale or missing, prints a one-line "Checking for newer
168
+ * versions..." notice to stderr, then fires the fetch. The fetch's success
169
+ * updates the cache as a side effect. If aborted or it errors, resolves
170
+ * with the stale cache value (if any) or null.
171
+ */
172
+ export function startVersionCheck(
173
+ opts: StartVersionCheckOptions = {},
174
+ ): VersionCheckHandle {
175
+ const transport = opts.transport ?? defaultTransport;
176
+ const cachePath = opts.cachePath ?? getCachePath();
177
+ const now = opts.now ?? Date.now;
178
+ const stderr = opts.stderr ?? process.stderr;
179
+
180
+ const cache = readCache(cachePath);
181
+ const cachedVersion = cache?.latestVersion ?? null;
182
+
183
+ // Fresh cache → no network, no notice
184
+ if (cache && isCacheFresh(cache, now())) {
185
+ return {
186
+ result: Promise.resolve(cachedVersion),
187
+ abort: () => {},
188
+ };
189
+ }
190
+
191
+ // We're going to fetch — tell the user what's happening so a slow GitHub
192
+ // request doesn't read as a hang. Single line; the nag (if any) appears on
193
+ // a new line below.
194
+ stderr.write("Checking for newer versions of claude-mode...\n");
195
+
196
+ const controller = new AbortController();
197
+ let aborted = false;
198
+
199
+ const result: Promise<string | null> = (async () => {
200
+ try {
201
+ const release = await Promise.race([
202
+ fetchLatestRelease(transport),
203
+ abortPromise(controller.signal),
204
+ ]);
205
+ if (aborted) return cachedVersion;
206
+ writeCache(cachePath, {
207
+ checkedAt: now(),
208
+ latestVersion: release.version,
209
+ });
210
+ return release.version;
211
+ } catch {
212
+ return cachedVersion;
213
+ }
214
+ })();
215
+
216
+ return {
217
+ result,
218
+ abort: () => {
219
+ aborted = true;
220
+ controller.abort();
221
+ },
222
+ };
223
+ }
224
+
225
+ /** Resolves to never; rejects with an Error when the signal aborts. */
226
+ function abortPromise(signal: AbortSignal): Promise<never> {
227
+ return new Promise((_, reject) => {
228
+ if (signal.aborted) {
229
+ reject(new Error("aborted"));
230
+ return;
231
+ }
232
+ signal.addEventListener("abort", () => reject(new Error("aborted")), { once: true });
233
+ });
234
+ }
235
+
236
+ // ----------------------------------------------------------------------
237
+ // Final-step: race against timeout, print nag, sleep
238
+ // ----------------------------------------------------------------------
239
+
240
+ /**
241
+ * Awaits the version-check handle with a hard timeout. If the result shows
242
+ * a newer version than `current`, writes a one-line nag to stderr and
243
+ * sleeps for NAG_PAUSE_MS so the user can read it.
244
+ *
245
+ * Always returns; never throws. Safe to call even when the check was never
246
+ * fired (caller passes null).
247
+ */
248
+ export async function awaitAndNag(
249
+ handle: VersionCheckHandle | null,
250
+ current: string = VERSION,
251
+ stderr: NodeJS.WritableStream = process.stderr,
252
+ sleep: (ms: number) => Promise<void> = defaultSleep,
253
+ ): Promise<void> {
254
+ if (!handle) return;
255
+
256
+ const latest = await Promise.race([
257
+ handle.result,
258
+ sleep(FETCH_RACE_TIMEOUT_MS).then(() => null),
259
+ ]);
260
+
261
+ // If we timed out, abort the underlying fetch so it doesn't keep the
262
+ // process alive after claude exits.
263
+ if (latest === null) handle.abort();
264
+
265
+ if (!latest) return;
266
+ if (compareSemver(latest, current) <= 0) return;
267
+
268
+ stderr.write(
269
+ `claude-mode update available: ${current} -> ${latest}. ` +
270
+ `Run \`claude-mode update\` to install.\n`,
271
+ );
272
+ await sleep(NAG_PAUSE_MS);
273
+ }
274
+
275
+ function defaultSleep(ms: number): Promise<void> {
276
+ return new Promise((resolve) => setTimeout(resolve, ms));
277
+ }