packlight-cli 0.0.0-stage → 0.1.1

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 (60) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/LICENSE +21 -0
  3. package/README.md +72 -2
  4. package/dist/adapters/claude-code/inventory.d.ts +48 -0
  5. package/dist/adapters/claude-code/inventory.js +449 -0
  6. package/dist/adapters/claude-code/logs.d.ts +54 -0
  7. package/dist/adapters/claude-code/logs.js +359 -0
  8. package/dist/adapters/codex/inventory.d.ts +17 -0
  9. package/dist/adapters/codex/inventory.js +130 -0
  10. package/dist/adapters/codex/logs.d.ts +22 -0
  11. package/dist/adapters/codex/logs.js +151 -0
  12. package/dist/adapters/codex/toml.d.ts +10 -0
  13. package/dist/adapters/codex/toml.js +76 -0
  14. package/dist/archive/apply.d.ts +62 -0
  15. package/dist/archive/apply.js +284 -0
  16. package/dist/archive/fsops.d.ts +24 -0
  17. package/dist/archive/fsops.js +95 -0
  18. package/dist/archive/journal.d.ts +41 -0
  19. package/dist/archive/journal.js +95 -0
  20. package/dist/archive/lock.d.ts +2 -0
  21. package/dist/archive/lock.js +32 -0
  22. package/dist/archive/manifest.d.ts +37 -0
  23. package/dist/archive/manifest.js +9 -0
  24. package/dist/archive/methods.d.ts +46 -0
  25. package/dist/archive/methods.js +239 -0
  26. package/dist/archive/paths.d.ts +18 -0
  27. package/dist/archive/paths.js +61 -0
  28. package/dist/archive/restore.d.ts +30 -0
  29. package/dist/archive/restore.js +84 -0
  30. package/dist/cli.d.ts +2 -0
  31. package/dist/cli.js +329 -0
  32. package/dist/core/buckets.d.ts +3 -0
  33. package/dist/core/buckets.js +6 -0
  34. package/dist/core/frontmatter.d.ts +6 -0
  35. package/dist/core/frontmatter.js +31 -0
  36. package/dist/core/hash.d.ts +19 -0
  37. package/dist/core/hash.js +42 -0
  38. package/dist/core/messages.d.ts +16 -0
  39. package/dist/core/messages.js +17 -0
  40. package/dist/core/scan.d.ts +16 -0
  41. package/dist/core/scan.js +345 -0
  42. package/dist/core/summary.d.ts +4 -0
  43. package/dist/core/summary.js +63 -0
  44. package/dist/core/types.d.ts +185 -0
  45. package/dist/core/types.js +3 -0
  46. package/dist/report/client.d.ts +1 -0
  47. package/dist/report/client.js +676 -0
  48. package/dist/report/fix.d.ts +46 -0
  49. package/dist/report/fix.js +116 -0
  50. package/dist/report/logo.d.ts +7 -0
  51. package/dist/report/logo.js +23 -0
  52. package/dist/report/model.d.ts +103 -0
  53. package/dist/report/model.js +194 -0
  54. package/dist/report/render.d.ts +9 -0
  55. package/dist/report/render.js +45 -0
  56. package/dist/report/styles.d.ts +1 -0
  57. package/dist/report/styles.js +202 -0
  58. package/dist/report/suggest.d.ts +39 -0
  59. package/dist/report/suggest.js +88 -0
  60. package/package.json +56 -4
