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 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
@@ -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
- ## Install (10 seconds)
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
- claude plugin marketplace add iops-leo/claude-slim
122
- claude plugin install claude-slim
127
+ npx claude-slim scan
123
128
  ```
124
129
 
125
- Then just type `/claude-slim` in any session.
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
- npx claude-slim scan
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,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,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,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,2 @@
1
+ import type { ScanResult } from '../types.js';
2
+ export declare function scan(): Promise<ScanResult>;
@@ -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,5 @@
1
+ export interface McpScanResult {
2
+ count: number;
3
+ names: string[];
4
+ }
5
+ export declare function scanMcpServers(): Promise<McpScanResult>;
@@ -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>;