claude-slim 2.8.1 → 2.9.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
@@ -149,6 +149,7 @@ Then just type `/claude-slim` in any session.
149
149
  /claude-slim scan --json # Machine-readable JSON output
150
150
  /claude-slim scan --lookback-days 30 # Treat skills idle for 30+ days as unused
151
151
  /claude-slim doctor # Check scanner prerequisites and data fidelity
152
+ /claude-slim check-update # Is a newer version published?
152
153
  /claude-slim restore # Bring back anything you disabled
153
154
  ```
154
155
 
@@ -161,10 +162,26 @@ npx claude-slim clean --auto # Non-interactive, Tier 1 only (CI/scri
161
162
  npx claude-slim clean --lookback-days N # Tune the unused-skill detection window
162
163
  npx claude-slim scan # Report only
163
164
  npx claude-slim doctor # Diagnose Node/Claude/session-log readiness
165
+ npx claude-slim doctor --offline # Same, without the version check (no network)
166
+ npx claude-slim check-update # Report-only version check
164
167
  npx claude-slim restore # Undo
165
168
  npx claude-slim report # Show savings from last clean
166
169
  ```
167
170
 
171
+ ### Staying current
172
+
173
+ An outdated claude-slim doesn't just miss features — it reports **wrong numbers**. Versions before 2.8.0 inflated the startup estimate roughly 8×, and nothing told you that you were behind.
174
+
175
+ `doctor` now compares your installed version against npm and prints the upgrade command for how you actually installed it:
176
+
177
+ ```
178
+ ! Version: 2.0.0 installed, 2.8.1 available
179
+ Outdated versions report wrong token totals. Update with:
180
+ claude plugin marketplace update claude-slim && claude plugin update claude-slim@claude-slim
181
+ ```
182
+
183
+ claude-slim never updates itself — that's your package manager's job, and writing into a directory `claude plugin` owns is how installs get corrupted. It only tells you. The check is the tool's only outbound request, is skipped with `--offline`, caches for 24h, and fails open when you're offline.
184
+
168
185
  ---
169
186
 
170
187
  ## Safety first
