proc-cli 1.13.2

A semantic CLI tool for process management
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
//! proc - Semantic Process Management CLI
//!
//! A semantic command-line tool for process management.

use clap::{CommandFactory, Parser, Subcommand};
use clap_complete::{generate, Shell};
use proc_cli::commands::{
    ByCommand, ForCommand, FreeCommand, FreezeCommand, InCommand, InfoCommand, KillCommand,
    ListCommand, OnCommand, OrphansCommand, PortsCommand, StopCommand, StuckCommand, ThawCommand,
    TreeCommand, UnstickCommand, WaitCommand, WatchCommand, WhyCommand,
};
use proc_cli::error::{ExitCode, ProcError};
use std::io;
use std::process;

const VERSION_INFO: &str = concat!(
    env!("CARGO_PKG_VERSION"),
    "\nhttps://github.com/yazeed/proc",
    "\nLicense: MIT"
);

/// Semantic CLI tool for process management. Target by port, PID, name or path.
#[derive(Parser)]
#[command(name = "proc")]
#[command(author, version = VERSION_INFO, about, long_about = None)]
#[command(propagate_version = true)]
#[command(
    after_help = "Targets: :port, PID, or process name. Comma-separate for multiple.
Run 'proc --help' for examples or visit https://github.com/yazeed/proc"
)]
#[command(after_long_help = "EXAMPLES:

  Port/Process Lookup:
    proc on :3000                  What's on port 3000?
    proc on :3000,:8080            What's on multiple ports?
    proc on node                   What ports are node processes using?

  Find by File:
    proc for ./script.py           What's running this file?
    proc for /usr/bin/node         Processes running this executable
    proc for app.log               What has this file open?

  Filter by Name:
    proc by node                   Processes named 'node'
    proc by node --in .            Node processes in current directory
    proc by node --min-cpu 5       Node processes using >5% CPU

  Filter by Directory:
    proc in .                      Processes in current directory
    proc in . --by node            Node processes in cwd

  List All:
    proc list                      All processes
    proc list --min-cpu 10         Processes using >10% CPU

  Info/Kill/Stop (multi-target):
    proc info :3000,:8080          Info for multiple targets
    proc kill :3000,node           Kill port 3000 and node processes
    proc stop :3000,:8080          Stop multiple targets gracefully

  Watch (Real-Time):
    proc watch                     Watch all processes (alias: proc top)
    proc watch node                Watch node processes
    proc watch :3000               Watch process on port 3000
    proc watch -n 1 --sort mem     1s refresh, sorted by memory
    proc watch --in . --by node    Node processes in current directory

  Freeze/Thaw:
    proc freeze node               Pause node processes (SIGSTOP)
    proc freeze :3000 --yes        Freeze by port, skip confirm
    proc thaw node                 Resume frozen processes (SIGCONT)

  Free Ports:
    proc free :3000                Kill process, verify port freed
    proc free :3000,:8080 --yes    Free multiple ports

  Diagnostics:
    proc why :3000                 Why is port 3000 busy? (ancestry)
    proc orphans                   Find orphaned processes
    proc orphans --kill            Find and kill orphans

  Wait:
    proc wait node                 Wait until node processes exit
    proc wait :3000 --timeout 60   Wait up to 60s for port to free
    proc wait node -n 10 -q        Check every 10s, quiet mode

  Other:
    proc ports                     List all listening ports
    proc tree --min-cpu 5          Process tree filtered by CPU
    proc stuck                     Find hung processes
    proc unstick --force           Recover or terminate stuck processes
    proc stop nginx --signal HUP   Send custom signal (config reload)

Targets: :port, PID, or process name. Comma-separate for multiple.
For more information, visit: https://github.com/yazeed/proc")]
struct Cli {
    #[command(subcommand)]
    command: Commands,
}

#[derive(Subcommand)]
enum Commands {
    /// Port/process lookup (bidirectional)
    #[command(visible_alias = ":")]
    On(OnCommand),

    /// Find processes by file path
    #[command(visible_alias = "f")]
    For(ForCommand),

    /// Filter processes by name
    #[command(visible_alias = "b")]
    By(ByCommand),

    /// Filter processes by working directory
    In(InCommand),

    /// List processes
    #[command(visible_aliases = ["l", "ps"])]
    List(ListCommand),

    /// Show detailed process information
    #[command(visible_alias = "i")]
    Info(InfoCommand),

    /// List all listening ports
    #[command(visible_alias = "p")]
    Ports(PortsCommand),

    /// Kill process(es) forcefully
    #[command(visible_alias = "k")]
    Kill(KillCommand),

