claude-slim 2.2.2 → 2.3.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 +25 -8
- package/dist/cleaner.js +22 -3
- package/dist/paths.d.ts +1 -0
- package/dist/paths.js +10 -1
- package/dist/report.js +4 -4
- 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 +20 -0
- package/dist/scanner/detectors.js +183 -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 +2 -0
- package/dist/scanner/index.js +55 -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.d.ts +5 -15
- package/dist/scanner.js +8 -528
- package/dist/selection.js +3 -1
- package/dist/tokenizer.js +19 -6
- package/package.json +5 -1
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
|
|
@@ -115,21 +119,23 @@ That's slower responses. Hitting your usage cap faster. Paying for context you'r
|
|
|
115
119
|
|
|
116
120
|
---
|
|
117
121
|
|
|
118
|
-
##
|
|
122
|
+
## Try it (10 seconds)
|
|
123
|
+
|
|
124
|
+
No install needed — run once and see what's in your `~/.claude/`:
|
|
119
125
|
|
|
120
126
|
```bash
|
|
121
|
-
|
|
122
|
-
claude plugin install claude-slim
|
|
127
|
+
npx claude-slim scan
|
|
123
128
|
```
|
|
124
129
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
Or use the standalone CLI:
|
|
130
|
+
Happy with what you see? Make it part of your Claude Code workflow:
|
|
128
131
|
|
|
129
132
|
```bash
|
|
130
|
-
|
|
133
|
+
claude plugin marketplace add iops-leo/claude-slim
|
|
134
|
+
claude plugin install claude-slim
|
|
131
135
|
```
|
|
132
136
|
|
|
137
|
+
Then just type `/claude-slim` in any session.
|
|
138
|
+
|
|
133
139
|
---
|
|
134
140
|
|
|
135
141
|
## Usage
|
|
@@ -162,6 +168,17 @@ npx claude-slim report # Show savings from last clean
|
|
|
162
168
|
| **Reversible** | `/claude-slim restore` brings anything back, any time |
|
|
163
169
|
| **User-controlled** | Always asks before making changes. `--dry-run` to preview. |
|
|
164
170
|
| **Hands off** | Never touches CLAUDE.md, settings.json, or plugin configs |
|
|
171
|
+
| **Scoped** | All operations are refused if the target path escapes `~/.claude/` |
|
|
172
|
+
|
|
173
|
+
### What claude-slim never touches
|
|
174
|
+
|
|
175
|
+
- **`~/.claude/CLAUDE.md`** — your system instructions, read-only.
|
|
176
|
+
- **`~/.claude/settings.json`** — MCP server config, hooks, and any other settings. Read-only.
|
|
177
|
+
- **Plugin internals** (`~/.claude/plugins/config.json`, individual `plugin.json` files) — left alone; use `claude plugin` to manage plugins.
|
|
178
|
+
- **Git / project sources** — claude-slim only looks inside `~/.claude/`, never at your code.
|
|
179
|
+
- **Anything outside `~/.claude/`** — a path-containment guard refuses destructive ops anywhere else, even if a tampered manifest asked it to.
|
|
180
|
+
|
|
181
|
+
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
182
|
|
|
166
183
|
---
|
|
167
184
|
|
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
|
-
|
|
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: `${
|
|
31
|
+
saved: `${localBefore - localAfter}`,
|
|
32
32
|
},
|
|
33
33
|
{
|
|
34
34
|
label: 'System prompt',
|
|
35
35
|
before: `~${promptBefore}`,
|
|
36
36
|
after: `~${promptAfter}`,
|
|
37
|
-
saved: `${
|
|
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: `${((
|
|
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: `~${(
|
|
49
|
+
saved: `~${(before - after).toLocaleString()}`,
|
|
50
50
|
},
|
|
51
51
|
];
|
|
52
52
|
return {
|
|
@@ -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,20 @@
|
|
|
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
|
+
}
|
|
15
|
+
export interface Detector {
|
|
16
|
+
name: string;
|
|
17
|
+
detect(ctx: DetectorContext): Issue[];
|
|
18
|
+
}
|
|
19
|
+
export declare const detectors: Detector[];
|
|
20
|
+
export declare function classifyIssues(ctx: DetectorContext, registry?: Detector[]): Issue[];
|
|
@@ -0,0 +1,183 @@
|
|
|
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 disabledPluginDetector = {
|
|
148
|
+
name: 'disabled_plugin',
|
|
149
|
+
detect({ plugins, disabledPlugins }) {
|
|
150
|
+
const issues = [];
|
|
151
|
+
for (const plugin of plugins) {
|
|
152
|
+
if (disabledPlugins.has(plugin.name)) {
|
|
153
|
+
issues.push({
|
|
154
|
+
type: 'disabled_plugin',
|
|
155
|
+
tier: 2,
|
|
156
|
+
name: plugin.name,
|
|
157
|
+
detail: `${plugin.skillCount} skills`,
|
|
158
|
+
tokens: plugin.skillCount * SKILL_PROMPT_OVERHEAD_TOKENS,
|
|
159
|
+
path: join(getPluginsDir(), plugin.name),
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return issues;
|
|
164
|
+
},
|
|
165
|
+
};
|
|
166
|
+
// The full registry. Order only matters for ties in the tier sort.
|
|
167
|
+
// New detectors: define above, add here, update CONTRIBUTING.md's issue-type table.
|
|
168
|
+
export const detectors = [
|
|
169
|
+
brokenSymlinkDetector,
|
|
170
|
+
templateDetector,
|
|
171
|
+
duplicateDetector,
|
|
172
|
+
oversizedSkillDetector,
|
|
173
|
+
skillDupDetector,
|
|
174
|
+
tempCacheDetector,
|
|
175
|
+
oversizedMemoryDetector,
|
|
176
|
+
staleProjectDetector,
|
|
177
|
+
disabledPluginDetector,
|
|
178
|
+
];
|
|
179
|
+
export function classifyIssues(ctx, registry = detectors) {
|
|
180
|
+
const issues = registry.flatMap((d) => d.detect(ctx));
|
|
181
|
+
issues.sort((a, b) => a.tier - b.tier);
|
|
182
|
+
return issues;
|
|
183
|
+
}
|
|
@@ -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,55 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
import { countTokensCached } from '../tokenizer.js';
|
|
3
|
+
import { getClaudeDir } from '../paths.js';
|
|
4
|
+
import { safeReadFile } from './fs-walk.js';
|
|
5
|
+
import { scanLocalSkills } from './local-skills.js';
|
|
6
|
+
import { scanPluginSkills } from './plugin-skills.js';
|
|
7
|
+
import { scanMemoryFiles } from './memory.js';
|
|
8
|
+
import { scanMcpServers } from './mcp.js';
|
|
9
|
+
import { parseClaudeMdSections } from './claude-md.js';
|
|
10
|
+
import { getDisabledPlugins } from './disabled-plugins.js';
|
|
11
|
+
import { classifyIssues } from './detectors.js';
|
|
12
|
+
import { SKILL_PROMPT_OVERHEAD_TOKENS } from './constants.js';
|
|
13
|
+
export async function scan() {
|
|
14
|
+
const [{ skills: localSkills, brokenSymlinks, contents }, { skills: pluginSkills, plugins, tempCaches }, { memoryFiles, staleProjects }, mcp, disabledPlugins,] = await Promise.all([
|
|
15
|
+
scanLocalSkills(),
|
|
16
|
+
scanPluginSkills(),
|
|
17
|
+
scanMemoryFiles(),
|
|
18
|
+
scanMcpServers(),
|
|
19
|
+
getDisabledPlugins(),
|
|
20
|
+
]);
|
|
21
|
+
// Annotate plugin status
|
|
22
|
+
for (const plugin of plugins) {
|
|
23
|
+
plugin.status = disabledPlugins.has(plugin.name) ? 'disabled' : 'enabled';
|
|
24
|
+
}
|
|
25
|
+
// CLAUDE.md
|
|
26
|
+
const claudeMdContent = await safeReadFile(join(getClaudeDir(), 'CLAUDE.md'));
|
|
27
|
+
const claudeMdBytes = claudeMdContent ? Buffer.byteLength(claudeMdContent) : 0;
|
|
28
|
+
const claudeMdTokens = claudeMdContent
|
|
29
|
+
? countTokensCached(claudeMdContent, join(getClaudeDir(), 'CLAUDE.md'))
|
|
30
|
+
: 0;
|
|
31
|
+
const claudeMdSections = claudeMdContent ? parseClaudeMdSections(claudeMdContent) : [];
|
|
32
|
+
const issues = classifyIssues({
|
|
33
|
+
localSkills, pluginSkills, brokenSymlinks, memoryFiles,
|
|
34
|
+
tempCaches, staleProjects, disabledPlugins, plugins,
|
|
35
|
+
contents,
|
|
36
|
+
});
|
|
37
|
+
// Estimate total tokens at startup
|
|
38
|
+
const skillListingTokens = (localSkills.length + pluginSkills.length) * SKILL_PROMPT_OVERHEAD_TOKENS;
|
|
39
|
+
const memoryTokens = memoryFiles.reduce((sum, m) => sum + m.tokens, 0);
|
|
40
|
+
const totalTokensBefore = skillListingTokens + claudeMdTokens + memoryTokens;
|
|
41
|
+
return {
|
|
42
|
+
localSkills,
|
|
43
|
+
pluginSkills,
|
|
44
|
+
plugins,
|
|
45
|
+
brokenSymlinks,
|
|
46
|
+
memoryFiles,
|
|
47
|
+
claudeMdBytes,
|
|
48
|
+
claudeMdTokens,
|
|
49
|
+
claudeMdSections,
|
|
50
|
+
mcpServers: mcp.count,
|
|
51
|
+
mcpServerNames: mcp.names,
|
|
52
|
+
issues,
|
|
53
|
+
totalTokensBefore,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { SkillInfo, BrokenSymlink } from '../types.js';
|
|
2
|
+
export interface SkillCandidate {
|
|
3
|
+
skill: SkillInfo;
|
|
4
|
+
realMdPath: string;
|
|
5
|
+
}
|
|
6
|
+
export declare function dedupeBySymlink(candidates: SkillCandidate[]): SkillInfo[];
|
|
7
|
+
export interface LocalSkillsResult {
|
|
8
|
+
skills: SkillInfo[];
|
|
9
|
+
brokenSymlinks: BrokenSymlink[];
|
|
10
|
+
contents: Map<string, string>;
|
|
11
|
+
}
|
|
12
|
+
export declare function scanLocalSkills(): Promise<LocalSkillsResult>;
|