@@ -0,0 +1,61 @@
1
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ export function packlightPaths(home, root = join(home, '.packlight')) {
5
+ return {
6
+ home,
7
+ root,
8
+ scans: join(root, 'scans'),
9
+ archive: join(root, 'archive'),
10
+ journal: join(root, 'journal.json'),
11
+ lock: join(root, 'lock'),
12
+ keeps: join(root, 'keeps.json'),
13
+ log: join(root, 'packlight.log'),
14
+ };
15
+ }
16
+ export function readScan(paths, scanId) {
17
+ if (!/^[\w.-]+$/.test(scanId))
18
+ return undefined;
19
+ const file = join(paths.scans, scanId, 'inventory.json');
20
+ if (!existsSync(file))
21
+ return undefined;
22
+ try {
23
+ return JSON.parse(readFileSync(file, 'utf8'));
24
+ }
25
+ catch {
26
+ return undefined;
27
+ }
28
+ }
29
+ /** Newest scan for one agent and project scope (eng X6 correction to F3). */
30
+ export function newestScan(paths, agent, projectScope) {
31
+ let ids;
32
+ try {
33
+ ids = readdirSync(paths.scans).sort().reverse();
34
+ }
35
+ catch {
36
+ return undefined;
37
+ }
38
+ for (const id of ids) {
39
+ const inv = readScan(paths, id);
40
+ if (inv && inv.agent === agent && inv.projectScope === projectScope)
41
+ return inv;
42
+ }
43
+ return undefined;
44
+ }
45
+ /** The browser download folder per OS (eng SC1); undefined when it cannot be found. */
46
+ export function downloadsDir(home = homedir(), platform = process.platform) {
47
+ if (platform === 'linux') {
48
+ try {
49
+ const dirs = readFileSync(join(home, '.config', 'user-dirs.dirs'), 'utf8');
50
+ const m = /^XDG_DOWNLOAD_DIR="?([^"\n]+)"?/m.exec(dirs);
51
+ if (m?.[1]) {
52
+ const p = m[1].replace(/^\$HOME/, home);
53
+ if (existsSync(p))
54
+ return p;
55
+ }
56
+ }
57
+ catch { /* fall through to ~/Downloads */ }
58
+ }
59
+ const p = join(home, 'Downloads');
60
+ return existsSync(p) ? p : undefined;
61
+ }
@@ -0,0 +1,30 @@
1
+ import type { Manifest } from './manifest.js';
2
+ export interface RestoreOptions {
3
+ home: string;
4
+ packlightRoot?: string;
5
+ /** Operation ids or item ids; ignored when `all` is set. */
6
+ ids: string[];
7
+ all: boolean;
8
+ out: (line: string) => void;
9
+ now?: () => Date;
10
+ crash?: (point: string) => void;
11
+ }
12
+ export interface RestoreResult {
13
+ exitCode: number;
14
+ restored: Manifest[];
15
+ refused: {
16
+ manifest: Manifest;
17
+ reason: string;
18
+ }[];
19
+ notFound: string[];
20
+ }
21
+ /**
22
+ * Puts archived items back, newest first, so several edits to one settings file unwind in order (D12).
23
+ * One refusal never stops the rest; the exit code is non-zero if any item was refused.
24
+ */
25
+ export declare function restore(opts: RestoreOptions): RestoreResult;
26
+ export declare function archiveList(opts: {
27
+ home: string;
28
+ packlightRoot?: string;
29
+ out: (line: string) => void;
30
+ }): void;
@@ -0,0 +1,84 @@
1
+ import { appendFileSync, mkdirSync } from 'node:fs';
2
+ import { listManifests, recover } from './journal.js';
3
+ import { acquireLock } from './lock.js';
4
+ import { restoreManifest } from './methods.js';
5
+ import { packlightPaths } from './paths.js';
6
+ import { reportRecovery } from './apply.js';
7
+ /**
8
+ * Puts archived items back, newest first, so several edits to one settings file unwind in order (D12).
9
+ * One refusal never stops the rest; the exit code is non-zero if any item was refused.
10
+ */
11
+ export function restore(opts) {
12
+ const paths = packlightPaths(opts.home, opts.packlightRoot);
13
+ const result = { exitCode: 0, restored: [], refused: [], notFound: [] };
14
+ const release = acquireLock(paths.lock);
15
+ try {
16
+ return restoreLocked(opts, paths, result);
17
+ }
18
+ finally {
19
+ release();
20
+ }
21
+ }
22
+ function restoreLocked(opts, paths, result) {
23
+ const recovery = recover(paths);
24
+ reportRecovery(recovery, opts.out);
25
+ if (recovery.refused.length)
26
+ return { ...result, exitCode: 1 };
27
+ const manifests = listManifests(paths);
28
+ const open = manifests.filter(m => m.status !== 'restored');
29
+ let chosen;
30
+ // Items removed by hand cannot be put back by packlight; --all lists them instead of failing on them.
31
+ if (opts.all) {
32
+ chosen = open.filter(m => m.status === 'archived');
33
+ const waiting = open.filter(m => m.status === 'waiting');
34
+ if (waiting.length)
35
+ opts.out(`${waiting.length} item${waiting.length === 1 ? ' was' : 's were'} removed by hand; add ${waiting.length === 1 ? 'it' : 'them'} back where ${waiting.length === 1 ? 'it was' : 'they were'} removed: ${waiting.map(m => m.itemName).join(', ')}.`);
36
+ }
37
+ else {
38
+ chosen = [];
39
+ for (const id of opts.ids) {
40
+ // An operation id names one archive; an item id names every open archive of that item.
41
+ const hits = open.filter(m => m.opId === id || m.itemId === id);
42
+ if (hits.length)
43
+ chosen.push(...hits);
44
+ else
45
+ result.notFound.push(id);
46
+ }
47
+ }
48
+ chosen = [...new Map(chosen.map(m => [m.opId, m])).values()].sort((a, b) => b.opId.localeCompare(a.opId));
49
+ for (const id of result.notFound)
50
+ opts.out(`Nothing to restore for ${id}: no archive of it is open. Run \`packlight archive list\` to see what is archived.`);
51
+ const now = opts.now ?? (() => new Date());
52
+ {
53
+ const ctx = { paths, scanId: '', now, expectedHash: new Map(), crash: opts.crash ?? (() => { }) };
54
+ for (const m of chosen) {
55
+ const o = restoreManifest(ctx, m);
56
+ mkdirSync(paths.root, { recursive: true });
57
+ appendFileSync(paths.log, `${now().toISOString()} restore ${m.method} ${m.itemId} ${m.opId} ${o.ok ? 'ok' : `refused: ${o.reason}`}\n`);
58
+ if (o.ok) {
59
+ result.restored.push(m);
60
+ opts.out(`Restored ${m.itemName} (${m.opId}).`);
61
+ continue;
62
+ }
63
+ result.refused.push({ manifest: m, reason: o.reason });
64
+ opts.out(`Not restored: ${m.itemName} (${m.opId}). ${o.reason}`);
65
+ if (o.fragment !== undefined)
66
+ opts.out(JSON.stringify(o.fragment, null, 2).split('\n').map(l => ` ${l}`).join('\n'));
67
+ }
68
+ }
69
+ if (opts.all && !chosen.length)
70
+ opts.out('Nothing is archived.');
71
+ result.exitCode = result.refused.length || result.notFound.length ? 1 : 0;
72
+ return result;
73
+ }
74
+ export function archiveList(opts) {
75
+ const manifests = listManifests(packlightPaths(opts.home, opts.packlightRoot));
76
+ if (!manifests.length) {
77
+ opts.out('Nothing archived yet.');
78
+ return;
79
+ }
80
+ for (const m of manifests) {
81
+ const where = m.originalPath ?? (m.settingsFile ? `${m.settingsFile} ${m.jsonPointer}` : m.manualSteps ?? '');
82
+ opts.out(`${m.opId} ${m.status.padEnd(8)} ${m.method.padEnd(14)} ${m.itemName} ${where}`);
83
+ }
84
+ }
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,329 @@
1
+ #!/usr/bin/env node
2
+ import { existsSync, mkdirSync, readdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
3
+ import { homedir } from 'node:os';
4
+ import { join, sep } from 'node:path';
5
+ import { execFileSync, spawn } from 'node:child_process';
6
+ import { createInterface } from 'node:readline/promises';
7
+ import { apply, claudeCodeRunning, findPicksFile, readKeeps, STALE_PICKS } from './archive/apply.js';
8
+ import { downloadsDir, newestScan, packlightPaths, readScan } from './archive/paths.js';
9
+ import { archiveList, restore } from './archive/restore.js';
10
+ import { scan, scanCodex } from './core/scan.js';
11
+ import { codexPaths } from './adapters/codex/inventory.js';
12
+ import { INVOKE, MESSAGES, PASTE_COMMAND } from './core/messages.js';
13
+ import { summarize } from './core/summary.js';
14
+ import { buildReport, CHARS_PER_TOKEN, loadTotal, sessionLoad } from './report/model.js';
15
+ import { fixPlan } from './report/fix.js';
16
+ import { renderReport } from './report/render.js';
17
+ import { MIN_DAYS, MIN_SESSIONS, suggestions } from './report/suggest.js';
18
+ const USAGE = `packlight: see what your coding agent carries
19
+
20
+ Usage:
21
+ packlight scan, write the report and open it
22
+ packlight report [--scan <id>] [--no-open]
23
+ packlight scan [--home <dir>] [--since <YYYY-MM-DD>] [--out <dir>] [--json]
24
+ packlight fix [--yes] archive everything suggested, after one question
25
+ packlight apply [picks.json | --paste] [--yes] [--home <dir>] [--out <dir>]
26
+ packlight restore <id…> | --all [--home <dir>] [--out <dir>]
27
+ packlight archive list [--home <dir>] [--out <dir>]
28
+
29
+ --agent claude-code or codex (default: Claude Code when ~/.claude exists, else Codex)
30
+ --home home folder to work on (default: your home folder)
31
+ --out packlight's own folder (default: <home>/.packlight)
32
+ --since only count sessions from this date
33
+ --json print the inventory to stdout instead of a summary
34
+ --yes apply without asking (it still prints the plan)
35
+ --no-open write the report without opening it`;
36
+ function arg(argv, name) {
37
+ const i = argv.indexOf(name);
38
+ return i >= 0 ? argv[i + 1] : undefined;
39
+ }
40
+ /** Positional arguments after the command, skipping flags and their values. */
41
+ function positional(argv) {
42
+ const withValue = new Set(['--home', '--out', '--since', '--agent']);
43
+ const out = [];
44
+ for (let i = 1; i < argv.length; i++) {
45
+ const a = argv[i];
46
+ if (withValue.has(a)) {
47
+ i++;
48
+ continue;
49
+ }
50
+ if (!a.startsWith('--'))
51
+ out.push(a);
52
+ }
53
+ return out;
54
+ }
55
+ /** Test hook: PACKLIGHT_CRASH_AT=<point> stops the process there, as kill -9 would (eng X3). */
56
+ const crash = (point) => {
57
+ if (process.env.PACKLIGHT_CRASH_AT === point)
58
+ process.exit(137);
59
+ };
60
+ async function confirm(question) {
61
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
62
+ try {
63
+ return /^y(es)?$/i.test((await rl.question(`${question} [y/N] `)).trim());
64
+ }
65
+ finally {
66
+ rl.close();
67
+ }
68
+ }
69
+ /** The newest scan's scope: the current folder's project if it has a scan, else the newest scan of any scope. */
70
+ const AGENT_NAME = { 'claude-code': 'Claude Code', codex: 'Codex' };
71
+ const setupDir = (agent, home) => (agent === 'codex' ? codexPaths(home).root : join(home, '.claude'));
72
+ /** --agent, else Claude Code when its folder exists, else Codex when its folder exists. */
73
+ function pickAgent(argv, home) {
74
+ const a = arg(argv, '--agent');
75
+ if (a)
76
+ return a === 'codex' || a === 'claude-code' ? a : null;
77
+ return existsSync(join(home, '.claude')) || !existsSync(codexPaths(home).root) ? 'claude-code' : 'codex';
78
+ }
79
+ function noSetup(agent, home) {
80
+ console.error(`No ${AGENT_NAME[agent]} setup found: ${setupDir(agent, home)} does not exist. Pass --home to scan another folder${agent === 'codex' ? '' : ', or --agent codex for Codex'}.`);
81
+ return 1;
82
+ }
83
+ function scopeFor(paths, agent) {
84
+ try {
85
+ const ids = readdirSync(paths.scans).sort().reverse();
86
+ const scans = ids.map(i => readScan(paths, i)).filter((x) => !!x && x.agent === agent);
87
+ const here = scans.find(s => s.projectScope !== 'global' && (process.cwd() === s.projectScope || process.cwd().startsWith(s.projectScope + sep)));
88
+ return (here ?? scans[0])?.projectScope ?? 'global';
89
+ }
90
+ catch {
91
+ return 'global';
92
+ }
93
+ }
94
+ /** One report file per agent, so a Codex scan never replaces the Claude Code report (or the other way round). */
95
+ const reportFile = (agent) => (agent === 'codex' ? 'report-codex.html' : 'report-claude-code.html');
96
+ /** Writes the agent's report beside packlight's data and returns its path. */
97
+ function writeReport(home, packlightRoot, inv) {
98
+ const paths = packlightPaths(home, packlightRoot);
99
+ const file = join(packlightRoot, reportFile(inv.agent));
100
+ mkdirSync(packlightRoot, { recursive: true });
101
+ writeFileSync(file, renderReport(buildReport(inv, paths)));
102
+ // Before 0.1, Claude Code's report was report.html: remove that copy so an old report is never opened by mistake.
103
+ // Only packlight's own file goes (it carries packlight's data block); anything else at that name is left alone.
104
+ const legacy = join(packlightRoot, 'report.html');
105
+ if (inv.agent === 'claude-code' && existsSync(legacy)) {
106
+ try {
107
+ if (readFileSync(legacy, 'utf8').includes('id="packlight-data"'))
108
+ unlinkSync(legacy);
109
+ }
110
+ catch { /* leave it */ }
111
+ }
112
+ return file;
113
+ }
114
+ function readClipboard() {
115
+ const [cmd, args] = process.platform === 'darwin' ? ['pbpaste', []] : process.platform === 'win32' ? ['powershell', ['-NoProfile', '-Command', 'Get-Clipboard -Raw']] : ['xclip', ['-selection', 'clipboard', '-o']];
116
+ try {
117
+ return execFileSync(cmd, args, { encoding: 'utf8', maxBuffer: 10 * 1024 * 1024 });
118
+ }
119
+ catch {
120
+ return undefined;
121
+ }
122
+ }
123
+ function openFile(file) {
124
+ const [cmd, args] = process.platform === 'darwin' ? ['open', [file]] : process.platform === 'win32' ? ['cmd', ['/c', 'start', '""', file]] : ['xdg-open', [file]];
125
+ try {
126
+ spawn(cmd, args, { detached: true, stdio: 'ignore' }).on('error', () => { }).unref();
127
+ }
128
+ catch { /* the path is printed anyway */ }
129
+ }
130
+ async function scanAndSave(home, packlightRoot, since, agent) {
131
+ const started = Date.now();
132
+ const inventory = await (agent === 'codex' ? scanCodex : scan)({ home, cwd: process.cwd(), since });
133
+ const dir = join(packlightRoot, 'scans', inventory.scanId);
134
+ mkdirSync(dir, { recursive: true });
135
+ const file = join(dir, 'inventory.json');
136
+ writeFileSync(file, JSON.stringify(inventory));
137
+ return { inventory, ms: Date.now() - started, file };
138
+ }
139
+ const KIND_WORD = { skill: ['skill', 'skills'], command: ['command', 'commands'], agent: ['agent', 'agents'], hook: ['hook', 'hooks'], plugin: ['plugin', 'plugins'], mcp: ['MCP server', 'MCP servers'] };
140
+ const count = (n, kind) => `${n} ${KIND_WORD[kind]?.[n === 1 ? 0 : 1] ?? kind}`;
141
+ /** "about 2k tokens less per session (10% of your setup; 7.9k characters)": tokens estimated at CHARS_PER_TOKEN. */
142
+ const saving = (savedChars, setup) => {
143
+ const pct = setup ? Math.min(100, (savedChars / setup) * 100) : 0;
144
+ return `about ${kchars(savedChars / CHARS_PER_TOKEN)} tokens less per session (${setup ? `${pct > 0 && pct < 1 ? 'under 1' : Math.round(pct)}% of your setup; ` : ''}${kchars(savedChars)} characters)`;
145
+ };
146
+ const kchars = (n) => (n >= 1000 ? `${(n / 1000).toFixed(1).replace(/\.0$/, '')}k` : String(n));
147
+ /** What the fix will do and buy, in the words the report uses for its Fix button. */
148
+ function fixSummary(plan, sessions, setup) {
149
+ if (!plan.ids.length)
150
+ return ['Nothing to fix: no item passes the rules for archiving (unused since it was installed, over at least ' + `${MIN_SESSIONS} sessions and ${MIN_DAYS} days, not kept, not shared).`];
151
+ const kinds = Object.entries(plan.byKind).map(([k, n]) => count(n, k)).join(', ');
152
+ const gains = [
153
+ plan.descriptionsBack ? `${plan.descriptionsBack} skill${plan.descriptionsBack === 1 ? '' : 's'} get their description back in Claude's skill listing` : null,
154
+ plan.sessionCharsSaved > 0 ? saving(plan.sessionCharsSaved, setup) : null,
155
+ ].filter(Boolean);
156
+ return [
157
+ `\nFix: archive ${plan.ids.length} unused items (${kinds}${plan.turnsOff > plan.ids.length ? `; ${plan.turnsOff} items with plugin parts` : ''}).`,
158
+ `None was used in the ${sessions} sessions scanned since it was installed. Everything can be restored.`,
159
+ gains.length ? `Projected: ${gains.join('; ')}.` : 'Projected: little change in what Claude loads each session; this mostly clears unused items from your setup.',
160
+ ];
161
+ }
162
+ function printByHand(plan, out, setup) {
163
+ const h = plan.byHand;
164
+ if (!h?.items.length)
165
+ return;
166
+ const gains = [h.sessionCharsSaved > plan.sessionCharsSaved ? saving(h.sessionCharsSaved, setup) : null, h.descriptionsBack > plan.descriptionsBack ? `${h.descriptionsBack} skill descriptions back` : null].filter(Boolean);
167
+ out(`\nAlso unused, but only you can remove these.${gains.length ? ` Removing them${plan.ids.length ? ' too' : ''}: ${gains.join(', ')}${plan.ids.length ? ' in all' : ''}.` : ''}`);
168
+ const byWhere = new Map();
169
+ for (const i of h.items)
170
+ byWhere.set(i.where, [...(byWhere.get(i.where) ?? []), i]);
171
+ for (const [where, list] of byWhere)
172
+ out(` ${where}: ${list.map(i => i.kind === 'plugin' ? `${i.name} (${i.turnsOff} parts)` : i.name).join(', ')}`);
173
+ }
174
+ async function main(argv) {
175
+ const [command] = argv;
176
+ if (command === '--help' || command === '-h' || command === 'help') {
177
+ console.log(USAGE);
178
+ return 0;
179
+ }
180
+ const home = arg(argv, '--home') ?? homedir();
181
+ const packlightRoot = arg(argv, '--out') ?? join(home, '.packlight');
182
+ const out = (line) => console.log(line);
183
+ const agent = pickAgent(argv, home);
184
+ if (!agent) {
185
+ console.error('--agent takes claude-code or codex.');
186
+ return 2;
187
+ }
188
+ if (!command || command.startsWith('--')) {
189
+ if (!existsSync(setupDir(agent, home)))
190
+ return noSetup(agent, home);
191
+ const { inventory, ms } = await scanAndSave(home, packlightRoot, arg(argv, '--since'), agent);
192
+ console.log(summarize(inventory, ms));
193
+ const report = writeReport(home, packlightRoot, inventory);
194
+ console.log(`\nReport: ${report}\n${MESSAGES.afterScan}`);
195
+ if (!argv.includes('--no-open'))
196
+ openFile(report);
197
+ return 0;
198
+ }
199
+ if (command === 'report') {
200
+ const id = arg(argv, '--scan');
201
+ const paths = packlightPaths(home, packlightRoot);
202
+ const inv = id ? readScan(paths, id) : newestScan(paths, agent, scopeFor(paths, agent));
203
+ if (!inv) {
204
+ console.error(id ? `No scan ${id} in ${paths.scans}.` : `No scan yet. Run \`${INVOKE}\` first.`);
205
+ return 1;
206
+ }
207
+ const report = writeReport(home, packlightRoot, inv);
208
+ console.log(`Report: ${report}`);
209
+ if (!argv.includes('--no-open'))
210
+ openFile(report);
211
+ return 0;
212
+ }
213
+ if (command === 'scan') {
214
+ const since = arg(argv, '--since');
215
+ if (since && !/^\d{4}-\d{2}-\d{2}/.test(since)) {
216
+ console.error('--since takes a date like 2026-08-01.');
217
+ return 2;
218
+ }
219
+ if (!existsSync(setupDir(agent, home)))
220
+ return noSetup(agent, home);
221
+ if (argv.includes('--json')) {
222
+ process.stdout.write(JSON.stringify(await (agent === 'codex' ? scanCodex : scan)({ home, cwd: process.cwd(), since }), null, 1) + '\n');
223
+ return 0;
224
+ }
225
+ const { inventory, ms, file } = await scanAndSave(home, packlightRoot, since, agent);
226
+ console.log(summarize(inventory, ms));
227
+ console.log(`\nInventory written to ${file}`);
228
+ return 0;
229
+ }
230
+ if (command === 'apply') {
231
+ let picksFile = positional(argv)[0] ?? (argv.includes('--paste') ? undefined : findPicksFile(downloadsDir(home)));
232
+ if (argv.includes('--paste')) {
233
+ // Picks copied from the report: written to packlight's own folder, then read like any picks file (still untrusted).
234
+ const text = readClipboard();
235
+ if (!text?.trim().startsWith('{')) {
236
+ console.error('The clipboard holds no picks. In the report, press Copy picks, then run this again.');
237
+ return 2;
238
+ }
239
+ mkdirSync(packlightRoot, { recursive: true });
240
+ picksFile = join(packlightRoot, 'pasted-picks.json');
241
+ writeFileSync(picksFile, text);
242
+ }
243
+ if (picksFile && !existsSync(picksFile)) {
244
+ console.error(`There is no file at ${picksFile}. Run \`${INVOKE} apply\` with no path to use the newest picks file in your downloads folder.`);
245
+ return 2;
246
+ }
247
+ if (!picksFile) {
248
+ console.error(`No picks file found in your downloads folder. Pass its path (packlight apply <file>), or press Copy picks in the report and run: ${PASTE_COMMAND}`);
249
+ return 2;
250
+ }
251
+ const yes = argv.includes('--yes');
252
+ if (!yes && !process.stdin.isTTY) {
253
+ console.error('apply asks before it changes anything. Run it in a terminal, or pass --yes.');
254
+ return 2;
255
+ }
256
+ out(`Picks: ${picksFile}`);
257
+ try {
258
+ const r = await apply({ home, packlightRoot, picksFile, yes, confirm, claudeRunning: claudeCodeRunning, out, crash });
259
+ if (r.archived.length || r.kept || r.unkept)
260
+ out(`\n${MESSAGES.stepReport}`);
261
+ return r.exitCode;
262
+ }
263
+ catch (err) {
264
+ console.error(err.message === STALE_PICKS ? STALE_PICKS : `packlight: ${err.message}`);
265
+ return 1;
266
+ }
267
+ }
268
+ if (command === 'fix') {
269
+ if (!existsSync(setupDir(agent, home)))
270
+ return noSetup(agent, home);
271
+ const yes = argv.includes('--yes');
272
+ if (!yes && !process.stdin.isTTY) {
273
+ console.error('fix asks before it changes anything. Run it in a terminal, or pass --yes.');
274
+ return 2;
275
+ }
276
+ // Always from a fresh scan: the plan never acts on an old picture of your setup.
277
+ const { inventory } = await scanAndSave(home, packlightRoot, arg(argv, '--since'), agent);
278
+ const paths = packlightPaths(home, packlightRoot);
279
+ const kept = new Set(Object.keys(readKeeps(paths).keeps));
280
+ const plan = fixPlan(inventory, suggestions(inventory, kept), kept);
281
+ writeReport(home, packlightRoot, inventory);
282
+ const load = sessionLoad(inventory);
283
+ const setup = load ? loadTotal(load) : 0;
284
+ if (load)
285
+ out(`Each session starts with about ${kchars(setup / CHARS_PER_TOKEN)} tokens of setup (${kchars(setup)} characters).`);
286
+ for (const line of fixSummary(plan, inventory.sessionsInWindow, setup))
287
+ out(line);
288
+ if (!plan.ids.length) {
289
+ printByHand(plan, out, setup);
290
+ return 0;
291
+ }
292
+ mkdirSync(packlightRoot, { recursive: true });
293
+ const picksFile = join(packlightRoot, `fix-picks-${inventory.scanId}.json`);
294
+ writeFileSync(picksFile, JSON.stringify({ scanId: inventory.scanId, picks: plan.ids.map(id => ({ id, action: 'archive' })) }, null, 1));
295
+ out('');
296
+ try {
297
+ const r = await apply({ home, packlightRoot, picksFile, yes, confirm, claudeRunning: claudeCodeRunning, out, crash, singleQuestion: `Archive these ${plan.ids.length} items?` });
298
+ if (r.archived.length) {
299
+ out(`\nDone. Undo everything with \`${INVOKE} restore --all\`, or one item with the restore command above.`);
300
+ out(`${AGENT_NAME[agent]} reads its setup when a session starts: start a new session, then run \`${INVOKE}${agent === 'codex' ? ' --agent codex' : ''}\` to measure the result.`);
301
+ }
302
+ printByHand(plan, out, setup);
303
+ return r.exitCode;
304
+ }
305
+ catch (err) {
306
+ console.error(`packlight: ${err.message}`);
307
+ return 1;
308
+ }
309
+ }
310
+ if (command === 'restore') {
311
+ const all = argv.includes('--all');
312
+ const ids = positional(argv);
313
+ if (!all && !ids.length) {
314
+ console.error('Name what to restore (an id from `packlight archive list`) or pass --all.');
315
+ return 2;
316
+ }
317
+ return restore({ home, packlightRoot, ids, all, out, crash }).exitCode;
318
+ }
319
+ if (command === 'archive' && positional(argv)[0] === 'list') {
320
+ archiveList({ home, packlightRoot, out });
321
+ return 0;
322
+ }
323
+ console.error(`Unknown command "${argv.join(' ')}".\n\n${USAGE}`);
324
+ return 2;
325
+ }
326
+ main(process.argv.slice(2)).then(code => { process.exitCode = code; }, err => {
327
+ console.error(`packlight: ${err instanceof Error ? err.message : String(err)}`);
328
+ process.exitCode = 1;
329
+ });
@@ -0,0 +1,3 @@
1
+ export type UsageBucket = 'unused' | 'rare' | 'regular';
2
+ /** Usage bucket relative to the scan window (eng Q1): rare means at most one use per ten sessions. */
3
+ export declare function usageBucket(uses: number, sessionsInWindow: number): UsageBucket;
@@ -0,0 +1,6 @@
1
+ /** Usage bucket relative to the scan window (eng Q1): rare means at most one use per ten sessions. */
2
+ export function usageBucket(uses, sessionsInWindow) {
3
+ if (uses <= 0)
4
+ return 'unused';
5
+ return uses <= sessionsInWindow / 10 ? 'rare' : 'regular';
6
+ }
@@ -0,0 +1,6 @@
1
+ /** Reads `name`, `description` and `model` from a markdown file's YAML frontmatter (simple scalars and folded blocks). */
2
+ export declare function frontmatter(text: string): {
3
+ name?: string;
4
+ description?: string;
5
+ model?: string;
6
+ };
@@ -0,0 +1,31 @@
1
+ /** Reads `name`, `description` and `model` from a markdown file's YAML frontmatter (simple scalars and folded blocks). */
2
+ export function frontmatter(text) {
3
+ const m = /^?---\r?\n([\s\S]*?)\r?\n---/.exec(text);
4
+ if (!m?.[1])
5
+ return {};
6
+ const out = {};
7
+ const lines = m[1].split(/\r?\n/);
8
+ for (let i = 0; i < lines.length; i++) {
9
+ const kv = /^([A-Za-z][\w-]*):\s*(.*)$/.exec(lines[i] ?? '');
10
+ if (!kv?.[1])
11
+ continue;
12
+ const key = kv[1];
13
+ if (key !== 'name' && key !== 'description' && key !== 'model')
14
+ continue;
15
+ let value = kv[2] ?? '';
16
+ // Block scalar (| or >) or a value continued on indented lines.
17
+ if (/^[|>][+-]?$/.test(value))
18
+ value = '';
19
+ const cont = [];
20
+ while (i + 1 < lines.length && /^(\s+\S|\s*$)/.test(lines[i + 1] ?? '') && !/^[A-Za-z][\w-]*:/.test(lines[i + 1] ?? '')) {
21
+ cont.push((lines[++i] ?? '').trim());
22
+ }
23
+ value = [value, ...cont].join(' ').replace(/\s+/g, ' ').trim();
24
+ if (value.length >= 2 && value.startsWith('"') && value.endsWith('"'))
25
+ value = value.slice(1, -1).replace(/\\(["\\/])/g, '$1').replace(/\\n/g, ' ');
26
+ else if (value.length >= 2 && value.startsWith("'") && value.endsWith("'"))
27
+ value = value.slice(1, -1).replace(/''/g, "'");
28
+ out[key] = value;
29
+ }
30
+ return out;
31
+ }
@@ -0,0 +1,19 @@
1
+ export declare const sha256: (data: string | Buffer) => string;
2
+ /**
3
+ * Stable item id (eng A1, extended by X5): readable slug plus 8 hex of a hash over everything that
4
+ * identifies one declaration, including the JSON pointer for settings-file entries.
5
+ */
6
+ export declare function itemId(parts: {
7
+ agent: string;
8
+ kind: string;
9
+ source: string;
10
+ name: string;
11
+ path: string | null;
12
+ pointer?: string;
13
+ }): string;
14
+ /**
15
+ * Content fingerprint (eng X1): a symlink hashes its target string (the link is what packlight moves),
16
+ * a file hashes its bytes, a folder hashes every entry's relative path and content in sorted order.
17
+ */
18
+ export declare function pathFingerprint(path: string): string;
19
+ export declare const valueFingerprint: (value: unknown) => string;
@@ -0,0 +1,42 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { lstatSync, readdirSync, readFileSync, readlinkSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ export const sha256 = (data) => createHash('sha256').update(data).digest('hex');
5
+ const slug = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 60) || 'item';
6
+ /**
7
+ * Stable item id (eng A1, extended by X5): readable slug plus 8 hex of a hash over everything that
8
+ * identifies one declaration, including the JSON pointer for settings-file entries.
9
+ */
10
+ export function itemId(parts) {
11
+ const key = [parts.agent, parts.kind, parts.source, parts.name, parts.path ?? '', parts.pointer ?? ''].join('\u0000');
12
+ return `${slug(`${parts.kind}-${parts.name}`)}-${sha256(key).slice(0, 8)}`;
13
+ }
14
+ /**
15
+ * Content fingerprint (eng X1): a symlink hashes its target string (the link is what packlight moves),
16
+ * a file hashes its bytes, a folder hashes every entry's relative path and content in sorted order.
17
+ */
18
+ export function pathFingerprint(path) {
19
+ const h = createHash('sha256');
20
+ const walk = (p, rel) => {
21
+ const st = lstatSync(p, { throwIfNoEntry: false });
22
+ if (!st) {
23
+ h.update(`missing:${rel}\n`);
24
+ return;
25
+ }
26
+ if (st.isSymbolicLink()) {
27
+ h.update(`link:${rel}:${readlinkSync(p)}\n`);
28
+ return;
29
+ }
30
+ if (st.isDirectory()) {
31
+ h.update(`dir:${rel}\n`);
32
+ for (const name of readdirSync(p).sort())
33
+ walk(join(p, name), rel ? `${rel}/${name}` : name);
34
+ return;
35
+ }
36
+ h.update(`file:${rel}:${st.size}\n`);
37
+ h.update(readFileSync(p));
38
+ };
39
+ walk(path, '');
40
+ return h.digest('hex');
41
+ }
42
+ export const valueFingerprint = (value) => sha256(JSON.stringify(value));
@@ -0,0 +1,16 @@
1
+ /**
2
+ * How to call packlight again: "npx packlight-cli" when this run came through npx, plain "packlight" when it is
3
+ * installed (npm link or a global install). Until packlight is on npm, npx can only find a local install.
4
+ */
5
+ export declare const INVOKE: string;
6
+ export declare const APPLY_COMMAND: string;
7
+ export declare const REPORT_COMMAND: string;
8
+ export declare const PASTE_COMMAND: string;
9
+ export declare const FIX_COMMAND: string;
10
+ export declare const MESSAGES: {
11
+ readonly picksSaved: "Your browser should now have packlight-picks.json in your downloads folder. Nothing has changed yet.";
12
+ readonly noFile: `No file in your downloads? Press Copy picks, then run \`${string}\`.`;
13
+ readonly stepApply: `Run \`${string}\` in your terminal: it shows the plan and asks before it changes anything.`;
14
+ readonly stepReport: `Then run \`${string}\` to see the result.`;
15
+ readonly afterScan: "Mark what to archive in the report, press Save my picks, then run the command it shows.";
16
+ };
@@ -0,0 +1,17 @@
1
+ // Lines the CLI and the report both show, word for word (design DR8): one source so they never drift.
2
+ /**
3
+ * How to call packlight again: "npx packlight-cli" when this run came through npx, plain "packlight" when it is
4
+ * installed (npm link or a global install). Until packlight is on npm, npx can only find a local install.
5
+ */
6
+ export const INVOKE = process.env.npm_command === 'exec' ? 'npx packlight-cli' : 'packlight';
7
+ export const APPLY_COMMAND = `${INVOKE} apply`;
8
+ export const REPORT_COMMAND = `${INVOKE} report`;
9
+ export const PASTE_COMMAND = `${INVOKE} apply --paste`;
10
+ export const FIX_COMMAND = `${INVOKE} fix`;
11
+ export const MESSAGES = {
12
+ picksSaved: 'Your browser should now have packlight-picks.json in your downloads folder. Nothing has changed yet.',
13
+ noFile: `No file in your downloads? Press Copy picks, then run \`${PASTE_COMMAND}\`.`,
14
+ stepApply: `Run \`${APPLY_COMMAND}\` in your terminal: it shows the plan and asks before it changes anything.`,
15
+ stepReport: `Then run \`${REPORT_COMMAND}\` to see the result.`,
16
+ afterScan: 'Mark what to archive in the report, press Save my picks, then run the command it shows.',
17
+ };