@@ -228,17 +245,14 @@ Token counts come from [js-tiktoken](https://github.com/nicolo-ribaudo/js-tiktok
228
245
 
229
246
  ---
230
247
 
231
- ## v2.8.0 — What's new
248
+ ## v2.9.1 — What's new
232
249
 
233
- Accuracy release. Three reported numbers were wrong; the largest was wrong by an order of magnitude. **If your startup estimate drops sharply after upgrading, the old number was the inaccurate one.**
250
+ Found while stress-testing the scanner against deliberately hostile `~/.claude` fixtures.
234
251
 
235
- - **Startup estimate no longer sums memory across every project on disk.** Claude Code loads `~/.claude/projects/<slug>/memory/` for the project you're in not the other 40 project directories in your `~/.claude`. The old total scaled with how many projects you'd ever opened: on the dev machine it reported **116,259 tokens where the real per-session cost was 14,399**. Now scoped to the current project, with the cross-project total still shown and labelled as not a per-session cost.
236
- - **Skill listing cost is measured, not assumed.** Each skill adds a `- <name>: <description>` line to the system prompt. The flat 30-tokens-per-skill estimate stood in for all of them; measured across 68 installed skills the real spread is **30 → 509 tokens (mean 51)**. The per-plugin cost gradient can now tell five terse skills apart from five verbose ones.
237
- - **`~/.claude/agents/` and `~/.claude/commands/` are now scanned.** Previously invisible despite loading into every session — 12 agents worth ~2,254 tokens on the dev machine. Reported only; never moved or deleted, because there's no restore path for them yet.
238
- - **Fixed: plugin manifests were stuck at 2.7.0 for three releases**, so `claude plugin install` advertised a stale version. CI now fails on version drift.
239
- - **Fixed: the token cache grew without bound** — 355 of 776 entries (46%) pointed at deleted files. `flushCache()` now prunes them.
252
+ - **Fixed: `scan` could hang forever on a single long line.** js-tiktoken's BPE is quadratic in the length of one whitespace-free run. Normal prose is fine (8,000 characters encodes in ~1ms), but 800 characters of Hangul cost ~450ms, 3,200 cost ~6.8s, and a 60,000-character run wedged `scan` past 20 seconds with **no output at all** indistinguishable from a freeze. `SKILL.md` files hit this via base64 blobs, minified snippets, embedded JSON schemas, and CJK text. Long runs are now estimated by encoding a bounded prefix and scaling; **files without one produce byte-identical counts** (verified across 71 installed skills: 70 exact, one moved 0.01%). Hostile fixture: 20s+ → 1.78s, with no measurable cost on ordinary input.
253
+ - **Fixed: a mistyped subcommand blamed the wrong thing.** `claude-slim scam` printed `error: too many arguments. Expected 0 arguments but got 1`. It now names the unknown command and lists the real ones.
240
254
 
241
- Tests: 206241 (+35).
255
+ Tests: 266279 (+13).
242
256
 
243
257
  For older release notes, see [CHANGELOG.md](CHANGELOG.md).
244
258
 
package/dist/cli.js CHANGED
@@ -10,6 +10,7 @@ import { cleanIssues, restoreItem } from './cleaner.js';
10
10
  import { readManifest } from './manifest.js';
11
11
  import { formatScanSummary, formatReportBox, calculateReport, } from './report.js';
12
12
  import { collectDoctorReport, formatDoctorReport } from './doctor.js';
13
+ import { checkForUpdate, formatUpdateNotice } from './update-check.js';
13
14
  import { resolveSelection, resolveRestoreSelection } from './selection.js';
14
15
  const pkgPath = join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json');
15
16
  const { version: PKG_VERSION } = JSON.parse(readFileSync(pkgPath, 'utf-8'));
@@ -52,9 +53,11 @@ program
52
53
  .description('Check local Claude Code environment and scanner fidelity')
53
54
  .option('--json', 'Output raw JSON')
54
55
  .option('--lookback-days <n>', 'Days of session history for skill-usage analysis', '60')
56
+ .option('--offline', 'Skip the npm version check (no outbound request)')
55
57
  .action(async (opts) => {
56
58
  const report = await collectDoctorReport({
57
59
  lookbackDays: parseNonNegativeInt(opts.lookbackDays, 60),
60
+ checkUpdate: !opts.offline,
58
61
  });
59
62
  if (opts.json) {
60
63
  console.log(JSON.stringify(report, null, 2));
@@ -63,6 +66,31 @@ program
63
66
  console.log(formatDoctorReport(report));
64
67
  }
65
68
  });
69
+ // --- check-update ---
70
+ // Detection only. Updating is the package manager's job — claude-slim writing
71
+ // into a directory `claude plugin` owns is how installs get corrupted.
72
+ program
73
+ .command('check-update')
74
+ .description('Check whether a newer claude-slim is published (no changes made)')
75
+ .option('--json', 'Output raw JSON')
76
+ .option('--force', 'Ignore the 24h cache and re-query the registry')
77
+ .action(async (opts) => {
78
+ const result = await checkForUpdate({ force: Boolean(opts.force) });
79
+ if (opts.json) {
80
+ console.log(JSON.stringify(result, null, 2));
81
+ return;
82
+ }
83
+ const notice = formatUpdateNotice(result);
84
+ if (notice) {
85
+ console.log(`\n \x1b[33m${notice}\x1b[0m\n`);
86
+ }
87
+ else if (result.latest === null) {
88
+ console.log(`\n Could not reach the npm registry. Installed: ${result.installed}\n`);
89
+ }
90
+ else {
91
+ console.log(`\n \x1b[32m✓\x1b[0m claude-slim ${result.installed} is up to date.\n`);
92
+ }
93
+ });
66
94
  // --- clean ---
67
95
  program
68
96
  .command('clean')
@@ -216,7 +244,21 @@ program
216
244
  await flushCache();
217
245
  });
