claude-slim 2.2.3 → 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/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 -530
- 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
|
|
|
@@ -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>;
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { readlink } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { countTokensCached } from '../tokenizer.js';
|
|
4
|
+
import { getSkillsDir } from '../paths.js';
|
|
5
|
+
import { safeReadFile, safeReaddir, isDirectory, isBrokenSymlink, resolveRealPath, } from './fs-walk.js';
|
|
6
|
+
export function dedupeBySymlink(candidates) {
|
|
7
|
+
const seen = new Map();
|
|
8
|
+
for (const { skill, realMdPath } of candidates) {
|
|
9
|
+
const existing = seen.get(realMdPath);
|
|
10
|
+
if (!existing) {
|
|
11
|
+
seen.set(realMdPath, skill);
|
|
12
|
+
continue;
|
|
13
|
+
}
|
|
14
|
+
// Prefer top-level name (no slash) over nested duplicate
|
|
15
|
+
const existingIsNested = existing.name.includes('/');
|
|
16
|
+
const currentIsNested = skill.name.includes('/');
|
|
17
|
+
if (existingIsNested && !currentIsNested) {
|
|
18
|
+
seen.set(realMdPath, skill);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return Array.from(seen.values());
|
|
22
|
+
}
|
|
23
|
+
export async function scanLocalSkills() {
|
|
24
|
+
const skillsDir = getSkillsDir();
|
|
25
|
+
const candidates = [];
|
|
26
|
+
const brokenSymlinks = [];
|
|
27
|
+
const contents = new Map();
|
|
28
|
+
const entries = await safeReaddir(skillsDir);
|
|
29
|
+
const scanPromises = entries.map(async (entry) => {
|
|
30
|
+
const dirPath = join(skillsDir, entry);
|
|
31
|
+
if (!(await isDirectory(dirPath)))
|
|
32
|
+
return;
|
|
33
|
+
const skillMd = join(dirPath, 'SKILL.md');
|
|
34
|
+
if (await isBrokenSymlink(skillMd)) {
|
|
35
|
+
let target = 'unknown';
|
|
36
|
+
try {
|
|
37
|
+
target = await readlink(skillMd);
|
|
38
|
+
}
|
|
39
|
+
catch { /* */ }
|
|
40
|
+
brokenSymlinks.push({ name: entry, path: skillMd, target });
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
const content = await safeReadFile(skillMd);
|
|
44
|
+
if (content !== null) {
|
|
45
|
+
contents.set(skillMd, content);
|
|
46
|
+
const tokens = countTokensCached(content, skillMd);
|
|
47
|
+
const realMdPath = await resolveRealPath(skillMd);
|
|
48
|
+
candidates.push({
|
|
49
|
+
skill: {
|
|
50
|
+
name: entry,
|
|
51
|
+
path: dirPath,
|
|
52
|
+
sizeBytes: Buffer.byteLength(content),
|
|
53
|
+
tokens,
|
|
54
|
+
source: 'local',
|
|
55
|
+
},
|
|
56
|
+
realMdPath,
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
// Nested skills (e.g., @internal-sys/commit-guide)
|
|
60
|
+
const subEntries = await safeReaddir(dirPath);
|
|
61
|
+
for (const sub of subEntries) {
|
|
62
|
+
const subDir = join(dirPath, sub);
|
|
63
|
+
if (!(await isDirectory(subDir)))
|
|
64
|
+
continue;
|
|
65
|
+
const subSkillMd = join(subDir, 'SKILL.md');
|
|
66
|
+
if (await isBrokenSymlink(subSkillMd)) {
|
|
67
|
+
let target = 'unknown';
|
|
68
|
+
try {
|
|
69
|
+
target = await readlink(subSkillMd);
|
|
70
|
+
}
|
|
71
|
+
catch { /* */ }
|
|
72
|
+
brokenSymlinks.push({ name: `${entry}/${sub}`, path: subSkillMd, target });
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
const subContent = await safeReadFile(subSkillMd);
|
|
76
|
+
if (subContent !== null) {
|
|
77
|
+
const name = `${entry}/${sub}`;
|
|
78
|
+
contents.set(subSkillMd, subContent);
|
|
79
|
+
const tokens = countTokensCached(subContent, subSkillMd);
|
|
80
|
+
const realMdPath = await resolveRealPath(subSkillMd);
|
|
81
|
+
candidates.push({
|
|
82
|
+
skill: {
|
|
83
|
+
name,
|
|
84
|
+
path: subDir,
|
|
85
|
+
sizeBytes: Buffer.byteLength(subContent),
|
|
86
|
+
tokens,
|
|
87
|
+
source: 'local',
|
|
88
|
+
},
|
|
89
|
+
realMdPath,
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
await Promise.all(scanPromises);
|
|
95
|
+
const skills = dedupeBySymlink(candidates);
|
|
96
|
+
return { skills, brokenSymlinks, contents };
|
|
97
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
import { getClaudeDir } from '../paths.js';
|
|
3
|
+
import { safeReadFile } from './fs-walk.js';
|
|
4
|
+
export async function scanMcpServers() {
|
|
5
|
+
const content = await safeReadFile(join(getClaudeDir(), 'settings.json'));
|
|
6
|
+
if (!content)
|
|
7
|
+
return { count: 0, names: [] };
|
|
8
|
+
try {
|
|
9
|
+
const data = JSON.parse(content);
|
|
10
|
+
const servers = data.mcpServers || {};
|
|
11
|
+
const names = Object.keys(servers).sort();
|
|
12
|
+
return { count: names.length, names };
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return { count: 0, names: [] };
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { MemoryFile } from '../types.js';
|
|
2
|
+
export interface StaleProject {
|
|
3
|
+
project: string;
|
|
4
|
+
path: string;
|
|
5
|
+
ageDays: number;
|
|
6
|
+
fileCount: number;
|
|
7
|
+
totalBytes: number;
|
|
8
|
+
}
|
|
9
|
+
export interface MemoryScanResult {
|
|
10
|
+
memoryFiles: MemoryFile[];
|
|
11
|
+
staleProjects: StaleProject[];
|
|
12
|
+
}
|
|
13
|
+
export declare function scanMemoryFiles(): Promise<MemoryScanResult>;
|