packlight-cli 0.0.0-stage → 0.1.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.
Files changed (59) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +68 -2
  3. package/dist/adapters/claude-code/inventory.d.ts +48 -0
  4. package/dist/adapters/claude-code/inventory.js +449 -0
  5. package/dist/adapters/claude-code/logs.d.ts +54 -0
  6. package/dist/adapters/claude-code/logs.js +359 -0
  7. package/dist/adapters/codex/inventory.d.ts +17 -0
  8. package/dist/adapters/codex/inventory.js +130 -0
  9. package/dist/adapters/codex/logs.d.ts +22 -0
  10. package/dist/adapters/codex/logs.js +151 -0
  11. package/dist/adapters/codex/toml.d.ts +10 -0
  12. package/dist/adapters/codex/toml.js +76 -0
  13. package/dist/archive/apply.d.ts +62 -0
  14. package/dist/archive/apply.js +284 -0
  15. package/dist/archive/fsops.d.ts +24 -0
  16. package/dist/archive/fsops.js +95 -0
  17. package/dist/archive/journal.d.ts +41 -0
  18. package/dist/archive/journal.js +95 -0
  19. package/dist/archive/lock.d.ts +2 -0
  20. package/dist/archive/lock.js +32 -0
  21. package/dist/archive/manifest.d.ts +37 -0
  22. package/dist/archive/manifest.js +9 -0
  23. package/dist/archive/methods.d.ts +46 -0
  24. package/dist/archive/methods.js +239 -0
  25. package/dist/archive/paths.d.ts +18 -0
  26. package/dist/archive/paths.js +61 -0
  27. package/dist/archive/restore.d.ts +30 -0
  28. package/dist/archive/restore.js +84 -0
  29. package/dist/cli.d.ts +2 -0
  30. package/dist/cli.js +329 -0
  31. package/dist/core/buckets.d.ts +3 -0
  32. package/dist/core/buckets.js +6 -0
  33. package/dist/core/frontmatter.d.ts +6 -0
  34. package/dist/core/frontmatter.js +31 -0
  35. package/dist/core/hash.d.ts +19 -0
  36. package/dist/core/hash.js +42 -0
  37. package/dist/core/messages.d.ts +16 -0
  38. package/dist/core/messages.js +17 -0
  39. package/dist/core/scan.d.ts +16 -0
  40. package/dist/core/scan.js +345 -0
  41. package/dist/core/summary.d.ts +4 -0
  42. package/dist/core/summary.js +63 -0
  43. package/dist/core/types.d.ts +185 -0
  44. package/dist/core/types.js +3 -0
  45. package/dist/report/client.d.ts +1 -0
  46. package/dist/report/client.js +676 -0
  47. package/dist/report/fix.d.ts +46 -0
  48. package/dist/report/fix.js +116 -0
  49. package/dist/report/logo.d.ts +7 -0
  50. package/dist/report/logo.js +23 -0
  51. package/dist/report/model.d.ts +103 -0
  52. package/dist/report/model.js +194 -0
  53. package/dist/report/render.d.ts +9 -0
  54. package/dist/report/render.js +45 -0
  55. package/dist/report/styles.d.ts +1 -0
  56. package/dist/report/styles.js +202 -0
  57. package/dist/report/suggest.d.ts +39 -0
  58. package/dist/report/suggest.js +88 -0
  59. package/package.json +55 -4
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Cooper Kao
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,69 @@
1
- # Temporary Holding Version
1
+ <p>
2
+ <picture>
3
+ <source media="(prefers-color-scheme: dark)" srcset="docs/brand/packlight-mark-light.svg">
4
+ <img src="docs/brand/packlight-mark.svg" width="72" height="72" alt="">
5
+ </picture>
6
+ </p>
2
7
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
8
+ # packlight
9
+
10
+ See what your coding agent carries, keep what you use, archive the rest.
11
+
12
+ Claude Code loads every skill, agent, hook and MCP server you have installed into every session, used or not. packlight scans your setup and your local session logs, then writes one self-contained report. The report shows:
13
+
14
+ - how many tokens each session starts with, by source: MCP tool names, the skill listing, hook text, agent descriptions
15
+ - which skills are pushed out of Claude Code's skill listing
16
+ - how much text each hook injects
17
+ - which items were not used in any session since they were installed
18
+
19
+ Works with **Claude Code** and **Codex**. packlight picks Claude Code when `~/.claude` exists; for Codex, add `--agent codex` (`packlight --agent codex`, `packlight fix --agent codex`). Each tool gets its own report: `~/.packlight/report-claude-code.html` for Claude Code, `~/.packlight/report-codex.html` for Codex. For Codex the headline is measured: Codex logs the input tokens of each session's first request, and packlight reports the median. Codex plugins and MCP servers are switched off in `~/.codex/config.toml`, so packlight lists them with the exact line to change rather than editing that file.
20
+
21
+ **[See a sample report →](https://cooperism-lab.github.io/packlight/sample/)** (an invented setup, so you can click around before installing).
22
+
23
+ You mark what to archive. `packlight apply` moves it out reversibly, and `packlight restore` brings it back.
24
+
25
+ Everything runs locally: no telemetry, and no network calls from the CLI or the report.
26
+
27
+ ## Status
28
+
29
+ Issues 1 to 3 are built (v0.1 scope). `packlight` scans your setup and opens a report: what every session starts with (MCP tool names per server, the skill listing, MCP instructions, hook text), which skills Claude Code dropped from its listing, what each hook injects, and what was not observed in your sessions. Mark what to archive and press Save my picks; `packlight apply` archives it and `packlight restore` puts it back byte for byte, with a crash-safe journal.
30
+
31
+ Or skip the marking: `packlight fix` scans again, shows a plan that archives every unused item (none used since it was installed, over at least 20 sessions and 14 days, nothing you kept) with its projected gain, and asks once. It also lists the unused items only you can remove, such as claude.ai synced skills and the Claude app's own plugins.
32
+
33
+ Needs Node 20 or later. Run it without installing:
34
+
35
+ ```bash
36
+ npx packlight-cli # Claude Code: scan, write the report and open it
37
+ npx packlight-cli --agent codex # Codex
38
+ ```
39
+
40
+ Or install it once, then use the short command:
41
+
42
+ ```bash
43
+ npm install -g packlight-cli
44
+ packlight
45
+ packlight fix
46
+ packlight restore --all
47
+ ```
48
+
49
+ `npx` always runs the newest version; a global install updates with `npm update -g packlight-cli`.
50
+
51
+ The spec has been through strategy, design and engineering review.
52
+
53
+ - [docs/spec.md](docs/spec.md) is the full spec, including every review decision and the implementation tasks.
54
+ - [docs/design.md](docs/design.md) is the approved design doc: problem, premises and approach.
55
+ - [docs/report-design.md](docs/report-design.md) is the visual system for the report.
56
+
57
+ Planned releases:
58
+
59
+ - **v0.1:** scanner for Claude Code, the report, and reversible archive and restore.
60
+ - **v0.2:** overlap detection, plus a packlight skill that rates items and writes "which one" cards. This is the public launch.
61
+ - **v0.3:** Codex adapter.
62
+
63
+ ## Who made it
64
+
65
+ Built by Cooper Kao ([cooperism-lab](https://github.com/cooperism-lab)). Issues and pull requests are welcome.
66
+
67
+ ## Licence
68
+
69
+ MIT © 2026 Cooper Kao
@@ -0,0 +1,48 @@
1
+ import { dirname } from 'node:path';
2
+ import type { Declaration, Item, Kind } from '../../core/types.js';
3
+ export interface ClaudePaths {
4
+ home: string;
5
+ claude: string;
6
+ claudeJson: string;
7
+ projectsLogDir: string;
8
+ desktopExtensions: string;
9
+ /** Claude desktop app folder holding claude.ai organisation plugins (macOS). */
10
+ appSessions: string;
11
+ }
12
+ export declare function claudePaths(home: string): ClaudePaths;
13
+ export declare const readText: (p: string) => string;
14
+ export declare const dirs: (p: string) => string[];
15
+ export declare const files: (p: string, ext: string) => string[];
16
+ export declare function firstSeenOf(path: string): {
17
+ firstSeen: string | null;
18
+ firstSeenSource: Item['firstSeenSource'];
19
+ };
20
+ export interface ItemInput {
21
+ agent?: Item['agent'];
22
+ kind: Kind;
23
+ name: string;
24
+ source: string;
25
+ path: string | null;
26
+ projectRoot?: string | null;
27
+ enabled?: boolean;
28
+ description?: string;
29
+ standingChars?: number;
30
+ fingerprint: string;
31
+ firstSeen?: string | null;
32
+ firstSeenSource?: Item['firstSeenSource'];
33
+ logKeys?: string[];
34
+ plugin?: string;
35
+ hook?: Item['hook'];
36
+ members?: Item['members'];
37
+ declarations?: Declaration[];
38
+ removal: Item['removal'];
39
+ pointer?: string;
40
+ }
41
+ export declare function makeItem(i: ItemInput): Item;
42
+ export declare function collectInventory(paths: ClaudePaths, projectRoots: string[]): Item[];
43
+ /** Skills Claude Code counts as used in ~/.claude.json: their descriptions go into the listing first. */
44
+ export declare function skillUsageNames(paths: ClaudePaths): string[];
45
+ /** Project roots Claude Code itself registered in ~/.claude.json. */
46
+ export declare function registeredProjects(paths: ClaudePaths): string[];
47
+ export declare const isDir: (p: string) => boolean;
48
+ export { dirname };
@@ -0,0 +1,449 @@
1
+ import { existsSync, lstatSync, readdirSync, readFileSync, realpathSync, statSync } from 'node:fs';
2
+ import { basename, dirname, join, sep } from 'node:path';
3
+ import { frontmatter } from '../../core/frontmatter.js';
4
+ import { itemId, pathFingerprint, sha256, valueFingerprint } from '../../core/hash.js';
5
+ const AGENT = 'claude-code';
6
+ export function claudePaths(home) {
7
+ const claude = join(home, '.claude');
8
+ return {
9
+ home,
10
+ claude,
11
+ claudeJson: join(home, '.claude.json'),
12
+ projectsLogDir: join(claude, 'projects'),
13
+ desktopExtensions: join(home, 'Library', 'Application Support', 'Claude', 'Claude Extensions'),
14
+ appSessions: join(home, 'Library', 'Application Support', 'Claude', 'local-agent-mode-sessions'),
15
+ };
16
+ }
17
+ const readJson = (p) => {
18
+ try {
19
+ return JSON.parse(readFileSync(p, 'utf8').replace(/^/, ''));
20
+ }
21
+ catch {
22
+ return undefined;
23
+ }
24
+ };
25
+ export const readText = (p) => { try {
26
+ return readFileSync(p, 'utf8');
27
+ }
28
+ catch {
29
+ return '';
30
+ } };
31
+ export const dirs = (p) => {
32
+ try {
33
+ return readdirSync(p, { withFileTypes: true }).filter(d => d.isDirectory() || d.isSymbolicLink()).map(d => d.name).sort();
34
+ }
35
+ catch {
36
+ return [];
37
+ }
38
+ };
39
+ export const files = (p, ext) => {
40
+ try {
41
+ return readdirSync(p, { withFileTypes: true }).filter(d => !d.isDirectory() && d.name.endsWith(ext)).map(d => d.name).sort();
42
+ }
43
+ catch {
44
+ return [];
45
+ }
46
+ };
47
+ /** Pointer segment escaping per RFC 6901. */
48
+ const ptr = (...parts) => '/' + parts.map(p => String(p).replace(/~/g, '~0').replace(/\//g, '~1')).join('/');
49
+ export function firstSeenOf(path) {
50
+ const st = lstatSync(path, { throwIfNoEntry: false });
51
+ if (!st)
52
+ return { firstSeen: null, firstSeenSource: null };
53
+ if (st.birthtimeMs > 0)
54
+ return { firstSeen: new Date(st.birthtimeMs).toISOString(), firstSeenSource: 'birthtime' };
55
+ return { firstSeen: new Date(st.mtimeMs).toISOString(), firstSeenSource: 'mtime' };
56
+ }
57
+ export function makeItem(i) {
58
+ const description = i.description ?? '';
59
+ const agent = i.agent ?? AGENT;
60
+ return {
61
+ id: itemId({ agent, kind: i.kind, source: i.source, name: i.name, path: i.path, pointer: i.pointer }),
62
+ agent,
63
+ kind: i.kind,
64
+ name: i.name,
65
+ source: i.source,
66
+ path: i.path,
67
+ projectRoot: i.projectRoot ?? null,
68
+ enabled: i.enabled ?? true,
69
+ description,
70
+ descHash: sha256(`${i.name}\u0000${description}`),
71
+ standingChars: i.standingChars ?? 0,
72
+ targetFingerprint: i.fingerprint,
73
+ firstSeen: i.firstSeen ?? null,
74
+ firstSeenSource: i.firstSeenSource ?? null,
75
+ logKeys: i.logKeys ?? [],
76
+ ...(i.plugin ? { plugin: i.plugin } : {}),
77
+ ...(i.hook ? { hook: i.hook } : {}),
78
+ ...(i.members ? { members: i.members } : {}),
79
+ declarations: i.declarations ?? (i.path ? [{ file: i.path, scope: 'user' }] : []),
80
+ removal: i.removal,
81
+ usage: { total: 0, last30: 0, lastUsed: null, byProject: {}, ambiguous: false, uncertain: false },
82
+ };
83
+ }
84
+ /** What an item contributes to every session: its line in the skill or agent listing. */
85
+ const listingChars = (name, description) => `- ${name}: ${description}\n`.length;
86
+ // ---------------------------------------------------------------- skills, commands, agents
87
+ function skillsIn(root, opts) {
88
+ const out = [];
89
+ for (const d of dirs(root)) {
90
+ if (d === 'synced' && !opts.plugin && !opts.projectRoot)
91
+ continue;
92
+ const dir = join(root, d);
93
+ const file = join(dir, 'SKILL.md');
94
+ if (!existsSync(file))
95
+ continue;
96
+ const fm = frontmatter(readText(file));
97
+ const name = opts.plugin ? `${pluginShortName(opts.plugin)}:${d}` : d;
98
+ const description = fm.description ?? '';
99
+ const seen = opts.firstSeen !== undefined ? { firstSeen: opts.firstSeen, firstSeenSource: opts.firstSeen ? 'installedAt' : null } : firstSeenOf(dir);
100
+ // A folder that holds other skills or shared scripts is a suite (gstack is one): moving it would break the
101
+ // skills that depend on it, so it is only removable through the suite's own uninstaller.
102
+ const suite = !opts.plugin && (existsSync(join(dir, 'bin')) || dirs(dir).some(c => existsSync(join(dir, c, 'SKILL.md'))));
103
+ out.push(makeItem({
104
+ kind: 'skill', name, source: opts.source, path: dir, projectRoot: opts.projectRoot, enabled: opts.enabled,
105
+ description, standingChars: opts.enabled ? listingChars(name, description) : 0,
106
+ fingerprint: suite ? valueFingerprint({ dir, description }) : pathFingerprint(dir), ...seen, logKeys: [name], plugin: opts.plugin,
107
+ removal: suite ? { method: 'manual', where: `the ${name} suite's own uninstaller (other skills depend on ${dir})`, suite: true } : { method: opts.method, where: opts.where },
108
+ }));
109
+ }
110
+ // Skills a suite installed belong to it too: removing one by hand would leave the suite half-installed, and
111
+ // its own updater would put it back. A skill is a member when a file in it links into a suite folder, a suite
112
+ // has a folder of the same name, or its description ends with "(<suite name>)" as gstack writes them.
113
+ const suites = out.filter(i => i.removal.suite && i.path);
114
+ for (const it of out) {
115
+ if (it.removal.suite || !it.path || !suites.length)
116
+ continue;
117
+ const owner = suites.find(su => linksInto(it.path, su.path) || existsSync(join(su.path, basename(it.path), 'SKILL.md'))
118
+ || new RegExp(`\\(${su.name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\)\\s*$`).test(it.description));
119
+ if (owner)
120
+ it.removal = { method: 'manual', where: `the ${owner.name} suite's own uninstaller (installed with ${owner.name})`, suite: true };
121
+ }
122
+ return out;
123
+ }
124
+ /** Whether any file directly in `dir` is a symlink that resolves inside `target`. */
125
+ function linksInto(dir, target) {
126
+ let names;
127
+ try {
128
+ names = readdirSync(dir);
129
+ }
130
+ catch {
131
+ return false;
132
+ }
133
+ const real = (() => { try {
134
+ return realpathSync(target);
135
+ }
136
+ catch {
137
+ return target;
138
+ } })();
139
+ return names.some(n => {
140
+ try {
141
+ if (!lstatSync(join(dir, n)).isSymbolicLink())
142
+ return false;
143
+ const r = realpathSync(join(dir, n));
144
+ return r === real || r.startsWith(real + sep);
145
+ }
146
+ catch {
147
+ return false;
148
+ }
149
+ });
150
+ }
151
+ function commandsIn(root, opts, prefix = '') {
152
+ const out = [];
153
+ for (const f of files(root, '.md')) {
154
+ const path = join(root, f);
155
+ const fm = frontmatter(readText(path));
156
+ const base = prefix + f.slice(0, -3);
157
+ const name = opts.plugin ? `${pluginShortName(opts.plugin)}:${base}` : base;
158
+ const description = fm.description ?? '';
159
+ const seen = opts.firstSeen !== undefined ? { firstSeen: opts.firstSeen, firstSeenSource: opts.firstSeen ? 'installedAt' : null } : firstSeenOf(path);
160
+ out.push(makeItem({
161
+ kind: 'command', name, source: opts.source, path, projectRoot: opts.projectRoot, enabled: opts.enabled,
162
+ description, standingChars: opts.enabled ? listingChars(name, description) : 0,
163
+ fingerprint: pathFingerprint(path), ...seen, logKeys: [name], plugin: opts.plugin,
164
+ removal: { method: opts.method, where: opts.where },
165
+ }));
166
+ }
167
+ // Commands in subfolders are invoked as "folder:name".
168
+ for (const d of dirs(root))
169
+ out.push(...commandsIn(join(root, d), opts, `${prefix}${d}:`));
170
+ return out;
171
+ }
172
+ function agentsIn(root, opts) {
173
+ const out = [];
174
+ for (const f of files(root, '.md')) {
175
+ const path = join(root, f);
176
+ const fm = frontmatter(readText(path));
177
+ const base = fm.name || f.slice(0, -3);
178
+ const name = opts.plugin ? `${pluginShortName(opts.plugin)}:${base}` : base;
179
+ const description = fm.description ?? '';
180
+ const seen = opts.firstSeen !== undefined ? { firstSeen: opts.firstSeen, firstSeenSource: opts.firstSeen ? 'installedAt' : null } : firstSeenOf(path);
181
+ out.push(makeItem({
182
+ kind: 'agent', name, source: opts.source, path, projectRoot: opts.projectRoot, enabled: opts.enabled,
183
+ description, standingChars: opts.enabled ? listingChars(name, description) : 0,
184
+ fingerprint: pathFingerprint(path), ...seen, logKeys: [name], plugin: opts.plugin,
185
+ removal: { method: opts.method, where: opts.where },
186
+ }));
187
+ }
188
+ return out;
189
+ }
190
+ function hooksFrom(config, ctx) {
191
+ const out = [];
192
+ if (!config || typeof config !== 'object')
193
+ return out;
194
+ for (const [event, groups] of Object.entries(config)) {
195
+ if (!Array.isArray(groups))
196
+ continue;
197
+ groups.forEach((g, gi) => {
198
+ (g?.hooks ?? []).forEach((h, hi) => {
199
+ const command = typeof h?.command === 'string' ? h.command : '';
200
+ if (!command)
201
+ return;
202
+ const pointer = ptr('hooks', event, gi, 'hooks', hi);
203
+ const label = hookLabel(command, ctx.plugin);
204
+ const matcher = g?.matcher ?? null;
205
+ out.push(makeItem({
206
+ kind: 'hook', name: `${label} · ${event}${matcher ? ` (${matcher})` : ''}`, source: ctx.source, path: ctx.file,
207
+ projectRoot: ctx.projectRoot, enabled: ctx.enabled, fingerprint: valueFingerprint({ event, matcher, command }),
208
+ firstSeen: ctx.firstSeen ?? null, firstSeenSource: ctx.firstSeen ? 'installedAt' : null,
209
+ plugin: ctx.plugin, hook: { event, matcher, command },
210
+ declarations: [{ file: ctx.file, scope: ctx.scope, pointer }], pointer,
211
+ removal: { method: ctx.method, where: ctx.where ?? ctx.file },
212
+ }));
213
+ });
214
+ });
215
+ }
216
+ return out;
217
+ }
218
+ function hookLabel(command, plugin) {
219
+ if (plugin) {
220
+ // Name a plugin hook by its script so two hooks on one event stay distinguishable.
221
+ const script = /([\w.-]+\.(?:m?js|cjs|ts|sh|py|cmd))\b/.exec(command)?.[1];
222
+ const arg = command.trim().split(/\s+/).slice(1).find(w => /^[a-z][\w-]*$/.test(w));
223
+ return [pluginShortName(plugin), script, script ? undefined : arg].filter(Boolean).join(' ');
224
+ }
225
+ const first = command.trim().split(/\s+/)[0] ?? command;
226
+ const tool = basename(first.replace(/^["']|["']$/g, ''));
227
+ const rest = command.trim().split(/\s+/).slice(1, 3).join(' ');
228
+ return (tool + (rest && !rest.startsWith('-c') ? ` ${rest}` : '')).slice(0, 60);
229
+ }
230
+ function mcpFrom(servers, ctx) {
231
+ const out = [];
232
+ if (!servers || typeof servers !== 'object')
233
+ return out;
234
+ for (const [name, config] of Object.entries(servers)) {
235
+ const pointer = ptr(...ctx.pointerBase, name);
236
+ const logKey = ctx.plugin ? `plugin_${pluginShortName(ctx.plugin)}_${name}` : name;
237
+ out.push(makeItem({
238
+ kind: 'mcp', name, source: ctx.source, path: ctx.file, projectRoot: ctx.projectRoot, enabled: ctx.enabled,
239
+ fingerprint: valueFingerprint(config), firstSeen: ctx.firstSeen ?? null, firstSeenSource: ctx.firstSeen ? 'installedAt' : null,
240
+ logKeys: [logKey], plugin: ctx.plugin, declarations: [{ file: ctx.file, scope: ctx.scope, pointer }], pointer,
241
+ removal: { method: ctx.method, where: ctx.where ?? ctx.file },
242
+ }));
243
+ }
244
+ return out;
245
+ }
246
+ // ---------------------------------------------------------------- plugins
247
+ const pluginShortName = (key) => key.split('@')[0] ?? key;
248
+ function settingsFiles(paths, projectRoots) {
249
+ const out = [];
250
+ const add = (file, scope, projectRoot) => {
251
+ const json = readJson(file);
252
+ if (json && typeof json === 'object')
253
+ out.push({ file, scope, projectRoot, json });
254
+ };
255
+ add(join(paths.claude, 'settings.json'), 'user', null);
256
+ add(join(paths.claude, 'settings.local.json'), 'local', null);
257
+ for (const root of projectRoots) {
258
+ add(join(root, '.claude', 'settings.json'), 'project', root);
259
+ add(join(root, '.claude', 'settings.local.json'), 'project-local', root);
260
+ }
261
+ return out;
262
+ }
263
+ function pluginItems(paths, settings) {
264
+ const installed = readJson(join(paths.claude, 'plugins', 'installed_plugins.json'))?.plugins ?? {};
265
+ const out = [];
266
+ for (const [key, installs] of Object.entries(installed)) {
267
+ const install = Array.isArray(installs) ? installs[0] : undefined;
268
+ const root = install?.installPath;
269
+ if (!root || !existsSync(root))
270
+ continue;
271
+ const declarations = settings
272
+ .filter(s => s.json?.enabledPlugins && key in s.json.enabledPlugins)
273
+ .map(s => ({ file: s.file, scope: s.scope, pointer: ptr('enabledPlugins', key), enabled: s.json.enabledPlugins[key] !== false }));
274
+ // Effective state outside any project: local settings beat user settings; undeclared means enabled.
275
+ const enabled = (declarations.find(d => d.scope === 'local') ?? declarations.find(d => d.scope === 'user'))?.enabled ?? true;
276
+ const firstSeen = install?.installedAt ?? null;
277
+ const childOpts = { source: `plugin:${pluginShortName(key)}`, projectRoot: null, plugin: key, enabled, method: 'plugin-disable', where: key, firstSeen };
278
+ const children = [
279
+ ...skillsIn(join(root, 'skills'), childOpts),
280
+ ...commandsIn(join(root, 'commands'), childOpts),
281
+ ...agentsIn(join(root, 'agents'), childOpts),
282
+ ...hooksFrom(readJson(join(root, 'hooks', 'hooks.json'))?.hooks, { ...childOpts, file: join(root, 'hooks', 'hooks.json'), scope: 'plugin' }),
283
+ ...(() => { const m = mcpFile(join(root, '.mcp.json')); return mcpFrom(m.servers, { ...childOpts, file: join(root, '.mcp.json'), scope: 'plugin', pointerBase: m.pointerBase }); })(),
284
+ ];
285
+ const members = {};
286
+ for (const c of children)
287
+ members[c.kind] = (members[c.kind] ?? 0) + 1;
288
+ const manifest = readJson(join(root, '.claude-plugin', 'plugin.json')) ?? readJson(join(root, 'package.json')) ?? {};
289
+ out.push(makeItem({
290
+ kind: 'plugin', name: pluginShortName(key), source: key.includes('@') ? `marketplace:${key.split('@')[1]}` : 'plugin', path: root,
291
+ enabled, description: typeof manifest.description === 'string' ? manifest.description : '',
292
+ fingerprint: valueFingerprint({ key, version: install?.version, members: children.map(c => c.id).sort() }),
293
+ firstSeen, firstSeenSource: firstSeen ? 'installedAt' : null, plugin: key, members,
294
+ declarations: declarations.length ? declarations : [{ file: join(paths.claude, 'plugins', 'installed_plugins.json'), scope: 'user', pointer: ptr('plugins', key), enabled: true }],
295
+ removal: { method: 'plugin-disable', where: key },
296
+ }));
297
+ out.push(...children);
298
+ }
299
+ return out;
300
+ }
301
+ const mcpServersOf = (json) => json && typeof json === 'object' ? (json.mcpServers && typeof json.mcpServers === 'object' ? json.mcpServers : undefined) : undefined;
302
+ /** A .mcp.json may list servers under "mcpServers" or, as some plugins ship it, at the top level. */
303
+ function mcpFile(path) {
304
+ const json = readJson(path);
305
+ const wrapped = mcpServersOf(json);
306
+ if (wrapped)
307
+ return { servers: wrapped, pointerBase: ['mcpServers'] };
308
+ const isServer = (v) => !!v && typeof v === 'object' && (typeof v.command === 'string' || typeof v.url === 'string' || typeof v.type === 'string');
309
+ if (json && typeof json === 'object' && Object.values(json).length && Object.values(json).every(isServer))
310
+ return { servers: json, pointerBase: [] };
311
+ return { servers: undefined, pointerBase: ['mcpServers'] };
312
+ }
313
+ /** claude.ai organisation plugins the desktop app syncs: their skills load into Claude Code, removable only in the app. */
314
+ function desktopPlugins(paths) {
315
+ const out = [];
316
+ for (const account of dirs(paths.appSessions)) {
317
+ for (const org of dirs(join(paths.appSessions, account))) {
318
+ const rpm = join(paths.appSessions, account, org, 'rpm');
319
+ const manifest = readJson(join(rpm, 'manifest.json'));
320
+ const entries = Array.isArray(manifest?.plugins) ? manifest.plugins : [];
321
+ for (const entry of entries) {
322
+ const root = join(rpm, String(entry.id ?? ''));
323
+ const name = readJson(join(root, '.claude-plugin', 'plugin.json'))?.name ?? entry.name;
324
+ if (!name || !existsSync(root))
325
+ continue;
326
+ const key = `${name}@claude.ai`;
327
+ const where = 'Claude desktop app › Customize › Plugins';
328
+ const opts = { source: `plugin:${name}`, projectRoot: null, plugin: key, enabled: true, method: 'manual', where };
329
+ const children = [
330
+ ...skillsIn(join(root, 'skills'), opts),
331
+ ...commandsIn(join(root, 'commands'), opts),
332
+ ...agentsIn(join(root, 'agents'), opts),
333
+ ...(() => { const m = mcpFile(join(root, '.mcp.json')); return mcpFrom(m.servers, { ...opts, file: join(root, '.mcp.json'), scope: 'app', pointerBase: m.pointerBase }); })(),
334
+ ];
335
+ const members = {};
336
+ for (const c of children)
337
+ members[c.kind] = (members[c.kind] ?? 0) + 1;
338
+ out.push(makeItem({
339
+ kind: 'plugin', name, source: 'claude.ai plugin', path: root, description: entry.displayName ?? '',
340
+ fingerprint: valueFingerprint({ key, updatedAt: entry.updatedAt, members: children.map(c => c.id).sort() }),
341
+ ...firstSeenOf(root), plugin: key, members, declarations: [{ file: join(rpm, 'manifest.json'), scope: 'app' }],
342
+ removal: { method: 'manual', where },
343
+ }));
344
+ out.push(...children);
345
+ }
346
+ }
347
+ }
348
+ return out;
349
+ }
350
+ // ---------------------------------------------------------------- always-loaded instructions
351
+ function instructionItem(path, source, projectRoot) {
352
+ if (!existsSync(path))
353
+ return null;
354
+ const text = readText(path);
355
+ return makeItem({
356
+ kind: 'instructions', name: projectRoot ? `${basename(projectRoot)}/${path.slice(projectRoot.length + 1)}` : basename(path),
357
+ source, path, projectRoot, standingChars: text.length, fingerprint: pathFingerprint(path), ...firstSeenOf(path),
358
+ removal: { method: 'manual', where: path },
359
+ });
360
+ }
361
+ // ---------------------------------------------------------------- entry point
362
+ export function collectInventory(paths, projectRoots) {
363
+ const items = [];
364
+ const claudeJson = readJson(paths.claudeJson) ?? {};
365
+ const settings = settingsFiles(paths, projectRoots);
366
+ // Personal skills, commands, agents.
367
+ const personal = { source: 'personal', projectRoot: null, enabled: true, method: 'move' };
368
+ items.push(...skillsIn(join(paths.claude, 'skills'), personal));
369
+ items.push(...commandsIn(join(paths.claude, 'commands'), personal));
370
+ items.push(...agentsIn(join(paths.claude, 'agents'), personal));
371
+ // claude.ai skills synced into Claude Code: removable only on claude.ai.
372
+ for (const org of dirs(join(paths.claude, 'skills', 'synced'))) {
373
+ for (const s of dirs(join(paths.claude, 'skills', 'synced', org))) {
374
+ const dir = join(paths.claude, 'skills', 'synced', org, s);
375
+ const file = join(dir, 'SKILL.md');
376
+ if (!existsSync(file))
377
+ continue;
378
+ const description = frontmatter(readText(file)).description ?? '';
379
+ const name = `anthropic-skills:${s}`;
380
+ items.push(makeItem({
381
+ kind: 'skill', name, source: 'claude.ai synced', path: dir, description, standingChars: listingChars(name, description),
382
+ fingerprint: pathFingerprint(dir), ...firstSeenOf(dir), logKeys: [name],
383
+ removal: { method: 'manual', where: 'claude.ai › Settings › Capabilities › Skills' },
384
+ }));
385
+ }
386
+ }
387
+ items.push(...pluginItems(paths, settings));
388
+ items.push(...desktopPlugins(paths));
389
+ // Hooks declared in user and project settings files.
390
+ for (const s of settings) {
391
+ items.push(...hooksFrom(s.json.hooks, {
392
+ file: s.file, scope: s.scope, projectRoot: s.projectRoot, enabled: true, method: 'hook-extract',
393
+ source: s.projectRoot ? `project:${basename(s.projectRoot)}` : s.scope === 'local' ? 'user local settings' : 'user settings',
394
+ }));
395
+ }
396
+ // MCP servers: user scope in ~/.claude.json, local scope per project there, project scope in .mcp.json.
397
+ items.push(...mcpFrom(mcpServersOf(claudeJson), { file: paths.claudeJson, scope: 'user', source: 'user config', projectRoot: null, pointerBase: ['mcpServers'], enabled: true, method: 'mcp-extract' }));
398
+ for (const root of projectRoots) {
399
+ const local = claudeJson.projects?.[root];
400
+ items.push(...mcpFrom(mcpServersOf(local), { file: paths.claudeJson, scope: 'project-local', source: `project:${basename(root)}`, projectRoot: root, pointerBase: ['projects', root, 'mcpServers'], enabled: true, method: 'mcp-extract' }));
401
+ const projMcp = mcpFile(join(root, '.mcp.json'));
402
+ items.push(...mcpFrom(projMcp.servers, { file: join(root, '.mcp.json'), scope: 'project', source: `project:${basename(root)}`, projectRoot: root, pointerBase: projMcp.pointerBase, enabled: true, method: 'mcp-extract' }));
403
+ // Project skills, commands, agents and instructions.
404
+ const proj = { source: `project:${basename(root)}`, projectRoot: root, enabled: true, method: 'move' };
405
+ items.push(...skillsIn(join(root, '.claude', 'skills'), proj));
406
+ items.push(...commandsIn(join(root, '.claude', 'commands'), proj));
407
+ items.push(...agentsIn(join(root, '.claude', 'agents'), proj));
408
+ for (const f of ['CLAUDE.md', join('.claude', 'CLAUDE.md'), 'CLAUDE.local.md']) {
409
+ const it = instructionItem(join(root, f), `project:${basename(root)}`, root);
410
+ if (it)
411
+ items.push(it);
412
+ }
413
+ }
414
+ const userInstructions = instructionItem(join(paths.claude, 'CLAUDE.md'), 'personal', null);
415
+ if (userInstructions)
416
+ items.push(userInstructions);
417
+ // Desktop app extensions (macOS app folder): removable only in the app.
418
+ for (const d of dirs(paths.desktopExtensions)) {
419
+ const manifest = readJson(join(paths.desktopExtensions, d, 'manifest.json'));
420
+ if (!manifest)
421
+ continue;
422
+ const name = manifest.display_name || manifest.name || d;
423
+ items.push(makeItem({
424
+ kind: 'mcp', name, source: 'desktop extension', path: join(paths.desktopExtensions, d),
425
+ description: typeof manifest.description === 'string' ? manifest.description : '',
426
+ fingerprint: valueFingerprint(manifest), ...firstSeenOf(join(paths.desktopExtensions, d)),
427
+ logKeys: [name.replace(/[^A-Za-z0-9]+/g, '_')],
428
+ removal: { method: 'manual', where: 'Claude desktop app › Settings › Extensions' },
429
+ }));
430
+ }
431
+ return items;
432
+ }
433
+ /** Skills Claude Code counts as used in ~/.claude.json: their descriptions go into the listing first. */
434
+ export function skillUsageNames(paths) {
435
+ const usage = readJson(paths.claudeJson)?.skillUsage;
436
+ return usage && typeof usage === 'object' ? Object.keys(usage) : [];
437
+ }
438
+ /** Project roots Claude Code itself registered in ~/.claude.json. */
439
+ export function registeredProjects(paths) {
440
+ const projects = readJson(paths.claudeJson)?.projects;
441
+ return projects && typeof projects === 'object' ? Object.keys(projects) : [];
442
+ }
443
+ export const isDir = (p) => { try {
444
+ return statSync(p).isDirectory();
445
+ }
446
+ catch {
447
+ return false;
448
+ } };
449
+ export { dirname };
@@ -0,0 +1,54 @@
1
+ import type { BudgetObservation, SessionRecord, ToolListing } from '../../core/types.js';
2
+ /** Top-level line types seen in Claude Code logs up to 2.1.293 (eng A2). Anything else is counted as an unknown shape. */
3
+ export declare const KNOWN_LINE_TYPES: Set<string>;
4
+ export interface Use {
5
+ ts: string | null;
6
+ project: string | null;
7
+ }
8
+ export interface HookFiring {
9
+ event: string;
10
+ command: string;
11
+ ts: string | null;
12
+ project: string | null;
13
+ durationMs: number | null;
14
+ /** Output Claude Code recorded on the success line (used when no separate context line follows). */
15
+ ownChars: number;
16
+ /** Characters from additional-context/system-message lines linked to this firing. */
17
+ linkedChars: number;
18
+ /** Linked text was shared with other hooks on the same tool call and could not be split. */
19
+ shared: boolean;
20
+ }
21
+ export interface LogScan {
22
+ sessions: SessionRecord[];
23
+ skillUses: Map<string, Use[]>;
24
+ agentUses: Map<string, Use[]>;
25
+ mcpUses: Map<string, Use[]>;
26
+ hookFirings: HookFiring[];
27
+ /** Initial skill listings, oldest first. */
28
+ listings: BudgetObservation[];
29
+ /** One per main session file that logged a tool list, oldest first. */
30
+ toolListings: ToolListing[];
31
+ unattributedHookChars: number;
32
+ linesTotal: number;
33
+ linesUnknownShape: number;
34
+ /** Unknown-shape lines that looked like usage evidence (tool calls, hooks, listings). */
35
+ linesUnknownRelevant: number;
36
+ linesUnreadable: number;
37
+ unknownByVersion: Record<string, number>;
38
+ versions: string[];
39
+ }
40
+ export interface LogOptions {
41
+ /** Maps a working directory to its project root, or null when it belongs to no known project. */
42
+ projectOf: (cwd: string) => string | null;
43
+ since?: string;
44
+ }
45
+ /** Every .jsonl under the projects folder; subagent transcripts belong to their parent session. */
46
+ export declare function logFiles(projectsDir: string): {
47
+ file: string;
48
+ sessionId: string;
49
+ isSubagent: boolean;
50
+ }[];
51
+ export declare function scanLogs(projectsDir: string, opts: LogOptions): Promise<LogScan>;
52
+ /** A server name in the form tool names use: "plugin:x:y" and "My Server" become "plugin_x_y" and "My_Server". */
53
+ export declare const toolKey: (name: string) => string;
54
+ export declare function compareVersions(a: string, b: string): number;