claude-slim 2.14.1 → 2.14.3

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
@@ -7,6 +7,7 @@
7
7
  [![CI](https://github.com/iops-leo/claude-slim/actions/workflows/ci.yml/badge.svg)](https://github.com/iops-leo/claude-slim/actions/workflows/ci.yml)
8
8
  [![node](https://img.shields.io/node/v/claude-slim.svg)](https://nodejs.org)
9
9
  [![license](https://img.shields.io/npm/l/claude-slim.svg)](./LICENSE)
10
+ [![skills.sh](https://skills.sh/b/iops-leo/claude-slim)](https://skills.sh/iops-leo/claude-slim)
10
11
 
11
12
  **Your Claude Code session burns thousands of tokens before you even say "hello."**
12
13
 
@@ -4,6 +4,7 @@ import { countTokensCached } from '../tokenizer.js';
4
4
  import { listingTokens, listingTokensFromContent, parseFrontmatterDescription } from '../scanner/skill-listing.js';
5
5
  import { safeReadFile, safeReaddir, isDirectory, isBrokenSymlink } from '../scanner/fs-walk.js';
6
6
  import { detectBackupArtifact } from '../scanner/backup-artifacts.js';
7
+ import { sanitizeUntrustedTree } from '../scanner/untrusted.js';
7
8
  // Codex support.
8
9
  //
9
10
  // The Claude Code scanner is deliberately left untouched: it is the
@@ -183,7 +184,11 @@ export async function scanCodex(contents) {
183
184
  : 0;
184
185
  const skills = [...local, ...plugin];
185
186
  const listing = (xs) => xs.reduce((sum, x) => sum + x.listingTokens, 0);
186
- return {
187
+ // ~/.codex labels are authored by whoever wrote those skills and agents, and
188
+ // the CLI merges this tree into `scan --json` and prints skill names to the
189
+ // terminal. It never passes through the ~/.claude scanner, so it sanitizes on
190
+ // its own way out.
191
+ return sanitizeUntrustedTree({
187
192
  root: getCodexDir(),
188
193
  skills,
189
194
  agents,
@@ -192,7 +197,7 @@ export async function scanCodex(contents) {
192
197
  totalTokens: listing(skills) + listing(agents) + instructionsTokens,
193
198
  unusedDetectionAvailable: false,
194
199
  unusedDetectionReason: UNUSED_DETECTION_REASON,
195
- };
200
+ });
196
201
  }
197
202
  /** Re-exported so callers can reuse the frontmatter parser without a deep import. */
198
203
  export { parseFrontmatterDescription };
@@ -1,15 +1,22 @@
1
1
  import type { ScanResult } from '../types.js';
2
2
  /**
3
- * Names read off disk are written by whoever authored the skill, plugin, or
4
- * memory file — not by the user running the scan. They flow through the report
5
- * into the agent's context, which makes them an indirect prompt injection
6
- * surface: a skill directory or frontmatter `name:` can carry instructions
7
- * aimed at the model rather than a label aimed at a human.
3
+ * Names read off disk are written by whoever authored the skill, plugin, agent,
4
+ * or memory file — not by the person running the scan. They flow through the
5
+ * report into the agent's context, which makes them an indirect prompt
6
+ * injection surface: a directory name can carry instructions aimed at the model
7
+ * rather than a label aimed at a human.
8
8
  *
9
9
  * Snyk's audit of this skill (W011, medium 0.30) is about exactly this path.
10
10
  * The scan never emits file *bodies* — descriptions are measured for token cost
11
- * and then discarded — so what this module covers is the whole exposed surface,
12
- * not a sample of it.
11
+ * and then discarded — so labels are the whole exposed surface.
12
+ *
13
+ * v2.14.1 sanitized a hand-written list of fields and claimed that a single
14
+ * chokepoint could not be forgotten. That was wrong: the chokepoint was one
15
+ * function, but its contents were an enumeration, and the first version of it
16
+ * already missed `pluginSkills[].pluginName`, `pluginSkills[].plugin`, and the
17
+ * entire `codex` subtree — whose skill names are printed to the terminal too.
18
+ * So this walks the result instead of listing its fields. A field added later
19
+ * is covered because it exists, not because someone remembered it.
13
20
  */
14
21
  /** Longest label we render. Real names are far shorter; payloads are not. */
15
22
  export declare const MAX_NAME_LENGTH = 120;
@@ -21,10 +28,15 @@ export declare const MAX_NAME_LENGTH = 120;
21
28
  */
22
29
  export declare function sanitizeUntrusted(value: string, max?: number): string;
23
30
  /**
24
- * Return a copy of the scan with every outsider-authored label flattened.
31
+ * Return a copy of any scanned tree with every label flattened and bounded.
32
+ *
33
+ * Numbers and booleans pass through untouched; the input is not mutated.
25
34
  *
26
- * Applied once at the scanner's exit rather than at each of the dozen sites
27
- * that read a name off disk: one chokepoint cannot be forgotten by whoever adds
28
- * the next detector.
35
+ * Each scanner applies this at its own exit. There is more than one scanner:
36
+ * `scanCodex()` runs separately and the CLI merges it in at print time
37
+ * (`src/cli.ts`), so `~/.codex` labels never pass through the `~/.claude`
38
+ * scanner and have to be sanitized on their own way out.
29
39
  */
40
+ export declare function sanitizeUntrustedTree<T>(value: T): T;
41
+ /** The `~/.claude` scanner's exit. */
30
42
  export declare function sanitizeScanResult(result: ScanResult): ScanResult;
@@ -1,14 +1,21 @@
1
1
  /**
2
- * Names read off disk are written by whoever authored the skill, plugin, or
3
- * memory file — not by the user running the scan. They flow through the report
4
- * into the agent's context, which makes them an indirect prompt injection
5
- * surface: a skill directory or frontmatter `name:` can carry instructions
6
- * aimed at the model rather than a label aimed at a human.
2
+ * Names read off disk are written by whoever authored the skill, plugin, agent,
3
+ * or memory file — not by the person running the scan. They flow through the
4
+ * report into the agent's context, which makes them an indirect prompt
5
+ * injection surface: a directory name can carry instructions aimed at the model
6
+ * rather than a label aimed at a human.
7
7
  *
8
8
  * Snyk's audit of this skill (W011, medium 0.30) is about exactly this path.
9
9
  * The scan never emits file *bodies* — descriptions are measured for token cost
10
- * and then discarded — so what this module covers is the whole exposed surface,
11
- * not a sample of it.
10
+ * and then discarded — so labels are the whole exposed surface.
11
+ *
12
+ * v2.14.1 sanitized a hand-written list of fields and claimed that a single
13
+ * chokepoint could not be forgotten. That was wrong: the chokepoint was one
14
+ * function, but its contents were an enumeration, and the first version of it
15
+ * already missed `pluginSkills[].pluginName`, `pluginSkills[].plugin`, and the
16
+ * entire `codex` subtree — whose skill names are printed to the terminal too.
17
+ * So this walks the result instead of listing its fields. A field added later
18
+ * is covered because it exists, not because someone remembered it.
12
19
  */
13
20
  /** Longest label we render. Real names are far shorter; payloads are not. */
14
21
  export const MAX_NAME_LENGTH = 120;
@@ -20,82 +27,74 @@ const CONTROL_CHARS = /[\u0000-\u001F\u007F-\u009F]/g;
20
27
  */
21
28
  const INVISIBLE = /[\u200B-\u200F\u202A-\u202E\u2060-\u2064\u2066-\u2069\uFEFF]/g;
22
29
  /**
23
- * Collapse an untrusted label to a single bounded, printable line.
30
+ * Keys whose values are truncated only at their peril.
24
31
  *
25
- * Deliberately not an escape or an encoding: the value is a display label, and
26
- * a reversible transform would relocate a payload rather than remove it.
32
+ * Paths locate files that cleanup then acts on, so a shortened path is a wrong
33
+ * path. `currentProjectSlug` is matched against directory names. The two
34
+ * `*Reason` strings are our own prose and already run past the label bound.
35
+ * All of them are still flattened — that is the part that blocks injection.
27
36
  */
28
- export function sanitizeUntrusted(value, max = MAX_NAME_LENGTH) {
29
- const flattened = value
37
+ const FLATTEN_ONLY_KEYS = new Set([
38
+ 'path',
39
+ 'root',
40
+ 'target',
41
+ 'currentProjectSlug',
42
+ 'unusedDetectionReason',
43
+ ]);
44
+ /** Strip anything that could forge a row or hide from a human reader. */
45
+ function flatten(value) {
46
+ return value
30
47
  .replace(CONTROL_CHARS, ' ')
31
48
  .replace(INVISIBLE, '')
32
49
  .replace(/\s+/g, ' ')
33
50
  .trim();
51
+ }
52
+ /**
53
+ * Collapse an untrusted label to a single bounded, printable line.
54
+ *
55
+ * Deliberately not an escape or an encoding: the value is a display label, and
56
+ * a reversible transform would relocate a payload rather than remove it.
57
+ */
58
+ export function sanitizeUntrusted(value, max = MAX_NAME_LENGTH) {
59
+ const flattened = flatten(value);
34
60
  if (flattened.length <= max)
35
61
  return flattened;
36
62
  return `${flattened.slice(0, max)}…`;
37
63
  }
38
64
  /**
39
- * Paths are shown to the user and used to locate files for cleanup, so they are
40
- * flattened but never truncated — a shortened path would be a wrong path.
65
+ * Walk any scan value, sanitizing every string it contains.
66
+ *
67
+ * `key` is the property name the string arrived under; for arrays it is
68
+ * inherited from the property holding the array, so `mcpServerNames[]` and
69
+ * `plugins[].skills[]` are treated as the labels they are.
41
70
  */
42
- function sanitizePath(value) {
43
- return value.replace(CONTROL_CHARS, ' ').replace(INVISIBLE, '').trim();
71
+ function sanitizeDeep(value, key) {
72
+ if (typeof value === 'string') {
73
+ return FLATTEN_ONLY_KEYS.has(key) ? flatten(value) : sanitizeUntrusted(value);
74
+ }
75
+ if (Array.isArray(value)) {
76
+ return value.map((entry) => sanitizeDeep(entry, key));
77
+ }
78
+ if (value !== null && typeof value === 'object') {
79
+ return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, sanitizeDeep(v, k)]));
80
+ }
81
+ // Numbers, booleans, null, undefined — nothing to sanitize, nothing to copy.
82
+ return value;
44
83
  }
45
84
  /**
46
- * Return a copy of the scan with every outsider-authored label flattened.
85
+ * Return a copy of any scanned tree with every label flattened and bounded.
47
86
  *
48
- * Applied once at the scanner's exit rather than at each of the dozen sites
49
- * that read a name off disk: one chokepoint cannot be forgotten by whoever adds
50
- * the next detector.
87
+ * Numbers and booleans pass through untouched; the input is not mutated.
88
+ *
89
+ * Each scanner applies this at its own exit. There is more than one scanner:
90
+ * `scanCodex()` runs separately and the CLI merges it in at print time
91
+ * (`src/cli.ts`), so `~/.codex` labels never pass through the `~/.claude`
92
+ * scanner and have to be sanitized on their own way out.
51
93
  */
94
+ export function sanitizeUntrustedTree(value) {
95
+ return sanitizeDeep(value, '');
96
+ }
97
+ /** The `~/.claude` scanner's exit. */
52
98
  export function sanitizeScanResult(result) {
53
- const skill = (s) => ({
54
- ...s,
55
- name: sanitizeUntrusted(s.name),
56
- path: sanitizePath(s.path),
57
- });
58
- return {
59
- ...result,
60
- localSkills: result.localSkills.map(skill),
61
- pluginSkills: result.pluginSkills.map(skill),
62
- plugins: result.plugins.map((p) => ({
63
- ...p,
64
- name: sanitizeUntrusted(p.name),
65
- skills: p.skills.map((s) => sanitizeUntrusted(s)),
66
- })),
67
- brokenSymlinks: result.brokenSymlinks.map((b) => ({
68
- ...b,
69
- name: sanitizeUntrusted(b.name),
70
- path: sanitizePath(b.path),
71
- target: sanitizeUntrusted(b.target),
72
- })),
73
- memoryFiles: result.memoryFiles.map((m) => ({
74
- ...m,
75
- project: sanitizeUntrusted(m.project),
76
- name: sanitizeUntrusted(m.name),
77
- path: sanitizePath(m.path),
78
- })),
79
- claudeMdSections: result.claudeMdSections.map((s) => ({
80
- ...s,
81
- name: sanitizeUntrusted(s.name),
82
- })),
83
- mcpServerNames: result.mcpServerNames.map((n) => sanitizeUntrusted(n)),
84
- issues: result.issues.map((i) => ({
85
- ...i,
86
- name: sanitizeUntrusted(i.name),
87
- path: sanitizePath(i.path),
88
- ...(i.detail === undefined ? {} : { detail: sanitizeUntrusted(i.detail) }),
89
- ...(i.marketplace === undefined
90
- ? {}
91
- : { marketplace: sanitizeUntrusted(i.marketplace) }),
92
- })),
93
- pluginBreakdown: result.pluginBreakdown.map((p) => ({
94
- ...p,
95
- name: sanitizeUntrusted(p.name),
96
- marketplace: sanitizeUntrusted(p.marketplace),
97
- })),
98
- userAgents: result.userAgents.map((a) => ({ ...a, name: sanitizeUntrusted(a.name) })),
99
- userCommands: result.userCommands.map((c) => ({ ...c, name: sanitizeUntrusted(c.name) })),
100
- };
99
+ return sanitizeUntrustedTree(result);
101
100
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-slim",
3
- "version": "2.14.1",
3
+ "version": "2.14.3",
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": {
@@ -27,6 +27,14 @@ block does not survive into the next. It resolves in three tiers — the plugin'
27
27
  `npx`. That last tier is what makes the skill work when it was installed by
28
28
  `npx skills add` rather than `claude plugin install`, where no plugin root exists.
29
29
 
30
+ The `npx` tier pins a **minimum version, not just a major**. `claude-slim@^2` looks
31
+ safer but is worse: npx reuses any cached `_npx` install that satisfies the range and
32
+ never re-checks the registry, so a machine that once fetched an older 2.x keeps running
33
+ it — a security fix would never arrive. `--prefer-online` does not override this. Pinning
34
+ to the exact release changes the cache key every time, which forces a fresh fetch while
35
+ still refusing an unreviewed next major. `npm run check:versions` keeps this pin equal to
36
+ `package.json`, so bump them together.
37
+
30
38
  Do not shorten the name. A two-letter `cs` collides with claude-squad's binary, and a
31
39
  shell function is invisible to `timeout`, `env`, and `xargs` — a wrapped call would
32
40
  silently run that other program instead. Call `claude_slim` directly, never through a
@@ -39,7 +47,7 @@ wrapper.
39
47
  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:
40
48
 
41
49
  ```bash
42
- claude_slim(){ if [ -n "${CLAUDE_PLUGIN_ROOT:-}" ] && [ -f "$CLAUDE_PLUGIN_ROOT/dist/cli.js" ]; then node "$CLAUDE_PLUGIN_ROOT/dist/cli.js" "$@"; elif command -v claude-slim >/dev/null 2>&1; then claude-slim "$@"; else npx -y 'claude-slim@^2' "$@"; fi; }
50
+ claude_slim(){ if [ -n "${CLAUDE_PLUGIN_ROOT:-}" ] && [ -f "$CLAUDE_PLUGIN_ROOT/dist/cli.js" ]; then node "$CLAUDE_PLUGIN_ROOT/dist/cli.js" "$@"; elif command -v claude-slim >/dev/null 2>&1; then claude-slim "$@"; else npx -y 'claude-slim@^2.14.3' "$@"; fi; }
43
51
  claude_slim check-update --json
44
52
  ```
45
53
 
@@ -72,7 +80,7 @@ If `"outdated": false`, say nothing and continue to Phase 1.
72
80
  Run the CLI to collect environment data:
73
81
 
74
82
  ```bash
75
- claude_slim(){ if [ -n "${CLAUDE_PLUGIN_ROOT:-}" ] && [ -f "$CLAUDE_PLUGIN_ROOT/dist/cli.js" ]; then node "$CLAUDE_PLUGIN_ROOT/dist/cli.js" "$@"; elif command -v claude-slim >/dev/null 2>&1; then claude-slim "$@"; else npx -y 'claude-slim@^2' "$@"; fi; }
83
+ claude_slim(){ if [ -n "${CLAUDE_PLUGIN_ROOT:-}" ] && [ -f "$CLAUDE_PLUGIN_ROOT/dist/cli.js" ]; then node "$CLAUDE_PLUGIN_ROOT/dist/cli.js" "$@"; elif command -v claude-slim >/dev/null 2>&1; then claude-slim "$@"; else npx -y 'claude-slim@^2.14.3' "$@"; fi; }
76
84
  claude_slim scan --json
77
85
  ```
78
86
 
@@ -192,20 +200,20 @@ If subcommand is `scan`, stop here. Ask a localized equivalent of "Proceed with
192
200
  Run the interactive clean command:
193
201
 
194
202
  ```bash
195
- claude_slim(){ if [ -n "${CLAUDE_PLUGIN_ROOT:-}" ] && [ -f "$CLAUDE_PLUGIN_ROOT/dist/cli.js" ]; then node "$CLAUDE_PLUGIN_ROOT/dist/cli.js" "$@"; elif command -v claude-slim >/dev/null 2>&1; then claude-slim "$@"; else npx -y 'claude-slim@^2' "$@"; fi; }
203
+ claude_slim(){ if [ -n "${CLAUDE_PLUGIN_ROOT:-}" ] && [ -f "$CLAUDE_PLUGIN_ROOT/dist/cli.js" ]; then node "$CLAUDE_PLUGIN_ROOT/dist/cli.js" "$@"; elif command -v claude-slim >/dev/null 2>&1; then claude-slim "$@"; else npx -y 'claude-slim@^2.14.3' "$@"; fi; }
196
204
  claude_slim clean
197
205
  ```
198
206
 
199
207
  Or with dry-run:
200
208
  ```bash
201
- claude_slim(){ if [ -n "${CLAUDE_PLUGIN_ROOT:-}" ] && [ -f "$CLAUDE_PLUGIN_ROOT/dist/cli.js" ]; then node "$CLAUDE_PLUGIN_ROOT/dist/cli.js" "$@"; elif command -v claude-slim >/dev/null 2>&1; then claude-slim "$@"; else npx -y 'claude-slim@^2' "$@"; fi; }
209
+ claude_slim(){ if [ -n "${CLAUDE_PLUGIN_ROOT:-}" ] && [ -f "$CLAUDE_PLUGIN_ROOT/dist/cli.js" ]; then node "$CLAUDE_PLUGIN_ROOT/dist/cli.js" "$@"; elif command -v claude-slim >/dev/null 2>&1; then claude-slim "$@"; else npx -y 'claude-slim@^2.14.3' "$@"; fi; }
202
210
  claude_slim clean --dry-run
203
211
  ```
204
212
 
205
213
  After cleanup, re-run scan to get updated numbers, then show the savings report:
206
214
 
207
215
  ```bash
208
- claude_slim(){ if [ -n "${CLAUDE_PLUGIN_ROOT:-}" ] && [ -f "$CLAUDE_PLUGIN_ROOT/dist/cli.js" ]; then node "$CLAUDE_PLUGIN_ROOT/dist/cli.js" "$@"; elif command -v claude-slim >/dev/null 2>&1; then claude-slim "$@"; else npx -y 'claude-slim@^2' "$@"; fi; }
216
+ claude_slim(){ if [ -n "${CLAUDE_PLUGIN_ROOT:-}" ] && [ -f "$CLAUDE_PLUGIN_ROOT/dist/cli.js" ]; then node "$CLAUDE_PLUGIN_ROOT/dist/cli.js" "$@"; elif command -v claude-slim >/dev/null 2>&1; then claude-slim "$@"; else npx -y 'claude-slim@^2.14.3' "$@"; fi; }
209
217
  claude_slim report
210
218
  ```
211
219
 
@@ -218,7 +226,7 @@ Present the report box AND the before/after breakdown table to the user.
218
226
  When `/claude-slim restore` is invoked:
219
227
 
220
228
  ```bash
221
- claude_slim(){ if [ -n "${CLAUDE_PLUGIN_ROOT:-}" ] && [ -f "$CLAUDE_PLUGIN_ROOT/dist/cli.js" ]; then node "$CLAUDE_PLUGIN_ROOT/dist/cli.js" "$@"; elif command -v claude-slim >/dev/null 2>&1; then claude-slim "$@"; else npx -y 'claude-slim@^2' "$@"; fi; }
229
+ claude_slim(){ if [ -n "${CLAUDE_PLUGIN_ROOT:-}" ] && [ -f "$CLAUDE_PLUGIN_ROOT/dist/cli.js" ]; then node "$CLAUDE_PLUGIN_ROOT/dist/cli.js" "$@"; elif command -v claude-slim >/dev/null 2>&1; then claude-slim "$@"; else npx -y 'claude-slim@^2.14.3' "$@"; fi; }
222
230
  claude_slim restore
223
231
  ```
224
232
 
@@ -227,7 +235,7 @@ claude_slim restore
227
235
  When `/claude-slim doctor` is invoked:
228
236
 
229
237
  ```bash
230
- claude_slim(){ if [ -n "${CLAUDE_PLUGIN_ROOT:-}" ] && [ -f "$CLAUDE_PLUGIN_ROOT/dist/cli.js" ]; then node "$CLAUDE_PLUGIN_ROOT/dist/cli.js" "$@"; elif command -v claude-slim >/dev/null 2>&1; then claude-slim "$@"; else npx -y 'claude-slim@^2' "$@"; fi; }
238
+ claude_slim(){ if [ -n "${CLAUDE_PLUGIN_ROOT:-}" ] && [ -f "$CLAUDE_PLUGIN_ROOT/dist/cli.js" ]; then node "$CLAUDE_PLUGIN_ROOT/dist/cli.js" "$@"; elif command -v claude-slim >/dev/null 2>&1; then claude-slim "$@"; else npx -y 'claude-slim@^2.14.3' "$@"; fi; }
231
239
  claude_slim doctor
232
240
  ```
233
241