    /// Stop process(es) gracefully
    #[command(visible_alias = "s")]
    Stop(StopCommand),

    /// Show process tree
    #[command(visible_alias = "t")]
    Tree(TreeCommand),

    /// Watch processes in real-time
    #[command(visible_aliases = ["w", "top"])]
    Watch(WatchCommand),

    /// Find stuck/hung processes
    #[command(visible_alias = "x")]
    Stuck(StuckCommand),

    /// Attempt to recover stuck processes
    #[command(visible_alias = "u")]
    Unstick(UnstickCommand),

    /// Freeze (pause) process(es) with SIGSTOP
    Freeze(FreezeCommand),

    /// Resume frozen process(es) with SIGCONT
    Thaw(ThawCommand),

    /// Find orphaned processes
    #[command(visible_alias = "o")]
    Orphans(OrphansCommand),

    /// Trace why a port is busy or show process ancestry
    Why(WhyCommand),

    /// Free port(s) by killing process and verifying availability
    Free(FreeCommand),

    /// Wait for process(es) to exit
    Wait(WaitCommand),

    /// Generate shell completions
    #[command(hide = true)]
    Completions {
        /// Shell to generate completions for
        #[arg(value_enum)]
        shell: Shell,
    },

    /// Generate man page
    #[command(hide = true)]
    Manpage,
}

/// Extract the unknown argument from a clap error message
fn extract_unknown_arg(msg: &str) -> Option<String> {
    // Pattern: "unexpected argument 'X' found"
    let start = msg.find("unexpected argument '")? + "unexpected argument '".len();
    let end = msg[start..].find('\'')?;
    Some(msg[start..start + end].to_string())
}

/// Simple edit distance for flag suggestion
fn edit_distance(a: &str, b: &str) -> usize {
    let (a, b) = (a.as_bytes(), b.as_bytes());
    let (m, n) = (a.len(), b.len());
    let mut dp = vec![vec![0usize; n + 1]; m + 1];
    for (i, row) in dp.iter_mut().enumerate().take(m + 1) {
        row[0] = i;
    }
    for (j, val) in dp[0].iter_mut().enumerate().take(n + 1) {
        *val = j;
    }
    for i in 1..=m {
        for j in 1..=n {
            let cost = if a[i - 1] == b[j - 1] { 0 } else { 1 };
            dp[i][j] = (dp[i - 1][j] + 1)
                .min(dp[i][j - 1] + 1)
                .min(dp[i - 1][j - 1] + cost);
        }
    }
    dp[m][n]
}

/// Find the closest matching flag for a subcommand
fn suggest_flag(unknown: &str, subcmd_name: &str) -> Option<String> {
    let cmd = Cli::command();
    let subcmd = cmd.find_subcommand(subcmd_name)?;
    let unknown_clean = unknown.trim_start_matches('-');

    let mut best: Option<String> = None;
    let mut best_dist = usize::MAX;

    for arg in subcmd.get_arguments() {
        if let Some(long) = arg.get_long() {
            // Check edit distance (skip very short flags and large length differences)
            let len_diff = (unknown_clean.len() as isize - long.len() as isize).unsigned_abs();
            if len_diff <= 2 && long.len() >= 3 {
                let dist = edit_distance(unknown_clean, long);
                if dist < best_dist {
                    best_dist = dist;
                    best = Some(format!("--{}", long));
                }
            }

            // Also check if unknown is a substring of the flag (e.g., "cpu" → "min-cpu")
            if long.contains(unknown_clean) && unknown_clean.len() >= 3 {
                return Some(format!("--{}", long));
            }
        }
    }

    // Only suggest if edit distance is reasonable
    let threshold = if unknown_clean.len() <= 4 { 2 } else { 3 };
    if best_dist <= threshold {
        best
    } else {
        None
    }
}

