claude-slim 2.9.1 → 2.11.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
@@ -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, since v2.11, cleanable under the same tiers. Moves go to `~/.codex/skills.disabled/` and reverse with `restore`. The path guard is per-agent, so a Codex item can never resolve into `~/.claude/`. Unused-skill detection is still 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,16 @@ Token counts come from [js-tiktoken](https://github.com/nicolo-ribaudo/js-tiktok
245
247
 
246
248
  ---
247
249
 
248
- ## v2.9.1 — What's new
250
+ ## v2.11.0 — What's new
249
251
 
250
- Found while stress-testing the scanner against deliberately hostile `~/.claude` fixtures.
252
+ Codex gets the same tiered propose-and-choose cleanup Claude Code has, minus the two categories Codex cannot honestly support.
251
253
 
252
- - **Fixed: `scan` could hang forever on a single long line.** js-tiktoken's BPE is quadratic in the length of one whitespace-free run. Normal prose is fine (8,000 characters encodes in ~1ms), but 800 characters of Hangul cost ~450ms, 3,200 cost ~6.8s, and a 60,000-character run wedged `scan` past 20 seconds with **no output at all** indistinguishable from a freeze. `SKILL.md` files hit this via base64 blobs, minified snippets, embedded JSON schemas, and CJK text. Long runs are now estimated by encoding a bounded prefix and scaling; **files without one produce byte-identical counts** (verified across 71 installed skills: 70 exact, one moved 0.01%). Hostile fixture: 20s+ 1.78s, with no measurable cost on ordinary input.
253
- - **Fixed: a mistyped subcommand blamed the wrong thing.** `claude-slim scam` printed `error: too many arguments. Expected 0 arguments but got 1`. It now names the unknown command and lists the real ones.
254
+ - **Codex cleanup, tiered.** Five of seven categories carry over because they are filesystem facts, not usage inferences: broken symlinks and unfilled templates (Tier 1), install leftovers in `~/.codex/.tmp` (Tier 1146MB on the dev machine), backup copies and plugin-shadowed duplicates (Tier 2), oversized skills (Tier 3). Codex items join the same numbered list tagged `[codex]`. `--no-codex` opts out.
255
+ - **`~/.codex/skills.disabled/`** moves are reversible through the same `restore`, and manifest entries record which agent they came from.
256
+ - **The path guard is now per-agent.** Not "inside any known root" — a Codex issue must not resolve into `~/.claude/` and vice versa, so a tampered manifest cannot cross between agents.
257
+ - `unused_skill` and `oversized_memory` remain unavailable for Codex: no invocation record, and no `~/.codex/projects/*/memory/`.
254
258
 
255
- Tests: 266279 (+13).
259
+ Tests: 347374 (+27), the guard suite being the point — cross-agent isolation both directions, traversal escapes, and a `~/.claude-backup` sibling a naive `startsWith` would have allowed.
256
260
 
257
261
  For older release notes, see [CHANGELOG.md](CHANGELOG.md).
258
262
 
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, resolve, sep } from 'node:path';
3
- import { appendManifest, ensureDisabledDir, getDisabledDir, removeEntry, recordDisabledPlugin, removeDisabledPlugin } from './manifest.js';
4
- import { assertInsideClaudeDir, getSkillsDir, getProjectsDir } from './paths.js';
3
+ import { appendManifest, ensureDisabledDir, removeEntry, recordDisabledPlugin, removeDisabledPlugin } from './manifest.js';
4
+ import { assertInsideAgentRoot, getAgentRoot, getAgentDisabledDir, getSkillsDir, getProjectsDir } from './paths.js';
5
5
  import { disablePlugin, enablePlugin, isClaudeCliAvailable, ClaudeCliMissingError } from './plugin-runtime.js';
6
6
  // Restrict a restore target to a specific subtree of ~/.claude/. Complements
7
7
  // assertInsideClaudeDir: a tampered manifest could still name a legal
@@ -40,8 +40,6 @@ async function recordOrRollback(entry, rollback) {
40
40
  }
41
41
  }
