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 +53 -19
- package/dist/cleaner.js +2 -1
- package/dist/cli.js +12 -6
- package/dist/scanner/claude-md.d.ts +5 -0
- package/dist/scanner/claude-md.js +45 -0
- package/dist/scanner/constants.d.ts +4 -0
- package/dist/scanner/constants.js +4 -0
- package/dist/scanner/detectors.d.ts +23 -0
- package/dist/scanner/detectors.js +215 -0
- package/dist/scanner/disabled-plugins.d.ts +2 -0
- package/dist/scanner/disabled-plugins.js +26 -0
- package/dist/scanner/fs-walk.d.ts +7 -0
- package/dist/scanner/fs-walk.js +82 -0
- package/dist/scanner/index.d.ts +5 -0
- package/dist/scanner/index.js +62 -0
- package/dist/scanner/local-skills.d.ts +12 -0
- package/dist/scanner/local-skills.js +97 -0
- package/dist/scanner/mcp.d.ts +5 -0
- package/dist/scanner/mcp.js +17 -0
- package/dist/scanner/memory.d.ts +13 -0
- package/dist/scanner/memory.js +50 -0
- package/dist/scanner/plugin-skills.d.ts +12 -0
- package/dist/scanner/plugin-skills.js +65 -0
- package/dist/scanner/sessions.d.ts +8 -0
- package/dist/scanner/sessions.js +150 -0
- package/dist/scanner.d.ts +5 -15
- package/dist/scanner.js +8 -530
- package/dist/types.d.ts +1 -1
- package/package.json +6 -2
package/README.md
CHANGED
|
@@ -2,9 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
# claude-slim
|
|
4
4
|
|
|
5
|
+
[](https://www.npmjs.com/package/claude-slim)
|
|
6
|
+
[](https://github.com/iops-leo/claude-slim/actions/workflows/ci.yml)
|
|
7
|
+
[](./LICENSE)
|
|
8
|
+
|
|
5
9
|
**You're burning thousands of tokens before you even say "hello."**
|
|
6
10
|
|
|
7
|
-
Every session loads
|
|
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
|
-
##
|
|
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
|
-
|
|
122
|
-
claude plugin install claude-slim
|
|
128
|
+
npx claude-slim scan
|
|
123
129
|
```
|
|
124
130
|
|
|
125
|
-
|
|
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
|
-
|
|
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
|
|
139
|
-
/claude-slim scan
|
|
140
|
-
/claude-slim scan --json
|
|
141
|
-
/claude-slim
|
|
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
|
|
148
|
-
npx claude-slim clean --dry-run
|
|
149
|
-
npx claude-slim clean --auto
|
|
150
|
-
npx claude-slim
|
|
151
|
-
npx claude-slim
|
|
152
|
-
npx claude-slim
|
|
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
|
|
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([
|
|
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,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,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,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
|
+
}
|