diff --git a/.github/hooks/rtk-rewrite.json b/.github/hooks/rtk-rewrite.json index c488d4349..eb2a5a7f2 100644 --- a/.github/hooks/rtk-rewrite.json +++ b/.github/hooks/rtk-rewrite.json @@ -3,7 +3,7 @@ "PreToolUse": [ { "type": "command", - "command": "rtk hook", + "command": "rtk hook copilot", "cwd": ".", "timeout": 5 } diff --git a/README.md b/README.md index 1452b1ca8..db900da47 100644 --- a/README.md +++ b/README.md @@ -229,6 +229,14 @@ rtk kubectl logs # Deduplicated logs rtk kubectl services # Compact service list ``` +### Windows Package Manager +```powershell +rtk winget list # Upgradeable packages + up-to-date count +rtk winget upgrade # Compact list of available updates +rtk winget install # Install, stripping spinners and boilerplate +rtk winget uninstall # Uninstall, stripping noise +``` + ### Data & Analytics ```bash rtk json config.json # Structure without values @@ -344,8 +352,9 @@ rtk git status | Feature | WSL | Native Windows | |---------|-----|----------------| | Filters (cargo, git, etc.) | Full | Full | -| Auto-rewrite hook | Yes | No (CLAUDE.md fallback) | -| `rtk init -g` | Hook mode | CLAUDE.md mode | +| `rtk winget` | N/A | Full | +| Auto-rewrite hook | Yes | Yes (`.cmd` wrapper) | +| `rtk init -g` | Hook mode | Hook mode | | `rtk gain` / analytics | Full | Full | ## Supported AI Tools diff --git a/docs/usage/FEATURES.md b/docs/usage/FEATURES.md index bf3412b82..a9957c536 100644 --- a/docs/usage/FEATURES.md +++ b/docs/usage/FEATURES.md @@ -870,6 +870,31 @@ Auto-detecte : `Cargo.toml`, `package.json`, `pyproject.toml`, `go.mod`, `Gemfil --- +## Windows Package Manager + +### `rtk winget` -- winget + +| Commande | Description | Economies | +|----------|-------------|----------| +| `rtk winget list` | Paquets upgradeables + compteur a jour | ~70% | +| `rtk winget upgrade` | Liste compacte des mises a jour disponibles | ~73% | +| `rtk winget install ` | Installe en supprimant spinners et boilerplate | ~60% | +| `rtk winget uninstall ` | Desinstalle en supprimant le bruit | ~60% | +| Autres sous-commandes | Passthrough avec suppression des spinners | — | + +Les colonnes Available et Source de winget sont parfois separees par un seul espace — RTK gere ce cas particulier. + +**Avant / Apres :** +``` +# winget upgrade (sortie brute ~80 tokens) # rtk winget upgrade (~22 tokens) +Nom Identifiant Version Disponible upgrades (3): +Docker Docker.DockerDesktop 4.38.0 4.39.0 ... Docker.DockerDesktop: 4.38.0→4.39.0 +Git Git.Git 2.47.1 2.48.0 ... Git.Git: 2.47.1→2.48.0 +GitHub GitHub.cli 2.63.2 2.65.0 ... GitHub.cli: 2.63.2→2.65.0 +``` + +--- + ## Conteneurs et orchestration ### `rtk docker` -- Docker diff --git a/src/cmds/git/git.rs b/src/cmds/git/git.rs index 7ac95c4d3..fe84ac9c4 100644 --- a/src/cmds/git/git.rs +++ b/src/cmds/git/git.rs @@ -771,6 +771,33 @@ fn run_status(args: &[String], verbose: u8, global_args: &[String]) -> Result Result { + let subcommand = args.first().map(|s| s.as_str()).unwrap_or(""); + + let mut cmd = resolved_command("winget"); + for arg in args { + cmd.arg(arg); + } + + if verbose > 0 { + eprintln!("Running: winget {}", args.join(" ")); + } + + // Passthrough for help/version flags — don't filter help text + let has_help = args + .iter() + .any(|a| matches!(a.as_str(), "--help" | "-?" | "-h" | "--version")); + + if has_help { + return runner::run_filtered( + cmd, + "winget", + &args.join(" "), + filter_strip_noise, + RunOptions::default(), + ); + } + + match subcommand { + "list" => runner::run_filtered( + cmd, + "winget", + &args.join(" "), + filter_winget_list, + RunOptions::default(), + ), + "upgrade" => runner::run_filtered( + cmd, + "winget", + &args.join(" "), + filter_winget_upgrade, + RunOptions::default(), + ), + "install" | "uninstall" | "reinstall" => runner::run_filtered( + cmd, + "winget", + &args.join(" "), + filter_winget_install, + RunOptions::default(), + ), + _ => { + // search, show, source, etc. — passthrough with spinner strip only + runner::run_filtered( + cmd, + "winget", + &args.join(" "), + filter_strip_noise, + RunOptions::default(), + ) + } + } +} + +struct PackageRow { + id: String, + version: String, + available: String, +} + +fn is_known_source(s: &str) -> bool { + matches!(s, "winget" | "msstore" | "msstorexml" | "winget-pkgs") +} + +/// Parse a single data row from a winget table. +/// +/// Winget uses fixed-width columns separated by 2+ spaces between column boundaries, +/// but adjacent columns (e.g. Available + Source) may be separated by only 1 space. +/// We split by 2+ spaces and handle the Available/Source edge case explicitly. +fn parse_data_row(line: &str) -> Option { + let raw: Vec<&str> = COL_SPLIT_RE + .split(line.trim()) + .filter(|s| !s.is_empty()) + .collect(); + + if raw.len() < 3 { + return None; + } + + let id = raw.get(1)?.to_string(); + + // Skip unmanaged ARP entries + if id.starts_with("ARP\\") || id.starts_with("ARP/") { + return None; + } + + let version = raw.get(2)?.to_string(); + if version == "Версия" || version == "Version" { + return None; // header row + } + + // Determine available: + // - 5+ parts → [name, id, version, available, source, ...] + // - 4 parts → [name, id, version, "available source"] or [name, id, version, source] + let available = if raw.len() >= 5 { + let candidate = raw[3].to_string(); + if is_known_source(&candidate) { + String::new() + } else { + candidate + } + } else if raw.len() == 4 { + let last = raw[3]; + // Could be "5.22.166.1003 winget" (available stuck to source with 1 space) + if let Some(pos) = last.rfind(' ') { + let potential_source = &last[pos + 1..]; + if is_known_source(potential_source) { + last[..pos].to_string() // extract available part + } else if is_known_source(last) { + String::new() // pure source column, no available + } else { + String::new() + } + } else if is_known_source(last) { + String::new() + } else { + String::new() + } + } else { + String::new() + }; + + Some(PackageRow { + id, + version, + available, + }) +} + +/// Extract data rows from winget table output. +fn collect_rows(input: &str) -> Vec { + let mut past_sep = false; + let mut rows = Vec::new(); + + for line in input.lines() { + let t = line.trim_end(); + + if SEPARATOR_RE.is_match(t) { + past_sep = true; + continue; + } + if !past_sep { + continue; + } + if t.trim().is_empty() || SPINNER_RE.is_match(t.trim()) { + continue; + } + + if let Some(row) = parse_data_row(t) { + rows.push(row); + } + } + + rows +} + +/// `winget list` — show upgradeable packages and up-to-date count. +pub fn filter_winget_list(input: &str) -> String { + let rows = collect_rows(input); + if rows.is_empty() && !input.contains('-') { + return String::new(); + } + + let mut upgradeable: Vec = Vec::new(); + let mut up_to_date = 0usize; + + for row in &rows { + if !row.available.is_empty() { + upgradeable.push(format!(" {}: {}→{}", row.id, row.version, row.available)); + } else { + up_to_date += 1; + } + } + + let mut out = String::new(); + + if !upgradeable.is_empty() { + out.push_str(&format!( + "upgradeable ({}/{}):\n", + upgradeable.len(), + rows.len() + )); + out.push_str(&upgradeable.join("\n")); + out.push('\n'); + } + + out.push_str(&format!("up-to-date: {}", up_to_date)); + out +} + +/// `winget upgrade` — compact list of available updates. +pub fn filter_winget_upgrade(input: &str) -> String { + let rows = collect_rows(input); + if rows.is_empty() && !input.contains('-') { + return String::new(); + } + + let lines: Vec = rows + .iter() + .filter(|r| !r.available.is_empty()) + .map(|r| format!(" {}: {}→{}", r.id, r.version, r.available)) + .collect(); + + let count = lines.len(); + let mut out = format!("upgrades ({}):\n", count); + out.push_str(&lines.join("\n")); + out +} + +/// `winget install/uninstall` — strip noise, keep key status lines. +pub fn filter_winget_install(input: &str) -> String { + let mut lines: Vec<&str> = Vec::new(); + + for line in input.lines() { + let t = line.trim(); + + if t.is_empty() || SPINNER_RE.is_match(t) || PROGRESS_RE.is_match(t) { + continue; + } + if SEPARATOR_RE.is_match(t) { + continue; + } + // Skip license boilerplate (Russian and English) + if t.starts_with("Лицензия") + || t.starts_with("License") + || t.starts_with("Корпорация") + || t.starts_with("Microsoft is not") + { + continue; + } + + lines.push(t); + } + + lines.join("\n") +} + +/// Strip spinners and progress bars from any winget output. +pub fn filter_strip_noise(input: &str) -> String { + input + .lines() + .filter(|l| { + let t = l.trim(); + !t.is_empty() && !SPINNER_RE.is_match(t) && !PROGRESS_RE.is_match(t) + }) + .collect::>() + .join("\n") +} + +#[cfg(test)] +mod tests { + use super::*; + + fn count_tokens(s: &str) -> usize { + s.split_whitespace().count() + } + + #[test] + fn test_list_shows_upgradeable() { + let input = include_str!("../../../tests/fixtures/winget_list_raw.txt"); + let output = filter_winget_list(input); + assert!(output.contains("upgradeable"), "should label upgradeable section:\n{output}"); + assert!(output.contains("→"), "should show version arrow:\n{output}"); + assert!(output.contains("Docker.DockerDesktop"), "should include Docker:\n{output}"); + assert!(output.contains("up-to-date"), "should show up-to-date count:\n{output}"); + } + + #[test] + fn test_list_skips_unmanaged() { + let input = include_str!("../../../tests/fixtures/winget_list_raw.txt"); + let output = filter_winget_list(input); + assert!(!output.contains("ARP\\"), "should skip unmanaged ARP entries:\n{output}"); + } + + #[test] + fn test_list_token_savings() { + let input = include_str!("../../../tests/fixtures/winget_list_raw.txt"); + let output = filter_winget_list(input); + let savings = 100.0 + * (1.0 - count_tokens(&output) as f64 / count_tokens(input) as f64); + assert!( + savings >= 60.0, + "expected ≥60% savings for winget list, got {:.1}%\noutput:\n{output}", + savings + ); + } + + #[test] + fn test_upgrade_compact() { + let input = include_str!("../../../tests/fixtures/winget_upgrade_raw.txt"); + let output = filter_winget_upgrade(input); + assert!(output.starts_with("upgrades ("), "should start with count:\n{output}"); + assert!(output.contains("→"), "should show version arrow:\n{output}"); + assert!(output.contains("GitHub.cli"), "should include GitHub CLI:\n{output}"); + } + + #[test] + fn test_upgrade_token_savings() { + let input = include_str!("../../../tests/fixtures/winget_upgrade_raw.txt"); + let output = filter_winget_upgrade(input); + let savings = 100.0 + * (1.0 - count_tokens(&output) as f64 / count_tokens(input) as f64); + assert!( + savings >= 60.0, + "expected ≥60% savings for winget upgrade, got {:.1}%\noutput:\n{output}", + savings + ); + } + + #[test] + fn test_install_strips_spinners() { + let input = include_str!("../../../tests/fixtures/winget_install_raw.txt"); + let output = filter_winget_install(input); + assert!(!output.contains(" -"), "should strip spinner lines:\n{output}"); + assert!(!output.contains('█'), "should strip progress bars:\n{output}"); + assert!(output.contains("Git"), "should keep found/status lines:\n{output}"); + } + + #[test] + fn test_install_strips_boilerplate() { + let input = include_str!("../../../tests/fixtures/winget_install_raw.txt"); + let output = filter_winget_install(input); + assert!(!output.contains("Лицензия"), "should strip license line:\n{output}"); + assert!(!output.contains("Корпорация"), "should strip MS disclaimer:\n{output}"); + } + + #[test] + fn test_empty_input() { + assert_eq!(filter_winget_install(""), ""); + // list/upgrade with no table just return empty + let list_out = filter_winget_list(""); + assert!(list_out.is_empty() || list_out.contains("up-to-date")); + } + + #[test] + fn test_available_bundled_with_source() { + // winget list sometimes has only 1 space between available and source + let row = "BlueStacks BlueStack.BlueStacks 5.22.51.1038 5.22.166.1003 winget"; + let parsed = parse_data_row(row).expect("should parse row"); + assert_eq!(parsed.id, "BlueStack.BlueStacks"); + assert_eq!(parsed.version, "5.22.51.1038"); + assert_eq!(parsed.available, "5.22.166.1003"); + } + + #[test] + fn test_no_available() { + let row = "NVM for Windows 1.2.2 CoreyButler.NVMforWindows 1.2.2 winget"; + let parsed = parse_data_row(row).expect("should parse row"); + assert_eq!(parsed.id, "CoreyButler.NVMforWindows"); + assert_eq!(parsed.version, "1.2.2"); + assert!(parsed.available.is_empty(), "available should be empty"); + } +} diff --git a/src/hooks/hook_check.rs b/src/hooks/hook_check.rs index 12f49755e..d4baf9980 100644 --- a/src/hooks/hook_check.rs +++ b/src/hooks/hook_check.rs @@ -126,7 +126,12 @@ fn check_and_warn() -> Option<()> { pub fn parse_hook_version(content: &str) -> u8 { // Version tag must be in the first 5 lines (shebang + header convention) for line in content.lines().take(5) { - if let Some(rest) = line.strip_prefix("# rtk-hook-version:") { + // Unix: "# rtk-hook-version: N" + let rest = line + .strip_prefix("# rtk-hook-version:") + // Windows .cmd: "REM rtk-hook-version: N" + .or_else(|| line.strip_prefix("REM rtk-hook-version:")); + if let Some(rest) = rest { if let Ok(v) = rest.trim().parse::() { return v; } @@ -152,15 +157,24 @@ fn other_integration_installed(home: &std::path::Path) -> bool { fn hook_installed_path() -> Option { let home = dirs::home_dir()?; - let path = home - .join(CLAUDE_DIR) - .join(HOOKS_SUBDIR) - .join(REWRITE_HOOK_FILE); - if path.exists() { - Some(path) - } else { - None + let base = home.join(CLAUDE_DIR).join(HOOKS_SUBDIR); + + // Primary: Unix shell script + let sh = base.join(REWRITE_HOOK_FILE); + if sh.exists() { + return Some(sh); } + + // Windows fallback: .cmd wrapper + #[cfg(target_os = "windows")] + { + let cmd = base.join("rtk-rewrite.cmd"); + if cmd.exists() { + return Some(cmd); + } + } + + None } fn warn_marker_path() -> Option { diff --git a/src/main.rs b/src/main.rs index e8a19c2be..53c9e90bf 100644 --- a/src/main.rs +++ b/src/main.rs @@ -20,7 +20,7 @@ use cmds::ruby::{rake_cmd, rspec_cmd, rubocop_cmd}; use cmds::rust::{cargo_cmd, runner}; use cmds::system::{ deps, env_cmd, find_cmd, format_cmd, grep_cmd, json_cmd, local_llm, log_cmd, ls, pipe_cmd, - read, summary, tree, wc_cmd, + read, summary, tree, wc_cmd, winget_cmd, }; use anyhow::{Context, Result}; @@ -370,6 +370,13 @@ enum Commands { args: Vec, }, + /// Windows Package Manager with compact output (strips spinners, progress bars) + Winget { + /// winget arguments (subcommand + options) + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + args: Vec, + }, + /// Word/line/byte count with compact output (strips paths and padding) Wc { /// Arguments passed to wc (files, flags like -l, -w, -c) @@ -1793,6 +1800,8 @@ fn run_cli() -> Result { Commands::Wc { args } => wc_cmd::run(&args, cli.verbose)?, + Commands::Winget { args } => winget_cmd::run(&args, cli.verbose)?, + Commands::Gain { project, // added graph, @@ -2385,6 +2394,8 @@ fn is_operational_command(cmd: &Commands) -> bool { | Commands::Go { .. } | Commands::GolangciLint { .. } | Commands::Gt { .. } + | Commands::Wc { .. } + | Commands::Winget { .. } ) } diff --git a/tests/fixtures/winget_install_raw.txt b/tests/fixtures/winget_install_raw.txt new file mode 100644 index 000000000..2c57ef6d6 --- /dev/null +++ b/tests/fixtures/winget_install_raw.txt @@ -0,0 +1,13 @@ +Найдено Git.Git +Лицензия: Данное приложение лицензировано предоставляющим его участником. +Корпорация Майкрософт не несёт ответственности за пакеты сторонних производителей. + - + \ + | + / + - +Загрузка Git.Git... +██████████████████████████████ 100% +Проверка установщика... +Запуск установщика... +Установлено успешно: Git.Git diff --git a/tests/fixtures/winget_list_raw.txt b/tests/fixtures/winget_list_raw.txt new file mode 100644 index 000000000..e7a4cf4f7 --- /dev/null +++ b/tests/fixtures/winget_list_raw.txt @@ -0,0 +1,16 @@ +Имя Идентификатор Версия Доступно Источник +------------------------------------------------------------------------------------------------------------------------------------------- +BlueStacks BlueStack.BlueStacks 5.22.51.1038 5.22.166.1003 winget +Docker Desktop Docker.DockerDesktop 4.38.0 4.39.0 winget +GitHub CLI GitHub.cli 2.63.2 2.65.0 winget +NVM for Windows 1.2.2 CoreyButler.NVMforWindows 1.2.2 winget +Git Git.Git 2.47.1.2 2.48.0 winget +Node.js OpenJS.NodeJS.LTS 22.13.1 winget +Visual Studio Code Microsoft.VisualStudioCode 1.96.2 1.97.0 winget +7-Zip 24.09 (x64) 7zip.7zip 24.09 winget +Python 3.11.9 (64-bit) Python.Python.3.11 3.11.9 3.12.0 winget +Microsoft Edge Microsoft.Edge 131.0.2903.112 winget +PowerShell 7.4.6.0 Microsoft.PowerShell 7.4.6.0 winget +Rust toolchain installer Rustlang.Rustup 1.27.1 1.28.0 winget +Windows Terminal Microsoft.WindowsTerminal 1.21.3231.0 winget +ARP\{A1B2C3D4} ARP\SomePkg 1.0.0 ARP diff --git a/tests/fixtures/winget_upgrade_raw.txt b/tests/fixtures/winget_upgrade_raw.txt new file mode 100644 index 000000000..37132415c --- /dev/null +++ b/tests/fixtures/winget_upgrade_raw.txt @@ -0,0 +1,10 @@ +Имя Идентификатор Версия Доступно Источник +------------------------------------------------------------------------------------------------------------------------------------------- +BlueStacks BlueStack.BlueStacks 5.22.51.1038 5.22.166.1003 winget +Docker Desktop Docker.DockerDesktop 4.38.0 4.39.0 winget +GitHub CLI GitHub.cli 2.63.2 2.65.0 winget +Git Git.Git 2.47.1.2 2.48.0 winget +Visual Studio Code Microsoft.VisualStudioCode 1.96.2 1.97.0 winget +Python 3.11.9 (64-bit) Python.Python.3.11 3.11.9 3.12.0 winget +Rust toolchain installer Rustlang.Rustup 1.27.1 1.28.0 winget +5 обновлений доступно.