z-clean 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/LICENSE +21 -0
- package/README.md +186 -0
- package/bin/zclean.js +272 -0
- package/package.json +12 -0
- package/src/config.js +148 -0
- package/src/detector/orphan.js +149 -0
- package/src/detector/patterns.js +148 -0
- package/src/detector/whitelist.js +178 -0
- package/src/installer/hook.js +136 -0
- package/src/installer/launchd.js +166 -0
- package/src/installer/systemd.js +162 -0
- package/src/installer/taskscheduler.js +102 -0
- package/src/killer.js +248 -0
- package/src/reporter.js +220 -0
- package/src/scanner.js +296 -0
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { execSync } = require('child_process');
|
|
4
|
+
const os = require('os');
|
|
5
|
+
|
|
6
|
+
const platform = os.platform();
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Check if a PID is an orphan process.
|
|
10
|
+
*
|
|
11
|
+
* Orphan definition by platform:
|
|
12
|
+
* macOS: PPID === 1 (launchd)
|
|
13
|
+
* Linux: PPID === 1 OR PPID === systemd --user PID
|
|
14
|
+
* Windows: parent process no longer exists
|
|
15
|
+
*
|
|
16
|
+
* Returns { isOrphan: boolean, ppid: number|null, reason: string }
|
|
17
|
+
*/
|
|
18
|
+
function checkOrphan(pid) {
|
|
19
|
+
try {
|
|
20
|
+
if (platform === 'win32') {
|
|
21
|
+
return checkOrphanWindows(pid);
|
|
22
|
+
} else {
|
|
23
|
+
return checkOrphanUnix(pid);
|
|
24
|
+
}
|
|
25
|
+
} catch {
|
|
26
|
+
// Process may have disappeared during check
|
|
27
|
+
return { isOrphan: false, ppid: null, reason: 'check-failed' };
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Unix (macOS/Linux) orphan check.
|
|
33
|
+
*/
|
|
34
|
+
function checkOrphanUnix(pid) {
|
|
35
|
+
let ppidStr;
|
|
36
|
+
try {
|
|
37
|
+
ppidStr = execSync(`ps -o ppid= -p ${pid}`, { encoding: 'utf-8', timeout: 5000 }).trim();
|
|
38
|
+
} catch {
|
|
39
|
+
return { isOrphan: false, ppid: null, reason: 'process-gone' };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const ppid = parseInt(ppidStr, 10);
|
|
43
|
+
if (isNaN(ppid)) {
|
|
44
|
+
return { isOrphan: false, ppid: null, reason: 'invalid-ppid' };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// macOS: PPID 1 means reparented to launchd
|
|
48
|
+
if (platform === 'darwin' && ppid === 1) {
|
|
49
|
+
return { isOrphan: true, ppid, reason: 'reparented-to-launchd' };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Linux: PPID 1 means reparented to init/systemd
|
|
53
|
+
if (platform === 'linux' && ppid === 1) {
|
|
54
|
+
return { isOrphan: true, ppid, reason: 'reparented-to-init' };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Linux: also check if reparented to systemd --user
|
|
58
|
+
if (platform === 'linux' && ppid > 1) {
|
|
59
|
+
try {
|
|
60
|
+
const parentCmd = execSync(`ps -o comm= -p ${ppid}`, { encoding: 'utf-8', timeout: 5000 }).trim();
|
|
61
|
+
if (parentCmd === 'systemd') {
|
|
62
|
+
return { isOrphan: true, ppid, reason: 'reparented-to-systemd-user' };
|
|
63
|
+
}
|
|
64
|
+
} catch {
|
|
65
|
+
// Parent might have died between checks
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return { isOrphan: false, ppid, reason: 'has-parent' };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Windows orphan check — parent process doesn't exist.
|
|
74
|
+
*/
|
|
75
|
+
function checkOrphanWindows(pid) {
|
|
76
|
+
try {
|
|
77
|
+
// Get parent PID via wmic
|
|
78
|
+
const output = execSync(
|
|
79
|
+
`wmic process where ProcessId=${pid} get ParentProcessId /value`,
|
|
80
|
+
{ encoding: 'utf-8', timeout: 5000 }
|
|
81
|
+
).trim();
|
|
82
|
+
|
|
83
|
+
const match = output.match(/ParentProcessId=(\d+)/);
|
|
84
|
+
if (!match) {
|
|
85
|
+
return { isOrphan: false, ppid: null, reason: 'no-ppid-info' };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const ppid = parseInt(match[1], 10);
|
|
89
|
+
|
|
90
|
+
// Check if parent process still exists
|
|
91
|
+
try {
|
|
92
|
+
execSync(`wmic process where ProcessId=${ppid} get ProcessId /value`, {
|
|
93
|
+
encoding: 'utf-8',
|
|
94
|
+
timeout: 5000,
|
|
95
|
+
});
|
|
96
|
+
return { isOrphan: false, ppid, reason: 'has-parent' };
|
|
97
|
+
} catch {
|
|
98
|
+
// Parent doesn't exist — orphan
|
|
99
|
+
return { isOrphan: true, ppid, reason: 'parent-gone' };
|
|
100
|
+
}
|
|
101
|
+
} catch {
|
|
102
|
+
return { isOrphan: false, ppid: null, reason: 'check-failed' };
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Check if a process is inside a tmux or screen session tree.
|
|
108
|
+
* Walks the process tree upward looking for tmux/screen ancestor.
|
|
109
|
+
*
|
|
110
|
+
* Returns true if the process has a tmux or screen ancestor.
|
|
111
|
+
*/
|
|
112
|
+
function isInTerminalMultiplexer(pid) {
|
|
113
|
+
if (platform === 'win32') return false;
|
|
114
|
+
|
|
115
|
+
const visited = new Set();
|
|
116
|
+
let currentPid = pid;
|
|
117
|
+
|
|
118
|
+
while (currentPid > 1 && !visited.has(currentPid)) {
|
|
119
|
+
visited.add(currentPid);
|
|
120
|
+
|
|
121
|
+
try {
|
|
122
|
+
const info = execSync(`ps -o ppid=,comm= -p ${currentPid}`, {
|
|
123
|
+
encoding: 'utf-8',
|
|
124
|
+
timeout: 5000,
|
|
125
|
+
}).trim();
|
|
126
|
+
|
|
127
|
+
// Parse " PPID COMMAND"
|
|
128
|
+
const parts = info.trim().split(/\s+/);
|
|
129
|
+
if (parts.length < 2) break;
|
|
130
|
+
|
|
131
|
+
const parentPid = parseInt(parts[0], 10);
|
|
132
|
+
const comm = parts.slice(1).join(' ');
|
|
133
|
+
|
|
134
|
+
// Check for tmux/screen
|
|
135
|
+
if (/^(tmux|screen)/.test(comm)) {
|
|
136
|
+
return true;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (isNaN(parentPid) || parentPid <= 1) break;
|
|
140
|
+
currentPid = parentPid;
|
|
141
|
+
} catch {
|
|
142
|
+
break;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return false;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
module.exports = { checkOrphan, isInTerminalMultiplexer };
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Process patterns to detect AI tool zombies.
|
|
5
|
+
*
|
|
6
|
+
* Each pattern has:
|
|
7
|
+
* name — human-readable identifier
|
|
8
|
+
* match — RegExp to test against full command line
|
|
9
|
+
* minAge — minimum age (ms) before considering it a zombie (0 = any age)
|
|
10
|
+
* maxOrphanAge — if set, orphan processes older than this are zombies (duration string)
|
|
11
|
+
* memThreshold — if set, only flag if RSS exceeds this (bytes string like "500MB")
|
|
12
|
+
* orphanOnly — if true, only flag orphaned processes (default: true for most)
|
|
13
|
+
*/
|
|
14
|
+
const PATTERNS = [
|
|
15
|
+
// MCP servers — any orphaned MCP server is suspect
|
|
16
|
+
{
|
|
17
|
+
name: 'mcp-server',
|
|
18
|
+
match: /mcp-server/,
|
|
19
|
+
minAge: 0,
|
|
20
|
+
maxOrphanAge: '1h',
|
|
21
|
+
orphanOnly: true,
|
|
22
|
+
},
|
|
23
|
+
|
|
24
|
+
// Headless browsers spawned by agents
|
|
25
|
+
{
|
|
26
|
+
name: 'agent-browser',
|
|
27
|
+
match: /agent-browser|chrome-headless-shell/,
|
|
28
|
+
minAge: 0,
|
|
29
|
+
orphanOnly: true,
|
|
30
|
+
},
|
|
31
|
+
|
|
32
|
+
// Playwright driver processes
|
|
33
|
+
{
|
|
34
|
+
name: 'playwright',
|
|
35
|
+
match: /playwright[/\\]driver/,
|
|
36
|
+
minAge: 0,
|
|
37
|
+
orphanOnly: true,
|
|
38
|
+
},
|
|
39
|
+
|
|
40
|
+
// Claude subagent processes
|
|
41
|
+
{
|
|
42
|
+
name: 'claude-subagent',
|
|
43
|
+
match: /claude\s+--print/,
|
|
44
|
+
minAge: 0,
|
|
45
|
+
orphanOnly: true,
|
|
46
|
+
},
|
|
47
|
+
|
|
48
|
+
// Codex exec processes
|
|
49
|
+
{
|
|
50
|
+
name: 'codex-exec',
|
|
51
|
+
match: /codex\s+exec/,
|
|
52
|
+
minAge: 0,
|
|
53
|
+
orphanOnly: true,
|
|
54
|
+
},
|
|
55
|
+
|
|
56
|
+
// Build tools — only if orphaned for 24h+
|
|
57
|
+
{
|
|
58
|
+
name: 'esbuild',
|
|
59
|
+
match: /esbuild/,
|
|
60
|
+
minAge: 0,
|
|
61
|
+
maxOrphanAge: '24h',
|
|
62
|
+
orphanOnly: true,
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
name: 'vite',
|
|
66
|
+
match: /vite/,
|
|
67
|
+
minAge: 0,
|
|
68
|
+
maxOrphanAge: '24h',
|
|
69
|
+
orphanOnly: true,
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
name: 'next-dev',
|
|
73
|
+
match: /next\s+dev/,
|
|
74
|
+
minAge: 0,
|
|
75
|
+
maxOrphanAge: '24h',
|
|
76
|
+
orphanOnly: true,
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
name: 'webpack',
|
|
80
|
+
match: /webpack/,
|
|
81
|
+
minAge: 0,
|
|
82
|
+
maxOrphanAge: '24h',
|
|
83
|
+
orphanOnly: true,
|
|
84
|
+
},
|
|
85
|
+
|
|
86
|
+
// npx/npm exec — orphaned
|
|
87
|
+
{
|
|
88
|
+
name: 'npm-exec',
|
|
89
|
+
match: /npm\s+exec|npx\s/,
|
|
90
|
+
minAge: 0,
|
|
91
|
+
orphanOnly: true,
|
|
92
|
+
},
|
|
93
|
+
|
|
94
|
+
// Node processes with AI tool paths — orphan + age/memory gated
|
|
95
|
+
{
|
|
96
|
+
name: 'node-ai-path',
|
|
97
|
+
match: /node\b.*(?:\.claude[/\\]|[/\\]mcp[/\\]|[/\\]agent[/\\])/,
|
|
98
|
+
minAge: 0,
|
|
99
|
+
maxOrphanAge: '24h',
|
|
100
|
+
memThreshold: '500MB',
|
|
101
|
+
orphanOnly: true,
|
|
102
|
+
},
|
|
103
|
+
|
|
104
|
+
// TypeScript runners — orphan + AI tool related
|
|
105
|
+
{
|
|
106
|
+
name: 'tsx',
|
|
107
|
+
match: /\btsx\b/,
|
|
108
|
+
minAge: 0,
|
|
109
|
+
maxOrphanAge: '24h',
|
|
110
|
+
orphanOnly: true,
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
name: 'ts-node',
|
|
114
|
+
match: /ts-node/,
|
|
115
|
+
minAge: 0,
|
|
116
|
+
maxOrphanAge: '24h',
|
|
117
|
+
orphanOnly: true,
|
|
118
|
+
},
|
|
119
|
+
{
|
|
120
|
+
name: 'bun',
|
|
121
|
+
match: /\bbun\b.*(?:\.claude|mcp|agent)/,
|
|
122
|
+
minAge: 0,
|
|
123
|
+
maxOrphanAge: '24h',
|
|
124
|
+
orphanOnly: true,
|
|
125
|
+
},
|
|
126
|
+
{
|
|
127
|
+
name: 'deno',
|
|
128
|
+
match: /\bdeno\b.*(?:\.claude|mcp|agent)/,
|
|
129
|
+
minAge: 0,
|
|
130
|
+
maxOrphanAge: '24h',
|
|
131
|
+
orphanOnly: true,
|
|
132
|
+
},
|
|
133
|
+
];
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Match a command line against known patterns.
|
|
137
|
+
* Returns the first matching pattern or null.
|
|
138
|
+
*/
|
|
139
|
+
function matchPattern(cmdline) {
|
|
140
|
+
for (const pattern of PATTERNS) {
|
|
141
|
+
if (pattern.match.test(cmdline)) {
|
|
142
|
+
return pattern;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
module.exports = { PATTERNS, matchPattern };
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { execSync } = require('child_process');
|
|
4
|
+
const os = require('os');
|
|
5
|
+
const fs = require('fs');
|
|
6
|
+
|
|
7
|
+
const platform = os.platform();
|
|
8
|
+
|
|
9
|
+
// Daemon managers that indicate intentionally long-running processes
|
|
10
|
+
const DAEMON_MANAGERS = ['pm2', 'forever', 'supervisord', 'supervisor', 'nodemon'];
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Check if a process should be protected from cleanup.
|
|
14
|
+
*
|
|
15
|
+
* Protection criteria:
|
|
16
|
+
* 1. In user's config whitelist (PID or name pattern)
|
|
17
|
+
* 2. Has a daemon manager ancestor (pm2, forever, supervisord)
|
|
18
|
+
* 3. Running inside a Docker container (Linux: different PID namespace)
|
|
19
|
+
* 4. Launched with nohup
|
|
20
|
+
* 5. VS Code child process (48h grace period)
|
|
21
|
+
*
|
|
22
|
+
* Returns { protected: boolean, reason: string }
|
|
23
|
+
*/
|
|
24
|
+
function isWhitelisted(proc, config) {
|
|
25
|
+
// 1. Config whitelist — match by PID or name substring
|
|
26
|
+
if (config.whitelist && config.whitelist.length > 0) {
|
|
27
|
+
for (const entry of config.whitelist) {
|
|
28
|
+
if (typeof entry === 'number' && proc.pid === entry) {
|
|
29
|
+
return { protected: true, reason: `config-whitelist-pid:${entry}` };
|
|
30
|
+
}
|
|
31
|
+
if (typeof entry === 'string' && proc.cmd.includes(entry)) {
|
|
32
|
+
return { protected: true, reason: `config-whitelist-pattern:${entry}` };
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// 2. Daemon manager ancestor
|
|
38
|
+
if (hasDaemonAncestor(proc.pid)) {
|
|
39
|
+
return { protected: true, reason: 'daemon-managed' };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// 3. Docker container (Linux only)
|
|
43
|
+
if (isInDocker(proc.pid)) {
|
|
44
|
+
return { protected: true, reason: 'docker-container' };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// 4. nohup-launched
|
|
48
|
+
if (isNohup(proc.cmd)) {
|
|
49
|
+
return { protected: true, reason: 'nohup-launched' };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// 5. VS Code child with grace period
|
|
53
|
+
const vscodeGrace = 48 * 60 * 60 * 1000; // 48 hours
|
|
54
|
+
if (isVSCodeChild(proc.pid) && proc.age < vscodeGrace) {
|
|
55
|
+
return { protected: true, reason: 'vscode-child-grace' };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return { protected: false, reason: '' };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Walk process tree upward looking for daemon manager ancestors.
|
|
63
|
+
*/
|
|
64
|
+
function hasDaemonAncestor(pid) {
|
|
65
|
+
if (platform === 'win32') {
|
|
66
|
+
// Simplified check for Windows — just check parent command
|
|
67
|
+
try {
|
|
68
|
+
const output = execSync(
|
|
69
|
+
`wmic process where ProcessId=${pid} get ParentProcessId /value`,
|
|
70
|
+
{ encoding: 'utf-8', timeout: 5000 }
|
|
71
|
+
).trim();
|
|
72
|
+
const match = output.match(/ParentProcessId=(\d+)/);
|
|
73
|
+
if (!match) return false;
|
|
74
|
+
const ppid = parseInt(match[1], 10);
|
|
75
|
+
const parentCmd = execSync(
|
|
76
|
+
`wmic process where ProcessId=${ppid} get CommandLine /value`,
|
|
77
|
+
{ encoding: 'utf-8', timeout: 5000 }
|
|
78
|
+
).trim();
|
|
79
|
+
return DAEMON_MANAGERS.some((dm) => parentCmd.toLowerCase().includes(dm));
|
|
80
|
+
} catch {
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Unix: walk up the tree
|
|
86
|
+
const visited = new Set();
|
|
87
|
+
let currentPid = pid;
|
|
88
|
+
|
|
89
|
+
while (currentPid > 1 && !visited.has(currentPid)) {
|
|
90
|
+
visited.add(currentPid);
|
|
91
|
+
try {
|
|
92
|
+
const info = execSync(`ps -o ppid=,comm= -p ${currentPid}`, {
|
|
93
|
+
encoding: 'utf-8',
|
|
94
|
+
timeout: 5000,
|
|
95
|
+
}).trim();
|
|
96
|
+
|
|
97
|
+
const parts = info.trim().split(/\s+/);
|
|
98
|
+
if (parts.length < 2) break;
|
|
99
|
+
|
|
100
|
+
const parentPid = parseInt(parts[0], 10);
|
|
101
|
+
const comm = parts.slice(1).join(' ').toLowerCase();
|
|
102
|
+
|
|
103
|
+
if (DAEMON_MANAGERS.some((dm) => comm.includes(dm))) {
|
|
104
|
+
return true;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (isNaN(parentPid) || parentPid <= 1) break;
|
|
108
|
+
currentPid = parentPid;
|
|
109
|
+
} catch {
|
|
110
|
+
break;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Check if a process is running inside a Docker container.
|
|
119
|
+
* Linux only: compares PID namespace with init (PID 1).
|
|
120
|
+
*/
|
|
121
|
+
function isInDocker(pid) {
|
|
122
|
+
if (platform !== 'linux') return false;
|
|
123
|
+
|
|
124
|
+
try {
|
|
125
|
+
const procNs = fs.readlinkSync(`/proc/${pid}/ns/pid`);
|
|
126
|
+
const initNs = fs.readlinkSync('/proc/1/ns/pid');
|
|
127
|
+
return procNs !== initNs;
|
|
128
|
+
} catch {
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Check if the command line suggests nohup launch.
|
|
135
|
+
*/
|
|
136
|
+
function isNohup(cmdline) {
|
|
137
|
+
return /\bnohup\b/.test(cmdline);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Check if a process is a child of VS Code.
|
|
142
|
+
*/
|
|
143
|
+
function isVSCodeChild(pid) {
|
|
144
|
+
if (platform === 'win32') return false;
|
|
145
|
+
|
|
146
|
+
const visited = new Set();
|
|
147
|
+
let currentPid = pid;
|
|
148
|
+
|
|
149
|
+
while (currentPid > 1 && !visited.has(currentPid)) {
|
|
150
|
+
visited.add(currentPid);
|
|
151
|
+
try {
|
|
152
|
+
const info = execSync(`ps -o ppid=,comm= -p ${currentPid}`, {
|
|
153
|
+
encoding: 'utf-8',
|
|
154
|
+
timeout: 5000,
|
|
155
|
+
}).trim();
|
|
156
|
+
|
|
157
|
+
const parts = info.trim().split(/\s+/);
|
|
158
|
+
if (parts.length < 2) break;
|
|
159
|
+
|
|
160
|
+
const parentPid = parseInt(parts[0], 10);
|
|
161
|
+
const comm = parts.slice(1).join(' ').toLowerCase();
|
|
162
|
+
|
|
163
|
+
// VS Code process names: code, code-insiders, electron (Code.app)
|
|
164
|
+
if (/\b(code|code-insiders|electron.*code)\b/.test(comm)) {
|
|
165
|
+
return true;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (isNaN(parentPid) || parentPid <= 1) break;
|
|
169
|
+
currentPid = parentPid;
|
|
170
|
+
} catch {
|
|
171
|
+
break;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return false;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
module.exports = { isWhitelisted, hasDaemonAncestor, isInDocker, isNohup, isVSCodeChild };
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const os = require('os');
|
|
6
|
+
|
|
7
|
+
const CLAUDE_SETTINGS_PATH = path.join(os.homedir(), '.claude', 'settings.json');
|
|
8
|
+
// Use npx to ensure zclean is found regardless of global install status
|
|
9
|
+
const HOOK_COMMAND = 'npx --yes @thestackai/zclean --yes --session-pid=$PPID';
|
|
10
|
+
const HOOK_ID = 'zclean-session-cleanup';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Install a Claude Code SessionEnd hook that runs zclean on session end.
|
|
14
|
+
*
|
|
15
|
+
* Claude Code hooks use a matcher + hooks array format:
|
|
16
|
+
* {
|
|
17
|
+
* "hooks": {
|
|
18
|
+
* "Stop": [
|
|
19
|
+
* {
|
|
20
|
+
* "matcher": "",
|
|
21
|
+
* "hooks": [
|
|
22
|
+
* { "type": "command", "command": "..." }
|
|
23
|
+
* ]
|
|
24
|
+
* }
|
|
25
|
+
* ]
|
|
26
|
+
* }
|
|
27
|
+
* }
|
|
28
|
+
*
|
|
29
|
+
* This is idempotent — won't duplicate if already registered.
|
|
30
|
+
*
|
|
31
|
+
* @returns {{ installed: boolean, message: string }}
|
|
32
|
+
*/
|
|
33
|
+
function installHook() {
|
|
34
|
+
// Check if Claude Code settings directory exists
|
|
35
|
+
const claudeDir = path.dirname(CLAUDE_SETTINGS_PATH);
|
|
36
|
+
if (!fs.existsSync(claudeDir)) {
|
|
37
|
+
return {
|
|
38
|
+
installed: false,
|
|
39
|
+
message: `Claude Code config directory not found: ${claudeDir}`,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Load or create settings
|
|
44
|
+
let settings = {};
|
|
45
|
+
if (fs.existsSync(CLAUDE_SETTINGS_PATH)) {
|
|
46
|
+
try {
|
|
47
|
+
settings = JSON.parse(fs.readFileSync(CLAUDE_SETTINGS_PATH, 'utf-8'));
|
|
48
|
+
} catch {
|
|
49
|
+
return {
|
|
50
|
+
installed: false,
|
|
51
|
+
message: `Failed to parse ${CLAUDE_SETTINGS_PATH}. Please fix the JSON and retry.`,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Ensure hooks structure
|
|
57
|
+
if (!settings.hooks) settings.hooks = {};
|
|
58
|
+
if (!Array.isArray(settings.hooks.Stop)) settings.hooks.Stop = [];
|
|
59
|
+
|
|
60
|
+
// Check if already installed (handle both old flat format and new matcher format)
|
|
61
|
+
const existing = settings.hooks.Stop.find(
|
|
62
|
+
(h) =>
|
|
63
|
+
h.id === HOOK_ID ||
|
|
64
|
+
(h.command && h.command.includes('zclean')) ||
|
|
65
|
+
(Array.isArray(h.hooks) && h.hooks.some((sub) => sub.command && sub.command.includes('zclean')))
|
|
66
|
+
);
|
|
67
|
+
if (existing) {
|
|
68
|
+
return {
|
|
69
|
+
installed: true,
|
|
70
|
+
message: 'Hook already registered in Claude Code settings.',
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Add hook using the matcher + hooks array format required by Claude Code
|
|
75
|
+
settings.hooks.Stop.push({
|
|
76
|
+
matcher: '',
|
|
77
|
+
hooks: [
|
|
78
|
+
{
|
|
79
|
+
type: 'command',
|
|
80
|
+
command: HOOK_COMMAND,
|
|
81
|
+
},
|
|
82
|
+
],
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
// Write back
|
|
86
|
+
fs.writeFileSync(CLAUDE_SETTINGS_PATH, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
installed: true,
|
|
90
|
+
message: `Hook registered: ${CLAUDE_SETTINGS_PATH}`,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Remove the zclean hook from Claude Code settings.
|
|
96
|
+
*
|
|
97
|
+
* @returns {{ removed: boolean, message: string }}
|
|
98
|
+
*/
|
|
99
|
+
function removeHook() {
|
|
100
|
+
if (!fs.existsSync(CLAUDE_SETTINGS_PATH)) {
|
|
101
|
+
return { removed: false, message: 'Claude Code settings not found.' };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
let settings;
|
|
105
|
+
try {
|
|
106
|
+
settings = JSON.parse(fs.readFileSync(CLAUDE_SETTINGS_PATH, 'utf-8'));
|
|
107
|
+
} catch {
|
|
108
|
+
return { removed: false, message: 'Failed to parse settings.' };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (!settings.hooks || !Array.isArray(settings.hooks.Stop)) {
|
|
112
|
+
return { removed: false, message: 'No hooks found.' };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const before = settings.hooks.Stop.length;
|
|
116
|
+
settings.hooks.Stop = settings.hooks.Stop.filter(
|
|
117
|
+
(h) =>
|
|
118
|
+
h.id !== HOOK_ID &&
|
|
119
|
+
!(h.command && h.command.includes('zclean')) &&
|
|
120
|
+
!(Array.isArray(h.hooks) && h.hooks.some((sub) => sub.command && sub.command.includes('zclean')))
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
if (settings.hooks.Stop.length === before) {
|
|
124
|
+
return { removed: false, message: 'Hook was not registered.' };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Clean up empty structures
|
|
128
|
+
if (settings.hooks.Stop.length === 0) delete settings.hooks.Stop;
|
|
129
|
+
if (Object.keys(settings.hooks).length === 0) delete settings.hooks;
|
|
130
|
+
|
|
131
|
+
fs.writeFileSync(CLAUDE_SETTINGS_PATH, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
|
|
132
|
+
|
|
133
|
+
return { removed: true, message: 'Hook removed from Claude Code settings.' };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
module.exports = { installHook, removeHook };
|