218
246
  // --- default (no subcommand) → run clean ---
247
+ // Because the program itself carries an action, commander routes a mistyped
248
+ // subcommand here and reports its own "too many arguments. Expected 0 arguments
249
+ // but got 1" — which says nothing about what the user actually got wrong.
250
+ // Accept the excess argument so we can name it instead.
251
+ program.allowExcessArguments(true);
219
252
  program.action(async () => {
253
+ const stray = program.args;
254
+ if (stray.length > 0) {
255
+ const names = program.commands.map((c) => c.name()).join(', ');
256
+ console.error(`error: unknown command '${stray[0]}'`);
257
+ console.error(`available commands: ${names}`);
258
+ console.error(`run 'claude-slim --help' for usage`);
259
+ process.exitCode = 1;
260
+ return;
261
+ }
220
262
  await runCleanPipeline({ dryRun: false, auto: false, sessionsPerDay: 2, lookbackDays: 60 });
221
263
  });
222
264
  // --- shared clean pipeline ---
package/dist/doctor.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { type UpdateCheckResult } from './update-check.js';
1
2
  export type DoctorStatus = 'ok' | 'warn' | 'fail';
2
3
  export interface DoctorCheck {
3
4
  label: string;
@@ -11,5 +12,6 @@ export interface DoctorReport {
11
12
  export declare function isSupportedRuntimeNode(version: string): boolean;
12
13
  export declare function collectDoctorReport(opts?: {
13
14
  lookbackDays?: number;
15
+ checkUpdate?: boolean | (() => Promise<UpdateCheckResult>);
14
16
  }): Promise<DoctorReport>;
15
17
  export declare function formatDoctorReport(report: DoctorReport): string;
package/dist/doctor.js CHANGED
@@ -2,6 +2,7 @@ import { access, readdir } from 'node:fs/promises';
2
2
  import { getClaudeDir, getPluginsDir, getProjectsDir, getSkillsDir } from './paths.js';
3
3
  import { runCommand } from './scanner/fs-walk.js';
4
4
  import { scanSessionUsage } from './scanner/sessions.js';
5
+ import { checkForUpdate } from './update-check.js';
5
6
  const MIN_RUNTIME_NODE_MAJOR = 20;
6
7
  export function isSupportedRuntimeNode(version) {
7
8
  const normalized = version.trim().replace(/^v/, '');
@@ -87,6 +88,37 @@ export async function collectDoctorReport(opts = {}) {
87
88
  : 'Unused-skill detection will be suppressed until enough reliable session data exists.',
88
89
  });
89
90
  }
91
+ // Version drift, last: it is the only check that touches the network, and it
92
+ // is opt-in so `doctor` stays offline-safe when the caller wants that.
93
+ if (opts.checkUpdate) {
94
+ const run = typeof opts.checkUpdate === 'function' ? opts.checkUpdate : checkForUpdate;
95
+ const update = await run();
96
+ if (update.latest === null) {
97
+ checks.push({
98
+ label: 'Version',
99
+ status: 'warn',
100
+ detail: `${update.installed} installed, latest unknown`,
101
+ hint: 'Could not reach the npm registry. This does not affect scanning.',
102
+ });
103
+ }
104
+ else if (update.outdated) {
105
+ checks.push({
106
+ label: 'Version',
107
+ status: 'warn',
108
+ detail: `${update.installed} installed, ${update.latest} available`,
109
+ hint: update.upgradeCommand
110
+ ? `Outdated versions report wrong token totals. Update with: ${update.upgradeCommand}`
111
+ : 'Outdated versions report wrong token totals — update via your package manager.',
112
+ });
113
+ }
114
+ else {
115
+ checks.push({
116
+ label: 'Version',
117
+ status: 'ok',
118
+ detail: `${update.installed} (latest)`,
119
+ });
120
+ }
121
+ }
90
122
  return { checks };
91
123
  }