fn main() {
    let cli = match Cli::try_parse() {
        Ok(cli) => cli,
        Err(e) => {
            use clap::error::ErrorKind;

            // Let help/version pass through as-is
            if matches!(e.kind(), ErrorKind::DisplayHelp | ErrorKind::DisplayVersion) {
                e.exit();
            }

            // Strip clap's misleading "tip:" lines
            let msg = e.to_string();
            let cleaned: String = msg
                .lines()
                .filter(|line| !line.trim_start().starts_with("tip:"))
                .collect::<Vec<_>>()
                .join("\n");

            // Try to suggest a similar flag
            let mut suggestion = None;
            if matches!(
                e.kind(),
                ErrorKind::UnknownArgument | ErrorKind::InvalidValue
            ) {
                let args: Vec<String> = std::env::args().collect();
                let subcmd = args.iter().skip(1).find(|a| !a.starts_with('-'));
                let unknown = extract_unknown_arg(&msg);
                if let (Some(subcmd), Some(unknown)) = (subcmd, unknown) {
                    suggestion = suggest_flag(&unknown, subcmd);
                }
            }

            // Usage errors are invalid input, same as runtime validation errors
            let exit_code = ExitCode::InvalidInput as i32;

            if wants_json() {
                let mut error = cleaned
                    .trim()
                    .trim_start_matches("error: ")
                    .lines()
                    .next()
                    .unwrap_or_default()
                    .to_string();
                if let Some(ref suggestion) = suggestion {
                    error.push_str(&format!("\n  Try: did you mean '{}'?", suggestion));
                }
                print_json_error(&error, exit_code);
            } else {
                eprint!("{}", cleaned);
                if let Some(suggestion) = suggestion {
                    eprintln!("\n  tip: did you mean '{}'?", suggestion);
                }
            }

            process::exit(exit_code);
        }
    };

    let result = match cli.command {
        Commands::On(cmd) => cmd.execute(),
        Commands::For(cmd) => cmd.execute(),
        Commands::By(cmd) => cmd.execute(),
        Commands::In(cmd) => cmd.execute(),
        Commands::List(cmd) => cmd.execute(),
        Commands::Info(cmd) => cmd.execute(),
        Commands::Ports(cmd) => cmd.execute(),
        Commands::Kill(cmd) => cmd.execute(),
        Commands::Stop(cmd) => cmd.execute(),
        Commands::Tree(cmd) => cmd.execute(),
        Commands::Watch(cmd) => cmd.execute(),
        Commands::Stuck(cmd) => cmd.execute(),
        Commands::Unstick(cmd) => cmd.execute(),
        Commands::Freeze(cmd) => cmd.execute(),
        Commands::Thaw(cmd) => cmd.execute(),
        Commands::Orphans(cmd) => cmd.execute(),
        Commands::Why(cmd) => cmd.execute(),
        Commands::Free(cmd) => cmd.execute(),
        Commands::Wait(cmd) => cmd.execute(),
        Commands::Completions { shell } => {
            generate(shell, &mut Cli::command(), "proc", &mut io::stdout());
            Ok(())
        }
        Commands::Manpage => {
            let cmd = Cli::command();
            let man = clap_mangen::Man::new(cmd);
            man.render(&mut io::stdout())
                .expect("Failed to generate man page");
            Ok(())
        }
    };

    if let Err(e) = result {
        let exit_code = ExitCode::from(&e);

        // The command already printed its result; only the exit code is left
        if matches!(e, ProcError::Reported { .. }) {
            process::exit(exit_code as i32);
        }

        // If --json was requested, output error as JSON to stdout
        if wants_json() {
            print_json_error(&e.to_string(), exit_code as i32);
        } else {
            eprintln!("{}", e);
        }

        process::exit(exit_code as i32);
    }
}

/// Print an error as the standard JSON error object on stdout
fn print_json_error(error: &str, exit_code: i32) {
    let error_json = serde_json::json!({
        "action": action_name(),
        "success": false,
        "error": error,
        "exit_code": exit_code
    });
    println!("{}", serde_json::to_string_pretty(&error_json).unwrap());
}

/// Canonical command name for the JSON `action` field, resolving aliases
/// (e.g. `proc k` → "kill"). Falls back to the raw argument, or "proc".
fn action_name() -> String {
    let first = std::env::args().skip(1).find(|a| !a.starts_with('-'));
    match first {
        Some(arg) => Cli::command()
            .find_subcommand(&arg)
            .map(|cmd| cmd.get_name().to_string())
            .unwrap_or(arg),
        None => "proc".to_string(),
    }
}

/// Check if --json or -j was passed in the command line arguments
fn wants_json() -> bool {
    std::env::args()
        .skip(1)
        .take_while(|a| a != "--")
        .any(|a| is_json_flag(&a))
}

/// Matches `--json`, `-j`, and short-flag clusters such as `-yj`.
///
/// Scanning a cluster stops at the first short flag that takes a value,
/// because everything after it is that flag's value (`-bj` is `--by j`).
fn is_json_flag(arg: &str) -> bool {
    if arg == "--json" {
        return true;
    }
    let Some(cluster) = arg.strip_prefix('-') else {
        return false;
    };
    if cluster.starts_with('-') {
        return false;
    }
    for c in cluster.chars() {
        match c {
            'j' => return true,
            'b' | 'd' | 'i' | 'l' | 'n' | 'p' | 'r' | 's' | 'S' | 't' => return false,
            _ => {}
        }
    }
    false
}