procm-cli 0.0.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/CLAUDE.md +106 -0
- package/README.md +56 -0
- package/bun.lock +213 -0
- package/index.ts +547 -0
- package/lib.test.ts +258 -0
- package/lib.ts +122 -0
- package/package.json +31 -0
- package/tsconfig.json +29 -0
package/lib.test.ts
ADDED
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import { test, expect, describe } from "bun:test"
|
|
2
|
+
import {
|
|
3
|
+
parseProcessLine,
|
|
4
|
+
pad,
|
|
5
|
+
rpad,
|
|
6
|
+
cpuColor,
|
|
7
|
+
memColor,
|
|
8
|
+
statLabel,
|
|
9
|
+
statColor,
|
|
10
|
+
sortProcs,
|
|
11
|
+
sortFields,
|
|
12
|
+
c,
|
|
13
|
+
type Process,
|
|
14
|
+
} from "./lib"
|
|
15
|
+
|
|
16
|
+
// ── parseProcessLine ────────────────────────────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
describe("parseProcessLine", () => {
|
|
19
|
+
test("parses a valid ps output line", () => {
|
|
20
|
+
const line = " 123 1 2.3 0.5 Ss ?? Thu Feb 20 14:30:00 2026 /usr/sbin/syslogd"
|
|
21
|
+
const p = parseProcessLine(line)
|
|
22
|
+
expect(p).toEqual({
|
|
23
|
+
pid: 123,
|
|
24
|
+
ppid: 1,
|
|
25
|
+
cpu: 2.3,
|
|
26
|
+
mem: 0.5,
|
|
27
|
+
stat: "Ss",
|
|
28
|
+
tty: "??",
|
|
29
|
+
command: "/usr/sbin/syslogd",
|
|
30
|
+
})
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
test("parses command with spaces", () => {
|
|
34
|
+
const line = " 200 1 0.0 0.1 Ss ?? Thu Feb 20 14:30:00 2026 /usr/bin/some command with spaces"
|
|
35
|
+
const p = parseProcessLine(line)
|
|
36
|
+
expect(p).not.toBeNull()
|
|
37
|
+
expect(p!.command).toBe("/usr/bin/some command with spaces")
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
test("skips header line", () => {
|
|
41
|
+
const line = " PID PPID %CPU %MEM STAT TT LSTART COMMAND"
|
|
42
|
+
expect(parseProcessLine(line)).toBeNull()
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
test("skips empty lines", () => {
|
|
46
|
+
expect(parseProcessLine("")).toBeNull()
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
test("includes processes with tty=??", () => {
|
|
50
|
+
const line = " 300 1 1.0 0.2 R+ ?? Thu Feb 20 14:30:00 2026 /usr/bin/daemon"
|
|
51
|
+
const p = parseProcessLine(line)
|
|
52
|
+
expect(p).not.toBeNull()
|
|
53
|
+
expect(p!.pid).toBe(300)
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
test("includes sleeping process with a TTY", () => {
|
|
57
|
+
const line = " 400 1 0.0 0.1 S ttys000 Thu Feb 20 14:30:00 2026 /bin/zsh"
|
|
58
|
+
const p = parseProcessLine(line)
|
|
59
|
+
expect(p).not.toBeNull()
|
|
60
|
+
expect(p!.pid).toBe(400)
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
test("excludes running process with a TTY", () => {
|
|
64
|
+
const line = " 500 1 5.2 1.3 R+ ttys000 Thu Feb 20 14:30:00 2026 vim test.txt"
|
|
65
|
+
expect(parseProcessLine(line)).toBeNull()
|
|
66
|
+
})
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
// ── pad / rpad ──────────────────────────────────────────────────────────────────
|
|
70
|
+
|
|
71
|
+
describe("pad", () => {
|
|
72
|
+
test("right-pads short string", () => {
|
|
73
|
+
expect(pad("foo", 10)).toBe("foo ")
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
test("truncates long string with ~", () => {
|
|
77
|
+
expect(pad("very long string", 5)).toBe("very~")
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
test("returns exact length string unchanged", () => {
|
|
81
|
+
expect(pad("hello", 5)).toBe("hello")
|
|
82
|
+
})
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
describe("rpad", () => {
|
|
86
|
+
test("left-pads short string", () => {
|
|
87
|
+
expect(rpad("42", 7)).toBe(" 42")
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
test("truncates long string with ~", () => {
|
|
91
|
+
expect(rpad("very long string", 5)).toBe("very~")
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
test("returns exact length string unchanged", () => {
|
|
95
|
+
expect(rpad("hello", 5)).toBe("hello")
|
|
96
|
+
})
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
// ── cpuColor / memColor ─────────────────────────────────────────────────────────
|
|
100
|
+
|
|
101
|
+
describe("cpuColor", () => {
|
|
102
|
+
test(">50 returns red", () => {
|
|
103
|
+
expect(cpuColor(51)).toBe(c.red)
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
test(">20 returns orange", () => {
|
|
107
|
+
expect(cpuColor(21)).toBe(c.orange)
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
test(">5 returns yellow", () => {
|
|
111
|
+
expect(cpuColor(6)).toBe(c.yellow)
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
test("<=5 returns green", () => {
|
|
115
|
+
expect(cpuColor(5)).toBe(c.green)
|
|
116
|
+
expect(cpuColor(0)).toBe(c.green)
|
|
117
|
+
})
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
describe("memColor", () => {
|
|
121
|
+
test(">30 returns red", () => {
|
|
122
|
+
expect(memColor(31)).toBe(c.red)
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
test(">10 returns orange", () => {
|
|
126
|
+
expect(memColor(11)).toBe(c.orange)
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
test(">3 returns yellow", () => {
|
|
130
|
+
expect(memColor(4)).toBe(c.yellow)
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
test("<=3 returns green", () => {
|
|
134
|
+
expect(memColor(3)).toBe(c.green)
|
|
135
|
+
expect(memColor(0)).toBe(c.green)
|
|
136
|
+
})
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
// ── statLabel / statColor ───────────────────────────────────────────────────────
|
|
140
|
+
|
|
141
|
+
describe("statLabel", () => {
|
|
142
|
+
test("R+ → Running", () => {
|
|
143
|
+
expect(statLabel("R+")).toBe("Running")
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
test("Ss → Sleeping", () => {
|
|
147
|
+
expect(statLabel("Ss")).toBe("Sleeping")
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
test("D → Disk", () => {
|
|
151
|
+
expect(statLabel("D")).toBe("Disk")
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
test("T → Stopped", () => {
|
|
155
|
+
expect(statLabel("T")).toBe("Stopped")
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
test("Z → Zombie", () => {
|
|
159
|
+
expect(statLabel("Z")).toBe("Zombie")
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
test("I → Idle", () => {
|
|
163
|
+
expect(statLabel("I")).toBe("Idle")
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
test("unknown status returns raw string", () => {
|
|
167
|
+
expect(statLabel("X+")).toBe("X+")
|
|
168
|
+
})
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
describe("statColor", () => {
|
|
172
|
+
test("R → green", () => {
|
|
173
|
+
expect(statColor("R+")).toBe(c.green)
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
test("T → yellow", () => {
|
|
177
|
+
expect(statColor("T")).toBe(c.yellow)
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
test("Z → red", () => {
|
|
181
|
+
expect(statColor("Z")).toBe(c.red)
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
test("D → orange", () => {
|
|
185
|
+
expect(statColor("D")).toBe(c.orange)
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
test("S → dim (default)", () => {
|
|
189
|
+
expect(statColor("Ss")).toBe(c.dim)
|
|
190
|
+
})
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
// ── sortProcs ───────────────────────────────────────────────────────────────────
|
|
194
|
+
|
|
195
|
+
function makeProc(overrides: Partial<Process> = {}): Process {
|
|
196
|
+
return {
|
|
197
|
+
pid: 1,
|
|
198
|
+
ppid: 0,
|
|
199
|
+
cpu: 0,
|
|
200
|
+
mem: 0,
|
|
201
|
+
stat: "Ss",
|
|
202
|
+
tty: "??",
|
|
203
|
+
command: "/bin/test",
|
|
204
|
+
...overrides,
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
describe("sortProcs", () => {
|
|
209
|
+
test("sorts by CPU descending (default)", () => {
|
|
210
|
+
const procs = [
|
|
211
|
+
makeProc({ pid: 1, cpu: 5 }),
|
|
212
|
+
makeProc({ pid: 2, cpu: 50 }),
|
|
213
|
+
makeProc({ pid: 3, cpu: 20 }),
|
|
214
|
+
]
|
|
215
|
+
const sorted = sortProcs(procs, "cpu", false)
|
|
216
|
+
expect(sorted.map((p) => p.cpu)).toEqual([50, 20, 5])
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
test("sorts by PID ascending", () => {
|
|
220
|
+
const procs = [
|
|
221
|
+
makeProc({ pid: 300 }),
|
|
222
|
+
makeProc({ pid: 100 }),
|
|
223
|
+
makeProc({ pid: 200 }),
|
|
224
|
+
]
|
|
225
|
+
const sorted = sortProcs(procs, "pid", true)
|
|
226
|
+
expect(sorted.map((p) => p.pid)).toEqual([100, 200, 300])
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
test("sorts by command alphabetically ascending", () => {
|
|
230
|
+
const procs = [
|
|
231
|
+
makeProc({ command: "zsh" }),
|
|
232
|
+
makeProc({ command: "bash" }),
|
|
233
|
+
makeProc({ command: "fish" }),
|
|
234
|
+
]
|
|
235
|
+
const sorted = sortProcs(procs, "command", true)
|
|
236
|
+
expect(sorted.map((p) => p.command)).toEqual(["bash", "fish", "zsh"])
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
test("flipping sort direction reverses order", () => {
|
|
240
|
+
const procs = [
|
|
241
|
+
makeProc({ pid: 1, cpu: 5 }),
|
|
242
|
+
makeProc({ pid: 2, cpu: 50 }),
|
|
243
|
+
makeProc({ pid: 3, cpu: 20 }),
|
|
244
|
+
]
|
|
245
|
+
const asc = sortProcs([...procs], "cpu", true)
|
|
246
|
+
const desc = sortProcs([...procs], "cpu", false)
|
|
247
|
+
expect(asc.map((p) => p.cpu)).toEqual([5, 20, 50])
|
|
248
|
+
expect(desc.map((p) => p.cpu)).toEqual([50, 20, 5])
|
|
249
|
+
})
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
// ── sortFields ──────────────────────────────────────────────────────────────────
|
|
253
|
+
|
|
254
|
+
describe("sortFields", () => {
|
|
255
|
+
test("contains all expected fields", () => {
|
|
256
|
+
expect(sortFields).toEqual(["pid", "cpu", "mem", "status", "command"])
|
|
257
|
+
})
|
|
258
|
+
})
|
package/lib.ts
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// ── Types ──────────────────────────────────────────────────────────────────────
|
|
2
|
+
|
|
3
|
+
export interface Process {
|
|
4
|
+
pid: number
|
|
5
|
+
ppid: number
|
|
6
|
+
cpu: number
|
|
7
|
+
mem: number
|
|
8
|
+
stat: string
|
|
9
|
+
tty: string
|
|
10
|
+
command: string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// ── Process data ───────────────────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
export const PS_REGEX =
|
|
16
|
+
/^\s*(\d+)\s+(\d+)\s+([\d.]+)\s+([\d.]+)\s+(\S+)\s+(\S+)\s+\w+\s+\w+\s+\d+\s+[\d:]+\s+\d+\s+(.+)$/
|
|
17
|
+
|
|
18
|
+
export function parseProcessLine(line: string): Process | null {
|
|
19
|
+
if (line.length === 0) return null
|
|
20
|
+
const m = PS_REGEX.exec(line)
|
|
21
|
+
if (!m) return null
|
|
22
|
+
|
|
23
|
+
const stat = m[5]
|
|
24
|
+
const tty = m[6]
|
|
25
|
+
// Daemon filter: no TTY or sleeping/idle
|
|
26
|
+
const ch0 = stat.charCodeAt(0)
|
|
27
|
+
if (tty !== "??" && ch0 !== 83 && ch0 !== 115 && ch0 !== 73) return null // S=83,s=115,I=73
|
|
28
|
+
|
|
29
|
+
return {
|
|
30
|
+
pid: +m[1],
|
|
31
|
+
ppid: +m[2],
|
|
32
|
+
cpu: +m[3],
|
|
33
|
+
mem: +m[4],
|
|
34
|
+
stat,
|
|
35
|
+
tty,
|
|
36
|
+
command: m[7],
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// ── Colors ─────────────────────────────────────────────────────────────────────
|
|
41
|
+
|
|
42
|
+
export const c = {
|
|
43
|
+
bg: "#1a1b26",
|
|
44
|
+
bgAlt: "#24283b",
|
|
45
|
+
bgHl: "#364a82",
|
|
46
|
+
border: "#565f89",
|
|
47
|
+
text: "#c0caf5",
|
|
48
|
+
dim: "#565f89",
|
|
49
|
+
muted: "#a9b1d6",
|
|
50
|
+
green: "#9ece6a",
|
|
51
|
+
red: "#f7768e",
|
|
52
|
+
yellow: "#e0af68",
|
|
53
|
+
blue: "#7aa2f7",
|
|
54
|
+
cyan: "#7dcfff",
|
|
55
|
+
magenta: "#bb9af7",
|
|
56
|
+
orange: "#ff9e64",
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ── Helpers ────────────────────────────────────────────────────────────────────
|
|
60
|
+
|
|
61
|
+
export function pad(s: string, n: number) {
|
|
62
|
+
return s.length > n ? s.slice(0, n - 1) + "~" : s.padEnd(n)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function rpad(s: string, n: number) {
|
|
66
|
+
return s.length > n ? s.slice(0, n - 1) + "~" : s.padStart(n)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function cpuColor(v: number) {
|
|
70
|
+
if (v > 50) return c.red
|
|
71
|
+
if (v > 20) return c.orange
|
|
72
|
+
if (v > 5) return c.yellow
|
|
73
|
+
return c.green
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function memColor(v: number) {
|
|
77
|
+
if (v > 30) return c.red
|
|
78
|
+
if (v > 10) return c.orange
|
|
79
|
+
if (v > 3) return c.yellow
|
|
80
|
+
return c.green
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function statLabel(s: string) {
|
|
84
|
+
switch (s.charCodeAt(0)) {
|
|
85
|
+
case 82: return "Running" // R
|
|
86
|
+
case 83: return "Sleeping" // S
|
|
87
|
+
case 68: return "Disk" // D
|
|
88
|
+
case 84: return "Stopped" // T
|
|
89
|
+
case 90: return "Zombie" // Z
|
|
90
|
+
case 73: return "Idle" // I
|
|
91
|
+
default: return s
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function statColor(s: string) {
|
|
96
|
+
switch (s.charCodeAt(0)) {
|
|
97
|
+
case 82: return c.green // R
|
|
98
|
+
case 84: return c.yellow // T
|
|
99
|
+
case 90: return c.red // Z
|
|
100
|
+
case 68: return c.orange // D
|
|
101
|
+
default: return c.dim
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ── Sort ────────────────────────────────────────────────────────────────────────
|
|
106
|
+
|
|
107
|
+
export type SortField = "pid" | "cpu" | "mem" | "status" | "command"
|
|
108
|
+
export const sortFields: SortField[] = ["pid", "cpu", "mem", "status", "command"]
|
|
109
|
+
|
|
110
|
+
export function sortProcs(procs: Process[], sortBy: SortField, sortAsc: boolean) {
|
|
111
|
+
return procs.sort((a, b) => {
|
|
112
|
+
let v = 0
|
|
113
|
+
switch (sortBy) {
|
|
114
|
+
case "pid": v = a.pid - b.pid; break
|
|
115
|
+
case "cpu": v = a.cpu - b.cpu; break
|
|
116
|
+
case "mem": v = a.mem - b.mem; break
|
|
117
|
+
case "status": v = a.stat.localeCompare(b.stat); break
|
|
118
|
+
case "command": v = a.command.localeCompare(b.command); break
|
|
119
|
+
}
|
|
120
|
+
return sortAsc ? v : -v
|
|
121
|
+
})
|
|
122
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "procm-cli",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "Terminal process manager with sorting, filtering, and kill/restart",
|
|
5
|
+
"module": "index.ts",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": {
|
|
8
|
+
"procm": "./index.ts"
|
|
9
|
+
},
|
|
10
|
+
"scripts": {
|
|
11
|
+
"start": "bun index.ts"
|
|
12
|
+
},
|
|
13
|
+
"keywords": [
|
|
14
|
+
"process",
|
|
15
|
+
"manager",
|
|
16
|
+
"tui",
|
|
17
|
+
"terminal",
|
|
18
|
+
"cli",
|
|
19
|
+
"daemon"
|
|
20
|
+
],
|
|
21
|
+
"license": "MIT",
|
|
22
|
+
"devDependencies": {
|
|
23
|
+
"@types/bun": "latest"
|
|
24
|
+
},
|
|
25
|
+
"peerDependencies": {
|
|
26
|
+
"typescript": "^5"
|
|
27
|
+
},
|
|
28
|
+
"dependencies": {
|
|
29
|
+
"@opentui/core": "^0.1.80"
|
|
30
|
+
}
|
|
31
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
// Environment setup & latest features
|
|
4
|
+
"lib": ["ESNext"],
|
|
5
|
+
"target": "ESNext",
|
|
6
|
+
"module": "Preserve",
|
|
7
|
+
"moduleDetection": "force",
|
|
8
|
+
"jsx": "react-jsx",
|
|
9
|
+
"allowJs": true,
|
|
10
|
+
|
|
11
|
+
// Bundler mode
|
|
12
|
+
"moduleResolution": "bundler",
|
|
13
|
+
"allowImportingTsExtensions": true,
|
|
14
|
+
"verbatimModuleSyntax": true,
|
|
15
|
+
"noEmit": true,
|
|
16
|
+
|
|
17
|
+
// Best practices
|
|
18
|
+
"strict": true,
|
|
19
|
+
"skipLibCheck": true,
|
|
20
|
+
"noFallthroughCasesInSwitch": true,
|
|
21
|
+
"noUncheckedIndexedAccess": true,
|
|
22
|
+
"noImplicitOverride": true,
|
|
23
|
+
|
|
24
|
+
// Some stricter flags (disabled by default)
|
|
25
|
+
"noUnusedLocals": false,
|
|
26
|
+
"noUnusedParameters": false,
|
|
27
|
+
"noPropertyAccessFromIndexSignature": false
|
|
28
|
+
}
|
|
29
|
+
}
|