claude-slim 2.2.1 → 2.2.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/dist/cleaner.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { rename, readdir, rmdir, rm, unlink, lstat, mkdir } from 'node:fs/promises';
2
2
  import { join, dirname } from 'node:path';
3
3
  import { appendManifest, ensureDisabledDir, getDisabledDir, removeEntry } from './manifest.js';
4
- import { getSkillsDir } from './paths.js';
4
+ import { assertInsideClaudeDir, getSkillsDir } from './paths.js';
5
5
  async function pathExists(p) {
6
6
  try {
7
7
  await lstat(p);
@@ -33,6 +33,7 @@ export async function cleanIssues(issues) {
33
33
  const errors = [];
34
34
  for (const issue of issues) {
35
35
  try {
36
+ assertInsideClaudeDir(issue.path);
36
37
  if (issue.type === 'broken_symlink') {
37
38
  await unlink(issue.path);
38
39
  const entry = {
@@ -67,8 +68,17 @@ export async function cleanIssues(issues) {
67
68
  moved.push(entry);
68
69
  }
69
70
  else if (issue.type === 'temp_cache') {
70
- // Delete temp directories (failed plugin installs, not restorable)
71
- await rm(issue.path, { recursive: true, force: true });
71
+ // Delete temp directories (failed plugin installs, not restorable).
72
+ // If the path is itself a symlink, only remove the link — never
73
+ // follow it into whatever it points at. fs.rm on Node >=18 already
74
+ // behaves this way, but we encode the invariant explicitly.
75
+ const st = await lstat(issue.path);
76
+ if (st.isSymbolicLink()) {
77
+ await unlink(issue.path);
78
+ }
79
+ else {
80
+ await rm(issue.path, { recursive: true, force: true });
81
+ }
72
82
  const entry = {
73
83
  date: new Date().toISOString(),
74
84
  name: issue.name,
@@ -138,6 +148,7 @@ async function cleanEmptyDirs(dir) {
138
148
  catch { /* skip */ }
139
149
  }
140
150
  export async function restoreItem(entry) {
151
+ assertInsideClaudeDir(entry.from);
141
152
  if (entry.type === 'broken_symlink') {
142
153
  throw new Error(`Broken symlinks cannot be restored (${entry.name})`);
143
154
  }
@@ -163,6 +174,14 @@ export async function restoreItem(entry) {
163
174
  // Restore skill directory using the same naming as cleanIssues
164
175
  const safeName = entry.name.replace(/\//g, '--');
165
176
  const src = join(disabledDir, safeName);
177
+ if (!(await pathExists(src))) {
178
+ throw new Error(`Backup not found for "${entry.name}" at ${src}. ` +
179
+ `It may have been manually removed.`);
180
+ }
181
+ if (await pathExists(entry.from)) {
182
+ throw new Error(`Cannot restore: ${entry.from} already exists. ` +
183
+ `Remove or rename it first.`);
184
+ }
166
185
  await mkdir(dirname(entry.from), { recursive: true });
167
186
  await rename(src, entry.from);
168
187
  }
package/dist/paths.d.ts CHANGED
@@ -5,3 +5,4 @@ export declare function getProjectsDir(): string;
5
5
  export declare function getDisabledDir(): string;
6
6
  export declare function getManifestPath(): string;
7
7
  export declare function getLegacyManifestPath(): string;
8
+ export declare function assertInsideClaudeDir(p: string): void;
package/dist/paths.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { homedir } from 'node:os';
2
- import { join } from 'node:path';
2
+ import { join, resolve, sep } from 'node:path';
3
3
  export function getClaudeDir() {
4
4
  return join(homedir(), '.claude');
5
5
  }
@@ -21,3 +21,12 @@ export function getManifestPath() {
21
21
  export function getLegacyManifestPath() {
22
22
  return join(getDisabledDir(), '.claude-slim-manifest.jsonl');
23
23
  }
24
+ // Refuse to operate on a path outside ~/.claude/. Guards destructive operations
25
+ // (rename/rm/unlink) from acting on attacker-tampered manifests or scanner bugs.
26
+ export function assertInsideClaudeDir(p) {
27
+ const resolved = resolve(p);
28
+ const root = resolve(getClaudeDir());
29
+ if (resolved !== root && !resolved.startsWith(root + sep)) {
30
+ throw new Error(`Refusing to operate on path outside ~/.claude/: ${p}`);
31
+ }
32
+ }
package/dist/report.js CHANGED
@@ -28,25 +28,25 @@ export function calculateReport(scanBefore, scanAfter, movedEntries, sessionsPer
28
28
  label: 'Local skills',
29
29
  before: String(localBefore),
30
30
  after: String(localAfter),
31
- saved: `${localAfter - localBefore}`,
31
+ saved: `${localBefore - localAfter}`,
32
32
  },
33
33
  {
34
34
  label: 'System prompt',
35
35
  before: `~${promptBefore}`,
36
36
  after: `~${promptAfter}`,
37
- saved: `${promptAfter - promptBefore}`,
37
+ saved: `${promptBefore - promptAfter}`,
38
38
  },
39
39
  {
40
40
  label: 'Memory files',
41
41
  before: `${(memBefore / 1024).toFixed(1)}KB`,
42
42
  after: `${(memAfter / 1024).toFixed(1)}KB`,
43
- saved: `${((memAfter - memBefore) / 1024).toFixed(1)}KB`,
43
+ saved: `${((memBefore - memAfter) / 1024).toFixed(1)}KB`,
44
44
  },
45
45
  {
46
46
  label: 'Est. tokens',
47
47
  before: `~${before.toLocaleString()}`,
48
48
  after: `~${after.toLocaleString()}`,
49
- saved: `~${(after - before).toLocaleString()}`,
49
+ saved: `~${(before - after).toLocaleString()}`,
50
50
  },
51
51
  ];
52
52
  return {
package/dist/scanner.js CHANGED
@@ -47,11 +47,13 @@ async function isBrokenSymlink(p) {
47
47
  }
48
48
  }
49
49
  }
50
- async function runCommand(cmd) {
50
+ // execFile (not exec) — never routes through a shell, so command arguments
51
+ // cannot be interpreted as shell metacharacters regardless of caller inputs.
52
+ async function runCommand(file, args) {
51
53
  try {
52
- const { exec } = await import('node:child_process');
54
+ const { execFile } = await import('node:child_process');
53
55
  return new Promise((resolve) => {
54
- exec(cmd, { timeout: 10000 }, (_err, stdout) => {
56
+ execFile(file, args, { timeout: 10000 }, (_err, stdout) => {
55
57
  resolve(stdout || '');
56
58
  });
57
59
  });
@@ -307,7 +309,7 @@ export function parseDisabledPlugins(output) {
307
309
  return disabled;
308
310
  }
309
311
  async function getDisabledPlugins() {
310
- return parseDisabledPlugins(await runCommand('claude plugin list'));
312
+ return parseDisabledPlugins(await runCommand('claude', ['plugin', 'list']));
311
313
  }
312
314
  export function parseClaudeMdSections(content) {
313
315
  const sections = [];
package/dist/selection.js CHANGED
@@ -28,9 +28,11 @@ export function resolveRestoreSelection(input, count) {
28
28
  return Array.from({ length: count }, (_, i) => i);
29
29
  }
30
30
  const indices = [];
31
+ const seen = new Set();
31
32
  for (const part of trimmed.split(',')) {
32
33
  const num = parseInt(part.trim(), 10);
33
- if (!isNaN(num) && num >= 1 && num <= count) {
34
+ if (!isNaN(num) && num >= 1 && num <= count && !seen.has(num)) {
35
+ seen.add(num);
34
36
  indices.push(num - 1);
35
37
  }
36
38
  }
package/dist/tokenizer.js CHANGED
@@ -1,10 +1,13 @@
1
1
  import { createHash } from 'node:crypto';
2
- import { readFile, writeFile, mkdir } from 'node:fs/promises';
2
+ import { readFile, writeFile, mkdir, rename } from 'node:fs/promises';
3
3
  import { dirname, join } from 'node:path';
4
- import { homedir } from 'node:os';
4
+ import { getClaudeDir } from './paths.js';
5
5
  let encoder = null;
6
6
  let useFallback = false;
7
- const CACHE_PATH = join(homedir(), '.claude', '.token-cache.json');
7
+ // Resolved lazily so the HOME env stub used in tests is honored.
8
+ function getCachePath() {
9
+ return join(getClaudeDir(), '.token-cache.json');
10
+ }
8
11
  let cache = { version: 1, entries: {} };
9
12
  let cacheDirty = false;
10
13
  export async function initTokenizer() {
@@ -16,8 +19,12 @@ export async function initTokenizer() {
16
19
  catch {
17
20
  useFallback = true;
18
21
  }
22
+ // Reset in-memory state so repeated initTokenizer() calls (e.g. across
23
+ // test cases) don't bleed cache entries from a prior invocation.
24
+ cache = { version: 1, entries: {} };
25
+ cacheDirty = false;
19
26
  try {
20
- const raw = await readFile(CACHE_PATH, 'utf-8');
27
+ const raw = await readFile(getCachePath(), 'utf-8');
21
28
  cache = JSON.parse(raw);
22
29
  }
23
30
  catch {
@@ -47,9 +54,15 @@ export function countTokensCached(text, filePath) {
47
54
  export async function flushCache() {
48
55
  if (!cacheDirty)
49
56
  return;
57
+ const target = getCachePath();
58
+ const tmp = target + '.tmp';
50
59
  try {
51
- await mkdir(dirname(CACHE_PATH), { recursive: true });
52
- await writeFile(CACHE_PATH, JSON.stringify(cache, null, 2));
60
+ await mkdir(dirname(target), { recursive: true });
61
+ // Atomic: write to a sibling tmp file first, then rename. A crash mid-write
62
+ // leaves the prior cache (or nothing) — never a torn JSON file.
63
+ await writeFile(tmp, JSON.stringify(cache, null, 2));
64
+ await rename(tmp, target);
65
+ cacheDirty = false;
53
66
  }
54
67
  catch {
55
68
  // Non-critical
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-slim",
3
- "version": "2.2.1",
3
+ "version": "2.2.3",
4
4
  "description": "Analyze and reduce Claude Code token overhead",
5
5
  "type": "module",
6
6
  "bin": {
@@ -41,26 +41,28 @@ bash "${CLAUDE_PLUGIN_ROOT}/skills/claude-slim/scripts/scan.sh"
41
41
 
42
42
  After getting the scan JSON, YOU must interpret and present results to the user. Do NOT just dump raw CLI output. Present a full diagnostic report in the user's language.
43
43
 
44
+ > **Templates below are shown in English for readability. Always translate headers, labels, and prompts into the user's detected language when rendering.**
45
+
44
46
  ### 2-1. Environment Snapshot Table
45
47
 
46
48
  Show a summary table:
47
49
 
48
- | 항목 | 수치 | 토큰 |
49
- |------|------|------|
50
- | 로컬 스킬 | N (XKB) | X tok |
51
- | 플러그인 | N (M 스킬) | ~X tok |
50
+ | Item | Count | Tokens |
51
+ |------|-------|--------|
52
+ | Local skills | N (XKB) | X tok |
53
+ | Plugins | N (M skills) | ~X tok |
52
54
  | CLAUDE.md | XKB | X tok |
53
- | 메모리 파일 | N (XKB) | ~X tok |
54
- | **세션 시작 오버헤드** | | **~X tok** |
55
+ | Memory files | N (XKB) | ~X tok |
56
+ | **Session startup overhead** | | **~X tok** |
55
57
 
56
58
  ### 2-2. Plugin Detail Table
57
59
 
58
60
  List each plugin with skill count and a judgment:
59
61
 
60
- | 플러그인 | 스킬 | 비고 |
61
- |----------|:-------:|------|
62
- | omc | 36 | 코어 플러그인. 유지 |
63
- | temp_local_... | 1 | **실패한 설치 잔여물. 삭제 대상** |
62
+ | Plugin | Skills | Notes |
63
+ |--------|:------:|-------|
64
+ | omc | 36 | Core plugin. Keep. |
65
+ | temp_local_... | 1 | **Failed install remnant. Cleanup target.** |
64
66
 
65
67
  Annotate each with status: actively used, possibly unused, or cleanup target. Flag `temp_local_*` entries as failed install remnants.
66
68
 
@@ -68,18 +70,18 @@ Annotate each with status: actively used, possibly unused, or cleanup target. Fl
68
70
 
69
71
  Group issues by tier and explain EACH one with context and recommendation:
70
72
 
71
- **Tier 1 — 즉시 정리 (위험 없음):**
73
+ **Tier 1 — Immediate cleanup (zero risk):**
72
74
  These are safe to remove with zero risk: broken symlinks, empty templates, .skill/ duplicates, temp_local_* cache. Pre-selected. Explain why each is safe.
73
75
 
74
- **Tier 2 — 정리 추천:**
76
+ **Tier 2 — Recommended cleanup:**
75
77
  These are recommended but need user judgment. For each issue, explain:
76
78
  - What is it and why it's flagged
77
79
  - What happens if you remove it (safe? any side effects?)
78
80
  - How many tokens it saves
79
81
 
80
- Example: "frontend-design 로컬과 플러그인에 있습니다. 로컬 제거해도 플러그인 버전이 남으니 안전하게 제거 가능. ~823 tok 절감."
82
+ Example: "frontend-design exists both locally and in a plugin. Removing the local copy is safe because the plugin version remains. Saves ~823 tok."
81
83
 
82
- **Tier 3 — 선택 사항 (사용자 판단):**
84
+ **Tier 3 — Optional (user judgment):**
83
85
  These are large skills that cost tokens but might be in active use. For each:
84
86
  - Show size and token cost
85
87
  - Judge whether the user likely uses it (based on what it does)
@@ -94,7 +96,7 @@ End with a numbered action list, ordered by impact:
94
96
 
95
97
  Show estimated total token savings if all recommended actions are taken.
96
98
 
97
- If subcommand is `scan`, stop here. Ask "정리할까요?" only for the full pipeline.
99
+ If subcommand is `scan`, stop here. Ask a localized equivalent of "Proceed with cleanup?" only for the full pipeline.
98
100
 
99
101
  ---
100
102
 
@@ -133,7 +135,7 @@ cd "${CLAUDE_PLUGIN_ROOT}" && node dist/cli.js restore
133
135
 
134
136
  ## Language
135
137
 
136
- Detect the user's language from their most recent message. Present all reports, analysis, and explanations in that language. The CLI output is machine-readable translate only the user-facing interpretation.
138
+ Detect the user's language from their most recent message. Present all reports, analysis, and explanations in that language — including table headers, tier labels, prompts, and every user-facing string. The CLI output is machine-readable (always English) and must not be echoed verbatim; translate its content into the user's language when you interpret it. The example tables above are written in English only for authoring clarity — do not treat them as a required output format.
137
139
 
138
140
  ## Rules
139
141