claude-slim 2.2.3 → 2.4.0

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
@@ -2,9 +2,13 @@
2
2
 
3
3
  # claude-slim
4
4
 
5
+ [![npm](https://img.shields.io/npm/v/claude-slim.svg)](https://www.npmjs.com/package/claude-slim)
6
+ [![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)
7
+ [![license](https://img.shields.io/npm/l/claude-slim.svg)](./LICENSE)
8
+
5
9
  **You're burning thousands of tokens before you even say "hello."**
6
10
 
7
- Every session loads *every* skill, memory file, and plugin instruction into the system prompt — even the ones you never use. claude-slim finds and removes that waste.
11
+ Every Claude Code session auto-loads every skill, memory file, and plugin instruction into the system prompt — even the ones you never use. If you run OMC, marketplace plugins, or a custom skill stack, you're paying for context you'll never touch. claude-slim finds and removes that waste.
8
12
 
9
13
  ```
10
14
  /claude-slim
@@ -69,6 +73,7 @@ That's slower responses. Hitting your usage cap faster. Paying for context you'r
69
73
  | Duplicates | Same skill registered from multiple sources |
70
74
  | Empty templates | Placeholder skills with no content |
71
75
  | Oversized files | SKILL.md over 10KB |
76
+ | **Unused skills** | **Local skills never invoked in your last N days of sessions (default 60d)** |
72
77
  | Stale memory | Large memory files loaded every session |
73
78
  | Disabled plugins | Installed but disabled plugins still in cache |
74
79
  | Stale projects | Project memory untouched for 90+ days |
@@ -115,41 +120,45 @@ That's slower responses. Hitting your usage cap faster. Paying for context you'r
115
120
 
116
121
  ---
117
122
 
118
- ## Install (10 seconds)
123
+ ## Try it (10 seconds)
124
+
125
+ No install needed — run once and see what's in your `~/.claude/`:
119
126
 
120
127
  ```bash
121
- claude plugin marketplace add iops-leo/claude-slim
122
- claude plugin install claude-slim
128
+ npx claude-slim scan
123
129
  ```
124
130
 
125
- Then just type `/claude-slim` in any session.
126
-
127
- Or use the standalone CLI:
131
+ Happy with what you see? Make it part of your Claude Code workflow:
128
132
 
129
133
  ```bash
130
- npx claude-slim scan
134
+ claude plugin marketplace add iops-leo/claude-slim
135
+ claude plugin install claude-slim
131
136
  ```
132
137
 
138
+ Then just type `/claude-slim` in any session.
139
+
133
140
  ---
134
141
 
135
142
  ## Usage
136
143
 
137
144
  ```bash
138
- /claude-slim # Full pipeline: scan → propose → clean → report
139
- /claude-slim scan # Report only, no changes
140
- /claude-slim scan --json # Machine-readable JSON output
141
- /claude-slim restore # Bring back anything you disabled
145
+ /claude-slim # Full pipeline: scan → propose → clean → report
146
+ /claude-slim scan # Report only, no changes
147
+ /claude-slim scan --json # Machine-readable JSON output
148
+ /claude-slim scan --lookback-days 30 # Treat skills idle for 30+ days as unused
149
+ /claude-slim restore # Bring back anything you disabled
142
150
  ```
143
151
 
144
152
  CLI equivalents:
145
153
 
146
154
  ```bash
147
- npx claude-slim clean # Full pipeline
148
- npx claude-slim clean --dry-run # See what would happen (no changes)
149
- npx claude-slim clean --auto # Non-interactive, Tier 1 only (CI/scripts)
150
- npx claude-slim scan # Report only
151
- npx claude-slim restore # Undo
152
- npx claude-slim report # Show savings from last clean
155
+ npx claude-slim clean # Full pipeline
156
+ npx claude-slim clean --dry-run # See what would happen (no changes)
157
+ npx claude-slim clean --auto # Non-interactive, Tier 1 only (CI/scripts)
158
+ npx claude-slim clean --lookback-days N # Tune the unused-skill detection window
159
+ npx claude-slim scan # Report only
160
+ npx claude-slim restore # Undo
161
+ npx claude-slim report # Show savings from last clean
153
162
  ```
154
163
 
155
164
  ---
@@ -162,6 +171,17 @@ npx claude-slim report # Show savings from last clean
162
171
  | **Reversible** | `/claude-slim restore` brings anything back, any time |
163
172
  | **User-controlled** | Always asks before making changes. `--dry-run` to preview. |
164
173
  | **Hands off** | Never touches CLAUDE.md, settings.json, or plugin configs |
174
+ | **Scoped** | All operations are refused if the target path escapes `~/.claude/` |
175
+
176
+ ### What claude-slim never touches
177
+
178
+ - **`~/.claude/CLAUDE.md`** — your system instructions, read-only.
179
+ - **`~/.claude/settings.json`** — MCP server config, hooks, and any other settings. Read-only.
180
+ - **Plugin internals** (`~/.claude/plugins/config.json`, individual `plugin.json` files) — left alone; use `claude plugin` to manage plugins.
181
+ - **Git / project sources** — claude-slim only looks inside `~/.claude/`, never at your code.
182
+ - **Anything outside `~/.claude/`** — a path-containment guard refuses destructive ops anywhere else, even if a tampered manifest asked it to.
183
+
184
+ Only touched: entries under `~/.claude/skills/`, `~/.claude/plugins/cache/temp_local_*`, and `~/.claude/projects/*/memory/` — and even those are moved to `skills.disabled/`, not deleted (except `temp_local_*` failed-install caches, which are removed outright).
165
185
 
166
186
  ---
167
187
 
@@ -195,6 +215,20 @@ From a real cleanup session:
195
215
 
196
216
  ---
197
217
 
218
+ ## v2.4 — What's new
219
+
220
+ - **Unused-skill detection** — claude-slim now reads your `~/.claude/projects/*/*.jsonl` session transcripts, finds every `Skill` tool invocation in the last 60 days, and flags local skills you've installed but never actually used. Tier 3 (Optional, never auto-selected) so you decide. Configurable lookback via `--lookback-days <n>`. Falls back silently if there's not enough session history (≥3 sessions required) — no false-flagging when the data source is unreliable.
221
+ - **Plugin skills are intentionally out of scope** for this detector. They live inside `~/.claude/plugins/cache/` and are managed by the Claude Code plugin runtime; moving them would partially uninstall the plugin. Use `claude plugin disable <name>` for plugin-level cleanup.
222
+ - **Per-file session-usage cache** at `~/.claude/.skill-usage-cache.json` keyed by mtime. Warm rescans only re-parse session logs that have changed.
223
+ - **Node 20+** is now the engine floor (previously `>=18`, but Node 18 was already dropped from CI in v2.3.0).
224
+
225
+ ## v2.3 — What's new
226
+
227
+ - **Detector registry refactor (v2.3.0)** — Scanner split from a 588-line module into focused detectors under `src/scanner/`. Adding a new heuristic is a one-function addition; see CONTRIBUTING.md. Public API unchanged.
228
+ - **Path-containment guard (v2.2.3)** — Every destructive op refuses any target path that escapes `~/.claude/`. `runCommand` no longer goes through a shell. `temp_cache` cleanup is symlink-safe.
229
+ - **Report sign fix (v2.2.3)** — The breakdown table's Saved column was inverted in earlier 2.2.x; cleanup now shows correct savings per row.
230
+ - **85 tests (was 73)** — New round-trip coverage for path containment, restore guards, breakdown sign, restore-selection dedup, atomic tokenizer flush, and custom detector injection.
231
+
198
232
  ## v2.2 — What's new
199
233
 
200
234
  - **Atomic `stale_project` clean/restore** — Single directory `rename()` instead of per-file loop. No more partial-failure state if the operation is interrupted.
@@ -225,7 +259,7 @@ From a real cleanup session:
225
259
 
226
260
  ## Requirements
227
261
 
228
- - Node.js 18+
262
+ - Node.js 20+
229
263
  - macOS or Linux
230
264
  - Claude Code CLI
231
265
 
package/dist/cleaner.js CHANGED
@@ -51,7 +51,8 @@ export async function cleanIssues(issues) {
51
51
  else if (issue.type === 'template' ||
52
52
  issue.type === 'duplicate' ||
53
53
  issue.type === 'skill_dup' ||
54
- issue.type === 'oversized_skill') {
54
+ issue.type === 'oversized_skill' ||
55
+ issue.type === 'unused_skill') {
55
56
  // Move skill directory to disabled — use name (not basename) to avoid namespace collisions
56
57
  const safeName = issue.name.replace(/\//g, '--');
57
58
  const dest = join(disabledDir, safeName);
package/dist/cli.js CHANGED
@@ -22,9 +22,10 @@ program
22
22
  .command('scan')
23
23
  .description('Scan environment and report issues')
24
24
  .option('--json', 'Output raw JSON')
25
+ .option('--lookback-days <n>', 'Days of session history for skill-usage analysis', '60')
25
26
  .action(async (opts) => {
26
27
  await initTokenizer();
27
- const result = await scan();
28
+ const result = await scan({ lookbackDays: parseInt(opts.lookbackDays, 10) || 60 });
28
29
  await flushCache();
29
30
  if (opts.json) {
30
31
  console.log(JSON.stringify(result, null, 2));
@@ -40,11 +41,13 @@ program
40
41
  .option('--dry-run', 'Show what would happen without making changes')
41
42
  .option('--auto', 'Non-interactive: auto-select Tier 1 items only')
42
43
  .option('--sessions-per-day <n>', 'Sessions per day for savings estimate', '2')
44
+ .option('--lookback-days <n>', 'Days of session history for skill-usage analysis', '60')
43
45
  .action(async (opts) => {
44
46
  await runCleanPipeline({
45
47
  dryRun: !!opts.dryRun,
46
48
  auto: !!opts.auto,
47
49
  sessionsPerDay: parseInt(opts.sessionsPerDay, 10) || 2,
50
+ lookbackDays: parseInt(opts.lookbackDays, 10) || 60,
48
51
  });
49
52
  });
50
53
  // --- restore ---
@@ -110,9 +113,10 @@ program
110
113
  .command('report')
111
114
  .description('Show savings report from last clean')
112
115
  .option('--sessions-per-day <n>', 'Sessions per day for savings estimate', '2')
116
+ .option('--lookback-days <n>', 'Days of session history for skill-usage analysis', '60')
113
117
  .action(async (opts) => {
114
118
  await initTokenizer();
115
- const result = await scan();
119
+ const result = await scan({ lookbackDays: parseInt(opts.lookbackDays, 10) || 60 });
116
120
  const entries = await readManifest();
117
121
  const movedEntries = entries.filter((e) => e.tokenCount && e.tokenCount > 0);
118
122
  if (movedEntries.length === 0) {
@@ -125,7 +129,9 @@ program
125
129
  // Only skill-type entries contributed to the per-skill prompt overhead
126
130
  // (stale_project restores memory tokens separately; broken_symlink/
127
131
  // temp_cache never counted toward totalTokensBefore).
128
- const SKILL_TYPES = new Set(['template', 'duplicate', 'skill_dup', 'oversized_skill']);
132
+ const SKILL_TYPES = new Set([
133
+ 'template', 'duplicate', 'skill_dup', 'oversized_skill', 'unused_skill',
134
+ ]);
129
135
  const removedSkillEntries = movedEntries.filter((e) => SKILL_TYPES.has(e.type));
130
136
  const removedMemoryTokens = movedEntries
131
137
  .filter((e) => e.type === 'stale_project')
@@ -155,12 +161,12 @@ program
155
161
  });
156
162
  // --- default (no subcommand) → run clean ---
157
163
  program.action(async () => {
158
- await runCleanPipeline({ dryRun: false, auto: false, sessionsPerDay: 2 });
164
+ await runCleanPipeline({ dryRun: false, auto: false, sessionsPerDay: 2, lookbackDays: 60 });
159
165
  });
160
166
  // --- shared clean pipeline ---
161
167
  async function runCleanPipeline(opts) {
162
168
  await initTokenizer();
163
- const result = await scan();
169
+ const result = await scan({ lookbackDays: opts.lookbackDays });
164
170
  if (result.issues.length === 0) {
165
171
  console.log('\n \x1b[32mAlready slim!\x1b[0m No issues found.\n');
166
172
  await flushCache();
@@ -207,7 +213,7 @@ async function runCleanPipeline(opts) {
207
213
  console.log('');
208
214
  const cleanResult = await cleanIssues(selectedIssues);
209
215
  // Re-scan after cleanup for accurate breakdown
210
- const afterResult = await scan();
216
+ const afterResult = await scan({ lookbackDays: opts.lookbackDays });
211
217
  const reportData = calculateReport(result, afterResult, cleanResult.moved, opts.sessionsPerDay);
212
218
  console.log('');
213
219
  console.log(formatReportBox(reportData));
@@ -0,0 +1,5 @@
1
+ export declare function parseClaudeMdSections(content: string): Array<{
2
+ name: string;
3
+ sizeBytes: number;
4
+ tokens: number;
5
+ }>;
@@ -0,0 +1,45 @@
1
+ import { countTokensCached } from '../tokenizer.js';
2
+ export function parseClaudeMdSections(content) {
3
+ const sections = [];
4
+ const lines = content.split('\n');
5
+ let currentName = null;
6
+ let currentContent = '';
7
+ for (const line of lines) {
8
+ if (line.startsWith('# ')) {
9
+ if (currentName !== null) {
10
+ sections.push({
11
+ name: currentName,
12
+ sizeBytes: Buffer.byteLength(currentContent),
13
+ tokens: countTokensCached(currentContent, `claude-md-section:${currentName}`),
14
+ });
15
+ }
16
+ else if (currentContent.trim()) {
17
+ sections.push({
18
+ name: '(preamble)',
19
+ sizeBytes: Buffer.byteLength(currentContent),
20
+ tokens: countTokensCached(currentContent, 'claude-md-section:preamble'),
21
+ });
22
+ }
23
+ currentName = line.slice(2).trim().slice(0, 60);
24
+ currentContent = line + '\n';
25
+ }
26
+ else {
27
+ currentContent += line + '\n';
28
+ }
29
+ }
30
+ if (currentName !== null) {
31
+ sections.push({
32
+ name: currentName,
33
+ sizeBytes: Buffer.byteLength(currentContent),
34
+ tokens: countTokensCached(currentContent, `claude-md-section:${currentName}`),
35
+ });
36
+ }
37
+ else if (currentContent.trim()) {
38
+ sections.push({
39
+ name: '(preamble)',
40
+ sizeBytes: Buffer.byteLength(currentContent),
41
+ tokens: countTokensCached(currentContent, 'claude-md-section:preamble'),
42
+ });
43
+ }
44
+ return sections;
45
+ }
@@ -0,0 +1,4 @@
1
+ export declare const STALE_DAYS = 90;
2
+ export declare const OVERSIZED_SKILL_BYTES = 10240;
3
+ export declare const OVERSIZED_MEMORY_BYTES = 5120;
4
+ export declare const SKILL_PROMPT_OVERHEAD_TOKENS = 30;
@@ -0,0 +1,4 @@
1
+ export const STALE_DAYS = 90;
2
+ export const OVERSIZED_SKILL_BYTES = 10240;
3
+ export const OVERSIZED_MEMORY_BYTES = 5120;
4
+ export const SKILL_PROMPT_OVERHEAD_TOKENS = 30;
@@ -0,0 +1,23 @@
1
+ import type { SkillInfo, BrokenSymlink, MemoryFile, PluginInfo, Issue } from '../types.js';
2
+ import type { TempCache } from './plugin-skills.js';
3
+ import type { StaleProject } from './memory.js';
4
+ export interface DetectorContext {
5
+ localSkills: SkillInfo[];
6
+ pluginSkills: SkillInfo[];
7
+ brokenSymlinks: BrokenSymlink[];
8
+ memoryFiles: MemoryFile[];
9
+ tempCaches: TempCache[];
10
+ staleProjects: StaleProject[];
11
+ disabledPlugins: Set<string>;
12
+ plugins: PluginInfo[];
13
+ contents: Map<string, string>;
14
+ recentSkillInvocations: Set<string>;
15
+ sessionDataAvailable: boolean;
16
+ lookbackDays: number;
17
+ }
18
+ export interface Detector {
19
+ name: string;
20
+ detect(ctx: DetectorContext): Issue[];
21
+ }
22
+ export declare const detectors: Detector[];
23
+ export declare function classifyIssues(ctx: DetectorContext, registry?: Detector[]): Issue[];
@@ -0,0 +1,215 @@
1
+ import { join } from 'node:path';
2
+ import { getPluginsDir } from '../paths.js';
3
+ import { OVERSIZED_SKILL_BYTES, OVERSIZED_MEMORY_BYTES, SKILL_PROMPT_OVERHEAD_TOKENS, } from './constants.js';
4
+ const brokenSymlinkDetector = {
5
+ name: 'broken_symlink',
6
+ detect({ brokenSymlinks }) {
7
+ return brokenSymlinks.map((link) => ({
8
+ type: 'broken_symlink',
9
+ tier: 1,
10
+ name: link.name,
11
+ detail: link.target,
12
+ tokens: 0,
13
+ path: link.path,
14
+ }));
15
+ },
16
+ };
17
+ const templateDetector = {
18
+ name: 'template',
19
+ detect({ localSkills, contents }) {
20
+ const issues = [];
21
+ for (const skill of localSkills) {
22
+ const skillMdPath = join(skill.path, 'SKILL.md');
23
+ const content = contents.get(skillMdPath);
24
+ if (content && content.includes('Replace with description')) {
25
+ issues.push({
26
+ type: 'template',
27
+ tier: 1,
28
+ name: skill.name,
29
+ tokens: skill.tokens,
30
+ path: skill.path,
31
+ });
32
+ }
33
+ }
34
+ return issues;
35
+ },
36
+ };
37
+ const duplicateDetector = {
38
+ name: 'duplicate',
39
+ detect({ localSkills, pluginSkills }) {
40
+ const pluginSkillNames = new Set(pluginSkills.map((s) => s.name));
41
+ const issues = [];
42
+ for (const skill of localSkills) {
43
+ // Check base name for nested skills (e.g. "org/ship" → "ship")
44
+ const baseName = skill.name.includes('/') ? skill.name.split('/').pop() : skill.name;
45
+ if (pluginSkillNames.has(baseName)) {
46
+ issues.push({
47
+ type: 'duplicate',
48
+ tier: 2,
49
+ name: skill.name,
50
+ detail: 'local+plugin',
51
+ tokens: skill.tokens,
52
+ path: skill.path,
53
+ });
54
+ }
55
+ }
56
+ return issues;
57
+ },
58
+ };
59
+ const oversizedSkillDetector = {
60
+ name: 'oversized_skill',
61
+ detect({ localSkills }) {
62
+ const issues = [];
63
+ for (const skill of localSkills) {
64
+ if (skill.sizeBytes > OVERSIZED_SKILL_BYTES) {
65
+ issues.push({
66
+ type: 'oversized_skill',
67
+ tier: 3,
68
+ name: skill.name,
69
+ detail: `${Math.round(skill.sizeBytes / 1024)}KB`,
70
+ tokens: skill.tokens,
71
+ path: skill.path,
72
+ });
73
+ }
74
+ }
75
+ return issues;
76
+ },
77
+ };
78
+ const skillDupDetector = {
79
+ name: 'skill_dup',
80
+ detect({ localSkills }) {
81
+ const issues = [];
82
+ for (const skill of localSkills) {
83
+ const dotSkillDir = skill.path + '.skill';
84
+ if (localSkills.some((s) => s.path === dotSkillDir)) {
85
+ issues.push({
86
+ type: 'skill_dup',
87
+ tier: 1,
88
+ name: skill.name,
89
+ tokens: 0,
90
+ path: dotSkillDir,
91
+ });
92
+ }
93
+ }
94
+ return issues;
95
+ },
96
+ };
97
+ const tempCacheDetector = {
98
+ name: 'temp_cache',
99
+ detect({ tempCaches }) {
100
+ return tempCaches.map((temp) => ({
101
+ type: 'temp_cache',
102
+ tier: 1,
103
+ name: temp.name,
104
+ detail: `${temp.sizeKB}KB`,
105
+ tokens: 0,
106
+ path: temp.path,
107
+ }));
108
+ },
109
+ };
110
+ const oversizedMemoryDetector = {
111
+ name: 'oversized_memory',
112
+ detect({ memoryFiles }) {
113
+ const issues = [];
114
+ for (const mem of memoryFiles) {
115
+ if (mem.sizeBytes > OVERSIZED_MEMORY_BYTES) {
116
+ issues.push({
117
+ type: 'oversized_memory',
118
+ tier: 2,
119
+ name: `${mem.project}/${mem.name}`,
120
+ detail: `${Math.round(mem.sizeBytes / 1024)}KB`,
121
+ tokens: mem.tokens,
122
+ path: mem.path,
123
+ });
124
+ }
125
+ }
126
+ return issues;
127
+ },
128
+ };
129
+ const staleProjectDetector = {
130
+ name: 'stale_project',
131
+ detect({ staleProjects, memoryFiles }) {
132
+ return staleProjects.map((stale) => {
133
+ const memTokens = memoryFiles
134
+ .filter((m) => m.project === stale.project)
135
+ .reduce((sum, m) => sum + m.tokens, 0);
136
+ return {
137
+ type: 'stale_project',
138
+ tier: 2,
139
+ name: stale.project,
140
+ detail: `${stale.ageDays}d, ${stale.fileCount} files, ${Math.round(stale.totalBytes / 1024)}KB`,
141
+ tokens: memTokens,
142
+ path: stale.path,
143
+ };
144
+ });
145
+ },
146
+ };
147
+ const unusedSkillDetector = {
148
+ name: 'unused_skill',
149
+ detect({ localSkills, recentSkillInvocations, sessionDataAvailable, lookbackDays, }) {
150
+ // Suppress entirely when the data source is unreliable — better no signal
151
+ // than a wrong one that flags every skill as unused.
152
+ if (!sessionDataAvailable)
153
+ return [];
154
+ const issues = [];
155
+ for (const skill of localSkills) {
156
+ // Direct hit: invocation set contains the skill name as-is.
157
+ if (recentSkillInvocations.has(skill.name))
158
+ continue;
159
+ // Nested skill (e.g. "org/ship"): also check the bare leaf name, which
160
+ // is how it would appear in a Skill tool_use input.
161
+ if (skill.name.includes('/')) {
162
+ const leaf = skill.name.split('/').pop();
163
+ if (recentSkillInvocations.has(leaf))
164
+ continue;
165
+ }
166
+ issues.push({
167
+ type: 'unused_skill',
168
+ tier: 3,
169
+ name: skill.name,
170
+ detail: `not invoked in ${lookbackDays}d`,
171
+ tokens: skill.tokens,
172
+ path: skill.path,
173
+ });
174
+ }
175
+ return issues;
176
+ },
177
+ };
178
+ const disabledPluginDetector = {
179
+ name: 'disabled_plugin',
180
+ detect({ plugins, disabledPlugins }) {
181
+ const issues = [];
182
+ for (const plugin of plugins) {
183
+ if (disabledPlugins.has(plugin.name)) {
184
+ issues.push({
185
+ type: 'disabled_plugin',
186
+ tier: 2,
187
+ name: plugin.name,
188
+ detail: `${plugin.skillCount} skills`,
189
+ tokens: plugin.skillCount * SKILL_PROMPT_OVERHEAD_TOKENS,
190
+ path: join(getPluginsDir(), plugin.name),
191
+ });
192
+ }
193
+ }
194
+ return issues;
195
+ },
196
+ };
197
+ // The full registry. Order only matters for ties in the tier sort.
198
+ // New detectors: define above, add here, update CONTRIBUTING.md's issue-type table.
199
+ export const detectors = [
200
+ brokenSymlinkDetector,
201
+ templateDetector,
202
+ duplicateDetector,
203
+ oversizedSkillDetector,
204
+ skillDupDetector,
205
+ tempCacheDetector,
206
+ oversizedMemoryDetector,
207
+ staleProjectDetector,
208
+ unusedSkillDetector,
209
+ disabledPluginDetector,
210
+ ];
211
+ export function classifyIssues(ctx, registry = detectors) {
212
+ const issues = registry.flatMap((d) => d.detect(ctx));
213
+ issues.sort((a, b) => a.tier - b.tier);
214
+ return issues;
215
+ }
@@ -0,0 +1,2 @@
1
+ export declare function parseDisabledPlugins(output: string): Set<string>;
2
+ export declare function getDisabledPlugins(): Promise<Set<string>>;
@@ -0,0 +1,26 @@
1
+ import { runCommand } from './fs-walk.js';
2
+ export function parseDisabledPlugins(output) {
3
+ const disabled = new Set();
4
+ if (!output)
5
+ return disabled;
6
+ let currentName = null;
7
+ for (const line of output.split('\n')) {
8
+ const trimmed = line.trim();
9
+ if (trimmed.startsWith('❯')) {
10
+ const full = trimmed.split('❯')[1]?.trim() || '';
11
+ // Format: sub-plugin@marketplace — extract marketplace name for cache dir matching
12
+ currentName = full.includes('@') ? full.split('@')[1] : full;
13
+ }
14
+ else if (trimmed.toLowerCase().includes('disabled') && currentName) {
15
+ disabled.add(currentName);
16
+ currentName = null;
17
+ }
18
+ else if (trimmed.toLowerCase().includes('enabled')) {
19
+ currentName = null;
20
+ }
21
+ }
22
+ return disabled;
23
+ }
24
+ export async function getDisabledPlugins() {
25
+ return parseDisabledPlugins(await runCommand('claude', ['plugin', 'list']));
26
+ }
@@ -0,0 +1,7 @@
1
+ export declare function safeReadFile(p: string): Promise<string | null>;
2
+ export declare function safeReaddir(p: string): Promise<string[]>;
3
+ export declare function isDirectory(p: string): Promise<boolean>;
4
+ export declare function isBrokenSymlink(p: string): Promise<boolean>;
5
+ export declare function resolveRealPath(p: string): Promise<string>;
6
+ export declare function getDirSize(dir: string): Promise<number>;
7
+ export declare function runCommand(file: string, args: string[]): Promise<string>;
@@ -0,0 +1,82 @@
1
+ import { readFile, readdir, lstat, realpath, stat } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ export async function safeReadFile(p) {
4
+ try {
5
+ return await readFile(p, 'utf-8');
6
+ }
7
+ catch {
8
+ return null;
9
+ }
10
+ }
11
+ export async function safeReaddir(p) {
12
+ try {
13
+ return await readdir(p);
14
+ }
15
+ catch {
16
+ return [];
17
+ }
18
+ }
19
+ export async function isDirectory(p) {
20
+ try {
21
+ return (await stat(p)).isDirectory();
22
+ }
23
+ catch {
24
+ return false;
25
+ }
26
+ }
27
+ export async function isBrokenSymlink(p) {
28
+ try {
29
+ const lstats = await lstat(p);
30
+ if (!lstats.isSymbolicLink())
31
+ return false;
32
+ await realpath(p);
33
+ return false;
34
+ }
35
+ catch {
36
+ try {
37
+ return (await lstat(p)).isSymbolicLink();
38
+ }
39
+ catch {
40
+ return false;
41
+ }
42
+ }
43
+ }
44
+ export async function resolveRealPath(p) {
45
+ try {
46
+ return await realpath(p);
47
+ }
48
+ catch {
49
+ return p;
50
+ }
51
+ }
52
+ export async function getDirSize(dir) {
53
+ let total = 0;
54
+ const entries = await safeReaddir(dir);
55
+ for (const entry of entries) {
56
+ const p = join(dir, entry);
57
+ try {
58
+ const s = await stat(p);
59
+ if (s.isFile())
60
+ total += s.size;
61
+ else if (s.isDirectory())
62
+ total += await getDirSize(p);
63
+ }
64
+ catch { /* skip */ }
65
+ }
66
+ return total;
67
+ }
68
+ // execFile (not exec) — never routes through a shell, so command arguments
69
+ // cannot be interpreted as shell metacharacters regardless of caller inputs.
70
+ export async function runCommand(file, args) {
71
+ try {
72
+ const { execFile } = await import('node:child_process');
73
+ return new Promise((resolve) => {
74
+ execFile(file, args, { timeout: 10000 }, (_err, stdout) => {
75
+ resolve(stdout || '');
76
+ });
77
+ });
78
+ }
79
+ catch {
80
+ return '';
81
+ }
82
+ }
@@ -0,0 +1,5 @@
1
+ import type { ScanResult } from '../types.js';
2
+ export interface ScanOptions {
3
+ lookbackDays?: number;
4
+ }
5
+ export declare function scan(opts?: ScanOptions): Promise<ScanResult>;