use crate::cleaner::{clean, inspect};
use crate::types::MediaHint;
use crate::unicode::{CleanOpts, InspectOpts, clean_text, inspect_text};
use anyhow::{Context, Result, bail};
use clap::builder::styling::{AnsiColor, Effects, Styles};
use clap::{Parser, Subcommand, ValueEnum};
use std::path::PathBuf;
fn styles() -> Styles {
Styles::styled()
.header(AnsiColor::Magenta.on_default() | Effects::BOLD)
.usage(AnsiColor::Magenta.on_default() | Effects::BOLD)
.literal(AnsiColor::Blue.on_default() | Effects::BOLD)
.error(AnsiColor::Red.on_default() | Effects::BOLD)
.placeholder(AnsiColor::Green.on_default())
}
#[derive(Debug, Parser)]
#[command(
name = "cum",
author = "Mahmoud Harmouch <oss@wiseai.dev>",
version = env!("CARGO_PKG_VERSION"),
propagate_version = true,
arg_required_else_help = true,
styles = styles(),
help_template = r#"{before-help}{about}
{usage-heading} {usage}
{all-args}{after-help}
AUTHORS:
{author}
"#,
about = r#"
โโโโโโโ โโโ โโโโโโโ โโโโ
โโโโโโโโ โโโ โโโโโโโโ โโโโโ
โโโ โโโ โโโโโโโโโโโโโโ
โโโ โโโ โโโโโโโโโโโโโโ
โโโโโโโโ โโโโโโโโโโโโ โโโ โโโ
โโโโโโโ โโโโโโโ โโโ โโโ
Claude Unmarking Machine
=========================
Remove AI-provider watermarks from text, images, and documents safely and completely offline.
FEATURES:
- Text format checking: Strip Layer A Unicode carriers (ZWSP, Tags, Variations).
- Confusable patching: --aggressive swaps Cyrillic / fullwidth homoglyphs back to Latin.
- Image sweeping: Remove hidden C2PA or invisible EXIF tracking.
- Document sweeping: Scrub hidden markers in PDF, DOCX, ODT.
- Media auto-detection: Format guessed from magic bytes automatically.
- Output formatting: --json output for programmatic scraping.
USAGE:
cum [OPTIONS] <COMMAND>
EXAMPLES:
- Clean inline text using --text (-t):
cum clean --text "Hello world"
- Strip watermarks from an image to an output file:
cum clean profile.jpeg --output profile_clean.jpeg
- Inspect metadata without modifying:
cum inspect suspected.pdf
- Aggressively fix spacing/confusables over stdin:
cat prompt.txt | cum clean --stdin -a
For more detail, check CLI.md or https://github.com/wiseaidotdev/cum
"#
)]
pub struct Cli {
#[command(subcommand)]
pub command: Command,
#[arg(long, short = 'j', global = true)]
pub json: bool,
#[arg(long, short = 'q', global = true)]
pub quiet: bool,
}
#[derive(Debug, Subcommand)]
pub enum Command {
#[command(visible_alias = "c")]
Clean {
#[arg(value_name = "FILE")]
file: Option<PathBuf>,
#[arg(long, short = 't', value_name = "TEXT", conflicts_with = "file")]
text: Option<String>,
#[arg(long, conflicts_with_all = &["file", "text"])]
stdin: bool,
#[arg(long, short = 'o', value_name = "OUT")]
output: Option<PathBuf>,
#[arg(long, short = 'a', default_value_t = true)]
aggressive: bool,
#[arg(long, short = 'm', value_name = "MEDIA", value_enum)]
media: Option<MediaArg>,
},
#[command(visible_alias = "i")]
Inspect {
#[arg(value_name = "FILE")]
file: Option<PathBuf>,
#[arg(long, short = 't', value_name = "TEXT", conflicts_with = "file")]
text: Option<String>,
#[arg(long, conflicts_with_all = &["file", "text"])]
stdin: bool,
#[arg(long, short = 'a', default_value_t = true)]
aggressive: bool,
#[arg(long, short = 'm', value_name = "MEDIA", value_enum)]
media: Option<MediaArg>,
},
#[command(hide = true)]
Version,
}
#[derive(Debug, Clone, ValueEnum)]
pub enum MediaArg {
Text,
Png,
Jpeg,
Webp,
Svg,
Pdf,
Docx,
Odt,
Html,
Markdown,
}
impl From<MediaArg> for MediaHint {
fn from(a: MediaArg) -> Self {
match a {
MediaArg::Text => MediaHint::Text,
MediaArg::Png => MediaHint::Png,
MediaArg::Jpeg => MediaHint::Jpeg,
MediaArg::Webp => MediaHint::Webp,
MediaArg::Svg => MediaHint::Svg,
MediaArg::Pdf => MediaHint::Pdf,
MediaArg::Docx => MediaHint::Docx,
MediaArg::Odt => MediaHint::Odt,
MediaArg::Html => MediaHint::Html,
MediaArg::Markdown => MediaHint::Markdown,
}
}
}
pub fn run(cli: Cli) -> Result<()> {
match cli.command {
Command::Version => {
println!("{}", env!("CARGO_PKG_VERSION"));
}
Command::Clean {
file,
text,
stdin,
output,
aggressive,
media,
} => {
run_clean(
file, text, stdin, output, aggressive, media, cli.json, cli.quiet,
)?;
}
Command::Inspect {
file,
text,
stdin,
aggressive,
media,
} => {
run_inspect(file, text, stdin, aggressive, media, cli.json)?;
}
}
Ok(())
}
#[allow(clippy::too_many_arguments)]
fn run_clean(
file: Option<PathBuf>,
text: Option<String>,
stdin: bool,
output: Option<PathBuf>,
aggressive: bool,
media: Option<MediaArg>,
json: bool,
quiet: bool,
) -> Result<()> {
let (bytes, is_text_mode) = resolve_input(file.as_deref(), text.as_deref(), stdin)?;
let (cleaned_bytes, removed, replaced) = if is_text_mode {
let s = std::str::from_utf8(&bytes).context("input is not valid UTF-8")?;
let opts = CleanOpts {
aggressive_confusables: aggressive,
..CleanOpts::safe()
};
let (clean, stats) = clean_text(s, &opts)?;
(
clean.into_bytes(),
stats.removed_count,
stats.replaced_count,
)
} else {
let hint = media.map(MediaHint::from);
let out = clean(&bytes, hint)?;
let r = out.stats.removed_count;
let rp = out.stats.replaced_count;
(out.bytes, r, rp)
};
if let Some(path) = output {
std::fs::write(&path, &cleaned_bytes)
.with_context(|| format!("writing to {}", path.display()))?;
if !quiet {
eprintln!(
"โ
Wrote cleaned output โ {} ({} stripped, {} replaced)",
path.display(),
removed,
replaced
);
}
} else {
if is_text_mode {
print!("{}", String::from_utf8_lossy(&cleaned_bytes));
} else {
use std::io::Write;
std::io::stdout().write_all(&cleaned_bytes)?;
}
if !quiet && json {
eprintln!(
"{}",
serde_json::json!({
"removed_count": removed,
"replaced_count": replaced,
})
);
} else if !quiet {
eprintln!("๐งน {} stripped, {} replaced", removed, replaced);
}
}
Ok(())
}
fn run_inspect(
file: Option<PathBuf>,
text: Option<String>,
stdin: bool,
aggressive: bool,
media: Option<MediaArg>,
json: bool,
) -> Result<()> {
let (bytes, is_text_mode) = resolve_input(file.as_deref(), text.as_deref(), stdin)?;
if is_text_mode {
let s = std::str::from_utf8(&bytes).context("input is not valid UTF-8")?;
let opts = InspectOpts {
aggressive_confusables: aggressive,
..InspectOpts::default()
};
let report = inspect_text(s, &opts)?;
if json {
println!("{}", serde_json::to_string_pretty(&report)?);
} else {
let total = report.hits.iter().map(|h| h.count).sum::<usize>();
if total == 0 {
println!("โ
No watermarks detected.");
} else {
println!("โ ๏ธ {} suspicious codepoint instance(s) found:", total);
for hit in &report.hits {
println!(
" U+{:04X} {:?} ร{} ({:?})",
hit.codepoint, hit.kind, hit.count, hit.confidence
);
}
}
}
} else {
let hint = media.map(MediaHint::from);
let out = inspect(&bytes, hint)?;
if json {
println!(
"{}",
serde_json::to_string_pretty(&serde_json::json!({
"format": format!("{:?}", out.format),
"meta_findings": out.meta_findings,
}))?
);
} else {
let findings = &out.meta_findings;
if findings.is_empty() {
println!("โ
No metadata watermarks found.");
} else {
println!("โ ๏ธ {} finding(s):", findings.len());
for f in findings {
println!(" [{:?}] {}", f.confidence, f.description);
}
}
}
}
Ok(())
}
fn resolve_input(
file: Option<&std::path::Path>,
text: Option<&str>,
stdin: bool,
) -> Result<(Vec<u8>, bool)> {
if let Some(t) = text {
return Ok((t.as_bytes().to_vec(), true));
}
if stdin {
use std::io::Read;
let mut buf = Vec::new();
std::io::stdin().read_to_end(&mut buf)?;
return Ok((buf, false));
}
if let Some(path) = file {
let bytes = std::fs::read(path).with_context(|| format!("reading {}", path.display()))?;
let is_text = matches!(
path.extension().and_then(|e| e.to_str()),
Some("txt" | "md" | "markdown" | "html" | "htm")
);
return Ok((bytes, is_text));
}
bail!("Provide a FILE, --text TEXT, or --stdin")
}