From 35c0a812b153a4f437af74fbbf394a3181d5498a Mon Sep 17 00:00:00 2001 From: Bhada Yun Date: Sat, 16 May 2026 20:47:32 +0200 Subject: [PATCH 1/2] polish: addictive-grade close-outs + vim-yank + show context strip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lifts the CLI from "polished" to "fzf/gh/lazygit-tier daily driver" by closing the at-a-glance and reward-the-user gaps the dashboard's polish pass exposed: - pcr --help now renders the help.rs single-source-of-truth (purpose / when-to-use / examples / see-also / docs) so the line CLI matches what `pcr help` (TUI) has always shown. - pcr push ends with `▲ pushed N prompts in K bundles · 2.1s` in success tone, with a next-step hint. Failures now print a retry hint too so flaky network leaves no dead air. - pcr log drafts section groups by session, color-codes per source (cursor cyan / claude magenta / vscode yellow), and aligns HH:MM timestamps for scanability. - show TUI gains vim-style `y` (yank focused draft/bundle id) and `o` (open the focused bundle's review URL or the draft's project page) on both panes, plus an updated `?` legend. - show TUI grows a 1-row context strip under the header — project · branch · pending drafts · last-push age — refreshed every 2s so users never have to bounce back to `pcr status` to remember where they are. - pcr init / bundle / pull success lines now match the push close-out shape (▲ glyph + success tone + next-command hint) so every state-changing command exits on the same beat. No new deps, no breaking flag or output changes (goldens pass), no network protocol changes. cargo build / test / clippy / fmt all green. Co-authored-by: Cursor --- crates/pcr-core/src/commands/bundle.rs | 30 ++- crates/pcr-core/src/commands/init.rs | 19 +- crates/pcr-core/src/commands/log.rs | 139 ++++++++++++- crates/pcr-core/src/commands/pull.rs | 17 +- crates/pcr-core/src/commands/push.rs | 55 ++++- crates/pcr-core/src/entry.rs | 75 ++++++- crates/pcr-core/src/help.rs | 49 +++++ crates/pcr-core/src/tui/screens/show.rs | 266 +++++++++++++++++++++++- 8 files changed, 604 insertions(+), 46 deletions(-) diff --git a/crates/pcr-core/src/commands/bundle.rs b/crates/pcr-core/src/commands/bundle.rs index 6263ea0..0a26110 100644 --- a/crates/pcr-core/src/commands/bundle.rs +++ b/crates/pcr-core/src/commands/bundle.rs @@ -452,11 +452,20 @@ fn run_bundle_create(name: &str, select_arg: &str, repo_filter: Option<&str>) -> return ExitCode::GenericError; } + display::eprintln(""); display::eprintln(&format!( - "PCR: Created prompt bundle {name:?} ({} prompt{}) — push with `pcr push`", - selected.len(), - plural(selected.len()), + "{} {}", + display::cstr(Color::Green, "▲"), + display::cstr( + Color::Bold, + &format!( + "bundled {} prompt{} as {name:?}", + selected.len(), + plural(selected.len()), + ), + ), )); + display::print_hint("push it to PCR.dev with `pcr push`"); ExitCode::Success } @@ -501,11 +510,20 @@ fn run_bundle_add(name: &str, select_arg: &str) -> ExitCode { display::print_error("bundle", &e.to_string()); return ExitCode::GenericError; } + display::eprintln(""); display::eprintln(&format!( - "PCR: Added {} prompt{} to {name:?} — push with `pcr push`", - selected.len(), - plural(selected.len()) + "{} {}", + display::cstr(Color::Green, "▲"), + display::cstr( + Color::Bold, + &format!( + "added {} prompt{} to {name:?}", + selected.len(), + plural(selected.len()) + ), + ), )); + display::print_hint("push it to PCR.dev with `pcr push`"); ExitCode::Success } diff --git a/crates/pcr-core/src/commands/init.rs b/crates/pcr-core/src/commands/init.rs index babd441..8e4b2c4 100644 --- a/crates/pcr-core/src/commands/init.rs +++ b/crates/pcr-core/src/commands/init.rs @@ -5,7 +5,7 @@ use std::path::Path; use crate::agent::OutputMode; use crate::auth; -use crate::display; +use crate::display::{self, Color}; use crate::entry::InitArgs; use crate::exit::ExitCode; use crate::projects; @@ -30,7 +30,7 @@ pub fn run(_mode: OutputMode, args: InitArgs) -> ExitCode { if is_git_repo(&project_path) { register_one(&project_path); - display::eprintln("\nPCR: Run `pcr start` to begin capturing prompts."); + print_init_summary(1); return ExitCode::Success; } @@ -68,10 +68,23 @@ pub fn run(_mode: OutputMode, args: InitArgs) -> ExitCode { register_one(sub); display::eprintln(""); } - display::eprintln("PCR: Run `pcr start` to begin capturing prompts."); + print_init_summary(found.len()); ExitCode::Success } +/// Closing summary for `pcr init`. Matches the push close-out so both +/// state-changing commands end the same way: glyph in success tone, +/// count, and a one-line "what to do next" hint. +fn print_init_summary(n: usize) { + display::eprintln(""); + display::eprintln(&format!( + "{} {}", + display::cstr(Color::Green, "▲"), + display::cstr(Color::Bold, &format!("registered {n} project{}", plural(n)),), + )); + display::print_hint("run `pcr start` to begin capturing prompts"); +} + fn register_one(project_path: &str) { let git_remote = git::git_output_in(project_path, &["remote", "get-url", "origin"]); let project = projects::register(project_path); diff --git a/crates/pcr-core/src/commands/log.rs b/crates/pcr-core/src/commands/log.rs index 45978aa..176f865 100644 --- a/crates/pcr-core/src/commands/log.rs +++ b/crates/pcr-core/src/commands/log.rs @@ -4,10 +4,23 @@ use crate::agent::OutputMode; use crate::commands::project_context::resolve; use crate::display::{self, Color}; use crate::exit::ExitCode; -use crate::store; +use crate::store::{self, DraftRecord}; use crate::util::text::{plural, prompt_preview}; use crate::util::time::fmt_time; +/// Map a draft's source string to a stable display label + color so the +/// drafts section reads like the dashboard's source chips. Anything we +/// don't recognise falls back to a neutral `…` glyph and dim styling so +/// future sources don't crash the formatter. +fn source_chip(source: &str) -> (&'static str, Color) { + match source { + "cursor" => ("cursor", Color::Cyan), + "claude-code" | "claude" => ("claude", Color::Magenta), + "vscode" | "vscode-copilot" | "copilot" => ("vscode", Color::Yellow), + _ => ("source", Color::Gray), + } +} + pub fn run(mode: OutputMode) -> ExitCode { if matches!(mode, OutputMode::Json) { return run_json(); @@ -158,16 +171,7 @@ fn run_plain() -> ExitCode { display::cstr(Color::Cyan, "DRAFTS — not yet bundled"), drafts.len() )); - for d in &drafts { - let preview = prompt_preview(&d.prompt_text, 65); - let date = d.captured_at.split('T').next().unwrap_or(""); - display::eprintln(&format!( - " {} {} {}", - display::cstr(Color::Gray, "◦"), - display::cstr(Color::Bold, &preview), - display::cstr(Color::Dim, date), - )); - } + render_grouped_drafts(&drafts); display::eprintln("\n Run `pcr bundle` to create a prompt bundle"); } @@ -175,6 +179,119 @@ fn run_plain() -> ExitCode { ExitCode::Success } +/// Render the drafts list grouped by session. Drafts inside a session +/// are scrollable as a unit and align their HH:MM timestamps so the +/// reader can scan a long log the same way they'd skim `git log`. +/// Sessions are sorted by their newest draft's `captured_at` (newest +/// first) so the most-relevant context is at the top of the section. +fn render_grouped_drafts(drafts: &[DraftRecord]) { + use std::collections::BTreeMap; + + if drafts.is_empty() { + return; + } + + // Bucket by `session_id`. Drafts with no session id (legacy + // captures) get their own per-id bucket so they still render but + // don't share a session header with unrelated rows. + let mut buckets: BTreeMap> = BTreeMap::new(); + let mut order: Vec = Vec::new(); + for d in drafts { + let key = if d.session_id.is_empty() { + format!("solo:{}", d.id) + } else { + d.session_id.clone() + }; + if !buckets.contains_key(&key) { + order.push(key.clone()); + } + buckets.entry(key).or_default().push(d); + } + + // Sort each bucket by captured_at ascending so the session header + // can advertise the start time and the row order reads forward. + for v in buckets.values_mut() { + v.sort_by(|a, b| a.captured_at.cmp(&b.captured_at)); + } + // Reorder bucket keys: newest-session-first, by the bucket's last + // draft captured_at. + order.sort_by(|a, b| { + let aa = buckets + .get(a) + .and_then(|v| v.last()) + .map(|d| &d.captured_at); + let bb = buckets + .get(b) + .and_then(|v| v.last()) + .map(|d| &d.captured_at); + bb.cmp(&aa) + }); + + for key in order { + let Some(items) = buckets.get(&key) else { + continue; + }; + if items.is_empty() { + continue; + } + // Session header. Show source chip + N exchanges + start time. + // Solo (legacy) buckets skip the header entirely so a single + // unattributed draft doesn't get a "session of 1" decoration. + let solo = key.starts_with("solo:"); + if !solo { + let first = items[0]; + let (label, color) = source_chip(&first.source); + let ts = first + .captured_at + .split('T') + .next() + .unwrap_or("") + .to_string(); + let session_short: String = first.session_id.chars().take(8).collect(); + display::eprintln(&format!( + " {} {} {}", + display::cstr(color, &format!("● {label}")), + display::cstr( + Color::Dim, + &format!( + "session {session_short} · {} prompt{}", + items.len(), + plural(items.len()), + ), + ), + display::cstr(Color::Gray, &ts), + )); + } + for d in items { + let preview = prompt_preview(&d.prompt_text, 60); + // HH:MM aligned right of the bullet so eyes can scan a + // column of times. Falls back to the bare ISO date when + // captured_at lacks a time component. + let time = match d.captured_at.split_once('T') { + Some((_, tail)) => tail + .split_once(':') + .map(|(h, rest)| { + let m = rest.split(':').next().unwrap_or(""); + format!("{h}:{m}") + }) + .unwrap_or_else(|| d.captured_at.clone()), + None => d.captured_at.clone(), + }; + let (_, color) = source_chip(&d.source); + // Two-space indent under the session header so the visual + // tree is unambiguous; solo rows keep the original indent + // level so they don't look orphaned. + let indent = if solo { " " } else { " " }; + display::eprintln(&format!( + "{indent}{} {} {}", + display::cstr(color, "◦"), + display::cstr(Color::Dim, &time), + display::cstr(Color::Bold, &preview), + )); + } + } +} + fn run_json() -> ExitCode { let ctx = resolve(); let pushed = store::list_commits(Some(true), &ctx.ids, &ctx.names).unwrap_or_default(); diff --git a/crates/pcr-core/src/commands/pull.rs b/crates/pcr-core/src/commands/pull.rs index 3ba10eb..2abe84f 100644 --- a/crates/pcr-core/src/commands/pull.rs +++ b/crates/pcr-core/src/commands/pull.rs @@ -2,7 +2,7 @@ use crate::agent::{self, OutputMode}; use crate::auth; -use crate::display; +use crate::display::{self, Color}; use crate::entry::PullArgs; use crate::exit::ExitCode; use crate::store; @@ -89,11 +89,18 @@ pub fn run(_mode: OutputMode, args: PullArgs) -> ExitCode { restored += 1; } } + display::eprintln(""); display::eprintln(&format!( - "PCR: Restored {} prompt{} from prompt bundle {}", - restored, - plural(restored), - remote_id + "{} {}", + display::cstr(Color::Green, "▲"), + display::cstr( + Color::Bold, + &format!( + "restored {restored} prompt{} from bundle {remote_id}", + plural(restored) + ), + ), )); + display::print_hint("inspect them with `pcr show` or re-bundle and push when ready"); ExitCode::Success } diff --git a/crates/pcr-core/src/commands/push.rs b/crates/pcr-core/src/commands/push.rs index 9bc3d64..bb62cec 100644 --- a/crates/pcr-core/src/commands/push.rs +++ b/crates/pcr-core/src/commands/push.rs @@ -3,11 +3,12 @@ //! `upsert_bundle_prompts`. use std::collections::BTreeMap; +use std::time::Instant; use crate::agent::OutputMode; use crate::auth; use crate::config; -use crate::display; +use crate::display::{self, Color}; use crate::exit::ExitCode; use crate::projects; use crate::sources::shared::git; @@ -52,7 +53,9 @@ pub fn run(_mode: OutputMode) -> ExitCode { commits.push(c); } - let mut pushed = 0usize; + let started = Instant::now(); + let mut pushed_bundles = 0usize; + let mut pushed_prompts = 0usize; // The cwd's current branch is wherever the user happens to be when // they run `pcr push` — not where the prompts were captured. Pass // it as a last-ditch fallback only; per-bundle and per-prompt @@ -60,22 +63,57 @@ pub fn run(_mode: OutputMode) -> ExitCode { // normalizes detached-HEAD to empty. let cwd_branch_fallback = crate::commands::helpers::current_branch(); for commit in &commits { - pushed += push_bundle(&commit.id, &cwd_branch_fallback, &a.user_id); + let (ok, n_prompts) = push_bundle(&commit.id, &cwd_branch_fallback, &a.user_id); + pushed_bundles += ok; + pushed_prompts += n_prompts; } - if pushed == 0 { + if pushed_bundles == 0 { display::eprintln("PCR: Nothing new pushed."); + return ExitCode::Success; } + print_push_summary(pushed_prompts, pushed_bundles, started.elapsed()); ExitCode::Success } -fn push_bundle(local_id: &str, cwd_branch_fallback: &str, user_id: &str) -> usize { +/// Reward-the-user closing line. Mirrors the dashboard's "shipped" +/// surface: glyph + count + duration in success tone, with a tight hint +/// for the next move so review-flow momentum doesn't stall here. +fn print_push_summary(prompts: usize, bundles: usize, dur: std::time::Duration) { + let secs = dur.as_secs_f64(); + let took = if secs < 1.0 { + format!("{:.0}ms", dur.as_millis()) + } else { + format!("{:.1}s", secs) + }; + display::eprintln(""); + display::eprintln(&format!( + "{} {}", + display::cstr(Color::Green, "▲"), + display::cstr( + Color::Bold, + &format!( + "pushed {prompts} prompt{} in {bundles} bundle{} · {took}", + plural(prompts), + plural(bundles), + ), + ), + )); + display::print_hint("review on PCR.dev or run `pcr pull ` to restore drafts elsewhere"); +} + +/// Push a single sealed bundle. Returns `(bundles_pushed, prompts_pushed)` +/// — both are 1+N on success and 0/0 on any failure. Splitting them +/// lets the caller assemble a one-line summary without re-querying the +/// store for prompt counts. +fn push_bundle(local_id: &str, cwd_branch_fallback: &str, user_id: &str) -> (usize, usize) { let Some(c) = store::get_commit_with_items(local_id).ok().flatten() else { - return 0; + return (0, 0); }; let source = dominant_source(&c.items); let touched = collect_touched_projects(&c.items, cwd_branch_fallback); + let prompt_count = c.items.len(); let remote_id = match supabase::upsert_bundle( "", &BundleData { @@ -97,7 +135,8 @@ fn push_bundle(local_id: &str, cwd_branch_fallback: &str, user_id: &str) -> usiz "PCR: Failed to push prompt bundle {:?}: {e}", c.message )); - return 0; + display::print_hint("retry with `pcr push` — sealed bundles persist locally"); + return (0, 0); } }; @@ -138,7 +177,7 @@ fn push_bundle(local_id: &str, cwd_branch_fallback: &str, user_id: &str) -> usiz if let Some(pr_url) = detect_github_pr() { display::eprintln(&format!(" PR: {pr_url}")); } - 1 + (1, prompt_count) } /// Pick the bundle's branch from what the watchers actually captured. diff --git a/crates/pcr-core/src/entry.rs b/crates/pcr-core/src/entry.rs index 045d7e4..cca7b7f 100644 --- a/crates/pcr-core/src/entry.rs +++ b/crates/pcr-core/src/entry.rs @@ -5,7 +5,7 @@ //! `pcr help` (interactive) and `pcr --help` (line) say the same //! thing forever, without us having to maintain two copies. -use clap::{Args, Parser, Subcommand}; +use clap::{Args, CommandFactory, FromArgMatches, Parser, Subcommand}; use crate::agent::OutputMode; use crate::exit::ExitCode; @@ -211,7 +211,21 @@ pub struct GcArgs { /// Parse `argv` and dispatch to the matching command. Returns the process exit code. pub fn run(argv: Vec) -> i32 { - let cli = match Cli::try_parse_from(argv) { + // Inject rich `long_about` + `after_help` for every subcommand from + // the help.rs single-source-of-truth so `pcr --help` shows the + // same purpose / when-to-use / examples / see-also that the + // interactive `pcr help` TUI does. clap derive can't take runtime + // strings, so we splice them in on the builder side here. + let cmd = decorate_subcommand_help(Cli::command()); + let matches = match cmd.try_get_matches_from(argv) { + Ok(m) => m, + Err(e) => { + let code = e.exit_code(); + let _ = e.print(); + return code; + } + }; + let cli = match Cli::from_arg_matches(&matches) { Ok(cli) => cli, Err(e) => { let code = e.exit_code(); @@ -247,3 +261,60 @@ pub fn run(argv: Vec) -> i32 { // Re-export a render helper for the command implementations that want to // emit the long-form help in plain mode. pub use help::render_plain as render_command_help; + +/// Map clap's subcommand name back to the `help.rs` entry key. Keep this +/// centralised — clap subcommand names are kebab-cased while help keys +/// stay lower-case-with-underscores, so an exact 1:1 lookup misses the +/// nuance and dead-codes the rich help. +fn help_key_for(clap_name: &str) -> &'static str { + match clap_name { + "login" => "login", + "logout" => "logout", + "init" => "init", + "start" => "start", + "mcp" => "mcp", + "status" => "status", + "bundle" => "bundle", + "push" => "push", + "log" => "log", + "show" => "show", + "pull" => "pull", + "gc" => "gc", + "help" => "help", + "hook" => "hook", + _ => "", + } +} + +/// Walk every visible subcommand of the parsed `Cli::command()` tree and +/// splice the help.rs `long_about` + `after_help` into it. Non-visible +/// commands (today: just `hook`) are left alone — their help text is +/// internal and we'd rather they not show up under `--help` at all. +fn decorate_subcommand_help(mut cmd: clap::Command) -> clap::Command { + let names: Vec = cmd + .get_subcommands() + .map(|sc| sc.get_name().to_string()) + .collect(); + for name in names { + let key = help_key_for(&name); + if key.is_empty() { + continue; + } + let Some(entry) = help::entry(key) else { + continue; + }; + // `help` is a synthetic command that just opens the interactive + // index; rich `--help` output for it would just duplicate the + // root long_about. Skip. + if key == "help" { + continue; + } + let long_about = help::render_long_about(entry); + let after_help = help::render_after_help(entry); + cmd = cmd.mut_subcommand(&name, |sc| { + sc.long_about(long_about.clone()) + .after_help(after_help.clone()) + }); + } + cmd +} diff --git a/crates/pcr-core/src/help.rs b/crates/pcr-core/src/help.rs index d42f7a6..d61f4df 100644 --- a/crates/pcr-core/src/help.rs +++ b/crates/pcr-core/src/help.rs @@ -241,3 +241,52 @@ pub fn render_plain(entry: &HelpEntry) -> String { out.push_str(&format!("\nMore: https://pcr.dev/docs/{}\n", entry.command)); out } + +/// Concise `long_about` for `pcr --help`. Pairs with +/// [`render_after_help`] so clap's two help slots split cleanly: +/// `long_about` carries purpose + the "when to use" paragraph (always +/// shown above the auto-generated USAGE / OPTIONS), and `after_help` +/// carries the worked examples + cross-refs (always shown below). +pub fn render_long_about(entry: &HelpEntry) -> String { + let mut out = String::new(); + out.push_str(entry.purpose); + out.push_str("\n\nWhen to use:\n "); + out.push_str(entry.when_to_use); + out +} + +/// Render the worked examples + see-also block for clap's `after_help`. +/// Falls under USAGE in `pcr --help` so newcomers always get a +/// copy-pasteable command instead of having to read the prose. +pub fn render_after_help(entry: &HelpEntry) -> String { + let mut out = String::new(); + if !entry.examples.is_empty() { + out.push_str("Examples:\n"); + for (cmd, desc) in entry.examples { + out.push_str(&format!(" $ {cmd}\n {desc}\n")); + } + } + if !entry.see_also.is_empty() { + if !out.is_empty() { + out.push('\n'); + } + out.push_str("See also: "); + out.push_str( + &entry + .see_also + .iter() + .map(|s| format!("pcr {s}")) + .collect::>() + .join(", "), + ); + out.push('\n'); + } + if !out.is_empty() { + out.push('\n'); + } + out.push_str(&format!( + "Docs: https://pcr.dev/docs/{}", + entry.command + )); + out +} diff --git a/crates/pcr-core/src/tui/screens/show.rs b/crates/pcr-core/src/tui/screens/show.rs index a6e1fcd..d0cb925 100644 --- a/crates/pcr-core/src/tui/screens/show.rs +++ b/crates/pcr-core/src/tui/screens/show.rs @@ -35,6 +35,10 @@ use crate::util::id::generate_hex_id; use crate::util::time::{fmt_time, local_hms}; use crate::VERSION; +/// Refresh "last push age" every ~2s. 500 ms tick × 4 = 2 s — same +/// cadence the `pcr start` dashboard refreshes its project counts at. +const STATUS_STRIP_REFRESH_TICKS: u32 = 4; + /// Re-exported alias so callers reading the source see what the /// browser was historically returning. New code should prefer /// [`crate::tui::NavTarget`] directly. @@ -122,6 +126,8 @@ pub fn run_focused_with_reload( InitialView::Drafts => BrowseMode::Drafts, InitialView::Bundles => BrowseMode::Bundles, }; + let bundles = load_bundles(); + let status_strip = build_status_strip(drafts.len(), &bundles); let mut state = ShowState { drafts, focus, @@ -134,7 +140,7 @@ pub fn run_focused_with_reload( outcome: NavTarget::Quit, nav_dir: NavDir::Down, mode, - bundles: load_bundles(), + bundles, bundle_focus: 0, bundles_state: ListState::default(), prefill_bundle_name: prefill_bundle_name @@ -142,6 +148,8 @@ pub fn run_focused_with_reload( .filter(|s| !s.is_empty()), reload_drafts, last_mode_switch: None, + status_strip, + tick_counter: 0, }; state.list_state.select(Some(focus)); if !state.bundles.is_empty() { @@ -168,6 +176,10 @@ pub fn run_focused_with_reload( state.push_armed = false; } } + state.tick_counter = state.tick_counter.wrapping_add(1); + if state.tick_counter % STATUS_STRIP_REFRESH_TICKS == 0 { + state.status_strip = build_status_strip(state.drafts.len(), &state.bundles); + } } Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {} _ => {} @@ -220,6 +232,26 @@ struct ShowState { /// terminals, don't render-storm). `None` means "never toggled", /// which always allows the next toggle through. last_mode_switch: Option, + /// Context strip data — surfaces project / branch / pending / last + /// push at the top of the screen so the user never has to bounce + /// back to `pcr status` to remember where they are. Refreshed on + /// init + every 2 s (`STATUS_STRIP_REFRESH_TICKS`). + status_strip: StatusStrip, + /// Tick counter for the status-strip refresh cadence. + tick_counter: u32, +} + +/// Snapshot of the context strip rendered just under the header. All +/// fields are owned strings so the renderer doesn't need access to +/// state-resolving helpers — just read these and lay them out. +struct StatusStrip { + project: String, + branch: String, + pending: usize, + /// ISO-8601 timestamp of the most recent push, or empty when the + /// user hasn't pushed yet. The strip renders it as a human age + /// ("3m ago") at draw time. + last_push_at: String, } #[derive(Clone, Copy, PartialEq, Eq)] @@ -332,6 +364,42 @@ fn handle_key(k: KeyEvent, state: &mut ShowState) -> bool { )); } } + KeyCode::Char('y') => { + // vim-style yank: copy the focused draft's *id* so users + // can paste it into `pcr show `, an issue tracker, etc. + // The prompt body is on `c`; this is the cheap addressable + // handle. + if let Some(d) = state.drafts.get(state.focus) { + let copied = copy_to_clipboard(&d.id); + state.copy_flash = Some(( + if copied { + format!("Yanked draft id ({}…)", short_id(&d.id)) + } else { + "Couldn't access clipboard.".into() + }, + 3, + )); + } + } + KeyCode::Char('o') => { + // Open the project dashboard for the focused draft, or the + // root dashboard if the draft has no remote project_id yet. + // No-op (with a flash) when neither path is reachable. + let url = state + .drafts + .get(state.focus) + .map(draft_dashboard_url) + .unwrap_or_else(|| crate::config::APP_URL.to_string()); + let ok = webbrowser::open(&url).is_ok(); + state.copy_flash = Some(( + if ok { + format!("Opened {url}") + } else { + format!("Couldn't open browser — visit {url}") + }, + 4, + )); + } KeyCode::Char(' ') | KeyCode::Enter => { toggle_focused(state); advance_focus(state); @@ -435,7 +503,7 @@ fn handle_key(k: KeyEvent, state: &mut ShowState) -> bool { } KeyCode::Char('?') => { state.copy_flash = Some(( - "j/k move · enter/space select · J/K select+move · : range · a all · b bundle · p push · tab bundles view · c copy · d delete (focused, or all selected with confirmation) · q quit" + "j/k move · enter/space select · J/K select+move · : range · a all · b bundle · p push · tab bundles view · c copy prompt · y yank id · o open in browser · d delete (focused, or all selected with confirmation) · q quit" .into(), 14, )); @@ -665,10 +733,54 @@ fn handle_bundles_key(k: KeyEvent, state: &mut ShowState) -> bool { return false; } } + KeyCode::Char('y') => { + // Yank the focused bundle's id (remote_id if it's been + // pushed, otherwise the local bundle id). Mirrors the + // dashboard URL bar's "copy bundle link" affordance. + if let Some(b) = state.bundles.get(state.bundle_focus) { + let id = if b.remote_id.is_empty() { + &b.id + } else { + &b.remote_id + }; + let copied = copy_to_clipboard(id); + state.copy_flash = Some(( + if copied { + format!("Yanked bundle id ({}…)", short_id(id)) + } else { + "Couldn't access clipboard.".into() + }, + 3, + )); + } + } + KeyCode::Char('o') => { + // Open the review URL on PCR.dev — works whether the bundle + // has been pushed (real remote_id) or only sealed locally + // (we still link to /review/ so the dashboard + // shows a "not yet pushed" surface instead of a 404). + if let Some(b) = state.bundles.get(state.bundle_focus) { + let id = if b.remote_id.is_empty() { + &b.id + } else { + &b.remote_id + }; + let url = format!("{}/review/{}", crate::config::APP_URL, id); + let ok = webbrowser::open(&url).is_ok(); + state.copy_flash = Some(( + if ok { + format!("Opened {url}") + } else { + format!("Couldn't open browser — visit {url}") + }, + 4, + )); + } + } KeyCode::Char('d') => delete_focused_bundle(state), KeyCode::Char('?') => { state.copy_flash = Some(( - "tab back to drafts · j/k move · p push all · d delete focused · q quit".into(), + "tab back to drafts · j/k move · p push all · y yank id · o open · d delete · q quit".into(), 10, )); } @@ -1012,6 +1124,111 @@ fn load_bundles() -> Vec { all } +/// Build the context strip data once. Reads the project context (cheap, +/// in-process) and queries the store for the newest pushed commit's +/// `pushed_at` timestamp. Falls back to empty strings when any of the +/// reads fail so the strip never panics on a fresh install. +fn build_status_strip(pending: usize, bundles: &[PromptCommit]) -> StatusStrip { + let ctx = resolve(); + let _ = bundles; + // Newest pushed commit's `pushed_at`. We rely on the store's + // descending sort (newest first); the lookup is a single small + // query so we can afford to redo it every 2 s. + let last_push_at = store::list_pushed_commits() + .ok() + .and_then(|v| v.into_iter().next()) + .map(|c| c.pushed_at) + .unwrap_or_default(); + StatusStrip { + project: ctx.name, + branch: current_branch(), + pending, + last_push_at, + } +} + +/// Render the 1-row context strip: project · branch · drafts · last +/// push. Mirrors the dashboard's "at a glance" header so terminal users +/// don't have to bounce back to `pcr status` to remember what they're +/// looking at. +fn draw_status_strip(frame: &mut ratatui::Frame, area: Rect, state: &ShowState) { + let s = &state.status_strip; + let mut spans: Vec> = Vec::new(); + spans.push(Span::raw(" ")); + + if s.project.is_empty() { + spans.push(Span::styled("no project", theme::pending())); + } else { + spans.push(Span::styled(s.project.clone(), theme::accent_bold())); + } + + if !s.branch.is_empty() { + spans.push(Span::styled(" · ", theme::chrome())); + spans.push(Span::styled(format!("⎇ {}", s.branch), theme::text())); + } + + spans.push(Span::styled(" · ", theme::chrome())); + let pending_glyph = if s.pending == 0 { + glyphs::SUCCESS + } else { + glyphs::PENDING + }; + let pending_style = if s.pending == 0 { + theme::dim() + } else { + theme::pending() + }; + spans.push(Span::styled( + format!("{} {} draft{}", pending_glyph, s.pending, plural(s.pending)), + pending_style, + )); + + spans.push(Span::styled(" · ", theme::chrome())); + let push_label = if s.last_push_at.is_empty() { + "↑ never pushed".to_string() + } else { + format!("↑ {}", time_ago_short(&s.last_push_at)) + }; + let push_style = if s.last_push_at.is_empty() { + theme::dim() + } else { + theme::text() + }; + spans.push(Span::styled(push_label, push_style)); + + frame.render_widget(Paragraph::new(Line::from(spans)), area); +} + +/// Compact "Nm ago" / "Nh ago" / "Nd ago" formatter for the status +/// strip. Always returns a short string so the strip stays scannable +/// on a 80-column terminal. Returns `now` for sub-minute deltas. +fn time_ago_short(iso: &str) -> String { + use chrono::{DateTime, Utc}; + let Ok(t) = DateTime::parse_from_rfc3339(iso) else { + return "—".into(); + }; + let secs = (Utc::now() - t.with_timezone(&Utc)).num_seconds(); + if secs < 0 { + return "now".into(); + } + if secs < 60 { + return "now".into(); + } + if secs < 3600 { + return format!("{}m ago", secs / 60); + } + if secs < 86_400 { + return format!("{}h ago", secs / 3600); + } + if secs < 86_400 * 30 { + return format!("{}d ago", secs / 86_400); + } + // Anything older than a month: stop being precise — long enough + // that you'd open the dashboard for context anyway. + let months = secs / (86_400 * 30); + format!("{}mo ago", months) +} + fn bundle_choices_from(bundles: &[PromptCommit]) -> Vec { // Every unpushed bundle is a valid add target — the store's // `add_drafts_to_bundle` re-opens sealed bundles automatically. @@ -1093,6 +1310,7 @@ fn draw(frame: &mut ratatui::Frame, state: &ShowState) { .direction(Direction::Vertical) .constraints([ Constraint::Length(1), // header + Constraint::Length(1), // status strip Constraint::Min(10), // body Constraint::Length(1), // footer ]) @@ -1110,9 +1328,11 @@ fn draw(frame: &mut ratatui::Frame, state: &ShowState) { } .render(frame, chunks[0]); + draw_status_strip(frame, chunks[1], state); + if state.mode == BrowseMode::Bundles { - draw_bundles_view(frame, chunks[1], state); - draw_footer(frame, chunks[2], state); + draw_bundles_view(frame, chunks[2], state); + draw_footer(frame, chunks[3], state); return; } @@ -1133,8 +1353,8 @@ fn draw(frame: &mut ratatui::Frame, state: &ShowState) { ]) .alignment(Alignment::Left) .wrap(Wrap { trim: false }); - frame.render_widget(empty, chunks[1]); - draw_footer(frame, chunks[2], state); + frame.render_widget(empty, chunks[2]); + draw_footer(frame, chunks[3], state); return; } @@ -1149,7 +1369,7 @@ fn draw(frame: &mut ratatui::Frame, state: &ShowState) { Constraint::Min(40), // detail Constraint::Length(28), // changed files / tools ]) - .split(chunks[1]) + .split(chunks[2]) } else { Layout::default() .direction(Direction::Horizontal) @@ -1157,7 +1377,7 @@ fn draw(frame: &mut ratatui::Frame, state: &ShowState) { Constraint::Length(28), // drafts list Constraint::Min(40), // detail (gets the sidebar's columns) ]) - .split(chunks[1]) + .split(chunks[2]) }; draw_list(frame, cols[0], state); @@ -1165,10 +1385,10 @@ fn draw(frame: &mut ratatui::Frame, state: &ShowState) { if show_sidebar { draw_sidebar(frame, cols[2], state); } - draw_footer(frame, chunks[2], state); + draw_footer(frame, chunks[3], state); if state.prompt.is_some() { - draw_name_prompt(frame, chunks[1], state); + draw_name_prompt(frame, chunks[2], state); } } @@ -1924,6 +2144,10 @@ fn bundles_view_hints() -> Vec> { Span::styled(" move ", theme::dim()), Span::styled("p", theme::accent()), Span::styled(" push all ", theme::dim()), + Span::styled("y", theme::accent()), + Span::styled(" yank id ", theme::dim()), + Span::styled("o", theme::accent()), + Span::styled(" open ", theme::dim()), Span::styled("d", theme::accent()), Span::styled(" delete ", theme::dim()), Span::styled("q", theme::accent()), @@ -1940,6 +2164,26 @@ fn short_path(p: &str) -> String { format!("…/{}", tail.join("/")) } +/// First 8 chars of an id for the copy-flash banner. Bundle ids look +/// like `bundle-<32hex>` and draft ids are uuid-shaped; either way the +/// first 8 chars are enough to identify a row in the active screen +/// without bleeding clipboard contents into the flash. +fn short_id(id: &str) -> String { + let take = id.strip_prefix("bundle-").unwrap_or(id); + take.chars().take(8).collect() +} + +/// Dashboard URL for the project a draft belongs to. Falls back to the +/// app root when the draft hasn't been attributed to a remote project +/// yet (e.g. anonymous capture). +fn draft_dashboard_url(d: &DraftRecord) -> String { + if d.project_id.is_empty() { + crate::config::APP_URL.to_string() + } else { + format!("{}/projects/{}", crate::config::APP_URL, d.project_id) + } +} + /// Best-effort clipboard copy via `pbcopy` / `wl-copy` / `xclip` / /// `xsel` / `clip.exe`. Returns false when no tool was available. fn copy_to_clipboard(text: &str) -> bool { From 4a7ad784a43d4f06649318585db3092552701b29 Mon Sep 17 00:00:00 2001 From: Bhada Yun Date: Sat, 16 May 2026 21:24:03 +0200 Subject: [PATCH 2/2] TUI improvements Co-authored-by: Cursor --- .github/workflows/ci.yml | 47 +++++ Cargo.toml | 2 +- README.md | 2 +- RELEASING.md | 25 ++- crates/pcr-cli/Cargo.toml | 3 + crates/pcr-cli/tests/common/mod.rs | 76 +++++++ crates/pcr-cli/tests/cursor_ingestion.rs | 125 ++++++++++++ crates/pcr-cli/tests/db_migration.rs | 187 ++++++++++++++++++ crates/pcr-cli/tests/golden.rs | 56 ++++-- crates/pcr-core/src/tui/widgets/header_bar.rs | 81 ++++++++ .../tests/claude_fixture_roundtrip.rs | 98 +++++++++ .../fixtures/claude/minimal_session.jsonl | 3 + .../fixtures/vscode/minimal_chatsession.jsonl | 2 + .../tests/vscode_fixture_roundtrip.rs | 81 ++++++++ crates/pcr-napi/npm/darwin-arm64/package.json | 2 +- crates/pcr-napi/npm/darwin-x64/package.json | 2 +- .../pcr-napi/npm/linux-x64-gnu/package.json | 2 +- .../pcr-napi/npm/win32-x64-msvc/package.json | 2 +- crates/pcr-napi/package.json | 10 +- 19 files changed, 778 insertions(+), 28 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 crates/pcr-cli/tests/common/mod.rs create mode 100644 crates/pcr-cli/tests/cursor_ingestion.rs create mode 100644 crates/pcr-cli/tests/db_migration.rs create mode 100644 crates/pcr-core/tests/claude_fixture_roundtrip.rs create mode 100644 crates/pcr-core/tests/fixtures/claude/minimal_session.jsonl create mode 100644 crates/pcr-core/tests/fixtures/vscode/minimal_chatsession.jsonl create mode 100644 crates/pcr-core/tests/vscode_fixture_roundtrip.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..f02997c --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,47 @@ +name: CI + +# `release.yml` already runs `lint` on every tag push. This workflow +# covers everything else: pull requests targeting `main`, and direct +# pushes to any non-main branch (so the author sees a green check +# before opening the PR). `main` is protected — push events on main +# only come from squash-merges, which the PR's own check already +# validated. + +on: + pull_request: + branches: [main] + push: + branches-ignore: [main] + +permissions: + contents: read + +env: + CARGO_TERM_COLOR: always + +jobs: + lint-and-test: + name: Lint & test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + components: clippy, rustfmt + - uses: Swatinem/rust-cache@v2 + with: + workspaces: . + - name: fmt + run: cargo fmt --all -- --check + - name: clippy + run: cargo clippy --workspace --all-targets -- -D warnings + - name: test + # `Cargo.lock` is gitignored (see `.gitignore`), so `--locked` + # would fail on a fresh clone before `cargo` resolved deps. + # Match `release.yml`'s `lint` job exactly. + run: cargo test --workspace + - name: doc (non-failing signal) + # Best-effort docs build — no `-D warnings` yet. Once doc + # warnings are under control, promote this to a hard gate. + run: cargo doc --workspace --no-deps + continue-on-error: true diff --git a/Cargo.toml b/Cargo.toml index bf2a3e7..888323f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,7 +7,7 @@ members = [ ] [workspace.package] -version = "0.2.8" +version = "0.2.9" edition = "2021" license = "Apache-2.0" repository = "https://github.com/pcr-developers/cli" diff --git a/README.md b/README.md index 82cb0f8..63b4fb2 100644 --- a/README.md +++ b/README.md @@ -104,7 +104,7 @@ Run `pcr --help` for examples. Full reference at [pcr.dev/docs](https://pc registered projects, the live event log, and the draft → bundled → pushed pipeline: ```text -PCR.dev · start · v0.2.8 ✓ bhada@pcr.dev 14:08:42 +PCR.dev · start · v0.2.9 ✓ bhada@pcr.dev 14:08:42 Capturing — 7 exchanges this session, 3 unbundled across 2 projects ▁▃▆█▇▅▂ diff --git a/RELEASING.md b/RELEASING.md index ddfc50e..83c1ba4 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -1,5 +1,9 @@ # Releasing `pcr-dev` +> `main` is protected — direct pushes are rejected. All releases must +> go through a PR (`gh pr create … && gh pr merge --squash +> --delete-branch`) before the tag push step below. + This repo now produces two artifacts from a single Rust codebase rooted at the repo root: @@ -23,13 +27,28 @@ merge. The last Go release lives behind the `v0.1.17` tag if you ever need it. # crates/pcr-napi/package.json — optionalDependencies entries ``` -2. Commit, tag, push. +2. Land the bump via a PR, then tag the merge commit. + + `main` is protected; direct pushes are rejected. Open a PR with the + version-bump commit, get the PR-CI workflow green, squash-merge, and + then tag the resulting merge commit: ```bash + # On a release branch: + git checkout -b release/v0.2.0 git add -A git commit -m "release v0.2.0" - git tag v0.2.0 - git push && git push --tags + git push -u origin HEAD + + # Open + merge the PR (squash to keep a single release commit on main): + gh pr create --fill --base main + gh pr merge --squash --delete-branch + + # Once the squash-merge lands, tag the merge commit on main and push: + git checkout main + git pull --ff-only + git tag -a v0.2.0 -m "v0.2.0" + git push origin v0.2.0 ``` The tag push triggers `.github/workflows/release.yml`, which runs: diff --git a/crates/pcr-cli/Cargo.toml b/crates/pcr-cli/Cargo.toml index f55e70c..531f36c 100644 --- a/crates/pcr-cli/Cargo.toml +++ b/crates/pcr-cli/Cargo.toml @@ -20,3 +20,6 @@ assert_cmd = "2.0" predicates = "3.1" tempfile = "3.10" serde_json = { workspace = true } +# Already a workspace dep — reused here so the migration smoke test can +# build a v1-schema fixture DB and re-query it after migrations run. +rusqlite = { workspace = true } diff --git a/crates/pcr-cli/tests/common/mod.rs b/crates/pcr-cli/tests/common/mod.rs new file mode 100644 index 0000000..16cf441 --- /dev/null +++ b/crates/pcr-cli/tests/common/mod.rs @@ -0,0 +1,76 @@ +// Each integration test under `tests/` compiles as its own binary, so +// not every helper here will be referenced by every binary. Suppress +// the unused-helper warnings instead of sprinkling per-fn `allow`s. +#![allow(dead_code)] + +//! Shared integration-test harness for the `pcr` binary. +//! +//! Every integration test in this crate spawns the built `pcr` binary +//! through `assert_cmd`. The factory below bundles the env scrubbing +//! and `$HOME` / `cwd` isolation each test needs so we don't drift +//! between tests when the rules of "what env makes pcr deterministic" +//! evolve. + +use assert_cmd::Command; +use tempfile::TempDir; + +/// Build a freshly scrubbed `pcr` command bound to an isolated `$HOME`. +/// +/// The temp dir's lifetime is returned alongside the command — drop it +/// at the end of the test or the dir disappears mid-run on platforms +/// that reap deleted directories aggressively. +pub fn pcr() -> (Command, TempDir) { + let tmp = TempDir::new().expect("tempdir"); + let cmd = build(&tmp); + (cmd, tmp) +} + +/// Two-temp-dir bundle: one for `$HOME` (where `.pcr-dev/` lives), one +/// for the spawned process's cwd. Splitting them keeps the registered +/// project state separate from the working directory we're pretending +/// to be inside — useful when a test needs to assert that `pcr` reads +/// state from `$HOME` while running in an unrelated repo. +pub struct HomeFixture { + pub home: TempDir, + pub cwd: TempDir, +} + +impl HomeFixture { + pub fn home_path(&self) -> &std::path::Path { + self.home.path() + } + pub fn cwd_path(&self) -> &std::path::Path { + self.cwd.path() + } + pub fn pcr_dir(&self) -> std::path::PathBuf { + self.home.path().join(".pcr-dev") + } +} + +/// Build a `HomeFixture` with `$HOME/.pcr-dev/` pre-created so commands +/// don't have to mkdir on first boot. +pub fn home_fixture() -> HomeFixture { + let home = TempDir::new().expect("home tempdir"); + let cwd = TempDir::new().expect("cwd tempdir"); + std::fs::create_dir_all(home.path().join(".pcr-dev")).expect("pre-create pcr-dev"); + HomeFixture { home, cwd } +} + +/// Wire a `pcr` command against the given fixture: isolated `$HOME`, +/// the fixture's `cwd` as the process's working directory, and the +/// usual env scrubbing. +pub fn pcr_in(fx: &HomeFixture) -> Command { + let mut cmd = build(&fx.home); + cmd.current_dir(fx.cwd.path()); + cmd +} + +fn build(home: &TempDir) -> Command { + let mut cmd = Command::cargo_bin("pcr").expect("binary built"); + cmd.env("HOME", home.path()) + .env("USERPROFILE", home.path()) + .env_remove("CI") + .env_remove("NO_COLOR") + .env_remove("CURSOR_AGENT"); + cmd +} diff --git a/crates/pcr-cli/tests/cursor_ingestion.rs b/crates/pcr-cli/tests/cursor_ingestion.rs new file mode 100644 index 0000000..b8be168 --- /dev/null +++ b/crates/pcr-cli/tests/cursor_ingestion.rs @@ -0,0 +1,125 @@ +//! Cursor ingestion → drafts integration test (partial). +//! +//! ## Status: harness smoke + registered-project log roundtrip +//! +//! The original Step 1 plan asked for a full e2e test driving a Cursor +//! transcript through `pcr --plain bundle` into the drafts store, then +//! re-reading via `pcr --json log`. That turned out to be intractable +//! to fixture from a subprocess: +//! +//! - `crates/pcr-core/src/sources/cursor/watcher.rs` only iterates +//! `~/.cursor/projects//agent-transcripts/*.jsonl` to discover +//! *session ids*. The actual prompt + response payloads live in +//! Cursor's `state.vscdb` SQLite database +//! (`~/Library/Application Support/Cursor/User/globalStorage/state.vscdb` +//! on macOS, `~/.config/Cursor/...` on Linux, `%APPDATA%/Cursor/...` on +//! Windows), parsed by `sources/cursor/db.rs::get_session_meta` from +//! the `cursorDiskKV` key-value table with `composerData:` and +//! `bubbleId::` rows. +//! - Faking that DB inside a subprocess test is brittle: it requires +//! replicating Cursor's exact JSON-in-SQLite schema, OS-specific path +//! resolution, and the `headers_only` + per-bubble fan-out. Drift on +//! their side silently makes our test green for the wrong reason. +//! +//! The pragmatic fallback that lives below exercises: +//! - The shared `common` harness (HOME + cwd isolation). +//! - `projects.json` seeding (no `pcr init` needed — register direct). +//! - `pcr --json log` resolving the cwd to the registered project. +//! +//! TODO(repair/follow-up): if we ever need true Cursor ingestion +//! coverage, the right shape is a `crates/pcr-core/tests/`-style test +//! that calls `sources/cursor/watcher.rs::PromptScanner` directly with +//! a hand-built `state.vscdb` fixture, NOT a subprocess test through +//! `pcr bundle`. + +mod common; + +use common::{home_fixture, pcr_in}; + +fn seed_projects_json(fx: &common::HomeFixture, name: &str) { + // Match the on-disk shape `crates/pcr-core/src/projects.rs::Registry` + // serializes: `{ "projects": [ { path, cursorSlug, claudeSlug, name, + // registeredAt } ] }`. Keep the camelCase keys — the registry is + // shared with the (archived) Go build's `projects.json`. + // + // Canonicalize the cwd path: `tempfile::TempDir` on macOS hands back + // `/var/folders/...` but `pcr`'s `std::env::current_dir()` resolves + // through the `/private` symlink and reports `/private/var/...`. + // `project_context::resolve` does a literal string compare against + // `projects.json::path`, so we must write the post-symlink form. + let path = std::fs::canonicalize(fx.cwd_path()) + .expect("canonicalize cwd") + .to_string_lossy() + .into_owned(); + let cursor_slug = path.trim_start_matches('/').replace(['/', '.'], "-"); + let claude_slug = path.replace('/', "-"); + let registry = serde_json::json!({ + "projects": [{ + "path": path, + "cursorSlug": cursor_slug, + "claudeSlug": claude_slug, + "name": name, + "registeredAt": "2026-01-01T00:00:00Z", + }] + }); + let projects_path = fx.pcr_dir().join("projects.json"); + std::fs::write( + &projects_path, + serde_json::to_vec_pretty(®istry).unwrap(), + ) + .expect("write projects.json"); +} + +#[test] +fn registered_project_log_returns_named_empty_store() { + let fx = home_fixture(); + seed_projects_json(&fx, "test-proj"); + + let out = pcr_in(&fx) + .args(["--json", "log"]) + .assert() + .success() + .get_output() + .clone(); + let parsed: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap_or_else(|e| { + let s = String::from_utf8_lossy(&out.stdout); + panic!("expected JSON, got {s:?}: {e}") + }); + + assert_eq!(parsed["project_name"], "test-proj"); + for key in ["pushed", "unpushed", "drafts"] { + let arr = parsed[key] + .as_array() + .unwrap_or_else(|| panic!("{key} not an array: {parsed}")); + assert!(arr.is_empty(), "{key} expected empty, got {arr:?}"); + } +} + +#[test] +fn registered_project_log_is_idempotent_across_runs() { + let fx = home_fixture(); + seed_projects_json(&fx, "idem-proj"); + + let first = pcr_in(&fx) + .args(["--json", "log"]) + .assert() + .success() + .get_output() + .clone() + .stdout; + let second = pcr_in(&fx) + .args(["--json", "log"]) + .assert() + .success() + .get_output() + .clone() + .stdout; + + // Byte-identical (or at least the parsed JSON matches). Use parsed + // comparison since the json! pretty-printer may shift trailing + // whitespace between runs. + let a: serde_json::Value = serde_json::from_slice(&first).unwrap(); + let b: serde_json::Value = serde_json::from_slice(&second).unwrap(); + assert_eq!(a, b, "log JSON should be stable across runs"); + assert_eq!(a["project_name"], "idem-proj"); +} diff --git a/crates/pcr-cli/tests/db_migration.rs b/crates/pcr-cli/tests/db_migration.rs new file mode 100644 index 0000000..57aa92a --- /dev/null +++ b/crates/pcr-cli/tests/db_migration.rs @@ -0,0 +1,187 @@ +//! SQLite migration smoke test. +//! +//! Materializes a v1-schema `drafts.db` in an isolated `$HOME/.pcr-dev/`, +//! invokes `pcr --json log` (which opens the store and triggers the +//! migration ladder in `crates/pcr-core/src/store/db.rs::migrate`), and +//! re-queries the resulting DB to confirm: +//! +//! - `schema_version` advanced to 6. +//! - All v2..v6 column adds + table adds (`git_diff`, `head_sha`, +//! `permission_mode`, `diff_events`, `session_state_events`, +//! `saved_bubbles`, `bundle_status`) are present. +//! - The original v1 drafts survived. +//! +//! ## Fixture provenance +//! +//! The plan asked for a checked-in `tests/fixtures/db/drafts_v1.sqlite` +//! binary blob. We materialize it programmatically instead — same +//! observable outcome (a v1 DB that the migration ladder must lift), +//! without committing a binary that's hard to diff in code review. The +//! v1 schema SQL below is copy-pasted verbatim from `migrate_v1` as of +//! v0.2.8; future drift in `migrate_v1` will NOT affect this test +//! because the fixture's schema is frozen in this file. + +mod common; + +use common::home_fixture; +use rusqlite::Connection; + +/// Original `migrate_v1` SQL, frozen at v0.2.8. Keep byte-identical with +/// the production code path that shipped in v1 of `drafts.db`. +const V1_SCHEMA_SQL: &str = r#" +CREATE TABLE IF NOT EXISTS drafts ( + id TEXT PRIMARY KEY, + content_hash TEXT UNIQUE NOT NULL, + session_id TEXT NOT NULL, + project_id TEXT, + project_name TEXT NOT NULL, + branch_name TEXT, + prompt_text TEXT NOT NULL, + response_text TEXT, + model TEXT, + source TEXT NOT NULL, + capture_method TEXT NOT NULL, + tool_calls TEXT, + file_context TEXT, + captured_at TEXT NOT NULL, + session_commit_shas TEXT, + status TEXT NOT NULL DEFAULT 'draft', + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE INDEX IF NOT EXISTS drafts_status ON drafts(status); +CREATE INDEX IF NOT EXISTS drafts_project ON drafts(project_id); +CREATE INDEX IF NOT EXISTS drafts_captured ON drafts(captured_at); + +CREATE TABLE IF NOT EXISTS prompt_commits ( + id TEXT PRIMARY KEY, + message TEXT NOT NULL, + project_id TEXT, + project_name TEXT, + branch_name TEXT, + session_shas TEXT, + head_sha TEXT NOT NULL, + pushed_at TEXT, + remote_id TEXT, + committed_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS prompt_commit_items ( + prompt_commit_id TEXT NOT NULL REFERENCES prompt_commits(id), + draft_id TEXT NOT NULL REFERENCES drafts(id), + PRIMARY KEY (prompt_commit_id, draft_id) +); + +CREATE TABLE IF NOT EXISTS schema_version (version INTEGER NOT NULL); +INSERT INTO schema_version (version) VALUES (1); +"#; + +fn install_v1_drafts_db(db_path: &std::path::Path) { + let conn = Connection::open(db_path).expect("create v1 drafts.db"); + conn.execute_batch(V1_SCHEMA_SQL).expect("apply v1 schema"); + // Two sample drafts so the post-migration query has rows to find. + conn.execute( + "INSERT INTO drafts (id, content_hash, session_id, project_name, prompt_text, source, capture_method, captured_at) \ + VALUES ('id-1', 'hash-1', 'sess-a', 'fixture-proj', 'how do I refactor this?', 'cursor', 'prompt-scanner', '2026-01-01T00:00:00Z')", + [], + ).expect("insert sample draft 1"); + conn.execute( + "INSERT INTO drafts (id, content_hash, session_id, project_name, prompt_text, source, capture_method, captured_at) \ + VALUES ('id-2', 'hash-2', 'sess-b', 'fixture-proj', 'add a test', 'claude-code', 'file-watcher', '2026-01-02T00:00:00Z')", + [], + ).expect("insert sample draft 2"); +} + +#[test] +fn migrates_v1_drafts_db_to_current() { + let fx = home_fixture(); + let db_path = fx.pcr_dir().join("drafts.db"); + install_v1_drafts_db(&db_path); + + // Pre-migration sanity: schema_version is 1 and v2+ tables are absent. + { + let conn = Connection::open(&db_path).expect("open pre-migrate"); + let v: i64 = conn + .query_row("SELECT MAX(version) FROM schema_version", [], |r| r.get(0)) + .expect("read schema_version"); + assert_eq!(v, 1, "fixture must start at v1"); + } + + // `pcr --json log` opens the store, which triggers `migrate`. With + // no projects.json + no drafts attributed to this cwd it exits 0. + common::pcr_in(&fx) + .args(["--json", "log"]) + .assert() + .success(); + + // Post-migration: schema_version advanced and v2..v6 changes landed. + let conn = Connection::open(&db_path).expect("open post-migrate"); + let version: i64 = conn + .query_row("SELECT MAX(version) FROM schema_version", [], |r| r.get(0)) + .expect("read schema_version post-migrate"); + assert_eq!(version, 6, "expected migrations up through v6 to run"); + + // Spot-check each migration's add. + // v2: drafts.git_diff column + prompt_commits.bundle_status column. + let drafts_cols = collect_columns(&conn, "drafts"); + assert!( + drafts_cols.contains(&"git_diff".to_string()), + "drafts.git_diff missing (v2 didn't run): {drafts_cols:?}" + ); + let commits_cols = collect_columns(&conn, "prompt_commits"); + assert!( + commits_cols.contains(&"bundle_status".to_string()), + "prompt_commits.bundle_status missing (v2 didn't run): {commits_cols:?}" + ); + // v3: diff_events table. + assert!( + table_exists(&conn, "diff_events"), + "diff_events missing (v3)" + ); + // v4: drafts.head_sha column. + assert!( + drafts_cols.contains(&"head_sha".to_string()), + "drafts.head_sha missing (v4 didn't run): {drafts_cols:?}" + ); + // v5: session_state_events + saved_bubbles tables. + assert!( + table_exists(&conn, "session_state_events"), + "session_state_events missing (v5)" + ); + assert!( + table_exists(&conn, "saved_bubbles"), + "saved_bubbles missing (v5)" + ); + // v6: drafts.permission_mode column. + assert!( + drafts_cols.contains(&"permission_mode".to_string()), + "drafts.permission_mode missing (v6 didn't run): {drafts_cols:?}" + ); + + // Original rows survived. + let count: i64 = conn + .query_row("SELECT COUNT(*) FROM drafts", [], |r| r.get(0)) + .expect("count drafts"); + assert_eq!(count, 2, "original v1 drafts should survive migration"); +} + +fn collect_columns(conn: &Connection, table: &str) -> Vec { + let mut stmt = conn + .prepare(&format!("PRAGMA table_info({table})")) + .expect("prepare table_info"); + let rows = stmt + .query_map([], |r| r.get::<_, String>(1)) + .expect("table_info rows"); + rows.filter_map(|r| r.ok()).collect() +} + +fn table_exists(conn: &Connection, name: &str) -> bool { + let n: i64 = conn + .query_row( + "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?", + [name], + |r| r.get(0), + ) + .unwrap_or(0); + n > 0 +} diff --git a/crates/pcr-cli/tests/golden.rs b/crates/pcr-cli/tests/golden.rs index 7c90f16..37cf592 100644 --- a/crates/pcr-cli/tests/golden.rs +++ b/crates/pcr-cli/tests/golden.rs @@ -7,23 +7,12 @@ //! The Go CLI's stdout for the same inputs is expected to differ only in //! trailing whitespace, which assert_cmd's `contains` predicates tolerate. -use assert_cmd::Command; +mod common; + +use common::pcr; use predicates::prelude::*; use tempfile::TempDir; -/// Build a cmd bound to an isolated `$HOME` so the test doesn't touch the -/// developer's real `~/.pcr-dev/` state. -fn pcr() -> (Command, TempDir) { - let tmp = TempDir::new().expect("tempdir"); - let mut cmd = Command::cargo_bin("pcr").expect("binary built"); - cmd.env("HOME", tmp.path()) - .env("USERPROFILE", tmp.path()) - .env_remove("CI") - .env_remove("NO_COLOR") - .env_remove("CURSOR_AGENT"); - (cmd, tmp) -} - #[test] fn help_mentions_pcr_dev_and_every_subcommand() { let (mut cmd, _tmp) = pcr(); @@ -187,3 +176,42 @@ fn log_plain_empty_store_messaging() { "no project is registered for this directory", )); } + +// ─── Exit-code matrix gap-fillers (Step 5) ────────────────────────────────── + +#[test] +fn push_without_auth_exits_auth_required() { + // `pcr push` reaches Supabase, which requires an auth token; with + // an empty `~/.pcr-dev/auth.json` we should bail with the + // AuthRequired code (10) before any network call. + let (mut cmd, _tmp) = pcr(); + cmd.arg("--plain") + .arg("push") + .assert() + .code(10) + .stderr(predicate::str::contains("Not logged in")); +} + +#[test] +fn bundle_with_unknown_flag_exits_usage() { + // clap rejects unknown args with exit code 2 (Usage) — pin the code + // so a refactor that swaps clap for hand-rolled parsing keeps the + // contract. + let (mut cmd, _tmp) = pcr(); + cmd.arg("--plain") + .arg("bundle") + .arg("--nonexistent-flag") + .assert() + .code(2); +} + +#[test] +fn bundle_delete_without_name_exits_usage() { + let (mut cmd, _tmp) = pcr(); + cmd.arg("--plain") + .arg("bundle") + .arg("--delete") + .assert() + .code(2) + .stderr(predicate::str::contains("--delete requires a bundle name")); +} diff --git a/crates/pcr-core/src/tui/widgets/header_bar.rs b/crates/pcr-core/src/tui/widgets/header_bar.rs index d3b1125..33f6c56 100644 --- a/crates/pcr-core/src/tui/widgets/header_bar.rs +++ b/crates/pcr-core/src/tui/widgets/header_bar.rs @@ -83,3 +83,84 @@ impl HeaderBar { self.render(frame, inner); } } + +#[cfg(test)] +mod tests { + use super::*; + use ratatui::backend::TestBackend; + use ratatui::Terminal; + + /// Render `bar` onto an 80×3 test backend and return the flattened + /// row-major text content (no styles). Styles are intentionally + /// dropped — asserting on `Cell` styling makes tests fragile to + /// theme tweaks. The cell glyphs alone catch the regressions we + /// actually care about (missing version, brand, user, clock). + fn render_to_text(bar: &HeaderBar, width: u16) -> String { + let backend = TestBackend::new(width, 3); + let mut terminal = Terminal::new(backend).expect("test terminal"); + terminal + .draw(|frame| { + let area = Rect::new(0, 0, width, 1); + bar.render(frame, area); + }) + .expect("draw"); + let buf = terminal.backend().buffer().clone(); + let mut out = String::new(); + for y in 0..buf.area.height { + for x in 0..buf.area.width { + out.push_str(buf[(x, y)].symbol()); + } + out.push('\n'); + } + out + } + + #[test] + fn renders_brand_command_version_and_user() { + let bar = HeaderBar { + version: "0.2.8".into(), + user: Some("dev@example.com".into()), + command: "status", + clock: "12:34:56".into(), + }; + let text = render_to_text(&bar, 80); + assert!(text.contains("PCR"), "brand missing: {text:?}"); + assert!(text.contains(".dev"), "tld missing: {text:?}"); + assert!(text.contains("status"), "command missing: {text:?}"); + assert!(text.contains("v0.2.8"), "version missing: {text:?}"); + assert!(text.contains("dev@example.com"), "user missing: {text:?}"); + assert!(text.contains("12:34:56"), "clock missing: {text:?}"); + } + + #[test] + fn version_string_strips_leading_v_so_we_never_render_double() { + // Release CI passes the version straight from the git tag + // (`v0.2.8`). Without the strip in `render`, the header would + // print `vv0.2.8`. This regression was a real bug — keep it + // pinned. + let bar = HeaderBar { + version: "v0.2.8".into(), + user: None, + command: "status", + clock: "00:00:00".into(), + }; + let text = render_to_text(&bar, 80); + assert!(text.contains("v0.2.8")); + assert!(!text.contains("vv0.2.8"), "doubled v: {text:?}"); + } + + #[test] + fn shows_not_signed_in_when_user_is_none_and_wide_enough() { + let bar = HeaderBar { + version: "0.2.8".into(), + user: None, + command: "status", + clock: "00:00:00".into(), + }; + let text = render_to_text(&bar, 80); + assert!( + text.contains("not signed in"), + "anonymous label missing: {text:?}" + ); + } +} diff --git a/crates/pcr-core/tests/claude_fixture_roundtrip.rs b/crates/pcr-core/tests/claude_fixture_roundtrip.rs new file mode 100644 index 0000000..39b29e8 --- /dev/null +++ b/crates/pcr-core/tests/claude_fixture_roundtrip.rs @@ -0,0 +1,98 @@ +//! Claude Code transcript → draft store fixture roundtrip. +//! +//! Runs the real `parse_claude_code_session` parser on a frozen JSONL +//! fixture and saves the result via `store::drafts::save_draft` (the +//! same public API the watcher uses). Then re-queries the store and +//! asserts the row is intact and stable across a second save (idempotent +//! dedupe via `content_hash`). +//! +//! Single test per file: the in-process SQLite singleton in +//! `crates/pcr-core/src/store/db.rs` would otherwise survive between +//! tests inside the same integration binary. + +use std::path::PathBuf; + +use pcr_core::sources::claudecode::parser::parse_claude_code_session; +use pcr_core::store; +use pcr_core::supabase::{prompt_content_hash_v2, prompt_id_v2, PromptRecord}; +use tempfile::TempDir; + +fn fixture_path() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("tests") + .join("fixtures") + .join("claude") + .join("minimal_session.jsonl") +} + +#[test] +fn claude_fixture_parses_and_persists_through_store() { + // Isolate HOME so `$HOME/.pcr-dev/drafts.db` lands in a tempdir + // and we don't touch the developer's real store. Must happen + // BEFORE the first `store::open()` because the singleton caches + // the resolved path. + let home = TempDir::new().expect("home tempdir"); + // SAFETY: this integration test binary contains exactly one #[test]; + // cargo runs each integration test binary in its own process; no + // other thread can observe the env mutation. + unsafe { + std::env::set_var("HOME", home.path()); + std::env::set_var("USERPROFILE", home.path()); + } + std::fs::create_dir_all(home.path().join(".pcr-dev")).expect("mkdir pcr-dev"); + + let body = std::fs::read_to_string(fixture_path()).expect("fixture readable"); + let parsed = + parse_claude_code_session(&body, "fixture-proj", &fixture_path().to_string_lossy()); + + assert_eq!(parsed.session_id, "claude-fixture-001"); + assert_eq!(parsed.prompts.len(), 1); + let prompt = &parsed.prompts[0]; + assert_eq!( + prompt.prompt_text, + "refactor extract_text to drop empty strings" + ); + assert!(prompt.response_text.contains("Filtering blank chunks now")); + + // The parser leaves `id` and `content_hash` empty — `save_draft` + // computes them. Pre-compute the expected hash so we can re-query + // and verify stability across the two saves. + let expected_hash = + prompt_content_hash_v2(&prompt.session_id, &prompt.prompt_text, &prompt.captured_at); + let mut record_for_save = PromptRecord { + content_hash: expected_hash.clone(), + id: prompt_id_v2(&prompt.session_id, &prompt.prompt_text, &prompt.captured_at), + ..prompt.clone() + }; + record_for_save.project_id = "p-claude-fixture".into(); + + store::save_draft(&record_for_save, &[], "", "").expect("first save"); + + let drafts = + store::get_drafts_by_status(store::DraftStatus::Draft, &[], &[]).expect("re-query drafts"); + assert_eq!(drafts.len(), 1, "exactly one draft after save"); + let saved = &drafts[0]; + assert_eq!(saved.content_hash, expected_hash); + assert_eq!(saved.session_id, "claude-fixture-001"); + assert_eq!(saved.source, "claude-code"); + assert!(!saved.id.is_empty(), "id populated"); + // file_context should carry at least the tool_results we saw in + // the fixture (one Edit tool_use → one tool_result). + let fc = saved.file_context.as_ref().expect("file_context populated"); + assert!( + fc.contains_key("tool_results"), + "tool_results missing from file_context: {fc:?}" + ); + + // Idempotent dedupe: a second save of the same record must not + // produce a duplicate row (content_hash UNIQUE) AND must leave the + // existing row's hash unchanged. + store::save_draft(&record_for_save, &[], "", "").expect("idempotent save"); + let drafts_after = store::get_drafts_by_status(store::DraftStatus::Draft, &[], &[]) + .expect("re-query drafts after second save"); + assert_eq!(drafts_after.len(), 1, "no duplicate after second save"); + assert_eq!( + drafts_after[0].content_hash, expected_hash, + "hash must remain stable" + ); +} diff --git a/crates/pcr-core/tests/fixtures/claude/minimal_session.jsonl b/crates/pcr-core/tests/fixtures/claude/minimal_session.jsonl new file mode 100644 index 0000000..2764706 --- /dev/null +++ b/crates/pcr-core/tests/fixtures/claude/minimal_session.jsonl @@ -0,0 +1,3 @@ +{"type":"user","timestamp":"2026-04-24T10:00:00Z","session_id":"claude-fixture-001","message":{"role":"user","content":"refactor extract_text to drop empty strings"}} +{"type":"assistant","timestamp":"2026-04-24T10:00:02Z","session_id":"claude-fixture-001","message":{"role":"assistant","model":"claude-sonnet-4-6","content":[{"type":"text","text":"Filtering blank chunks now — see the diff below."},{"type":"tool_use","id":"tc1","name":"Edit","input":{"file_path":"/tmp/parser.rs"}}],"usage":{"input_tokens":17,"output_tokens":9}}} +{"type":"user","timestamp":"2026-04-24T10:00:03Z","session_id":"claude-fixture-001","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"tc1","content":"ok","is_error":false}]}} diff --git a/crates/pcr-core/tests/fixtures/vscode/minimal_chatsession.jsonl b/crates/pcr-core/tests/fixtures/vscode/minimal_chatsession.jsonl new file mode 100644 index 0000000..e922a19 --- /dev/null +++ b/crates/pcr-core/tests/fixtures/vscode/minimal_chatsession.jsonl @@ -0,0 +1,2 @@ +{"kind":0,"v":{"sessionId":"vscode-fixture-001","requests":[]}} +{"kind":2,"k":["requests"],"v":[{"requestId":"r1","timestamp":1714060800000,"message":{"text":"summarize the failing test"},"response":[{"value":"It panics at line 218 because clock_override is None."}],"result":{"metadata":{"toolCallRounds":[{"toolCalls":[{"name":"read_file","id":"rc1","arguments":"{\"filePath\":\"/repo/src/main.rs\"}"}]}]}}}]} diff --git a/crates/pcr-core/tests/vscode_fixture_roundtrip.rs b/crates/pcr-core/tests/vscode_fixture_roundtrip.rs new file mode 100644 index 0000000..0b13680 --- /dev/null +++ b/crates/pcr-core/tests/vscode_fixture_roundtrip.rs @@ -0,0 +1,81 @@ +//! VS Code Copilot Chat chatSession → draft store fixture roundtrip. +//! +//! Exercises `parse_chatsession` → `exchange_to_prompt_record` → +//! `store::drafts::save_draft`. Mirrors the Claude Code fixture test +//! but on the new CRDT-style `chatSessions/` format. + +use std::path::PathBuf; + +use pcr_core::sources::vscode::chatsession_parser::parse_chatsession; +use pcr_core::sources::vscode::parser::exchange_to_prompt_record; +use pcr_core::store; +use pcr_core::supabase::{prompt_content_hash_v2, prompt_id_v2}; +use tempfile::TempDir; + +fn fixture_path() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("tests") + .join("fixtures") + .join("vscode") + .join("minimal_chatsession.jsonl") +} + +#[test] +fn vscode_fixture_parses_and_persists_through_store() { + let home = TempDir::new().expect("home tempdir"); + // SAFETY: this integration test binary contains exactly one #[test]; + // cargo runs each integration test binary in its own process; no + // other thread can observe the env mutation. + unsafe { + std::env::set_var("HOME", home.path()); + std::env::set_var("USERPROFILE", home.path()); + } + std::fs::create_dir_all(home.path().join(".pcr-dev")).expect("mkdir pcr-dev"); + + let body = std::fs::read_to_string(fixture_path()).expect("fixture readable"); + let transcript = parse_chatsession(&body); + + assert_eq!(transcript.session_id, "vscode-fixture-001"); + assert_eq!(transcript.exchanges.len(), 1); + let ex = &transcript.exchanges[0]; + assert_eq!(ex.prompt_text, "summarize the failing test"); + assert!(ex.response_text.contains("clock_override is None")); + assert_eq!(ex.relevant_files, vec!["/repo/src/main.rs"]); + + let mut record = exchange_to_prompt_record( + ex, + &transcript.session_id, + "fixture-proj", + "p-vscode-fixture", + "main", + ); + // The parser does not seed `id` / `content_hash`; mirror the + // production watcher path that fills them in before save. + let expected_hash = + prompt_content_hash_v2(&record.session_id, &record.prompt_text, &record.captured_at); + record.content_hash = expected_hash.clone(); + record.id = prompt_id_v2(&record.session_id, &record.prompt_text, &record.captured_at); + + store::save_draft(&record, &[], "", "").expect("first save"); + + let drafts = + store::get_drafts_by_status(store::DraftStatus::Draft, &[], &[]).expect("re-query drafts"); + assert_eq!(drafts.len(), 1); + let saved = &drafts[0]; + assert_eq!(saved.content_hash, expected_hash); + assert_eq!(saved.session_id, "vscode-fixture-001"); + assert_eq!(saved.source, "vscode"); + assert_eq!(saved.project_id, "p-vscode-fixture"); + assert_eq!(saved.branch_name, "main"); + let fc = saved.file_context.as_ref().expect("file_context populated"); + assert!( + fc.contains_key("relevant_files"), + "relevant_files missing: {fc:?}" + ); + + store::save_draft(&record, &[], "", "").expect("idempotent save"); + let drafts_after = store::get_drafts_by_status(store::DraftStatus::Draft, &[], &[]) + .expect("re-query drafts after second save"); + assert_eq!(drafts_after.len(), 1); + assert_eq!(drafts_after[0].content_hash, expected_hash); +} diff --git a/crates/pcr-napi/npm/darwin-arm64/package.json b/crates/pcr-napi/npm/darwin-arm64/package.json index 895d0a7..dd72356 100644 --- a/crates/pcr-napi/npm/darwin-arm64/package.json +++ b/crates/pcr-napi/npm/darwin-arm64/package.json @@ -1,6 +1,6 @@ { "name": "pcr-dev-darwin-arm64", - "version": "0.2.8", + "version": "0.2.9", "description": "PCR.dev CLI prebuilt binary for macOS arm64", "license": "Apache-2.0", "os": [ diff --git a/crates/pcr-napi/npm/darwin-x64/package.json b/crates/pcr-napi/npm/darwin-x64/package.json index 4ad5fae..a762065 100644 --- a/crates/pcr-napi/npm/darwin-x64/package.json +++ b/crates/pcr-napi/npm/darwin-x64/package.json @@ -1,6 +1,6 @@ { "name": "pcr-dev-darwin-x64", - "version": "0.2.8", + "version": "0.2.9", "description": "PCR.dev CLI prebuilt binary for macOS x64", "license": "Apache-2.0", "os": [ diff --git a/crates/pcr-napi/npm/linux-x64-gnu/package.json b/crates/pcr-napi/npm/linux-x64-gnu/package.json index 18c1f41..ff1605b 100644 --- a/crates/pcr-napi/npm/linux-x64-gnu/package.json +++ b/crates/pcr-napi/npm/linux-x64-gnu/package.json @@ -1,6 +1,6 @@ { "name": "pcr-dev-linux-x64-gnu", - "version": "0.2.8", + "version": "0.2.9", "description": "PCR.dev CLI prebuilt binary for Linux x64 (glibc)", "license": "Apache-2.0", "os": [ diff --git a/crates/pcr-napi/npm/win32-x64-msvc/package.json b/crates/pcr-napi/npm/win32-x64-msvc/package.json index 85b961d..997c920 100644 --- a/crates/pcr-napi/npm/win32-x64-msvc/package.json +++ b/crates/pcr-napi/npm/win32-x64-msvc/package.json @@ -1,6 +1,6 @@ { "name": "pcr-dev-windows-x64", - "version": "0.2.8", + "version": "0.2.9", "description": "PCR.dev CLI prebuilt binary for Windows x64", "license": "Apache-2.0", "os": [ diff --git a/crates/pcr-napi/package.json b/crates/pcr-napi/package.json index 5c7cda0..7feb76e 100644 --- a/crates/pcr-napi/package.json +++ b/crates/pcr-napi/package.json @@ -1,6 +1,6 @@ { "name": "pcr-dev", - "version": "0.2.8", + "version": "0.2.9", "description": "PCR.dev CLI \u2014 capture AI coding prompts for peer review", "license": "Apache-2.0", "author": "PCR.dev", @@ -49,10 +49,10 @@ } }, "optionalDependencies": { - "pcr-dev-darwin-x64": "0.2.8", - "pcr-dev-darwin-arm64": "0.2.8", - "pcr-dev-linux-x64-gnu": "0.2.8", - "pcr-dev-windows-x64": "0.2.8" + "pcr-dev-darwin-x64": "0.2.9", + "pcr-dev-darwin-arm64": "0.2.9", + "pcr-dev-linux-x64-gnu": "0.2.9", + "pcr-dev-windows-x64": "0.2.9" }, "devDependencies": { "@napi-rs/cli": "^2.18.4"