claude-slim 2.9.1 → 2.10.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 +11 -5
- package/dist/cleaner.js +2 -1
- package/dist/cli.js +11 -1
- package/dist/codex/index.d.ts +46 -0
- package/dist/codex/index.js +192 -0
- package/dist/codex/report.d.ts +9 -0
- package/dist/codex/report.js +56 -0
- package/dist/scanner/backup-artifacts.d.ts +11 -0
- package/dist/scanner/backup-artifacts.js +46 -0
- package/dist/scanner/detectors.js +26 -0
- package/dist/types.d.ts +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -161,6 +161,7 @@ npx claude-slim clean --dry-run # See what would happen (no changes)
|
|
|
161
161
|
npx claude-slim clean --auto # Non-interactive, Tier 1 only (CI/scripts)
|
|
162
162
|
npx claude-slim clean --lookback-days N # Tune the unused-skill detection window
|
|
163
163
|
npx claude-slim scan # Report only
|
|
164
|
+
npx claude-slim scan --no-codex # Skip the ~/.codex scan
|
|
164
165
|
npx claude-slim doctor # Diagnose Node/Claude/session-log readiness
|
|
165
166
|
npx claude-slim doctor --offline # Same, without the version check (no network)
|
|
166
167
|
npx claude-slim check-update # Report-only version check
|
|
@@ -201,6 +202,7 @@ claude-slim never updates itself — that's your package manager's job, and writ
|
|
|
201
202
|
- **`~/.claude/agents/` and `~/.claude/commands/`** — measured and reported since v2.8, never moved or deleted. There is no restore path for them yet, and a destructive action without its undo isn't worth shipping.
|
|
202
203
|
- **Plugin internals** (`~/.claude/plugins/config.json`, individual `plugin.json` files) — left alone; use `claude plugin` to manage plugins.
|
|
203
204
|
- **Git / project sources** — claude-slim only looks inside `~/.claude/`, never at your code.
|
|
205
|
+
- **`~/.codex/`** — scanned and reported when Codex is installed, never modified. Unused-skill detection is not offered there: Codex session logs record the skill catalog, not invocations, so there is no honest usage signal to act on.
|
|
204
206
|
- **Anything outside `~/.claude/`** — a path-containment guard refuses destructive ops anywhere else, even if a tampered manifest asked it to.
|
|
205
207
|
|
|
206
208
|
Only touched: entries under `~/.claude/skills/`, `~/.claude/plugins/cache/temp_local_*`, and `~/.claude/projects/*/memory/`. Skill and memory entries are moved to `skills.disabled/`; broken symlink files are unlinked and `temp_local_*` failed-install caches are removed outright.
|
|
@@ -245,14 +247,18 @@ Token counts come from [js-tiktoken](https://github.com/nicolo-ribaudo/js-tiktok
|
|
|
245
247
|
|
|
246
248
|
---
|
|
247
249
|
|
|
248
|
-
## v2.
|
|
250
|
+
## v2.10.0 — What's new
|
|
249
251
|
|
|
250
|
-
|
|
252
|
+
Codex support, scoped to what Codex can actually be asked.
|
|
251
253
|
|
|
252
|
-
-
|
|
253
|
-
-
|
|
254
|
+
- **`~/.codex/` is scanned when present.** `scan` auto-detects a Codex install and reports its startup cost — local skills, plugin skills, agents, and `AGENTS.md`. `scan --json` gains a `codex` key; `--no-codex` skips it. On the dev machine this surfaced **10,926 tokens** nothing was measuring, including a `.bak` copy of a skill still costing 285 tokens every session.
|
|
255
|
+
- Codex `SKILL.md` frontmatter matches Claude Code's exactly, so the existing listing parser is reused unchanged. Agents differ (`<name>.toml` with `description = "…"`) and get a small parser, verified against all 18 installed agents.
|
|
256
|
+
- **Unused-skill detection is not offered for Codex, and `scan` says so.** Codex session logs record the skill *catalog* injected into each prompt, not invocations — every skill shows up in nearly every session, so using them as a usage signal would mark everything "used". Checked against 408 session files, a 56,724-row log database, and the tool-registry table before concluding.
|
|
257
|
+
- **`~/.codex/` is read-only.** Nothing is moved or deleted there, same as `~/.claude/agents/`.
|
|
254
258
|
|
|
255
|
-
|
|
259
|
+
- **Backup-artifact detection, on both agents.** `foo.bak.20260711`, `foo (1)`, `foo~` and similar are flagged — Tier 2 on Claude Code (movable, restorable), report-only on Codex. Matching is limited to artifact *shapes*, so `backup-manager` and `test-engineer` are never touched.
|
|
260
|
+
|
|
261
|
+
Tests: 279 → 347 (+68).
|
|
256
262
|
|
|
257
263
|
For older release notes, see [CHANGELOG.md](CHANGELOG.md).
|
|
258
264
|
|
package/dist/cleaner.js
CHANGED
|
@@ -77,7 +77,8 @@ export async function cleanIssues(issues) {
|
|
|
77
77
|
issue.type === 'duplicate' ||
|
|
78
78
|
issue.type === 'skill_dup' ||
|
|
79
79
|
issue.type === 'oversized_skill' ||
|
|
80
|
-
issue.type === 'unused_skill'
|
|
80
|
+
issue.type === 'unused_skill' ||
|
|
81
|
+
issue.type === 'backup_artifact') {
|
|
81
82
|
// Move skill directory to disabled — use name (not basename) to avoid namespace collisions
|
|
82
83
|
const safeName = issue.name.replace(/\//g, '--');
|
|
83
84
|
const dest = join(disabledDir, safeName);
|
package/dist/cli.js
CHANGED
|
@@ -11,6 +11,8 @@ import { readManifest } from './manifest.js';
|
|
|
11
11
|
import { formatScanSummary, formatReportBox, calculateReport, } from './report.js';
|
|
12
12
|
import { collectDoctorReport, formatDoctorReport } from './doctor.js';
|
|
13
13
|
import { checkForUpdate, formatUpdateNotice } from './update-check.js';
|
|
14
|
+
import { scanCodex } from './codex/index.js';
|
|
15
|
+
import { formatCodexSummary } from './codex/report.js';
|
|
14
16
|
import { resolveSelection, resolveRestoreSelection } from './selection.js';
|
|
15
17
|
const pkgPath = join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json');
|
|
16
18
|
const { version: PKG_VERSION } = JSON.parse(readFileSync(pkgPath, 'utf-8'));
|
|
@@ -36,15 +38,22 @@ program
|
|
|
36
38
|
.description('Scan environment and report issues')
|
|
37
39
|
.option('--json', 'Output raw JSON')
|
|
38
40
|
.option('--lookback-days <n>', 'Days of session history for skill-usage analysis', '60')
|
|
41
|
+
.option('--no-codex', 'Skip the ~/.codex scan even if Codex is installed')
|
|
39
42
|
.action(async (opts) => {
|
|
40
43
|
await initTokenizer();
|
|
41
44
|
const result = await scan({ lookbackDays: parseNonNegativeInt(opts.lookbackDays, 60) });
|
|
45
|
+
// Codex is scanned when present. Reported only — it is never modified, and
|
|
46
|
+
// unused-skill detection is suppressed there for lack of a usage signal.
|
|
47
|
+
// commander maps `--no-codex` to `opts.codex === false`, not `opts.noCodex`.
|
|
48
|
+
const codex = opts.codex === false ? null : await scanCodex();
|
|
42
49
|
await flushCache();
|
|
43
50
|
if (opts.json) {
|
|
44
|
-
console.log(JSON.stringify(result, null, 2));
|
|
51
|
+
console.log(JSON.stringify({ ...result, codex }, null, 2));
|
|
45
52
|
}
|
|
46
53
|
else {
|
|
47
54
|
console.log(formatScanSummary(result));
|
|
55
|
+
if (codex)
|
|
56
|
+
console.log(formatCodexSummary(codex));
|
|
48
57
|
}
|
|
49
58
|
});
|
|
50
59
|
// --- doctor ---
|
|
@@ -211,6 +220,7 @@ program
|
|
|
211
220
|
// temp_cache never counted toward totalTokensBefore).
|
|
212
221
|
const SKILL_TYPES = new Set([
|
|
213
222
|
'template', 'duplicate', 'skill_dup', 'oversized_skill', 'unused_skill',
|
|
223
|
+
'backup_artifact',
|
|
214
224
|
]);
|
|
215
225
|
const removedSkillEntries = movedEntries.filter((e) => SKILL_TYPES.has(e.type));
|
|
216
226
|
const removedMemoryTokens = movedEntries
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { parseFrontmatterDescription } from '../scanner/skill-listing.js';
|
|
2
|
+
export declare const UNUSED_DETECTION_REASON = "Codex session logs record the skill catalog, not invocations \u2014 there is no reliable usage signal to detect unused skills from.";
|
|
3
|
+
export interface CodexSkill {
|
|
4
|
+
name: string;
|
|
5
|
+
path: string;
|
|
6
|
+
sizeBytes: number;
|
|
7
|
+
/** Full SKILL.md body — what the skill costs once invoked. */
|
|
8
|
+
tokens: number;
|
|
9
|
+
/** The `- name: description` line it adds to the system prompt. */
|
|
10
|
+
listingTokens: number;
|
|
11
|
+
source: 'local' | 'plugin';
|
|
12
|
+
pluginName?: string;
|
|
13
|
+
/** Set when the name looks like a leftover copy; reported, never acted on. */
|
|
14
|
+
backupArtifact?: string;
|
|
15
|
+
}
|
|
16
|
+
export interface CodexAgent {
|
|
17
|
+
name: string;
|
|
18
|
+
path: string;
|
|
19
|
+
sizeBytes: number;
|
|
20
|
+
tokens: number;
|
|
21
|
+
listingTokens: number;
|
|
22
|
+
}
|
|
23
|
+
export interface CodexScanResult {
|
|
24
|
+
root: string;
|
|
25
|
+
skills: CodexSkill[];
|
|
26
|
+
agents: CodexAgent[];
|
|
27
|
+
instructionsBytes: number;
|
|
28
|
+
instructionsTokens: number;
|
|
29
|
+
/** Skill + agent listing lines + AGENTS.md — the fixed startup cost. */
|
|
30
|
+
totalTokens: number;
|
|
31
|
+
unusedDetectionAvailable: false;
|
|
32
|
+
unusedDetectionReason: string;
|
|
33
|
+
}
|
|
34
|
+
export declare function getCodexDir(): string;
|
|
35
|
+
export declare function isCodexInstalled(): Promise<boolean>;
|
|
36
|
+
/**
|
|
37
|
+
* Pull `description` out of a Codex agent TOML.
|
|
38
|
+
*
|
|
39
|
+
* All 18 agents observed use a single-line `description = "…"`; the multi-line
|
|
40
|
+
* `"""` form is handled too so a future agent using it does not silently fall
|
|
41
|
+
* back to the flat estimate.
|
|
42
|
+
*/
|
|
43
|
+
export declare function parseTomlDescription(content: string): string | null;
|
|
44
|
+
export declare function scanCodex(): Promise<CodexScanResult | null>;
|
|
45
|
+
/** Re-exported so callers can reuse the frontmatter parser without a deep import. */
|
|
46
|
+
export { parseFrontmatterDescription };
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { countTokensCached } from '../tokenizer.js';
|
|
4
|
+
import { listingTokens, listingTokensFromContent, parseFrontmatterDescription } from '../scanner/skill-listing.js';
|
|
5
|
+
import { safeReadFile, safeReaddir, isDirectory, isBrokenSymlink } from '../scanner/fs-walk.js';
|
|
6
|
+
import { detectBackupArtifact } from '../scanner/backup-artifacts.js';
|
|
7
|
+
// Codex support.
|
|
8
|
+
//
|
|
9
|
+
// The Claude Code scanner is deliberately left untouched: it is the
|
|
10
|
+
// battle-tested path, and a shared abstraction would have meant refactoring
|
|
11
|
+
// every scanner to prove a point. This module reuses the primitives that
|
|
12
|
+
// genuinely transfer — the tokenizer and the frontmatter parser — and adds only
|
|
13
|
+
// what differs.
|
|
14
|
+
//
|
|
15
|
+
// What transfers unchanged:
|
|
16
|
+
// ~/.codex/skills/<name>/SKILL.md identical YAML frontmatter to Claude Code
|
|
17
|
+
// ~/.codex/plugins/cache/…/skills/ same nested layout
|
|
18
|
+
// ~/.codex/AGENTS.md the CLAUDE.md equivalent
|
|
19
|
+
//
|
|
20
|
+
// What differs:
|
|
21
|
+
// agents are `<name>.toml` with `description = "…"`, not `<name>.md`
|
|
22
|
+
// there is no user-level commands/ directory
|
|
23
|
+
//
|
|
24
|
+
// What is NOT available: unused-skill detection. Codex session logs
|
|
25
|
+
// (~/.codex/sessions/**.jsonl) record the skill *catalog* injected into each
|
|
26
|
+
// system prompt, not invocations — every skill appears in nearly every session,
|
|
27
|
+
// so using them as a usage signal would mark everything "used". Verified across
|
|
28
|
+
// 408 session files, a 56,724-row log database, and the thread_dynamic_tools
|
|
29
|
+
// table (a tool registry, not a history). See UNUSED_DETECTION_REASON.
|
|
30
|
+
export const UNUSED_DETECTION_REASON = 'Codex session logs record the skill catalog, not invocations — there is no reliable usage signal to detect unused skills from.';
|
|
31
|
+
export function getCodexDir() {
|
|
32
|
+
return join(homedir(), '.codex');
|
|
33
|
+
}
|
|
34
|
+
export async function isCodexInstalled() {
|
|
35
|
+
return isDirectory(getCodexDir());
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Pull `description` out of a Codex agent TOML.
|
|
39
|
+
*
|
|
40
|
+
* All 18 agents observed use a single-line `description = "…"`; the multi-line
|
|
41
|
+
* `"""` form is handled too so a future agent using it does not silently fall
|
|
42
|
+
* back to the flat estimate.
|
|
43
|
+
*/
|
|
44
|
+
export function parseTomlDescription(content) {
|
|
45
|
+
const multi = /^description\s*=\s*"""\r?\n?([\s\S]*?)"""/m.exec(content);
|
|
46
|
+
if (multi) {
|
|
47
|
+
const joined = multi[1].split(/\r?\n/).map((l) => l.trim()).join(' ').trim();
|
|
48
|
+
return joined || null;
|
|
49
|
+
}
|
|
50
|
+
// These files carry the agent's whole prompt inside `developer_instructions =
|
|
51
|
+
// """…"""`, and that prose can contain a line that itself starts with
|
|
52
|
+
// `description = "…"`. Matching line-anchored across the raw text picks up the
|
|
53
|
+
// wrong one, so drop every triple-quoted block before looking for the
|
|
54
|
+
// single-line form. (The multi-line `description` case is handled above, i.e.
|
|
55
|
+
// before anything is stripped.)
|
|
56
|
+
const withoutBlocks = content.replace(/"""[\s\S]*?"""/g, '');
|
|
57
|
+
const single = /^description\s*=\s*"((?:[^"\\]|\\.)*)"/m.exec(withoutBlocks);
|
|
58
|
+
if (!single)
|
|
59
|
+
return null;
|
|
60
|
+
const unescaped = single[1].replace(/\\"/g, '"').replace(/\\\\/g, '\\').trim();
|
|
61
|
+
return unescaped || null;
|
|
62
|
+
}
|
|
63
|
+
async function scanLocalSkills() {
|
|
64
|
+
const dir = join(getCodexDir(), 'skills');
|
|
65
|
+
const entries = await safeReaddir(dir);
|
|
66
|
+
const results = [];
|
|
67
|
+
for (const entry of entries) {
|
|
68
|
+
// `.system` and other dot-directories are Codex internals, not user skills.
|
|
69
|
+
if (entry.startsWith('.'))
|
|
70
|
+
continue;
|
|
71
|
+
const skillDir = join(dir, entry);
|
|
72
|
+
if (!(await isDirectory(skillDir)))
|
|
73
|
+
continue;
|
|
74
|
+
const md = join(skillDir, 'SKILL.md');
|
|
75
|
+
if (await isBrokenSymlink(md))
|
|
76
|
+
continue;
|
|
77
|
+
const content = await safeReadFile(md);
|
|
78
|
+
if (content === null)
|
|
79
|
+
continue;
|
|
80
|
+
results.push({
|
|
81
|
+
name: entry,
|
|
82
|
+
path: skillDir,
|
|
83
|
+
sizeBytes: Buffer.byteLength(content),
|
|
84
|
+
tokens: countTokensCached(content, md),
|
|
85
|
+
listingTokens: listingTokensFromContent(entry, content),
|
|
86
|
+
source: 'local',
|
|
87
|
+
backupArtifact: detectBackupArtifact(entry)?.label,
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
return results;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Walk `plugins/cache/<marketplace>/<plugin>/<version>/skills/<skill>/SKILL.md`.
|
|
94
|
+
* Bounded rather than fully recursive so a deep or looping tree cannot wedge the
|
|
95
|
+
* scan — the layout is fixed and known.
|
|
96
|
+
*/
|
|
97
|
+
async function scanPluginSkills() {
|
|
98
|
+
const cache = join(getCodexDir(), 'plugins', 'cache');
|
|
99
|
+
const results = [];
|
|
100
|
+
for (const marketplace of await safeReaddir(cache)) {
|
|
101
|
+
if (marketplace.startsWith('.'))
|
|
102
|
+
continue;
|
|
103
|
+
const mDir = join(cache, marketplace);
|
|
104
|
+
if (!(await isDirectory(mDir)))
|
|
105
|
+
continue;
|
|
106
|
+
for (const plugin of await safeReaddir(mDir)) {
|
|
107
|
+
if (plugin.startsWith('.'))
|
|
108
|
+
continue;
|
|
109
|
+
const pDir = join(mDir, plugin);
|
|
110
|
+
if (!(await isDirectory(pDir)))
|
|
111
|
+
continue;
|
|
112
|
+
for (const version of await safeReaddir(pDir)) {
|
|
113
|
+
const skillsDir = join(pDir, version, 'skills');
|
|
114
|
+
if (!(await isDirectory(skillsDir)))
|
|
115
|
+
continue;
|
|
116
|
+
for (const skill of await safeReaddir(skillsDir)) {
|
|
117
|
+
const sDir = join(skillsDir, skill);
|
|
118
|
+
if (!(await isDirectory(sDir)))
|
|
119
|
+
continue;
|
|
120
|
+
const md = join(sDir, 'SKILL.md');
|
|
121
|
+
if (await isBrokenSymlink(md))
|
|
122
|
+
continue;
|
|
123
|
+
const content = await safeReadFile(md);
|
|
124
|
+
if (content === null)
|
|
125
|
+
continue;
|
|
126
|
+
results.push({
|
|
127
|
+
name: skill,
|
|
128
|
+
path: sDir,
|
|
129
|
+
sizeBytes: Buffer.byteLength(content),
|
|
130
|
+
tokens: countTokensCached(content, md),
|
|
131
|
+
listingTokens: listingTokensFromContent(skill, content),
|
|
132
|
+
source: 'plugin',
|
|
133
|
+
pluginName: plugin,
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return results;
|
|
140
|
+
}
|
|
141
|
+
async function scanAgents() {
|
|
142
|
+
const dir = join(getCodexDir(), 'agents');
|
|
143
|
+
const results = [];
|
|
144
|
+
for (const entry of await safeReaddir(dir)) {
|
|
145
|
+
if (!entry.endsWith('.toml'))
|
|
146
|
+
continue;
|
|
147
|
+
const path = join(dir, entry);
|
|
148
|
+
if (await isBrokenSymlink(path))
|
|
149
|
+
continue;
|
|
150
|
+
const content = await safeReadFile(path);
|
|
151
|
+
if (content === null)
|
|
152
|
+
continue;
|
|
153
|
+
const name = entry.slice(0, -'.toml'.length);
|
|
154
|
+
results.push({
|
|
155
|
+
name,
|
|
156
|
+
path,
|
|
157
|
+
sizeBytes: Buffer.byteLength(content),
|
|
158
|
+
tokens: countTokensCached(content, path),
|
|
159
|
+
listingTokens: listingTokens(name, parseTomlDescription(content)),
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
return results;
|
|
163
|
+
}
|
|
164
|
+
export async function scanCodex() {
|
|
165
|
+
if (!(await isCodexInstalled()))
|
|
166
|
+
return null;
|
|
167
|
+
const [local, plugin, agents] = await Promise.all([
|
|
168
|
+
scanLocalSkills(),
|
|
169
|
+
scanPluginSkills(),
|
|
170
|
+
scanAgents(),
|
|
171
|
+
]);
|
|
172
|
+
const instructionsPath = join(getCodexDir(), 'AGENTS.md');
|
|
173
|
+
const instructions = await safeReadFile(instructionsPath);
|
|
174
|
+
const instructionsBytes = instructions ? Buffer.byteLength(instructions) : 0;
|
|
175
|
+
const instructionsTokens = instructions
|
|
176
|
+
? countTokensCached(instructions, instructionsPath)
|
|
177
|
+
: 0;
|
|
178
|
+
const skills = [...local, ...plugin];
|
|
179
|
+
const listing = (xs) => xs.reduce((sum, x) => sum + x.listingTokens, 0);
|
|
180
|
+
return {
|
|
181
|
+
root: getCodexDir(),
|
|
182
|
+
skills,
|
|
183
|
+
agents,
|
|
184
|
+
instructionsBytes,
|
|
185
|
+
instructionsTokens,
|
|
186
|
+
totalTokens: listing(skills) + listing(agents) + instructionsTokens,
|
|
187
|
+
unusedDetectionAvailable: false,
|
|
188
|
+
unusedDetectionReason: UNUSED_DETECTION_REASON,
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
/** Re-exported so callers can reuse the frontmatter parser without a deep import. */
|
|
192
|
+
export { parseFrontmatterDescription };
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { CodexScanResult } from './index.js';
|
|
2
|
+
/**
|
|
3
|
+
* Render the Codex section appended to `scan` output.
|
|
4
|
+
*
|
|
5
|
+
* Deliberately reports only what Codex can actually tell us. The "not
|
|
6
|
+
* available" line for unused-skill detection is not an apology — stating the
|
|
7
|
+
* limit is what keeps the tool from inventing a signal it does not have.
|
|
8
|
+
*/
|
|
9
|
+
export declare function formatCodexSummary(result: CodexScanResult): string;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
const TOP_N = 5;
|
|
2
|
+
/**
|
|
3
|
+
* Render the Codex section appended to `scan` output.
|
|
4
|
+
*
|
|
5
|
+
* Deliberately reports only what Codex can actually tell us. The "not
|
|
6
|
+
* available" line for unused-skill detection is not an apology — stating the
|
|
7
|
+
* limit is what keeps the tool from inventing a signal it does not have.
|
|
8
|
+
*/
|
|
9
|
+
export function formatCodexSummary(result) {
|
|
10
|
+
const lines = [];
|
|
11
|
+
const local = result.skills.filter((s) => s.source === 'local');
|
|
12
|
+
const plugin = result.skills.filter((s) => s.source === 'plugin');
|
|
13
|
+
const sum = (xs) => xs.reduce((acc, x) => acc + x.listingTokens, 0);
|
|
14
|
+
lines.push('');
|
|
15
|
+
lines.push('\x1b[1m=== codex ===\x1b[0m');
|
|
16
|
+
lines.push('');
|
|
17
|
+
lines.push(` \x1b[90m${result.root}\x1b[0m`);
|
|
18
|
+
lines.push('');
|
|
19
|
+
const row = (label, count, tokens) => ` ${label.padEnd(24)} ${count.padStart(8)} ${tokens.toLocaleString().padStart(8)} tok`;
|
|
20
|
+
lines.push('\x1b[1m STARTUP COST\x1b[0m');
|
|
21
|
+
lines.push(row('Local skills', `${local.length}`, sum(local)));
|
|
22
|
+
lines.push(row('Plugin skills', `${plugin.length}`, sum(plugin)));
|
|
23
|
+
lines.push(row('Agents', `${result.agents.length}`, sum(result.agents)));
|
|
24
|
+
lines.push(row('AGENTS.md', `${(result.instructionsBytes / 1024).toFixed(1)}KB`, result.instructionsTokens));
|
|
25
|
+
lines.push(` ${'─'.repeat(46)}`);
|
|
26
|
+
lines.push(row('Total', '', result.totalTokens));
|
|
27
|
+
const heaviest = [...result.skills].sort((a, b) => b.listingTokens - a.listingTokens).slice(0, TOP_N);
|
|
28
|
+
if (heaviest.length > 0) {
|
|
29
|
+
lines.push('');
|
|
30
|
+
lines.push(`\x1b[1m HEAVIEST LISTINGS\x1b[0m (top ${heaviest.length})`);
|
|
31
|
+
for (const s of heaviest) {
|
|
32
|
+
const origin = s.pluginName ? `plugin:${s.pluginName}` : 'local';
|
|
33
|
+
// Names can exceed the column (backup copies like `foo.bak.20260711`);
|
|
34
|
+
// truncate so the token column stays aligned.
|
|
35
|
+
const name = s.name.length > 30 ? `${s.name.slice(0, 29)}…` : s.name;
|
|
36
|
+
lines.push(` ${name.padEnd(30)} ${String(s.listingTokens).padStart(5)} tok \x1b[90m${origin}\x1b[0m`);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
const backups = result.skills.filter((s) => s.backupArtifact);
|
|
40
|
+
if (backups.length > 0) {
|
|
41
|
+
lines.push('');
|
|
42
|
+
lines.push(`\x1b[1m LIKELY BACKUP COPIES\x1b[0m (${backups.length})`);
|
|
43
|
+
for (const b of backups) {
|
|
44
|
+
lines.push(` ${b.name.length > 30 ? `${b.name.slice(0, 29)}…` : b.name.padEnd(30)} ` +
|
|
45
|
+
`${String(b.listingTokens).padStart(5)} tok \x1b[90m${b.backupArtifact}\x1b[0m`);
|
|
46
|
+
}
|
|
47
|
+
lines.push(` \x1b[90mNot removed — ~/.codex/ is read-only here. Delete manually if stale.\x1b[0m`);
|
|
48
|
+
}
|
|
49
|
+
lines.push('');
|
|
50
|
+
lines.push(` \x1b[33m!\x1b[0m Unused-skill detection unavailable for Codex.`);
|
|
51
|
+
lines.push(` \x1b[90m${result.unusedDetectionReason}\x1b[0m`);
|
|
52
|
+
lines.push('');
|
|
53
|
+
lines.push(` \x1b[90mReported only — claude-slim never modifies ~/.codex/.\x1b[0m`);
|
|
54
|
+
lines.push('');
|
|
55
|
+
return lines.join('\n');
|
|
56
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export interface BackupMatch {
|
|
2
|
+
/** Which convention matched, for showing the user why. */
|
|
3
|
+
label: string;
|
|
4
|
+
}
|
|
5
|
+
/**
|
|
6
|
+
* Return why `name` looks like a backup artifact, or null if it does not.
|
|
7
|
+
*
|
|
8
|
+
* Matches on the entry name only. Callers decide what to do with it — the
|
|
9
|
+
* Claude path raises a cleanup issue, the Codex path only reports.
|
|
10
|
+
*/
|
|
11
|
+
export declare function detectBackupArtifact(name: string): BackupMatch | null;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// Backup-artifact detection.
|
|
2
|
+
//
|
|
3
|
+
// Unused-skill detection needs a usage signal. This does not: a name like
|
|
4
|
+
// `humanize-korean.bak.20260711-100101` is self-evidently a leftover copy
|
|
5
|
+
// regardless of whether anything ever invoked it. That makes it the one useful
|
|
6
|
+
// cleanup hint that works even on Codex, where session logs carry no
|
|
7
|
+
// invocation history.
|
|
8
|
+
//
|
|
9
|
+
// The whole risk here is false positives. `backup-manager`, `test-engineer`,
|
|
10
|
+
// and `old-school-linter` are real skills whose names merely contain the words
|
|
11
|
+
// "backup", "test", and "old". So these patterns match only *artifact shapes* —
|
|
12
|
+
// dotted segments, trailing markers, timestamp suffixes — never a bare
|
|
13
|
+
// substring anywhere in the name.
|
|
14
|
+
const PATTERNS = [
|
|
15
|
+
// `foo.bak`, `foo.bak.20260711` — dotted segment, not the word "bak" inside a name.
|
|
16
|
+
{ re: /\.bak(\.|$)/i, label: '.bak' },
|
|
17
|
+
{ re: /\.backup(\.|$)/i, label: '.backup' },
|
|
18
|
+
{ re: /\.orig(\.|$)/i, label: '.orig' },
|
|
19
|
+
{ re: /\.old(\.|$)/i, label: '.old' },
|
|
20
|
+
{ re: /\.save(\.|$)/i, label: '.save' },
|
|
21
|
+
{ re: /\.disabled(\.|$)/i, label: '.disabled' },
|
|
22
|
+
// `foo.20260711` / `foo.20260711-100101` — a dated snapshot.
|
|
23
|
+
{ re: /\.\d{8}(-\d{6})?(\.|$)/, label: 'timestamp suffix' },
|
|
24
|
+
// `foo-2026-07-11`
|
|
25
|
+
{ re: /[-_]\d{4}-\d{2}-\d{2}(\.|$)/, label: 'dated suffix' },
|
|
26
|
+
// Editor/rsync leftovers.
|
|
27
|
+
{ re: /~$/, label: 'editor backup' },
|
|
28
|
+
// `foo copy`, `foo-copy`, `foo (copy)` — trailing only.
|
|
29
|
+
{ re: /[ _-]\(?copy\)?$/i, label: 'copy suffix' },
|
|
30
|
+
{ re: /^copy[ _-]of[ _-]/i, label: 'copy-of prefix' },
|
|
31
|
+
// `foo (1)` — duplicate-download naming.
|
|
32
|
+
{ re: / \(\d+\)$/, label: 'numbered duplicate' },
|
|
33
|
+
];
|
|
34
|
+
/**
|
|
35
|
+
* Return why `name` looks like a backup artifact, or null if it does not.
|
|
36
|
+
*
|
|
37
|
+
* Matches on the entry name only. Callers decide what to do with it — the
|
|
38
|
+
* Claude path raises a cleanup issue, the Codex path only reports.
|
|
39
|
+
*/
|
|
40
|
+
export function detectBackupArtifact(name) {
|
|
41
|
+
for (const { re, label } of PATTERNS) {
|
|
42
|
+
if (re.test(name))
|
|
43
|
+
return { label };
|
|
44
|
+
}
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { join } from 'node:path';
|
|
2
2
|
import { getPluginsDir } from '../paths.js';
|
|
3
3
|
import { OVERSIZED_SKILL_BYTES, OVERSIZED_MEMORY_BYTES, SKILL_PROMPT_OVERHEAD_TOKENS, } from './constants.js';
|
|
4
|
+
import { detectBackupArtifact } from './backup-artifacts.js';
|
|
4
5
|
const brokenSymlinkDetector = {
|
|
5
6
|
name: 'broken_symlink',
|
|
6
7
|
detect({ brokenSymlinks }) {
|
|
@@ -241,6 +242,30 @@ const disabledPluginDetector = {
|
|
|
241
242
|
};
|
|
242
243
|
// The full registry. Order only matters for ties in the tier sort.
|
|
243
244
|
// New detectors: define above, add here, update CONTRIBUTING.md's issue-type table.
|
|
245
|
+
// Backup leftovers are the one cleanup hint that needs no usage signal: a name
|
|
246
|
+
// like `foo.bak.20260711` announces itself. Tier 2 rather than Tier 1 — a
|
|
247
|
+
// backup still has some value, so the user confirms rather than it being
|
|
248
|
+
// pre-selected. The move is reversible via `restore` like any other skill.
|
|
249
|
+
const backupArtifactDetector = {
|
|
250
|
+
name: 'backup_artifact',
|
|
251
|
+
detect({ localSkills }) {
|
|
252
|
+
const issues = [];
|
|
253
|
+
for (const skill of localSkills) {
|
|
254
|
+
const match = detectBackupArtifact(skill.name);
|
|
255
|
+
if (!match)
|
|
256
|
+
continue;
|
|
257
|
+
issues.push({
|
|
258
|
+
type: 'backup_artifact',
|
|
259
|
+
tier: 2,
|
|
260
|
+
name: skill.name,
|
|
261
|
+
detail: `looks like a backup copy (${match.label})`,
|
|
262
|
+
tokens: skill.tokens,
|
|
263
|
+
path: skill.path,
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
return issues;
|
|
267
|
+
},
|
|
268
|
+
};
|
|
244
269
|
export const detectors = [
|
|
245
270
|
brokenSymlinkDetector,
|
|
246
271
|
templateDetector,
|
|
@@ -253,6 +278,7 @@ export const detectors = [
|
|
|
253
278
|
unusedSkillDetector,
|
|
254
279
|
unusedPluginDetector,
|
|
255
280
|
disabledPluginDetector,
|
|
281
|
+
backupArtifactDetector,
|
|
256
282
|
];
|
|
257
283
|
export function classifyIssues(ctx, registry = detectors) {
|
|
258
284
|
const issues = registry.flatMap((d) => d.detect(ctx));
|
package/dist/types.d.ts
CHANGED
|
@@ -34,7 +34,7 @@ export interface PluginInfo {
|
|
|
34
34
|
status?: 'enabled' | 'disabled';
|
|
35
35
|
}
|
|
36
36
|
export type IssueTier = 1 | 2 | 3;
|
|
37
|
-
export type IssueType = 'broken_symlink' | 'template' | 'skill_dup' | 'duplicate' | 'oversized_memory' | 'oversized_skill' | 'unused_skill' | 'unused_plugin' | 'disabled_plugin' | 'stale_project' | 'temp_cache';
|
|
37
|
+
export type IssueType = 'broken_symlink' | 'template' | 'skill_dup' | 'duplicate' | 'oversized_memory' | 'oversized_skill' | 'unused_skill' | 'unused_plugin' | 'disabled_plugin' | 'stale_project' | 'temp_cache' | 'backup_artifact';
|
|
38
38
|
export interface Issue {
|
|
39
39
|
type: IssueType;
|
|
40
40
|
tier: IssueTier;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-slim",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.10.0",
|
|
4
4
|
"description": "Audit and shrink your Claude Code startup context. Measures what every skill, plugin, agent, command, and memory file costs in the system prompt, then reversibly disables the dead weight. Non-destructive scan, tiered proposals, one-command restore — no proxy, no compression.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|