42
42
  export async function cleanIssues(issues) {
43
- await ensureDisabledDir();
44
- const disabledDir = getDisabledDir();
45
43
  const moved = [];
46
44
  const skipped = [];
47
45
  const errors = [];
@@ -54,15 +52,20 @@ export async function cleanIssues(issues) {
54
52
  claudeCliMissing = true;
55
53
  }
56
54
  for (const issue of issues) {
55
+ // Scope every path decision to the issue's own agent, so a Codex issue can
56
+ // never resolve into ~/.claude/ (or the reverse).
57
+ const agent = issue.agent ?? 'claude';
58
+ const disabledDir = await ensureDisabledDir(agent);
57
59
  try {
58
60
  // unused_plugin and report-only types don't touch filesystem paths directly
59
61
  if (issue.type !== 'unused_plugin' && issue.type !== 'oversized_memory' && issue.type !== 'disabled_plugin') {
60
- assertInsideClaudeDir(issue.path);
62
+ assertInsideAgentRoot(issue.path, agent);
61
63
  }
62
64
  if (issue.type === 'broken_symlink') {
63
65
  await unlink(issue.path);
64
66
  const entry = {
65
67
  date: new Date().toISOString(),
68
+ agent,
66
69
  name: issue.name,
67
70
  from: issue.path,
68
71
  type: issue.type,
@@ -77,13 +80,15 @@ export async function cleanIssues(issues) {
77
80
  issue.type === 'duplicate' ||
78
81
  issue.type === 'skill_dup' ||
79
82
  issue.type === 'oversized_skill' ||
80
- issue.type === 'unused_skill') {
83
+ issue.type === 'unused_skill' ||
84
+ issue.type === 'backup_artifact') {
81
85
  // Move skill directory to disabled — use name (not basename) to avoid namespace collisions
82
86
  const safeName = issue.name.replace(/\//g, '--');
83
87
  const dest = join(disabledDir, safeName);
84
88
  await rename(issue.path, dest);
85
89
  const entry = {
86
90
  date: new Date().toISOString(),
91
+ agent,
87
92
  name: issue.name,
88
93
  from: issue.path,
89
94
  type: issue.type,
@@ -107,6 +112,7 @@ export async function cleanIssues(issues) {
107
112
  }
108
113
  const entry = {
109
114
  date: new Date().toISOString(),
115
+ agent,
110
116
  name: issue.name,
111
117
  from: issue.path,
112
118
  type: issue.type,
@@ -130,6 +136,7 @@ export async function cleanIssues(issues) {
130
136
  await rename(issue.path, backupDir);
131
137
  const entry = {
132
138
  date: new Date().toISOString(),
139
+ agent,
133
140
  name: issue.name,
134
141
  from: issue.path,
135
142
  type: issue.type,
@@ -209,7 +216,7 @@ export async function restoreItem(entry) {
209
216
  return;
210
217
  }
211
218
  const legacyEntry = entry;
212
- assertInsideClaudeDir(legacyEntry.from);
219
+ assertInsideAgentRoot(legacyEntry.from, legacyEntry.agent ?? 'claude');
213
220
  if (legacyEntry.type === 'broken_symlink') {
214
221
  throw new Error(`Broken symlinks cannot be restored (${legacyEntry.name})`);
215
222
  }
@@ -219,7 +226,9 @@ export async function restoreItem(entry) {
219
226
  if (legacyEntry.type === 'temp_cache') {
220
227
  throw new Error(`Temp caches were deleted and cannot be restored (${legacyEntry.name})`);
221
228
  }
222
- const disabledDir = getDisabledDir();
229
+ // Resolve the store from the entry's own agent — a Codex skill was parked
230
+ // under ~/.codex/skills.disabled and must be looked for there.
231
+ const disabledDir = getAgentDisabledDir(('agent' in entry ? entry.agent : undefined) ?? 'claude');
223
232
  if (legacyEntry.type === 'stale_project') {
224
233
  // Type-scoped path guard: stale-project backups must restore under
225
234
  // ~/.claude/projects/. Prevents a tampered manifest from redirecting a
@@ -238,7 +247,9 @@ export async function restoreItem(entry) {
238
247
  }
239
248
  else {
240
249
  // Type-scoped path guard: skill restores must land under ~/.claude/skills/.
241
- assertInsideSubtree(legacyEntry.from, getSkillsDir(), 'skill');
250
+ // Per-agent skills/ subtree: a Codex entry restores under ~/.codex/skills,
251
+ // never ~/.claude/skills.
252
+ assertInsideSubtree(legacyEntry.from, join(getAgentRoot(legacyEntry.agent ?? 'claude'), 'skills'), 'skill');
242
253
  // Restore skill directory using the same naming as cleanIssues
243
254
  const safeName = legacyEntry.name.replace(/\//g, '--');
244
255
  const src = join(disabledDir, safeName);
package/dist/cli.js CHANGED
@@ -11,6 +11,9 @@ 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';
16
+ import { classifyCodexIssues } from './codex/detectors.js';
14
17
  import { resolveSelection, resolveRestoreSelection } from './selection.js';
15
18
  const pkgPath = join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json');
16
19
  const { version: PKG_VERSION } = JSON.parse(readFileSync(pkgPath, 'utf-8'));
@@ -36,15 +39,22 @@ program
36
39
  .description('Scan environment and report issues')
37
40
  .option('--json', 'Output raw JSON')
38
41
  .option('--lookback-days <n>', 'Days of session history for skill-usage analysis', '60')
42
+ .option('--no-codex', 'Skip the ~/.codex scan even if Codex is installed')
39
43
  .action(async (opts) => {
40
44
  await initTokenizer();
41
45
  const result = await scan({ lookbackDays: parseNonNegativeInt(opts.lookbackDays, 60) });
46
+ // Codex is scanned when present. Reported only — it is never modified, and
47
+ // unused-skill detection is suppressed there for lack of a usage signal.
48
+ // commander maps `--no-codex` to `opts.codex === false`, not `opts.noCodex`.
49
+ const codex = opts.codex === false ? null : await scanCodex();
42
50
  await flushCache();
43
51
  if (opts.json) {
44
- console.log(JSON.stringify(result, null, 2));
52
+ console.log(JSON.stringify({ ...result, codex }, null, 2));
45
53
  }
46
54
  else {
47
55
  console.log(formatScanSummary(result));
56
+ if (codex)
57
+ console.log(formatCodexSummary(codex));
48
58
  }
49
59
  });
50
60
  // --- doctor ---
@@ -99,12 +109,14 @@ program
99
109
  .option('--auto', 'Non-interactive: auto-select Tier 1 items only')
100
110
  .option('--sessions-per-day <n>', 'Sessions per day for savings estimate', '2')
101
111
  .option('--lookback-days <n>', 'Days of session history for skill-usage analysis', '60')
112
+ .option('--no-codex', 'Skip ~/.codex entirely')
102
113
  .action(async (opts) => {
103
114
  await runCleanPipeline({
104
115
  dryRun: !!opts.dryRun,
105
116
  auto: !!opts.auto,
106
117
  sessionsPerDay: parseNonNegativeInt(opts.sessionsPerDay, 2),
107
118
  lookbackDays: parseNonNegativeInt(opts.lookbackDays, 60),
119
+ codex: opts.codex !== false,
108
120
  });
109
121
  });
110
122
  // --- restore ---
@@ -211,6 +223,7 @@ program
211
223
  // temp_cache never counted toward totalTokensBefore).
212
224
  const SKILL_TYPES = new Set([
213
225
  'template', 'duplicate', 'skill_dup', 'oversized_skill', 'unused_skill',
226
+ 'backup_artifact',
214
227
  ]);
215
228
  const removedSkillEntries = movedEntries.filter((e) => SKILL_TYPES.has(e.type));
216
229
  const removedMemoryTokens = movedEntries
@@ -259,12 +272,20 @@ program.action(async () => {
259
272
  process.exitCode = 1;
260
273
  return;
261
274
  }
262
- await runCleanPipeline({ dryRun: false, auto: false, sessionsPerDay: 2, lookbackDays: 60 });
275
+ await runCleanPipeline({ dryRun: false, auto: false, sessionsPerDay: 2, lookbackDays: 60, codex: true });
263
276
  });
264
277
  // --- shared clean pipeline ---
265
278
  async function runCleanPipeline(opts) {
266
279
  await initTokenizer();
267
280
  const result = await scan({ lookbackDays: opts.lookbackDays });
281
+ // Codex issues join the same tiered list. They carry `agent: 'codex'`, which
282
+ // is what keeps the cleaner's path guard pointed at ~/.codex/.
283
+ const codexContents = new Map();
284
+ const codexScan = opts.codex ? await scanCodex(codexContents) : null;
285
+ if (codexScan) {
286
+ result.issues.push(...(await classifyCodexIssues({ scan: codexScan, contents: codexContents })));
287
+ result.issues.sort((a, b) => a.tier - b.tier || b.tokens - a.tokens);
288
+ }
268
289
  if (result.issues.length === 0) {
269
290
  console.log('\n \x1b[32mAlready slim!\x1b[0m No issues found.\n');
270
291
  await flushCache();
@@ -0,0 +1,26 @@
1
+ import type { Issue } from '../types.js';
2
+ import type { CodexScanResult } from './index.js';
3
+ export interface CodexDetectorContext {
4
+ scan: CodexScanResult;
5
+ /** SKILL.md contents keyed by absolute path, so detectors need not re-read. */
6
+ contents: Map<string, string>;
7
+ }
8
+ /** Tier 1 — a SKILL.md symlink whose target is gone. Contributes nothing. */
9
+ export declare function detectBrokenSymlinks(ctx: CodexDetectorContext): Promise<Issue[]>;
10
+ /** Tier 1 — a scaffold nobody filled in. */
11
+ export declare function detectTemplates(ctx: CodexDetectorContext): Issue[];
12
+ /**
13
+ * Tier 1 — `~/.codex/.tmp`, where interrupted plugin installs accumulate.
14
+ * Sized rather than token-counted: this is disk, not context.
15
+ */
16
+ export declare function detectInstallLeftovers(): Promise<Issue[]>;
17
+ /** Tier 2 — a leftover copy, recognised by name shape alone. */
18
+ export declare function detectBackups(ctx: CodexDetectorContext): Issue[];
19
+ /**
20
+ * Tier 2 — a local skill shadowed by a plugin-provided one of the same name.
21
+ * Removing the local copy leaves the plugin version in place.
22
+ */
23
+ export declare function detectDuplicates(ctx: CodexDetectorContext): Issue[];
24
+ /** Tier 3 — large enough to be worth a look, but possibly still in use. */
25
+ export declare function detectOversized(ctx: CodexDetectorContext): Issue[];
26
+ export declare function classifyCodexIssues(ctx: CodexDetectorContext): Promise<Issue[]>;
@@ -0,0 +1,110 @@
1
+ import { join } from 'node:path';
2
+ import { getCodexDir } from '../paths.js';
3
+ import { OVERSIZED_SKILL_BYTES } from '../scanner/constants.js';
4
+ import { isDirectory, safeReaddir, getDirSize, isBrokenSymlink } from '../scanner/fs-walk.js';
5
+ // Codex cleanup candidates, in the same three tiers the Claude path uses.
6
+ //
7
+ // Five of Claude's seven categories carry over unchanged, because they are
8
+ // filesystem facts rather than usage inferences:
9
+ //
10
+ // broken symlinks · empty templates · duplicates · oversized · install leftovers
11
+ //
12
+ // The two that do not:
13
+ // unused_skill — no invocation record exists in Codex session logs
14
+ // oversized_memory— Codex has no ~/.codex/projects/*/memory/ equivalent
15
+ //
16
+ // Every issue produced here carries `agent: 'codex'`, which is what keeps the
17
+ // cleaner's path guard scoped to ~/.codex/ and unable to reach ~/.claude/.
18
+ const TEMPLATE_MARKER = 'Replace with description';
19
+ /** MB once it is worth calling MB; KB below that, so a 3KB leftover is not "0MB". */
20
+ function formatBytes(bytes) {
21
+ const mb = bytes / 1024 / 1024;
22
+ return mb >= 1 ? `${mb.toFixed(0)}MB` : `${Math.max(1, Math.round(bytes / 1024))}KB`;
23
+ }
24
+ function issue(type, tier, skill, detail) {
25
+ return { type, tier, agent: 'codex', name: skill.name, path: skill.path, tokens: skill.tokens, detail };
26
+ }
27
+ /** Tier 1 — a SKILL.md symlink whose target is gone. Contributes nothing. */
28
+ export async function detectBrokenSymlinks(ctx) {
29
+ const out = [];
30
+ const skillsDir = join(getCodexDir(), 'skills');
31
+ for (const entry of await safeReaddir(skillsDir)) {
32
+ if (entry.startsWith('.'))
33
+ continue;
34
+ const dir = join(skillsDir, entry);
35
+ if (!(await isDirectory(dir)))
36
+ continue;
37
+ const md = join(dir, 'SKILL.md');
38
+ if (await isBrokenSymlink(md)) {
39
+ out.push({
40
+ type: 'broken_symlink', tier: 1, agent: 'codex',
41
+ name: entry, path: md, tokens: 0, detail: 'dead symlink',
42
+ });
43
+ }
44
+ }
45
+ return out;
46
+ }
47
+ /** Tier 1 — a scaffold nobody filled in. */
48
+ export function detectTemplates(ctx) {
49
+ return ctx.scan.skills
50
+ .filter((s) => s.source === 'local')
51
+ .filter((s) => ctx.contents.get(join(s.path, 'SKILL.md'))?.includes(TEMPLATE_MARKER))
52
+ .map((s) => issue('template', 1, s, 'unfilled template'));
53
+ }
54
+ /**
55
+ * Tier 1 — `~/.codex/.tmp`, where interrupted plugin installs accumulate.
56
+ * Sized rather than token-counted: this is disk, not context.
57
+ */
58
+ export async function detectInstallLeftovers() {
59
+ const out = [];
60
+ for (const name of ['.tmp', '.remote-plugin-install-staging']) {
61
+ const path = join(getCodexDir(), name);
62
+ if (!(await isDirectory(path)))
63
+ continue;
64
+ const bytes = await getDirSize(path);
65
+ if (bytes === 0)
66
+ continue;
67
+ out.push({
68
+ type: 'temp_cache', tier: 1, agent: 'codex',
69
+ name, path, tokens: 0,
70
+ detail: `${formatBytes(bytes)} of install leftovers`,
71
+ });
72
+ }
73
+ return out;
74
+ }
75
+ /** Tier 2 — a leftover copy, recognised by name shape alone. */
76
+ export function detectBackups(ctx) {
77
+ return ctx.scan.skills
78
+ .filter((s) => s.source === 'local' && s.backupArtifact)
79
+ .map((s) => issue('backup_artifact', 2, s, `looks like a backup copy (${s.backupArtifact})`));
80
+ }
81
+ /**
82
+ * Tier 2 — a local skill shadowed by a plugin-provided one of the same name.
83
+ * Removing the local copy leaves the plugin version in place.
84
+ */
85
+ export function detectDuplicates(ctx) {
86
+ const fromPlugins = new Set(ctx.scan.skills.filter((s) => s.source === 'plugin').map((s) => s.name));
87
+ return ctx.scan.skills
88
+ .filter((s) => s.source === 'local' && fromPlugins.has(s.name))
89
+ .map((s) => issue('duplicate', 2, s, 'also provided by a plugin'));
90
+ }
91
+ /** Tier 3 — large enough to be worth a look, but possibly still in use. */
92
+ export function detectOversized(ctx) {
93
+ return ctx.scan.skills
94
+ .filter((s) => s.source === 'local' && s.sizeBytes > OVERSIZED_SKILL_BYTES)
95
+ .map((s) => issue('oversized_skill', 3, s, `${Math.round(s.sizeBytes / 1024)}KB`));
96
+ }
97
+ export async function classifyCodexIssues(ctx) {
98
+ const [symlinks, leftovers] = await Promise.all([
99
+ detectBrokenSymlinks(ctx),
100
+ detectInstallLeftovers(),
101
+ ]);
102
+ return [
103
+ ...symlinks,
104
+ ...detectTemplates(ctx),
105
+ ...leftovers,
106
+ ...detectBackups(ctx),
107
+ ...detectDuplicates(ctx),
108
+ ...detectOversized(ctx),
109
+ ].sort((a, b) => a.tier - b.tier || b.tokens - a.tokens);
110
+ }
@@ -0,0 +1,51 @@
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
+ /**
45
+ * @param contents optional sink for SKILL.md bodies, so detectors can inspect
46
+ * them without a second pass over the filesystem. Deliberately an out-param
47
+ * rather than part of the result: it must not land in `scan --json`.
48
+ */
49
+ export declare function scanCodex(contents?: Map<string, string>): Promise<CodexScanResult | null>;
50
+ /** Re-exported so callers can reuse the frontmatter parser without a deep import. */
51
+ export { parseFrontmatterDescription };
@@ -0,0 +1,198 @@
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(contents) {
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
+ contents?.set(md, content);
81
+ results.push({
82
+ name: entry,
83
+ path: skillDir,
84
+ sizeBytes: Buffer.byteLength(content),
85
+ tokens: countTokensCached(content, md),
86
+ listingTokens: listingTokensFromContent(entry, content),
87
+ source: 'local',
88
+ backupArtifact: detectBackupArtifact(entry)?.label,
89
+ });
90
+ }
91
+ return results;
92
+ }
93
+ /**
94
+ * Walk `plugins/cache/<marketplace>/<plugin>/<version>/skills/<skill>/SKILL.md`.
95
+ * Bounded rather than fully recursive so a deep or looping tree cannot wedge the
96
+ * scan — the layout is fixed and known.
97
+ */
98
+ async function scanPluginSkills() {
99
+ const cache = join(getCodexDir(), 'plugins', 'cache');
100
+ const results = [];
101
+ for (const marketplace of await safeReaddir(cache)) {
102
+ if (marketplace.startsWith('.'))
103
+ continue;
104
+ const mDir = join(cache, marketplace);
105
+ if (!(await isDirectory(mDir)))
106
+ continue;
107
+ for (const plugin of await safeReaddir(mDir)) {
108
+ if (plugin.startsWith('.'))
109
+ continue;
110
+ const pDir = join(mDir, plugin);
111
+ if (!(await isDirectory(pDir)))
112
+ continue;
113
+ for (const version of await safeReaddir(pDir)) {
114
+ const skillsDir = join(pDir, version, 'skills');
115
+ if (!(await isDirectory(skillsDir)))
116
+ continue;
117
+ for (const skill of await safeReaddir(skillsDir)) {
118
+ const sDir = join(skillsDir, skill);
119
+ if (!(await isDirectory(sDir)))
120
+ continue;
121
+ const md = join(sDir, 'SKILL.md');
122
+ if (await isBrokenSymlink(md))
123
+ continue;
124
+ const content = await safeReadFile(md);
125
+ if (content === null)
126
+ continue;
127
+ results.push({
128
+ name: skill,
129
+ path: sDir,
130
+ sizeBytes: Buffer.byteLength(content),
131
+ tokens: countTokensCached(content, md),
132
+ listingTokens: listingTokensFromContent(skill, content),
133
+ source: 'plugin',
134
+ pluginName: plugin,
135
+ });
136
+ }
137
+ }
138
+ }
139
+ }
140
+ return results;
141
+ }
142
+ async function scanAgents() {
143
+ const dir = join(getCodexDir(), 'agents');
144
+ const results = [];
145
+ for (const entry of await safeReaddir(dir)) {
146
+ if (!entry.endsWith('.toml'))
147
+ continue;
148
+ const path = join(dir, entry);
149
+ if (await isBrokenSymlink(path))
150
+ continue;
151
+ const content = await safeReadFile(path);
152
+ if (content === null)
153
+ continue;
154
+ const name = entry.slice(0, -'.toml'.length);
155
+ results.push({
156
+ name,
157
+ path,
158
+ sizeBytes: Buffer.byteLength(content),
159
+ tokens: countTokensCached(content, path),
160
+ listingTokens: listingTokens(name, parseTomlDescription(content)),
161
+ });
162
+ }
163
+ return results;
164
+ }
165
+ /**
166
+ * @param contents optional sink for SKILL.md bodies, so detectors can inspect
167
+ * them without a second pass over the filesystem. Deliberately an out-param
168
+ * rather than part of the result: it must not land in `scan --json`.
169
+ */
170
+ export async function scanCodex(contents) {
171
+ if (!(await isCodexInstalled()))
172
+ return null;
173
+ const [local, plugin, agents] = await Promise.all([
174
+ scanLocalSkills(contents),
175
+ scanPluginSkills(),
176
+ scanAgents(),
177
+ ]);
178
+ const instructionsPath = join(getCodexDir(), 'AGENTS.md');
179
+ const instructions = await safeReadFile(instructionsPath);
180
+ const instructionsBytes = instructions ? Buffer.byteLength(instructions) : 0;
181
+ const instructionsTokens = instructions
182
+ ? countTokensCached(instructions, instructionsPath)
183
+ : 0;
184
+ const skills = [...local, ...plugin];
185
+ const listing = (xs) => xs.reduce((sum, x) => sum + x.listingTokens, 0);
186
+ return {
187
+ root: getCodexDir(),
188
+ skills,
189
+ agents,
190
+ instructionsBytes,
191
+ instructionsTokens,
192
+ totalTokens: listing(skills) + listing(agents) + instructionsTokens,
193
+ unusedDetectionAvailable: false,
194
+ unusedDetectionReason: UNUSED_DETECTION_REASON,
195
+ };
196
+ }
197
+ /** Re-exported so callers can reuse the frontmatter parser without a deep import. */
198
+ 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
+ }
@@ -1,6 +1,8 @@
1
1
  import type { Manifest, ManifestEntry, AnyManifestEntry, DisabledPluginEntry } from './types.js';
2
+ import type { AgentId } from './paths.js';
2
3
  export declare function getDisabledDir(): string;
3
- export declare function ensureDisabledDir(): Promise<void>;
4
+ /** Create (if needed) and return the disabled-skill store for one agent. */
5
+ export declare function ensureDisabledDir(agent?: AgentId): Promise<string>;
4
6
  export declare function migrateLegacyIfNeeded(): Promise<void>;
5
7
  export declare function readManifestV2(): Promise<Manifest>;
6
8
  export declare function writeManifestV2(manifest: Manifest): Promise<void>;
package/dist/manifest.js CHANGED
@@ -1,10 +1,13 @@
1
1
  import { readFile, writeFile, mkdir, rename, access } from 'node:fs/promises';
2
- import { getDisabledDir as getDir, getManifestPath, getLegacyManifestPath, } from './paths.js';
2
+ import { getDisabledDir as getDir, getAgentDisabledDir, getManifestPath, getLegacyManifestPath, } from './paths.js';
3
3
  export function getDisabledDir() {
4
4
  return getDir();
5
5
  }
6
- export async function ensureDisabledDir() {
7
- await mkdir(getDisabledDir(), { recursive: true });
6
+ /** Create (if needed) and return the disabled-skill store for one agent. */
7
+ export async function ensureDisabledDir(agent = 'claude') {
8
+ const dir = getAgentDisabledDir(agent);
9
+ await mkdir(dir, { recursive: true });
10
+ return dir;
8
11
  }
9
12
  async function pathExists(p) {
10
13
  try {
package/dist/paths.d.ts CHANGED
@@ -14,4 +14,23 @@ export declare function getDisabledDir(): string;
14
14
  export declare function getCurrentProjectSlug(cwd?: string): string;
15
15
  export declare function getManifestPath(): string;
16
16
  export declare function getLegacyManifestPath(): string;
17
+ /** The agents claude-slim is allowed to touch. Adding one widens what every
18
+ * destructive operation may reach, so this list is the security boundary. */
19
+ export type AgentId = 'claude' | 'codex';
20
+ export declare function getCodexDir(): string;
21
+ export declare function getAgentRoot(agent: AgentId): string;
22
+ export declare function getAgentDisabledDir(agent: AgentId): string;
23
+ /**
24
+ * Refuse to operate on a path outside the given agent's root. Guards destructive
25
+ * operations (rename/rm/unlink) against tampered manifests and scanner bugs.
26
+ *
27
+ * Deliberately per-agent rather than "inside any known root": a Codex issue must
28
+ * not be able to reach into ~/.claude/ and vice versa. Widening this to a single
29
+ * combined check would let one bad manifest entry cross between agents, which is
30
+ * exactly the failure this exists to prevent.
31
+ */
32
+ export declare function assertInsideAgentRoot(p: string, agent: AgentId): void;
33
+ /** Back-compat wrapper — the Claude path is by far the most common caller. */
17
34
  export declare function assertInsideClaudeDir(p: string): void;
35
+ /** Which agent owns this path, or null if it belongs to neither. */
36
+ export declare function agentForPath(p: string): AgentId | null;
package/dist/paths.js CHANGED
@@ -32,12 +32,44 @@ export function getManifestPath() {
32
32
  export function getLegacyManifestPath() {
33
33
  return join(getDisabledDir(), '.claude-slim-manifest.jsonl');
34
34
  }
35
- // Refuse to operate on a path outside ~/.claude/. Guards destructive operations
36
- // (rename/rm/unlink) from acting on attacker-tampered manifests or scanner bugs.
37
- export function assertInsideClaudeDir(p) {
38
- const resolved = resolve(p);
39
- const root = resolve(getClaudeDir());
40
- if (resolved !== root && !resolved.startsWith(root + sep)) {
41
- throw new Error(`Refusing to operate on path outside ~/.claude/: ${p}`);
35
+ export function getCodexDir() {
36
+ return join(homedir(), '.codex');
37
+ }
38
+ export function getAgentRoot(agent) {
39
+ return agent === 'claude' ? getClaudeDir() : getCodexDir();
40
+ }
41
+ export function getAgentDisabledDir(agent) {
42
+ return join(getAgentRoot(agent), 'skills.disabled');
43
+ }
44
+ function isInside(child, root) {
45
+ const c = resolve(child);
46
+ const r = resolve(root);
47
+ return c === r || c.startsWith(r + sep);
48
+ }
49
+ /**
50
+ * Refuse to operate on a path outside the given agent's root. Guards destructive
51
+ * operations (rename/rm/unlink) against tampered manifests and scanner bugs.
52
+ *
53
+ * Deliberately per-agent rather than "inside any known root": a Codex issue must
54
+ * not be able to reach into ~/.claude/ and vice versa. Widening this to a single
55
+ * combined check would let one bad manifest entry cross between agents, which is
56
+ * exactly the failure this exists to prevent.
57
+ */
58
+ export function assertInsideAgentRoot(p, agent) {
59
+ if (!isInside(p, getAgentRoot(agent))) {
60
+ const label = agent === 'claude' ? '~/.claude/' : '~/.codex/';
61
+ throw new Error(`Refusing to operate on path outside ${label}: ${p}`);
42
62
  }
43
63
  }
64
+ /** Back-compat wrapper — the Claude path is by far the most common caller. */
65
+ export function assertInsideClaudeDir(p) {
66
+ assertInsideAgentRoot(p, 'claude');
67
+ }
68
+ /** Which agent owns this path, or null if it belongs to neither. */
69
+ export function agentForPath(p) {
70
+ if (isInside(p, getClaudeDir()))
71
+ return 'claude';
72
+ if (isInside(p, getCodexDir()))
73
+ return 'codex';
74
+ return null;
75
+ }
package/dist/report.js CHANGED
@@ -261,7 +261,10 @@ export function formatScanSummary(result) {
261
261
  const detail = issue.detail ? ` (${issue.detail})` : '';
262
262
  const tokStr = issue.tokens > 0 ? ` ~${issue.tokens.toLocaleString()} tok` : '';
263
263
  const permanent = permanentTypes.has(issue.type) ? ' \x1b[31m(permanent)\x1b[0m' : '';
264
- lines.push(` ${selected} ${i + 1}. \x1b[${color}m[${tierLabel}]\x1b[0m ${issue.type}: ${issue.name}${detail}${tokStr}${permanent}`);
264
+ // Tag the agent when it is not Claude Code, so a ~/.codex item is never
265
+ // mistaken for one under ~/.claude.
266
+ const agentTag = issue.agent && issue.agent !== 'claude' ? ` \x1b[36m[${issue.agent}]\x1b[0m` : '';
267
+ lines.push(` ${selected} ${i + 1}. \x1b[${color}m[${tierLabel}]\x1b[0m${agentTag} ${issue.type}: ${issue.name}${detail}${tokStr}${permanent}`);
265
268
  }
266
269
  }
267
270
  // --- PLUGINS BREAKDOWN ---
@@ -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
@@ -1,5 +1,6 @@
1
1
  import type { UserSurfaceEntry } from './scanner/user-surfaces.js';
2
- export type { UserSurfaceEntry };
2
+ import type { AgentId } from './paths.js';
3
+ export type { UserSurfaceEntry, AgentId };
3
4
  export interface SkillInfo {
4
5
  name: string;
5
6
  path: string;
@@ -34,9 +35,11 @@ export interface PluginInfo {
34
35
  status?: 'enabled' | 'disabled';
35
36
  }
36
37
  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';
38
+ 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
39
  export interface Issue {
39
40
  type: IssueType;
41
+ /** Which agent root this issue lives under. Absent means Claude Code. */
42
+ agent?: AgentId;
40
43
  tier: IssueTier;
41
44
  name: string;
42
45
  detail?: string;
@@ -85,6 +88,8 @@ export interface ScanResult {
85
88
  }
86
89
  export interface ManifestEntry {
87
90
  date: string;
91
+ /** Which agent root `from` belongs to. Absent means Claude Code (pre-2.11 entries). */
92
+ agent?: AgentId;
88
93
  name: string;
89
94
  from: string;
90
95
  type: IssueType;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-slim",
3
- "version": "2.9.1",
3
+ "version": "2.11.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": {