92
124
  export function formatDoctorReport(report) {
package/dist/tokenizer.js CHANGED
@@ -39,11 +39,72 @@ export async function initTokenizer() {
39
39
  function hashContent(content) {
40
40
  return createHash('md5').update(content).digest('hex');
41
41
  }
42
+ // js-tiktoken's BPE is quadratic in the length of a single whitespace-free run.
43
+ // Ordinary prose of any length is fine — the pre-tokenizer splits on whitespace,
44
+ // so 8,000 characters of normal text encodes in ~1ms. One long unbroken run does
45
+ // not: measured on cl100k_base, 800 characters of Hangul costs ~450ms, 3,200
46
+ // costs ~6.8s, and a 60,000-character run wedges the scan for minutes with no
47
+ // output at all. Real SKILL.md files reach this with base64 blobs, minified
48
+ // snippets, rule separators, and CJK text.
49
+ //
50
+ // 512 sits above anything a normal word, path, or URL produces and well below
51
+ // where the curve turns painful.
52
+ const MAX_ENCODE_RUN = 512;
53
+ const LONG_RUN_PATTERN = /\S{513,}/;
54
+ const FALLBACK_CHARS_PER_TOKEN = 4;
55
+ /**
56
+ * Estimate an over-long run by encoding a bounded prefix and scaling.
57
+ *
58
+ * A fixed characters-per-token divisor cannot work here: measured over 1,000-char
59
+ * runs, cl100k_base yields 0.8 chars/token for Hangul but 8.0 for a repeated
60
+ * ASCII character — a 10× spread. Sampling the run's own prefix adapts to
61
+ * whatever it actually contains (base64, hex, minified JSON, CJK) at the cost of
62
+ * exactly one bounded encode.
63
+ */
64
+ function estimateRun(segment, encode) {
65
+ const sample = segment.slice(0, MAX_ENCODE_RUN);
66
+ const sampleTokens = encode(sample).length;
67
+ if (sampleTokens === 0)
68
+ return 0;
69
+ return Math.ceil((sampleTokens * segment.length) / sample.length);
70
+ }
71
+ /**
72
+ * Encode `text`, estimating any whitespace-free run longer than
73
+ * {@link MAX_ENCODE_RUN} instead of feeding the whole run to the BPE.
74
+ *
75
+ * Text without such a run takes the fast path and is encoded whole, so counts
76
+ * for well-formed files are identical to encoding directly.
77
+ */
78
+ function encodeBounded(text, encode) {
79
+ if (!LONG_RUN_PATTERN.test(text)) {
80
+ return encode(text).length;
81
+ }
82
+ let total = 0;
83
+ let buffered = '';
84
+ // Splitting on a captured group keeps the whitespace in the stream, so the
85
+ // buffered pieces still look to the encoder like the original text.
86
+ for (const segment of text.split(/(\s+)/)) {
87
+ if (segment.length > MAX_ENCODE_RUN) {
88
+ if (buffered) {
89
+ total += encode(buffered).length;
90
+ buffered = '';
91
+ }
92
+ total += estimateRun(segment, encode);
93
+ continue;
94
+ }
95
+ buffered += segment;
96
+ }
97
+ if (buffered) {
98
+ total += encode(buffered).length;
99
+ }
100
+ return total;
101
+ }
42
102
  export function countTokens(text) {
43
103
  if (useFallback || !encoder) {
44
- return Math.ceil(text.length / 4);
104
+ return Math.ceil(text.length / FALLBACK_CHARS_PER_TOKEN);
45
105
  }
46
- return encoder.encode(text).length;
106
+ const enc = encoder;
107
+ return encodeBounded(text, (s) => enc.encode(s));
47
108
  }
48
109
  export function countTokensCached(text, filePath) {
49
110
  const hash = hashContent(text);
@@ -0,0 +1,38 @@
1
+ export type InstallMethod = 'plugin' | 'global' | 'npx' | 'source' | 'unknown';
2
+ export interface UpdateCheckResult {
3
+ installed: string;
4
+ /** null when the lookup failed or was skipped — never treat as "up to date". */
5
+ latest: string | null;
6
+ outdated: boolean;
7
+ installMethod: InstallMethod;
8
+ /** The command that actually upgrades this install, or null if unknown. */
9
+ upgradeCommand: string | null;
10
+ fromCache: boolean;
11
+ }
12
+ /**
13
+ * Compare dotted numeric versions. Returns >0 if a is newer, <0 if b is newer.
14
+ * Pre-release suffixes (`-beta.1`) sort below the same release, matching semver
15
+ * closely enough for "is there something newer" without pulling in a dep.
16
+ */
17
+ export declare function compareVersions(a: string, b: string): number;
18
+ /**
19
+ * Infer how this copy was installed from where it sits on disk, so the hint we
20
+ * print is the command that will actually work for this user.
21
+ */
22
+ export declare function detectInstallMethod(modulePath: string): InstallMethod;
23
+ export declare function upgradeCommandFor(method: InstallMethod): string | null;
24
+ export declare function getInstalledVersion(): string;
25
+ export interface CheckOptions {
26
+ installed?: string;
27
+ modulePath?: string;
28
+ cachePath?: string;
29
+ now?: number;
30
+ ttlMs?: number;
31
+ /** Ignore a fresh cache entry and re-query. */
32
+ force?: boolean;
33
+ /** Injected for tests; defaults to the real registry lookup. */
34
+ fetchLatest?: () => Promise<string | null>;
35
+ }
36
+ export declare function checkForUpdate(opts?: CheckOptions): Promise<UpdateCheckResult>;
37
+ /** One-line human summary; null when there is nothing worth saying. */
38
+ export declare function formatUpdateNotice(result: UpdateCheckResult): string | null;
@@ -0,0 +1,177 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { readFile, writeFile, mkdir, rename } from 'node:fs/promises';
3
+ import { dirname, join } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { getClaudeDir } from './paths.js';
6
+ // Version-drift detection.
7
+ //
8
+ // Running an outdated claude-slim is not a cosmetic problem: v2.8.0 corrected
9
+ // a startup estimate that earlier versions inflated ~8x, so a stale install
10
+ // reports numbers that are simply wrong. Nothing told users they were behind —
11
+ // this module closes that gap.
12
+ //
13
+ // It only *detects*. Updating is the package manager's job (`claude plugin
14
+ // update`, `npm update -g`), and claude-slim writing into a directory the
15
+ // plugin manager owns would be a good way to corrupt an install.
16
+ //
17
+ // NETWORK: this is the only outbound request claude-slim makes, and it is never
18
+ // issued by `scan`/`clean`. It runs when the user explicitly asks (`doctor`,
19
+ // `check-update`), fails open on any error, and caches for a day so repeated
20
+ // invocations do not hammer the registry.
21
+ const REGISTRY_URL = 'https://registry.npmjs.org/claude-slim/latest';
22
+ const FETCH_TIMEOUT_MS = 2500;
23
+ const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
24
+ function getCachePath() {
25
+ return join(getClaudeDir(), '.claude-slim-update-check.json');
26
+ }
27
+ /**
28
+ * Compare dotted numeric versions. Returns >0 if a is newer, <0 if b is newer.
29
+ * Pre-release suffixes (`-beta.1`) sort below the same release, matching semver
30
+ * closely enough for "is there something newer" without pulling in a dep.
31
+ */
32
+ export function compareVersions(a, b) {
33
+ const split = (v) => {
34
+ const [core, ...rest] = v.trim().replace(/^v/, '').split('-');
35
+ return [core.split('.').map((n) => Number.parseInt(n, 10) || 0), rest.join('-')];
36
+ };
37
+ const [aNums, aPre] = split(a);
38
+ const [bNums, bPre] = split(b);
39
+ for (let i = 0; i < Math.max(aNums.length, bNums.length); i++) {
40
+ const diff = (aNums[i] ?? 0) - (bNums[i] ?? 0);
41
+ if (diff !== 0)
42
+ return diff;
43
+ }
44
+ if (aPre === bPre)
45
+ return 0;
46
+ if (!aPre)
47
+ return 1;
48
+ if (!bPre)
49
+ return -1;
50
+ return aPre < bPre ? -1 : 1;
51
+ }
52
+ /**
53
+ * Infer how this copy was installed from where it sits on disk, so the hint we
54
+ * print is the command that will actually work for this user.
55
+ */
56
+ export function detectInstallMethod(modulePath) {
57
+ const p = modulePath.replace(/\\/g, '/');
58
+ if (p.includes('/.claude/plugins/'))
59
+ return 'plugin';
60
+ if (p.includes('/_npx/'))
61
+ return 'npx';
62
+ if (/\/(lib\/)?node_modules\/claude-slim\//.test(p))
63
+ return 'global';
64
+ if (p.includes('/dist/') || p.includes('/src/'))
65
+ return 'source';
66
+ return 'unknown';
67
+ }
68
+ export function upgradeCommandFor(method) {
69
+ switch (method) {
70
+ case 'plugin':
71
+ // `claude plugin update <name>` resolves plugin@marketplace ids; the bare
72
+ // name fails when the marketplace shares the plugin's name.
73
+ return 'claude plugin marketplace update claude-slim && claude plugin update claude-slim@claude-slim';
74
+ case 'global':
75
+ return 'npm install -g claude-slim@latest';
76
+ case 'npx':
77
+ // npx resolves latest per invocation, but a cached older copy can stick.
78
+ return 'npx claude-slim@latest';
79
+ case 'source':
80
+ return 'git pull && npm install';
81
+ default:
82
+ return null;
83
+ }
84
+ }
85
+ export function getInstalledVersion() {
86
+ try {
87
+ const pkgPath = join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json');
88
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
89
+ return typeof pkg.version === 'string' ? pkg.version : '0.0.0';
90
+ }
91
+ catch {
92
+ return '0.0.0';
93
+ }
94
+ }
95
+ async function fetchLatestFromRegistry() {
96
+ try {
97
+ const res = await fetch(REGISTRY_URL, {
98
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
99
+ headers: { accept: 'application/json' },
100
+ });
101
+ if (!res.ok)
102
+ return null;
103
+ const body = (await res.json());
104
+ return typeof body.version === 'string' ? body.version : null;
105
+ }
106
+ catch {
107
+ // Offline, DNS failure, timeout, proxy, malformed JSON — all fail open.
108
+ return null;
109
+ }
110
+ }
111
+ async function readCache(path, now, ttlMs) {
112
+ try {
113
+ const parsed = JSON.parse(await readFile(path, 'utf-8'));
114
+ if (parsed.version !== 1)
115
+ return undefined;
116
+ if (now - parsed.checkedAt > ttlMs)
117
+ return undefined;
118
+ return parsed.latest;
119
+ }
120
+ catch {
121
+ return undefined;
122
+ }
123
+ }
124
+ async function writeCache(path, now, latest) {
125
+ const tmp = `${path}.tmp`;
126
+ try {
127
+ await mkdir(dirname(path), { recursive: true });
128
+ const body = { version: 1, checkedAt: now, latest };
129
+ await writeFile(tmp, JSON.stringify(body));
130
+ await rename(tmp, path);
131
+ }
132
+ catch {
133
+ // A cache we cannot persist just means the next run checks again.
134
+ }
135
+ }
136
+ export async function checkForUpdate(opts = {}) {
137
+ const installed = opts.installed ?? getInstalledVersion();
138
+ const modulePath = opts.modulePath ?? fileURLToPath(import.meta.url);
139
+ const installMethod = detectInstallMethod(modulePath);
140
+ const cachePath = opts.cachePath ?? getCachePath();
141
+ const now = opts.now ?? Date.now();
142
+ const ttlMs = opts.ttlMs ?? CACHE_TTL_MS;
143
+ const fetchLatest = opts.fetchLatest ?? fetchLatestFromRegistry;
144
+ let latest;
145
+ let fromCache = false;
146
+ if (!opts.force) {
147
+ latest = await readCache(cachePath, now, ttlMs);
148
+ fromCache = latest !== undefined;
149
+ }
150
+ if (latest === undefined) {
151
+ // Defensive at the boundary: the built-in lookup already fails open, but a
152
+ // version check must never be able to take down `doctor`, whatever the
153
+ // injected fetcher does.
154
+ try {
155
+ latest = await fetchLatest();
156
+ }
157
+ catch {
158
+ latest = null;
159
+ }
160
+ await writeCache(cachePath, now, latest);
161
+ }
162
+ return {
163
+ installed,
164
+ latest: latest ?? null,
165
+ outdated: latest != null && compareVersions(latest, installed) > 0,
166
+ installMethod,
167
+ upgradeCommand: upgradeCommandFor(installMethod),
168
+ fromCache,
169
+ };
170
+ }
171
+ /** One-line human summary; null when there is nothing worth saying. */
172
+ export function formatUpdateNotice(result) {
173
+ if (!result.outdated || result.latest === null)
174
+ return null;
175
+ const cmd = result.upgradeCommand ? `\n → ${result.upgradeCommand}` : '';
176
+ return `claude-slim ${result.installed} is installed; ${result.latest} is available.${cmd}`;
177
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-slim",
3
- "version": "2.8.1",
3
+ "version": "2.9.1",
4
4
  "description": "Audit and shrink your Claude Code startup context. Measures what every skill, plugin, agent, command, and memory file costs in the system prompt, then reversibly disables the dead weight. Non-destructive scan, tiered proposals, one-command restore — no proxy, no compression.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -13,10 +13,38 @@ Analyze the user's Claude Code environment for token waste and perform non-destr
13
13
  - `/claude-slim scan` → report only, no changes
14
14
  - `/claude-slim scan --json` → raw JSON output
15
15
  - `/claude-slim doctor` → check scanner prerequisites and session-log signal quality
16
+ - `/claude-slim check-update` → report whether a newer version is published
16
17
  - `/claude-slim restore` → restore previously disabled items
17
18
 
18
19
  ---
19
20
 
21
+ ## Phase 0 — Version gate (run before every scan)
22
+
23
+ An outdated claude-slim does not merely lack features — it reports **wrong numbers**. Versions before 2.8.0 summed memory across every project on disk and inflated the startup estimate roughly 8×. Presenting those figures as fact is worse than not running at all, so check first:
24
+
25
+ ```bash
26
+ cd "${CLAUDE_PLUGIN_ROOT}" && node dist/cli.js check-update --json
27
+ ```
28
+
29
+ The check is cached for 24h and fails open — if it errors, times out, or returns `"latest": null`, **proceed silently**. Never block the user because a version lookup failed.
30
+
31
+ If `"outdated": true`, stop and tell the user before scanning:
32
+
33
+ > 설치된 claude-slim이 {installed}이고 최신은 {latest}입니다.
34
+ > 2.8.0 이전 버전은 시작 토큰을 약 8배 부풀려 보고합니다 — 지금 스캔하면 그 숫자가 나옵니다.
35
+ >
36
+ > {upgradeCommand}
37
+ >
38
+ > 업데이트 후 진행할까요, 아니면 현재 버전으로 계속할까요?
39
+
40
+ Then honour their answer. If they choose to continue, run the scan but **label the numbers as coming from an outdated version** in your report.
41
+
42
+ **Plugin installs need a restart.** `claude plugin update` writes the new version to disk, but the running session keeps the loaded copy. Tell the user this explicitly — otherwise they update, re-run, and see the same stale numbers with no idea why.
43
+
44
+ If `"outdated": false`, say nothing and continue to Phase 1.
45
+
46
+ ---
47
+
20
48
  ## Phase 1 — Scan
21
49
 
22
50
  Run the CLI to collect environment data: