claude-slim 2.10.0 → 2.12.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
@@ -165,6 +165,7 @@ npx claude-slim scan --no-codex # Skip the ~/.codex scan
165
165
  npx claude-slim doctor # Diagnose Node/Claude/session-log readiness
166
166
  npx claude-slim doctor --offline # Same, without the version check (no network)
167
167
  npx claude-slim check-update # Report-only version check
168
+ npx claude-slim update # Run the upgrade for this install method
168
169
  npx claude-slim restore # Undo
169
170
  npx claude-slim report # Show savings from last clean
170
171
  ```
@@ -202,7 +203,7 @@ claude-slim never updates itself — that's your package manager's job, and writ
202
203
  - **`~/.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.
203
204
  - **Plugin internals** (`~/.claude/plugins/config.json`, individual `plugin.json` files) — left alone; use `claude plugin` to manage plugins.
204
205
  - **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.
206
+ - **`~/.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.
206
207
  - **Anything outside `~/.claude/`** — a path-containment guard refuses destructive ops anywhere else, even if a tampered manifest asked it to.
207
208
 
208
209
  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.
@@ -247,18 +248,12 @@ Token counts come from [js-tiktoken](https://github.com/nicolo-ribaudo/js-tiktok
247
248
 
248
249
  ---
249
250
 
250
- ## v2.10.0 — What's new
251
+ ## v2.12.0 — What's new
251
252
 
252
- Codex support, scoped to what Codex can actually be asked.
253
+ - **`claude-slim update`** — runs the upgrade command for however this copy was installed, after showing it and asking. `--dry-run` to preview, `--yes` to skip the prompt. Plugin installs get the marketplace refresh first, then the qualified `claude-slim@claude-slim` id, and are reminded that a restart is needed. npx and source checkouts run nothing and say why — npx already resolves the latest every invocation, and pulling your own repository is not claude-slim's to do.
254
+ - **This corrects v2.9.0's reasoning.** That release stopped at detection, arguing updating belonged to the package manager. Half held: claude-slim must not write into a directory `claude plugin` owns. The other half did not — invoking the package manager is something this tool already does during cleanup (`claude plugin disable`), so refusing to here was inconsistent. It still writes nothing itself.
253
255
 
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/`.
258
-
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
+ Tests: 374 392 (+18), pinning the safety properties every argv a fixed literal, no shell metacharacters, executable only ever `claude` or `npm`, and a refusal to run unattended with no TTY and no `--yes`.
262
257
 
263
258
  For older release notes, see [CHANGELOG.md](CHANGELOG.md).
264
259
 
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,
@@ -85,6 +88,7 @@ export async function cleanIssues(issues) {
85
88
  await rename(issue.path, dest);
86
89
  const entry = {
87
90
  date: new Date().toISOString(),
91
+ agent,
88
92
  name: issue.name,
89
93
  from: issue.path,
90
94
  type: issue.type,
@@ -108,6 +112,7 @@ export async function cleanIssues(issues) {
108
112
  }
109
113
  const entry = {
110
114
  date: new Date().toISOString(),
115
+ agent,
111
116
  name: issue.name,
112
117
  from: issue.path,
113
118
  type: issue.type,
@@ -131,6 +136,7 @@ export async function cleanIssues(issues) {
131
136
  await rename(issue.path, backupDir);
132
137
  const entry = {
133
138
  date: new Date().toISOString(),
139
+ agent,
134
140
  name: issue.name,
135
141
  from: issue.path,
136
142
  type: issue.type,
@@ -210,7 +216,7 @@ export async function restoreItem(entry) {
210
216
  return;
211
217
  }
212
218
  const legacyEntry = entry;
213
- assertInsideClaudeDir(legacyEntry.from);
219
+ assertInsideAgentRoot(legacyEntry.from, legacyEntry.agent ?? 'claude');
214
220
  if (legacyEntry.type === 'broken_symlink') {
215
221
  throw new Error(`Broken symlinks cannot be restored (${legacyEntry.name})`);
216
222
  }
@@ -220,7 +226,9 @@ export async function restoreItem(entry) {
220
226
  if (legacyEntry.type === 'temp_cache') {
221
227
  throw new Error(`Temp caches were deleted and cannot be restored (${legacyEntry.name})`);
222
228
  }
223
- 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');
224
232
  if (legacyEntry.type === 'stale_project') {
225
233
  // Type-scoped path guard: stale-project backups must restore under
226
234
  // ~/.claude/projects/. Prevents a tampered manifest from redirecting a
@@ -239,7 +247,9 @@ export async function restoreItem(entry) {
239
247
  }
240
248
  else {
241
249
  // Type-scoped path guard: skill restores must land under ~/.claude/skills/.
242
- 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');
243
253
  // Restore skill directory using the same naming as cleanIssues
244
254
  const safeName = legacyEntry.name.replace(/\//g, '--');
245
255
  const src = join(disabledDir, safeName);
package/dist/cli.js CHANGED
@@ -11,8 +11,10 @@ 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 { confirmDecision, planUpdate, renderStep, runUpdate } from './update-run.js';
14
15
  import { scanCodex } from './codex/index.js';
15
16
  import { formatCodexSummary } from './codex/report.js';
17
+ import { classifyCodexIssues } from './codex/detectors.js';
16
18
  import { resolveSelection, resolveRestoreSelection } from './selection.js';
17
19
  const pkgPath = join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json');
18
20
  const { version: PKG_VERSION } = JSON.parse(readFileSync(pkgPath, 'utf-8'));
@@ -100,6 +102,81 @@ program
100
102
  console.log(`\n \x1b[32m✓\x1b[0m claude-slim ${result.installed} is up to date.\n`);
101
103
  }
102
104
  });
105
+ // --- update ---
106
+ // Detection lives in `check-update`; this runs the command that detection
107
+ // identified. claude-slim still never writes into a package manager's
108
+ // directories itself — it invokes the manager, the same way cleanup already
109
+ // invokes `claude plugin disable`.
110
+ program
111
+ .command('update')
112
+ .description('Run the upgrade command for however this copy was installed')
113
+ .option('--yes', 'Skip the confirmation prompt')
114
+ .option('--dry-run', 'Show the commands without running them')
115
+ .action(async (opts) => {
116
+ const result = await checkForUpdate({ force: true });
117
+ const plan = planUpdate(result.installMethod);
118
+ console.log('');
119
+ console.log(` Installed: ${result.installed} Latest: ${result.latest ?? 'unknown'}`);
120
+ console.log(` Install method: ${result.installMethod}`);
121
+ console.log('');
122
+ if (result.latest === null) {
123
+ console.log(' Could not reach the npm registry, so there is nothing to compare against.');
124
+ console.log('');
125
+ process.exitCode = 1;
126
+ return;
127
+ }
128
+ if (!result.outdated) {
129
+ console.log(' \x1b[32m✓\x1b[0m Already up to date.\n');
130
+ return;
131
+ }
132
+ if (!plan.runnable) {
133
+ console.log(` ${plan.guidance}\n`);
134
+ return;
135
+ }
136
+ console.log(' Will run:');
137
+ for (const step of plan.steps)
138
+ console.log(` ${renderStep(step)}`);
139
+ console.log('');
140
+ if (opts.dryRun) {
141
+ console.log(' \x1b[90mDry run — nothing was executed.\x1b[0m\n');
142
+ return;
143
+ }
144
+ const gate = confirmDecision(opts, Boolean(process.stdin.isTTY));
145
+ if (gate === 'refuse') {
146
+ console.error(' Refusing to run unattended. Re-run with --yes to confirm.\n');
147
+ process.exitCode = 1;
148
+ return;
149
+ }
150
+ if (gate === 'prompt') {
151
+ const answer = await askUser(' Proceed? [y/N] ');
152
+ if (!/^y(es)?$/i.test(answer)) {
153
+ console.log(' Cancelled.\n');
154
+ return;
155
+ }
156
+ console.log('');
157
+ }
158
+ const results = await runUpdate(plan);
159
+ for (const r of results) {
160
+ const mark = r.ok ? '\x1b[32m✓\x1b[0m' : '\x1b[31m✗\x1b[0m';
161
+ console.log(` ${mark} ${renderStep(r.step)}`);
162
+ if (r.output)
163
+ console.log(r.output.split('\n').map((l) => ` ${l}`).join('\n'));
164
+ }
165
+ const failed = results.find((r) => !r.ok);
166
+ console.log('');
167
+ if (failed) {
168
+ console.log(' Update did not complete. Run the commands above manually to see the full error.\n');
169
+ process.exitCode = 1;
170
+ }
171
+ else if (result.installMethod === 'plugin') {
172
+ // The new version is on disk but the running session still holds the old
173
+ // one — without saying so, users update, re-run, and see identical output.
174
+ console.log(' \x1b[33m!\x1b[0m Restart Claude Code for the update to take effect.\n');
175
+ }
176
+ else {
177
+ console.log(' \x1b[32m✓\x1b[0m Updated.\n');
178
+ }
179
+ });
103
180
  // --- clean ---
104
181
  program
105
182
  .command('clean')
@@ -108,12 +185,14 @@ program
108
185
  .option('--auto', 'Non-interactive: auto-select Tier 1 items only')
109
186
  .option('--sessions-per-day <n>', 'Sessions per day for savings estimate', '2')
110
187
  .option('--lookback-days <n>', 'Days of session history for skill-usage analysis', '60')
188
+ .option('--no-codex', 'Skip ~/.codex entirely')
111
189
  .action(async (opts) => {
112
190
  await runCleanPipeline({
113
191
  dryRun: !!opts.dryRun,
114
192
  auto: !!opts.auto,
115
193
  sessionsPerDay: parseNonNegativeInt(opts.sessionsPerDay, 2),
116
194
  lookbackDays: parseNonNegativeInt(opts.lookbackDays, 60),
195
+ codex: opts.codex !== false,
117
196
  });
118
197
  });
119
198
  // --- restore ---
@@ -269,12 +348,20 @@ program.action(async () => {
269
348
  process.exitCode = 1;
270
349
  return;
271
350
  }
272
- await runCleanPipeline({ dryRun: false, auto: false, sessionsPerDay: 2, lookbackDays: 60 });
351
+ await runCleanPipeline({ dryRun: false, auto: false, sessionsPerDay: 2, lookbackDays: 60, codex: true });
273
352
  });
274
353
  // --- shared clean pipeline ---
275
354
  async function runCleanPipeline(opts) {
276
355
  await initTokenizer();
277
356
  const result = await scan({ lookbackDays: opts.lookbackDays });
357
+ // Codex issues join the same tiered list. They carry `agent: 'codex'`, which
358
+ // is what keeps the cleaner's path guard pointed at ~/.codex/.
359
+ const codexContents = new Map();
360
+ const codexScan = opts.codex ? await scanCodex(codexContents) : null;
361
+ if (codexScan) {
362
+ result.issues.push(...(await classifyCodexIssues({ scan: codexScan, contents: codexContents })));
363
+ result.issues.sort((a, b) => a.tier - b.tier || b.tokens - a.tokens);
364
+ }
278
365
  if (result.issues.length === 0) {
279
366
  console.log('\n \x1b[32mAlready slim!\x1b[0m No issues found.\n');
280
367
  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
+ }
@@ -41,6 +41,11 @@ export declare function isCodexInstalled(): Promise<boolean>;
41
41
  * back to the flat estimate.
42
42
  */
43
43
  export declare function parseTomlDescription(content: string): string | null;
44
- export declare function scanCodex(): Promise<CodexScanResult | 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>;
45
50
  /** Re-exported so callers can reuse the frontmatter parser without a deep import. */
46
51
  export { parseFrontmatterDescription };
@@ -60,7 +60,7 @@ export function parseTomlDescription(content) {
60
60
  const unescaped = single[1].replace(/\\"/g, '"').replace(/\\\\/g, '\\').trim();
61
61
  return unescaped || null;
62
62
  }
63
- async function scanLocalSkills() {
63
+ async function scanLocalSkills(contents) {
64
64
  const dir = join(getCodexDir(), 'skills');
65
65
  const entries = await safeReaddir(dir);
66
66
  const results = [];
@@ -77,6 +77,7 @@ async function scanLocalSkills() {
77
77
  const content = await safeReadFile(md);
78
78
  if (content === null)
79
79
  continue;
80
+ contents?.set(md, content);
80
81
  results.push({
81
82
  name: entry,
82
83
  path: skillDir,
@@ -161,11 +162,16 @@ async function scanAgents() {
161
162
  }
162
163
  return results;
163
164
  }
164
- export async function scanCodex() {
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) {
165
171
  if (!(await isCodexInstalled()))
166
172
  return null;
167
173
  const [local, plugin, agents] = await Promise.all([
168
- scanLocalSkills(),
174
+ scanLocalSkills(contents),
169
175
  scanPluginSkills(),
170
176
  scanAgents(),
171
177
  ]);
@@ -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 ---
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;
@@ -37,6 +38,8 @@ export type IssueTier = 1 | 2 | 3;
37
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;
@@ -0,0 +1,40 @@
1
+ import type { InstallMethod } from './update-check.js';
2
+ export interface UpdateStep {
3
+ file: string;
4
+ args: string[];
5
+ }
6
+ export interface UpdatePlan {
7
+ /** false when there is nothing to execute — see `guidance`. */
8
+ runnable: boolean;
9
+ steps: UpdateStep[];
10
+ /** Why nothing runs, for the methods where that is the correct answer. */
11
+ guidance?: string;
12
+ }
13
+ export declare function planUpdate(method: InstallMethod): UpdatePlan;
14
+ /** Human-readable rendering of a step, matching what the user would type. */
15
+ export declare function renderStep(step: UpdateStep): string;
16
+ export interface StepResult {
17
+ step: UpdateStep;
18
+ ok: boolean;
19
+ output: string;
20
+ }
21
+ /**
22
+ * Run the plan's steps in order, stopping at the first failure.
23
+ *
24
+ * Sequential rather than parallel, and halting on error, because step 2 depends
25
+ * on step 1 having refreshed the marketplace — running it against a stale
26
+ * manifest would report success while changing nothing.
27
+ */
28
+ export declare function runUpdate(plan: UpdatePlan): Promise<StepResult[]>;
29
+ export type ConfirmDecision = 'run' | 'prompt' | 'refuse';
30
+ /**
31
+ * Decide how to gate an update that will modify the user's install.
32
+ *
33
+ * Refusing when there is no TTY is the important case: piped into a script or a
34
+ * CI job, there is nobody to answer the prompt, and silently proceeding would
35
+ * mean a tool that changes an installation without anyone agreeing to it.
36
+ * `--yes` is how you say so deliberately.
37
+ */
38
+ export declare function confirmDecision(opts: {
39
+ yes?: boolean;
40
+ }, isTTY: boolean): ConfirmDecision;
@@ -0,0 +1,101 @@
1
+ import { execFile } from 'node:child_process';
2
+ // Running the upgrade.
3
+ //
4
+ // v2.9.0 shipped detection only, on the reasoning that updating is the package
5
+ // manager's job. Half of that was right and half was not. Writing into the
6
+ // plugin directory ourselves would indeed corrupt an install — but *invoking*
7
+ // the package manager is something this tool already does elsewhere
8
+ // (`claude plugin disable` during unused-plugin cleanup). Refusing to invoke it
9
+ // here was inconsistent, not principled.
10
+ //
11
+ // So: still never write to those directories directly. Just run the command the
12
+ // user would have typed, after showing it to them.
13
+ //
14
+ // Every argv below is a fixed literal. Nothing from the environment, the
15
+ // manifest, or user input is interpolated, and execFile never routes through a
16
+ // shell — so there is no argument-injection surface to guard.
17
+ const STEP_TIMEOUT_MS = 180_000;
18
+ export function planUpdate(method) {
19
+ switch (method) {
20
+ case 'plugin':
21
+ return {
22
+ runnable: true,
23
+ steps: [
24
+ // Refresh the marketplace first; otherwise `plugin update` compares
25
+ // against a stale manifest and reports "already up to date".
26
+ { file: 'claude', args: ['plugin', 'marketplace', 'update', 'claude-slim'] },
27
+ // The qualified id is required: the bare name fails with
28
+ // `Plugin "claude-slim" not found` when a marketplace shares its name.
29
+ { file: 'claude', args: ['plugin', 'update', 'claude-slim@claude-slim'] },
30
+ ],
31
+ };
32
+ case 'global':
33
+ return {
34
+ runnable: true,
35
+ steps: [{ file: 'npm', args: ['install', '-g', 'claude-slim@latest'] }],
36
+ };
37
+ case 'npx':
38
+ return {
39
+ runnable: false,
40
+ steps: [],
41
+ guidance: 'npx resolves the latest version on every invocation, so there is nothing to update. ' +
42
+ 'If a stale copy is cached, run `npx claude-slim@latest` once to refresh it.',
43
+ };
44
+ case 'source':
45
+ return {
46
+ runnable: false,
47
+ steps: [],
48
+ guidance: 'This is a source checkout. Updating it means pulling your own repository, ' +
49
+ 'which claude-slim will not do on your behalf — run `git pull && npm install` yourself.',
50
+ };
51
+ default:
52
+ return {
53
+ runnable: false,
54
+ steps: [],
55
+ guidance: 'Could not tell how this copy was installed, so no upgrade command can be chosen safely. ' +
56
+ 'Update it the same way you installed it.',
57
+ };
58
+ }
59
+ }
60
+ /** Human-readable rendering of a step, matching what the user would type. */
61
+ export function renderStep(step) {
62
+ return [step.file, ...step.args].join(' ');
63
+ }
64
+ function runStep(step) {
65
+ return new Promise((resolve) => {
66
+ execFile(step.file, step.args, { timeout: STEP_TIMEOUT_MS }, (err, stdout, stderr) => {
67
+ const output = [stdout, stderr].map((s) => s?.trim()).filter(Boolean).join('\n');
68
+ resolve({ step, ok: !err, output: err ? `${err.message}${output ? `\n${output}` : ''}` : output });
69
+ });
70
+ });
71
+ }
72
+ /**
73
+ * Run the plan's steps in order, stopping at the first failure.
74
+ *
75
+ * Sequential rather than parallel, and halting on error, because step 2 depends
76
+ * on step 1 having refreshed the marketplace — running it against a stale
77
+ * manifest would report success while changing nothing.
78
+ */
79
+ export async function runUpdate(plan) {
80
+ const results = [];
81
+ for (const step of plan.steps) {
82
+ const result = await runStep(step);
83
+ results.push(result);
84
+ if (!result.ok)
85
+ break;
86
+ }
87
+ return results;
88
+ }
89
+ /**
90
+ * Decide how to gate an update that will modify the user's install.
91
+ *
92
+ * Refusing when there is no TTY is the important case: piped into a script or a
93
+ * CI job, there is nobody to answer the prompt, and silently proceeding would
94
+ * mean a tool that changes an installation without anyone agreeing to it.
95
+ * `--yes` is how you say so deliberately.
96
+ */
97
+ export function confirmDecision(opts, isTTY) {
98
+ if (opts.yes)
99
+ return 'run';
100
+ return isTTY ? 'prompt' : 'refuse';
101
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-slim",
3
- "version": "2.10.0",
3
+ "version": "2.12.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": {