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.
- package/CHANGELOG.md +25 -0
- package/LICENSE +21 -0
- package/README.md +72 -2
- package/dist/adapters/claude-code/inventory.d.ts +48 -0
- package/dist/adapters/claude-code/inventory.js +449 -0
- package/dist/adapters/claude-code/logs.d.ts +54 -0
- package/dist/adapters/claude-code/logs.js +359 -0
- package/dist/adapters/codex/inventory.d.ts +17 -0
- package/dist/adapters/codex/inventory.js +130 -0
- package/dist/adapters/codex/logs.d.ts +22 -0
- package/dist/adapters/codex/logs.js +151 -0
- package/dist/adapters/codex/toml.d.ts +10 -0
- package/dist/adapters/codex/toml.js +76 -0
- package/dist/archive/apply.d.ts +62 -0
- package/dist/archive/apply.js +284 -0
- package/dist/archive/fsops.d.ts +24 -0
- package/dist/archive/fsops.js +95 -0
- package/dist/archive/journal.d.ts +41 -0
- package/dist/archive/journal.js +95 -0
- package/dist/archive/lock.d.ts +2 -0
- package/dist/archive/lock.js +32 -0
- package/dist/archive/manifest.d.ts +37 -0
- package/dist/archive/manifest.js +9 -0
- package/dist/archive/methods.d.ts +46 -0
- package/dist/archive/methods.js +239 -0
- package/dist/archive/paths.d.ts +18 -0
- package/dist/archive/paths.js +61 -0
- package/dist/archive/restore.d.ts +30 -0
- package/dist/archive/restore.js +84 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +329 -0
- package/dist/core/buckets.d.ts +3 -0
- package/dist/core/buckets.js +6 -0
- package/dist/core/frontmatter.d.ts +6 -0
- package/dist/core/frontmatter.js +31 -0
- package/dist/core/hash.d.ts +19 -0
- package/dist/core/hash.js +42 -0
- package/dist/core/messages.d.ts +16 -0
- package/dist/core/messages.js +17 -0
- package/dist/core/scan.d.ts +16 -0
- package/dist/core/scan.js +345 -0
- package/dist/core/summary.d.ts +4 -0
- package/dist/core/summary.js +63 -0
- package/dist/core/types.d.ts +185 -0
- package/dist/core/types.js +3 -0
- package/dist/report/client.d.ts +1 -0
- package/dist/report/client.js +676 -0
- package/dist/report/fix.d.ts +46 -0
- package/dist/report/fix.js +116 -0
- package/dist/report/logo.d.ts +7 -0
- package/dist/report/logo.js +23 -0
- package/dist/report/model.d.ts +103 -0
- package/dist/report/model.js +194 -0
- package/dist/report/render.d.ts +9 -0
- package/dist/report/render.js +45 -0
- package/dist/report/styles.d.ts +1 -0
- package/dist/report/styles.js +202 -0
- package/dist/report/suggest.d.ts +39 -0
- package/dist/report/suggest.js +88 -0
- package/package.json +56 -4
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// Reads Codex session logs (~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl): sessions, skill and MCP use, and what
|
|
2
|
+
// each session started with (its world_state record and the input tokens of its first request).
|
|
3
|
+
import { createReadStream, readdirSync } from 'node:fs';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { createInterface } from 'node:readline';
|
|
6
|
+
function rollouts(dir) {
|
|
7
|
+
const out = [];
|
|
8
|
+
const walk = (d) => {
|
|
9
|
+
let names;
|
|
10
|
+
try {
|
|
11
|
+
names = readdirSync(d, { withFileTypes: true }).map(e => (e.isDirectory() ? `${e.name}/` : e.name));
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
for (const n of names) {
|
|
17
|
+
if (n.endsWith('/'))
|
|
18
|
+
walk(join(d, n.slice(0, -1)));
|
|
19
|
+
else if (/^rollout-.*\.jsonl$/.test(n))
|
|
20
|
+
out.push(join(d, n));
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
walk(dir);
|
|
24
|
+
return out.sort();
|
|
25
|
+
}
|
|
26
|
+
const push = (m, k, u) => { const l = m.get(k); if (l)
|
|
27
|
+
l.push(u);
|
|
28
|
+
else
|
|
29
|
+
m.set(k, [u]); };
|
|
30
|
+
const textOf = (v) => (typeof v === 'string' ? v.length : v && typeof v === 'object' ? JSON.stringify(v).length : 0);
|
|
31
|
+
/** Skills a tool call opened: Codex reads a skill by reading its SKILL.md. Plugin skills are "<plugin>:<skill>". */
|
|
32
|
+
export function skillsIn(text) {
|
|
33
|
+
const out = new Set();
|
|
34
|
+
// Either separator, and doubled backslashes as they appear inside JSON-encoded arguments (Windows).
|
|
35
|
+
const sep = String.raw `[\\/]+`;
|
|
36
|
+
const seg = String.raw `[^\\/"'\s]+`;
|
|
37
|
+
const plugin = new RegExp(String.raw `${sep}plugins${sep}cache${sep}${seg}${sep}(${seg})${sep}${seg}${sep}skills${sep}(${seg})${sep}SKILL\.md`, 'g');
|
|
38
|
+
const own = new RegExp(String.raw `${sep}\.codex${sep}skills${sep}(?:\.system${sep})?(${seg})${sep}SKILL\.md`, 'g');
|
|
39
|
+
for (const m of text.matchAll(plugin))
|
|
40
|
+
out.add(`${m[1]}:${m[2]}`);
|
|
41
|
+
for (const m of text.matchAll(own))
|
|
42
|
+
out.add(m[1]);
|
|
43
|
+
return [...out];
|
|
44
|
+
}
|
|
45
|
+
/** MCP servers a tool call used: "mcp__<server>__<tool>" as the tool name, or called from Codex's code mode. */
|
|
46
|
+
export function mcpServersIn(name, input) {
|
|
47
|
+
const out = new Set();
|
|
48
|
+
if (typeof name === 'string' && name.startsWith('mcp__'))
|
|
49
|
+
out.add(name.split('__')[1]);
|
|
50
|
+
for (const m of input.matchAll(/\bmcp__([A-Za-z0-9_-]+?)__[A-Za-z0-9_]/g))
|
|
51
|
+
out.add(m[1]);
|
|
52
|
+
return [...out];
|
|
53
|
+
}
|
|
54
|
+
const KNOWN = new Set(['session_meta', 'response_item', 'event_msg', 'turn_context', 'world_state', 'token_usage_record', 'compacted']);
|
|
55
|
+
export async function scanCodexLogs(dir, opts) {
|
|
56
|
+
const scan = { sessions: [], skillUses: new Map(), mcpUses: new Map(), starts: [], linesTotal: 0, linesUnknownShape: 0, linesUnreadable: 0, unknownByVersion: {}, versions: [] };
|
|
57
|
+
const versions = new Set();
|
|
58
|
+
for (const file of rollouts(dir)) {
|
|
59
|
+
const s = { id: file, file, projectRoot: null, start: null, end: null, lines: 0, unknownShapeLines: 0, unreadableLines: 0, versions: [] };
|
|
60
|
+
let version = null;
|
|
61
|
+
let project = null;
|
|
62
|
+
let start = null;
|
|
63
|
+
let firstTokens = null;
|
|
64
|
+
const rl = createInterface({ input: createReadStream(file, { encoding: 'utf8' }), crlfDelay: Infinity });
|
|
65
|
+
for await (const line of rl) {
|
|
66
|
+
if (!line)
|
|
67
|
+
continue;
|
|
68
|
+
scan.linesTotal++;
|
|
69
|
+
s.lines++;
|
|
70
|
+
let e;
|
|
71
|
+
try {
|
|
72
|
+
e = JSON.parse(line);
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
scan.linesUnreadable++;
|
|
76
|
+
s.unreadableLines++;
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
const ts = typeof e?.timestamp === 'string' ? e.timestamp : null;
|
|
80
|
+
if (ts && opts.since && ts < opts.since)
|
|
81
|
+
continue;
|
|
82
|
+
if (ts) {
|
|
83
|
+
if (!s.start || ts < s.start)
|
|
84
|
+
s.start = ts;
|
|
85
|
+
if (!s.end || ts > s.end)
|
|
86
|
+
s.end = ts;
|
|
87
|
+
}
|
|
88
|
+
const p = e?.payload ?? {};
|
|
89
|
+
if (!KNOWN.has(e?.type)) {
|
|
90
|
+
scan.linesUnknownShape++;
|
|
91
|
+
if (version)
|
|
92
|
+
scan.unknownByVersion[version] = (scan.unknownByVersion[version] ?? 0) + 1;
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
if (e.type === 'session_meta') {
|
|
96
|
+
if (typeof p.id === 'string')
|
|
97
|
+
s.id = p.id;
|
|
98
|
+
if (typeof p.cli_version === 'string') {
|
|
99
|
+
version = p.cli_version;
|
|
100
|
+
versions.add(p.cli_version);
|
|
101
|
+
s.versions = [p.cli_version];
|
|
102
|
+
}
|
|
103
|
+
if (typeof p.cwd === 'string') {
|
|
104
|
+
project = opts.projectOf(p.cwd);
|
|
105
|
+
s.projectRoot = project;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
else if (e.type === 'turn_context' && typeof p.cwd === 'string' && !project) {
|
|
109
|
+
project = opts.projectOf(p.cwd);
|
|
110
|
+
s.projectRoot = project;
|
|
111
|
+
}
|
|
112
|
+
else if (e.type === 'world_state' && !start) {
|
|
113
|
+
const st = p.state ?? {};
|
|
114
|
+
const skills = typeof st.host_skills?.body === 'string' ? st.host_skills.body : '';
|
|
115
|
+
start = {
|
|
116
|
+
sessionId: s.id, timestamp: ts, version, projectRoot: project,
|
|
117
|
+
skillListingChars: skills.length,
|
|
118
|
+
listedSkills: [...skills.matchAll(/^- ([^:\n]+(?::[^:\n ]+)?): /gm)].map(m => m[1].trim()),
|
|
119
|
+
agentsMdChars: typeof st.agents_md?.text === 'string' ? st.agents_md.text.length : 0,
|
|
120
|
+
pluginInstructionChars: st.plugins_instructions ? textOf(st.plugins_instructions) : 0,
|
|
121
|
+
appInstructionChars: st.apps_instructions ? textOf(st.apps_instructions) : 0,
|
|
122
|
+
firstRequestTokens: [],
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
else if (e.type === 'event_msg' && p.type === 'token_count' && firstTokens === null) {
|
|
126
|
+
const n = p.info?.last_token_usage?.input_tokens;
|
|
127
|
+
if (typeof n === 'number')
|
|
128
|
+
firstTokens = n;
|
|
129
|
+
}
|
|
130
|
+
else if (e.type === 'response_item' && (p.type === 'function_call' || p.type === 'custom_tool_call' || p.type === 'local_shell_call')) {
|
|
131
|
+
const input = typeof p.input === 'string' ? p.input : typeof p.arguments === 'string' ? p.arguments : JSON.stringify(p.action ?? p.arguments ?? '');
|
|
132
|
+
const use = { ts, project };
|
|
133
|
+
for (const k of skillsIn(input))
|
|
134
|
+
push(scan.skillUses, k, use);
|
|
135
|
+
for (const k of mcpServersIn(p.name, input))
|
|
136
|
+
push(scan.mcpUses, k, use);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
if (start) {
|
|
140
|
+
if (firstTokens !== null)
|
|
141
|
+
start.firstRequestTokens.push(firstTokens);
|
|
142
|
+
start.projectRoot = project;
|
|
143
|
+
scan.starts.push(start);
|
|
144
|
+
}
|
|
145
|
+
if (s.start)
|
|
146
|
+
scan.sessions.push(s);
|
|
147
|
+
}
|
|
148
|
+
scan.starts.sort((a, b) => (a.timestamp ?? '').localeCompare(b.timestamp ?? ''));
|
|
149
|
+
scan.versions = [...versions].sort();
|
|
150
|
+
return scan;
|
|
151
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export type TomlValue = string | number | boolean | TomlValue[] | null;
|
|
2
|
+
export interface TomlTable {
|
|
3
|
+
path: string[];
|
|
4
|
+
values: Record<string, TomlValue>;
|
|
5
|
+
line: number;
|
|
6
|
+
}
|
|
7
|
+
/** Splits a header like `plugins."pdf@openai"` or `mcp_servers.node_repl.env` into its keys. */
|
|
8
|
+
export declare function headerKeys(header: string): string[];
|
|
9
|
+
/** Every table in the file, in order, with its single-line `key = value` pairs. Multi-line values are skipped. */
|
|
10
|
+
export declare function readToml(text: string): TomlTable[];
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// A small reader for the parts of Codex's config.toml packlight needs: table headers and simple values.
|
|
2
|
+
// It never writes TOML; turning a plugin or MCP server off is a step the person takes (removal method "manual").
|
|
3
|
+
/** Splits a header like `plugins."pdf@openai"` or `mcp_servers.node_repl.env` into its keys. */
|
|
4
|
+
export function headerKeys(header) {
|
|
5
|
+
const keys = [];
|
|
6
|
+
let i = 0;
|
|
7
|
+
while (i < header.length) {
|
|
8
|
+
const c = header[i];
|
|
9
|
+
if (c === '.' || c === ' ' || c === '\t') {
|
|
10
|
+
i++;
|
|
11
|
+
continue;
|
|
12
|
+
}
|
|
13
|
+
if (c === '"' || c === "'") {
|
|
14
|
+
let j = i + 1;
|
|
15
|
+
let key = '';
|
|
16
|
+
while (j < header.length && header[j] !== c) {
|
|
17
|
+
if (c === '"' && header[j] === '\\' && j + 1 < header.length) {
|
|
18
|
+
key += header[j + 1];
|
|
19
|
+
j += 2;
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
key += header[j];
|
|
23
|
+
j++;
|
|
24
|
+
}
|
|
25
|
+
keys.push(key);
|
|
26
|
+
i = j + 1;
|
|
27
|
+
}
|
|
28
|
+
else {
|
|
29
|
+
let j = i;
|
|
30
|
+
while (j < header.length && header[j] !== '.')
|
|
31
|
+
j++;
|
|
32
|
+
keys.push(header.slice(i, j).trim());
|
|
33
|
+
i = j;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return keys;
|
|
37
|
+
}
|
|
38
|
+
function value(raw) {
|
|
39
|
+
const v = raw.trim();
|
|
40
|
+
if (v === 'true')
|
|
41
|
+
return true;
|
|
42
|
+
if (v === 'false')
|
|
43
|
+
return false;
|
|
44
|
+
if (/^-?\d+(\.\d+)?$/.test(v))
|
|
45
|
+
return Number(v);
|
|
46
|
+
if (v.startsWith('"') && v.endsWith('"') && v.length >= 2)
|
|
47
|
+
return v.slice(1, -1).replace(/\\"/g, '"').replace(/\\\\/g, '\\');
|
|
48
|
+
if (v.startsWith("'") && v.endsWith("'") && v.length >= 2)
|
|
49
|
+
return v.slice(1, -1);
|
|
50
|
+
if (v.startsWith('[') && v.endsWith(']')) {
|
|
51
|
+
const inner = v.slice(1, -1).trim();
|
|
52
|
+
return inner ? inner.split(/,(?=(?:[^"]*"[^"]*")*[^"]*$)/).map(x => value(x)) : [];
|
|
53
|
+
}
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
/** Every table in the file, in order, with its single-line `key = value` pairs. Multi-line values are skipped. */
|
|
57
|
+
export function readToml(text) {
|
|
58
|
+
const tables = [{ path: [], values: {}, line: 0 }];
|
|
59
|
+
const lines = text.split(/\r?\n/);
|
|
60
|
+
lines.forEach((line, n) => {
|
|
61
|
+
const t = line.replace(/\s+#.*$/, '').trim();
|
|
62
|
+
if (!t || t.startsWith('#'))
|
|
63
|
+
return;
|
|
64
|
+
const h = /^\[\s*([^\[\]]+?)\s*\]$/.exec(t);
|
|
65
|
+
if (h) {
|
|
66
|
+
tables.push({ path: headerKeys(h[1]), values: {}, line: n + 1 });
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
const kv = /^("[^"]+"|'[^']+'|[A-Za-z0-9_.-]+)\s*=\s*(.+)$/.exec(t);
|
|
70
|
+
if (!kv)
|
|
71
|
+
return;
|
|
72
|
+
const key = kv[1].replace(/^["']|["']$/g, '');
|
|
73
|
+
tables[tables.length - 1].values[key] = value(kv[2]);
|
|
74
|
+
});
|
|
75
|
+
return tables;
|
|
76
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { Item, RemovalMethod } from '../core/types.js';
|
|
2
|
+
import { type RecoveryResult } from './journal.js';
|
|
3
|
+
import { type PacklightPaths } from './paths.js';
|
|
4
|
+
export type PickAction = 'archive' | 'keep' | 'unkeep';
|
|
5
|
+
export interface Picks {
|
|
6
|
+
scanId: string;
|
|
7
|
+
picks: {
|
|
8
|
+
id: string;
|
|
9
|
+
action: PickAction;
|
|
10
|
+
}[];
|
|
11
|
+
}
|
|
12
|
+
export declare const STALE_PICKS = "These picks are from an older scan. Re-open the newest report and save again.";
|
|
13
|
+
/**
|
|
14
|
+
* A picks file is untrusted input (CEO F3): only a scan id and item ids with an action are read.
|
|
15
|
+
* Paths, commands or anything else in the file are ignored.
|
|
16
|
+
*/
|
|
17
|
+
export declare function parsePicks(text: string): Picks;
|
|
18
|
+
/** The newest packlight-picks-*.json in a folder (the report saves there). */
|
|
19
|
+
export declare function findPicksFile(dir: string | undefined): string | undefined;
|
|
20
|
+
/** Claude Code may rewrite settings.json while packlight edits it (D21): warn when it is running. */
|
|
21
|
+
export declare function claudeCodeRunning(): boolean;
|
|
22
|
+
export interface ApplyOptions {
|
|
23
|
+
home: string;
|
|
24
|
+
packlightRoot?: string;
|
|
25
|
+
picksFile: string;
|
|
26
|
+
yes: boolean;
|
|
27
|
+
confirm: (question: string) => Promise<boolean>;
|
|
28
|
+
claudeRunning: () => boolean;
|
|
29
|
+
out: (line: string) => void;
|
|
30
|
+
now?: () => Date;
|
|
31
|
+
crash?: (point: string) => void;
|
|
32
|
+
/** Ask this one question for the whole plan instead of one per method group (packlight fix). */
|
|
33
|
+
singleQuestion?: string;
|
|
34
|
+
}
|
|
35
|
+
export interface ApplyResult {
|
|
36
|
+
exitCode: number;
|
|
37
|
+
archived: {
|
|
38
|
+
item: Item;
|
|
39
|
+
opIds: string[];
|
|
40
|
+
}[];
|
|
41
|
+
refused: {
|
|
42
|
+
item: Item;
|
|
43
|
+
reason: string;
|
|
44
|
+
}[];
|
|
45
|
+
unknownIds: string[];
|
|
46
|
+
declined: RemovalMethod[];
|
|
47
|
+
stillEffective: Item[];
|
|
48
|
+
kept: number;
|
|
49
|
+
unkept: number;
|
|
50
|
+
}
|
|
51
|
+
export declare function reportRecovery(r: RecoveryResult, out: (l: string) => void): void;
|
|
52
|
+
export declare function apply(opts: ApplyOptions): Promise<ApplyResult>;
|
|
53
|
+
interface KeepsFile {
|
|
54
|
+
version: 1;
|
|
55
|
+
keeps: Record<string, {
|
|
56
|
+
name: string;
|
|
57
|
+
keptAt: string;
|
|
58
|
+
fingerprintAtKeep: string;
|
|
59
|
+
}>;
|
|
60
|
+
}
|
|
61
|
+
export declare function readKeeps(paths: PacklightPaths): KeepsFile;
|
|
62
|
+
export {};
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { claudePaths, collectInventory } from '../adapters/claude-code/inventory.js';
|
|
5
|
+
import { codexPaths, collectCodexInventory } from '../adapters/codex/inventory.js';
|
|
6
|
+
import { INVOKE } from '../core/messages.js';
|
|
7
|
+
import { atomicWrite, fileHash } from './fsops.js';
|
|
8
|
+
import { recover } from './journal.js';
|
|
9
|
+
import { acquireLock } from './lock.js';
|
|
10
|
+
import { archiveStep, comparePointersDesc } from './methods.js';
|
|
11
|
+
import { newestScan, packlightPaths, readScan } from './paths.js';
|
|
12
|
+
export const STALE_PICKS = 'These picks are from an older scan. Re-open the newest report and save again.';
|
|
13
|
+
/**
|
|
14
|
+
* A picks file is untrusted input (CEO F3): only a scan id and item ids with an action are read.
|
|
15
|
+
* Paths, commands or anything else in the file are ignored.
|
|
16
|
+
*/
|
|
17
|
+
export function parsePicks(text) {
|
|
18
|
+
let raw;
|
|
19
|
+
try {
|
|
20
|
+
raw = JSON.parse(text);
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
throw new Error('The picks file is not valid JSON.');
|
|
24
|
+
}
|
|
25
|
+
if (!raw || typeof raw.scanId !== 'string' || !Array.isArray(raw.picks))
|
|
26
|
+
throw new Error('The picks file has no scanId or picks list.');
|
|
27
|
+
const picks = raw.picks
|
|
28
|
+
.filter((p) => p && typeof p.id === 'string' && ['archive', 'keep', 'unkeep'].includes(p.action))
|
|
29
|
+
.map((p) => ({ id: p.id, action: p.action }));
|
|
30
|
+
return { scanId: raw.scanId, picks };
|
|
31
|
+
}
|
|
32
|
+
/** The newest packlight-picks-*.json in a folder (the report saves there). */
|
|
33
|
+
export function findPicksFile(dir) {
|
|
34
|
+
if (!dir)
|
|
35
|
+
return undefined;
|
|
36
|
+
let names;
|
|
37
|
+
try {
|
|
38
|
+
names = readdirSync(dir).filter(n => /^packlight-picks-.*\.json$/.test(n));
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return undefined;
|
|
42
|
+
}
|
|
43
|
+
return names.map(n => join(dir, n)).sort((a, b) => statSync(b).mtimeMs - statSync(a).mtimeMs)[0];
|
|
44
|
+
}
|
|
45
|
+
/** Claude Code may rewrite settings.json while packlight edits it (D21): warn when it is running. */
|
|
46
|
+
export function claudeCodeRunning() {
|
|
47
|
+
try {
|
|
48
|
+
const out = process.platform === 'win32'
|
|
49
|
+
? execFileSync('tasklist', ['/FO', 'CSV', '/NH'], { encoding: 'utf8' })
|
|
50
|
+
: execFileSync('ps', ['-A', '-o', 'comm='], { encoding: 'utf8' });
|
|
51
|
+
return /(^|[\\/"])claude(\.exe)?("|\s|$)/im.test(out);
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
const KIND_PLURAL = { skill: 'skills', command: 'commands', agent: 'agents', hook: 'hooks', plugin: 'plugins', mcp: 'MCP servers', instructions: 'instruction files' };
|
|
58
|
+
const GROUP_TEXT = {
|
|
59
|
+
move: n => `Move ${n} item${n === 1 ? '' : 's'} into packlight's archive`,
|
|
60
|
+
'hook-extract': n => `Cut ${n} hook${n === 1 ? '' : 's'} out of their settings files`,
|
|
61
|
+
'mcp-extract': n => `Cut ${n} MCP server${n === 1 ? '' : 's'} out of their config files`,
|
|
62
|
+
'plugin-disable': n => `Turn off ${n} plugin${n === 1 ? '' : 's'}`,
|
|
63
|
+
manual: n => `Record ${n} item${n === 1 ? '' : 's'} you remove by hand`,
|
|
64
|
+
};
|
|
65
|
+
export function reportRecovery(r, out) {
|
|
66
|
+
for (const x of r.completed)
|
|
67
|
+
out(`Recovered: finished an interrupted ${x}.`);
|
|
68
|
+
for (const x of r.rolledBack)
|
|
69
|
+
out(`Recovered: rolled back an interrupted ${x}.`);
|
|
70
|
+
for (const x of r.refused)
|
|
71
|
+
out(`Recovery stopped: ${x.direction} ${x.opId} left ${x.files.join(', ')} in a state packlight did not expect. Nothing was changed; check those files by hand.`);
|
|
72
|
+
}
|
|
73
|
+
const existingRoots = (inv) => inv.projects.filter(p => existsSync(p.root)).map(p => p.root);
|
|
74
|
+
/** The setup as it is now, read by the same adapter that made the scan. */
|
|
75
|
+
const readNow = (inv, home) => (inv.agent === 'codex' ? collectCodexInventory(codexPaths(home), existingRoots(inv)) : collectInventory(claudePaths(home), existingRoots(inv)));
|
|
76
|
+
/** Builds the steps for one item from its current declarations (eng X2: every place it is switched on). */
|
|
77
|
+
function stepsFor(item, home) {
|
|
78
|
+
switch (item.removal.method) {
|
|
79
|
+
case 'move': return item.path ? [{ method: 'move', item, path: item.path }] : [];
|
|
80
|
+
case 'hook-extract':
|
|
81
|
+
case 'mcp-extract':
|
|
82
|
+
return item.declarations.filter(d => d.pointer).map(d => ({ method: item.removal.method, item, file: d.file, pointer: d.pointer }));
|
|
83
|
+
case 'plugin-disable': {
|
|
84
|
+
const key = item.plugin ?? item.name;
|
|
85
|
+
const on = item.declarations.filter(d => d.pointer?.startsWith('/enabledPlugins/') && d.enabled !== false);
|
|
86
|
+
if (on.length)
|
|
87
|
+
return on.map(d => ({ method: 'plugin-disable', item, file: d.file, pointer: d.pointer, key }));
|
|
88
|
+
// Installed but never declared means enabled: turn it off in user settings.
|
|
89
|
+
return item.enabled ? [{ method: 'plugin-disable', item, file: join(claudePaths(home).claude, 'settings.json'), pointer: `/enabledPlugins/${key.replace(/~/g, '~0').replace(/\//g, '~1')}`, key }] : [];
|
|
90
|
+
}
|
|
91
|
+
case 'manual': return [{ method: 'manual', item, where: item.removal.where ?? 'the app it came from' }];
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
export async function apply(opts) {
|
|
95
|
+
const paths = packlightPaths(opts.home, opts.packlightRoot);
|
|
96
|
+
const now = opts.now ?? (() => new Date());
|
|
97
|
+
const out = opts.out;
|
|
98
|
+
const result = { exitCode: 0, archived: [], refused: [], unknownIds: [], declined: [], stillEffective: [], kept: 0, unkept: 0 };
|
|
99
|
+
// Recovery and every change happen under the lock, so one run never rolls back another's work in progress.
|
|
100
|
+
const release = acquireLock(paths.lock);
|
|
101
|
+
try {
|
|
102
|
+
return await applyLocked(opts, paths, now, out, result);
|
|
103
|
+
}
|
|
104
|
+
finally {
|
|
105
|
+
release();
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
async function applyLocked(opts, paths, now, out, result) {
|
|
109
|
+
const recovery = recover(paths);
|
|
110
|
+
reportRecovery(recovery, out);
|
|
111
|
+
if (recovery.refused.length)
|
|
112
|
+
return { ...result, exitCode: 1 };
|
|
113
|
+
const picks = parsePicks(readFileSync(opts.picksFile, 'utf8'));
|
|
114
|
+
const inv = readScan(paths, picks.scanId);
|
|
115
|
+
if (!inv)
|
|
116
|
+
throw new Error(`These picks name scan ${picks.scanId}, which packlight does not have. Run \`${INVOKE}\` and save your picks again.`);
|
|
117
|
+
if (newestScan(paths, inv.agent, inv.projectScope)?.scanId !== picks.scanId)
|
|
118
|
+
throw new Error(STALE_PICKS);
|
|
119
|
+
const byId = new Map(inv.items.map(i => [i.id, i]));
|
|
120
|
+
result.unknownIds = picks.picks.filter(p => !byId.has(p.id)).map(p => p.id);
|
|
121
|
+
for (const id of result.unknownIds)
|
|
122
|
+
out(`Skipped ${id}: not an item in scan ${picks.scanId}.`);
|
|
123
|
+
// The current state of every item, read fresh: a target that changed since the scan is refused (eng X1).
|
|
124
|
+
const fresh = new Map(readNow(inv, opts.home).map(i => [i.id, i]));
|
|
125
|
+
// Baseline hashes are taken now, with the inventory the plan is built from, not after the questions (CEO F2).
|
|
126
|
+
const baseline = new Map();
|
|
127
|
+
for (const i of fresh.values())
|
|
128
|
+
for (const d of i.declarations)
|
|
129
|
+
if (d.pointer && !baseline.has(d.file))
|
|
130
|
+
baseline.set(d.file, fileHash(d.file));
|
|
131
|
+
baseline.set(join(claudePaths(opts.home).claude, 'settings.json'), fileHash(join(claudePaths(opts.home).claude, 'settings.json')));
|
|
132
|
+
// Plugins are the removal unit for everything they bundle (CEO O3).
|
|
133
|
+
const pluginOf = (i) => (i.plugin && i.kind !== 'plugin' ? inv.items.find(p => p.kind === 'plugin' && p.plugin === i.plugin) ?? i : i);
|
|
134
|
+
const targets = new Map();
|
|
135
|
+
for (const p of picks.picks.filter(p => p.action === 'archive' && byId.has(p.id))) {
|
|
136
|
+
const t = pluginOf(byId.get(p.id));
|
|
137
|
+
targets.set(t.id, t);
|
|
138
|
+
}
|
|
139
|
+
const plan = [];
|
|
140
|
+
for (const t of targets.values()) {
|
|
141
|
+
const cur = fresh.get(t.id);
|
|
142
|
+
if (!cur) {
|
|
143
|
+
result.refused.push({ item: t, reason: 'It is no longer installed (already archived or removed).' });
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
if (cur.targetFingerprint !== t.targetFingerprint) {
|
|
147
|
+
result.refused.push({ item: t, reason: 'It changed since you reviewed it. Scan again and re-mark it.' });
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
const steps = stepsFor(cur, opts.home);
|
|
151
|
+
if (!steps.length) {
|
|
152
|
+
result.refused.push({ item: t, reason: 'It is already turned off everywhere.' });
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
plan.push({ item: cur, steps });
|
|
156
|
+
}
|
|
157
|
+
const keeps = picks.picks.filter(p => p.action === 'keep' && byId.has(p.id));
|
|
158
|
+
const unkeeps = picks.picks.filter(p => p.action === 'unkeep' && byId.has(p.id));
|
|
159
|
+
// Show the plan grouped by method.
|
|
160
|
+
const groups = new Map();
|
|
161
|
+
for (const p of plan) {
|
|
162
|
+
const m = p.item.removal.method;
|
|
163
|
+
groups.set(m, [...(groups.get(m) ?? []), p]);
|
|
164
|
+
}
|
|
165
|
+
out(`Plan for scan ${picks.scanId}:`);
|
|
166
|
+
for (const [method, list] of groups) {
|
|
167
|
+
out(`\n${GROUP_TEXT[method](list.length)}:`);
|
|
168
|
+
for (const { item, steps } of list) {
|
|
169
|
+
const impact = item.kind === 'plugin' && item.members && Object.keys(item.members).length ? ` (${Object.entries(item.members).map(([k, n]) => `${n} ${n === 1 ? k : KIND_PLURAL[k]}`).join(', ')})` : '';
|
|
170
|
+
const where = steps.map(s => ('file' in s ? s.file : 'path' in s ? s.path : s.where)).join('; ');
|
|
171
|
+
out(` ${item.name}${impact} ${where}`);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
if (keeps.length || unkeeps.length)
|
|
175
|
+
out(`\nKeep: ${keeps.length} item${keeps.length === 1 ? '' : 's'}${unkeeps.length ? `, clear ${unkeeps.length} keep${unkeeps.length === 1 ? '' : 's'}` : ''} (no change to your setup)`);
|
|
176
|
+
for (const r of result.refused)
|
|
177
|
+
out(`\nNot archived: ${r.item.name}. ${r.reason}`);
|
|
178
|
+
const touchesSettings = plan.some(p => p.steps.some(s => 'file' in s));
|
|
179
|
+
if (touchesSettings && opts.claudeRunning()) {
|
|
180
|
+
out(`\n${inv.agent === 'codex' ? 'Codex' : 'Claude Code'} is running. It can rewrite its settings files while packlight edits them; packlight checks each file just before writing, but a write in the same instant could still be lost.`);
|
|
181
|
+
if (!opts.yes && !opts.singleQuestion && !(await opts.confirm('Continue anyway?'))) {
|
|
182
|
+
out('Stopped. Nothing was changed.');
|
|
183
|
+
return { ...result, exitCode: 1 };
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
if (opts.singleQuestion && plan.length && !opts.yes && !(await opts.confirm(`\n${opts.singleQuestion}`))) {
|
|
187
|
+
out('Stopped. Nothing was changed.');
|
|
188
|
+
return { ...result, exitCode: 1 };
|
|
189
|
+
}
|
|
190
|
+
// One question per method group (acceptance criterion 7); "no" leaves that group untouched.
|
|
191
|
+
const approved = [];
|
|
192
|
+
for (const [method, list] of groups) {
|
|
193
|
+
if (opts.yes || opts.singleQuestion || (await opts.confirm(`${GROUP_TEXT[method](list.length)}?`)))
|
|
194
|
+
approved.push(...list);
|
|
195
|
+
else
|
|
196
|
+
result.declined.push(method);
|
|
197
|
+
}
|
|
198
|
+
const refusedBeforeRun = result.refused.length;
|
|
199
|
+
{
|
|
200
|
+
const ctx = { paths, scanId: picks.scanId, now, expectedHash: new Map(), crash: opts.crash ?? (() => { }) };
|
|
201
|
+
const steps = approved.flatMap(p => p.steps);
|
|
202
|
+
for (const s of steps)
|
|
203
|
+
if ('file' in s && !ctx.expectedHash.has(s.file))
|
|
204
|
+
ctx.expectedHash.set(s.file, baseline.has(s.file) ? baseline.get(s.file) : fileHash(s.file));
|
|
205
|
+
// Settings edits first, per file with the deepest pointers first so array removals never shift later ones.
|
|
206
|
+
const ordered = [
|
|
207
|
+
...steps.filter((s) => 'file' in s).sort((a, b) => a.file.localeCompare(b.file) || comparePointersDesc(a.pointer, b.pointer)),
|
|
208
|
+
...steps.filter(s => s.method === 'move'),
|
|
209
|
+
...steps.filter(s => s.method === 'manual'),
|
|
210
|
+
];
|
|
211
|
+
const outcomes = new Map();
|
|
212
|
+
for (const s of ordered) {
|
|
213
|
+
const o = archiveStep(ctx, s);
|
|
214
|
+
const rec = outcomes.get(s.item.id) ?? { item: s.item, opIds: [], failures: [] };
|
|
215
|
+
if (o.ok)
|
|
216
|
+
rec.opIds.push(o.opId);
|
|
217
|
+
else
|
|
218
|
+
rec.failures.push(o.reason);
|
|
219
|
+
outcomes.set(s.item.id, rec);
|
|
220
|
+
log(paths, `${now().toISOString()} archive ${s.method} ${s.item.id} ${o.ok ? `ok ${o.opId}` : `refused: ${o.reason}`}`);
|
|
221
|
+
}
|
|
222
|
+
for (const r of outcomes.values()) {
|
|
223
|
+
if (r.opIds.length)
|
|
224
|
+
result.archived.push({ item: r.item, opIds: r.opIds });
|
|
225
|
+
for (const f of r.failures)
|
|
226
|
+
result.refused.push({ item: r.item, reason: f });
|
|
227
|
+
}
|
|
228
|
+
const k = writeKeeps(paths, keeps.map(p => byId.get(p.id)), unkeeps.map(p => p.id), now());
|
|
229
|
+
result.kept = k.kept;
|
|
230
|
+
result.unkept = k.unkept;
|
|
231
|
+
}
|
|
232
|
+
// Verify against a fresh read that each archived item is no longer in effect (eng X2).
|
|
233
|
+
const after = new Map(readNow(inv, opts.home).map(i => [i.id, i]));
|
|
234
|
+
for (const { item } of result.archived) {
|
|
235
|
+
const a = after.get(item.id);
|
|
236
|
+
if (!a || item.removal.method === 'manual')
|
|
237
|
+
continue;
|
|
238
|
+
const stillOn = item.removal.method === 'plugin-disable' ? a.enabled || a.declarations.some(d => d.pointer?.startsWith('/enabledPlugins/') && d.enabled !== false) : true;
|
|
239
|
+
if (stillOn)
|
|
240
|
+
result.stillEffective.push(item);
|
|
241
|
+
}
|
|
242
|
+
out('');
|
|
243
|
+
for (const a of result.archived)
|
|
244
|
+
out(`Archived ${a.item.name}${a.item.removal.method === 'manual' ? ' (remove it by hand: ' + (a.item.removal.where ?? '') + ')' : ''}. Restore: ${INVOKE} restore ${a.opIds.join(' ')}`);
|
|
245
|
+
for (const r of result.refused.slice(refusedBeforeRun))
|
|
246
|
+
out(`Refused ${r.item.name}: ${r.reason}`);
|
|
247
|
+
for (const s of result.stillEffective)
|
|
248
|
+
out(`Still in effect after apply: ${s.name}. Another settings file may switch it on; scan to see where.`);
|
|
249
|
+
if (result.kept || result.unkept)
|
|
250
|
+
out(`Kept ${result.kept} item${result.kept === 1 ? '' : 's'}${result.unkept ? `, cleared ${result.unkept} keep${result.unkept === 1 ? '' : 's'}` : ''}.${result.archived.length ? '' : ' Nothing archived.'}`);
|
|
251
|
+
if (result.declined.length)
|
|
252
|
+
out(`Skipped at your answer: ${result.declined.map(m => GROUP_TEXT[m](groups.get(m).length).toLowerCase()).join('; ')}.`);
|
|
253
|
+
result.exitCode = result.refused.length || result.stillEffective.length ? 1 : 0;
|
|
254
|
+
return result;
|
|
255
|
+
}
|
|
256
|
+
function log(paths, line) {
|
|
257
|
+
mkdirSync(paths.root, { recursive: true });
|
|
258
|
+
appendFileSync(paths.log, line + '\n');
|
|
259
|
+
}
|
|
260
|
+
export function readKeeps(paths) {
|
|
261
|
+
try {
|
|
262
|
+
return JSON.parse(readFileSync(paths.keeps, 'utf8'));
|
|
263
|
+
}
|
|
264
|
+
catch {
|
|
265
|
+
return { version: 1, keeps: {} };
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
/** Keeps are written without a prompt (DE3), keyed by item id with the fingerprint at keep time (DE2), and cleared by unkeep (DE10). */
|
|
269
|
+
function writeKeeps(paths, keep, unkeep, now) {
|
|
270
|
+
if (!keep.length && !unkeep.length)
|
|
271
|
+
return { kept: 0, unkept: 0 };
|
|
272
|
+
const f = readKeeps(paths);
|
|
273
|
+
for (const i of keep)
|
|
274
|
+
f.keeps[i.id] = { name: i.name, keptAt: now.toISOString(), fingerprintAtKeep: i.targetFingerprint };
|
|
275
|
+
let unkept = 0;
|
|
276
|
+
for (const id of unkeep)
|
|
277
|
+
if (id in f.keeps) {
|
|
278
|
+
delete f.keeps[id];
|
|
279
|
+
unkept++;
|
|
280
|
+
}
|
|
281
|
+
atomicWrite(paths.keeps, JSON.stringify(f, null, 1) + '\n');
|
|
282
|
+
log(paths, `${now.toISOString()} keeps +${keep.length} -${unkept}`);
|
|
283
|
+
return { kept: keep.length, unkept };
|
|
284
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { renameSync } from 'node:fs';
|
|
2
|
+
/** rename with a bounded retry: Windows reports EPERM/EBUSY while another process briefly holds a file (eng SC1). */
|
|
3
|
+
export declare function renameWithRetry(from: string, to: string, rename?: typeof renameSync): void;
|
|
4
|
+
/** Settings writes go through write-file-atomic, which keeps the file's mode and owner (eng SC2). */
|
|
5
|
+
export declare function atomicWrite(path: string, data: string | Buffer): void;
|
|
6
|
+
export declare const fileHash: (path: string) => string | null;
|
|
7
|
+
export declare const exists: (path: string) => boolean;
|
|
8
|
+
/** Fingerprint of a path, or "missing" (journal states compare these). */
|
|
9
|
+
export declare const stateOf: (path: string) => string;
|
|
10
|
+
export declare class ChangedWhileMoving extends Error {
|
|
11
|
+
}
|
|
12
|
+
/** The verified copy is in place but the source could not be fully removed: both must be kept. */
|
|
13
|
+
export declare class SourceNotRemoved extends Error {
|
|
14
|
+
}
|
|
15
|
+
/** Where a cross-volume move stages its copy: hidden, beside the destination, and recorded in the journal as scratch. */
|
|
16
|
+
export declare const stagingPathFor: (to: string) => string;
|
|
17
|
+
/**
|
|
18
|
+
* Moves a file, folder or link. Same volume: one rename. Across volumes (EXDEV): copy into a staging path
|
|
19
|
+
* beside the destination, check the copy against the source's fingerprint, re-check the source, then swap
|
|
20
|
+
* the staging copy in and remove the source (eng X4). A link is moved as a link, never followed.
|
|
21
|
+
*/
|
|
22
|
+
export declare function moveTree(from: string, to: string, opts?: {
|
|
23
|
+
rename?: typeof renameSync;
|
|
24
|
+
}): void;
|