// <FILE>wsl_clip_core/src/orc_bridge_cli.rs</FILE> - <DESC>CLI orchestration for wsl-clip including bridge stubs</DESC>
// <VERS>VERSION: 0.1.1 - 2025-12-08T00:00:00Z</VERS>
// <WCTX>Align CLI help text with implemented bridge behavior and tone.</WCTX>
// <CLOG>Updated help/usage strings to describe bridge stubs and removed hype.</CLOG>
use crate::classifier::{self, ClipboardStrategy};
use crate::clipboard::{self, ClipboardMode};
use crate::bridge::{SessionStore, SessionStoreConfig};
use std::time::Duration;
use crate::config::{self, AppConfig, BridgeCliOverrides, BridgeMode, ResolvedBridgeCommand};
use crate::orc_bridge_runtime::run_bridge_command;
use crate::debug_logger::create_logger;
use crate::fnc_walker::{self, WalkerOptions};
use crate::paths;
use crate::text_processor::{self, TextOptions};
use anyhow::Result;
use clap::{
builder::styling::{AnsiColor, Effects, Styles},
ArgAction, Parser, Subcommand,
};
use std::path::PathBuf;
// Extended help surfaced via --daemon-help to expose daemon-specific knobs
// without bloating the default help output.
const EXTENDED_HELP: &str = "\\
DAEMON MODE (bridge listen; stub listener)\n --daemon Run listener in a detached background thread for tcp/udp.\n --log-file <PATH> Append daemon lifecycle events (start/exit/runtime errors).\n --idle-exit <DUR> Auto-exit after idle (default 2h; use 0 to stay alive).\n --ttl <DUR> TTL applied to the same listener code path as foreground.\n --max-bytes <N> Payload size cap (default 1GiB).\n --token/--token-file Apply the same auth gates in daemon mode.\n\nEXAMPLES:\n wsl-clip bridge listen --daemon --log-file /tmp/wsl-clip.log\n wsl-clip bridge listen --mode udp --daemon --idle-exit 0\n wsl-clip bridge listen --daemon --max-bytes 268435456\n";
fn get_styles() -> Styles {
Styles::styled()
.header(AnsiColor::Cyan.on_default() | Effects::BOLD)
.usage(AnsiColor::Cyan.on_default() | Effects::BOLD)
.literal(AnsiColor::Green.on_default())
.placeholder(AnsiColor::Yellow.on_default())
}
#[derive(Parser)]
#[command(
name = "wsl-clip",
version,
about = "WSL2 clipboard utility",
long_about = "Clipboard integration for WSL2.\n\nAuto-detects content types for local files (text, images, file drops) and supports text piping.\n\nBridge commands are experimental stubs; connect sends a small test payload.",
styles = get_styles(),
override_usage = "wsl-clip [OPTIONS] <COMMAND> | [FILES]...",
disable_help_flag = true,
help_template = "\
{before-help}{name} {version}
{author-with-newline}{about-with-newline}
{usage-heading}
{usage}
{all-args}{after-help}
",
after_help = "\
EXAMPLES:
wsl-clip image.png # Auto-detects Image mode
wsl-clip doc.pdf # Auto-detects File object
wsl-clip src/ # Recursively copies source code (Text Mode)
wsl-clip src/ --all-types # Copies ALL files (including binaries) as File Objects
wsl-clip f1.rs f2.rs --tree # Copies text with file tree header
ls --color | wsl-clip # Pipes clean text (colors removed)
\nFor daemon options use --daemon-help.",
after_long_help = EXTENDED_HELP,
)]
pub struct Cli {
#[arg(
short = 'h',
long = "help",
action = ArgAction::HelpShort,
global = true,
help = "Print help (use --daemon-help for daemon details)",
)]
_help: Option<bool>,
#[arg(
long = "daemon-help",
action = ArgAction::HelpLong,
global = true,
help = "Print extended help including daemon options",
)]
_daemon_help: Option<bool>,
#[command(subcommand)]
command: Option<Commands>,
/// Files to copy (Text Mode or Smart Mode). If empty, reads from Stdin.
#[arg()]
files: Option<Vec<PathBuf>>,
/// Suppress file headers in Text Mode
#[arg(short = 'n', long, global = true)]
no_header: bool,
/// Disable ANSI color stripping (Default: stripping is ON)
#[arg(long, global = true)]
no_strip: bool,
/// Convert Linux line endings (LF) to Windows (CRLF)
#[arg(long, global = true)]
crlf: bool,
/// Wrap content in Markdown code blocks
#[arg(long, global = true)]
code: bool,
/// Enable debug logging (Shows all files added in recursive mode)
#[arg(long, global = true)]
debug: bool,
// Walker Options
/// Include binary/image files in directory walk (Forces File Object Mode)
#[arg(long, global = true)]
all_types: bool,
/// Do not respect .gitignore files
#[arg(long, global = true)]
no_ignore: bool,
/// Include hidden files in directory walk
#[arg(long, global = true)]
hidden: bool,
/// Force enable ASCII tree generation
#[arg(long, global = true, conflicts_with = "no_tree")]
tree: bool,
/// Disable ASCII tree generation
#[arg(long, global = true)]
no_tree: bool,
}
#[derive(Subcommand)]
pub enum Commands {
/// Force Image Mode (copy pixels)
Img { file: PathBuf },
/// Force File Object Mode (copy as attachment)
File { files: Vec<PathBuf> },
/// Copy the Windows path string
Path { file: PathBuf },
/// Bridge commands (experimental stubs; connect sends a test payload)
Bridge {
#[command(subcommand)]
bridge: BridgeCommands,
},
}
#[derive(Subcommand, Debug, Clone)]
pub enum BridgeCommands {
/// Start bridge listener (stub; handles a single session)
Listen {
/// Bind address (default 127.0.0.1:8121 unless config overrides)
#[arg(long)]
bind: Option<String>,
/// Transport mode: tcp or udp (default tcp)
#[arg(long, value_enum)]
mode: Option<BridgeMode>,
/// Idle exit timeout (e.g. "2h", "0" for never; default 2h)
#[arg(long)]
idle_exit: Option<String>,
/// TTL duration (default listener setting if omitted)
#[arg(long)]
ttl: Option<String>,
/// Max bytes per payload (default 1GiB)
#[arg(long)]
max_bytes: Option<u64>,
/// Optional token string
#[arg(long)]
token: Option<String>,
/// Optional token file path
#[arg(long)]
token_file: Option<PathBuf>,
/// Run in background/daemon mode (see extended help)
#[arg(
long,
help = "Run in background/daemon mode (see --daemon-help)",
long_help = "Run listener in a detached background thread for tcp/udp. \n\
- Foreground returns immediately after spawning and prints `[bridge] <mode> listener daemon started`.\n\
- Uses the same listener code path as the foreground stub (size limits, tokens, ttl handling).\n\
- Respects `--idle-exit` (default 2h); set `--idle-exit 0` to keep the daemon alive indefinitely.\n\
- If `--log-file` is provided, daemon lifecycle events are appended as single-line records: \n\
`level=INFO|ERROR event=daemon_start|daemon_exit|daemon_runtime_failed mode=<tcp|udp> bind=<addr> [error=<msg>]`.\n\
- TCP and UDP share the same behavior; only the transport changes."
)]
daemon: bool,
/// Log lifecycle to file (primarily for daemon mode)
#[arg(
long,
help = "Append bridge lifecycle events to a log file (useful with --daemon)",
long_help = "When set, the listener appends daemon lifecycle events to the given file. \n\
Entries are newline-delimited and include the mode and bind address.\n\
Example entries: \n\
- `level=INFO event=daemon_start mode=tcp bind=127.0.0.1:8121`\n\
- `level=INFO event=daemon_exit mode=udp bind=127.0.0.1:8121 result=ok`\n\
- `level=ERROR event=daemon_runtime_failed mode=tcp bind=127.0.0.1:8121 error=<reason>`\n\
Logging is only emitted by daemon runs; foreground stubs remain silent."
)]
log_file: Option<PathBuf>,
},
/// Connect from container side (stub; sends a small test payload)
Connect {
/// Target host:port of listener (default 127.0.0.1:8121 unless config overrides)
#[arg(long)]
to: Option<String>,
/// Local UNIX socket path for apps to use (default /run/wsl-clip/socket)
#[arg(long)]
socket: Option<PathBuf>,
/// Transport mode: tcp or udp (default tcp)
#[arg(long, value_enum)]
mode: Option<BridgeMode>,
/// Optional token string
#[arg(long)]
token: Option<String>,
/// Optional token file path
#[arg(long)]
token_file: Option<PathBuf>,
},
/// Show bridge status (stub)
Status {},
}
fn resolve_bridge(cmd: BridgeCommands, cfg: &AppConfig) -> ResolvedBridgeCommand {
match cmd {
BridgeCommands::Listen {
bind,
mode,
idle_exit,
ttl,
max_bytes,
token,
token_file,
daemon,
log_file,
} => config::resolve_bridge_command(
BridgeCliOverrides::Listen {
bind,
mode,
idle_exit,
ttl,
max_bytes,
token,
token_file,
daemon,
log_file,
},
cfg,
),
BridgeCommands::Connect {
to,
socket,
mode,
token,
token_file,
} => config::resolve_bridge_command(
BridgeCliOverrides::Connect {
to,
socket,
mode,
token,
token_file,
},
cfg,
),
BridgeCommands::Status {} => {
config::resolve_bridge_command(BridgeCliOverrides::Status, cfg)
}
}
}
fn handle_bridge_command(cmd: BridgeCommands, cfg: &AppConfig) -> Result<()> {
let resolved = resolve_bridge(cmd, cfg);
let store_defaults = SessionStoreConfig {
default_max_bytes: match &resolved {
ResolvedBridgeCommand::Listen(l) => l.max_bytes,
ResolvedBridgeCommand::Connect(c) => c.max_bytes,
ResolvedBridgeCommand::Status(_) => 1_073_741_824,
},
default_ttl: match &resolved {
ResolvedBridgeCommand::Listen(l) => l
.ttl
.as_deref()
.and_then(crate::config::parse_duration)
.unwrap_or(Duration::from_secs(2 * 60 * 60)),
_ => Duration::from_secs(0),
},
default_idle_exit: Duration::from_secs(2 * 60 * 60),
spill_threshold: 1_073_741_824 / 4,
};
let _store = SessionStore::new(store_defaults);
run_bridge_command(resolved)
}
fn calculate_total_size(files: &[PathBuf]) -> u64 {
files
.iter()
.map(|f| std::fs::metadata(f).map(|m| m.len()).unwrap_or(0))
.sum()
}
fn format_kb(bytes: u64) -> String {
format!("{:.2} KB", bytes as f64 / 1024.0)
}
fn run_inner(cli: Cli) -> Result<()> {
let app_config = config::load_config();
if cli.debug {
crate::debug_logger::enable_all();
}
let log = create_logger("main");
log.debug("wsl-clip started");
match cli.command {
Some(Commands::Img { file }) => {
log.debug(&format!("Command: Img, File: {:?}", file));
let size = calculate_total_size(std::slice::from_ref(&file));
let win_path = paths::to_windows_path(&file)?;
clipboard::set_complex(&[win_path], ClipboardMode::Image)?;
println!("[OK] Copied Image to Clipboard ({})", format_kb(size));
}
Some(Commands::File { files }) => {
log.debug(&format!("Command: File, Files: {} count", files.len()));
let size = calculate_total_size(&files);
let mut win_paths = Vec::new();
for f in &files {
win_paths.push(paths::to_windows_path(f)?);
}
clipboard::set_complex(&win_paths, ClipboardMode::File)?;
println!(
"[OK] Copied {} File Object(s) to Clipboard ({})",
win_paths.len(),
format_kb(size)
);
}
Some(Commands::Path { file }) => {
log.debug(&format!("Command: Path, File: {:?}", file));
let win_path = paths::to_windows_path(&file)?;
clipboard::set_text_content(&win_path)?;
println!("[OK] Copied Path to Clipboard");
}
Some(Commands::Bridge { bridge }) => {
handle_bridge_command(bridge, &app_config)?;
}
None => {
let mut use_recursive_mode = false;
if let Some(files) = &cli.files {
if files.iter().any(|f| f.is_dir()) {
use_recursive_mode = true;
}
}
let mut dir_count = 0;
let final_files = if use_recursive_mode {
log.info("Directory detected: Entering Recursive Source Extraction Mode");
let opts = WalkerOptions {
include_binary: cli.all_types,
no_ignore: cli.no_ignore,
include_hidden: cli.hidden,
};
if let Some(files) = &cli.files {
let (results, count) = fnc_walker::walk_and_expand(files, &opts)?;
dir_count = count;
if cli.all_types {
log.info("Recursive Mode + All Types -> Switching to File Object Mode");
let size = calculate_total_size(&results);
let mut win_paths = Vec::new();
for f in &results {
win_paths.push(paths::to_windows_path(f)?);
}
clipboard::set_complex(&win_paths, ClipboardMode::File)?;
println!(
"[OK] Copied {} Files, {} Dirs (Recursive) ({})",
win_paths.len(),
dir_count,
format_kb(size)
);
return Ok(());
}
Some(results)
} else {
None
}
} else {
if let Some(files) = &cli.files {
if !files.is_empty() {
let mut img_count = 0;
let mut file_count = 0;
let mut text_count = 0;
for f in files {
match classifier::inspect(f) {
Ok(ClipboardStrategy::Image) => img_count += 1,
Ok(ClipboardStrategy::File) => file_count += 1,
Ok(ClipboardStrategy::Text) => text_count += 1,
Err(e) => {
log.warn(&format!("Classification failed for {:?}: {}", f, e));
anyhow::bail!("Failed to read file: {:?}", f);
}
}
}
let categories_present =
(img_count > 0) as u8 + (file_count > 0) as u8 + (text_count > 0) as u8;
if categories_present > 1 {
anyhow::bail!(
"Mixed content detected! ({} images, {} files/assets, {} text). \
Please run separate commands for each type.",
img_count,
file_count,
text_count
);
}
if img_count > 0 {
if files.len() == 1 {
log.debug("Smart Mode: Single Image");
let size = calculate_total_size(files);
let win_path = paths::to_windows_path(&files[0])?;
clipboard::set_complex(&[win_path], ClipboardMode::Image)?;
println!("[OK] Copied Image to Clipboard ({})", format_kb(size));
return Ok(());
} else {
log.debug("Smart Mode: Multiple Images -> File Mode");
let size = calculate_total_size(files);
let mut win_paths = Vec::new();
for f in files {
win_paths.push(paths::to_windows_path(f)?);
}
clipboard::set_complex(&win_paths, ClipboardMode::File)?;
println!(
"[OK] Copied {} Images as Files ({})",
win_paths.len(),
format_kb(size)
);
return Ok(());
}
}
if file_count > 0 {
log.debug("Smart Mode: Files/Assets detected");
let size = calculate_total_size(files);
let mut win_paths = Vec::new();
for f in files {
win_paths.push(paths::to_windows_path(f)?);
}
clipboard::set_complex(&win_paths, ClipboardMode::File)?;
println!(
"[OK] Copied {} Files ({})",
win_paths.len(),
format_kb(size)
);
return Ok(());
}
log.debug("Smart Mode: Text Mode");
}
}
cli.files.clone()
};
log.debug("Command: Default (Text Mode)");
let show_tree = if cli.no_tree {
false
} else if cli.tree {
true
} else {
use_recursive_mode
};
let opts = TextOptions {
no_header: cli.no_header,
strip_ansi: !cli.no_strip,
use_markdown: cli.code,
use_crlf: cli.crlf,
show_tree,
};
let mut stream = clipboard::start_text_stream()?;
let (bytes_written, file_count) = if let Some(writer) = &mut stream.writer {
text_processor::process_input(final_files, &opts, writer)?
} else {
anyhow::bail!("Failed to acquire stdin for clip.exe");
};
stream.wait()?;
if bytes_written == 0 {
eprintln!(
"Warning: 0 bytes written to clipboard. (Did the walker find any files?)"
);
}
let mut msg = if file_count > 0 {
if dir_count > 0 {
format!(
"[OK] Copied Text ({} files, {} dirs, {})",
file_count,
dir_count,
format_kb(bytes_written as u64)
)
} else {
format!(
"[OK] Copied Text ({} files, {})",
file_count,
format_kb(bytes_written as u64)
)
}
} else {
format!("[OK] Copied Text ({})", format_kb(bytes_written as u64))
};
if cli.no_strip {
msg.push_str(" (Raw ANSI)");
}
if opts.use_crlf {
msg.push_str(" (CRLF)");
}
if use_recursive_mode {
msg.push_str(" (Recursive)");
}
println!("{}", msg);
}
}
Ok(())
}
pub fn run() -> Result<()> {
let cli = Cli::parse();
run_inner(cli)
}
pub fn run_with_args(args: &[&str]) -> Result<()> {
let cli = Cli::parse_from(args);
run_inner(cli)
}
#[cfg(test)]
mod tests {
use super::*;
use clap::CommandFactory;
use serde_json::json;
#[test]
fn help_includes_bridge_commands() {
let top = Cli::command().render_help().to_string();
assert!(top.contains("bridge"));
let bridge_help = BridgeCommands::augment_subcommands(clap::Command::new("bridge"))
.render_help()
.to_string()
.to_lowercase();
assert!(bridge_help.contains("listen"));
assert!(bridge_help.contains("connect"));
assert!(bridge_help.contains("status"));
}
#[test]
fn extended_help_mentions_daemon_details() {
let long = Cli::command()
.render_long_help()
.to_string()
.to_lowercase();
assert!(long.contains("daemon mode"));
assert!(long.contains("--daemon"));
assert!(long.contains("--log-file"));
}
#[test]
fn parses_bridge_listen_minimal() {
let parsed =
Cli::try_parse_from(["wsl-clip", "bridge", "listen", "--bind", "127.0.0.1:8121"])
.expect("should parse bridge listen");
match parsed.command.expect("command") {
Commands::Bridge { bridge } => match bridge {
BridgeCommands::Listen { bind, .. } => {
assert_eq!(bind.as_deref(), Some("127.0.0.1:8121"));
}
_ => panic!("expected listen"),
},
_ => panic!("expected bridge command"),
}
}
#[test]
fn resolve_listen_defaults_json() {
let cfg = AppConfig::default();
let resolved = resolve_bridge(
BridgeCommands::Listen {
bind: None,
mode: None,
idle_exit: None,
ttl: None,
max_bytes: None,
token: None,
token_file: None,
daemon: false,
log_file: None,
},
&cfg,
);
let json = serde_json::to_value(&resolved).unwrap();
assert_eq!(json["Listen"]["bind"], json!("127.0.0.1:8121"));
assert_eq!(json["Listen"]["mode"], json!("tcp"));
assert_eq!(json["Listen"]["max_bytes"], json!(1_073_741_824u64));
}
#[test]
fn resolve_connect_respects_config() {
let cfg = AppConfig {
bridge: Some(crate::config::BridgeConfig {
target: Some("10.0.0.9:9999".into()),
socket: Some(PathBuf::from("/tmp/clip.sock")),
mode: Some(BridgeMode::Udp),
..Default::default()
}),
};
let resolved = resolve_bridge(
BridgeCommands::Connect {
to: None,
socket: None,
mode: None,
token: None,
token_file: None,
},
&cfg,
);
let json = serde_json::to_value(&resolved).unwrap();
assert_eq!(json["Connect"]["to"], json!("10.0.0.9:9999"));
assert_eq!(json["Connect"]["socket"], json!("/tmp/clip.sock"));
assert_eq!(json["Connect"]["mode"], json!("udp"));
}
}
// <FILE>wsl_clip_core/src/orc_bridge_cli.rs</FILE> - <DESC>CLI orchestration for wsl-clip including bridge stubs</DESC>
// <VERS>END OF VERSION: 0.1.1 - 2025-12-08T00:00:00Z</VERS>